';
}
+// 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));