hide_from_signals on a subscription, on an OPML or creator covering every show in it, as hide_from_all does for All Subscriptions: the judge does not put its items to Jev, and each Signal's list and counts leave them out. shown_in() is the one SQL rule for both. The settings dialog had grown one captioned setting at a time into a single column, one part boxed and the rest not. It is in named parts now, the name in a column beside the controls and above them on a phone, ruled apart; All Subscriptions and Signals carry the sidebar's icons. The address, the same for everyone, moves to the foot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- 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).
|
||||
- A feed, an OPML subscription or a Patreon creator can be kept out of Signals: `hide_from_signals` on `PATCH /api/feeds/{id}` and in the feed list. Jev is not asked about its items, so they cost nothing, and they are left out of each Signal's list and counts (#289).
|
||||
- `/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).
|
||||
|
||||
|
||||
@@ -126,6 +126,10 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
|
||||
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 hide_from_signals FROM subscriptions LIMIT 0").await.is_err() {
|
||||
let sql = "ALTER TABLE subscriptions ADD COLUMN hide_from_signals 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.
|
||||
@@ -1190,15 +1194,21 @@ 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::<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))"
|
||||
),
|
||||
// A Signal and All Subscriptions leave out what this person hid from them (#288, #289).
|
||||
// An episode under way still shows in Currently Listening, which is All Subscriptions'
|
||||
// in-progress filter.
|
||||
None if signal.is_some() => {
|
||||
format!(
|
||||
"e.feed_id IN (SELECT m.feed_id FROM subscriptions m WHERE m.user_id = {user} AND {})",
|
||||
shown_in("hide_from_signals", "m")
|
||||
)
|
||||
}
|
||||
None if filter != Filter::InProgress => {
|
||||
format!(
|
||||
"e.feed_id IN (SELECT m.feed_id FROM subscriptions m WHERE m.user_id = {user} AND {})",
|
||||
shown_in("hide_from_all", "m")
|
||||
)
|
||||
}
|
||||
None => format!("e.feed_id IN (SELECT feed_id FROM subscriptions WHERE user_id = {user})"),
|
||||
};
|
||||
if let Some(id) = signal {
|
||||
@@ -1226,6 +1236,15 @@ fn entries_from(
|
||||
)
|
||||
}
|
||||
|
||||
/// A subscription, `s`, not hidden from All Subscriptions or from Signals by `column`: neither on
|
||||
/// the feed nor on the OPML or Patreon creator it is in (#288, #289).
|
||||
fn shown_in(column: &str, s: &str) -> String {
|
||||
format!(
|
||||
"NOT {s}.{column} AND NOT EXISTS (SELECT 1 FROM feeds lf JOIN subscriptions lg
|
||||
ON lg.user_id = {s}.user_id AND lg.feed_id = lf.group_id WHERE lf.id = {s}.feed_id AND lg.{column})"
|
||||
)
|
||||
}
|
||||
|
||||
/// Not hidden from this person by their block lists. `e` is entries.
|
||||
fn not_hidden(user: &str) -> String {
|
||||
format!(
|
||||
@@ -1880,22 +1899,44 @@ 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<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())
|
||||
/// The feeds and lists this person hid from All Subscriptions (#288) and from Signals (#289).
|
||||
pub async fn hidden_from(
|
||||
&self,
|
||||
user_id: i64,
|
||||
) -> Result<(std::collections::HashSet<String>, std::collections::HashSet<String>)> {
|
||||
let (mut all, mut signals) = (std::collections::HashSet::new(), std::collections::HashSet::new());
|
||||
let rows =
|
||||
subscriptions::Entity::find().filter(subscriptions::Column::UserId.eq(user_id)).all(&self.orm).await?;
|
||||
for s in rows {
|
||||
if s.hide_from_all {
|
||||
all.insert(s.feed_id.clone());
|
||||
}
|
||||
if s.hide_from_signals {
|
||||
signals.insert(s.feed_id);
|
||||
}
|
||||
}
|
||||
Ok((all, signals))
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
self.set_hidden_from(subscriptions::Column::HideFromAll, user_id, feed_id, on).await
|
||||
}
|
||||
|
||||
/// As `set_hide_from_all`, for Signals (#289).
|
||||
pub async fn set_hide_from_signals(&self, user_id: i64, feed_id: &str, on: bool) -> Result<bool> {
|
||||
self.set_hidden_from(subscriptions::Column::HideFromSignals, user_id, feed_id, on).await
|
||||
}
|
||||
|
||||
async fn set_hidden_from(
|
||||
&self,
|
||||
column: subscriptions::Column,
|
||||
user_id: i64,
|
||||
feed_id: &str,
|
||||
on: bool,
|
||||
) -> Result<bool> {
|
||||
let r = subscriptions::Entity::update_many()
|
||||
.col_expr(subscriptions::Column::HideFromAll, Expr::val(on))
|
||||
.col_expr(column, Expr::val(on))
|
||||
.filter(subscriptions::Column::UserId.eq(user_id))
|
||||
.filter(subscriptions::Column::FeedId.eq(feed_id))
|
||||
.exec(&self.orm)
|
||||
@@ -2749,8 +2790,9 @@ impl Db {
|
||||
JOIN entries e ON e.feed_id = v.feed_id AND e.guid = v.guid
|
||||
JOIN subscriptions s ON s.user_id = g.user_id AND s.feed_id = v.feed_id
|
||||
LEFT JOIN entry_state st ON st.user_id = g.user_id AND st.feed_id = v.feed_id AND st.guid = v.guid
|
||||
WHERE v.signal_id = g.id AND v.hit AND {unread} AND {})",
|
||||
not_hidden(&user)
|
||||
WHERE v.signal_id = g.id AND v.hit AND {unread} AND {} AND {})",
|
||||
not_hidden(&user),
|
||||
shown_in("hide_from_signals", "s")
|
||||
)
|
||||
};
|
||||
let sql = format!(
|
||||
@@ -2869,6 +2911,8 @@ impl Db {
|
||||
};
|
||||
let limit = a.p(limit);
|
||||
let keyed = if everyone { "true" } else { "u.typesafe_key IS NOT NULL" };
|
||||
// A feed kept out of Signals is not put to Jev at all, so it costs nothing (#289).
|
||||
let in_signals = shown_in("hide_from_signals", "s");
|
||||
// Two halves, not one OR: the recent half reads entries_recent, and the rechecks are a few
|
||||
// rows of signal_verdicts. The same placeholders serve both.
|
||||
let half = |items: &str| {
|
||||
@@ -2880,7 +2924,7 @@ impl Db {
|
||||
JOIN subscriptions s ON s.user_id = g.user_id
|
||||
{items}
|
||||
LEFT JOIN feeds f ON f.id = e.feed_id
|
||||
WHERE {keyed} {skip}
|
||||
WHERE {keyed} {skip} AND {in_signals}
|
||||
AND NOT EXISTS (SELECT 1 FROM hidden h
|
||||
WHERE h.user_id = g.user_id AND h.feed_id = e.feed_id AND h.guid = e.guid)"
|
||||
)
|
||||
@@ -3798,6 +3842,42 @@ mod tests {
|
||||
assert_eq!(all(1, Filter::All).await, ["D four", "B two", "A one"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_feed_kept_out_of_signals_is_neither_judged_nor_listed() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
let now = now();
|
||||
db.exec_for_test(&format!(
|
||||
"INSERT INTO users (id, name, is_admin, typesafe_key) VALUES (1,'a',true,'k');
|
||||
INSERT INTO feeds (id, url, group_id, managed) VALUES ('a','u1','o',true), ('c','u3',NULL,false), ('d','u4',NULL,false);
|
||||
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'o'), (1,'a'), (1,'c'), (1,'d');
|
||||
INSERT INTO signals (id, user_id, name, keywords, created) VALUES (9, 1, 'S', '[\"x\"]', {now});
|
||||
INSERT INTO entries (feed_id, guid, title, first_seen) VALUES ('a','1','A one',{now}), ('c','3','C three',{now}), ('d','4','D four',{now});
|
||||
INSERT INTO signal_verdicts (signal_id, feed_id, guid, hit, recheck) VALUES (9,'a','1',true,false), (9,'c','3',true,false);"
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let judged = async || {
|
||||
let mut f: Vec<_> = db.unjudged(&[], 50, false, 30).await.unwrap().into_iter().map(|u| u.feed_id).collect();
|
||||
f.sort();
|
||||
f
|
||||
};
|
||||
let order = order_sql("published", "desc");
|
||||
let listed = async || {
|
||||
let rows = db.signal_entries(1, 9, Filter::All, None, 0, 50, &order).await.unwrap();
|
||||
let n = db.signal_count(1, 9, Filter::All, None).await.unwrap();
|
||||
assert_eq!(rows.len() as i64, n, "the count is of the same list");
|
||||
assert_eq!(db.signal(1, 9).await.unwrap().unwrap().items, n, "and the Signal's own count");
|
||||
rows.into_iter().map(|r| r.title.unwrap()).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(judged().await, ["d"]);
|
||||
assert_eq!(listed().await, ["C three", "A one"]);
|
||||
assert!(db.set_hide_from_signals(1, "o", true).await.unwrap());
|
||||
assert!(db.set_hide_from_signals(1, "d", true).await.unwrap());
|
||||
assert_eq!(judged().await, Vec::<String>::new(), "Jev is not asked about a hidden feed");
|
||||
assert_eq!(listed().await, ["C three"], "the OPML's show left out with it");
|
||||
assert_eq!(db.count_of(1, None, Filter::All, None).await.unwrap(), 3, "All Subscriptions keeps them");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_takes_the_latest_episodes_first() {
|
||||
// A cap of 3 must mean the three newest, not the three recorded first.
|
||||
|
||||
@@ -227,6 +227,9 @@ pub mod subscriptions {
|
||||
/// in it (#288).
|
||||
#[sea_orm(default_value = false)]
|
||||
pub hide_from_all: bool,
|
||||
/// Left out of every Signal, and never put to Jev for them, as `hide_from_all` (#289).
|
||||
#[sea_orm(default_value = false)]
|
||||
pub hide_from_signals: bool,
|
||||
}
|
||||
|
||||
owned_by_user!();
|
||||
|
||||
@@ -839,6 +839,8 @@ struct FeedRow {
|
||||
/// Left out of All Subscriptions, by this row's own setting; a show also is when its OPML
|
||||
/// is (#288).
|
||||
hide_from_all: bool,
|
||||
/// The same for Signals (#289).
|
||||
hide_from_signals: 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.
|
||||
@@ -877,7 +879,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 (hidden, quiet) = state.ctx.db.hidden_from(user.id).instrument(step("hidden_from")).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> =
|
||||
@@ -953,6 +955,7 @@ async fn feed_rows(state: &WebState, user: &crate::db::User, only: Option<&str>)
|
||||
pinned: pinned.contains(id),
|
||||
kept: kept.contains(id),
|
||||
hide_from_all: hidden.contains(id),
|
||||
hide_from_signals: quiet.contains(id),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
@@ -1670,6 +1673,8 @@ struct FeedPatch {
|
||||
kept: Option<bool>,
|
||||
/// Left out of All Subscriptions, or on an OPML every show in it (#288).
|
||||
hide_from_all: Option<bool>,
|
||||
/// Left out of Signals and never judged for them, on an OPML every show in it (#289).
|
||||
hide_from_signals: Option<bool>,
|
||||
/// 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")]
|
||||
@@ -1726,6 +1731,11 @@ async fn patch_feed(
|
||||
{
|
||||
return Err(ApiError::not_found("you do not subscribe to that feed"));
|
||||
}
|
||||
if let Some(on) = body.hide_from_signals
|
||||
&& !state.ctx.db.set_hide_from_signals(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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user