A spinner on the feed being checked, not toasts; check only your own feeds
The scan's events reach everyone, so every browser showed "<feed>: N new" and "Scanning…" toasts, and refreshed, for everyone's feeds. Now a feed's row, and its folder's, carries a spinner between feed_start and its done, skip or error; the list refreshes only for the reader's own feeds; the scan toasts are gone, and "Downloaded" is said only for a file on screen. "Check every feed" from the web UI sent a scan of every feed on the server. Command::Fetch takes an optional `feeds` list -- those feeds and the feeds inside any OPML among them -- and the web fills it with the asker's subscriptions. The schedule and the CLI send none, meaning every feed. Closes #37. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- A feed being checked shows a small spinner at the end of its row (and its folder's), instead of
|
||||||
|
toasts: no more "Scanning…" or "1 new" pop-ups, and none at all for feeds you do not read. A
|
||||||
|
"Downloaded" toast is only for a file on your screen.
|
||||||
|
- "Check every feed" checks every feed you subscribe to, not every feed on the server.
|
||||||
|
|
||||||
## [0.8.0] - 2026-09-18
|
## [0.8.0] - 2026-09-18
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ pub enum Command {
|
|||||||
feed: Option<String>,
|
feed: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
force: bool,
|
force: bool,
|
||||||
|
/// Only these feeds, and the feeds inside any of them that is an OPML: "check every feed"
|
||||||
|
/// from the web UI is every feed of the person asking, not of everyone (issue #37).
|
||||||
|
/// Empty is every feed, as the schedule and the CLI mean it.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
feeds: Vec<String>,
|
||||||
},
|
},
|
||||||
Reap {
|
Reap {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -301,10 +306,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn commands_parse_from_the_wire_form() {
|
fn commands_parse_from_the_wire_form() {
|
||||||
let got: Command = serde_json::from_str(r#"{"cmd":"fetch"}"#).unwrap();
|
let got: Command = serde_json::from_str(r#"{"cmd":"fetch"}"#).unwrap();
|
||||||
assert!(matches!(got, Command::Fetch { feed: None, force: false }));
|
assert!(matches!(got, Command::Fetch { feed: None, force: false, .. }));
|
||||||
|
|
||||||
let got: Command = serde_json::from_str(r#"{"cmd":"fetch","feed":"atp","force":true}"#).unwrap();
|
let got: Command = serde_json::from_str(r#"{"cmd":"fetch","feed":"atp","force":true}"#).unwrap();
|
||||||
assert!(matches!(got, Command::Fetch { feed: Some(f), force: true } if f == "atp"));
|
assert!(matches!(got, Command::Fetch { feed: Some(f), force: true, .. } if f == "atp"));
|
||||||
|
|
||||||
let got: Command = serde_json::from_str(r#"{"cmd":"reap","dry_run":true}"#).unwrap();
|
let got: Command = serde_json::from_str(r#"{"cmd":"reap","dry_run":true}"#).unwrap();
|
||||||
assert!(matches!(got, Command::Reap { dry_run: true }));
|
assert!(matches!(got, Command::Reap { dry_run: true }));
|
||||||
|
|||||||
16
src/main.rs
16
src/main.rs
@@ -197,7 +197,7 @@ async fn main() -> Result<()> {
|
|||||||
// A daemon owns the state; don't have two processes downloading the same thing.
|
// A daemon owns the state; don't have two processes downloading the same thing.
|
||||||
let wire_cmd = match &cli.command {
|
let wire_cmd = match &cli.command {
|
||||||
Command::Fetch { feed, force } => {
|
Command::Fetch { feed, force } => {
|
||||||
Some(Cmd::Fetch { feed: feed.clone(), force: *force })
|
Some(Cmd::Fetch { feed: feed.clone(), force: *force, feeds: vec![] })
|
||||||
}
|
}
|
||||||
Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }),
|
Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }),
|
||||||
Command::Status => Some(Cmd::Status),
|
Command::Status => Some(Cmd::Status),
|
||||||
@@ -354,10 +354,10 @@ async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
|
|||||||
|
|
||||||
async fn run(ctx: &Arc<Ctx>, cmd: Cmd) -> Result<()> {
|
async fn run(ctx: &Arc<Ctx>, cmd: Cmd) -> Result<()> {
|
||||||
match cmd {
|
match cmd {
|
||||||
Cmd::Fetch { feed, force } => {
|
Cmd::Fetch { feed, force, feeds } => {
|
||||||
// Make room before pulling more down, as the original did per download.
|
// Make room before pulling more down, as the original did per download.
|
||||||
reap(ctx, false, false).await?;
|
reap(ctx, false, false).await?;
|
||||||
fetch(ctx, feed.as_deref(), force).await
|
fetch(ctx, feed.as_deref(), force, &feeds).await
|
||||||
}
|
}
|
||||||
Cmd::Reap { dry_run } => reap(ctx, dry_run, true).await,
|
Cmd::Reap { dry_run } => reap(ctx, dry_run, true).await,
|
||||||
Cmd::Download { enclosure } => download_one(ctx, enclosure).await,
|
Cmd::Download { enclosure } => download_one(ctx, enclosure).await,
|
||||||
@@ -510,7 +510,7 @@ async fn daemon(
|
|||||||
}
|
}
|
||||||
_ = ticker.tick() => {
|
_ = ticker.tick() => {
|
||||||
// Per-feed schedule and TTL decide what actually gets polled.
|
// Per-feed schedule and TTL decide what actually gets polled.
|
||||||
let job = run(&ctx, Cmd::Fetch { feed: None, force: false });
|
let job = run(&ctx, Cmd::Fetch { feed: None, force: false, feeds: vec![] });
|
||||||
if !until_stopped(&ctx, &rx_stop, job).await {
|
if !until_stopped(&ctx, &rx_stop, job).await {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -892,7 +892,8 @@ async fn reap(ctx: &Ctx, dry_run: bool, standalone: bool) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool) -> Result<()> {
|
/// `scope`, when not empty, narrows the scan to those feeds and the feeds inside any of them.
|
||||||
|
async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]) -> Result<()> {
|
||||||
let cfg = ctx.cfg();
|
let cfg = ctx.cfg();
|
||||||
let subs = subscriptions(ctx).await?;
|
let subs = subscriptions(ctx).await?;
|
||||||
if let Some(id) = only
|
if let Some(id) = only
|
||||||
@@ -903,7 +904,10 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool) -> Result<()> {
|
|||||||
|
|
||||||
let mut scanned = 0;
|
let mut scanned = 0;
|
||||||
let mut fresh: Vec<String> = vec![];
|
let mut fresh: Vec<String> = vec![];
|
||||||
for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id)) {
|
let in_scope = |s: &Sub| {
|
||||||
|
scope.is_empty() || scope.contains(&s.id) || s.cfg.group.as_ref().is_some_and(|g| scope.contains(g))
|
||||||
|
};
|
||||||
|
for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id) && in_scope(s)) {
|
||||||
let (id, feed_cfg) = (&sub.id, &sub.cfg);
|
let (id, feed_cfg) = (&sub.id, &sub.cfg);
|
||||||
let state = ctx.db.http_state(id).await?;
|
let state = ctx.db.http_state(id).await?;
|
||||||
|
|
||||||
|
|||||||
14
src/web.rs
14
src/web.rs
@@ -1239,7 +1239,7 @@ async fn add_feed(
|
|||||||
/// succeeded either way, so a daemon not taking commands is only logged.
|
/// succeeded either way, so a daemon not taking commands is only logged.
|
||||||
async fn scan_soon(state: &WebState, feed: Option<String>) {
|
async fn scan_soon(state: &WebState, feed: Option<String>) {
|
||||||
let force = feed.is_some();
|
let force = feed.is_some();
|
||||||
if state.cmds.send(Command::Fetch { feed, force }).await.is_err() {
|
if state.cmds.send(Command::Fetch { feed, force, feeds: vec![] }).await.is_err() {
|
||||||
tracing::warn!("could not queue a scan: the daemon is not accepting commands");
|
tracing::warn!("could not queue a scan: the daemon is not accepting commands");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1527,11 +1527,21 @@ struct FetchBody {
|
|||||||
|
|
||||||
async fn fetch_now(
|
async fn fetch_now(
|
||||||
State(state): State<WebState>,
|
State(state): State<WebState>,
|
||||||
|
user: crate::db::User,
|
||||||
Json(body): Json<FetchBody>,
|
Json(body): Json<FetchBody>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
// "Check every feed" is every feed this person reads, not everyone's (issue #37).
|
||||||
|
let feeds = if body.feed.is_some() {
|
||||||
|
vec![]
|
||||||
|
} else {
|
||||||
|
state.ctx.db.subscriptions_for(user.id).await?.into_iter().map(|s| s.feed_id).collect()
|
||||||
|
};
|
||||||
|
if body.feed.is_none() && feeds.is_empty() {
|
||||||
|
return Ok(StatusCode::ACCEPTED); // nothing of theirs to check; an empty list would mean all
|
||||||
|
}
|
||||||
state
|
state
|
||||||
.cmds
|
.cmds
|
||||||
.send(Command::Fetch { feed: body.feed, force: body.force })
|
.send(Command::Fetch { feed: body.feed, force: body.force, feeds })
|
||||||
.await
|
.await
|
||||||
.map_err(|_| anyhow::anyhow!("the daemon is not accepting commands"))?;
|
.map_err(|_| anyhow::anyhow!("the daemon is not accepting commands"))?;
|
||||||
Ok(StatusCode::ACCEPTED)
|
Ok(StatusCode::ACCEPTED)
|
||||||
|
|||||||
@@ -1299,3 +1299,43 @@ test('a pinned feed, even one from inside a folder, goes to the top of the list'
|
|||||||
await expect(page.locator('.feed.pinned')).toHaveCount(0);
|
await expect(page.locator('.feed.pinned')).toHaveCount(0);
|
||||||
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(1);
|
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a feed being checked shows a spinner on its row, and no toast', async ({ page }) => {
|
||||||
|
const row = page.locator('#feedlist .feed', { hasText: 'Test Show' });
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await page.evaluate(() => setScanning('test-show', true));
|
||||||
|
await expect(row).toHaveClass(/\bscanning\b/);
|
||||||
|
await page.evaluate(() => setScanning('test-show', false));
|
||||||
|
await expect(row).not.toHaveClass(/\bscanning\b/);
|
||||||
|
// Checking every feed says so on the rows, not in a toast (issue #37).
|
||||||
|
await page.locator('#scanAll').click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
await expect(page.locator('.toast', { hasText: /Scanning|Checking| new/ })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"check every feed" checks only the feeds of the person asking', async ({ browser }) => {
|
||||||
|
// piper, made in an earlier test, subscribes to Test Show alone.
|
||||||
|
const ctx = await browser.newContext();
|
||||||
|
const piper = await ctx.newPage();
|
||||||
|
await piper.goto('/login');
|
||||||
|
await piper.locator('#name').fill('piper');
|
||||||
|
await piper.locator('#pw').fill('piperpassword');
|
||||||
|
await piper.locator('button[type=submit]').click();
|
||||||
|
await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
||||||
|
const mine = await piper.evaluate(() => S.feeds.map(f => f.id));
|
||||||
|
// Listen as the page does, then ask.
|
||||||
|
await piper.evaluate(() => {
|
||||||
|
window.started = []; window.done = false;
|
||||||
|
const es = new EventSource('/api/events');
|
||||||
|
es.onmessage = m => { const ev = JSON.parse(m.data);
|
||||||
|
if (ev.ev === 'feed_start') window.started.push(ev.feed);
|
||||||
|
if (ev.ev === 'scan_done') window.done = true; };
|
||||||
|
});
|
||||||
|
await piper.waitForTimeout(500);
|
||||||
|
await piper.locator('#scanAll').click();
|
||||||
|
await expect.poll(() => piper.evaluate(() => window.done), { timeout: 30_000 }).toBe(true);
|
||||||
|
const started = await piper.evaluate(() => window.started);
|
||||||
|
expect(started.length, 'something was checked').toBeGreaterThan(0);
|
||||||
|
for (const id of started) expect(mine, `${id} is not one of piper's feeds`).toContain(id);
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
|||||||
@@ -368,6 +368,12 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
}
|
}
|
||||||
.chev:hover{color:var(--fg)}
|
.chev:hover{color:var(--fg)}
|
||||||
.chev.bad,.chev.bad:hover{color:var(--bad)}
|
.chev.bad,.chev.bad:hover{color:var(--bad)}
|
||||||
|
/* A feed being checked: a small spinner at the row's end, after its count (issue #37). */
|
||||||
|
.feed.scanning::after{
|
||||||
|
content:"";flex:none;width:12px;height:12px;border-radius:50%;
|
||||||
|
border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes ipxspin{to{transform:rotate(360deg)}}
|
||||||
/* A feed's error mark, in the triangle's place: the same column as every folder's triangle,
|
/* A feed's error mark, in the triangle's place: the same column as every folder's triangle,
|
||||||
a child's included, which is why it moves left by the child's indent. */
|
a child's included, which is why it moves left by the child's indent. */
|
||||||
.ferr{position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;color:var(--bad)}
|
.ferr{position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;color:var(--bad)}
|
||||||
|
|||||||
@@ -371,7 +371,8 @@ function opmlModal(){
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scanAll(){ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
|
// No toast: the spinners on the rows being checked say it (issue #37).
|
||||||
|
async function scanAll(){ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
|
||||||
$('#scanAll').onclick=scanAll;
|
$('#scanAll').onclick=scanAll;
|
||||||
$('#prefs').onclick=prefsModal;
|
$('#prefs').onclick=prefsModal;
|
||||||
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
|
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
|
||||||
|
|||||||
@@ -5,12 +5,8 @@ function connect(){
|
|||||||
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
||||||
const refreshFeeds=soon(()=>loadFeeds(true));
|
const refreshFeeds=soon(()=>loadFeeds(true));
|
||||||
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
|
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
|
||||||
let fresh={};
|
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
|
||||||
const tellNew=soon(()=>{
|
const mine=id=>S.feeds.some(f=>f.id===id);
|
||||||
const feeds=Object.keys(fresh), n=feeds.reduce((a,k)=>a+fresh[k],0);
|
|
||||||
if(n) toast(feeds.length===1 ? `${feeds[0]}: ${n} new` : `${n} new in ${feeds.length} feeds`);
|
|
||||||
fresh={};
|
|
||||||
},900);
|
|
||||||
sse.onmessage=m=>{
|
sse.onmessage=m=>{
|
||||||
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
|
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
|
||||||
if(ev.ev==='progress'){
|
if(ev.ev==='progress'){
|
||||||
@@ -23,22 +19,28 @@ function connect(){
|
|||||||
}
|
}
|
||||||
else if(ev.ev==='download_done'){
|
else if(ev.ev==='download_done'){
|
||||||
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
||||||
if(bar) bar.classList.remove('live');
|
// Said only for a file on screen, as one downloaded by hand is: the scheduled downloads of
|
||||||
toast('Downloaded '+ev.path.split('/').pop()); refreshEntries(); refreshFeeds();
|
// everyone's feeds used to announce themselves to everyone.
|
||||||
|
if(bar){ bar.classList.remove('live'); toast('Downloaded '+ev.path.split('/').pop()); }
|
||||||
|
refreshEntries(); refreshFeeds();
|
||||||
}
|
}
|
||||||
else if(ev.ev==='download_error'){
|
else if(ev.ev==='download_error'){
|
||||||
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
// 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);
|
||||||
else if(ev.ev==='feed_done'){
|
else if(ev.ev==='feed_done'){
|
||||||
if(ev.new){ fresh[ev.feed]=(fresh[ev.feed]||0)+ev.new; tellNew(); }
|
setScanning(ev.feed,false);
|
||||||
|
if(!mine(ev.feed)) return;
|
||||||
refreshFeeds(); if(ev.feed===S.feed||S.feed===':all') refreshEntries();
|
refreshFeeds(); 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
|
// 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.
|
// red ! marks the feed instead, and its page says why.
|
||||||
else if(ev.ev==='feed_error') refreshFeeds();
|
else if(ev.ev==='feed_error'){ setScanning(ev.feed,false); if(mine(ev.feed)) refreshFeeds(); }
|
||||||
else if(ev.ev==='scan_done'){ refreshFeeds(); refreshEntries(); }
|
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); refreshFeeds(); refreshEntries(); }
|
||||||
};
|
};
|
||||||
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };
|
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ function renderGroup(f,kids){
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function feedAction(a,f){
|
async function feedAction(a,f){
|
||||||
if(a==='scan'){ toast('Scanning '+(f.title||f.id)+'…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
|
if(a==='scan'){ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
|
||||||
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
|
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
|
||||||
if(a==='rm') removeFeed(f);
|
if(a==='rm') removeFeed(f);
|
||||||
if(a==='pin'){
|
if(a==='pin'){
|
||||||
|
|||||||
@@ -25,6 +25,22 @@ const VIEWS={
|
|||||||
blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'},
|
blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'},
|
||||||
':all':{title:'All Subscriptions',icon:ICON.all},
|
':all':{title:'All Subscriptions',icon:ICON.all},
|
||||||
};
|
};
|
||||||
|
/// Feeds being checked right now, from the event stream: their rows, and the row of a folder
|
||||||
|
/// holding one, carry a spinner.
|
||||||
|
const scanning=new Set<string>();
|
||||||
|
function setScanning(id: string, on: boolean){
|
||||||
|
if(on) scanning.add(id); else scanning.delete(id);
|
||||||
|
paintScanning();
|
||||||
|
}
|
||||||
|
function paintScanning(){
|
||||||
|
for(const row of $$('#feedlist .feed')){
|
||||||
|
const id=row.dataset.id;
|
||||||
|
const on=scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id));
|
||||||
|
row.classList.toggle('scanning',on);
|
||||||
|
if(on) row.title='Checking for new items…'; else row.removeAttribute('title');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderFeeds(){
|
function renderFeeds(){
|
||||||
const q=$('#feedFilter').value.trim().toLowerCase();
|
const q=$('#feedFilter').value.trim().toLowerCase();
|
||||||
const list=$('#feedlist'); const top=list.scrollTop;
|
const list=$('#feedlist'); const top=list.scrollTop;
|
||||||
@@ -104,6 +120,7 @@ function renderFeeds(){
|
|||||||
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
||||||
list.appendChild(el);
|
list.appendChild(el);
|
||||||
}
|
}
|
||||||
|
paintScanning();
|
||||||
done();
|
done();
|
||||||
}
|
}
|
||||||
function selectFeed(id){
|
function selectFeed(id){
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ function pullShow(dy: number){
|
|||||||
function refreshFeed(){
|
function refreshFeed(){
|
||||||
const f = S.feeds.find(x => x.id === S.feed);
|
const f = S.feeds.find(x => x.id === S.feed);
|
||||||
if(!f && S.feed !== ':all') return;
|
if(!f && S.feed !== ':all') return;
|
||||||
toast(f ? `Checking ${f.title || f.id} for new items…` : 'Checking every feed for new items…');
|
|
||||||
// New items arrive by the event stream when the scan finishes, as they do for a button press.
|
// New items arrive by the event stream when the scan finishes, as they do for a button press.
|
||||||
api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
|
api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
|
||||||
.catch(e => toast(e.message, true));
|
.catch(e => toast(e.message, true));
|
||||||
|
|||||||
Reference in New Issue
Block a user