Signals: feeds made from your subscriptions, picked by Jev (#254)

A Signal is a name and keywords. A task beside DeadAir's asks TypeSafe's Jev, on the owner's own
key, about each item of their subscribed feeds from the last 30 days and every new one: one call
an item, one noul question for each of their Signals still to hear about it, 0.5 or more a fit.
Every verdict is kept, fitting or not, in signal_verdicts, so nothing is asked twice, and new
keywords drop a Signal's verdicts to be judged afresh. A key TypeSafe refuses (401/402/403) is
passed over until its owner saves another, so a bad key is not sent the whole queue.

A Signal's items come through entries_where with an EXISTS on its hits, so filters, search, sort
and read state are an ordinary list's, and reading an item in a Signal reads it in its feed.

The page shows Signals under the places, with New signal, and Settings takes the TypeSafe key,
which is never sent back. The browser suite stubs Jev in the fixture server (IPX_TYPESAFE_URL).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 16:54:50 +00:00
parent 30622fd3e1
commit 70990c70c7
21 changed files with 874 additions and 27 deletions

90
web/src/signals.ts Normal file
View File

@@ -0,0 +1,90 @@
/* ---------------- signals ---------------- */
/// 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 Signal's id in a place's id, or null for anything else.
function signalId(view: string|null){
const m=/^:signal:(\d+)$/.exec(view||'');
return m ? Number(m[1]) : null;
}
function signalOf(view: string|null){
const id=signalId(view);
return id===null ? undefined : S.signals.find(s=>s.id===id);
}
/// Your Signals under the places, and the way to make one.
function signalRows(places: HTMLElement){
for(const sg of S.signals){
const id=':signal:'+sg.id, el=document.createElement('div');
el.className='place'+(S.feed===id?' sel':'');
el.tabIndex=0; el.dataset.id=id;
el.innerHTML=`<span class="ico">${ICON.signal}</span><b>${esc(sg.name)}</b>`+
`<span class="badge${sg.unread?'':' zero'}" title="${sg.unread} unread">${sg.unread>999?'999+':sg.unread}</span>`;
el.onclick=()=>{ selectFeed(id); nav(false); };
el.onpointerenter=el.ontouchstart=()=>prefetch(id);
places.appendChild(el);
}
const add=document.createElement('div');
add.className='place'; add.tabIndex=0; add.dataset.id=':signal:new';
add.innerHTML=`<span class="ico">${ICON.plus}</span><b>New signal</b>`;
add.onclick=()=>{ signalModal(); nav(false); };
places.appendChild(add);
}
function signalHead(sg: Signal){
const nokey=S.me&&!S.me.typesafe_key_set;
return `
<div class="fhead slim">
<div class="art">${ICON.signal}</div>
<div class="meta">
<h2>${esc(sg.name)}</h2>
<div class="sub">Items from your feeds about ${esc(sg.keywords.join(', '))} · ${sg.unread} unread</div>
${nokey?'<div class="sub" style="color:var(--warn)">Nothing is picked until you add your TypeSafe key in Settings.</div>':''}
</div>
<div class="acts">
<button class="btn ico" data-a="settings" title="Edit this signal" aria-label="Edit this signal">${ICON.settings}</button>
<button class="btn ico danger" data-a="rm" title="Delete this signal" aria-label="Delete this signal">${ICON.trash}</button>
</div>
</div>`;
}
async function signalAction(a: string, sg: Signal){
if(a==='settings') signalModal(sg);
if(a==='rm'){
if(!confirm(`Delete the signal ${sg.name}? The items stay in their own feeds.`)) return;
try{
await api(`/api/signals/${sg.id}`,{method:'DELETE'});
toast('Deleted'); await loadFeeds(true); selectFeed(':all');
}catch(e){ toast(why(e),true); }
}
}
/// Making a Signal, or changing one.
function signalModal(sg?: Signal){
const nokey=S.me&&!S.me.typesafe_key_set;
openModal(`<h3>${sg?'Edit signal':'New signal'}</h3>
<div class="field"><label for="sgname">Name</label>
<input type="text" id="sgname" value="${esc(sg?.name||'')}" placeholder="Formula 1"></div>
<div class="field"><label for="sgkwin">About any of</label>
<div class="chips" id="sgkw">${chipsHTML(sg?.keywords||[],'sgkwin')}</div>
<span class="hint">TypeSafe's Jev reads each item in the feeds you subscribe to, the last 30 days
of them and every new one, and puts the ones about any of these here as well as in their own
feed. Each item is one call on your TypeSafe key.${sg?' Changing these reads them all again.':''}</span></div>
${nokey?'<p class="hint" style="color:var(--warn)">Add your TypeSafe key in Settings first: nothing is picked without one.</p>':''}
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico primary" id="sgsave" title="${sg?'Save':'Make signal'}" aria-label="${sg?'Save':'Make signal'}">${sg?ICON.check:ICON.plus}</button></div>`);
wireChips($('#sgkw'));
$<HTMLInputElement>('#sgname').focus();
$<HTMLButtonElement>('#sgsave').onclick=async()=>{
const name=$<HTMLInputElement>('#sgname').value.trim(), keywords=chipWords($('#sgkw'));
if(!name){ $<HTMLInputElement>('#sgname').focus(); return; }
if(!keywords.length){ $<HTMLInputElement>('#sgkwin').focus(); return; }
const body=JSON.stringify({name,keywords});
try{
const r=sg ? await api(`/api/signals/${sg.id}`,{method:'PATCH',body})
: await api('/api/signals',{method:'POST',body});
closeModal(); toast(sg?'Saved':'Made. Items show up here as Jev reads them');
await loadFeeds(true); selectFeed(':signal:'+(sg?sg.id:r.id));
}catch(e){ toast(why(e),true); }
};
}