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(``, + `${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.

':''}
-
${sw(``,'Download automatically',' lead')} + ${sect('Downloads',`${sw(``,'Download automatically',' lead')}
${ [['default',`Default (${globalMax||'all'})`],['newest','Newest'],['all','All']].map(([v,t])=> @@ -608,31 +613,32 @@ function settingsModal(f: Feed, newUrl?: string){
${chipsHTML(f.keywords,'skwin')}

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.

+
`)} + ${sect('Playback',`${sw(``,'DeadAir',' lead')} +

Shrinks long silences in ${these} to a beat, so episodes finish sooner. Leave it off + for stories whose pauses are part of the telling.

- -

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.`}

-
${sw(`BriskRelaxed
`)} + ${sect('Where it shows',`
${place('sinall','hide_from_all',ICON.all,'All Subscriptions')}${ + signalsOn()?place('sinsig','hide_from_signals',`${tile('signals')}`,'Signals'):''}
+

${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.

`}`)} + ${sect('Directory',`${sw(``,'Private',' lead')}

${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