diff --git a/CHANGELOG.md b/CHANGELOG.md index bd6d0d5..0eebde8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ The long form, with what was wrong before and how it was found, is in - Your theme is kept on your account rather than in the browser, so it follows you to another browser or computer, and the page arrives in it with no flash of the default. The theme a browser already had is saved to your account the first time you load the page. +- Pin a feed to the top of the feed list with the pin on its page, a feed from inside an OPML or + Patreon folder included, which comes out of the folder while pinned. Pins are yours alone. - Touch gestures: pull the item list down from its top to check the feed for new items, and swipe the item you are reading left for the next one and right for the one before, or back to the list from the first. diff --git a/src/db.rs b/src/db.rs index 74365d6..ac34db7 100644 --- a/src/db.rs +++ b/src/db.rs @@ -96,6 +96,8 @@ CREATE TABLE IF NOT EXISTS subscriptions ( auto_download INTEGER, allow_explicit INTEGER, max_new_per_check INTEGER, + -- Pinned to the top of this person's feed list, a feed inside a folder included. + pinned INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (user_id, feed_id) ); @@ -182,6 +184,7 @@ fn migrate(conn: &Connection) -> Result<()> { // Kept per account so a theme follows you to another browser; it was in localStorage. ("users", "theme", "TEXT"), ("users", "theme_mode", "TEXT"), + ("subscriptions", "pinned", "INTEGER NOT NULL DEFAULT 0"), ]; let retired: &[(&str, &str)] = &[ // Read state from before accounts, long since moved to entry_state. Two bugs came from @@ -1060,6 +1063,24 @@ impl Db { Ok(()) } + /// The feeds this person pinned to the top of their list. Kept apart from `Sub`, which is + /// what the scanner merges into its policy, and which a pin has nothing to do with. + pub fn pinned_feeds(&self, user_id: i64) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("SELECT feed_id FROM subscriptions WHERE user_id = ?1 AND pinned")?; + let ids = stmt.query_map([user_id], |r| r.get(0))?.collect::>()?; + Ok(ids) + } + + /// False when they do not subscribe to it, since there is then no row in their list to pin. + pub fn set_pinned(&self, user_id: i64, feed_id: &str, on: bool) -> Result { + let conn = self.conn.lock().unwrap(); + Ok(conn.execute( + "UPDATE subscriptions SET pinned = ?3 WHERE user_id = ?1 AND feed_id = ?2", + params![user_id, feed_id, on as i64], + )? > 0) + } + pub fn unsubscribe(&self, user_id: i64, feed_id: &str) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( @@ -2029,6 +2050,21 @@ mod tests { assert_eq!(after.error_since, None, "a clean check ends the run of failures"); } + #[test] + fn a_pin_survives_saving_the_feeds_settings_and_needs_a_subscription() { + let db = Db::memory().unwrap(); + let me = db.create_user("pat", None, false).unwrap(); + assert!(!db.set_pinned(me, "f", true).unwrap(), "not subscribed: nothing to pin"); + db.subscribe(me, "f").unwrap(); + assert!(db.set_pinned(me, "f", true).unwrap()); + // set_subscription writes the rest of the row; it must leave the pin alone. + db.set_subscription(me, &Sub { feed_id: "f".into(), auto_download: Some(false), ..Default::default() }) + .unwrap(); + assert!(db.pinned_feeds(me).unwrap().contains("f")); + db.set_pinned(me, "f", false).unwrap(); + assert!(db.pinned_feeds(me).unwrap().is_empty()); + } + #[test] fn enclosure_url_is_the_dedupe_key() { let db = Db::memory().unwrap(); diff --git a/src/web.rs b/src/web.rs index f4e6f58..b072afc 100644 --- a/src/web.rs +++ b/src/web.rs @@ -614,6 +614,8 @@ struct FeedRow { unread: i64, /// Including you. More than one means every file here is shared. subscribers: i64, + /// Pinned to the top of your list. + pinned: bool, } #[derive(Serialize)] @@ -641,6 +643,7 @@ async fn feeds( .map(|s| (s.feed_id.clone(), s)) .collect(); let counts = state.ctx.db.subscriber_counts()?; + let pinned = state.ctx.db.pinned_feeds(user.id)?; let mut out = Vec::with_capacity(mine.len()); for sub in &subs { let (id, feed) = (&sub.id, &sub.cfg); @@ -701,6 +704,7 @@ async fn feeds( downloaded: s.downloaded, unread: state.ctx.db.unread_count(user.id, id)?, subscribers: counts.get(id).copied().unwrap_or(0), + pinned: pinned.contains(id), }); } Ok(Json(out)) @@ -884,6 +888,13 @@ impl ApiError { status: StatusCode::FORBIDDEN, } } + + fn not_found(msg: impl Into) -> Self { + Self { + error: anyhow::Error::msg(msg.into()), + status: StatusCode::NOT_FOUND, + } + } } impl IntoResponse for ApiError { @@ -1218,6 +1229,7 @@ struct FeedPatch { auto_download: Option, #[serde(default, deserialize_with = "double_option")] max_new_per_check: Option>, + pinned: Option, } fn double_option<'de, T, D>(de: D) -> Result>, D::Error> @@ -1234,6 +1246,12 @@ async fn patch_feed( user: crate::db::User, Json(body): Json, ) -> Result { + // Pinning is yours alone too, and means nothing for a feed you do not subscribe to. + if let Some(on) = body.pinned + && !state.ctx.db.set_pinned(user.id, &id, on)? + { + return Err(ApiError::not_found("you do not subscribe to that feed")); + } // What one person wants -- which items, whether to fetch them, how many at a time -- // is theirs. It goes on their subscription and nobody else sees the change. if state.ctx.db.subscription(user.id, &id)?.is_some() { diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index a514682..16592b6 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -1263,3 +1263,30 @@ test.describe('an item with no files, on a phone', () => { await expect(page.locator('#detail')).not.toContainText('No files'); }); }); + +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'); + const group = page.locator('.feed.group').first(); + if ((await group.locator('.chev').getAttribute('aria-expanded')) !== 'true') await group.locator('.chev').click(); + const child = page.locator('.feed.child').first(); + const name = (await child.locator('.txt b').textContent()).trim(); + await child.click(); + await page.locator('#content .acts [data-a="pin"]').click(); + + // First in the list, out of its folder, marked, and with the rule under it. + await expect(rows.first().locator('.txt b')).toHaveText(name); + await expect(rows.first()).toHaveClass(/\bpinned\b/); + await expect(rows.first()).not.toHaveClass(/\bchild\b/); + await expect(rows.first()).toHaveClass(/\blastpin\b/); + await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(0); + await expect(page.locator('#content .acts [data-a="pin"]')).toHaveAttribute('aria-pressed', 'true'); + // It is on the account: a reload keeps it. + await page.reload(); + await expect(rows.first().locator('.txt b')).toHaveText(name); + + // Unpinned, it goes back into its folder. + await rows.first().click(); + await page.locator('#content .acts [data-a="pin"]').click(); + await expect(page.locator('.feed.pinned')).toHaveCount(0); + await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(1); +}); diff --git a/web/index.html b/web/index.html index 28e670b..da053d2 100644 --- a/web/index.html +++ b/web/index.html @@ -363,6 +363,10 @@ input:focus,select:focus{outline:0;border-color:var(--accent)} .feed.sel{background:var(--raise)} /* A show sits under its folder's title, a size down, so an open folder reads as one. */ .feed.child{margin-left:11px} +/* Pinned feeds, at the top of the list: a small pin before the name, and a rule under the last. */ +.feed .fpin .i{width:10px;height:10px;margin-right:5px;vertical-align:-1px;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} /* 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 diff --git a/web/src/feedpage.ts b/web/src/feedpage.ts index de44493..21fd4bd 100644 --- a/web/src/feedpage.ts +++ b/web/src/feedpage.ts @@ -30,6 +30,7 @@ function renderFeed(){ + @@ -118,6 +119,7 @@ function renderGroup(f,kids){
+
@@ -157,6 +159,12 @@ async function feedAction(a,f){ if(a==='scan'){ toast('Scanning '+(f.title||f.id)+'…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); } if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); } if(a==='rm') removeFeed(f); + if(a==='pin'){ + try{ + await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({pinned:!f.pinned})}); + await loadFeeds(true); renderFeed(); + }catch(e){ toast(e.message,true); } + } if(a==='settings') settingsModal(f); if(a==='dl') downloadLatestModal(f); } diff --git a/web/src/feeds.ts b/web/src/feeds.ts index 2b8c4a2..6cf9f19 100644 --- a/web/src/feeds.ts +++ b/web/src/feeds.ts @@ -53,19 +53,25 @@ function renderFeeds(){ const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q)); if(!shown.length){ list.insertAdjacentHTML('beforeend','

No feeds.

'); return done(); } - // Feeds from a subscribed OPML sit under it, so the group reads as one thing. + // 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. const byId=Object.fromEntries(shown.map(f=>[f.id,f])); + const inside=f=>shown.filter(c=>c.group===f.id&&!c.pinned); + const tops=shown.filter(f=>f.pinned||!(f.group&&byId[f.group])); const order=[]; - for(const f of shown){ - if(f.group && byId[f.group]) continue; // drawn under its parent instead + for(const f of [...tops.filter(f=>f.pinned),...tops.filter(f=>!f.pinned)]){ order.push([f,0]); // A subscription can hold dozens of feeds, so a folder starts closed. Searching // opens them all, or matches inside a closed folder would be invisible. if(expanded.has(f.id) || q) - for(const c of shown.filter(c=>c.group===f.id).sort(unreadFirst)) order.push([c,1]); + for(const c of inside(f).sort(unreadFirst)) order.push([c,1]); } + // The rule under the pinned block goes under its last row: a pinned folder's last feed when + // 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]; for(const [f,depth] of order){ - const kids=shown.filter(c=>c.group===f.id).length; + 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. const mine=S.feeds.filter(c=>c.group===f.id); @@ -79,7 +85,8 @@ function renderFeeds(){ const bad=c=>c.failing?.reason||c.last_error; const err=mine.length ? mine.map(bad).find(Boolean) : bad(f); const el=document.createElement('div'); - el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':''); + el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'')+ + (f.pinned&&!depth?' pinned':'')+(f===lastPin&&lastTop>=0?' lastpin':''); el.tabIndex=0; el.dataset.id=f.id; const open = !!(kids && (expanded.has(f.id) || q)); el.innerHTML = @@ -88,7 +95,7 @@ function renderFeeds(){ (kids?`` :err?`${ICON.alert}`:'')+ (mine.length?folderArt(f,mine):artHTML(f.image,f.title||f.id))+ - `
${esc(f.title||f.id)}`+ + `
${f.pinned?`${ICON.pinOn}`:''}${esc(f.title||f.id)}`+ `${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`+ `
`+ (f.orphaned?'Gone':'')+