Fix #14 (edit leaves a draft) and #8 (last-seen flapping)

#14: after editing a message the edited text reappeared in the composer as a
draft and re-sent as a new message. The input listener saved a draft while
editing, but saveEdit/cancelEdit cleared only the input value, not the stored
draft — so openConvo restored it. Now editing never writes a draft, and
save/cancel clear it.

#8: a contact's subtitle flapped between "last seen …" and "Offline". A
loadSidebar rebuild replaced the row with the server's lastSeen, which is
sometimes null (the disconnect touchSeen is fire-and-forget and can lag the
presence broadcast). Now the client keeps last-seen sticky (never overwrites a
known value with null) and the server never broadcasts a null last-seen for an
offline user.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 22:37:06 +05:30
parent e359271157
commit ecc8be3dba
2 changed files with 15 additions and 4 deletions
+6 -1
View File
@@ -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)
}
+9 -3
View File
@@ -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='<span>'+ic('edit',13)+' Editing message</span><span class="rx" id="editCancel">'+ic('x',15)+'</span>'; 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