From 8ae5c332c3d17dc868d67981853ec55fb4846cf8 Mon Sep 17 00:00:00 2001 From: rays Date: Tue, 15 Sep 2026 17:14:59 +0000 Subject: [PATCH] Pin, not flag Keeping an item is pinning it now: a thumbtack where the flag was, and Pin, Pinned and Unpin where Keep, Kept and Stop keeping were, on the toolbar, the item's own buttons, the filter tab, the table column, the retention hint and the warning before deleting a shared file. Pinned is the solid thumbtack and not pinned the same shape outlined, as the flag had its regular and solid pair. The API and database keep `flagged`. The icon test compared glyphs by their path alone, which the two pins share; it compares the whole glyph now. Closes #9. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 +++- README.md | 4 ++-- docs/users.md | 6 +++--- src/web.rs | 4 ++-- tests/ui/app.spec.js | 7 ++++--- web/index.html | 27 +++++++++++++++------------ 6 files changed, 29 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6b07c..4b3e366 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ The long form, with what was wrong before and how it was found, is in ### Added - Keyboard shortcuts after Feedly's: j and k through items, Shift-J and Shift-K through feeds, - g and a letter to go to a place, o to play, s to keep, and more. Press ? for the whole list. + g and a letter to go to a place, o to play, s to pin, and more. Press ? for the whole list. - Directory can be filtered to Podcasts or Blogs, and by each show's own iTunes category as a row of chips, the narrower one where a show gives two (Games, not Leisure). The two combine, and both filter in place. @@ -22,6 +22,8 @@ The long form, with what was wrong before and how it was found, is in - Directory shows each feed as its cover art in a grid, title and subscriber count underneath, instead of a list. Popular and the Add a feed dialog keep their rows. +- Keeping an item is now pinning it: a thumbtack in place of the flag, and Pin, Pinned and Unpin + in place of Keep, Kept and Stop keeping. A pinned item is still never deleted. - Currently Listening is its own place in the feed list, below Popular, instead of a section at the bottom of the Popular page. - The first scan after upgrading fetches every feed in full once, on its usual schedule, so each diff --git a/README.md b/README.md index 33f6260..b143bfd 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ behind iPodderX (2004-2008, Ray Slakinski & August Trometer). - **The web UI.** It has a toolbar, and a feed list that opens with Directory, Popular and All Subscriptions. Items sit in a sortable table with a Files pane, and there is a player bar. It comes in Dark, Light and Classic themes, and works on a phone. -- **Several people, one copy.** Each person has their own subscriptions and their own read, kept +- **Several people, one copy.** Each person has their own subscriptions and their own read, pinned and playback state. There is one file on disk per episode, however many people want it. People sign in with a password or through a proxy (Cloudflare Zero Trust or Authentik), and admins manage accounts and settings. @@ -21,7 +21,7 @@ behind iPodderX (2004-2008, Ray Slakinski & August Trometer). on new downloads per scan. - **Downloads.** Files come over HTTP or BitTorrent and are filed into a folder per feed. Retention deletes the oldest files to stay under a disk quota or an age limit, and never touches - an item someone has kept. + an item someone has pinned. - **OPML.** You can import and export your own subscriptions. You can also subscribe to an OPML URL, which keeps a whole list in step as a folder. diff --git a/docs/users.md b/docs/users.md index a126b7a..a151088 100644 --- a/docs/users.md +++ b/docs/users.md @@ -8,7 +8,7 @@ fetch, one parse and one file. | Yours alone | The same for everyone | |---|---| -| Read, kept, playback position | The feed's URL | +| Read, pinned, playback position | The feed's URL | | Which feeds you see at all | Its download folder | | Keywords, auto-download, explicit, per-scan cap | When it is scanned | | | The file on disk | @@ -34,10 +34,10 @@ is shared. Deleting a file deletes everyone's copy. A feed with other subscribers labels the button **Delete for everyone** and names them in the confirmation, and the server has the last word: if anyone else -has kept the item or not played it yet, `DELETE /api/enclosures/{id}` answers `409` with the +has pinned the item or not played it yet, `DELETE /api/enclosures/{id}` answers `409` with the reason, and only `?force=true` goes through. -Retention follows the same rule: an item anyone kept keeps its file, and it counts as read only once +Retention follows the same rule: an item anyone pinned keeps its file, and it counts as read only once every subscriber has read it. ## Signing in diff --git a/src/web.rs b/src/web.rs index 1a1ac09..3bb251f 100644 --- a/src/web.rs +++ b/src/web.rs @@ -1259,9 +1259,9 @@ async fn delete_file( let complaint = match (starred, unread) { (0, 0) => None, (0, u) => Some(format!("{} subscribed to this feed {} not played it yet", people(u), if u == 1 { "has" } else { "have" })), - (st, 0) => Some(format!("another {} kept it", people(st))), + (st, 0) => Some(format!("another {} pinned it", people(st))), (st, u) => Some(format!( - "another {} kept it, and {} not played it yet", + "another {} pinned it, and {} not played it yet", people(st), if u == 1 { "one person has".to_string() } else { format!("{u} have") } )), diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index c2c43f4..1576936 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -197,7 +197,7 @@ test('the filter tabs change what is listed', async ({ page }) => { await page.locator('.tabs button', { hasText: 'Unread' }).first().click(); expect(await page.locator('.ep').count()).toBeLessThanOrEqual(all); - await page.locator('.tabs button', { hasText: 'Kept' }).first().click(); + await page.locator('.tabs button', { hasText: 'Pinned' }).first().click(); await expect(page.locator('#count')).toContainText('0 items'); }); @@ -787,13 +787,14 @@ test('a deleted file looks as if it was never downloaded', async ({ page }) => { }); test('one action, one icon: the toolbar, the page and every dialog agree', async ({ page }) => { - const icon = loc => loc.locator('svg path').first().getAttribute('d'); + // The whole glyph, not just its path: pinned and not pinned share one outline and differ in fill. + const icon = loc => loc.locator('svg').first().innerHTML(); await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click(); // Unsubscribe is a minus in the toolbar and the feed header, never the x that closes things. expect(await icon(page.locator('#content .acts [data-a="rm"]'))).toBe(await icon(page.locator('#tbRemove'))); - // The toolbar's read and keep show the selected item's state, as its own buttons do, and follow + // The toolbar's read and pin show the selected item's state, as its own buttons do, and follow // a change made from the toolbar. await page.locator('.ep').first().click(); const pair = async a => [await icon(page.locator(a === 'read' ? '#tbRead' : '#tbFlag')), diff --git a/web/index.html b/web/index.html index 044c4ec..bb50001 100644 --- a/web/index.html +++ b/web/index.html @@ -626,7 +626,7 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
- +
@@ -705,8 +705,11 @@ const ICON={ play:fa('0 0 448 512',''), // solid/play check:fa('0 0 448 512',''), // solid/check checks:fa('0 0 384 512',''), // solid/check-double - flag:fa('0 0 448 512',''), // regular/flag - flagOn:fa('0 0 448 512',''), // solid/flag + // Pinned is the solid thumbtack; not pinned, the same shape outlined, as the flag had its regular + // and solid pair (Font Awesome's free set has no regular thumbtack). Both share a viewBox padded + // for the outline's stroke, so the two draw the same size. + pin:fa('-18 -18 420 548',''), // solid/thumbtack, outlined + pinOn:fa('-18 -18 420 548',''), // solid/thumbtack scan:fa('0 0 512 512',''), // solid/arrows-rotate download:fa('0 0 448 512',''), // solid/download save:fa('0 0 448 512',''), // solid/floppy-disk @@ -1011,7 +1014,7 @@ function renderFeed(){ `) + `
- ${[['all','All'],['unread','Unread'],['downloaded','Downloaded'],['flagged','Kept']].map(([t,label])=> + ${[['all','All'],['unread','Unread'],['downloaded','Downloaded'],['flagged','Pinned']].map(([t,label])=> ``).join('')}
`; @@ -1038,7 +1041,7 @@ function renderFeed(){ /// The item table's headings, each a button that sorts by its column. The first click goes the /// natural way round (A to Z; newest, largest and kept first) and the next one reverses it. -const COLS=[['kept','Kept',ICON.flag],['title','Title'],['feed','Feed'],['type','File'],['size','Size'],['published','Published']]; +const COLS=[['kept','Pinned',ICON.pin],['title','Title'],['feed','Feed'],['type','File'],['size','Size'],['published','Published']]; function sortHead(){ return '
'+COLS.map(([k,label,icon])=>{ const on=S.sort.col===k; @@ -1194,7 +1197,7 @@ function epEl(e){ + e.flagged?'Unpin':'Pin, so it is never deleted'}">${e.flagged?ICON.pinOn:ICON.pin}
${esc(e.title||'(untitled)')}
${[ @@ -1319,10 +1322,10 @@ function syncTools(e){ $('#tbRead').disabled=$('#tbFlag').disabled=!e; // The same icons as the item's own buttons beside its title, so the two never disagree. $('#tbRead').innerHTML=e&&e.read?ICON.unread:ICON.check; - $('#tbFlag').innerHTML=e&&e.flagged?ICON.flagOn:ICON.flag; + $('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin; if(e){ $('#tbRead').title=`Mark ${e.read?'unread':'read'}`; - $('#tbFlag').title=e.flagged?'Stop keeping':'Keep, so it is never deleted'; + $('#tbFlag').title=e.flagged?'Unpin':'Pin, so it is never deleted'; } } @@ -1352,8 +1355,8 @@ function showDetail(e){ .filter(Boolean).map(s=>`${s}`).join('')} - + ${e.link?`${ICON.open}`:''}
@@ -1601,7 +1604,7 @@ function keysModal(){ [k('j')+' or '+k('n'),'Next item'],[k('k')+' or '+k('p'),'Previous item'], [k('Shift')+' '+k('A'),'Mark all read'], ['The selected item'], - [k('o'),'Play it'],[k('m'),'Mark it read or unread'],[k('s'),'Keep it, or stop keeping it'], + [k('o'),'Play it'],[k('m'),'Mark it read or unread'],[k('s'),'Pin it, or unpin it'], [k('v'),'Open the original in a new tab'], ['The player'], [k('Space'),'Play or pause'],[k('←')+' '+k('→'),'Back 15 seconds, forward 30'], @@ -1940,7 +1943,7 @@ async function prefsModal(){ put article images in enclosures, and those are not worth keeping. Empty takes everything.
- Over this, the oldest played items are deleted first. Kept + Over this, the oldest played items are deleted first. Pinned items are never touched.
`:''}