feat: live presence, delivery-tick sync, brand rollout, call fixes, desktop 0.1.3
Live presence (fixes stale in-call/status until refresh — impossible in apps): - server broadcasts a user's status over the chat socket on connect/disconnect, call join/leave, and status change (chat.js broadcastPresence; signaling + routes hooks). - client onPresence() updates the contact dot + open-chat header live. Chat delivery ticks (#6): chat-list row now mirrors the thread (delivered→double grey, read→blue) via a new 'with' field on the delivered relay + onChatRead/onChatDelivered. Call fixes: no bogus 'host handed over' when a 1:1 call ends (leaveMeeting forced); branded call-connecting + chat-thread loaders; header subtitle tracks live call state. Notifications: web notify + sw.js use sender/group DP + brand icon (not old wordmark); desktop shell drops Web Push so only the single native toast fires (#5). Brand: master icon/splash/loaders wired everywhere (PWA/favicon/apple-touch/.ico), branded login (blue + gold CTA), branded toasts (BZToast) on all pages, Electron splash. Desktop: dev auto-targets localhost (packaged→prod); version 0.1.3 with new multi-size icon; dropped unused node-notifier; removed home-mockup.html. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+97
-33
@@ -7,9 +7,11 @@
|
||||
<!-- PWA: installable on Android & iOS ("Add to Home Screen"); also enables iOS web push -->
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#1F3B73">
|
||||
<link rel="icon" href="/favicon.ico?v=3" sizes="any">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png?v=3">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png?v=3">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=3">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon-180.png?v=3">
|
||||
<link rel="stylesheet" href="/bizconnect-toast.css">
|
||||
<script src="/bizconnect-toast.js"></script>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
@@ -645,10 +647,18 @@
|
||||
.modal-actions .gobtn{flex:1;border:none;border-radius:10px;padding:.6rem;font-weight:700;cursor:pointer;}
|
||||
|
||||
/* ---- Login (shown on /home when logged out) ---- */
|
||||
.authwrap{flex:1 1 auto;display:none;align-items:center;justify-content:center;padding:1.5rem;min-height:0;}
|
||||
.authcard{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:2rem;max-width:400px;width:100%;box-shadow:0 10px 30px rgba(20,30,60,.08);}
|
||||
.authcard h1{font-size:1.3rem;color:var(--blue);margin:0 0 .3rem;text-align:center;}
|
||||
.authwrap{flex:1 1 auto;display:none;align-items:center;justify-content:center;padding:1.5rem;min-height:0;
|
||||
background:radial-gradient(1200px 600px at 50% -10%, #24437f 0%, #1F3B73 42%, #16294F 100%);}
|
||||
.authcard{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:2rem 2rem 1.7rem;max-width:400px;width:100%;box-shadow:0 24px 60px rgba(9,17,38,.38);}
|
||||
.authcard .auth-brand{display:flex;flex-direction:column;align-items:center;gap:.55rem;margin-bottom:1rem;}
|
||||
.authcard .auth-brand img{width:66px;height:66px;border-radius:17px;box-shadow:0 10px 24px rgba(31,59,115,.30);}
|
||||
.authcard .auth-brand .wm{font-size:1.15rem;font-weight:800;color:var(--blue);letter-spacing:.2px;}
|
||||
.authcard .auth-brand .wm b{color:var(--brand-d);font-weight:800;}
|
||||
.authcard h1{font-size:1.12rem;color:var(--blue);margin:0 0 .3rem;text-align:center;}
|
||||
.authcard .sub{color:var(--muted);font-size:.9rem;text-align:center;margin-bottom:1.2rem;}
|
||||
/* Blue card + gold CTA so the sign-in screen carries BOTH brand colours (never mono-blue). */
|
||||
.authcard .gobtn{background:var(--brand);color:var(--blue-d);}
|
||||
.authcard .gobtn:hover{filter:brightness(.95);}
|
||||
.authtabs{display:flex;gap:.5rem;margin-bottom:1.1rem;}
|
||||
.authtabs button{flex:1;background:#eef1f6;color:var(--muted);font-weight:600;border:none;border-radius:9px;padding:.5rem;cursor:pointer;font-size:.9rem;}
|
||||
.authtabs button.active{background:var(--blue);color:#fff;}
|
||||
@@ -667,11 +677,14 @@
|
||||
.hidden{display:none;}
|
||||
|
||||
/* ---- Loading / toast ---- */
|
||||
.loading{position:fixed;inset:0;display:grid;place-items:center;background:var(--bg);z-index:9000;color:var(--muted);font-size:.9rem;}
|
||||
.loading .ld-inner{display:flex;flex-direction:column;align-items:center;gap:.9rem;}
|
||||
.loading .ld-inner img{width:64px;height:64px;}
|
||||
.toast{position:fixed;left:50%;bottom:1.6rem;transform:translateX(-50%) translateY(1rem);background:var(--blue);color:#fff;padding:.7rem 1.2rem;border-radius:10px;font-size:.88rem;box-shadow:0 10px 28px rgba(0,0,0,.22);opacity:0;pointer-events:none;transition:opacity .2s,transform .2s;z-index:9500;}
|
||||
.toast.show{opacity:1;transform:translateX(-50%) translateY(0);}
|
||||
.loading{position:fixed;inset:0;display:grid;place-items:center;background:#1F3B73;z-index:9000;color:rgba(255,255,255,.82);font-size:.9rem;}
|
||||
.loading .ld-inner{display:flex;flex-direction:column;align-items:center;gap:1rem;}
|
||||
.loading .ld-inner img{width:72px;height:72px;}
|
||||
/* Chat thread loading (light pane) and call-connecting (dark stage) — branded, centered. */
|
||||
.thread-loading{height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.7rem;color:var(--muted);font-size:.86rem;}
|
||||
.call-connecting{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1rem;background:#16294F;color:rgba(255,255,255,.82);font-size:.95rem;}
|
||||
.call-connecting .cc-txt{letter-spacing:.2px;}
|
||||
/* in-app toasts now render via /bizconnect-toast.css (BZToast) */
|
||||
|
||||
/* Hamburger menu button (header) */
|
||||
.navtoggle{background:transparent;border:none;color:#fff;cursor:pointer;display:grid;place-items:center;width:38px;height:38px;border-radius:9px;}
|
||||
@@ -740,11 +753,11 @@
|
||||
<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-02-batch26';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||
<script>window.__BUILD='2026-07-02-batch32';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>
|
||||
<div class="loading" id="loading"><div class="ld-inner"><img src="/loader-orbit.svg" alt="" width="64" height="64"><span>Loading…</span></div></div>
|
||||
<div class="loading" id="loading"><div class="ld-inner"><img src="/loaders/loader-orbit-dark.svg" alt="" width="72" height="72"><span>Loading…</span></div></div>
|
||||
|
||||
<header>
|
||||
<div class="brandrow">
|
||||
@@ -815,7 +828,6 @@ function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, {
|
||||
|
||||
<div class="authwrap" id="authwrap"></div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
// ---------- Helpers ----------
|
||||
@@ -832,8 +844,16 @@ function firstName(name){return String(name||'').trim().split(/\s+/)[0]||'there'
|
||||
const AV_COLORS=['#dbeafe','#e0e7ff','#dcfce7','#cffafe','#fae8ff','#fce7f3','#fee2e2','#fef3c7','#ecfccb','#fde4cf'];
|
||||
function avColor(name){let h=0;for(const c of String(name))h=(h*31+c.charCodeAt(0))>>>0;return AV_COLORS[h%AV_COLORS.length];}
|
||||
|
||||
let toastTimer=null;
|
||||
function toast(msg){const t=document.getElementById('toast');t.textContent=msg;t.classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>t.classList.remove('show'),2600);}
|
||||
// All in-app toasts route through the branded BZToast (bizconnect-toast.js). We keep the single
|
||||
// toast(msg) entry point so every existing call site is unchanged, and classify by wording so
|
||||
// errors/successes get the right colour+icon; everything else shows as a neutral message.
|
||||
function toast(msg){
|
||||
var s=String(msg==null?'':msg);
|
||||
if(!window.BZToast) return; // loaded synchronously in <head>
|
||||
if(/could ?n.t|cannot|can.t|failed|too large|invalid|error|denied|lost|blocked|not registered|required|need|no result|already/i.test(s)) return BZToast.error(s);
|
||||
if(/renamed|updated|enabled|added|saved|created|invited|removed|left the|muted|photo updated|now an admin|no longer|success/i.test(s)) return BZToast.success(s);
|
||||
return BZToast.message(s);
|
||||
}
|
||||
|
||||
// ---------- Profile dropdown (mirrors profileHTML()/wireProfile() from console.html) ----------
|
||||
function profileHTML(u){
|
||||
@@ -1180,9 +1200,12 @@ function welcomeHTML(){
|
||||
+ '</div></div>';
|
||||
}
|
||||
function wireWelcome(){ document.querySelectorAll('#chatPanel .wcard').forEach(card=>{ card.onclick=()=>switchTab(card.dataset.go); }); }
|
||||
// A DM's subtitle tracks the LIVE call state (pushed on call start AND end) so it can't get stuck
|
||||
// on "In a call" after the call ends — unlike it.status, which is only derived at conversation load.
|
||||
function dmSubLabel(it){ return (it&&it.callActive)?'In a call':statusLabel(it); }
|
||||
function convoShellHTML(it){
|
||||
const isG=it.kind==='group';
|
||||
const sub=it.self?'Message yourself':(isG?((it.members||0)+' members'):statusLabel(it));
|
||||
const sub=it.self?'Message yourself':(isG?((it.members||0)+' members'):dmSubLabel(it));
|
||||
return '<div class="convo">'
|
||||
+ '<div class="convo-head">'
|
||||
+ '<button class="convo-back" id="convoBack" title="Back (Esc)" aria-label="Back">'+ic('arrowLeft',18)+'</button>'
|
||||
@@ -1385,7 +1408,11 @@ function onChatReaction(d){ const m=THREAD.find(x=>x.id===d.messageId); if(m &&
|
||||
if(d.added && d.owner===ME.id && d.byId && d.byId!==ME.id){ addNotif({icon:'smilePlus', text:pEsc(d.by||'Someone')+' reacted '+(d.emoji||'')+' to your message', link:d.convId?{kind:'group',id:d.convId}:{kind:'dm',id:d.byId}}); }
|
||||
}
|
||||
// Read receipts (DM): the other party read my messages → mark mine as seen.
|
||||
function onChatRead(d){ if(!d||!d.by) return; if(selected && selected.kind==='dm' && selected.id===d.by){ let changed=false; THREAD.forEach(m=>{ if(m.from===ME.id && !m.read_at){ m.read_at=Date.now(); changed=true; } }); if(changed) renderThread(); } }
|
||||
function onChatRead(d){ if(!d||!d.by) return;
|
||||
if(selected && selected.kind==='dm' && selected.id===d.by){ let changed=false; THREAD.forEach(m=>{ if(m.from===ME.id && !m.read_at){ m.read_at=Date.now(); changed=true; } }); if(changed) renderThread(); }
|
||||
// #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()); }
|
||||
}
|
||||
// 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'); } }
|
||||
@@ -1397,7 +1424,11 @@ function markMsgDeleted(id){
|
||||
}
|
||||
function onChatDeleted(d){ if(!d||!d.id) return; markMsgDeleted(d.id); try{ loadSidebar(); }catch(_){} } // refresh last-message previews
|
||||
// DM delivered: recipient's client acked → second (grey) tick.
|
||||
function onChatDelivered(d){ if(!d||!d.id) return; const m=THREAD.find(x=>x.id===d.id); if(m && !m.delivered_at){ m.delivered_at=Date.now(); updateBubble(m); } }
|
||||
function onChatDelivered(d){ if(!d||!d.id) return;
|
||||
const m=THREAD.find(x=>x.id===d.id); if(m && !m.delivered_at){ m.delivered_at=Date.now(); updateBubble(m); }
|
||||
// #6: mirror the delivered state onto the chat-list tick (double grey), unless it's already read.
|
||||
const it=d.with?rowFor('dm', d.with):null; if(it && it.last_from_me && it.last_status==='sent'){ it.last_status='delivered'; renderChats(searchVal()); }
|
||||
}
|
||||
// Group read: a member opened the group → add them to "Seen by" on my messages up to that time.
|
||||
function onGroupRead(d){ if(!d||!d.group) return; if(selected && selected.kind==='group' && selected.id===d.group){ THREAD.forEach(m=>{ if(m.from===ME.id && m.created_at<=d.at){ m.seenBy=m.seenBy||[]; if(d.byName && !m.seenBy.includes(d.byName)){ m.seenBy.push(d.byName); updateBubble(m); } } }); } }
|
||||
// Shared group call: start it (or join the live one — the server returns the existing room).
|
||||
@@ -1419,9 +1450,20 @@ async function startOrJoinDmCall(otherId){
|
||||
try{ const r=await postJSON('/api/calls/dm/start',{ to:otherId }); if(r&&r.room){ meetReturn={kind:'dm',id:otherId}; switchTab('meeting'); enterMeeting(r.room); } }
|
||||
catch(e){ toast(e.message||'Could not start the call'); }
|
||||
}
|
||||
// Live presence: a contact came online/offline or entered/left a call — update their dot + the
|
||||
// open chat header immediately (no refresh, which the desktop/mobile apps can't do). #6
|
||||
function onPresence(d){
|
||||
if(!d||!d.userId) return;
|
||||
const it=rowFor('dm', d.userId);
|
||||
if(it){ it.online=!!d.online; it.status=d.status||'active'; renderChats(searchVal()); }
|
||||
if(selected && selected.kind==='dm' && selected.id===d.userId && it){
|
||||
const sub=document.querySelector('#convoTitle .st'); if(sub) sub.textContent=dmSubLabel(it);
|
||||
const dot=document.querySelector('.convo-head .avatar .dot'); if(dot) dot.className='dot '+statusCls(it);
|
||||
}
|
||||
}
|
||||
function onDmCall(d){
|
||||
if(!d) return; const it=rowFor('dm', d.with); if(it){ it.callActive=!!d.active; it.callRoom=d.room||null; }
|
||||
if(selected&&selected.kind==='dm'&&selected.id===d.with) updateCallBtn(!!d.active);
|
||||
if(!d) return; const it=rowFor('dm', d.with); if(it){ it.callActive=!!d.active; it.callRoom=d.room||null; if(!d.active && it.status==='incall') it.status='active'; } // #4: drop the stuck "in call" status
|
||||
if(selected&&selected.kind==='dm'&&selected.id===d.with){ updateCallBtn(!!d.active); const s=document.querySelector('#convoTitle .st'); if(s&&it) s.textContent=dmSubLabel(it); } // refresh the header subtitle live
|
||||
if(d.active && d.by && d.by!==ME.id) showCallInvite(d.room, d.byName, {kind:'dm',id:d.with}); // incoming 1:1 call
|
||||
if(!d.active) dismissCallInvite(d.room); // call ended/declined — stop ringing
|
||||
renderChats(searchVal());
|
||||
@@ -1629,6 +1671,7 @@ async function openConvo(kind,id){
|
||||
// when opened from a notification; an async-only render would defer the paint until a click).
|
||||
const ckey=kind+':'+id;
|
||||
if(THREAD_CACHE.has(ckey)){ THREAD=THREAD_CACHE.get(ckey).slice(); renderThread(); }
|
||||
else if(box){ box.innerHTML='<div class="thread-loading"><img src="/loaders/loader-ring.svg" width="34" height="34" alt=""><span>Loading messages…</span></div>'; } // branded loader instead of a blank pane on slow links
|
||||
if(kind==='group'){ try{ convoMembers=await fetch('/api/groups/members?group='+encodeURIComponent(id)).then(r=>r.json())||[]; }catch(_){ convoMembers=[]; } }
|
||||
if(!selected||selected.kind!==kind||selected.id!==id) return;
|
||||
wireMentions();
|
||||
@@ -1894,7 +1937,11 @@ async function setupNativePush(){
|
||||
return true; // handled the native path (skip Web Push regardless of outcome)
|
||||
}
|
||||
async function setupPush(){
|
||||
if(await setupNativePush()) return; // native app → FCM/APNs, not Web Push
|
||||
if(await setupNativePush()) return; // mobile native app → FCM/APNs, not Web Push
|
||||
// Desktop shell: the in-page bridge already shows a native toast (notify → replyNotify) while the
|
||||
// app runs, so ALSO subscribing to Web Push made every message pop TWICE (in-app toast + SW push,
|
||||
// with different icons). Drop any Web Push subscription here so only the branded native toast fires.
|
||||
if(nativePlatform()==='desktop'){ try{ await unsubscribePush(); }catch(_){} console.log('[push] desktop shell → native toasts only, skipping Web Push'); return; }
|
||||
if(!('serviceWorker' in navigator) || !('PushManager' in window)){ console.warn('[push] not supported by this browser'); return; }
|
||||
try{ await navigator.serviceWorker.register('/sw.js'); }catch(e){ console.warn('[push] SW register failed:', e); return; }
|
||||
try{ _swReg=await navigator.serviceWorker.ready; }catch(e){ console.warn('[push] SW never became ready:', e); return; } // ensure an ACTIVE worker before subscribe()
|
||||
@@ -1960,11 +2007,16 @@ function notify(title, body, kind, id){
|
||||
}).catch(()=>{});
|
||||
return;
|
||||
}
|
||||
// Web / PWA: standard Notification.
|
||||
// Web / PWA: standard Notification. Icon = the sender's/group's DP (falling back to their
|
||||
// initials, then the brand app icon) — never the old wordmark. badge = brand mark.
|
||||
if(!('Notification' in window) || Notification.permission!=='granted') return;
|
||||
const n=new Notification(title, { body, icon:'/logo.png' });
|
||||
n.onclick=()=>{ n.close(); openFromNotif(kind, id); };
|
||||
setTimeout(()=>{ try{ n.close(); }catch(_){} }, 8000);
|
||||
notifAvatarDataUrl(kind, id, String(title||'').replace(/^[^\p{L}\p{N}]+/u,'')).then(avatar=>{
|
||||
const n=new Notification(title, { body, icon: avatar || '/icon-192.png', badge:'/icon-192.png' });
|
||||
n.onclick=()=>{ n.close(); openFromNotif(kind, id); };
|
||||
setTimeout(()=>{ try{ n.close(); }catch(_){} }, 8000);
|
||||
}).catch(()=>{
|
||||
try{ const n=new Notification(title, { body, icon:'/icon-192.png' }); n.onclick=()=>{ n.close(); openFromNotif(kind, id); }; }catch(_){}
|
||||
});
|
||||
}catch(_){}
|
||||
}
|
||||
let _audioCtx=null;
|
||||
@@ -2038,7 +2090,7 @@ function onChatMessage(m){
|
||||
if(!THREAD.some(x=>x.id===m.id)){ THREAD.push(m); appendBubble(m); }
|
||||
if(m.from!==ME.id && !isSys){ if(it) it.unread=0; const body=JSON.stringify(kind==='group'?{group:rid}:{with:rid}); try{ fetch('/api/messages/read',{method:'POST',headers:{'Content-Type':'application/json'},body}); }catch(_){} }
|
||||
} else if(m.from!==ME.id && !isSys && notifOn(kind)){
|
||||
toast((m.fromName||'New message')+': '+(m.body?(m.body.length>60?m.body.slice(0,60)+'…':m.body):'📎 Attachment'));
|
||||
if(window.BZToast) BZToast.message(m.body?(m.body.length>60?m.body.slice(0,60)+'…':m.body):'📎 Attachment', {title:(m.fromName||'New message')});
|
||||
}
|
||||
renderChats(searchVal()); updateRailUnread();
|
||||
}
|
||||
@@ -2047,7 +2099,7 @@ 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.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-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==='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-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==='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(_){}
|
||||
}
|
||||
@@ -2379,6 +2431,12 @@ function openScheduleModal(gid, editMtg){
|
||||
}catch(e){ err.textContent=e.message||'Could not save'; }
|
||||
};
|
||||
}
|
||||
// Branded connecting screen shown from the moment a call is entered until the room is ready —
|
||||
// so a slow link shows our loader, not a blank/half meeting window.
|
||||
function renderCallConnecting(){
|
||||
const el=document.getElementById('meetingPanel'); if(!el) return;
|
||||
el.innerHTML='<div class="call-connecting"><img src="/loaders/loader-orbit-dark.svg" width="76" height="76" alt=""><div class="cc-txt">Connecting to call…</div></div>';
|
||||
}
|
||||
function renderCall(){
|
||||
const el=document.getElementById('meetingPanel'); if(!el) return;
|
||||
el.innerHTML='<div class="meet"><div class="meet-grid" id="meetGrid"></div>'
|
||||
@@ -2571,6 +2629,7 @@ async function enterMeeting(code, audioOnly){
|
||||
meetRec=null; meetTranscribe=false; meetRoomTx=false; meetSR=null; _addPool=null; meetStageId=null;
|
||||
try{ const c=await fetch('/api/ice').then(r=>r.json()); if(c&&c.iceServers) MEET_ICE=c; }catch(_){}
|
||||
meetState='call'; meetRailLive(true);
|
||||
renderCallConnecting(); // branded "Connecting…" until the room is created/joined (esp. on slow links)
|
||||
meetWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
|
||||
meetWs.onmessage=onMeetMsg;
|
||||
meetWs.onopen=()=>{ if(code){ meetRoom=code; renderCall(); meetSend({type:'meeting-join', room:code, name:(ME.name||ME.email||'Guest')}); } else { meetSend({type:'meeting-create'}); } };
|
||||
@@ -2590,7 +2649,7 @@ async function onMeetMsg(e){
|
||||
refreshMeetPanel(); updateHostControls();
|
||||
return;
|
||||
}
|
||||
if(m.type==='meeting-ended'){ toast('Call ended'); leaveMeeting(); return; } // 1:1 hangup, or host ended
|
||||
if(m.type==='meeting-ended'){ toast('Call ended'); leaveMeeting(true); return; } // 1:1 hangup, or host ended
|
||||
if(m.type==='meeting-peer-joined'){
|
||||
meetNames.set(m.peerId,m.name); if(m.avatar) meetAvatars.set(m.peerId,m.avatar); if(m.uid){ meetPeerUids.set(m.peerId,m.uid); meetInviteJoined(m.uid); }
|
||||
const pc=meetMakePeer(m.peerId,m.name); // I'm an existing peer → I OFFER to the newcomer (carries my screen)
|
||||
@@ -2619,7 +2678,7 @@ async function onMeetMsg(e){
|
||||
} else if(d.candidate){ const p=meetPeers.get(from); if(p&&p.pc){ try{ await p.pc.addIceCandidate(d.candidate); }catch(_){} } }
|
||||
return;
|
||||
}
|
||||
if(m.type==='error'){ const msg=m.message; leaveMeeting(); const e2=document.getElementById('meetErr'); if(e2) e2.textContent=msg||'Meeting error'; return; }
|
||||
if(m.type==='error'){ const msg=m.message; leaveMeeting(true); const e2=document.getElementById('meetErr'); if(e2) e2.textContent=msg||'Meeting error'; return; }
|
||||
}
|
||||
function updateMicBtn(){ const b=document.getElementById('meetMicBtn'); if(b){ b.classList.toggle('off',!meetMic); b.title=meetMic?'Mute':'Unmute'; b.innerHTML=ic(meetMic?'mic':'micOff',20); } }
|
||||
function updateCamBtn(){ const b=document.getElementById('meetCamBtn'); if(b){ b.classList.toggle('off',!meetCam); b.title=meetCam?'Turn camera off':'Turn camera on'; b.innerHTML=ic(meetCam?'video':'videoOff',20); } }
|
||||
@@ -2658,10 +2717,14 @@ async function toggleCam(){
|
||||
setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam});
|
||||
}
|
||||
let meetLeaving=false;
|
||||
function leaveMeeting(){
|
||||
// forced = the call is ending for us (1:1 hangup, host ended, room closed/error) rather than us
|
||||
// voluntarily stepping out of a group others are still in. Only a VOLUNTARY exit from a GROUP
|
||||
// call hands the host role on; a 1:1 call (or a forced end) has nobody to hand to — handing off
|
||||
// there wrongly announced "Host handed to <the person who just left>" (bug).
|
||||
function leaveMeeting(forced){
|
||||
if(meetLeaving) return; meetLeaving=true;
|
||||
// Host leaving voluntarily must hand off so the meeting isn't left host-less.
|
||||
if(meetIsHost && meetPeers.size>0){
|
||||
const isDm=!!(meetReturn && meetReturn.kind==='dm');
|
||||
if(!forced && !isDm && meetIsHost && meetPeers.size>0){
|
||||
const next=meetPeers.keys().next().value; // first remaining participant
|
||||
if(next){ meetSend({type:'meeting-host', to:next}); toast('Host handed to '+(meetNames.get(next)||'a participant')); }
|
||||
}
|
||||
@@ -2770,7 +2833,8 @@ async function renderLogin(){
|
||||
const aw=document.getElementById('authwrap'); aw.style.display='flex';
|
||||
let regOpen=false; try{ regOpen=(await (await fetch('/api/setup-state')).json()).registrationOpen; }catch(_){}
|
||||
aw.innerHTML=`<div class="authcard">
|
||||
<h1>Welcome to Biz Connect</h1>
|
||||
<div class="auth-brand"><img src="/icon-192.png" alt="Biz Connect"><div class="wm">Biz <b>Connect</b></div></div>
|
||||
<h1>Welcome back</h1>
|
||||
<div class="sub">Sign in to access chats, screen share and connect.</div>
|
||||
${regOpen?`<div class="authtabs">
|
||||
<button id="tabLogin" class="active">Sign in</button>
|
||||
|
||||
Reference in New Issue
Block a user