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:
@@ -34,6 +34,8 @@ pub enum Event {
|
|||||||
TorrentDeferred { feed: String, url: String },
|
TorrentDeferred { feed: String, url: String },
|
||||||
/// DeadAir finished a file's copy (#171), so a page showing it as in progress can stop.
|
/// 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 },
|
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 },
|
Reaped { path: String, bytes: u64 },
|
||||||
/// Terminal: a client that asked for work stops reading here.
|
/// Terminal: a client that asked for work stops reading here.
|
||||||
ScanDone {
|
ScanDone {
|
||||||
@@ -90,6 +92,7 @@ impl Event {
|
|||||||
Event::FeedStart { feed } => format!("{feed}: checking"),
|
Event::FeedStart { feed } => format!("{feed}: checking"),
|
||||||
Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"),
|
Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"),
|
||||||
Event::DeadairDone { feed, saved_s, .. } => format!("{feed}: DeadAir found the silences, {saved_s} s shorter"),
|
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)"),
|
Event::ScanDone { feeds, .. } => format!("scan complete, {feeds} feed(s)"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ async fn pass(ctx: &Ctx) -> Result<bool> {
|
|||||||
let span = tracing::info_span!("signals", entries = items.len());
|
let span = tracing::info_span!("signals", entries = items.len());
|
||||||
async {
|
async {
|
||||||
let mut refused = HashSet::new();
|
let mut refused = HashSet::new();
|
||||||
|
let mut hits_for = std::collections::BTreeMap::<i64, usize>::new();
|
||||||
for item in &items {
|
for item in &items {
|
||||||
if refused.contains(&item.user_id) {
|
if refused.contains(&item.user_id) {
|
||||||
continue;
|
continue;
|
||||||
@@ -60,6 +61,7 @@ async fn pass(ctx: &Ctx) -> Result<bool> {
|
|||||||
Ok(verdicts) => {
|
Ok(verdicts) => {
|
||||||
let hits = verdicts.iter().filter(|(_, h)| *h).count();
|
let hits = verdicts.iter().filter(|(_, h)| *h).count();
|
||||||
if hits > 0 {
|
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");
|
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?;
|
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"),
|
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();
|
let some_refused = !refused.is_empty();
|
||||||
ctx.signals_refused.lock().unwrap().extend(refused);
|
ctx.signals_refused.lock().unwrap().extend(refused);
|
||||||
// A pass that only failed would otherwise go straight round again on the same items.
|
// A pass that only failed would otherwise go straight round again on the same items.
|
||||||
|
|||||||
@@ -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,
|
await expect.poll(async () => (await (await page.request.get(`/api/entries/${encodeURIComponent(feed)}/sig-1`)).json()).read,
|
||||||
{ timeout: 5_000 }).toBe(true);
|
{ timeout: 5_000 }).toBe(true);
|
||||||
expect((await signals()).find(s => s.id === sg.id).unread).toBe(0);
|
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);
|
expect((await page.request.get(`/api/signals/${sg.id + 1000}/entries`)).status(), 'no such Signal').toBe(404);
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ function connect(){
|
|||||||
if(bar) bar.classList.remove('live');
|
if(bar) bar.classList.remove('live');
|
||||||
toast('Download failed: '+ev.msg,true); refreshEntries();
|
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).
|
// 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_start') setScanning(ev.feed,true);
|
||||||
else if(ev.ev==='feed_skip') setScanning(ev.feed,false);
|
else if(ev.ev==='feed_skip') setScanning(ev.feed,false);
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ function setRead(e: Entry,read: boolean){
|
|||||||
const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w);
|
const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w);
|
||||||
return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
|
return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
|
||||||
{method:'POST',body:JSON.stringify({read})})
|
{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
|
/// Opening an item is reading it. The row is redrawn where it stands rather than the list
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
/// Signals (#254): feeds you make from your own subscriptions, holding the items TypeSafe's Jev
|
/// 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>`.
|
/// 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.
|
/// The Signal's id in a place's id, or null for anything else.
|
||||||
function signalId(view: string|null){
|
function signalId(view: string|null){
|
||||||
const m=/^:signal:(\d+)$/.exec(view||'');
|
const m=/^:signal:(\d+)$/.exec(view||'');
|
||||||
|
|||||||
Reference in New Issue
Block a user