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);