From 1ae91170fd272da9db5e52d7cb84097c61a03f7f Mon Sep 17 00:00:00 2001 From: rays Date: Fri, 16 Oct 2026 17:54:42 +0000 Subject: [PATCH] Signal counts kept current on the page: after a read, and when Jev picks something out (#258) The page fetched the Signals' unread counts only with the feed list, so reading an item, here or in its own feed, left its Signal's badge as it was, and so did Jev adding one. The Apple Signal said 2 unread over a page with none. The page now fetches them again after a read or unread lands, and on a new signal_hits event, which the judge sends once a pass for each person it found items for. Co-Authored-By: Claude Opus 5.5 --- crates/ipx/src/ipc.rs | 3 +++ crates/ipx/src/signals.rs | 6 ++++++ tests/ui/app.spec.js | 2 ++ web/src/events.ts | 6 ++++++ web/src/items.ts | 2 +- web/src/signals.ts | 6 ++++++ 6 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/ipx/src/ipc.rs b/crates/ipx/src/ipc.rs index 2bf1427..a3643b3 100644 --- a/crates/ipx/src/ipc.rs +++ b/crates/ipx/src/ipc.rs @@ -34,6 +34,8 @@ pub enum Event { TorrentDeferred { feed: String, url: String }, /// DeadAir finished a file's copy (#171), so a page showing it as in progress can stop. DeadairDone { feed: String, enclosure: i64, saved_s: i64 }, + /// Jev found items for someone's Signals (#258), so their page fetches the counts again. + SignalHits { user: i64, hits: usize }, Reaped { path: String, bytes: u64 }, /// Terminal: a client that asked for work stops reading here. ScanDone { @@ -90,6 +92,7 @@ impl Event { Event::FeedStart { feed } => format!("{feed}: checking"), Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"), Event::DeadairDone { feed, saved_s, .. } => format!("{feed}: DeadAir found the silences, {saved_s} s shorter"), + Event::SignalHits { hits, .. } => format!("{hits} item(s) picked out for Signals"), Event::ScanDone { feeds, .. } => format!("scan complete, {feeds} feed(s)"), }) } diff --git a/crates/ipx/src/signals.rs b/crates/ipx/src/signals.rs index 27f3b83..6af62ed 100644 --- a/crates/ipx/src/signals.rs +++ b/crates/ipx/src/signals.rs @@ -52,6 +52,7 @@ async fn pass(ctx: &Ctx) -> Result { let span = tracing::info_span!("signals", entries = items.len()); async { let mut refused = HashSet::new(); + let mut hits_for = std::collections::BTreeMap::::new(); for item in &items { if refused.contains(&item.user_id) { continue; @@ -60,6 +61,7 @@ async fn pass(ctx: &Ctx) -> Result { Ok(verdicts) => { let hits = verdicts.iter().filter(|(_, h)| *h).count(); if hits > 0 { + *hits_for.entry(item.user_id).or_default() += hits; tracing::info!(ev = "signal", user = item.user_id, feed = %item.feed_id, new = hits, "an item fits a Signal"); } ctx.db.record_verdicts(&item.feed_id, &item.guid, &verdicts).await?; @@ -80,6 +82,10 @@ async fn pass(ctx: &Ctx) -> Result { Err(e) => tracing::warn!(user = item.user_id, feed = %item.feed_id, error = %format!("{e:#}"), "Jev could not judge an item"), } } + // Once a pass, not once an item: a page fetches its counts again on each. + for (user, hits) in hits_for { + ctx.out.emit(crate::ipc::Event::SignalHits { user, hits }); + } let some_refused = !refused.is_empty(); ctx.signals_refused.lock().unwrap().extend(refused); // A pass that only failed would otherwise go straight round again on the same items. diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index d3e8dac..70c6d58 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -2099,6 +2099,8 @@ test('a Signal holds the items Jev says fit its keywords, read where they are re await expect.poll(async () => (await (await page.request.get(`/api/entries/${encodeURIComponent(feed)}/sig-1`)).json()).read, { timeout: 5_000 }).toBe(true); expect((await signals()).find(s => s.id === sg.id).unread).toBe(0); + // And the sidebar says so without a reload: it kept saying 1 (#258). + await expect(row.locator('.badge')).toHaveText('0'); expect((await page.request.get(`/api/signals/${sg.id + 1000}/entries`)).status(), 'no such Signal').toBe(404); diff --git a/web/src/events.ts b/web/src/events.ts index ea296a7..d6254c4 100644 --- a/web/src/events.ts +++ b/web/src/events.ts @@ -50,6 +50,12 @@ function connect(){ if(bar) bar.classList.remove('live'); toast('Download failed: '+ev.msg,true); refreshEntries(); } + // Jev picked items out for someone's Signals: theirs are fetched again, and the one open. + else if(ev.ev==='signal_hits'){ + if(!S.signals.length) return; + refreshSignals(); + if(signalOf(S.feed)) refreshEntries(); + } // A spinner on the feed's row while it is checked, in place of a toast per feed (issue #37). else if(ev.ev==='feed_start') setScanning(ev.feed,true); else if(ev.ev==='feed_skip') setScanning(ev.feed,false); diff --git a/web/src/items.ts b/web/src/items.ts index 7486f6e..4417aee 100644 --- a/web/src/items.ts +++ b/web/src/items.ts @@ -275,7 +275,7 @@ function setRead(e: Entry,read: boolean){ 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(row=>{ w.done=performance.now(); if(row) patchFeed(row); }); + .then(row=>{ w.done=performance.now(); if(row) patchFeed(row); if(S.signals.length) refreshSignals(); }); } /// Opening an item is reading it. The row is redrawn where it stands rather than the list diff --git a/web/src/signals.ts b/web/src/signals.ts index fd3b3df..3ae6dfe 100644 --- a/web/src/signals.ts +++ b/web/src/signals.ts @@ -2,6 +2,12 @@ /// Signals (#254): feeds you make from your own subscriptions, holding the items TypeSafe's Jev /// says are about the keywords you gave. A Signal is a place in the sidebar, `:signal:`. +/// The Signals' counts again, which nothing else brings: reading an item, here or in its feed, +/// and Jev picking one out both change them (#258). Once a burst has passed. +const refreshSignals=(()=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(()=>{ + api('/api/signals').then((r: Signal[])=>{ S.signals=r; renderFeeds(); }).catch(()=>{}); +},400); }; })(); + /// The Signal's id in a place's id, or null for anything else. function signalId(view: string|null){ const m=/^:signal:(\d+)$/.exec(view||'');