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 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 17:54:42 +00:00
parent 20f53183ac
commit 1ae91170fd
6 changed files with 24 additions and 1 deletions

View File

@@ -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)"),
})
}

View File

@@ -52,6 +52,7 @@ async fn pass(ctx: &Ctx) -> Result<bool> {
let span = tracing::info_span!("signals", entries = items.len());
async {
let mut refused = HashSet::new();
let mut hits_for = std::collections::BTreeMap::<i64, usize>::new();
for item in &items {
if refused.contains(&item.user_id) {
continue;
@@ -60,6 +61,7 @@ async fn pass(ctx: &Ctx) -> Result<bool> {
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<bool> {
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.

View File

@@ -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);

View File

@@ -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);

View File

@@ -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

View File

@@ -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:<id>`.
/// 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||'');