Keep a feed out of Signals, and a feed's settings in named parts (#289, #290)

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:
2026-10-19 14:37:49 +00:00
parent b89c371662
commit b54b47897b
9 changed files with 190 additions and 65 deletions

View File

@@ -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). - 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). - 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). - `/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

@@ -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"; 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())?; 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() { 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.
@@ -1190,15 +1194,21 @@ 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 // A Signal and All Subscriptions leave out what this person hid from them (#288, #289).
// (#288). A Signal reads every feed, and an episode under way still shows in Currently // An episode under way still shows in Currently Listening, which is All Subscriptions'
// Listening, which is this list's in-progress filter. // in-progress filter.
None if signal.is_none() && filter != Filter::InProgress => format!( None if signal.is_some() => {
"e.feed_id IN (SELECT m.feed_id FROM subscriptions m JOIN feeds f ON f.id = m.feed_id format!(
WHERE m.user_id = {user} AND NOT m.hide_from_all "e.feed_id IN (SELECT m.feed_id FROM subscriptions m WHERE m.user_id = {user} AND {})",
AND NOT EXISTS (SELECT 1 FROM subscriptions g shown_in("hide_from_signals", "m")
WHERE g.user_id = {user} AND g.feed_id = f.group_id AND g.hide_from_all))" )
), }
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})"), 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 {
@@ -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. /// Not hidden from this person by their block lists. `e` is entries.
fn not_hidden(user: &str) -> String { fn not_hidden(user: &str) -> String {
format!( format!(
@@ -1880,22 +1899,44 @@ impl Db {
Ok(r.rows_affected > 0) Ok(r.rows_affected > 0)
} }
/// The feeds and lists this person hid from All Subscriptions (#288). /// The feeds and lists this person hid from All Subscriptions (#288) and from Signals (#289).
pub async fn hidden_from_all(&self, user_id: i64) -> Result<std::collections::HashSet<String>> { pub async fn hidden_from(
Ok(subscriptions::Entity::find() &self,
.filter(subscriptions::Column::UserId.eq(user_id)) user_id: i64,
.filter(subscriptions::Column::HideFromAll.eq(true)) ) -> Result<(std::collections::HashSet<String>, std::collections::HashSet<String>)> {
.all(&self.orm) let (mut all, mut signals) = (std::collections::HashSet::new(), std::collections::HashSet::new());
.await? let rows =
.into_iter() subscriptions::Entity::find().filter(subscriptions::Column::UserId.eq(user_id)).all(&self.orm).await?;
.map(|s| s.feed_id) for s in rows {
.collect()) 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`. /// 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> { 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() 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::UserId.eq(user_id))
.filter(subscriptions::Column::FeedId.eq(feed_id)) .filter(subscriptions::Column::FeedId.eq(feed_id))
.exec(&self.orm) .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 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 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 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 {})", WHERE v.signal_id = g.id AND v.hit AND {unread} AND {} AND {})",
not_hidden(&user) not_hidden(&user),
shown_in("hide_from_signals", "s")
) )
}; };
let sql = format!( let sql = format!(
@@ -2869,6 +2911,8 @@ impl Db {
}; };
let limit = a.p(limit); let limit = a.p(limit);
let keyed = if everyone { "true" } else { "u.typesafe_key IS NOT NULL" }; 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 // 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. // rows of signal_verdicts. The same placeholders serve both.
let half = |items: &str| { let half = |items: &str| {
@@ -2880,7 +2924,7 @@ impl Db {
JOIN subscriptions s ON s.user_id = g.user_id JOIN subscriptions s ON s.user_id = g.user_id
{items} {items}
LEFT JOIN feeds f ON f.id = e.feed_id 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 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)" 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"]); 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] #[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

