From b54b47897b67af61536f9b384598f0a7cf377670 Mon Sep 17 00:00:00 2001
From: rays
Date: Mon, 19 Oct 2026 14:37:49 +0000
Subject: [PATCH] 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
---
crates/ipx/CHANGELOG.md | 1 +
crates/ipx/src/db.rs | 126 ++++++++++++++++++++++++++++++++-------
crates/ipx/src/entity.rs | 3 +
crates/ipx/src/web.rs | 12 +++-
web/CHANGELOG.md | 5 ++
web/app.css | 38 ++++++++----
web/src/dialogs.ts | 61 +++++++++++--------
web/src/feeds.ts | 7 ++-
web/src/types.ts | 2 +
9 files changed, 190 insertions(+), 65 deletions(-)
diff --git a/crates/ipx/CHANGELOG.md b/crates/ipx/CHANGELOG.md
index c503996..015a96d 100644
--- a/crates/ipx/CHANGELOG.md
+++ b/crates/ipx/CHANGELOG.md
@@ -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).
diff --git a/crates/ipx/src/db.rs b/crates/ipx/src/db.rs
index e6b86f8..22c257f 100644
--- a/crates/ipx/src/db.rs
+++ b/crates/ipx/src/db.rs
@@ -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::>().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> {
- 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, std::collections::HashSet)> {
+ 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 {
+ 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 {
+ 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 {
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::>()
+ };
+ 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::::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.
diff --git a/crates/ipx/src/entity.rs b/crates/ipx/src/entity.rs
index d2e8e67..dfd6410 100644
--- a/crates/ipx/src/entity.rs
+++ b/crates/ipx/src/entity.rs
@@ -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!();
diff --git a/crates/ipx/src/web.rs b/crates/ipx/src/web.rs
index 3d8d390..dc0dbb8 100644
--- a/crates/ipx/src/web.rs
+++ b/crates/ipx/src/web.rs
@@ -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,
/// Left out of All Subscriptions, or on an OPML every show in it (#288).
hide_from_all: Option,
+ /// Left out of Signals and never judged for them, on an OPML every show in it (#289).
+ hide_from_signals: 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")]
@@ -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)
{
diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md
index 9da6c08..cc4b1c6 100644
--- a/web/CHANGELOG.md
+++ b/web/CHANGELOG.md
@@ -11,9 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [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
- 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).
- 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/app.css b/web/app.css
index fcd132b..24ef5e1 100644
--- a/web/app.css
+++ b/web/app.css
@@ -1095,14 +1095,30 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
/* ---------- 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
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}
.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 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)}
-.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)}
/* 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. */
@@ -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::after{transform:translateX(16px)}
.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
- when it is off. In --line, not --edge, so the box shows on a phone, which draws no outlines. The
- 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}
+/* What only matters while it downloads on its own folds away when it is off: the rows in a grid of
+ one row, 1fr open and 0fr shut, so the fold has a height to move. */
/* 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}
.fdeps{display:grid;grid-template-rows:1fr;transition:grid-template-rows .22s ease}
.fdeps.off{grid-template-rows:0fr}
.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. */
.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}
@@ -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 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}
+.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. */
.dpause{display:grid;gap:6px;margin-top:6px}
.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)}
.dpause:has(input:disabled){opacity:.45}
.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}
.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. */
@@ -1398,6 +1411,7 @@ body:has(#player.on) #status{padding-bottom:3px}
.fsetbody{padding:20px 16px 24px}
.card.fset .fsetfoot{padding:12px 16px calc(12px + var(--safe-b))}
.fsethead .art{width:64px;height:64px;border-radius:14px}
+ .fsec h4{font-size:14px}
.fsethead p,.wcap,.cap{font-size:14px}
.switch,.flabel{font-size:17px}
.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}
.chip{background:var(--panel);font-size:15px;padding:5px 4px 5px 12px}
.chipx{width:26px;height:26px}
- .fdl,.field.boxed{border-radius:14px}
+ .field.boxed{border-radius:14px}
.toast{border-radius:14px}
}
@keyframes sheet{from{transform:translateY(100%)}}
diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts
index 8ac4007..a89e538 100644
--- a/web/src/dialogs.ts
+++ b/web/src/dialogs.ts
@@ -578,8 +578,6 @@ 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).
@@ -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.`
: 'Your settings for this feed. Everyone else who reads it keeps their own.';
const sw=(input: string, text: string, cls='')=>``;
+ // 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)=>`
${title}
${body}
`;
+ 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(``,
+ `${icon}${title}`,' fplace');
+ };
+ const listHides=f.group?S.feeds.find(g=>g.id===f.group&&(g.hide_from_all||g.hide_from_signals)):undefined;
openModal(`
${artHTML(f.image,name)}
${esc(name)}
${whose}
-
-
-
- ${newUrl?'
The feed says it has moved here. Save to follow it.
Any one of these will do, and a phrase needs all its words. Empty takes everything.
${sw(``,'Include explicit items')}
-
-
${sw(``,'DeadAir',' lead')}
-
Cuts the dead air: long silences in ${isGroup?'these shows':'this show'} shrink to a beat, so
- episodes finish sooner and every voice keeps its own pace. Leave it off for stories whose pauses
- are part of the telling.
Shrinks long silences in ${these} to a beat, so episodes finish sooner. Leave it off
+ for stories whose pauses are part of the telling.
-
BriskRelaxed
-
The silence kept where a long one is cut. More is easier on the ear; less saves more time.
- ${isGroup?'':`
-
${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.`}
${listHides
+ ?`Off where ${esc(listHides.title||listHides.id)} is off; its settings bring them back.`
+ :`Off leaves ${these} out of that list and its count. ${isGroup?'Their':'Its'} own page still has everything.`}
+ ${isGroup?'':`
+
${chipsHTML(f.blocked||[],'sblockin')}
+
Whole words or phrases, hidden everywhere and not downloaded for you.
${f.private_by_address
?'Its address or login shows it is someone’s own, so it is never shared with the directory.'
:f.private&&!admin
?'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.`}
+ :`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.`}
`)}
+ ${sect('Address',`
+
+ ${newUrl?'
The feed says it has moved here. Save to follow it.
':''}`)}
`, 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.
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;
+ // Sent only when changed, and not while the OPML decides it.
+ for(const [id,key] of [['#sinall','hide_from_all'],['#sinsig','hide_from_signals']] as const){
+ const x=document.querySelector(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
// 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/feeds.ts b/web/src/feeds.ts
index 2052433..dfc657e 100644
--- a/web/src/feeds.ts
+++ b/web/src/feeds.ts
@@ -35,9 +35,10 @@ const VIEWS: Record!f.hide_from_all&&!(f.group&&S.feeds.some(g=>g.id===f.group&&g.hide_from_all));
+/// Whether a feed's items are in All Subscriptions, or in Signals: not when it, or the OPML or
+/// creator it came from, is hidden from them (#288, #289). The server leaves out the same.
+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.
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
diff --git a/web/src/types.ts b/web/src/types.ts
index 5ce51b7..39173f9 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -39,6 +39,8 @@ interface Feed {
kept: boolean;
/** Left out of All Subscriptions by its own setting; a show is also when its OPML is (#288). */
hide_from_all: boolean;
+ /** The same for Signals (#289). */
+ hide_from_signals: boolean;
private: boolean;
private_by_address: boolean;
}