fix(chat): batch 1 desktop fixes — resync-on-reconnect, branded confirm, esc, typing-in-panel, media errors
- #10/#18/#7: on WebSocket RE-connect, re-pull sidebar + re-open the current thread so the desktop/mobile apps recover missed messages/ticks without a refresh (the old onopen only re-sent chat-hello). - #11: branded bzConfirm() dialog replaces window.confirm for delete (the OS/ Electron default dialog looked off-brand on desktop). - #4: global Esc no longer closes the conversation while an image preview is open — the preview closes first, the conversation on a second Esc. - #6: live 'typing…' now also shows inside the conversation panel (animated), not only the header/sidebar. - #15 (diagnostic): mic/cam failures now report the REAL cause (permission vs no-device vs in-use) with desktop-specific guidance, and log the raw error, instead of always saying 'permission required'. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+74
-10
@@ -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 @@
|
||||
<body>
|
||||
<script src="/icons.js?v=4"></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-06-batch41';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||
<script>window.__BUILD='2026-07-06-batch42';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>
|
||||
@@ -1253,6 +1270,7 @@ function convoShellHTML(it){
|
||||
+ '<div class="float-date" id="floatDate" style="display:none"></div>'
|
||||
+ '<button class="jump-latest" id="jumpLatest" title="Jump to latest" style="display:none">'+ic('chevronDown',20)+'</button>'
|
||||
+ '<div class="reply-bar" id="replyBar" style="display:none"></div>'
|
||||
+ '<div class="typing-row" id="typingRow"></div>'
|
||||
+ '<form class="composer" id="composer">'
|
||||
+ '<div class="composer-box">'
|
||||
+ '<div class="attach-preview" id="attachBar" style="display:none"></div>'
|
||||
@@ -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<boolean>. 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='<div class="modal bz-confirm"><h3>'+pEsc(opts.title||'Please confirm')+'</h3>'
|
||||
+'<p class="bzc-msg">'+pEsc(message||'Are you sure?')+'</p>'
|
||||
+'<div class="bzc-actions"><button type="button" class="bzc-cancel">'+pEsc(opts.cancelText||'Cancel')+'</button>'
|
||||
+'<button type="button" class="bzc-ok'+(opts.danger?' danger':'')+'">'+pEsc(opts.okText||'Confirm')+'</button></div></div>';
|
||||
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='<span class="tdots"><i></i><i></i><i></i></span> '+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(){
|
||||
|
||||
Reference in New Issue
Block a user