From d7a4a0b66361fda10801e5474cf9000be1998325 Mon Sep 17 00:00:00 2001 From: rays Date: Fri, 18 Sep 2026 12:10:05 +0000 Subject: [PATCH] Fix the open bugs: read state, Unread tab, feed errors, theme button, log button, relative images - Opening an item stays read: a list refresh that crossed with the write no longer puts the unread dot back (#16). - On the Unread tab the item you were reading goes when you move to the next (#17). - Feed errors mark the feed with a red ! instead of a toast per failure (#20). - The theme is chosen in Settings only (#15). - The server leaves the Log button out of a non-admin's page, so it no longer flashes (#29). - Relative images and links in a post resolve against the post's link (#28). Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 17 ++++++++++++ src/web.rs | 48 ++++++++++++++++++++++++++++++--- tests/ui/app.spec.js | 57 ++++++++++++++++++++++----------------- web/index.html | 64 +++++++++++++++++++++++++++----------------- 4 files changed, 133 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 848e2e1..8dd304c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,23 @@ The long form, with what was wrong before and how it was found, is in ## [Unreleased] +### Changed + +- A feed that fails to check gets a red ! in the feed list, and its page says why, in place of a + pop-up per failure that everyone saw during a scan of every feed. +- The theme is chosen in Settings only; the button beside the iPodderX name is gone. + +### Fixed + +- An item you open stays read. A list refresh that crossed with marking it read could put its + unread dot back until the next refresh. +- On the Unread tab, the item you were reading leaves the list as soon as you move to the next + one, rather than a few read items lingering until a refresh cleared them. +- The Log button no longer shows for a moment on every load for anyone but an admin; the server + leaves it out of their page. +- An image or link in a post given relative to the post, such as The Observation Deck's, now + points at the post's site rather than at ipx, and shows. + ## [0.6.1] - 2026-09-15 ### Fixed diff --git a/src/web.rs b/src/web.rs index 115df5e..bdd5541 100644 --- a/src/web.rs +++ b/src/web.rs @@ -471,8 +471,21 @@ fn constant_time_eq(a: &str, b: &str) -> bool { a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 } -async fn index() -> Html<&'static str> { - Html(include_str!("../web/index.html")) +const INDEX: &str = include_str!("../web/index.html"); +const LOG_BUTTON: &str = r#"
@@ -750,7 +749,6 @@ const ICON={ log:fa('0 0 384 512',''), // solid/file-lines close:fa('0 0 384 512',''), // solid/xmark menu:fa('0 0 448 512',''), // solid/bars - theme:fa('0 0 512 512',''), // solid/circle-half-stroke directory:fa('0 0 448 512',''), // solid/table-list popular:fa('0 0 576 512',''), // solid/star all:fa('0 0 512 512',''), // solid/layer-group @@ -953,8 +951,10 @@ function renderFeeds(){ ? [sum('unread'),sum('entries'),sum('downloaded')] : [f.unread,f.entries,f.downloaded]; // A group's own row has no error of its own worth mentioning if the OPML itself - // reads fine; it is failing when any feed inside it is. - const failing=mine.length ? mine.find(c=>c.failing)?.failing : f.failing; + // reads fine; it is failing when any feed inside it is. The feed's own page says what + // went wrong (failBannerHTML); the list only has to make it findable. + const bad=c=>c.failing?.reason||c.last_error; + const err=mine.length ? mine.map(bad).find(Boolean) : bad(f); const el=document.createElement('div'); el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':''); el.tabIndex=0; el.dataset.id=f.id; @@ -966,7 +966,7 @@ function renderFeeds(){ `${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`+ ``+ (f.orphaned?'Gone':'')+ - (failing?`Error`:'')+ + (err?`!`:'')+ `${unread>999?'999+':unread}`; el.onclick=()=>{ selectFeed(f.id); nav(false); }; if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); }; @@ -1176,13 +1176,19 @@ async function loadEntries(append){ if(!S.feed || VIEWS[S.feed]?.url) return; const p=new URLSearchParams({offset:S.offset,limit:LIMIT,filter:S.filter,sort:S.sort.col,dir:S.sort.dir}); if(S.q) p.set('q',S.q); + const asked=performance.now(); const r=await api(S.feed===':all' ? `/api/entries?${p}` : `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`); S.total=r.total; + for(const e of r.entries){ + const w=readWrites.get(readKey(e)); + if(w && !(w.done!e.read||e.guid===S.sel); if(!append && S.sel && !entries.some(e=>e.guid===S.sel)){ const open=S.entries.find(e=>e.guid===S.sel); if(open) entries=[open,...entries]; @@ -1302,17 +1308,34 @@ function swapRow(e){ if(row) row.replaceWith(epEl(e)); } +/// Read and unread as this page last set them, and when the server had it. A list asked for +/// before then answers with the old state: it put the dot back on an item just read, until +/// the next refresh took it off again (loadEntries). +const readWrites=new Map(); +const readKey=e=>e.feed_id+'\n'+e.guid; +function setRead(e,read){ + e.read=read; + const w={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(()=>{ w.done=performance.now(); loadFeeds(true); }); +} + /// Opening an item is reading it. The row is redrawn where it stands rather than the list /// reloaded, so an item does not vanish from under the pointer on the Unread tab. function markRead(e){ if(e.read) return; - e.read=true; - api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`, - {method:'POST',body:JSON.stringify({read:true})}) - .then(()=>loadFeeds(true)).catch(err=>{ e.read=false; toast(err.message,true); }); + setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); }); } function selectEntry(e){ + // On the Unread tab the item you were reading goes as you move on, not whenever a refresh + // next happens to come along, which left a few read ones in the list for a while. + const prev=S.filter==='unread' && S.sel!==e.guid && S.entries.find(x=>x.guid===S.sel); + if(prev&&prev.read){ + S.entries=S.entries.filter(x=>x!==prev); S.total--; + $(`#eps .ep[data-guid="${CSS.escape(prev.guid)}"]`)?.remove(); + } S.sel=e.guid; markRead(e); $$('#eps .ep').forEach(x=>x.classList.toggle('sel', x.dataset.guid===e.guid)); @@ -1454,7 +1477,7 @@ async function epAction(a,e,el,encId){ try{ if(a==='play') play(e, encId!=null ? enc : undefined); if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); } - if(a==='read'){ e.read=!e.read; await api(path+'/flags',{method:'POST',body:JSON.stringify({read:e.read})}); redraw(); loadFeeds(true); } + if(a==='read'){ await setRead(e,!e.read); redraw(); } if(a==='get'){ if(!enc) return; await api(`/api/enclosures/${enc.id}/download`,{method:'POST'}); @@ -1493,9 +1516,7 @@ function markPlayed(){ player.marked=true; const e=player.entry; if(!e||e.read) return; - e.read=true; - api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`, - {method:'POST',body:JSON.stringify({read:true})}).then(()=>loadFeeds(true)).catch(()=>{}); + setRead(e,true).catch(()=>{}); } /// Plays one of the item's files in the player bar: the one asked for, or its first playable one. @@ -2245,10 +2266,6 @@ $('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); locat api('/api/me').then(u=>{ S.me=u; $('#who').textContent=u.name+(u.admin?' · admin':''); - // The log names every account and every failed sign-in; that alone is the operator's - // business, and the server refuses it from anyone else. Settings stays -- it opens the - // same modal for everyone, just without the admin-only fields (see prefsModal). - if(!u.admin) $('#logs').hidden=true; }).catch(()=>{}); $('#logs').onclick=logsModal; $('#feedFilter').oninput=renderFeeds; @@ -2265,21 +2282,16 @@ $('#feedlist').onkeydown=ev=>{ }; $('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open')); $('#scrim').onclick=()=>nav(false); -// Dark, light, the 2004 Mac app, and following the system. The header button steps through -// them and Settings offers the same four as a dropdown; either one sets ipx.theme and both -// read it, so they never disagree. Auto last in the cycle keeps the existing three clicks in -// "steps through dark, light and classic" reaching classic exactly where that test expects. +// Dark, light, the 2004 Mac app, and following the system, chosen in Settings and kept in +// ipx.theme. const THEMES={dark:'Dark',light:'Light',classic:'Classic, the 2004 Mac app',auto:'Auto (matches your system)'}; const THEME_ORDER=Object.keys(THEMES); -const nextTheme=t=>THEME_ORDER[(THEME_ORDER.indexOf(t)+1)%THEME_ORDER.length]; function setTheme(t){ if(!THEMES[t]) t='dark'; document.documentElement.dataset.theme=t; - $('#theme').title=`Theme: ${THEMES[t]}. Click for ${THEMES[nextTheme(t)]}`; const sel=$('#stheme'); if(sel) sel.value=t; try{ localStorage.setItem('ipx.theme',t); }catch{} } -$('#theme').onclick=()=>setTheme(nextTheme(document.documentElement.dataset.theme)); try{ setTheme(localStorage.getItem('ipx.theme')); }catch{ setTheme('dark'); } /* ---------------- live events ---------------- */ @@ -2319,7 +2331,9 @@ function connect(){ if(ev.new){ fresh[ev.feed]=(fresh[ev.feed]||0)+ev.new; tellNew(); } refreshFeeds(); if(ev.feed===S.feed||S.feed===':all') refreshEntries(); } - else if(ev.ev==='feed_error'){ toast(ev.feed+': '+ev.msg,true); refreshFeeds(); } + // 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. + else if(ev.ev==='feed_error') refreshFeeds(); else if(ev.ev==='scan_done'){ refreshFeeds(); refreshEntries(); } }; sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };