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