feat(chat): live typing indicators for DMs and groups

Ephemeral over the chat WebSocket (no DB). Composer emits chat-typing on/off
(throttled 2.5s, auto-stop after 4s idle / on send / on leaving the chat).
Server relays to the DM peer or fans out to group members (membership-checked).
Receiver shows 'typing…' in the conversation header subtitle and the sidebar
row preview (brand-blue italic), with per-sender auto-expiry so a dropped 'off'
can't stick. Group shows names ('Alice is typing…', 'N people are typing…').

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 17:17:03 +05:30
parent 820e7a08fc
commit 3f791cb120
2 changed files with 99 additions and 12 deletions
+85 -12
View File
@@ -239,6 +239,9 @@
.convo-back:hover{background:#dbe6fb;}
.convo-head .nm{font-weight:700;color:var(--ink);}
.convo-head .st{font-size:.78rem;color:var(--muted);}
/* Live "typing…" — brand blue + italic, in both the header subtitle and the sidebar preview. */
.convo-head .st.typing{color:var(--blue);font-weight:600;font-style:italic;}
.typing-prev{color:var(--blue);font-weight:600;font-style:italic;}
/* In-chat search: the header turns into a search bar with count + up/down jump arrows. */
.convo-search-head{position:absolute;inset:0;display:flex;align-items:center;gap:.4rem;padding:0 .8rem;background:var(--card);z-index:6;}
.convo-search-head input{flex:1;min-width:0;border:none;background:transparent;font-size:.95rem;color:var(--ink);}
@@ -767,7 +770,7 @@
<body>
<script src="/icons.js?v=4"></script>
<script src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script>
<script>window.__BUILD='2026-07-02-batch38';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
<script>window.__BUILD='2026-07-03-batch39';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
// Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load
// (emojis stay as plain Unicode). (#5)
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
@@ -1101,17 +1104,23 @@ function avatarHTML(it, big){
const img=it.avatar?'<img class="av-img" src="'+pEsc(it.avatar)+'" alt="" onerror="this.remove()">':'';
return '<div class="avatar'+(isG?' grp':'')+'" style="'+sz+'background:'+avColor(it.name)+'">'+inner+img+corner+'</div>';
}
// The sidebar row's preview line (draft / ongoing call / last message w/ tick). Factored out so a
// live typing indicator can temporarily replace it and then restore the real preview on stop.
function rowPreviewHTML(it){
const active=selected&&selected.kind===it.kind&&selected.id===it.id;
const isG=it.kind==='group';
const draft=getDraft(it.kind,it.id);
if(draft && !active){ return '<span class="draft-lbl">Draft:</span> '+pEsc(draft); } // unsent draft (#1)
if(it.callActive){ return '<span class="call-on">'+ic('phone',12)+' Ongoing call</span>'; }
if(it.last_from_me && it.last_status && it.last_body){ // my last message → tick (#3)
return '<span class="prev-tick '+it.last_status+'">'+ic(it.last_status==='sent'?'check':'checkCheck',13)+'</span> '+pEsc(it.last_body); }
return pEsc(it.last_body?((it.last_from_me?'You: ':'')+it.last_body):(isG?((it.members||0)+' members'):'No messages yet'));
}
function rowHTML(it){
const active=selected&&selected.kind===it.kind&&selected.id===it.id;
const cls=['chat-row']; if(active)cls.push('active'); if(it.unread>0)cls.push('unread'); if(it.self)cls.push('self');
const isG=it.kind==='group';
const draft=getDraft(it.kind,it.id);
let inner;
if(draft && !active){ inner='<span class="draft-lbl">Draft:</span> '+pEsc(draft); } // unsent draft (#1)
else if(it.callActive){ inner='<span class="call-on">'+ic('phone',12)+' Ongoing call</span>'; }
else if(it.last_from_me && it.last_status && it.last_body){ // my last message → tick (#3)
inner='<span class="prev-tick '+it.last_status+'">'+ic(it.last_status==='sent'?'check':'checkCheck',13)+'</span> '+pEsc(it.last_body); }
else { inner=pEsc(it.last_body?((it.last_from_me?'You: ':'')+it.last_body):(isG?((it.members||0)+' members'):'No messages yet')); }
const tl=typingLabel(it.kind,it.id);
const inner=tl?('<span class="typing-prev">'+pEsc(tl)+'</span>'):rowPreviewHTML(it); // live "typing…" wins over the preview
return '<div class="'+cls.join(' ')+'" data-kind="'+it.kind+'" data-id="'+pEsc(it.id)+'">'
+ avatarHTML(it,false)
+ '<div class="chat-main"><div class="chat-top"><span class="chat-name">'+pEsc(it.name)+'</span><span class="chat-time">'+pEsc(fmtTime(it.last_at))+'</span></div>'
@@ -1218,9 +1227,11 @@ function wireWelcome(){ document.querySelectorAll('#chatPanel .wcard').forEach(c
// A DM's subtitle tracks the LIVE call state (pushed on call start AND end) so it can't get stuck
// on "In a call" after the call ends — unlike it.status, which is only derived at conversation load.
function dmSubLabel(it){ return (it&&it.callActive)?'In a call':statusLabel(it); }
// The conversation header's default subtitle (used to restore it after a typing indicator clears).
function subtitleFor(it){ if(!it) return ''; return it.self?'Message yourself':(it.kind==='group'?((it.members||0)+' members'):dmSubLabel(it)); }
function convoShellHTML(it){
const isG=it.kind==='group';
const sub=it.self?'Message yourself':(isG?((it.members||0)+' members'):dmSubLabel(it));
const sub=subtitleFor(it);
return '<div class="convo">'
+ '<div class="convo-head">'
+ '<button class="convo-back" id="convoBack" title="Back (Esc)" aria-label="Back">'+ic('arrowLeft',18)+'</button>'
@@ -1682,9 +1693,11 @@ 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', ()=>{ autoGrow(inpEl); setDraft(kind,id,inpEl.value); }); // keep the draft in sync
inpEl.addEventListener('input', ()=>{ autoGrow(inpEl); setDraft(kind,id,inpEl.value); noteTyping(); }); // keep the draft in sync + emit "typing…"
inpEl.addEventListener('blur', stopTyping);
inpEl.addEventListener('keydown', (e)=>{ if(e.key==='Enter' && !e.shiftKey){ if(mentionItems && mentionItems.length) return; e.preventDefault(); sendMessage(); } }); // Enter sends, Shift+Enter = newline
}
refreshTyping(kind,id); // if someone's already typing in this conversation, show it right away
const ci=document.getElementById('convoInfo'); if(ci) ci.onclick=()=>openGroupInfo(id);
const ct=document.getElementById('convoTitle'); if(ct) ct.onclick=()=>{ if(kind==='group') openGroupInfo(id); else { const r=rowFor(kind,id); openSharedItems('dm', id, (r&&r.name)||it.name||''); } }; // name → group: info+media; DM: media/files
const cc=document.getElementById('convoCall'); if(cc) cc.onclick=()=>(kind==='group'?startOrJoinGroupCall(id):startOrJoinDmCall(id));
@@ -1892,6 +1905,7 @@ function openPollModal(gid){
}
async function selectChat(kind,id){
ensureNotifyPermission();
stopTyping(); // leaving the previous conversation → tell that peer we stopped
selected={kind,id};
document.body.classList.add('chat-open'); // mobile: show the conversation pane
const it=rowFor(kind,id); _openUnread=(it&&it.unread)||0; if(it) it.unread=0; // capture before reset (#3: open at first unread)
@@ -1904,6 +1918,7 @@ async function sendMessage(){
const text=inp.value.trim();
if(editTarget){ saveEdit(text); return; } // in edit mode → save the edit instead of sending new
if((!text&&!pendingAttach)||!selected) return;
stopTyping(); // sending → we're no longer "typing"
inp.value=''; inp.style.height='auto'; setDraft(selected.kind, selected.id, ''); // sent → clear the draft
const replyTo=replyTarget?replyTarget.id:null;
const attachmentId=pendingAttach?pendingAttach.id:null;
@@ -2112,6 +2127,7 @@ function onChatMessage(m){
const isGroupMsg=!!m.conversation_id;
const kind=isGroupMsg?'group':'dm';
const rid=isGroupMsg?m.conversation_id:(m.from===ME.id?m.to:m.from);
if(m.from&&m.from!==ME.id) clearTypingFrom(kind,rid,m.from); // their message landed → drop their "typing…"
let it=rowFor(kind,rid); const wasNew=!it;
if(!it){ loadSidebar(); } // first DM / a new group we were added to — refresh the list
// Keep the thread cache warm so a notification click can render this chat synchronously.
@@ -2153,10 +2169,67 @@ function connectChatWs(){
try{
chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
chatWs.onopen=()=>{ try{ chatWs.send(JSON.stringify({type:'chat-hello'})); }catch(_){} };
chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-deleted') onChatDeleted(d); else if(d.type==='chat-edited') onChatEdited(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='presence') onPresence(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); };
chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-deleted') onChatDeleted(d); else if(d.type==='chat-edited') onChatEdited(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='presence') onPresence(d); else if(d.type==='chat-typing') onTyping(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); };
chatWs.onclose=()=>{ setTimeout(connectChatWs, 3000); }; // auto-reconnect
}catch(_){}
}
// ---- Typing indicators (ephemeral, over the chat socket) ----------------------------------
// Outgoing: while you type in the open conversation we send `chat-typing on` (throttled to once
// per ~2.5s), and `off` after 4s idle / on send / on leaving the chat.
let _typingOn=false, _typingIdleT=null, _lastTypingSent=0;
function _cssEsc(s){ try{ return CSS.escape(String(s)); }catch(_){ return String(s).replace(/[^\w-]/g,'\\$&'); } }
function _typingSend(on){
if(!chatWs||chatWs.readyState!==1||!selected||selected.self) return;
const t=selected.kind==='group'?{group:selected.id}:{to:selected.id};
try{ chatWs.send(JSON.stringify(Object.assign({type:'chat-typing',on:!!on},t))); }catch(_){}
}
function noteTyping(){ // call on every composer keystroke
if(!selected||selected.self) return;
const now=Date.now();
if(!_typingOn || now-_lastTypingSent>2500){ _typingOn=true; _lastTypingSent=now; _typingSend(true); }
clearTimeout(_typingIdleT); _typingIdleT=setTimeout(stopTyping, 4000);
}
function stopTyping(){ clearTimeout(_typingIdleT); if(_typingOn){ _typingOn=false; _typingSend(false); } }
// Incoming: per-conversation set of who's currently typing, each with an auto-expiry so a dropped
// 'off' can't leave the indicator stuck. Keyed 'dm:<userId>' or 'group:<groupId>'.
const TYPING={};
function _tKey(kind,id){ return kind+':'+id; }
function onTyping(d){
if(!d||!d.from||d.from===ME.id) return; // ignore our own echo (shouldn't happen, but be safe)
const kind=d.group?'group':'dm', id=d.group||d.from, key=_tKey(kind,id);
let m=TYPING[key]; if(!m){ m=new Map(); TYPING[key]=m; }
const cur=m.get(d.from); if(cur&&cur.t) clearTimeout(cur.t);
if(d.on){ const t=setTimeout(()=>{ m.delete(d.from); if(!m.size) delete TYPING[key]; refreshTyping(kind,id); }, 6000); m.set(d.from,{name:d.name||'',t}); }
else { m.delete(d.from); if(!m.size) delete TYPING[key]; }
refreshTyping(kind,id);
}
function clearTypingFrom(kind,id,userId){ // a real message arrived → they stopped typing
const key=_tKey(kind,id), m=TYPING[key]; if(!m) return;
const cur=m.get(userId); if(cur&&cur.t) clearTimeout(cur.t);
m.delete(userId); if(!m.size) delete TYPING[key]; refreshTyping(kind,id);
}
function typingLabel(kind,id){
const m=TYPING[_tKey(kind,id)]; if(!m||!m.size) return '';
if(kind==='dm') return 'typing…';
const names=[...m.values()].map(v=>firstName(v.name||'Someone'));
if(names.length===1) return names[0]+' is typing…';
if(names.length===2) return names[0]+' and '+names[1]+' are typing…';
return names.length+' people are typing…';
}
function refreshTyping(kind,id){
const it=rowFor(kind,id), lbl=typingLabel(kind,id);
if(selected && selected.kind===kind && selected.id===id){ // open conversation → header subtitle
const st=document.querySelector('#convoTitle .st');
if(st){ if(lbl){ st.textContent=lbl; st.classList.add('typing'); } else { st.classList.remove('typing'); st.textContent=subtitleFor(it||selected); } }
}
if(listEl){ // sidebar row preview
const row=listEl.querySelector('.chat-row[data-kind="'+kind+'"][data-id="'+_cssEsc(id)+'"]');
const prev=row&&row.querySelector('.chat-prev');
if(prev){ if(lbl){ prev.innerHTML='<span class="typing-prev">'+pEsc(lbl)+'</span>'; } else if(it){ prev.innerHTML=rowPreviewHTML(it); } }
}
}
function renderChatPanel(){
const el=document.getElementById('chatPanel');
if(!selected){ el.classList.add('center'); el.innerHTML=welcomeHTML(); wireWelcome(); }