@@ -227,6 +227,9 @@ pub mod subscriptions {
/// in it (#288). /// in it (#288).
#[sea_orm(default_value = false)] #[sea_orm(default_value = false)]
pub hide_from_all: bool, 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!(); owned_by_user!();

View File

@@ -839,6 +839,8 @@ struct FeedRow {
/// Left out of All Subscriptions, by this row's own setting; a show also is when its OPML /// Left out of All Subscriptions, by this row's own setting; a show also is when its OPML
/// is (#288). /// is (#288).
hide_from_all: bool, hide_from_all: bool,
/// The same for Signals (#289).
hide_from_signals: 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.
@@ -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 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 (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 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> =
@@ -953,6 +955,7 @@ async fn feed_rows(state: &WebState, user: &crate::db::User, only: Option<&str>)
pinned: pinned.contains(id), pinned: pinned.contains(id),
kept: kept.contains(id), kept: kept.contains(id),
hide_from_all: hidden.contains(id), hide_from_all: hidden.contains(id),
hide_from_signals: quiet.contains(id),
}); });
} }
Ok(out) Ok(out)
@@ -1670,6 +1673,8 @@ struct FeedPatch {
kept: Option<bool>, kept: Option<bool>,
/// Left out of All Subscriptions, or on an OPML every show in it (#288). /// Left out of All Subscriptions, or on an OPML every show in it (#288).
hide_from_all: Option<bool>, 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 /// 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")]
@@ -1726,6 +1731,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_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 if let Some(p) = body.deadair_pause
&& !crate::deadair::PAUSES.contains(&p) && !crate::deadair::PAUSES.contains(&p)
{ {

View File

@@ -11,9 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Changed
- A feed's settings are in parts, each named beside its controls: Downloads, Playback, Where it shows, Directory and Address, with All Subscriptions and Signals drawn as the sidebar draws them (#290).
### 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). - 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).
- A feed's settings have a Signals switch beside All Subscriptions, to keep its items out of every Signal (#289).
- 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

@@ -1095,14 +1095,30 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
/* ---------- a feed's settings (#167) ---------- */ /* ---------- a feed's settings (#167) ---------- */
/* A frame of its own: the body scrolls and Save stays at the foot, where on a phone it had been /* A frame of its own: the body scrolls and Save stays at the foot, where on a phone it had been
three screens down. */ three screens down. */
.card.fset{padding:0;display:flex;flex-direction:column;overflow:hidden;width:min(520px,100%)} .card.fset{padding:0;display:flex;flex-direction:column;overflow:hidden;width:min(580px,100%);container-type:inline-size}
.fsetbody{overflow:auto;min-height:0;padding:22px 24px 24px} .fsetbody{overflow:auto;min-height:0;padding:22px 24px 24px}
.card.fset .fsetfoot{flex:none;margin:0;padding:12px 24px;border-top:1px solid var(--line)} .card.fset .fsetfoot{flex:none;margin:0;padding:12px 24px;border-top:1px solid var(--line)}
.fsethead{display:flex;gap:14px;align-items:center;margin-bottom:18px} .fsethead{display:flex;gap:14px;align-items:center;margin-bottom:20px}
.fsethead .art{width:56px;height:56px;border-radius:12px;font-size:18px} .fsethead .art{width:56px;height:56px;border-radius:12px;font-size:18px}
.fsethead h3{margin:0;font-size:19px;line-height:1.25;letter-spacing:-.01em;overflow-wrap:anywhere} .fsethead h3{margin:0;font-size:19px;line-height:1.25;letter-spacing:-.01em;overflow-wrap:anywhere}
.fsethead p{margin:3px 0 0;font-size:12.5px;line-height:1.45;color:var(--dim)} .fsethead p{margin:3px 0 0;font-size:12.5px;line-height:1.45;color:var(--dim)}
.faddr{margin-bottom:18px} /* A part of the settings, named in a column of its own beside its controls (#290); on a phone the
name sits above them. Ruled apart rather than boxed: the boxes, one round downloads and none
round the rest, read as a difference between the settings that was not there. */
.fsec{display:grid;grid-template-columns:104px minmax(0,1fr);gap:6px 20px;padding:14px 0 16px;border-top:1px solid var(--line)}
.fsec h4{margin:0;padding-top:10px;font-size:13px;font-weight:600;line-height:1.3;color:var(--dim)}
.fsecin{display:grid;gap:6px;min-width:0}
@container (max-width:480px){
.fsec{grid-template-columns:minmax(0,1fr);gap:4px}
.fsec h4{padding-top:0}
}
/* All Subscriptions and Signals with the icons the sidebar gives them, so the switch reads as that place. */
.switch.fplace>span:first-child{display:flex;align-items:center;gap:10px}
.fico{display:inline-flex;justify-content:center;width:18px;flex:none;color:var(--accent)}
.fico svg{width:15px;height:15px}
.fsig img{display:block;width:18px;height:18px;border-radius:5px}
/* Set on the OPML it came from, not here: shown, and plainly out of reach. */
.fplace:has(input:disabled){opacity:.5;cursor:default}
.moved{margin:2px 0 0;font-size:13px;color:var(--accent)} .moved{margin:2px 0 0;font-size:13px;color:var(--accent)}
/* On or off, drawn as iOS draws it, as the app is on a phone (#53). The box is the real control, /* On or off, drawn as iOS draws it, as the app is on a phone (#53). The box is the real control,
unseen over the track, so a click, a tap, the keyboard and a screen reader all reach it. */ unseen over the track, so a click, a tap, the keyboard and a screen reader all reach it. */
@@ -1117,16 +1133,14 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
.sw input:checked+i{background:var(--accent);border-color:var(--accent)} .sw input:checked+i{background:var(--accent);border-color:var(--accent)}
.sw input:checked+i::after{transform:translateX(16px)} .sw input:checked+i::after{transform:translateX(16px)}
.sw input:focus-visible+i{outline:2px solid var(--accent);outline-offset:2px} .sw input:focus-visible+i{outline:2px solid var(--accent);outline-offset:2px}
/* The switch and what only matters while it downloads on its own, in one box; the rest folds away /* What only matters while it downloads on its own folds away when it is off: the rows in a grid of
when it is off. In --line, not --edge, so the box shows on a phone, which draws no outlines. The one row, 1fr open and 0fr shut, so the fold has a height to move. */
rows in a grid of one row, 1fr open and 0fr shut, so the fold has a height to move. */
.fdl{border:1px solid var(--line);border-radius:12px;padding:4px 14px}
/* The same box round a part of a dialog that stands apart: API tokens in Settings. */ /* The same box round a part of a dialog that stands apart: API tokens in Settings. */
.field.boxed{border:1px solid var(--line);border-radius:12px;padding:12px 14px} .field.boxed{border:1px solid var(--line);border-radius:12px;padding:12px 14px}
.fdeps{display:grid;grid-template-rows:1fr;transition:grid-template-rows .22s ease} .fdeps{display:grid;grid-template-rows:1fr;transition:grid-template-rows .22s ease}
.fdeps.off{grid-template-rows:0fr} .fdeps.off{grid-template-rows:0fr}
.fdeps>div{min-height:0;overflow:hidden} .fdeps>div{min-height:0;overflow:hidden}
.fdepsin{padding:6px 2px 10px} .fdepsin{padding:6px 0 2px}
/* The app's own segmented control, with radio buttons in it, for the arrow keys. */ /* The app's own segmented control, with radio buttons in it, for the arrow keys. */
.seg{margin-bottom:12px} .seg{margin-bottom:12px}
.seg label{flex:1;position:relative;text-align:center;white-space:nowrap;padding:5px 10px;border-radius:7px;font-size:13px;color:var(--dim);cursor:pointer} .seg label{flex:1;position:relative;text-align:center;white-space:nowrap;padding:5px 10px;border-radius:7px;font-size:13px;color:var(--dim);cursor:pointer}
@@ -1146,6 +1160,8 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
.wcap{margin:0 0 16px;font-size:13px;line-height:1.55;color:var(--dim)} .wcap{margin:0 0 16px;font-size:13px;line-height:1.55;color:var(--dim)}
.wcap input[type=number]{display:inline-block;width:4.2em;margin:0 2px;padding:1px 4px;font-size:13px;text-align:center;font-variant-numeric:tabular-nums;color:var(--fg)} .wcap input[type=number]{display:inline-block;width:4.2em;margin:0 2px;padding:1px 4px;font-size:13px;text-align:center;font-variant-numeric:tabular-nums;color:var(--fg)}
.ffield{display:grid;gap:6px;margin-bottom:14px} .ffield{display:grid;gap:6px;margin-bottom:14px}
.fsecin>.ffield:last-child{margin-bottom:0}
.fsecin>.cap+.ffield{margin-top:10px}
/* DeadAir's pause: four stops on one scale, a dot at each so it reads as four places to stop. */ /* DeadAir's pause: four stops on one scale, a dot at each so it reads as four places to stop. */
.dpause{display:grid;gap:6px;margin-top:6px} .dpause{display:grid;gap:6px;margin-top:6px}
.dphead{display:flex;justify-content:space-between;align-items:baseline} .dphead{display:flex;justify-content:space-between;align-items:baseline}
@@ -1165,9 +1181,6 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
.dpends{display:flex;justify-content:space-between;font-size:12.5px;color:var(--dim)} .dpends{display:flex;justify-content:space-between;font-size:12.5px;color:var(--dim)}
.dpause:has(input:disabled){opacity:.45} .dpause:has(input:disabled){opacity:.45}
.dpslide input[type=range]:disabled{cursor:default} .dpslide input[type=range]:disabled{cursor:default}
.fdl+.ffield{margin-top:18px}
/* DeadAir shares the box but not the fold: it applies whether or not this show downloads on its own. */
.fdead{border-top:1px solid var(--line);padding-top:10px;margin-top:4px}
.flabel{font-size:14px;font-weight:500} .flabel{font-size:14px;font-weight:500}
.cap{margin:0;font-size:12.5px;line-height:1.45;color:var(--dim)} .cap{margin:0;font-size:12.5px;line-height:1.45;color:var(--dim)}
/* Words and phrases as chips: in one comma-separated box, one ran into the next. */ /* Words and phrases as chips: in one comma-separated box, one ran into the next. */
@@ -1398,6 +1411,7 @@ body:has(#player.on) #status{padding-bottom:3px}
.fsetbody{padding:20px 16px 24px} .fsetbody{padding:20px 16px 24px}
.card.fset .fsetfoot{padding:12px 16px calc(12px + var(--safe-b))} .card.fset .fsetfoot{padding:12px 16px calc(12px + var(--safe-b))}
.fsethead .art{width:64px;height:64px;border-radius:14px} .fsethead .art{width:64px;height:64px;border-radius:14px}
.fsec h4{font-size:14px}
.fsethead p,.wcap,.cap{font-size:14px} .fsethead p,.wcap,.cap{font-size:14px}
.switch,.flabel{font-size:17px} .switch,.flabel{font-size:17px}
.switch{min-height:44px} .switch{min-height:44px}
@@ -1413,7 +1427,7 @@ body:has(#player.on) #status{padding-bottom:3px}
.chips{background:var(--panel2);border-color:var(--edge);border-radius:10px} .chips{background:var(--panel2);border-color:var(--edge);border-radius:10px}
.chip{background:var(--panel);font-size:15px;padding:5px 4px 5px 12px} .chip{background:var(--panel);font-size:15px;padding:5px 4px 5px 12px}
.chipx{width:26px;height:26px} .chipx{width:26px;height:26px}
.fdl,.field.boxed{border-radius:14px} .field.boxed{border-radius:14px}
.toast{border-radius:14px} .toast{border-radius:14px}
} }
@keyframes sheet{from{transform:translateY(100%)}} @keyframes sheet{from{transform:translateY(100%)}}

View File

@@ -578,8 +578,6 @@ 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).
@@ -591,13 +589,20 @@ function settingsModal(f: Feed, newUrl?: string){
? `It comes from ${esc(parent.title||parent.id)}, and follows your settings there until you save these.` ? `It comes from ${esc(parent.title||parent.id)}, and follows your settings there until you save these.`
: 'Your settings for this feed. Everyone else who reads it keeps their own.'; : 'Your settings for this feed. Everyone else who reads it keeps their own.';
const sw=(input: string, text: string, cls='')=>`<label class="switch${cls}"><span>${text}</span><span class="sw">${input}<i></i></span></label>`; const sw=(input: string, text: string, cls='')=>`<label class="switch${cls}"><span>${text}</span><span class="sw">${input}<i></i></span></label>`;
// Each part named in a column of its own, the controls beside it (#290): the dialog had grown one
// captioned setting at a time into a single column three screens long on a phone.
const sect=(title: string, body: string)=>`<section class="fsec"><h4>${title}</h4><div class="fsecin">${body}</div></section>`;
const these=isGroup?'these shows':'this show';
// The places in the sidebar this show's items go to, drawn as the sidebar draws them (#288, #289).
const place=(id: string, key: 'hide_from_all'|'hide_from_signals', icon: string, title: string)=>{
const byList=f.group?S.feeds.find(g=>g.id===f.group&&g[key]):undefined;
return sw(`<input type="checkbox" role="switch" id="${id}"${shownIn(f,key)?' checked':''}${byList?' disabled':''}>`,
`<span class="fico" aria-hidden="true">${icon}</span>${title}`,' fplace');
};
const listHides=f.group?S.feeds.find(g=>g.id===f.group&&(g.hide_from_all||g.hide_from_signals)):undefined;
openModal(`<div class="fsetbody"> openModal(`<div class="fsetbody">
<header class="fsethead">${artHTML(f.image,name)}<div><h3>${esc(name)}</h3><p>${whose}</p></div></header> <header class="fsethead">${artHTML(f.image,name)}<div><h3>${esc(name)}</h3><p>${whose}</p></div></header>
<div class="field faddr"><label for="surl">Address</label> ${sect('Downloads',`${sw(`<input type="checkbox" role="switch" id="sauto"${f.auto_download?' checked':''}>`,'Download automatically',' lead')}
<div class="inline"><input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false"${admin?'':' readonly'}>
<button type="button" class="btn ico" id="scopy" title="Copy the address" aria-label="Copy the address">${ICON.copy}</button></div>
${newUrl?'<p class="moved">The feed says it has moved here. Save to follow it.</p>':''}</div>
<div class="fdl">${sw(`<input type="checkbox" role="switch" id="sauto"${f.auto_download?' checked':''}>`,'Download automatically',' lead')}
<div class="fdeps${f.auto_download?'':' off'}" id="sdeps"><div><div class="fdepsin"> <div class="fdeps${f.auto_download?'':' off'}" id="sdeps"><div><div class="fdepsin">
<div class="tabs seg" role="radiogroup" aria-label="How many to download">${ <div class="tabs seg" role="radiogroup" aria-label="How many to download">${
[['default',`Default (${globalMax||'all'})`],['newest','Newest'],['all','All']].map(([v,t])=> [['default',`Default (${globalMax||'all'})`],['newest','Newest'],['all','All']].map(([v,t])=>
@@ -608,31 +613,32 @@ function settingsModal(f: Feed, newUrl?: string){
<div class="chips" id="skw">${chipsHTML(f.keywords,'skwin')}</div> <div class="chips" id="skw">${chipsHTML(f.keywords,'skwin')}</div>
<p class="cap">Any one of these will do, and a phrase needs all its words. Empty takes everything.</p></div> <p class="cap">Any one of these will do, and a phrase needs all its words. Empty takes everything.</p></div>
${sw(`<input type="checkbox" role="switch" id="sexp"${f.allow_explicit?' checked':''}>`,'Include explicit items')} ${sw(`<input type="checkbox" role="switch" id="sexp"${f.allow_explicit?' checked':''}>`,'Include explicit items')}
</div></div></div> </div></div></div>`)}
<div class="ffield fdead">${sw(`<input type="checkbox" role="switch" id="sdead"${f.deadair?' checked':''}>`,'DeadAir',' lead')} ${sect('Playback',`${sw(`<input type="checkbox" role="switch" id="sdead"${f.deadair?' checked':''}>`,'DeadAir',' lead')}
<p class="cap">Cuts the dead air: long silences in ${isGroup?'these shows':'this show'} shrink to a beat, so <p class="cap">Shrinks long silences in ${these} to a beat, so episodes finish sooner. Leave it off
episodes finish sooner and every voice keeps its own pace. Leave it off for stories whose pauses for stories whose pauses are part of the telling.</p>
are part of the telling.</p>
<div class="dpause"> <div class="dpause">
<div class="dphead"><label class="flabel" for="sdpause">Breathing room</label><output id="sdpauseout" for="sdpause"></output></div> <div class="dphead"><label class="flabel" for="sdpause">Breathing room</label><output id="sdpauseout" for="sdpause"></output></div>
<div class="dpslide"><input type="range" id="sdpause" min="200" max="500" step="100" value="${f.deadair_pause||300}"${f.deadair?'':' disabled'}></div> <div class="dpslide"><input type="range" id="sdpause" min="200" max="500" step="100" value="${f.deadair_pause||300}"${f.deadair?'':' disabled'}></div>
<div class="dpends" aria-hidden="true"><span>Brisk</span><span>Relaxed</span></div> <div class="dpends" aria-hidden="true"><span>Brisk</span><span>Relaxed</span></div></div>`)}
<p class="cap">The silence kept where a long one is cut. More is easier on the ear; less saves more time.</p></div></div></div> ${sect('Where it shows',`<div class="fplaces">${place('sinall','hide_from_all',ICON.all,'All Subscriptions')}${
${isGroup?'':`<div class="ffield"><label class="flabel" for="sblockin">Hide items that mention</label> signalsOn()?place('sinsig','hide_from_signals',`<span class="fsig">${tile('signals')}</span>`,'Signals'):''}</div>
<div class="chips" id="sblock">${chipsHTML(f.blocked||[],'sblockin')}</div> <p class="cap">${listHides
<p class="cap">Whole words or phrases. Hidden from your list and not downloaded for you, along ?`Off where ${esc(listHides.title||listHides.id)} is off; its settings bring them back.`
with what Settings hides everywhere.</p></div>`} :`Off leaves ${these} out of that list and its count. ${isGroup?'Their':'Its'} own page still has everything.`}</p>
<div class="ffield">${sw(`<input type="checkbox" role="switch" id="sinall"${inAll(f)?' checked':''}${byList?' disabled':''}>`,'In All Subscriptions',' lead')} ${isGroup?'':`<div class="ffield"><label class="flabel" for="sblockin">Hide items that mention</label>
<p class="cap">${byList <div class="chips" id="sblock">${chipsHTML(f.blocked||[],'sblockin')}</div>
?`Left out with the rest of ${esc(byList.title||byList.id)}. Its own settings bring them back.` <p class="cap">Whole words or phrases, hidden everywhere and not downloaded for you.</p></div>`}`)}
:`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> ${sect('Directory',`${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
?'Its address or login shows it is someone’s own, so it is never shared with the directory.' ?'Its address or login shows it is someone’s own, so it is never shared with the directory.'
:f.private&&!admin :f.private&&!admin
?'Never shared with the directory. Only an admin can share it again.' ?'Never shared with the directory. Only an admin can share it again.'
:`Never shared with the directory, for ${isGroup?'shows':'a show'} you pay for. iPX knows most paid feeds by their address; this is for the ones it does not.`}</p></div> :`Never shared with the directory, for ${isGroup?'shows':'a show'} you pay for. iPX knows most paid feeds by their address; this is for the ones it does not.`}</p>`)}
${sect('Address',`<div class="inline"><input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false" aria-label="Address"${admin?'':' readonly'}>
<button type="button" class="btn ico" id="scopy" title="Copy the address" aria-label="Copy the address">${ICON.copy}</button></div>
${newUrl?'<p class="moved">The feed says it has moved here. Save to follow it.</p>':''}`)}
</div> </div>
<div class="cardacts fsetfoot"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button> <div class="cardacts fsetfoot"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`, false, 'fset'); <button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`, false, 'fset');
@@ -722,8 +728,11 @@ 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'); // Sent only when changed, and not while the OPML decides it.
if(!inall.disabled&&inall.checked===!!f.hide_from_all) patch.hide_from_all=!inall.checked; for(const [id,key] of [['#sinall','hide_from_all'],['#sinsig','hide_from_signals']] as const){
const x=document.querySelector<HTMLInputElement>(id);
if(x&&!x.disabled&&x.checked===!!f[key]) patch[key]=!x.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

@@ -35,9 +35,10 @@ 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 /// Whether a feed's items are in All Subscriptions, or in Signals: not when it, or the OPML or
/// from, is hidden from it (#288). The server leaves out the same. /// creator it came from, is hidden from them (#288, #289). 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)); const shownIn=(f: Feed, key: 'hide_from_all'|'hide_from_signals')=>!f[key]&&!(f.group&&S.feeds.some(g=>g.id===f.group&&g[key]));
const inAll=(f: Feed)=>shownIn(f,'hide_from_all');
/// All Subscriptions' unread count. /// All Subscriptions' unread count.
const unreadInAll=()=>S.feeds.reduce((n,f)=>n+(inAll(f)?f.unread||0:0),0); 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

View File

@@ -39,6 +39,8 @@ interface Feed {
kept: boolean; kept: boolean;
/** Left out of All Subscriptions by its own setting; a show is also when its OPML is (#288). */ /** Left out of All Subscriptions by its own setting; a show is also when its OPML is (#288). */
hide_from_all: boolean; hide_from_all: boolean;
/** The same for Signals (#289). */
hide_from_signals: boolean;
private: boolean; private: boolean;
private_by_address: boolean; private_by_address: boolean;
} }