Chat: render a bounded window and append new messages instead of rebuilding the log; polls back off while hidden
Opening the chat froze the machine: the log rendered all ~800 loaded messages (1.1 M characters, ~10,000 nodes, a 440,000 px column) and rebuilt the whole log on every new message, receipt change and mark refresh - about 1.8 s of blocked main thread each time. Now: - the log keeps the newest 150 rows; "Load earlier" adds 150 more (from memory, then the server) and keeps the reader's place; switching person or thread starts again at 150 - new messages are appended; a changed receipt, pin, correction or answer repaints only its row - off-screen rows skip layout and paint (content-visibility); the Intl date formatters are made once - polls go through every(): no overlapping requests; while the tab is hidden messages poll every 10 s (sound and notifications still work), people every 60 s, the overview every 30 s, and the marks refresh and board panels pause - removed the backdrop-filter blurs on the sidebar, top bar and composer, and the live dot's endless box-shadow animation (now opacity) Measured: forced full render 1,600-1,850 ms -> 16-21 ms (hidden pane, render + layout); load long task 1,806 ms -> 56 ms; DOM 13,318 -> 5,282 nodes (log 9,870 -> 1,806); a new message appends 2 nodes with no long task; hidden-tab traffic ~37 requests / ~2 MB per 30 s -> 4 requests / 156 KB.
This commit is contained in:
1 parent
9eee1294e0
commit
580fcfacb4
2 files changed
+97
-49
No files matched your search
+96
-47
@@ -16,7 +16,7 @@ body { overflow: hidden; }
|
||||
/* sidebar */
|
||||
.side {
|
||||
display: flex; flex-direction: column; gap: 22px; padding: 18px 12px 14px; overflow-y: auto; z-index: 20;
|
||||
border-right: 1px solid var(--line); backdrop-filter: blur(14px);
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 11px; padding: 2px 8px; }
|
||||
.logo {
|
||||
@@ -47,7 +47,7 @@ body { overflow: hidden; }
|
||||
.main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: 14px; padding: 18px 32px 16px; flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--line); background: var(--header); backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--line); background: var(--header);
|
||||
}
|
||||
.topbar h1 { margin: 0; font: 700 21px/1.2 var(--font-display); letter-spacing: -.5px; }
|
||||
.topbar .sub { font-size: 12.5px; color: var(--muted); margin-top: 2px; }
|
||||
@@ -349,8 +349,10 @@ dialog .body input[type=text], dialog .body select { width: 100%; }
|
||||
|
||||
/* the log */
|
||||
.log-wrap { flex: 1; min-height: 0; position: relative; display: flex; flex-direction: column; }
|
||||
.log { flex: 1; min-height: 0; overflow: auto; padding: 8px 32px 28px; overscroll-behavior: contain; }
|
||||
.log { position: relative; flex: 1; min-height: 0; overflow: auto; padding: 8px 32px 28px; overscroll-behavior: contain; }
|
||||
.log > * { max-width: 1040px; margin-left: auto; margin-right: auto; }
|
||||
/* rows out of view are not laid out or painted (the browser keeps their last size) */
|
||||
.log > .mrow { content-visibility: auto; contain-intrinsic-size: auto 90px; }
|
||||
.date { display: flex; align-items: center; gap: 14px; margin-top: 26px; margin-bottom: 8px; font-size: 11px; font-weight: 650; color: var(--muted); }
|
||||
.date::before, .date::after { content: ""; flex: 1; height: 1px; background: var(--line); }
|
||||
.date span { padding: 3px 11px; border-radius: 999px; background: var(--surface); border: 1px solid var(--line); box-shadow: var(--shadow-1); }
|
||||
@@ -414,7 +416,7 @@ button.chip:hover { color: var(--accent-ink); background: var(--accent-soft); }
|
||||
}
|
||||
|
||||
/* composer */
|
||||
.composer-area { flex-shrink: 0; padding: 12px 28px 12px; border-top: 1px solid var(--line); background: color-mix(in srgb, var(--surface) 60%, transparent); backdrop-filter: blur(12px); }
|
||||
.composer-area { flex-shrink: 0; padding: 12px 28px 12px; border-top: 1px solid var(--line); background: color-mix(in srgb, var(--surface) 60%, transparent); }
|
||||
.composer { max-width: 1040px; margin: 0 auto; background: var(--surface); border: 1px solid var(--line-2); border-radius: 18px; box-shadow: var(--shadow-2); transition: border-color .15s, box-shadow .15s; }
|
||||
.composer:focus-within { border-color: var(--accent); box-shadow: var(--ring), var(--shadow-2); }
|
||||
.recipient { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 10px 12px 0 14px; font-size: 12px; color: var(--muted); }
|
||||
@@ -821,6 +823,15 @@ function put(id,html){const e=typeof id==='string'?el(id):id;if(e._html===html)r
|
||||
open.forEach(k=>{const d=e.querySelector('details[data-k="'+CSS.escape(k)+'"]');if(d)d.open=true});return true}
|
||||
async function post(path,body,again){const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json','X-Harbor-Token':TOKEN},body:JSON.stringify(body)});const j=await r.json().catch(()=>({}));if(r.status===403&&/stale page/.test(j.error||'')&&!again&&await freshToken())return post(path,body,true);if(!r.ok||j.ok===false)throw new Error(j.error||('HTTP '+r.status));return j}
|
||||
async function get(path){const r=await fetch(path,{cache:'no-store'});const j=await r.json();if(!r.ok)throw new Error(j.error||('HTTP '+r.status));return j}
|
||||
/* Run fn every ms while the page is visible and every hiddenMs while it is hidden (null: not at all). The next run is
|
||||
timed from the end of the last one, so slow requests never pile up; showing the page again runs everything at once. */
|
||||
const LOOPS=[];
|
||||
function every(fn,ms,hiddenMs){const L={busy:false,t:0};
|
||||
L.run=async()=>{clearTimeout(L.t);if(L.busy)return;
|
||||
if(!document.hidden||hiddenMs){L.busy=true;try{await fn()}catch(e){}L.busy=false}
|
||||
L.t=setTimeout(L.run,document.hidden?(hiddenMs||5000):ms)};
|
||||
L.t=setTimeout(L.run,document.hidden&&hiddenMs?hiddenMs:ms);LOOPS.push(L);return L}
|
||||
document.addEventListener('visibilitychange',()=>{if(!document.hidden)LOOPS.forEach(L=>{if(!L.busy)L.run()})});
|
||||
|
||||
const VIEWS={overview:['Overview','Who is working, what needs you, and what happened','home'],launch:['Launch','Bring agents online one careful step at a time','rocket'],
|
||||
tasks:['Tasks','The shared queue that idle agents take work from','tasks'],review:['Review','Submitted work, acknowledgements and claims','review'],
|
||||
@@ -1186,11 +1197,13 @@ const Chat=(()=>{
|
||||
const CENTRAL='America/Chicago';
|
||||
const log=el('log');
|
||||
const fmt=s=>esc(s).replace(/`([^`]+)`/g,'<code>$1</code>');
|
||||
const centralParts=d=>Object.fromEntries(new Intl.DateTimeFormat('en-US',{timeZone:CENTRAL,year:'numeric',month:'numeric',day:'numeric'}).formatToParts(d).filter(p=>p.type!=='literal').map(p=>[p.type,Number(p.value)]));
|
||||
const centralDay=d=>{const p=centralParts(d);return [p.year,p.month,p.day].join('-')};
|
||||
const centralTime=d=>d.toLocaleTimeString('en-US',{timeZone:CENTRAL,hour:'numeric',minute:'2-digit',timeZoneName:'short'});
|
||||
/* one formatter each, made once: building an Intl formatter per message cost milliseconds per render */
|
||||
const DAY_FMT=new Intl.DateTimeFormat('en-US',{timeZone:CENTRAL,year:'numeric',month:'numeric',day:'numeric'});
|
||||
const TIME_FMT=new Intl.DateTimeFormat('en-US',{timeZone:CENTRAL,hour:'numeric',minute:'2-digit',timeZoneName:'short'});
|
||||
const centralDay=d=>DAY_FMT.format(d);
|
||||
const centralTime=d=>TIME_FMT.format(d);
|
||||
const since=s=>{if(!s)return 'never';const m=Math.round((Date.now()-new Date(s))/60000);return m<1?'just now':m<120?m+' min ago':m<2880?Math.round(m/60)+' h ago':Math.round(m/1440)+' days ago'};
|
||||
let people=[],byName={},human='User',messages=[],lastId=0,filter='All',thread=null,shownSignature='',sending=false,receipts={},overviewData=null,loaded=false;
|
||||
let people=[],byName={},human='User',messages=[],lastId=0,filter='All',thread=null,sending=false,receipts={},overviewData=null,loaded=false;
|
||||
async function getJSON(path){const r=await fetch(path,{cache:'no-store'});const j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.error||('HTTP '+r.status));return j}
|
||||
const face=(p,state,size)=>'<div class="avatar'+(size?' '+size:'')+'" style="--avatar:'+esc(p.color||'#7b8797')+'"'+(state?' data-state="'+esc(state)+'"':'')+' aria-hidden="true">'+esc(p.initials||String(p.name).slice(0,2))+'</div>';
|
||||
const display=n=>n===human?'You':n;
|
||||
@@ -1239,36 +1252,68 @@ function ackButton(m){if(!(m.ack||m.due_at)||m.from===human)return '';const r=re
|
||||
function visibleMessages(){return messages.filter(m=>{
|
||||
if(thread)return m.topic===thread;
|
||||
return filter==='All'||m.from===filter||(m.recipients||[m.to]).some(r=>r===filter||r==='All')})}
|
||||
function renderLog(force=false){
|
||||
const visible=visibleMessages();
|
||||
const signature=JSON.stringify([filter,thread,visible.length,visible.length&&visible[0].id,visible.length&&visible[visible.length-1].id,receipts,visible.filter(m=>m.corrected_by||m.pinned||m.question).map(m=>[m.id,m.corrected_by,m.pinned,m.question&&m.question.answer])]);
|
||||
if(!force&&signature===shownSignature)return;
|
||||
const atBottom=nearBottom()||shownSignature==='';const oldTop=log.scrollTop;const frag=document.createDocumentFragment();let prev=null,day='';
|
||||
if(!visible.length){const e=document.createElement('div');e.className='empty';e.innerHTML=ic(thread?'thread':'chat')+'<strong>Nothing here yet</strong><span>'+(thread?'This thread has no recent messages; search in Knowledge for older ones.':'Messages will appear here.')+'</span>';frag.appendChild(e)}
|
||||
visible.forEach((m,i)=>{
|
||||
const d=parseDate(m.at||m.time),next=visible[i+1],cday=d?centralDay(d):String(m.time);
|
||||
if(cday!==day){const dv=document.createElement('div');dv.className='date';dv.innerHTML='<span>'+esc(dateLabel(d)||m.time)+'</span>';frag.appendChild(dv);day=cday;prev=null}
|
||||
const pd=prev?parseDate(prev.at||prev.time):null;
|
||||
const cont=prev&&prev.from===m.from&&prev.to===m.to&&prev.topic===m.topic&&d&&pd&&d-pd<300000&&!m.ack;
|
||||
const nd=next?parseDate(next.at||next.time):null;
|
||||
const last=!next||next.from!==m.from||next.to!==m.to||next.topic!==m.topic||!nd||!d||nd-d>=300000||next.ack;
|
||||
const p=byName[m.from]||{name:m.from,initials:String(m.from).slice(0,2)};const own=m.from===human;
|
||||
const row=document.createElement('div');row.className='mrow'+(own?' own':'')+(cont?' cont':'');
|
||||
const to=(m.recipients||[m.to]).map(r=>r===human?'you':r==='All'?'everyone':r).join(', ');
|
||||
if(!Array.isArray(m.attachments))m.attachments=[];
|
||||
const files=m.attachments.length?'<div class="files">'+m.attachments.map(f=>'<a href="files/'+encodeURIComponent(f)+'">'+ic('file','sm')+esc(f.slice(13))+'</a>').join('')+'</div>':'';
|
||||
const pinBtn='<button class="linkbtn" data-pin="'+m.id+'" data-unpin="'+(m.pinned?1:'')+'">'+(m.pinned?'Unpin':'Pin')+'</button>';
|
||||
const foot=last||m.ack||m.pinned?'<div class="foot"><span title="#'+m.id+' · '+esc(m.time)+'">'+esc(d?centralTime(d):m.time)+'</span>'+(own?receiptLine(m):'')+ackButton(m)+pinBtn+'</div>':'';
|
||||
row.innerHTML=face(p)+'<div class="stack"><div class="meta"><strong>'+esc(display(m.from))+'</strong><span class="arrow">→</span>'+esc(to)+chips(m)+'</div><div class="bubble'+(m.importance==='urgent'?' urgent':'')+'">'+fmt(m.text)+files+questionBox(m)+'</div>'+foot+'</div>';
|
||||
frag.appendChild(row);prev=m});
|
||||
if(!thread&&messages.length&&messages[0].id>1){const b=document.createElement('button');b.className='earlier';b.id='earlier';b.innerHTML=ic('clock','sm')+'Load earlier messages';frag.prepend(b)}
|
||||
log.replaceChildren(frag);shownSignature=signature;
|
||||
if(atBottom||force)scrollBottom();else{log.scrollTop=oldTop;el('newMessages').hidden=false}
|
||||
/* The log keeps a bounded window of the newest messages in the DOM (WINDOW rows; "Load earlier" adds WINDOW more).
|
||||
Rendering all of them (800+ long messages, 10,000 nodes, a 440,000 px column) and rebuilding the whole log on every
|
||||
new message or receipt froze the page for about two seconds each time. Now new messages are appended, a changed
|
||||
receipt or mark repaints only its row, and the whole window is rebuilt only when the view changes. */
|
||||
const WINDOW=150;
|
||||
let windowSize=WINDOW,shown={key:null,ids:[],sigs:new Map()};
|
||||
const dayKey=m=>{const d=parseDate(m.at||m.time);return d?centralDay(d):String(m.time)};
|
||||
const prevOf=(vis,i)=>i>0&&dayKey(vis[i-1])===dayKey(vis[i])?vis[i-1]:null; /* a date line starts a new group */
|
||||
function rowSig(m){return JSON.stringify([m.corrected_by||null,!!m.pinned,m.question?[m.question.answer,m.question.answered_by]:null,receipts[m.id]||null])}
|
||||
function dateLine(m){const d=parseDate(m.at||m.time);const dv=document.createElement('div');dv.className='date';dv.innerHTML='<span>'+esc(dateLabel(d)||m.time)+'</span>';return dv}
|
||||
function buildRow(m,prev,next){
|
||||
const d=parseDate(m.at||m.time);
|
||||
const pd=prev?parseDate(prev.at||prev.time):null;
|
||||
const cont=prev&&prev.from===m.from&&prev.to===m.to&&prev.topic===m.topic&&d&&pd&&d-pd<300000&&!m.ack;
|
||||
const nd=next?parseDate(next.at||next.time):null;
|
||||
const last=!next||next.from!==m.from||next.to!==m.to||next.topic!==m.topic||!nd||!d||nd-d>=300000||next.ack;
|
||||
const p=byName[m.from]||{name:m.from,initials:String(m.from).slice(0,2)};const own=m.from===human;
|
||||
const row=document.createElement('div');row.className='mrow'+(own?' own':'')+(cont?' cont':'');row.dataset.id=m.id;
|
||||
const to=(m.recipients||[m.to]).map(r=>r===human?'you':r==='All'?'everyone':r).join(', ');
|
||||
if(!Array.isArray(m.attachments))m.attachments=[];
|
||||
const files=m.attachments.length?'<div class="files">'+m.attachments.map(f=>'<a href="files/'+encodeURIComponent(f)+'">'+ic('file','sm')+esc(f.slice(13))+'</a>').join('')+'</div>':'';
|
||||
const pinBtn='<button class="linkbtn" data-pin="'+m.id+'" data-unpin="'+(m.pinned?1:'')+'">'+(m.pinned?'Unpin':'Pin')+'</button>';
|
||||
const foot=last||m.ack||m.pinned?'<div class="foot"><span title="#'+m.id+' · '+esc(m.time)+'">'+esc(d?centralTime(d):m.time)+'</span>'+(own?receiptLine(m):'')+ackButton(m)+pinBtn+'</div>':'';
|
||||
row.innerHTML=face(p)+'<div class="stack"><div class="meta"><strong>'+esc(display(m.from))+'</strong><span class="arrow">→</span>'+esc(to)+chips(m)+'</div><div class="bubble'+(m.importance==='urgent'?' urgent':'')+'">'+fmt(m.text)+files+questionBox(m)+'</div>'+foot+'</div>';
|
||||
return row}
|
||||
/* the first row in view and its offset, to keep the reader's place across a rebuild */
|
||||
function topRow(){const st=log.scrollTop;for(const r of log.querySelectorAll('.mrow'))if(r.offsetTop+r.offsetHeight>st)return {id:r.dataset.id,off:r.offsetTop-st};return null}
|
||||
function renderLog(force=false,preserve=false){
|
||||
const all=visibleMessages(),key=JSON.stringify([filter,thread]);
|
||||
if(!force&&!preserve&&shown.key===key&&shown.ids.length){
|
||||
const start=all.findIndex(m=>m.id===shown.ids[0]),vis=start<0?null:all.slice(start),n=shown.ids.length;
|
||||
const sameHead=vis&&vis.length>=n&&shown.ids.every((id,i)=>vis[i].id===id);
|
||||
/* append in place, unless the window has grown far past its size and the reader is at the bottom (then trim) */
|
||||
if(sameHead&&!(vis.length>windowSize+WINDOW&&nearBottom()))return patchLog(vis)}
|
||||
const atBottom=!preserve&&(force||nearBottom()||shown.key===null);const anchor=atBottom?null:topRow();
|
||||
const vis=all.slice(-windowSize),frag=document.createDocumentFragment();let day='';
|
||||
if(!vis.length){const e=document.createElement('div');e.className='empty';e.innerHTML=ic(thread?'thread':'chat')+'<strong>Nothing here yet</strong><span>'+(thread?'This thread has no recent messages; search in Knowledge for older ones.':'Messages will appear here.')+'</span>';frag.appendChild(e)}
|
||||
vis.forEach((m,i)=>{const k=dayKey(m);if(k!==day){frag.appendChild(dateLine(m));day=k}frag.appendChild(buildRow(m,prevOf(vis,i),vis[i+1]))});
|
||||
if(all.length>vis.length||(!thread&&messages.length&&messages[0].id>1)){const b=document.createElement('button');b.className='earlier';b.id='earlier';b.innerHTML=ic('clock','sm')+'Load earlier messages';frag.prepend(b)}
|
||||
log.replaceChildren(frag);
|
||||
shown={key,ids:vis.map(m=>m.id),sigs:new Map(vis.map(m=>[m.id,rowSig(m)]))};
|
||||
if(atBottom)scrollBottom();
|
||||
else if(anchor){const r=log.querySelector('.mrow[data-id="'+anchor.id+'"]');if(r)log.scrollTop=r.offsetTop-anchor.off}
|
||||
}
|
||||
function patchLog(vis){
|
||||
const n=shown.ids.length,added=vis.length-n,redo=new Set();
|
||||
for(let i=0;i<n;i++)if(shown.sigs.get(vis[i].id)!==rowSig(vis[i]))redo.add(i);
|
||||
if(!added&&!redo.size)return;
|
||||
const atBottom=nearBottom();
|
||||
if(added)redo.add(n-1); /* the old last row may lose its footer or join the new group */
|
||||
for(const i of redo){const m=vis[i],node=log.querySelector('.mrow[data-id="'+m.id+'"]');
|
||||
if(node)node.replaceWith(buildRow(m,prevOf(vis,i),vis[i+1]));shown.sigs.set(m.id,rowSig(m))}
|
||||
if(added){const frag=document.createDocumentFragment();
|
||||
for(let i=n;i<vis.length;i++){const m=vis[i];if(dayKey(m)!==dayKey(vis[i-1]))frag.appendChild(dateLine(m));
|
||||
frag.appendChild(buildRow(m,prevOf(vis,i),vis[i+1]));shown.ids.push(m.id);shown.sigs.set(m.id,rowSig(m))}
|
||||
log.appendChild(frag);
|
||||
if(atBottom)scrollBottom();else el('newMessages').hidden=false}
|
||||
}
|
||||
function markRead(){const me=overviewData&&overviewData.agents.find(a=>a.name===human);if(me&&me.unread)post('api/read',{}).then(()=>refresh()).catch(()=>{})}
|
||||
function view(){mountPeople();if(!active())return;updateHeader();if(loaded)renderLog(true);if(!document.hidden&&document.hasFocus())markRead()}
|
||||
function setFilter(v){filter=v;thread=null;el('shell').classList.remove('open');if(active())view();else show('chat')}
|
||||
function setThread(t){thread=t;if(active())view();else show('chat');el('to').value='@thread'}
|
||||
function setFilter(v){filter=v;thread=null;windowSize=WINDOW;el('shell').classList.remove('open');if(active())view();else show('chat')}
|
||||
function setThread(t){thread=t;windowSize=WINDOW;if(active())view();else show('chat');el('to').value='@thread'}
|
||||
function updateHeader(){
|
||||
el('threadbar').classList.toggle('on',!!thread);
|
||||
if(thread){const m=messages.find(x=>x.topic===thread);el('threadName').textContent=thread+(m&&m.subject&&m.subject!==thread?' · '+m.subject:'')}
|
||||
@@ -1362,12 +1407,15 @@ document.addEventListener('click',async e=>{
|
||||
ans.disabled=true;try{await post('api/answer',{question:Number(ans.dataset.answer),answer:value});await refreshMarks();refresh();el('feedback').className='';el('feedback').textContent='Answer sent.'}catch(err){ans.disabled=false;alert(err.message)}return}
|
||||
const pb=e.target.closest('[data-pin]');if(pb){try{await post('api/pin',{id:Number(pb.dataset.pin),unpin:!!pb.dataset.unpin});await refreshMarks()}catch(err){alert(err.message)}return}
|
||||
const a=e.target.closest('[data-ack]');if(a){a.disabled=true;try{await post('api/ack',{ids:[Number(a.dataset.ack)]});await pollPeople();refresh()}catch(err){a.disabled=false;alert(err.message)}}});
|
||||
async function loadEarlier(){const first=messages[0];if(!first)return;const before=log.scrollHeight;
|
||||
try{const older=await getJSON('messages?before='+first.id);if(!older.length){el('earlier')&&el('earlier').remove();return}
|
||||
messages=older.concat(messages);shownSignature='x';renderLog();log.scrollTop=log.scrollHeight-before}catch(e){}}
|
||||
async function refreshMarks(){if(!messages.length)return;try{const ids=new Set(messages.slice(-400).map(m=>m.id));
|
||||
const tail=await getJSON('messages?before='+(messages[messages.length-1].id+1));const byId=Object.fromEntries(tail.map(m=>[m.id,m]));
|
||||
let changed=false;for(const m of messages){const n=byId[m.id];if(n&&ids.has(m.id)&&(JSON.stringify(n.corrected_by)!==JSON.stringify(m.corrected_by)||!!n.pinned!==!!m.pinned||JSON.stringify(n.question)!==JSON.stringify(m.question))){m.corrected_by=n.corrected_by;m.pinned=n.pinned;m.question=n.question;changed=true}}
|
||||
/* show WINDOW more rows: from memory first, from the server once everything loaded is on screen */
|
||||
async function loadEarlier(){const first=messages[0];if(!first)return;
|
||||
if(visibleMessages().length<=shown.ids.length){
|
||||
try{const older=await getJSON('messages?before='+first.id);if(!older.length){el('earlier')&&el('earlier').remove();return}
|
||||
messages=older.concat(messages)}catch(e){return}}
|
||||
windowSize=shown.ids.length+WINDOW;renderLog(false,true)}
|
||||
async function refreshMarks(){if(!messages.length||!active())return;try{
|
||||
const tail=await getJSON('messages?before='+(messages[messages.length-1].id+1));const byId=new Map(tail.map(m=>[m.id,m]));
|
||||
let changed=false;for(const m of messages){const n=byId.get(m.id);if(n&&(JSON.stringify(n.corrected_by)!==JSON.stringify(m.corrected_by)||!!n.pinned!==!!m.pinned||JSON.stringify(n.question)!==JSON.stringify(m.question))){m.corrected_by=n.corrected_by;m.pinned=n.pinned;m.question=n.question;changed=true}}
|
||||
if(changed&&active())renderLog()}catch(e){}}
|
||||
async function catchUp(sinceWhen,title){
|
||||
let d,o;try{[d,o]=await Promise.all([getJSON('api/digest?since='+encodeURIComponent(sinceWhen)),getJSON('api/overview')])}catch(e){return}
|
||||
@@ -1412,7 +1460,8 @@ el('notify').onclick=async()=>{if(!('Notification' in window))return alert('This
|
||||
if(store.get('notify',false)&&Notification.permission==='granted'){store.set('notify',false)}else{const p=await Notification.requestPermission();store.set('notify',p==='granted')}paintNotify()};
|
||||
window.addEventListener('focus',()=>{if(active())markRead()});
|
||||
paintNotify();paintSound();
|
||||
(async()=>{await pollPeople();await pollMessages();if(active())view();setInterval(pollMessages,2000);setInterval(pollPeople,6000);setInterval(refreshMarks,20000)})();
|
||||
(async()=>{await pollPeople();await pollMessages();if(active())view();
|
||||
every(pollMessages,2000,10000);every(pollPeople,6000,60000);every(refreshMarks,20000,null)})(); /* hidden: still poll for sound and notifications, slower */
|
||||
return {view,openThread:setThread};
|
||||
})();
|
||||
el('pulseHours').onchange=renderPulse;
|
||||
@@ -1421,11 +1470,11 @@ const startQ=new URLSearchParams(location.search).get('q');
|
||||
const onBoard=/\/board\/?$/.test(location.pathname);
|
||||
show(startQ?'knowledge':(location.hash.slice(1)||(onBoard?store.get('board.view','overview'):'chat')));
|
||||
refresh().then(()=>{renderDigest();if(current==='activity'){renderPulse();renderFeed()}if(current==='launch')renderLaunch();if(current==='decomp')renderDecomp();if(current==='logbook')renderLogbook();if(current==='health')renderTools();if(startQ){el('q').value=startQ;el('searchForm').requestSubmit()}});
|
||||
setInterval(refresh,3000);
|
||||
setInterval(()=>{if(current==='overview')renderDigest()},30000);
|
||||
setInterval(()=>{if(current==='activity')renderPulse()},60000);
|
||||
setInterval(()=>{if(current==='launch')renderLaunch()},5000);
|
||||
setInterval(()=>{if(current==='activity')renderFeed()},3000);
|
||||
every(refresh,3000,30000);
|
||||
every(()=>{if(current==='overview')return renderDigest()},30000,null);
|
||||
every(()=>{if(current==='activity')return renderPulse()},60000,null);
|
||||
every(()=>{if(current==='launch')return renderLaunch()},5000,null);
|
||||
every(()=>{if(current==='activity')return renderFeed()},3000,null);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -177,9 +177,8 @@ a.tag:hover { text-decoration: none; filter: brightness(.96); }
|
||||
/* ------------------------------------------------------------------------------------------------ live dot */
|
||||
.live { display: inline-flex; align-items: center; gap: 7px; font-size: 12px; color: var(--muted); white-space: nowrap; }
|
||||
.live .dot { width: 8px; height: 8px; background: var(--amber); box-shadow: 0 0 0 3px var(--amber-soft); }
|
||||
.live.ok .dot, .connection.connected .dot { background: var(--green); box-shadow: 0 0 0 3px var(--green-soft); animation: breathe 2.4s ease-in-out infinite; }
|
||||
.live.ok .dot, .connection.connected .dot { background: var(--green); box-shadow: 0 0 0 3px var(--green-soft); animation: pulse 2.4s ease-in-out infinite; } /* opacity only: an endless box-shadow animation repainted every frame */
|
||||
.live.bad .dot, .connection.failed .dot { background: var(--red); box-shadow: 0 0 0 3px var(--red-soft); }
|
||||
@keyframes breathe { 0%, 100% { box-shadow: 0 0 0 3px var(--green-soft); } 50% { box-shadow: 0 0 0 6px transparent; } }
|
||||
@keyframes pulse { 0% { opacity: 1; } 50% { opacity: .35; } 100% { opacity: 1; } }
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
|
||||
Reference in new issue
Block a user