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

View File

@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Signals, under the places in the sidebar: New signal makes one from a name and keywords, and its page lists the items from your feeds that are about them, read and unread as in their own feeds. Settings takes your TypeSafe key, which Signals need (#254).
- A list opens at once when you have seen it before or the page fetched it ahead: All Subscriptions and the feeds with something unread are fetched when the page loads, and a feed or filter as the pointer or a finger reaches it, then refreshed behind once shown. A list that has to be waited for shows a spinner instead of nothing (#247). Currently Listening and the Directory's front page are fetched ahead the same way and open at once (#248).
### Changed

View File

@@ -24,7 +24,7 @@ const WEB_VERSION = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.js
// The files are one script, concatenated in this order, not modules: they share one top-level
// scope, as the single inline script did, and code that runs at load needs what came before it.
const PAGES = {
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'gestures', 'events', 'native'] },
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'signals', 'gestures', 'events', 'native'] },
'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] },
'login.html': { script: 'login.js', src: ['login'] },
};

View File

@@ -440,6 +440,13 @@ async function prefsModal(){
<span class="hint">Comma separated words or phrases, in every feed you read. An item with
one in its title or text is hidden from you and not downloaded for you. Each feed's
settings can add more.</span></div>
<div class="field"><label for="stskey">TypeSafe key</label>
<div class="inline"><input type="password" id="stskey" autocomplete="off" spellcheck="false"
placeholder="${S.me?.typesafe_key_set?'Saved. Paste a new one to replace it':'Paste your key'}">
<button class="btn ico" id="stsforget" title="Forget the key" aria-label="Forget the key"${S.me?.typesafe_key_set?'':' hidden'}>${ICON.trash}</button></div>
<span class="hint">Signals are picked by TypeSafe's Jev on your own key, from
<a href="https://typesafe.ai" target="_blank" rel="noopener">typesafe.ai</a>. It is kept on this
server and never shown again.</span></div>
<div class="field boxed"><label for="tokname">API tokens</label>
<div id="tokens"></div>
<div class="inline"><input type="text" id="tokname" placeholder="What it is for">
@@ -469,6 +476,18 @@ async function prefsModal(){
$<HTMLInputElement>('#tokval').select();
}catch(err){ toast(why(err),true); }
};
const saveKey=async(typesafe_key: string|null)=>{
try{
await api('/api/me',{method:'PATCH',body:JSON.stringify({typesafe_key})});
if(S.me) S.me.typesafe_key_set=!!typesafe_key;
$<HTMLInputElement>('#stskey').value='';
$<HTMLInputElement>('#stskey').placeholder=typesafe_key?'Saved. Paste a new one to replace it':'Paste your key';
$<HTMLButtonElement>('#stsforget').hidden=!typesafe_key;
toast(typesafe_key?'Saved':'Forgotten');
}catch(err){ toast(why(err),true); }
};
$<HTMLInputElement>('#stskey').onchange=(e: Event)=>{ const k=(e.target as HTMLInputElement).value.trim(); if(k) saveKey(k); };
$<HTMLButtonElement>('#stsforget').onclick=()=>saveKey(null);
$<HTMLInputElement>('#sblock').onchange=async (e: Event)=>{
const blocked=splitWords((e.target as HTMLInputElement).value);
try{

View File

@@ -6,10 +6,11 @@ function renderFeed(){
const v=VIEWS[S.feed ?? ''];
if(v&&v.url) return renderListed(v);
const f=v?null:S.feeds.find(x=>x.id===S.feed);
const sg=signalOf(S.feed);
$<HTMLButtonElement>('#tbRemove').disabled=!f;
syncTools(null);
if(!v&&!f){ box.innerHTML='<p class="empty">Add a feed to get started.</p>'; $('#count').textContent=''; return; }
const name=f?(f.title||f.id):v.title;
if(!v&&!f&&!sg){ box.innerHTML='<p class="empty">Add a feed to get started.</p>'; $('#count').textContent=''; return; }
const name=f?(f.title||f.id):sg?sg.name:v.title;
$<HTMLInputElement>('#epSearch').placeholder=`Search ${name}…`;
const kids=f?S.feeds.filter(c=>c.group===f.id):[];
if(kids.length){ box.classList.add('plain'); renderGroup(f!,kids); return; }
@@ -36,7 +37,7 @@ function renderFeed(){
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
</div>
</div>` : `
</div>` : sg ? signalHead(sg) : `
<div class="fhead slim">
<div class="art">${v.icon}</div>
<div class="meta">
@@ -69,7 +70,7 @@ function renderFeed(){
S.sel=null;
showDetail(null);
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a!,f):allAction(b.dataset.a!));
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a!,f):sg?signalAction(b.dataset.a!,sg):allAction(b.dataset.a!));
$$('#content .tabs button').forEach(b=>b.onpointerenter=b.ontouchstart=()=>{ if(S.feed) prefetch(S.feed,b.dataset.f!); });
$$('#content .tabs button').forEach(b=>b.onclick=()=>{
S.filter=b.dataset.f!; S.offset=0;

View File

@@ -9,7 +9,8 @@ function patchFeed(row: Feed){
}
async function loadFeeds(keepSel?: boolean){
S.feeds = await api('/api/feeds');
// A Signal list that fails leaves the feeds to show.
[S.feeds, S.signals] = await Promise.all([api('/api/feeds'), api('/api/signals').catch((): Signal[]=>[])]);
// Only for a hint in a feed's settings, so once, on the page's first load, not on every reload
// of the list (#103).
if(!keepSel) api('/api/settings').then(g=>{globalMax=g.max_new_per_check}).catch(()=>{});
@@ -18,7 +19,7 @@ async function loadFeeds(keepSel?: boolean){
// All Subscriptions rather than picking one alphabetically. Nothing to land on at all (a
// brand new account) leaves S.feed alone, so the empty state's own message shows instead.
if(!keepSel && S.feeds.length){
const known = S.feed && (VIEWS[S.feed] || S.feeds.some(f=>f.id===S.feed));
const known = S.feed && (VIEWS[S.feed] || signalOf(S.feed) || S.feeds.some(f=>f.id===S.feed));
selectFeed(known ? S.feed! : ':all');
}
}
@@ -102,6 +103,7 @@ function renderFeeds(){
el.onpointerenter=el.ontouchstart=()=>prefetch(id);
places.appendChild(el);
}
signalRows(places);
list.appendChild(places);
const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q));
if(!shown.length){ list.insertAdjacentHTML('beforeend','<p class="empty" style="padding:20px 8px">No feeds.</p>'); return done(); }

View File

@@ -12,7 +12,10 @@ const listKey=(feed: string, filter=S.filter)=>[feed,filter,S.sort.col,S.sort.di
function listUrl(feed: string, filter=S.filter, offset=0){
const p=new URLSearchParams({offset:String(offset),limit:String(LIMIT),filter,sort:S.sort.col,dir:S.sort.dir});
if(S.q) p.set('q',S.q);
return feed===':all' ? `/api/entries?${p}` : `/api/feeds/${encodeURIComponent(feed)}/entries?${p}`;
const sg=signalId(feed);
return feed===':all' ? `/api/entries?${p}`
: sg!==null ? `/api/signals/${sg}/entries?${p}`
: `/api/feeds/${encodeURIComponent(feed)}/entries?${p}`;
}
function keepList(key: string, r: ListPage){
lists.delete(key); lists.set(key,r);
@@ -48,7 +51,8 @@ const prefetchPage=(url: string)=>{ if(!pages.has(url)) fetchPage(url).catch(()=
/// A feed changed (a scan, a read elsewhere): its kept lists, and every one of All Subscriptions,
/// are fetched again when next wanted.
function forgetLists(feed: string){
for(const k of [...lists.keys()]) if(k.startsWith(feed+'\n')||k.startsWith(':all\n')) lists.delete(k);
// A Signal holds items of any feed.
for(const k of [...lists.keys()]) if(k.startsWith(feed+'\n')||k.startsWith(':all\n')||k.startsWith(':signal:')) lists.delete(k);
}
/// What is likely to be opened next, fetched two at a time once the page is idle: All Subscriptions
/// and the feeds with something unread, most first.
@@ -120,10 +124,10 @@ function listLoading(box=$('#eps')){
}
function renderEntries(){
const box=$('#eps'); if(!box) return;
const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed ?? ''];
const unread=f?f.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed ?? ''], sg=signalOf(S.feed);
const unread=f?f.unread:sg?sg.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
$('#count').textContent=
`${f?(f.title||f.id):v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`;
`${f?(f.title||f.id):sg?sg.name:v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`;
const pane=$('#list'), top=pane?pane.scrollTop:0;
box.innerHTML='';
if(!S.entries.length){

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

View File

@@ -82,6 +82,16 @@ interface Me {
deadair_saved: number;
theme: {name: string, mode: string} | null;
version: string;
/// A TypeSafe key is saved for Signals; the key itself never comes back.
typesafe_key_set: boolean;
}
/// A Signal in your sidebar, `/api/signals` (db.rs SignalRow).
interface Signal {
id: number;
name: string;
keywords: string[];
unread: number;
}
/// A feed as the Directory lists it (web.rs PopularRow): never its address.
@@ -101,6 +111,7 @@ interface DirFeed {
/// The page's own state (util.ts `S`).
interface State {
feeds: Feed[];
signals: Signal[];
/// The feed or place open: a feed's id, or a view such as ':all'.
feed: string | null;
entries: Entry[];

View File

@@ -13,6 +13,8 @@ const esc = (s: string|null|undefined) => (s??'').replace(/[&<>"']/g,c=>ESCAPED[
// the button's own colour. To add one, copy the path from svgs/<style>/<name>.svg at the same tag.
const fa=(box: string,body: string)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`;
const ICON: Record<string,string>={
// Waves either side of a point: a Signal (#254).
signal:fa('0 0 512 512','<g fill="none" stroke="currentColor" stroke-width="44" stroke-linecap="round"><path d="M136 136a170 170 0 0 0 0 240"/><path d="M376 136a170 170 0 0 1 0 240"/><path d="M204 196a85 85 0 0 0 0 120"/><path d="M308 196a85 85 0 0 1 0 120"/></g><circle cx="256" cy="256" r="38" fill="currentColor"/>'),
share:fa('0 0 512 512','<path fill="currentColor" d="M384 192c53 0 96-43 96-96s-43-96-96-96-96 43-96 96c0 5.4 .5 10.8 1.3 16L159.6 184.1c-16.9-15-39.2-24.1-63.6-24.1-53 0-96 43-96 96s43 96 96 96c24.4 0 46.6-9.1 63.6-24.1L289.3 400c-.9 5.2-1.3 10.5-1.3 16 0 53 43 96 96 96s96-43 96-96-43-96-96-96c-24.4 0-46.6 9.1-63.6 24.1L190.7 272c.9-5.2 1.3-10.5 1.3-16s-.5-10.8-1.3-16l129.7-72.1c16.9 15 39.2 24.1 63.6 24.1z"/>'), // solid/share-nodes
plus:fa('0 0 448 512','<path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/>'), // solid/plus
circleMinus:fa('0 0 512 512','<path fill="currentColor" d="M512 256A256 256 0 1 0 0 256a256 256 0 1 0 512 0zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/>'), // solid/circle-minus, for Unsubscribe
@@ -218,7 +220,7 @@ function nav(on: boolean){ $('#sidebar').classList.toggle('open',on); $('#scrim'
/* ---------------- state ---------------- */
const S: State = {
feeds:[],
feeds:[], signals:[],
// Which feed (or place) and which tab were open last time, so a refresh lands back where
// you were instead of jumping to the first feed alphabetically.
feed:(()=>{ try{ return localStorage.getItem('ipx.feed'); }catch{ return null; } })(),