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 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 14:19:54 +00:00
parent 6daab6989e
commit b89c371662
10 changed files with 114 additions and 2 deletions

View File

@@ -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). - `/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). - 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 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). - `/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). - An admin setting, Signals for everyone on the server's TypeSafe key, so the server pays for Jev instead of each person (#271).

View File

@@ -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"; let sql = "ALTER TABLE subscriptions ADD COLUMN kept BOOLEAN NOT NULL DEFAULT false";
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?; 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() { 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 // 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. // 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([f]) => format!("e.feed_id = {}", a.p(*f)),
Some([]) => "false".into(), Some([]) => "false".into(),
Some(fs) => format!("e.feed_id IN ({})", fs.iter().map(|f| a.p(*f)).collect::<Vec<_>>().join(", ")), Some(fs) => format!("e.feed_id IN ({})", fs.iter().map(|f| a.p(*f)).collect::<Vec<_>>().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})"), None => format!("e.feed_id IN (SELECT feed_id FROM subscriptions WHERE user_id = {user})"),
}; };
if let Some(id) = signal { if let Some(id) = signal {
@@ -1867,6 +1880,29 @@ impl Db {
Ok(r.rows_affected > 0) 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<std::collections::HashSet<String>> {
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<bool> {
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). /// Who subscribes to a feed, and whether each keeps it (#280).
pub async fn subscribers_of(&self, feed_id: &str) -> Result<Vec<(i64, bool)>> { pub async fn subscribers_of(&self, feed_id: &str) -> Result<Vec<(i64, bool)>> {
Ok(subscriptions::Entity::find() Ok(subscriptions::Entity::find()
@@ -3731,6 +3767,37 @@ mod tests {
assert_eq!(db.count_of(1, Some(&[]), Filter::All, None).await.unwrap(), 0); 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::<Vec<_>>()
};
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] #[tokio::test]
async fn pending_takes_the_latest_episodes_first() { async fn pending_takes_the_latest_episodes_first() {
// A cap of 3 must mean the three newest, not the three recorded first. // A cap of 3 must mean the three newest, not the three recorded first.

View File

@@ -223,6 +223,10 @@ pub mod subscriptions {
/// they leave the list: it becomes a feed of its own instead of going (#280). /// they leave the list: it becomes a feed of its own instead of going (#280).
#[sea_orm(default_value = false)] #[sea_orm(default_value = false)]
pub kept: bool, 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!(); owned_by_user!();

View File

@@ -836,6 +836,9 @@ struct FeedRow {
pinned: bool, pinned: bool,
/// From an OPML, and yours to keep should it drop the feed (#280). /// From an OPML, and yours to keep should it drop the feed (#280).
kept: bool, 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). /// Marked private: kept out of the directory (#205).
private: bool, private: bool,
/// Private whatever it is marked, for a login or an address with a key in it. /// 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 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 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 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 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 deadair: std::collections::HashMap<&str, bool> = prefs.iter().map(|(_, f, w, _)| (f.as_str(), *w)).collect();
let pauses: std::collections::HashMap<&str, i64> = 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), subscribers: counts.get(id).copied().unwrap_or(0),
pinned: pinned.contains(id), pinned: pinned.contains(id),
kept: kept.contains(id), kept: kept.contains(id),
hide_from_all: hidden.contains(id),
}); });
} }
Ok(out) Ok(out)
@@ -1663,6 +1668,8 @@ struct FeedPatch {
pinned: Option<bool>, pinned: Option<bool>,
/// Kept should its OPML drop it (#280). /// Kept should its OPML drop it (#280).
kept: Option<bool>, kept: Option<bool>,
/// Left out of All Subscriptions, or on an OPML every show in it (#288).
hide_from_all: Option<bool>,
/// True plays the feed shortened by DeadAir, false keeps it out (of its group's too), null /// True plays the feed shortened by DeadAir, false keeps it out (of its group's too), null
/// follows its group again (#171). /// follows its group again (#171).
#[serde(default, deserialize_with = "double_option")] #[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")); 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 if let Some(p) = body.deadair_pause
&& !crate::deadair::PAUSES.contains(&p) && !crate::deadair::PAUSES.contains(&p)
{ {

View File

@@ -144,6 +144,18 @@ test('a Patreon feed is told by its address', () => {
assert.equal(run('isPatreon(null)'), false); 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', () => { test('a Signal is a place of its own, found by its id', () => {
run(`S.signals = [{id: 7, name: 'F1', keywords: ['f1'], unread: 2}]`); run(`S.signals = [{id: 7, name: 'F1', keywords: ['f1'], unread: 2}]`);
assert.equal(run(`signalId(':signal:7')`), 7); assert.equal(run(`signalId(':signal:7')`), 7);

View File

@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### 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). - 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). - 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).

View File

@@ -578,6 +578,8 @@ 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;
// 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 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).
@@ -620,6 +622,10 @@ function settingsModal(f: Feed, newUrl?: string){
<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
with what Settings hides everywhere.</p></div>`} with what Settings hides everywhere.</p></div>`}
<div class="ffield">${sw(`<input type="checkbox" role="switch" id="sinall"${inAll(f)?' checked':''}${byList?' disabled':''}>`,'In All Subscriptions',' lead')}
<p class="cap">${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.`}</p></div>
<div class="ffield">${sw(`<input type="checkbox" role="switch" id="sprivate"${f.private||f.private_by_address?' checked':''}${ <div class="ffield">${sw(`<input type="checkbox" role="switch" id="sprivate"${f.private||f.private_by_address?' checked':''}${
f.private_by_address||(f.private&&!admin)?' disabled':''}>`,'Private',' lead')} f.private_by_address||(f.private&&!admin)?' disabled':''}>`,'Private',' lead')}
<p class="cap">${f.private_by_address <p class="cap">${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. // 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;
const inall=$<HTMLInputElement>('#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 // 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. // API refuses it from anyone else. The folder and category are not this dialog's at all.
if(admin) patch.url=$<HTMLInputElement>('#surl').value.trim(); if(admin) patch.url=$<HTMLInputElement>('#surl').value.trim();

View File

@@ -14,7 +14,7 @@ function renderFeed(){
const name=f?(f.title||f.id):sg?sg.name:v.title; const name=f?(f.title||f.id):sg?sg.name:v.title;
$<HTMLInputElement>('#epSearch').placeholder=`Search ${name}…`; $<HTMLInputElement>('#epSearch').placeholder=`Search ${name}…`;
const kids=f?S.feeds.filter(c=>c.group===f.id):[]; 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 ? ` box.innerHTML = (f&&kids.length ? groupHead(f,kids) : f ? `
<div class="fhead slim"> <div class="fhead slim">
${artHTML(f.image,name)} ${artHTML(f.image,name)}

View File

@@ -35,6 +35,11 @@ const VIEWS: Record<string,{title: string, icon: string, url?: string, blurb?: s
blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'}, blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'},
':all':{title:'All Subscriptions',icon:ICON.all}, ':all':{title:'All Subscriptions',icon:ICON.all},
}; };
/// Whether a feed's items are in All Subscriptions: not when it, or the OPML or creator it came
/// from, is hidden from it (#288). The server leaves out the same.
const inAll=(f: Feed)=>!f.hide_from_all&&!(f.group&&S.feeds.some(g=>g.id===f.group&&g.hide_from_all));
/// All Subscriptions' unread count.
const unreadInAll=()=>S.feeds.reduce((n,f)=>n+(inAll(f)?f.unread||0:0),0);
/// Feeds being checked right now, from the event stream, and since when: their rows, and the row /// Feeds being checked right now, from the event stream, and since when: their rows, and the row
/// of a folder holding one, carry a spinner. /// of a folder holding one, carry a spinner.
const scanning=new Map<string, number>(); const scanning=new Map<string, number>();
@@ -90,7 +95,7 @@ function renderFeeds(){
const drawn=new Map<string,HTMLImageElement[]>(); const drawn=new Map<string,HTMLImageElement[]>();
for(const img of $$<HTMLImageElement>('img',list)){ const k=img.outerHTML; if(!drawn.has(k)) drawn.set(k,[]); drawn.get(k)!.push(img); } for(const img of $$<HTMLImageElement>('img',list)){ const k=img.outerHTML; if(!drawn.has(k)) drawn.set(k,[]); drawn.get(k)!.push(img); }
list.innerHTML=''; list.innerHTML='';
const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0); const unreadAll=unreadInAll();
const places=document.createElement('div'); const places=document.createElement('div');
places.className='places'; places.className='places';
for(const [id,v] of Object.entries(VIEWS)){ for(const [id,v] of Object.entries(VIEWS)){

View File

@@ -37,6 +37,8 @@ interface Feed {
pinned: boolean; pinned: boolean;
/** From an OPML, and yours to keep should it drop the feed (#280). */ /** From an OPML, and yours to keep should it drop the feed (#280). */
kept: boolean; kept: boolean;
/** Left out of All Subscriptions by its own setting; a show is also when its OPML is (#288). */
hide_from_all: boolean;
private: boolean; private: boolean;
private_by_address: boolean; private_by_address: boolean;
} }