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