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).
|
- 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).
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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!();
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
38
web/app.css
38
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 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%)}}
|
||||||
|
|||||||
@@ -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')}${
|
||||||
|
signalsOn()?place('sinsig','hide_from_signals',`<span class="fsig">${tile('signals')}</span>`,'Signals'):''}</div>
|
||||||
|
<p class="cap">${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.`}</p>
|
||||||
${isGroup?'':`<div class="ffield"><label class="flabel" for="sblockin">Hide items that mention</label>
|
${isGroup?'':`<div class="ffield"><label class="flabel" for="sblockin">Hide items that mention</label>
|
||||||
<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 everywhere and not downloaded for you.</p></div>`}`)}
|
||||||
with what Settings hides everywhere.</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="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':''}${
|
|
||||||
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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user