/* ---------------- 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:`. /// 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=()=>`
${tile('signals')}
`; 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)=>``; /// 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 `
${inner}
`; } const badgeHTML=(n: number)=>`${n>999?'999+':n}`; 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', ``+ sigArt()+`
Signals${n?plural(n,'signal'):'What matters, out of the noise'}
`+ // 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', `${signalTile(sg)}
${esc(sg.name)}${signalCounts(sg)}
`+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`; $('#tbRemove').disabled=true; syncTools(null); const box=$('#content'); box.classList.add('plain'); box.innerHTML=`
${tile('signals')}

Signals

Each one picks what matters out of the noise of your ${plural(S.feeds.length,'feed')} · ${unread} unread
`; $('#content .acts [data-a="new"]').onclick=()=>signalModal(); const list=$('#siglist'); if(!S.signals.length){ list.innerHTML='

No signals yet. Make one to pull a subject out of every feed you subscribe to.

'; return; } for(const sg of S.signals){ const el=document.createElement('div'); el.className='childrow'; el.innerHTML=signalTile(sg)+`
${esc(sg.name)}`+ `${signalCounts(sg)}
${sg.unread}`; el.onclick=()=>selectFeed(':signal:'+sg.id); list.appendChild(el); } } function signalHead(sg: Signal){ const nokey=S.me&&!signalsOn(); return `
${signalTile(sg)}

${esc(sg.name)}

Picked out of the noise of your ${plural(S.feeds.length,'feed')}: ${esc(sg.description||sg.keywords.join(', '))} · ${sg.unread} unread
${nokey?`
No TypeSafe key yet, so nothing is being picked out. ${KEY_LINKS}
`:''}
`; } 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(`

${sg?'Edit signal':'New signal'}

${chipsHTML(sg?.keywords||[],'sgkwin')}
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.':''}
${nokey?`

Without a TypeSafe key, nothing is picked out. ${KEY_LINKS}

`:''}
`); wireChips($('#sgkw')); let icon=sg?.icon??null; const drawPick=()=>{ $('#sgicon').innerHTML=signalTile({name:$('#sgname').value||'?',icon}); }; drawPick(); $('#sgname').oninput=drawPick; const grid=$('#sgicons'); const drawGrid=async()=>{ const all=await iconList(); const shown=iconMatches(all,$('#sgiconq').value,ICON_CELLS); grid.innerHTML=shown.length ? shown.map(([id,label])=>``).join('') : '

No icon matches that.

'; }; $('#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)); $('#sgiconq').focus(); } }; $('#sgiconq').oninput=()=>{ drawGrid().catch(()=>{}); }; grid.onclick=(e: MouseEvent)=>{ const b=(e.target as Element).closest('[data-ic]'); if(!b) return; icon=b.dataset.ic!; drawPick(); $('#sgpicker').hidden=true; $('#sgicon').setAttribute('aria-expanded','false'); }; $('#sgnoicon').onclick=()=>{ icon=null; drawPick(); $('#sgpicker').hidden=true; }; $('#sgjev').onclick=async()=>{ // A spinner while it is picked: what picks it is nobody's concern. const b=$('#sgjev'); b.disabled=true; b.classList.add('busy'); b.setAttribute('aria-busy','true'); try{ const got=await jevIcon($('#sgname').value.trim(),chipWords($('#sgkw')).filter((w): w is string=>!!w), $('#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'); } }; $('#sgname').focus(); $('#sgsave').onclick=async()=>{ const name=$('#sgname').value.trim(), keywords=chipWords($('#sgkw')).filter((w): w is string=>!!w); if(!name){ $('#sgname').focus(); return; } if(!keywords.length){ $('#sgkwin').focus(); return; } const description=$('#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={}; 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(); // 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{ 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=`Get one at typesafe.ai, then .`; /// Settings, open at the key's field. async function keySetting(){ closeModal(); await prefsModal(); $('#ssigon').checked=true; $('#ssigkey').hidden=false; const k=$('#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)); });