From b89c371662cb1828cc9a4136f12819d746891ea3 Mon Sep 17 00:00:00 2001 From: rays Date: Mon, 19 Oct 2026 14:19:54 +0000 Subject: [PATCH] Keep a feed, an OPML or a Patreon creator out of All Subscriptions (#288) A switch in the feed's settings, In All Subscriptions, on its subscription as hide_from_all. All Subscriptions' list and count leave out a feed hidden on its own or through the OPML it is in, and the page's unread count does the same. Signals read every feed as before, and Currently Listening, which is All Subscriptions' in-progress filter, keeps an episode under way whatever the feed's setting. Co-Authored-By: Claude Opus 5.5 --- crates/ipx/CHANGELOG.md | 1 + crates/ipx/src/db.rs | 67 ++++++++++++++++++++++++++++++++++++++++ crates/ipx/src/entity.rs | 4 +++ crates/ipx/src/web.rs | 12 +++++++ tests/page-unit.js | 12 +++++++ web/CHANGELOG.md | 1 + web/src/dialogs.ts | 8 +++++ web/src/feedpage.ts | 2 +- web/src/feeds.ts | 7 ++++- web/src/types.ts | 2 ++ 10 files changed, 114 insertions(+), 2 deletions(-) diff --git a/crates/ipx/CHANGELOG.md b/crates/ipx/CHANGELOG.md index c2f0f57..c503996 100644 --- a/crates/ipx/CHANGELOG.md +++ b/crates/ipx/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `/metrics` counts Signals: items judged and fitting, calls to Jev by how they ended, the time they took and the tokens TypeSafe counted, time since the judge's last pass, and keys refused; a failed call to Jev shows as a failed trace (#270, #275). - An admin setting for how many days back a new Signal reads (#276). - A list's trace says which filter, sort and page it was asked for, and whether a search, so a slow list can be told from the others (#287). +- A feed, an OPML subscription or a Patreon creator can be left out of All Subscriptions: `hide_from_all` on `PATCH /api/feeds/{id}` and in the feed list. Set on an OPML or creator it covers every show in it; Signals and Currently Listening still read them (#288). - `/metrics` breaks Signals' items, fits and TypeSafe tokens out by person and by whose key paid, so the server's bill can be split (#278). - An admin setting, Signals for everyone on the server's TypeSafe key, so the server pays for Jev instead of each person (#271). diff --git a/crates/ipx/src/db.rs b/crates/ipx/src/db.rs index 86e3ca1..e6b86f8 100644 --- a/crates/ipx/src/db.rs +++ b/crates/ipx/src/db.rs @@ -122,6 +122,10 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> { let sql = "ALTER TABLE subscriptions ADD COLUMN kept BOOLEAN NOT NULL DEFAULT false"; orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?; } + if orm.execute_unprepared("SELECT hide_from_all FROM subscriptions LIMIT 0").await.is_err() { + let sql = "ALTER TABLE subscriptions ADD COLUMN hide_from_all BOOLEAN NOT NULL DEFAULT false"; + orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?; + } if orm.execute_unprepared("SELECT description FROM signals LIMIT 0").await.is_err() { // Every verdict so far answered the old question, any one keyword on its own, which put // a game's remaster in AI Development (#259): all of them are asked again. @@ -1186,6 +1190,15 @@ fn entries_from( Some([f]) => format!("e.feed_id = {}", a.p(*f)), Some([]) => "false".into(), Some(fs) => format!("e.feed_id IN ({})", fs.iter().map(|f| a.p(*f)).collect::>().join(", ")), + // All Subscriptions leaves out what this person hid from it, on the feed or on its OPML + // (#288). A Signal reads every feed, and an episode under way still shows in Currently + // Listening, which is this list's in-progress filter. + None if signal.is_none() && filter != Filter::InProgress => format!( + "e.feed_id IN (SELECT m.feed_id FROM subscriptions m JOIN feeds f ON f.id = m.feed_id + WHERE m.user_id = {user} AND NOT m.hide_from_all + AND NOT EXISTS (SELECT 1 FROM subscriptions g + WHERE g.user_id = {user} AND g.feed_id = f.group_id AND g.hide_from_all))" + ), None => format!("e.feed_id IN (SELECT feed_id FROM subscriptions WHERE user_id = {user})"), }; if let Some(id) = signal { @@ -1867,6 +1880,29 @@ impl Db { Ok(r.rows_affected > 0) } + /// The feeds and lists this person hid from All Subscriptions (#288). + pub async fn hidden_from_all(&self, user_id: i64) -> Result> { + Ok(subscriptions::Entity::find() + .filter(subscriptions::Column::UserId.eq(user_id)) + .filter(subscriptions::Column::HideFromAll.eq(true)) + .all(&self.orm) + .await? + .into_iter() + .map(|s| s.feed_id) + .collect()) + } + + /// False when they do not subscribe to it, as `set_pinned`. + pub async fn set_hide_from_all(&self, user_id: i64, feed_id: &str, on: bool) -> Result { + let r = subscriptions::Entity::update_many() + .col_expr(subscriptions::Column::HideFromAll, Expr::val(on)) + .filter(subscriptions::Column::UserId.eq(user_id)) + .filter(subscriptions::Column::FeedId.eq(feed_id)) + .exec(&self.orm) + .await?; + Ok(r.rows_affected > 0) + } + /// Who subscribes to a feed, and whether each keeps it (#280). pub async fn subscribers_of(&self, feed_id: &str) -> Result> { Ok(subscriptions::Entity::find() @@ -3731,6 +3767,37 @@ mod tests { assert_eq!(db.count_of(1, Some(&[]), Filter::All, None).await.unwrap(), 0); } + #[tokio::test] + async fn all_subscriptions_leaves_out_what_was_hidden_from_it() { + let db = Db::memory().await.unwrap(); + db.exec_for_test( + "INSERT INTO users (id, name, is_admin) VALUES (1,'a',true), (2,'b',false); + INSERT INTO feeds (id, url, group_id, managed) VALUES ('a','u1','o',true), ('b','u2','o',true), ('c','u3',NULL,false), ('d','u4',NULL,false); + INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'o'), (1,'a'), (1,'b'), (1,'c'), (1,'d'), (2,'a'), (2,'o'); + INSERT INTO entries (feed_id, guid, title, first_seen) VALUES + ('a','1','A one',1), ('b','2','B two',2), ('c','3','C three',3), ('d','4','D four',4); + INSERT INTO entry_state (user_id, feed_id, guid, position) VALUES (1,'a','1',60);", + ) + .await + .unwrap(); + let order = order_sql("published", "desc"); + let all = async |user, filter| { + let rows = db.entries_of(user, None, filter, None, 0, 50, &order).await.unwrap(); + let n = db.count_of(user, None, filter, None).await.unwrap(); + assert_eq!(rows.len() as i64, n, "the count is of the same list"); + rows.into_iter().map(|r| r.title.unwrap()).collect::>() + }; + assert!(db.set_hide_from_all(1, "o", true).await.unwrap()); + assert!(db.set_hide_from_all(1, "c", true).await.unwrap()); + assert!(!db.set_hide_from_all(1, "x", true).await.unwrap(), "not subscribed"); + assert_eq!(all(1, Filter::All).await, ["D four"], "the OPML's shows and the hidden feed left out"); + assert_eq!(all(1, Filter::InProgress).await, ["A one"], "an episode under way still shows"); + assert_eq!(all(2, Filter::All).await, ["A one"], "someone else's All Subscriptions is their own"); + assert_eq!(db.count_of(1, Some(&["c"]), Filter::All, None).await.unwrap(), 1, "its own page still lists it"); + db.set_hide_from_all(1, "o", false).await.unwrap(); + assert_eq!(all(1, Filter::All).await, ["D four", "B two", "A one"]); + } + #[tokio::test] async fn pending_takes_the_latest_episodes_first() { // A cap of 3 must mean the three newest, not the three recorded first. diff --git a/crates/ipx/src/entity.rs b/crates/ipx/src/entity.rs index bc9b6c5..d2e8e67 100644 --- a/crates/ipx/src/entity.rs +++ b/crates/ipx/src/entity.rs @@ -223,6 +223,10 @@ pub mod subscriptions { /// they leave the list: it becomes a feed of its own instead of going (#280). #[sea_orm(default_value = false)] pub kept: bool, + /// Left out of All Subscriptions: this feed, or on an OPML or Patreon creator every show + /// in it (#288). + #[sea_orm(default_value = false)] + pub hide_from_all: bool, } owned_by_user!(); diff --git a/crates/ipx/src/web.rs b/crates/ipx/src/web.rs index f743d9a..3d8d390 100644 --- a/crates/ipx/src/web.rs +++ b/crates/ipx/src/web.rs @@ -836,6 +836,9 @@ struct FeedRow { pinned: bool, /// From an OPML, and yours to keep should it drop the feed (#280). kept: bool, + /// Left out of All Subscriptions, by this row's own setting; a show also is when its OPML + /// is (#288). + hide_from_all: bool, /// Marked private: kept out of the directory (#205). private: bool, /// Private whatever it is marked, for a login or an address with a key in it. @@ -874,6 +877,7 @@ async fn feed_rows(state: &WebState, user: &crate::db::User, only: Option<&str>) let counts = state.ctx.db.subscriber_counts().instrument(step("subscriber_counts")).await?; let pinned = state.ctx.db.pinned_feeds(user.id).instrument(step("pinned")).await?; let kept = state.ctx.db.kept_feeds(user.id).instrument(step("kept")).await?; + let hidden = state.ctx.db.hidden_from_all(user.id).instrument(step("hidden_from_all")).await?; let prefs = state.ctx.db.deadair_prefs(Some(user.id)).await?; let deadair: std::collections::HashMap<&str, bool> = prefs.iter().map(|(_, f, w, _)| (f.as_str(), *w)).collect(); let pauses: std::collections::HashMap<&str, i64> = @@ -948,6 +952,7 @@ async fn feed_rows(state: &WebState, user: &crate::db::User, only: Option<&str>) subscribers: counts.get(id).copied().unwrap_or(0), pinned: pinned.contains(id), kept: kept.contains(id), + hide_from_all: hidden.contains(id), }); } Ok(out) @@ -1663,6 +1668,8 @@ struct FeedPatch { pinned: Option, /// Kept should its OPML drop it (#280). kept: Option, + /// Left out of All Subscriptions, or on an OPML every show in it (#288). + hide_from_all: Option, /// True plays the feed shortened by DeadAir, false keeps it out (of its group's too), null /// follows its group again (#171). #[serde(default, deserialize_with = "double_option")] @@ -1714,6 +1721,11 @@ async fn patch_feed( { return Err(ApiError::not_found("you do not subscribe to that feed")); } + if let Some(on) = body.hide_from_all + && !state.ctx.db.set_hide_from_all(user.id, &id, on).await? + { + return Err(ApiError::not_found("you do not subscribe to that feed")); + } if let Some(p) = body.deadair_pause && !crate::deadair::PAUSES.contains(&p) { diff --git a/tests/page-unit.js b/tests/page-unit.js index 25bf1f7..4bdf521 100644 --- a/tests/page-unit.js +++ b/tests/page-unit.js @@ -144,6 +144,18 @@ test('a Patreon feed is told by its address', () => { assert.equal(run('isPatreon(null)'), false); }); +test("All Subscriptions' unread count leaves out feeds hidden from it, and an OPML's shows with it", () => { + run(`S.feeds = [ + {id: 'o', group: null, unread: 0, hide_from_all: true}, + {id: 'a', group: 'o', unread: 2, hide_from_all: false}, + {id: 'c', group: null, unread: 3, hide_from_all: true}, + {id: 'd', group: null, unread: 5, hide_from_all: false}]`); + assert.equal(run('unreadInAll()'), 5); + run(`S.feeds[0].hide_from_all = false`); + assert.equal(run('unreadInAll()'), 7); + run(`S.feeds = []`); +}); + test('a Signal is a place of its own, found by its id', () => { run(`S.signals = [{id: 7, name: 'F1', keywords: ['f1'], unread: 2}]`); assert.equal(run(`signalId(':signal:7')`), 7); diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index 857fec0..9da6c08 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- A feed's, an OPML subscription's or a Patreon creator's settings have In All Subscriptions: off keeps its items out of All Subscriptions and its unread count, still there on its own page, in Signals and in Currently Listening (#288). - An item sent as both HTML and Markdown, as Scripting News's are, has a choice beside its buttons to show it as the publisher's HTML or as the Markdown, kept from one item to the next (#285). - 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). diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts index 473f90d..8ac4007 100644 --- a/web/src/dialogs.ts +++ b/web/src/dialogs.ts @@ -578,6 +578,8 @@ function settingsModal(f: Feed, newUrl?: string){ const kids=S.feeds.filter(c=>c.group===f.id).length; const isGroup=kids>0; const parent=f.managed?S.feeds.find(p=>p.id===f.group):null; + // Hidden from All Subscriptions with the OPML it is in, whatever its own setting (#288). + const byList=f.group?S.feeds.find(g=>g.id===f.group&&g.hide_from_all):undefined; const admin=!!(S.me&&S.me.admin); const name=f.title||f.id; // Blank follows the server's default; 0 is every item (#166). @@ -620,6 +622,10 @@ function settingsModal(f: Feed, newUrl?: string){
${chipsHTML(f.blocked||[],'sblockin')}

Whole words or phrases. Hidden from your list and not downloaded for you, along with what Settings hides everywhere.

`} +
${sw(``,'In All Subscriptions',' lead')} +

${byList + ?`Left out with the rest of ${esc(byList.title||byList.id)}. Its own settings bring them back.` + :`Off keeps ${isGroup?'these shows':'this show'} out of All Subscriptions and its unread count. ${isGroup?'They are':'It is'} still here, in Signals and in Currently Listening.`}

${sw(``,'Private',' lead')}

${f.private_by_address @@ -716,6 +722,8 @@ function settingsModal(f: Feed, newUrl?: string){ // Sent only when it changed: anyone may mark a feed private, but only an admin unmark it. const priv=$('#sprivate'); if(!priv.disabled&&priv.checked!==!!f.private) patch.private=priv.checked; + const inall=$('#sinall'); + if(!inall.disabled&&inall.checked===!!f.hide_from_all) patch.hide_from_all=!inall.checked; // The address is the same for everyone: an admin's to change, for a feed that moved, and the // API refuses it from anyone else. The folder and category are not this dialog's at all. if(admin) patch.url=$('#surl').value.trim(); diff --git a/web/src/feedpage.ts b/web/src/feedpage.ts index bca2e3d..53fa97c 100644 --- a/web/src/feedpage.ts +++ b/web/src/feedpage.ts @@ -14,7 +14,7 @@ function renderFeed(){ const name=f?(f.title||f.id):sg?sg.name:v.title; $('#epSearch').placeholder=`Search ${name}…`; const kids=f?S.feeds.filter(c=>c.group===f.id):[]; - const unreadAll=S.feeds.reduce((n,x)=>n+(x.unread||0),0); + const unreadAll=unreadInAll(); box.innerHTML = (f&&kids.length ? groupHead(f,kids) : f ? `

${artHTML(f.image,name)} diff --git a/web/src/feeds.ts b/web/src/feeds.ts index 6586a76..2052433 100644 --- a/web/src/feeds.ts +++ b/web/src/feeds.ts @@ -35,6 +35,11 @@ const VIEWS: Record