diff --git a/server/chat.js b/server/chat.js index f2191fb..80e2465 100644 --- a/server/chat.js +++ b/server/chat.js @@ -70,9 +70,14 @@ async function broadcastPresence(userId) { if (!userId) return; // Carry last_seen too, so a contact going offline immediately reads "Last seen just now" instead of a // bare "Offline" until the next sidebar reload (#2). + const online = isOnline(userId); let lastSeen = null; try { const u = await repos().users.byId(userId); lastSeen = (u && u.last_seen) || null; } catch (_) {} - const payload = JSON.stringify({ type: 'presence', userId, online: isOnline(userId), status: await effectiveStatus(userId), lastSeen }); + // #8: never broadcast a NULL last-seen for someone who's offline — touchSeen() is fire-and-forget on + // disconnect, so the DB write can lag this broadcast and the client would flap to a bare "Offline". They're + // leaving now, so "just now" is accurate. + if (!lastSeen && !online) lastSeen = Date.now(); + const payload = JSON.stringify({ type: 'presence', userId, online, status: await effectiveStatus(userId), lastSeen }); deliverPresenceLocal(userId, payload); // this instance pubsub.publish('presence', { userId, payload }); // other instances (no-op on memory) } diff --git a/server/public/home.html b/server/public/home.html index a100402..cf6c0ef 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -1604,6 +1604,7 @@ function openStorage(){ let ME={}; let CONTACTS=[]; // team users (for new DMs / picking group members) let ROWS=[]; // sidebar items: {kind:'dm'|'group', id, name, online?, members?, last_body, last_at, last_from_me, unread} +const _lastSeen={}; // #8: userId -> last-seen ts, kept STICKY so a rebuild/presence event that lacks it can't flap the label back to "Offline" let selected=null; // {kind,id} or null = welcome let convoIsGroup=false; // the open thread is a group (drives per-message sender labels) let THREAD=[]; @@ -2032,6 +2033,10 @@ async function loadSidebar(){ const selfRow=items.find(i=>i.kind==='dm' && i.id===(ME&&ME.id)); if(selfRow){ selfRow.self=true; selfRow.name='You'; selfRow.avatar=ME.avatarUrl||selfRow.avatar||null; } else if(ME&&ME.id){ items.push({ kind:'dm', id:ME.id, name:'You', self:true, avatar:ME.avatarUrl||null, online:false, last_body:'', last_at:0, last_from_me:false, unread:0 }); } + // #8: keep last-seen sticky. The server occasionally returns null (a presence broadcast can race ahead of + // the last_seen DB write), which used to flip the subtitle from "last seen …" to "Offline". Remember any + // known value and backfill nulls from it so the label stays stable. + items.forEach(it=>{ if(it.kind==='dm'&&!it.self){ if(it.lastSeen) _lastSeen[it.id]=it.lastSeen; else if(_lastSeen[it.id]) it.lastSeen=_lastSeen[it.id]; } }); ROWS=items; renderChats(searchVal()); updateRailUnread(); @@ -2291,13 +2296,14 @@ function startEdit(m){ const bar=document.getElementById('replyBar'); if(bar){ bar.innerHTML=''+ic('edit',13)+' Editing message'+ic('x',15)+''; bar.style.display='flex'; const x=document.getElementById('editCancel'); if(x) x.onclick=cancelEdit; } const inp=document.getElementById('msgInput'); if(inp){ inp.value=m.body||''; autoGrow(inp); inp.focus(); } } -function cancelEdit(){ editTarget=null; const bar=document.getElementById('replyBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; } const inp=document.getElementById('msgInput'); if(inp){ inp.value=''; autoGrow(inp); } } +function cancelEdit(){ editTarget=null; const bar=document.getElementById('replyBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; } const inp=document.getElementById('msgInput'); if(inp){ inp.value=''; autoGrow(inp); } try{ if(selected) setDraft(selected.kind, selected.id, ''); }catch(_){} } // #14: clear the draft too async function saveEdit(text){ const m=editTarget; if(!m) return; if(!text){ cancelEdit(); return; } // empty → treat as cancel (delete is a separate action) if(text===(m.body||'')){ cancelEdit(); return; } // no change editTarget=null; const bar=document.getElementById('replyBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; } const inp=document.getElementById('msgInput'); if(inp){ inp.value=''; autoGrow(inp); } + try{ if(selected) setDraft(selected.kind, selected.id, ''); }catch(_){} // #14: clear the draft, else the edited text reappears and re-sends as a NEW message try{ await postJSON('/api/messages/edit',{ id:m.id, body:text }); const t=THREAD.find(x=>x.id===m.id); if(t){ t.body=text; t.edited_at=Date.now(); updateBubble(t); } @@ -2636,7 +2642,7 @@ async function startOrJoinDmCall(otherId){ function onPresence(d){ if(!d||!d.userId) return; const it=rowFor('dm', d.userId); - if(it){ it.online=!!d.online; it.status=d.status||'active'; if(d.lastSeen) it.lastSeen=d.lastSeen; renderChats(searchVal()); } // #2: keep last-seen fresh + if(it){ it.online=!!d.online; it.status=d.status||'active'; if(d.lastSeen){ it.lastSeen=d.lastSeen; _lastSeen[d.userId]=d.lastSeen; } renderChats(searchVal()); } // #2/#8: keep last-seen fresh + sticky if(selected && selected.kind==='dm' && selected.id===d.userId && it){ const sub=document.querySelector('#convoTitle .st'); if(sub) sub.textContent=dmSubLabel(it); const dot=document.querySelector('.convo-head .avatar .dot'); if(dot) dot.className='dot '+statusCls(it); @@ -3296,7 +3302,7 @@ async function openConvo(kind,id){ if(inpEl){ const _d=getDraft(kind,id); if(_d){ inpEl.value=_d; autoGrow(inpEl); } // restore unsent draft inpEl.addEventListener('paste', onPaste); - inpEl.addEventListener('input', ()=>{ maybeAutocorrect(inpEl); autoGrow(inpEl); setDraft(kind,id,inpEl.value); noteTyping(); }); // auto-correct common typos, keep the draft in sync + emit "typing…" + inpEl.addEventListener('input', ()=>{ maybeAutocorrect(inpEl); autoGrow(inpEl); if(!editTarget) setDraft(kind,id,inpEl.value); noteTyping(); }); // auto-correct common typos, keep the draft in sync (#14: not while editing) + emit "typing…" inpEl.addEventListener('blur', ()=>{ stopTyping(); setTimeout(()=>{ const a=document.activeElement; const stillTyping=a&&(a.tagName==='TEXTAREA'||a.tagName==='INPUT'); if(!stillTyping && window.__bzResetKb) window.__bzResetKb(); }, 120); }); // #1: composer lost focus → keyboard is closing → drop the --kb lift so no empty gap remains inpEl.addEventListener('keydown', (e)=>{ if(e.key==='Enter' && !e.shiftKey && !isMobileUA()){ if(mentionItems && mentionItems.length) return; e.preventDefault(); sendMessage(); } }); // Desktop: Enter sends, Shift+Enter = newline. Mobile: the Return key inserts a newline (default) — you send with the send button, like every native chat app. // Desktop: LEFT-click a red-squiggled word → native spelling suggestions right there (the shell