diff --git a/server/public/home.html b/server/public/home.html index 8ac594f..8bedb1d 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -504,6 +504,23 @@ .modal h3{margin:0 0 .8rem;color:var(--blue);} .modal input#grpName,.modal input#giName{width:100%;padding:.6rem .7rem;border:2px solid var(--line);border-radius:10px;background:#fbfcfe;font-size:.95rem;margin-bottom:.8rem;} .modal input#grpName:focus,.modal input#giName:focus{outline:none;border-color:var(--brand);} + /* Branded confirm dialog (replaces the OS/Electron window.confirm). */ + .bz-confirm .bzc-msg{margin:.2rem 0 1.1rem;color:var(--ink);font-size:.92rem;line-height:1.5;} + .bz-confirm .bzc-actions{display:flex;justify-content:flex-end;gap:.5rem;} + .bz-confirm .bzc-actions button{border:none;border-radius:10px;padding:.55rem 1rem;font-size:.9rem;font-weight:600;cursor:pointer;font-family:inherit;} + .bz-confirm .bzc-cancel{background:var(--blue-soft);color:var(--blue);} + .bz-confirm .bzc-cancel:hover{background:#dbe6fa;} + .bz-confirm .bzc-ok{background:var(--blue);color:#fff;} + .bz-confirm .bzc-ok:hover{background:var(--blue-d);} + .bz-confirm .bzc-ok.danger{background:var(--red);} + .bz-confirm .bzc-ok.danger:hover{background:#991b1b;} + /* "typing…" indicator inside the conversation, just above the composer. */ + .typing-row{padding:.1rem .95rem .3rem;min-height:0;color:var(--blue);font-size:.78rem;font-style:italic;font-weight:600;display:none;align-items:center;gap:.4rem;} + .typing-row.show{display:flex;} + .typing-row .tdots{display:inline-flex;gap:2px;} + .typing-row .tdots i{width:5px;height:5px;border-radius:50%;background:var(--blue);opacity:.5;animation:tdot 1s infinite;} + .typing-row .tdots i:nth-child(2){animation-delay:.15s;} .typing-row .tdots i:nth-child(3){animation-delay:.3s;} + @keyframes tdot{0%,60%,100%{opacity:.3;transform:translateY(0);}30%{opacity:1;transform:translateY(-2px);}} .avatar .mcount{position:absolute;right:-3px;bottom:-3px;min-width:16px;height:16px;border-radius:99px;background:var(--blue);color:#fff;font-size:.6rem;font-weight:800;display:grid;place-items:center;border:2px solid var(--card);padding:0 .15rem;z-index:1;} .convo-titlewrap{flex:1;min-width:0;} .convo-info{border:none;background:var(--blue-soft);color:var(--blue);width:32px;height:32px;border-radius:9px;font-size:1rem;cursor:pointer;flex:0 0 auto;display:grid;place-items:center;} @@ -771,7 +788,7 @@ - @@ -1253,6 +1270,7 @@ function convoShellHTML(it){ + '' + '' + '' + + '
' + '
' + '
' + '' @@ -1481,9 +1499,31 @@ function onChatRead(d){ if(!d||!d.by) return; // #6: keep the chat-list tick in sync with the thread — my last message is now read (blue double). const it=rowFor('dm', d.by); if(it && it.last_from_me && it.last_status!=='read'){ it.last_status='read'; renderChats(searchVal()); } } +// Branded confirm dialog — replaces window.confirm (which shows the OS/Electron default dialog on +// desktop). Returns a Promise. Reuses .modal-ov so the global Esc handler dismisses it; +// a MutationObserver resolves false whenever the overlay is removed (Esc / backdrop / cancel). +function bzConfirm(message, opts){ + opts=opts||{}; + return new Promise((resolve)=>{ + const ov=document.createElement('div'); ov.className='modal-ov'; + ov.innerHTML=''; + document.body.appendChild(ov); + let done=false; + const finish=(v)=>{ if(done) return; done=true; obs.disconnect(); if(document.body.contains(ov)) ov.remove(); resolve(v); }; + const obs=new MutationObserver(()=>{ if(!document.body.contains(ov)) finish(false); }); // Esc/global removal → cancel + obs.observe(document.body,{childList:true}); + ov.querySelector('.bzc-ok').onclick=()=>finish(true); + ov.querySelector('.bzc-cancel').onclick=()=>finish(false); + ov.addEventListener('mousedown',(e)=>{ if(e.target===ov) finish(false); }); // backdrop click + setTimeout(()=>{ const b=ov.querySelector('.bzc-ok'); if(b) b.focus(); },0); + }); +} // Delete-for-everyone: confirm, tell the server, then blank the message locally (the server also // broadcasts chat-deleted to the other side / other tabs). -async function deleteMessage(id){ if(!confirm('Delete this message for everyone?')) return; try{ await postJSON('/api/messages/delete',{id}); markMsgDeleted(id); }catch(e){ toast(e.message||'Could not delete'); } } +async function deleteMessage(id){ if(!(await bzConfirm('This message will be removed for everyone in the chat.', {title:'Delete message?', okText:'Delete', danger:true}))) return; try{ await postJSON('/api/messages/delete',{id}); markMsgDeleted(id); }catch(e){ toast(e.message||'Could not delete'); } } function markMsgDeleted(id){ let changed=false; THREAD.forEach(m=>{ if(m.id===id && !m.deleted){ m.deleted=true; m.body=''; m.attachment=null; m.reactions=[]; m.reply=null; m.poll=null; changed=true; } }); @@ -2181,11 +2221,19 @@ function onChatMessage(m){ } renderChats(searchVal()); updateRailUnread(); } -let chatWs=null; +let chatWs=null, _chatConnectedOnce=false; +// After a socket DROP + reconnect, this client may have missed messages/ticks while offline — and +// the desktop/mobile apps can't just be refreshed to recover. So on every RE-connect we re-pull the +// sidebar and re-open the current conversation (fresh thread + receipts). The first connect is +// handled by boot's loadSidebar, so we skip the resync then. +function resyncChat(){ + try{ loadSidebar(); }catch(_){} + if(selected && currentTab()==='chat'){ const s=selected; try{ openConvo(s.kind, s.id); }catch(_){} } +} 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.onopen=()=>{ try{ chatWs.send(JSON.stringify({type:'chat-hello'})); }catch(_){} if(_chatConnectedOnce) resyncChat(); _chatConnectedOnce=true; }; 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(_){} @@ -2237,9 +2285,11 @@ function typingLabel(kind,id){ } 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 + if(selected && selected.kind===kind && selected.id===id){ // open conversation → header subtitle + in-panel row 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); } } + const tr=document.getElementById('typingRow'); + if(tr){ if(lbl){ tr.innerHTML=' '+pEsc(lbl); tr.classList.add('show'); } else { tr.classList.remove('show'); tr.innerHTML=''; } } } if(listEl){ // sidebar row preview const row=listEl.querySelector('.chat-row[data-kind="'+kind+'"][data-id="'+_cssEsc(id)+'"]'); @@ -2452,6 +2502,20 @@ function sfuSyncLocal(){ async function sfuSetMic(on){ const lp=SFU.room&&SFU.room.localParticipant; if(!lp) return; await lp.setMicrophoneEnabled(on); sfuSyncLocal(); } async function sfuSetCam(on){ const lp=SFU.room&&SFU.room.localParticipant; if(!lp) return; await lp.setCameraEnabled(on); sfuSyncLocal(); } function sfuDisconnect(){ if(SFU.room){ try{ SFU.room.disconnect(); }catch(_){} SFU.room=null; } SFU.peers.clear(); } +// #15: turn a getUserMedia / LiveKit media failure into a SPECIFIC, actionable message (and log the +// raw error) instead of always blaming "permission" — which hid the real cause on the desktop app. +function mediaErrMsg(e, what){ + const n=((e&&e.name)||'')+''; try{ console.error('[media] '+what+' failed:', n, e&&e.message, e); }catch(_){} + const isDesk=(typeof nativePlatform==='function' && nativePlatform()==='desktop'); + const Cap=what.charAt(0).toUpperCase()+what.slice(1); + if(n==='NotAllowedError'||n==='PermissionDeniedError'||n==='SecurityError') return isDesk + ? Cap+' access is blocked. Enable it in Windows Settings → Privacy & security → '+(what==='camera'?'Camera':'Microphone')+' (turn on "Let desktop apps access…"), then retry.' + : Cap+' permission was blocked — allow it from your browser’s address-bar icon and retry.'; + if(n==='NotFoundError'||n==='DevicesNotFoundError') return 'No '+what+' was found on this device.'; + if(n==='NotReadableError'||n==='TrackStartError'||n==='AbortError') return 'Your '+what+' is in use by another app — close it and retry.'; + if(n==='OverconstrainedError') return 'No '+what+' matches the requested settings.'; + return 'Couldn’t start '+what+(e&&e.message?(' ('+e.message+')'):'')+'.'; +} // ================= end LiveKit SFU ================= function meetRailLive(on){ const b=document.querySelector('.railbtn[data-tab="meeting"]'); if(b) b.classList.toggle('live', !!on); } @@ -2901,11 +2965,11 @@ function updateCamBtn(){ const b=document.getElementById('meetCamBtn'); if(b){ b // Unmute acquires the mic on demand (no prompt until then) and renegotiates with peers. async function toggleMic(){ if(!meetLocalStream) return; - if(SFU.on){ const next=!meetMic; try{ await sfuSetMic(next); meetMic=next; }catch(e){ toast('Microphone permission is required to unmute'); return; } updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; } + if(SFU.on){ const next=!meetMic; try{ await sfuSetMic(next); meetMic=next; }catch(e){ toast(mediaErrMsg(e,'microphone')); return; } updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; } const hasTrack=meetLocalStream.getAudioTracks().length>0; if(!hasTrack){ let astream; try{ astream=await navigator.mediaDevices.getUserMedia({ audio:true }); } - catch(e){ toast('Microphone permission is required to unmute'); return; } + catch(e){ toast(mediaErrMsg(e,'microphone')); return; } const track=astream.getAudioTracks()[0]; if(!track) return; meetLocalStream.addTrack(track); meetMic=true; meetWatchStream('__local', meetLocalStream); for(const [pid,p] of meetPeers){ try{ p.pc.addTrack(track, meetLocalStream); const off=await p.pc.createOffer(); await p.pc.setLocalDescription(off); meetSend({type:'meeting-signal',to:pid,data:{sdp:p.pc.localDescription}}); }catch(_){} } @@ -2918,11 +2982,11 @@ async function toggleMic(){ // renegotiates with every peer, so you can always turn video on once a meeting has started. async function toggleCam(){ if(!meetLocalStream) return; - if(SFU.on){ const next=!meetCam; try{ await sfuSetCam(next); meetCam=next; meetAudioOnly=false; }catch(e){ toast('Camera permission is required to turn on video'); return; } updateCamBtn(); addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; } + if(SFU.on){ const next=!meetCam; try{ await sfuSetCam(next); meetCam=next; meetAudioOnly=false; }catch(e){ toast(mediaErrMsg(e,'camera')); return; } updateCamBtn(); addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; } const hasTrack=meetLocalStream.getVideoTracks().length>0; if(!hasTrack){ let vstream; try{ vstream=await navigator.mediaDevices.getUserMedia({ video:true }); } - catch(e){ toast('Camera permission is required to turn on video'); return; } + catch(e){ toast(mediaErrMsg(e,'camera')); return; } const track=vstream.getVideoTracks()[0]; if(!track) return; meetLocalStream.addTrack(track); meetCam=true; meetAudioOnly=false; for(const [pid,p] of meetPeers){ try{ const s=p.pc.addTrack(track, meetLocalStream); if(!p.vsender) p.vsender=s; const off=await p.pc.createOffer(); await p.pc.setLocalDescription(off); meetSend({type:'meeting-signal',to:pid,data:{sdp:p.pc.localDescription}}); }catch(_){} } @@ -2999,7 +3063,7 @@ document.addEventListener('keydown',(e)=>{ if(ovs.length){ ovs[ovs.length-1].remove(); e.preventDefault(); e.stopPropagation(); return; } const sh=document.getElementById('convoSearchHead'); if(sh && sh.style.display!=='none'){ closeSearch(); e.preventDefault(); e.stopPropagation(); return; } }, true); -document.addEventListener('keydown',(e)=>{ if(e.key==='Escape' && !document.querySelector('.modal-ov') && currentTab()==='chat' && selected!=null){ showWelcome(); } }); +document.addEventListener('keydown',(e)=>{ if(e.key==='Escape' && !document.querySelector('.modal-ov') && !document.getElementById('lightbox') && currentTab()==='chat' && selected!=null){ showWelcome(); } }); // #4: an open image preview closes first (its own Esc handler); the conversation only closes once no overlay remains // #10: the device/browser Back button closes the topmost layer (popup → search → open chat on // mobile) instead of leaving the app. We seed a history entry and re-seed after each handled back. function bzcBack(){