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:
2026-07-07 11:46:22 +05:30
parent 472170784b
commit 496eba3c17
+74 -10
View File
@@ -504,6 +504,23 @@
.modal h3{margin:0 0 .8rem;color:var(--blue);} .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,.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);} .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;} .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-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;} .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> <body>
<script src="/icons.js?v=4"></script> <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 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 // 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) // (emojis stay as plain Unicode). (#5)
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script> 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>' + '<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>' + '<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="reply-bar" id="replyBar" style="display:none"></div>'
+ '<div class="typing-row" id="typingRow"></div>'
+ '<form class="composer" id="composer">' + '<form class="composer" id="composer">'
+ '<div class="composer-box">' + '<div class="composer-box">'
+ '<div class="attach-preview" id="attachBar" style="display:none"></div>' + '<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). // #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()); } 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 // Delete-for-everyone: confirm, tell the server, then blank the message locally (the server also
// broadcasts chat-deleted to the other side / other tabs). // 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){ function markMsgDeleted(id){
let changed=false; 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; } }); 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(); 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(){ function connectChatWs(){
try{ try{
chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws'); 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.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 chatWs.onclose=()=>{ setTimeout(connectChatWs, 3000); }; // auto-reconnect
}catch(_){} }catch(_){}
@@ -2237,9 +2285,11 @@ function typingLabel(kind,id){
} }
function refreshTyping(kind,id){ function refreshTyping(kind,id){
const it=rowFor(kind,id), lbl=typingLabel(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'); 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); } } 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 if(listEl){ // sidebar row preview
const row=listEl.querySelector('.chat-row[data-kind="'+kind+'"][data-id="'+_cssEsc(id)+'"]'); 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 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(); } 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(); } 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 browsers 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 'Couldnt start '+what+(e&&e.message?(' ('+e.message+')'):'')+'.';
}
// ================= end LiveKit SFU ================= // ================= end LiveKit SFU =================
function meetRailLive(on){ const b=document.querySelector('.railbtn[data-tab="meeting"]'); if(b) b.classList.toggle('live', !!on); } 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. // Unmute acquires the mic on demand (no prompt until then) and renegotiates with peers.
async function toggleMic(){ async function toggleMic(){
if(!meetLocalStream) return; 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; const hasTrack=meetLocalStream.getAudioTracks().length>0;
if(!hasTrack){ if(!hasTrack){
let astream; try{ astream=await navigator.mediaDevices.getUserMedia({ audio:true }); } 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; const track=astream.getAudioTracks()[0]; if(!track) return;
meetLocalStream.addTrack(track); meetMic=true; meetWatchStream('__local', meetLocalStream); 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(_){} } 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. // renegotiates with every peer, so you can always turn video on once a meeting has started.
async function toggleCam(){ async function toggleCam(){
if(!meetLocalStream) return; 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; const hasTrack=meetLocalStream.getVideoTracks().length>0;
if(!hasTrack){ if(!hasTrack){
let vstream; try{ vstream=await navigator.mediaDevices.getUserMedia({ video:true }); } 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; const track=vstream.getVideoTracks()[0]; if(!track) return;
meetLocalStream.addTrack(track); meetCam=true; meetAudioOnly=false; 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(_){} } 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; } 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; } const sh=document.getElementById('convoSearchHead'); if(sh && sh.style.display!=='none'){ closeSearch(); e.preventDefault(); e.stopPropagation(); return; }
}, true); }, 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 // #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. // mobile) instead of leaving the app. We seed a history entry and re-seed after each handled back.
function bzcBack(){ function bzcBack(){