feat(meetings): in-meeting chat — Everyone or direct-to-participant, brand-styled (batch73)
Adds an ephemeral chat inside meetings (relayed over the meeting signaling socket, not persisted). A chat button in the meeting bar (with an unread badge) opens a brand-styled panel: pick "Everyone" or a specific participant (private), send with Enter/Send. Direct messages are marked private on both sides. Works for guests too. Chat state resets when you leave the call. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+69
-3
@@ -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 @@
|
||||
<body>
|
||||
<script src="/icons.js?v=5"></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-11-batch72';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||
<script>window.__BUILD='2026-07-11-batch73';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>
|
||||
@@ -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='<div class="mp-head"><b>'+ic('chat',15)+' Meeting chat</b><span style="flex:1"></span><button class="mp-x" title="Close">'+ic('x',16)+'</button></div>';
|
||||
const toSel='<div class="mc-to"><span>To</span><select id="mcTo"><option value=""'+(!meetChatTo?' selected':'')+'>Everyone</option>'+recips.map(r=>'<option value="'+pEsc(r.id)+'"'+(meetChatTo===r.id?' selected':'')+'>'+pEsc(r.name)+' (private)</option>').join('')+'</select></div>';
|
||||
const body='<div class="mc-log" id="mcLog">'+(meetChatLog.length?meetChatLog.map(mcRowHTML).join(''):'<div class="mc-empty">No messages yet — say hello 👋</div>')+'</div>';
|
||||
const foot='<form class="mc-foot" id="mcForm"><input id="mcInput" placeholder="Type a message…" autocomplete="off" maxlength="2000"><button type="submit" class="mc-send" title="Send">'+ic('send',16)+'</button></form>';
|
||||
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?('<span class="mc-priv">'+(mine?('to '+pEsc(m.toName||'someone')):'private')+'</span>'):'';
|
||||
const head=mine?(m.direct?('<div class="mc-nm">You '+priv+'</div>'):''):('<div class="mc-nm">'+pEsc(m.name||'Guest')+priv+'</div>');
|
||||
return '<div class="mc-row'+(mine?' mine':'')+(m.direct?' priv':'')+'"><div class="mc-bub">'+head+'<div class="mc-tx">'+fmtInline(m.text||'')+'</div><div class="mc-at">'+fmtClock(m.at||Date.now())+'</div></div></div>';
|
||||
}
|
||||
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(){
|
||||
+ '<button class="meet-ic" id="meetScreenBtn" title="Share screen">'+ic('monitor',20)+'</button>'
|
||||
+ '<button class="meet-ic host-only" id="meetRecBtn" title="Record meeting" style="display:none">'+ic('record',20)+'</button>'
|
||||
+ ((ME&&ME.guest)?'':'<button class="meet-ic" id="meetTransBtn" title="Live transcript">'+ic('fileText',20)+'</button>') // #12: transcript is a signed-in feature (guests can't download it)
|
||||
+ '<button class="meet-ic" id="meetChatBtn" title="Chat"><span class="mc-badge" id="meetChatBadge" style="display:none">0</span>'+ic('chat',20)+'</button>'
|
||||
+ '<button class="meet-ic" id="meetSpkBtn" title="Speaker / headphones">'+ic('headphones',20)+'</button>'
|
||||
+ '<button class="meet-ic" id="meetPplBtn" title="Participants">'+ic('users',20)+'</button>'
|
||||
+ '<button class="meet-ic leave" id="meetLeaveBtn" title="Leave">'+ic('callEnd',20)+'</button></div></div>';
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user