From 3f791cb120df688ef9313f18b86fcfb927d467df Mon Sep 17 00:00:00 2001 From: sravan Date: Fri, 3 Jul 2026 17:17:03 +0530 Subject: [PATCH] feat(chat): live typing indicators for DMs and groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/public/home.html | 97 ++++++++++++++++++++++++++++++++++++----- server/signaling.js | 14 ++++++ 2 files changed, 99 insertions(+), 12 deletions(-) diff --git a/server/public/home.html b/server/public/home.html index 04dd9ae..dd3ac6a 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -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 @@ - @@ -1101,17 +1104,23 @@ function avatarHTML(it, big){ const img=it.avatar?'':''; return '
'+inner+img+corner+'
'; } +// 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 'Draft: '+pEsc(draft); } // unsent draft (#1) + if(it.callActive){ return ''+ic('phone',12)+' Ongoing call'; } + if(it.last_from_me && it.last_status && it.last_body){ // my last message → tick (#3) + return ''+ic(it.last_status==='sent'?'check':'checkCheck',13)+' '+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='Draft: '+pEsc(draft); } // unsent draft (#1) - else if(it.callActive){ inner=''+ic('phone',12)+' Ongoing call'; } - else if(it.last_from_me && it.last_status && it.last_body){ // my last message → tick (#3) - inner=''+ic(it.last_status==='sent'?'check':'checkCheck',13)+' '+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?(''+pEsc(tl)+''):rowPreviewHTML(it); // live "typing…" wins over the preview return '
' + avatarHTML(it,false) + '
'+pEsc(it.name)+''+pEsc(fmtTime(it.last_at))+'
' @@ -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 '
' + '
' + '' @@ -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:' or 'group:'. +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=''+pEsc(lbl)+''; } else if(it){ prev.innerHTML=rowPreviewHTML(it); } } + } +} function renderChatPanel(){ const el=document.getElementById('chatPanel'); if(!selected){ el.classList.add('center'); el.innerHTML=welcomeHTML(); wireWelcome(); } diff --git a/server/signaling.js b/server/signaling.js index eab46f0..d549ee6 100644 --- a/server/signaling.js +++ b/server/signaling.js @@ -41,6 +41,20 @@ function handle(ws, m, req) { if (!msg.delivered_at) { R.messages.markDelivered(m.id); try { CHAT.pushToUser(msg.sender_id, { type: 'chat-delivered', id: m.id, with: msg.recipient_id }); } catch (_) {} } break; } + // Live "is typing…" — ephemeral, never persisted. Relay to the DM peer, or fan out to + // group members (membership-checked). The receiver auto-expires it, so a lost 'off' is harmless. + case 'chat-typing': { + const uid = ws._chatUserId; if (!uid) break; + const on = !!m.on; + let name = ''; try { const u = R.users.byId(uid); name = (u && u.name) || ''; } catch (_) {} + if (m.group) { + let members; try { if (!R.conversations.isMember(m.group, uid)) break; members = R.conversations.members(m.group); } catch (_) { break; } + for (const mid of members) { if (mid !== uid) { try { CHAT.pushToUser(mid, { type: 'chat-typing', group: m.group, from: uid, name, on }); } catch (_) {} } } + } else if (m.to) { + try { CHAT.pushToUser(m.to, { type: 'chat-typing', from: uid, name, on }); } catch (_) {} + } + break; + } // --- Meetings (mesh): create a room, join by code, relay SDP/ICE peer-to-peer --- case 'meeting-create': { let code; do { code = A.numericCode(6); } while (meetingRooms.has(code));