With signals_on_server set and TYPESAFE_KEY present, the judge asks Jev about everyone's Signals on the server's key; otherwise each person turns Signals on in Settings with a key of their own, which is now checked with TypeSafe before it is saved. The Signals folder shows only when they are on. The browser suite's daemon inherited the shell's TYPESAFE_KEY, so fixture feeds were sent to the real TypeSafe to be categorised; it is blanked now, and an empty key counts as none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
294 lines
17 KiB
TypeScript
294 lines
17 KiB
TypeScript
/* ---------------- 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 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.
|
|
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);
|
|
}
|
|
|
|
/// Whether you have Signals (#271): everyone does on the server's key; on your own, once you have
|
|
/// turned them on in Settings with a key TypeSafe took.
|
|
const signalsOn=()=>S.me?.signals==='server'||!!S.me?.typesafe_key_set;
|
|
|
|
/// The Signals folder: first in the feed list, under All Subscriptions, open or closed as an OPML's
|
|
/// is (#255, #256). As rows under the places, a long list of them pushed the feeds down.
|
|
const SIGNALS=':signals';
|
|
const sigArt=()=>`<span class="fart"><div class="art">${tile('signals')}</div></span>`;
|
|
function feedRow(id: string, cls: string, html: string, open: ()=>void){
|
|
const el=document.createElement('div');
|
|
el.className='feed '+cls+(S.feed===id?' sel':'');
|
|
el.tabIndex=0; el.dataset.id=id; el.innerHTML=html;
|
|
el.onclick=()=>{ open(); nav(false); };
|
|
return el;
|
|
}
|
|
/// A Signal's icon, drawn from /icons as a mask so it takes the theme's colour, or its initials.
|
|
/// The name goes into an address and a style: only a style and a plain name are drawn (the server
|
|
/// takes nothing else either).
|
|
const ICON_ID=/^(solid|regular|brands)\/[a-z0-9-]{1,64}$/;
|
|
const iconHTML=(icon: string)=>`<span class="ficon" style="--icon:url('/icons/${icon}.svg')"></span>`;
|
|
/// Every Signal in the accent tint the folder has, icon or initials: tinted by a hash of its name,
|
|
/// as a feed without art is, one came out a colour of its own for no reason anyone could see (#262).
|
|
function signalTile(sg: {name: string, icon: string|null}){
|
|
const inner=sg.icon&&ICON_ID.test(sg.icon) ? iconHTML(sg.icon) : esc(initials(sg.name));
|
|
return `<div class="art ini sig" style="--tint:var(--accent)">${inner}</div>`;
|
|
}
|
|
const badgeHTML=(n: number)=>`<span class="badge${n?'':' zero'}" title="${n} unread">${n>999?'999+':n}</span>`;
|
|
function signalFolder(list: HTMLElement, q: string){
|
|
if(!signalsOn()) return;
|
|
const mine=S.signals.filter(s=>!q||s.name.toLowerCase().includes(q));
|
|
if(q&&!mine.length) return;
|
|
const open=expanded.has(SIGNALS)||!!q;
|
|
const n=S.signals.length;
|
|
list.appendChild(feedRow(SIGNALS,'sigs',
|
|
`<button class="chev" aria-expanded="${open}" title="Show or hide your signals" aria-label="Show or hide your signals">${ICON.caret}</button>`+
|
|
sigArt()+`<div class="txt"><b>Signals</b><small>${n?plural(n,'signal'):'What matters, out of the noise'}</small></div>`+
|
|
// No + of its own (#265): a row like any folder's. New signal is on the folder's page.
|
|
badgeHTML(S.signals.reduce((k,s)=>k+s.unread,0)),
|
|
()=>selectFeed(SIGNALS)));
|
|
const row=list.lastElementChild!;
|
|
$('.chev',row).onclick=(ev: Event)=>{ ev.stopPropagation(); toggleGroup(SIGNALS); };
|
|
if(!open) return;
|
|
for(const sg of mine){
|
|
const el=feedRow(':signal:'+sg.id,'child',
|
|
`<span class="fart">${signalTile(sg)}</span><div class="txt"><b>${esc(sg.name)}</b><small>${signalCounts(sg)}</small></div>`+badgeHTML(sg.unread),
|
|
()=>selectFeed(':signal:'+sg.id));
|
|
el.onpointerenter=el.ontouchstart=()=>prefetch(':signal:'+sg.id);
|
|
list.appendChild(el);
|
|
}
|
|
}
|
|
|
|
/// The folder's page: every Signal, as an OPML's page lists its feeds.
|
|
function renderSignals(){
|
|
const unread=S.signals.reduce((k,s)=>k+s.unread,0);
|
|
$('#count').textContent=`Signals: ${plural(S.signals.length,'signal')}, ${unread} unread`;
|
|
$<HTMLButtonElement>('#tbRemove').disabled=true;
|
|
syncTools(null);
|
|
const box=$('#content');
|
|
box.classList.add('plain');
|
|
box.innerHTML=`
|
|
<div class="fhead slim">
|
|
<div class="art">${tile('signals')}</div>
|
|
<div class="meta">
|
|
<h2>Signals</h2>
|
|
<div class="sub">Each one picks what matters out of the noise of your ${plural(S.feeds.length,'feed')} · ${unread} unread</div>
|
|
</div>
|
|
<div class="acts">
|
|
<button class="btn ico" data-a="new" title="New signal" aria-label="New signal">${ICON.plus}</button>
|
|
</div>
|
|
</div>
|
|
<div class="childlist" id="siglist"></div>`;
|
|
$<HTMLButtonElement>('#content .acts [data-a="new"]').onclick=()=>signalModal();
|
|
const list=$('#siglist');
|
|
if(!S.signals.length){
|
|
list.innerHTML='<p class="empty">No signals yet. Make one to pull a subject out of every feed you subscribe to.</p>';
|
|
return;
|
|
}
|
|
for(const sg of S.signals){
|
|
const el=document.createElement('div');
|
|
el.className='childrow';
|
|
el.innerHTML=signalTile(sg)+`<div class="txt"><b>${esc(sg.name)}</b>`+
|
|
`<small class="meta">${signalCounts(sg)}</small></div><span class="badge${sg.unread?'':' zero'}">${sg.unread}</span>`;
|
|
el.onclick=()=>selectFeed(':signal:'+sg.id);
|
|
list.appendChild(el);
|
|
}
|
|
}
|
|
|
|
function signalHead(sg: Signal){
|
|
const nokey=S.me&&!signalsOn();
|
|
return `
|
|
<div class="fhead slim">
|
|
${signalTile(sg)}
|
|
<div class="meta">
|
|
<h2>${esc(sg.name)}</h2>
|
|
<div class="sub">Picked out of the noise of your ${plural(S.feeds.length,'feed')}: ${esc(sg.description||sg.keywords.join(', '))} · ${sg.unread} unread</div>
|
|
${nokey?`<div class="sub" style="color:var(--warn)">No TypeSafe key yet, so nothing is being picked out. ${KEY_LINKS}</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&&!signalsOn();
|
|
openModal(`<h3>${sg?'Edit signal':'New signal'}</h3>
|
|
<div class="field"><label for="sgname">Name</label>
|
|
<div class="inline"><button type="button" class="iconpick" id="sgicon" title="Choose an icon" aria-label="Choose an icon"
|
|
aria-expanded="false"></button>
|
|
<input type="text" id="sgname" value="${esc(sg?.name||'')}" placeholder="Formula 1"></div>
|
|
<div id="sgpicker" hidden>
|
|
<div class="inline"><input type="search" id="sgiconq" placeholder="Search icons: robot, car, music…" autocomplete="off">
|
|
<button type="button" class="dlink" id="sgjev" title="Pick an icon for me">Auto</button>
|
|
<button type="button" class="dlink" id="sgnoicon">Initials</button></div>
|
|
<div class="icongrid" id="sgicons" role="listbox" aria-label="Icons"></div>
|
|
</div></div>
|
|
<div class="field"><label for="sgkwin">Listen for</label>
|
|
<div class="chips" id="sgkw">${chipsHTML(sg?.keywords||[],'sgkwin')}</div></div>
|
|
<div class="field"><label for="sgdesc">In your own words (optional)</label>
|
|
<input type="text" id="sgdesc" value="${esc(sg?.description||'')}" placeholder="AI tools for writing software: coding agents, vibe coding">
|
|
<span class="hint">Every item in the feeds you subscribe to is read, the last 30 days of them
|
|
and each new one, and the ones about this subject are pulled out: its name, these words and
|
|
your sentence together. The rest stays noise, in its own feed.${S.me?.signals==='server'?'':' Each item is one call on your TypeSafe key.'}${sg?' A change here reads everything again.':''}</span></div>
|
|
${nokey?`<p class="hint" style="color:var(--warn)">Without a TypeSafe key, nothing is picked out. ${KEY_LINKS}</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'));
|
|
let icon=sg?.icon??null;
|
|
const drawPick=()=>{ $('#sgicon').innerHTML=signalTile({name:$<HTMLInputElement>('#sgname').value||'?',icon}); };
|
|
drawPick();
|
|
$<HTMLInputElement>('#sgname').oninput=drawPick;
|
|
const grid=$('#sgicons');
|
|
const drawGrid=async()=>{
|
|
const all=await iconList();
|
|
const shown=iconMatches(all,$<HTMLInputElement>('#sgiconq').value,ICON_CELLS);
|
|
grid.innerHTML=shown.length ? shown.map(([id,label])=>`<button type="button" class="iconcell" role="option" data-ic="${id}"
|
|
aria-selected="${id===icon}" title="${esc(label)}" aria-label="${esc(label)}">${iconHTML(id)}</button>`).join('')
|
|
: '<p class="hint">No icon matches that.</p>';
|
|
};
|
|
$<HTMLButtonElement>('#sgicon').onclick=()=>{
|
|
const p=$('#sgpicker'), open=p.hidden;
|
|
p.hidden=!open; $('#sgicon').setAttribute('aria-expanded',String(open));
|
|
if(open){ drawGrid().catch(e=>toast(why(e),true)); $<HTMLInputElement>('#sgiconq').focus(); }
|
|
};
|
|
$<HTMLInputElement>('#sgiconq').oninput=()=>{ drawGrid().catch(()=>{}); };
|
|
grid.onclick=(e: MouseEvent)=>{
|
|
const b=(e.target as Element).closest<HTMLElement>('[data-ic]'); if(!b) return;
|
|
icon=b.dataset.ic!; drawPick(); $('#sgpicker').hidden=true; $('#sgicon').setAttribute('aria-expanded','false');
|
|
};
|
|
$<HTMLButtonElement>('#sgnoicon').onclick=()=>{ icon=null; drawPick(); $('#sgpicker').hidden=true; };
|
|
$<HTMLButtonElement>('#sgjev').onclick=async()=>{
|
|
// A spinner while it is picked: what picks it is nobody's concern.
|
|
const b=$<HTMLButtonElement>('#sgjev'); b.disabled=true; b.classList.add('busy'); b.setAttribute('aria-busy','true');
|
|
try{
|
|
const got=await jevIcon($<HTMLInputElement>('#sgname').value.trim(),chipWords($('#sgkw')).filter((w): w is string=>!!w),
|
|
$<HTMLInputElement>('#sgdesc').value.trim()||null);
|
|
if(got){ icon=got; drawPick(); $('#sgpicker').hidden=true; $('#sgicon').setAttribute('aria-expanded','false'); }
|
|
else toast('No icon matches those words');
|
|
}catch(e){ toast(why(e),true); }
|
|
finally{ b.disabled=false; b.classList.remove('busy'); b.removeAttribute('aria-busy'); }
|
|
};
|
|
$<HTMLInputElement>('#sgname').focus();
|
|
$<HTMLButtonElement>('#sgsave').onclick=async()=>{
|
|
const name=$<HTMLInputElement>('#sgname').value.trim(), keywords=chipWords($('#sgkw')).filter((w): w is string=>!!w);
|
|
if(!name){ $<HTMLInputElement>('#sgname').focus(); return; }
|
|
if(!keywords.length){ $<HTMLInputElement>('#sgkwin').focus(); return; }
|
|
const description=$<HTMLInputElement>('#sgdesc').value.trim()||null;
|
|
// Only what changed: a new name, words or sentence has Jev read everything again, and saving
|
|
// a new icon alone did that too while the dialog sent them all back each time.
|
|
const body=JSON.stringify(sg ? signalChanges(sg,{name,keywords,description,icon}) : {name,keywords,description,icon});
|
|
try{
|
|
const r=sg ? await api(`/api/signals/${sg.id}`,{method:'PATCH',body})
|
|
: await api('/api/signals',{method:'POST',body});
|
|
// A new one made without an icon gets Jev's pick, behind, so making it does not wait (#263).
|
|
if(!sg&&!icon) jevIcon(name,keywords,description)
|
|
.then(got=>{ if(got) return api(`/api/signals/${r.id}`,{method:'PATCH',body:JSON.stringify({icon:got})}).then(refreshSignals); })
|
|
.catch(()=>{});
|
|
closeModal(); toast(sg?'Saved. Everything is read again':'Listening. What comes through shows up here');
|
|
// Open, so the new one is there to see.
|
|
if(!expanded.has(SIGNALS)) toggleGroup(SIGNALS);
|
|
await loadFeeds(true); selectFeed(':signal:'+(sg?sg.id:r.id));
|
|
}catch(e){ toast(why(e),true); }
|
|
};
|
|
}
|
|
|
|
/// What differs between a Signal and the dialog's fields, as PATCH takes it.
|
|
function signalChanges(sg: Signal, now: {name: string, keywords: string[], description: string|null, icon: string|null}){
|
|
const out: Record<string, unknown>={};
|
|
if(now.name!==sg.name) out.name=now.name;
|
|
if(now.keywords.join('\n')!==sg.keywords.join('\n')) out.keywords=now.keywords;
|
|
if(now.description!==sg.description) out.description=now.description;
|
|
if(now.icon!==sg.icon) out.icon=now.icon;
|
|
return out;
|
|
}
|
|
|
|
/// Font Awesome Free's index, `[style/name, label, terms]`, fetched once, when the picker first
|
|
/// opens: 2,883 icons are about 90 KB compressed, which the page has no other use for.
|
|
const ICON_CELLS=120;
|
|
let icons: Promise<[string,string,string][]>|null=null;
|
|
function iconList(){
|
|
if(!icons) icons=api('__ICONS__').catch((e: unknown)=>{ icons=null; throw e; });
|
|
return icons!;
|
|
}
|
|
/// The icons with a word starting with each word typed, in their name, label or search terms. The
|
|
/// first word typed ranks them: the icon named it, then one with it as a word of its name, then as
|
|
/// a search term, then the rest. Matching anywhere in a word, "car" filled the grid with caret and
|
|
/// cart (#261). At most `max`; nothing typed is the first `max`.
|
|
function iconMatches(all: [string,string,string][], q: string, max: number){
|
|
const words=q.toLowerCase().split(/\s+/).filter(Boolean);
|
|
if(!words.length) return all.slice(0,max);
|
|
const split=(s: string)=>s.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
const rank=([id,label,terms]: [string,string,string])=>{
|
|
const name=id.slice(id.indexOf('/')+1), own=split(name), hay=[...own,...split(label),...split(terms)];
|
|
if(!words.every(w=>hay.some(h=>h.startsWith(w)))) return -1;
|
|
const w=words[0];
|
|
return name===w ? 0 : own.includes(w) ? 1 : hay.includes(w) ? 2 : 3;
|
|
};
|
|
return all.map(i=>[rank(i),i] as const).filter(([r])=>r>=0).sort((a,b)=>a[0]-b[0]).slice(0,max).map(([,i])=>i);
|
|
}
|
|
|
|
/// "12 items · 3 unread", as a feed's row counts its own.
|
|
const signalCounts=(sg: Signal)=>`${plural(sg.items,'item')} · ${sg.unread} unread`;
|
|
|
|
/// The icons likeliest for a Signal: the best few for each word of its name, words and sentence,
|
|
/// at most `max`. Jev picks from these, since it chooses among options and cannot name one.
|
|
const ICON_STOP=new Set(['the','and','for','not','with','about','their','own','from','into','that','this','your']);
|
|
function iconCandidates(all: [string,string,string][], name: string, keywords: string[], description: string|null, max=40){
|
|
const words=[name,...keywords,description||''].join(' ').toLowerCase().split(/[^a-z0-9]+/).filter(w=>w.length>1&&!ICON_STOP.has(w));
|
|
const out=new Map<string,string>();
|
|
// A brand's icon stands for that brand, so it is offered only when its name is one of the
|
|
// Signal's words: by its search terms, "disney" brought the Mandalorian, a Disney+ show (#264).
|
|
const brandFor=(id: string, w: string)=>!id.startsWith('brands/')||id.slice(7).split('-').includes(w);
|
|
for(const w of [...new Set(words)])
|
|
for(const [id,label,terms] of iconMatches(all.filter(([id])=>brandFor(id,w)),w,6))
|
|
if(out.size<max) out.set(id,`${label}: ${terms.split(' ').slice(0,8).join(', ')}`);
|
|
return [...out];
|
|
}
|
|
/// Jev's pick of an icon, on the server's TypeSafe key, or null with nothing to choose from.
|
|
async function jevIcon(name: string, keywords: string[], description: string|null): Promise<string|null>{
|
|
const candidates=iconCandidates(await iconList(),name,keywords,description);
|
|
if(!candidates.length) return null;
|
|
const r=await api('/api/signals/icon',{method:'POST',body:JSON.stringify({name,keywords,description,candidates})});
|
|
return r.icon;
|
|
}
|
|
|
|
/// Where to get a TypeSafe key and where it goes, wherever a Signal says it has none: one click to
|
|
/// each, rather than "in Settings" left to be found.
|
|
const KEY_LINKS=`<a href="https://typesafe.ai" target="_blank" rel="noopener">Get one at typesafe.ai</a>, then
|
|
<button type="button" class="dlink" data-keylink>add it in Settings</button>.`;
|
|
/// Settings, open at the key's field.
|
|
async function keySetting(){
|
|
closeModal();
|
|
await prefsModal();
|
|
$<HTMLInputElement>('#ssigon').checked=true; $('#ssigkey').hidden=false;
|
|
const k=$<HTMLInputElement>('#stskey'); k.scrollIntoView({block:'center'}); k.focus();
|
|
}
|
|
document.addEventListener('click',(e: MouseEvent)=>{
|
|
if(!(e.target as Element).closest?.('[data-keylink]')) return;
|
|
e.preventDefault(); keySetting().catch(err=>toast(why(err),true));
|
|
});
|