diff --git a/server/public/home.html b/server/public/home.html
index f85529d..682d99b 100644
--- a/server/public/home.html
+++ b/server/public/home.html
@@ -412,6 +412,29 @@
.meet-panel .mp-invite-link .mil-txt span{font-size:.7rem;color:var(--muted);line-height:1.3;display:block;}
.meet-panel .mp-copylink{flex:0 0 auto;display:inline-flex;align-items:center;gap:.3rem;background:var(--blue);color:#fff;border:none;border-radius:8px;padding:.4rem .7rem;font-size:.78rem;font-weight:600;cursor:pointer;}
.meet-panel .mp-copylink:hover{filter:brightness(1.05);}
+ /* In-meeting chat panel (brand-styled) */
+ .meet-chat{width:320px;}
+ .meet-chat .mc-to{display:flex;align-items:center;gap:.5rem;padding:.5rem .8rem;border-bottom:1px solid var(--line);font-size:.8rem;color:var(--muted);}
+ .meet-chat .mc-to select{flex:1;border:1px solid var(--line);border-radius:8px;padding:.35rem .5rem;font:inherit;font-size:.82rem;background:#fbfcfe;color:var(--ink);}
+ .meet-chat .mc-log{flex:1;overflow-y:auto;padding:.6rem .7rem;display:flex;flex-direction:column;gap:.4rem;}
+ .meet-chat .mc-empty{color:var(--muted);font-size:.82rem;text-align:center;margin:auto;padding:1rem;}
+ .meet-chat .mc-row{display:flex;}
+ .meet-chat .mc-row.mine{justify-content:flex-end;}
+ .meet-chat .mc-bub{max-width:82%;background:#f1f5f9;border-radius:12px;padding:.4rem .6rem;}
+ .meet-chat .mc-row.mine .mc-bub{background:var(--blue);color:#fff;}
+ .meet-chat .mc-row.priv .mc-bub{outline:1.5px dashed rgba(31,59,115,.4);}
+ .meet-chat .mc-row.mine.priv .mc-bub{outline-color:rgba(255,255,255,.55);}
+ .meet-chat .mc-nm{font-size:.7rem;font-weight:700;color:var(--blue);margin-bottom:.1rem;display:flex;gap:.35rem;align-items:center;}
+ .meet-chat .mc-row.mine .mc-nm{color:rgba(255,255,255,.9);}
+ .meet-chat .mc-priv{font-weight:600;font-size:.62rem;text-transform:uppercase;letter-spacing:.03em;background:rgba(31,59,115,.1);color:var(--blue);border-radius:99px;padding:.02rem .35rem;}
+ .meet-chat .mc-row.mine .mc-priv{background:rgba(255,255,255,.2);color:#fff;}
+ .meet-chat .mc-tx{font-size:.88rem;line-height:1.35;word-wrap:break-word;overflow-wrap:anywhere;}
+ .meet-chat .mc-at{font-size:.62rem;opacity:.6;margin-top:.15rem;text-align:right;}
+ .meet-chat .mc-foot{display:flex;gap:.4rem;padding:.5rem .6rem;border-top:1px solid var(--line);}
+ .meet-chat .mc-foot input{flex:1;border:1px solid var(--line);border-radius:9px;padding:.5rem .6rem;font:inherit;font-size:.86rem;background:#fbfcfe;color:var(--ink);outline:none;}
+ .meet-chat .mc-foot input:focus{border-color:var(--blue);}
+ .meet-chat .mc-send{flex:0 0 auto;width:38px;border:none;border-radius:9px;background:var(--brand,#FFC708);color:#1f2430;display:grid;place-items:center;cursor:pointer;}
+ .meet-ic .mc-badge{position:absolute;top:-3px;right:-3px;min-width:16px;height:16px;padding:0 3px;border-radius:99px;background:#e6394f;color:#fff;font-size:.62rem;font-weight:700;display:grid;place-items:center;box-shadow:0 0 0 2px var(--card,#fff);}
.meet-panel .chk{display:flex;align-items:center;gap:.5rem;padding:.4rem .5rem;border-radius:8px;cursor:pointer;font-size:.88rem;}
.meet-panel .chk:hover{background:#f6f8fb;}
.meet-panel .chk input{width:16px;height:16px;flex:0 0 16px;accent-color:var(--blue);margin:0;}
@@ -436,7 +459,7 @@
.meet-bar .tgl{background:#eef1f6;color:var(--blue);}
.meet-bar .tgl.off{background:#fee2e2;color:var(--red);}
.meet-bar .leave{background:#dc2626;color:#fff;}
- .meet-bar .meet-ic{width:46px;height:46px;padding:0;border-radius:50%;background:#e8edf5;color:var(--blue);display:inline-flex;align-items:center;justify-content:center;}
+ .meet-bar .meet-ic{position:relative;width:46px;height:46px;padding:0;border-radius:50%;background:#e8edf5;color:var(--blue);display:inline-flex;align-items:center;justify-content:center;}
.meet-bar .meet-ic:hover{background:#dbe4f0;}
.meet-bar .meet-ic.off{background:#fee2e2;color:var(--red);}
.meet-bar .meet-ic.on{background:var(--blue);color:#fff;} /* #9: screen-share highlighted while active (record keeps its red pulse via #meetRecBtn.on) */
@@ -894,7 +917,7 @@
-
@@ -1974,6 +1997,46 @@ function showMeetingReminder(mtg){ if(!mtg||!mtg.room) return; try{ playPing();
}
// In-call participants panel (everyone) + host controls (mute all, transfer host).
function meetParticipantsList(){ const a=[{id:'__local', name:((ME&&ME.name)||(ME&&ME.email)||'You')+' (you)'}]; meetPeers.forEach((p,pid)=>a.push({id:pid, name:meetNames.get(pid)||p.name||'Guest'})); return a; }
+// ---- In-meeting chat: ephemeral (lives only for the call). Message EVERYONE or DIRECT to one person. ----
+let meetChatLog=[], meetChatUnread=0, meetChatTo='';
+function resetMeetChat(){ meetChatLog=[]; meetChatUnread=0; meetChatTo=''; const c=document.getElementById('meetChat'); if(c) c.remove(); updateMeetChatBadge(); }
+function meetChatRecipients(){ const a=[]; meetPeers.forEach((pp,pid)=>a.push({id:pid, name:meetNames.get(pid)||pp.name||'Guest'})); return a; }
+function toggleMeetChat(){ const ex=document.getElementById('meetChat'); if(ex){ ex.remove(); return; } const meet=document.querySelector('.meet'); if(!meet) return; const p=document.createElement('div'); p.id='meetChat'; p.className='meet-panel meet-chat'; meet.appendChild(p); meetChatUnread=0; updateMeetChatBadge(); renderMeetChat(); }
+function updateMeetChatBadge(){ const b=document.getElementById('meetChatBadge'); if(!b) return; if(meetChatUnread>0){ b.textContent=meetChatUnread>9?'9+':meetChatUnread; b.style.display='grid'; } else b.style.display='none'; }
+function renderMeetChat(){
+ const p=document.getElementById('meetChat'); if(!p) return;
+ const recips=meetChatRecipients();
+ if(meetChatTo && !recips.some(r=>r.id===meetChatTo)) meetChatTo=''; // recipient left → fall back to Everyone
+ const head=''+ic('chat',15)+' Meeting chat
';
+ const toSel='To
';
+ const body=''+(meetChatLog.length?meetChatLog.map(mcRowHTML).join(''):'
No messages yet — say hello 👋
')+'
';
+ const foot='';
+ p.innerHTML=head+toSel+body+foot;
+ p.querySelector('.mp-x').onclick=()=>p.remove();
+ const sel=p.querySelector('#mcTo'); if(sel) sel.onchange=()=>{ meetChatTo=sel.value; };
+ const form=p.querySelector('#mcForm'); if(form) form.onsubmit=(e)=>{ e.preventDefault(); meetChatSend(); };
+ const log=p.querySelector('#mcLog'); if(log) log.scrollTop=log.scrollHeight;
+ const inp=p.querySelector('#mcInput'); if(inp) setTimeout(()=>inp.focus(),0);
+}
+function mcRowHTML(m){
+ const mine=m.from==='__self';
+ const priv=m.direct?(''+(mine?('to '+pEsc(m.toName||'someone')):'private')+''):'';
+ const head=mine?(m.direct?('You '+priv+'
'):''):(''+pEsc(m.name||'Guest')+priv+'
');
+ return ''+head+'
'+fmtInline(m.text||'')+'
'+fmtClock(m.at||Date.now())+'
';
+}
+function meetChatSend(){
+ const inp=document.getElementById('mcInput'); if(!inp) return; const text=(inp.value||'').trim(); if(!text) return;
+ let toName=''; if(meetChatTo){ const r=meetChatRecipients().find(x=>x.id===meetChatTo); toName=r?r.name:''; }
+ meetSend({type:'meeting-chat', text, to:meetChatTo||undefined});
+ meetChatAppend({from:'__self', name:'You', text, direct:!!meetChatTo, toName, at:Date.now()});
+ inp.value=''; inp.focus();
+}
+function meetChatAppend(m){
+ meetChatLog.push(m); if(meetChatLog.length>500) meetChatLog.shift();
+ const panel=document.getElementById('meetChat');
+ if(panel){ const log=document.getElementById('mcLog'); if(log){ const empty=log.querySelector('.mc-empty'); if(empty) empty.remove(); log.insertAdjacentHTML('beforeend', mcRowHTML(m)); log.scrollTop=log.scrollHeight; } }
+ else if(m.from!=='__self'){ meetChatUnread++; updateMeetChatBadge(); try{ playPing(); }catch(_){} }
+}
function refreshMeetPanel(){ if(document.getElementById('meetPanel')) renderMeetPanel(); }
// #6: track invitees who haven't joined yet. They show under "Not joined yet"; after 30s the
// invite is re-enabled for them. When they actually join (matched by user id), they're cleared.
@@ -3294,6 +3357,7 @@ function renderCall(){
+ ''
+ ''
+ ((ME&&ME.guest)?'':'') // #12: transcript is a signed-in feature (guests can't download it)
+ + ''
+ ''
+ ''
+ '';
@@ -3303,6 +3367,7 @@ function renderCall(){
document.getElementById('meetRecBtn').onclick=toggleRecord;
{ const tb=document.getElementById('meetTransBtn'); if(tb) tb.onclick=toggleTranscribe; }
document.getElementById('meetPplBtn').onclick=toggleMeetPanel;
+ { const cb=document.getElementById('meetChatBtn'); if(cb) cb.onclick=toggleMeetChat; }
{ const sb=document.getElementById('meetSpkBtn'); if(sb) sb.onclick=(e)=>{ e.stopPropagation(); openSpeakerMenu(sb); }; }
document.getElementById('meetLeaveBtn').onclick=leaveMeeting;
updateHostControls();
@@ -3560,6 +3625,7 @@ async function onMeetMsg(e){
if(m.type==='meeting-rejected'){ toast('The host didn’t let you in.'); leaveMeeting(true); return; }
if(m.type==='meeting-lobby-request'){ showLobbyRequest(m.peerId, m.name); return; } // host: someone wants in
if(m.type==='meeting-lobby-cancel'){ dismissLobbyRequest(m.peerId); return; } // that guest left the lobby
+ if(m.type==='meeting-chat'){ meetChatAppend({ from:m.from, name:m.name, text:m.text, direct:!!m.direct, at:m.at }); return; } // in-meeting chat
if(m.type==='meeting-ended'){ toast(m.reason==='unanswered'?'No answer':'Call ended'); leaveMeeting(true); return; } // 1:1 hangup / host ended / unanswered
if(m.type==='meeting-peer-joined'){
stopRingback(); removeWaitingTile(); _dmCallWaiting=null; // #17/#7: they answered → stop ring + drop the ringing tile
@@ -3654,7 +3720,7 @@ function leaveMeeting(forced){
meetPeers.forEach(p=>{ try{p.pc.close();}catch(_){} }); meetPeers.clear(); meetNames.clear(); meetAvatars.clear(); meetPeerUids.clear(); meetCamOff.clear(); meetInvited.forEach(e=>{ if(e.timer) clearTimeout(e.timer); }); meetInvited.clear(); meetMuted.clear();
if(meetLocalStream){ try{ meetLocalStream.getTracks().forEach(t=>t.stop()); }catch(_){} meetLocalStream=null; }
if(meetWs){ try{ meetWs.close(); }catch(_){} meetWs=null; }
- meetRoom=null; meetMyId=null; meetState='idle'; meetIsHost=false; meetHostId=null; meetRailLive(false);
+ meetRoom=null; meetMyId=null; meetState='idle'; meetIsHost=false; meetHostId=null; meetRailLive(false); resetMeetChat();
const ret=meetReturn; meetReturn=null; meetLeaving=false;
if(ME&&ME.guest){ renderGuestLeft(); return; } // guests have no chat to return to — show a leave screen
if(ret){ switchTab('chat'); selectChat(ret.kind, ret.id); } // land back on the originating chat
diff --git a/server/signaling.js b/server/signaling.js
index d46a6a6..1309531 100644
--- a/server/signaling.js
+++ b/server/signaling.js
@@ -153,6 +153,17 @@ function handle(ws, m, req) {
if (target && target.ws.readyState === 1) target.ws.send(JSON.stringify({ type: 'meeting-signal', from: ws._peerId, data: m.data }));
break;
}
+ // In-meeting chat: relay a message to EVERYONE (m.to empty) or DIRECT to one peer (m.to = peerId).
+ // Ephemeral (not persisted) — it lives only for the duration of the call.
+ case 'meeting-chat': {
+ const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
+ if (!peers) return;
+ const text = String(m.text || '').slice(0, 2000); if (!text) return;
+ const base = { type: 'meeting-chat', from: ws._peerId, name: ws._peerName || 'Guest', text, at: Date.now() };
+ if (m.to) { const target = peers.get(m.to); if (target && target.ws.readyState === 1) target.ws.send(JSON.stringify({ ...base, to: m.to, direct: true })); }
+ else { for (const [id, p] of peers) { if (id !== ws._peerId && p.ws.readyState === 1) p.ws.send(JSON.stringify(base)); } }
+ break;
+ }
// Relay a peer's mic/cam state to everyone else in the room (for the tile mute icon).
case 'meeting-state': {
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);