diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c49e44..bf900ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- The feed list updates just the feed that changed, as a scan checks it or you read an item, instead of reloading the whole list. - A scan fetches several feeds at once, so a refresh no longer waits on every site in turn. - An open page reloads the feed list only when a scan has checked something, not every minute. - Adding an address checks it first: a feed is added, a web page adds the feed it links, and anything else is refused with the reason, instead of being added and failing on every check. diff --git a/src/db.rs b/src/db.rs index b934151..7363ad3 100644 --- a/src/db.rs +++ b/src/db.rs @@ -338,8 +338,14 @@ impl Db { /// What the feed list shows of every feed, for one person, in five queries whatever the /// number of feeds. Asked feed by feed (feed_summary, http_state, blocklist, unread_count) /// it was six round trips a feed, about 950 for 160 feeds and 320 ms a page load (#94). - pub async fn feed_list(&self, user_id: i64) -> Result> { + pub async fn feed_list( + &self, + user_id: i64, + // One feed only, for the row a live update sends (`web::feed_rows`). + only: Option<&str>, + ) -> Result> { use std::collections::HashMap; + let one = || sea_orm::Value::from(only.map(str::to_owned)); let counts = |sql: &'static str, args: Vec| async move { self.rows(sql, args) .await? @@ -347,20 +353,29 @@ impl Db { .map(|r| Ok((r.try_get::("", "feed_id")?, r.try_get::("", "n")?))) .collect::>>() }; - let entries = counts("SELECT feed_id, count(*) AS n FROM entries GROUP BY feed_id", vec![]).await?; - let downloaded = - counts("SELECT feed_id, count(*) AS n FROM enclosures WHERE path IS NOT NULL GROUP BY feed_id", vec![]) - .await?; + let entries = counts( + "SELECT feed_id, count(*) AS n FROM entries + WHERE (CAST($1 AS TEXT) IS NULL OR feed_id = $1) GROUP BY feed_id", + vec![one()], + ) + .await?; + let downloaded = counts( + "SELECT feed_id, count(*) AS n FROM enclosures + WHERE path IS NOT NULL AND (CAST($1 AS TEXT) IS NULL OR feed_id = $1) GROUP BY feed_id", + vec![one()], + ) + .await?; let unread = counts( "SELECT e.feed_id, count(*) AS n FROM entries e JOIN subscriptions sub ON sub.user_id = $1 AND sub.feed_id = e.feed_id LEFT JOIN entry_state s ON s.user_id = $1 AND s.feed_id = e.feed_id AND s.guid = e.guid WHERE NOT coalesce(s.read, false) + AND (CAST($2 AS TEXT) IS NULL OR e.feed_id = $2) AND NOT EXISTS (SELECT 1 FROM hidden h WHERE h.user_id = $1 AND h.feed_id = e.feed_id AND h.guid = e.guid) GROUP BY e.feed_id", - vec![user_id.into()], + vec![user_id.into(), one()], ) .await?; let mut blocked: HashMap> = blocklists::Entity::find() @@ -370,7 +385,11 @@ impl Db { .into_iter() .map(|b| (b.feed_id, keywords(Some(b.words)).unwrap_or_default())) .collect(); - Ok(feeds::Entity::find() + let mut rows = feeds::Entity::find(); + if let Some(id) = only { + rows = rows.filter(feeds::Column::Id.eq(id)); + } + Ok(rows .all(&self.orm) .await? .into_iter() @@ -1964,7 +1983,10 @@ mod tests { INSERT INTO blocklists (user_id, feed_id, words) VALUES (1,'g','[\"spoiler\"]');", ).await .unwrap(); - let list = db.feed_list(1).await.unwrap(); + let list = db.feed_list(1, None).await.unwrap(); + let g = db.feed_list(1, Some("g")).await.unwrap(); + assert_eq!(g.keys().collect::>(), ["g"]); + assert_eq!((g["g"].unread, g["g"].summary.entries, g["g"].blocked.clone()), (1, 1, vec!["spoiler".to_string()])); for id in ["f", "g"] { let one = &list[id]; let s = db.feed_summary(id).await.unwrap(); diff --git a/src/web.rs b/src/web.rs index 616f673..d9484b2 100644 --- a/src/web.rs +++ b/src/web.rs @@ -731,6 +731,12 @@ async fn feeds( State(state): State, user: crate::db::User, ) -> Result>, ApiError> { + Ok(Json(feed_rows(&state, &user, None).await?)) +} + +/// The feed list as this person sees it, or with `only` the one row for that feed: what a live +/// update sends after the feed changes, so the page redraws a row instead of reloading the list. +async fn feed_rows(state: &WebState, user: &crate::db::User, only: Option<&str>) -> anyhow::Result> { let cfg = state.ctx.cfg(); // Config entries plus the feeds derived from OPML subscriptions -- the catalogue. // What comes back is only the part of it this person subscribes to. @@ -744,9 +750,9 @@ async fn feeds( .collect(); let counts = state.ctx.db.subscriber_counts().await?; let pinned = state.ctx.db.pinned_feeds(user.id).await?; - let mut listed = state.ctx.db.feed_list(user.id).await?; + let mut listed = state.ctx.db.feed_list(user.id, only).await?; let mut out = Vec::with_capacity(mine.len()); - for sub in &subs { + for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id)) { let (id, feed) = (&sub.id, &sub.cfg); // In a group, what you have not set on the feed comes from your settings on the group, // the same fallback the scanner uses (`Db::subscribers`). @@ -809,7 +815,7 @@ async fn feeds( pinned: pinned.contains(id), }); } - Ok(Json(out)) + Ok(out) } // ---- popular on this server ---- @@ -1523,7 +1529,7 @@ async fn set_flags( Path((feed_id, guid)): Path<(String, String)>, user: crate::db::User, Json(body): Json, -) -> Result { +) -> Result>, ApiError> { use crate::db::EntryFlag; if let Some(v) = body.read { state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Read, v).await?; @@ -1531,7 +1537,9 @@ async fn set_flags( if let Some(v) = body.flagged { state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Flagged, v).await?; } - Ok(StatusCode::NO_CONTENT) + // The feed's row with its new unread count, for the page to put in place of the old one + // rather than reloading the whole list after every item read. + Ok(Json(feed_rows(&state, &user, Some(&feed_id)).await?.into_iter().next())) } /// Downloads one enclosure now. This cannot be "requeue and scan": a scan takes the @@ -1644,23 +1652,45 @@ async fn fetch_now( } /// The same broadcast the socket clients read, as server-sent events. -async fn events(State(state): State) -> Sse>> { +async fn events( + State(state): State, + user: crate::db::User, +) -> Sse>> { + let rx = state.events.subscribe(); // A client that falls behind skips what it missed rather than being cut off. - let stream = futures_util::stream::unfold(state.events.subscribe(), |mut rx| async move { + let stream = futures_util::stream::unfold((rx, state, user), |(mut rx, state, user)| async move { loop { match rx.recv().await { Ok(ev) => { - if let Ok(data) = serde_json::to_string(&ev) { - let ev = Ok::<_, std::convert::Infallible>(SseEvent::default().data(data)); - return Some((ev, rx)); + let Ok(data) = serde_json::to_string(&ev) else { continue }; + let mut out = vec![Ok::<_, std::convert::Infallible>(SseEvent::default().data(data))]; + // A feed that changed goes out as this person's row for it, which the page + // puts in place of the old one: it reloaded the whole list after each. + if let Some(feed) = changed_feed(&ev) + && let Ok(rows) = feed_rows(&state, &user, Some(feed)).await + && let Some(row) = rows.first() + && let Ok(data) = serde_json::to_string(&serde_json::json!({ "ev": "feed_row", "row": row })) + { + out.push(Ok(SseEvent::default().data(data))); } + return Some((futures_util::stream::iter(out), (rx, state, user))); } Err(broadcast::error::RecvError::Lagged(_)) => {} Err(broadcast::error::RecvError::Closed) => return None, } } }); - Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::default()) + Sse::new(futures_util::StreamExt::flatten(stream)).keep_alive(axum::response::sse::KeepAlive::default()) +} + +/// The feed an event changed what the list shows of: its counts, error or last check. Not the +/// routine skip of a feed not due, dozens a minute that change nothing. +fn changed_feed(ev: &Event) -> Option<&str> { + match ev { + Event::FeedDone { feed, .. } | Event::FeedError { feed, .. } | Event::DownloadDone { feed, .. } => Some(feed), + Event::FeedSkip { feed, reason } if !reason.starts_with("not due") => Some(feed), + _ => None, + } } /// Audio, served by ServeFile so Range requests work and the player can seek. diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index d36fbed..8f178e1 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -940,6 +940,22 @@ test('adding a page adds the feed it links, and a page with no feed is refused', await expect(page.locator('.feed', { hasText: 'Linked Site' })).toBeVisible({ timeout: 20_000 }); }); +test('reading an item updates its feed\'s count without reloading the list', async ({ page }) => { + const unread = page.locator('.feed:not(.group)', { has: page.locator('.badge:not(.zero)') }).first(); + await expect(unread).toBeVisible({ timeout: 20_000 }); + const id = await unread.getAttribute('data-id'); + const badge = page.locator(`.feed[data-id="${id}"] .badge`); + const before = Number(await badge.textContent()); + await unread.click(); + await page.locator('.tabs button', { hasText: 'Unread' }).click(); + await expect(page.locator('.ep').first()).toBeVisible(); + const lists = []; + page.on('request', r => { if (new URL(r.url()).pathname === '/api/feeds') lists.push(r.url()); }); + await page.locator('.ep').first().click(); // opening an item reads it + await expect(badge).toHaveText(String(before - 1)); + expect(lists, 'the row came back with the read, not by reloading the list').toEqual([]); +}); + test('a deleted file looks as if it was never downloaded', async ({ page }) => { // Other people subscribe to Picture Blog by now, so both prompts come; take them. page.on('dialog', d => d.accept()); diff --git a/web/src/events.ts b/web/src/events.ts index 7c33a57..49a9cad 100644 --- a/web/src/events.ts +++ b/web/src/events.ts @@ -3,7 +3,6 @@ let sse; function connect(){ sse=new EventSource('/api/events'); const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; }; - const refreshFeeds=soon(()=>loadFeeds(true)); const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); }); // Every scan's events reach everyone; only this person's feeds are theirs to show or refresh. const mine=id=>S.feeds.some(f=>f.id===id); @@ -22,7 +21,7 @@ function connect(){ // Said only for a file on screen, as one downloaded by hand is: the scheduled downloads of // everyone's feeds used to announce themselves to everyone. if(bar){ bar.classList.remove('live'); toast('Downloaded '+ev.path.split('/').pop()); } - refreshEntries(); refreshFeeds(); + refreshEntries(); } else if(ev.ev==='download_error'){ const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`); @@ -35,14 +34,19 @@ function connect(){ else if(ev.ev==='feed_done'){ setScanning(ev.feed,false); if(!mine(ev.feed)) return; - refreshFeeds(); if(ev.feed===S.feed||S.feed===':all') refreshEntries(); + if(ev.feed===S.feed||S.feed===':all') refreshEntries(); } // No toast: a scan of every feed raised one per failure, to everyone. The feed list's // red ! marks the feed instead, and its page says why. - else if(ev.ev==='feed_error'){ setScanning(ev.feed,false); if(mine(ev.feed)) refreshFeeds(); } + else if(ev.ev==='feed_error') setScanning(ev.feed,false); + // The server sends a feed's new row after anything changes it (counts, error, last check), + // only to those who subscribe; the page used to reload the whole list after each event. + else if(ev.ev==='feed_row') patchFeed(ev.row); // Only a scan that checked something: the scheduler scans every minute, due or not, and // every open page reloaded the whole list each time (#103). - else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); if(ev.feeds){ refreshFeeds(); refreshEntries(); } } + // Rows came as feed_row events. ponytail: a feed an OPML drops stays in the list until the + // page reloads; reload the list here when a scan synced an OPML if that ever matters. + else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); if(ev.feeds) refreshEntries(); } }; sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); }; } diff --git a/web/src/feeds.ts b/web/src/feeds.ts index b16def3..7cb6e09 100644 --- a/web/src/feeds.ts +++ b/web/src/feeds.ts @@ -1,4 +1,13 @@ /* ---------------- feeds ---------------- */ +/// One feed's row from the server, in place of the old one (or added, for a feed an OPML just +/// listed). A scan sends dozens; the list is redrawn once a frame, not once each. +let rowsQueued=0; +function patchFeed(row){ + const i=S.feeds.findIndex(f=>f.id===row.id); + if(i<0) S.feeds.push(row); else S.feeds[i]=row; + if(!rowsQueued) rowsQueued=requestAnimationFrame(()=>{ rowsQueued=0; renderFeeds(); }); +} + async function loadFeeds(keepSel?: boolean){ S.feeds = await api('/api/feeds'); // Only for a hint in a feed's settings, so once, on the page's first load, not on every reload diff --git a/web/src/items.ts b/web/src/items.ts index 9dc1889..d8dad5c 100644 --- a/web/src/items.ts +++ b/web/src/items.ts @@ -153,7 +153,7 @@ function setRead(e,read){ const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w); return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`, {method:'POST',body:JSON.stringify({read})}) - .then(()=>{ w.done=performance.now(); loadFeeds(true); }); + .then(row=>{ w.done=performance.now(); if(row) patchFeed(row); }); } /// Opening an item is reading it. The row is redrawn where it stands rather than the list