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:
+85
-12
@@ -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(); }
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user