fix: auto-apply new web builds; DP missing in DM but shown in group (batch79)

Auto-update (no manual step, no version confusion):
- New code now applies ITSELF. The client polls /api/build and, as soon as it's SAFE,
  silently hard-reloads onto the new build. Safe = not in a call, no live screen session,
  no dialog open, nothing half-typed; if the user is busy we wait and apply the moment
  they're free. A brief "Updated to the latest version" toast confirms it.
- Removed the "web build" row from Settings: users must never have to reason about an app
  version vs a web build. The only version surfaced is the desktop app's (auto-updater).

DP bug: a contact showed their photo in a GROUP but fell back to initials in the 1:1.
Two causes, both handled:
- Duplicate rows for one person (signed in by email once and by mobile another time before
  the bizgaze_user_id merge landed) — only one row carries the DP, and the group happened to
  reference the row WITH the photo. avatarsFor() now keys rows by stable person identity
  (bizgaze person id → email → name) so a photo-less row borrows its twin's photo. Applied to
  contacts, conversations, group members and group info.
- A DM whose counterparty was merged away is now keyed by the SURVIVING account, so the row
  carries that account's name/photo/presence (and split threads collapse into one).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 15:34:35 +05:30
parent a0017f2036
commit 2aeeb0a096
2 changed files with 70 additions and 23 deletions
+39 -16
View File
@@ -1002,7 +1002,7 @@
<body> <body>
<script src="/icons.js?v=6"></script> <script src="/icons.js?v=6"></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-14-batch78';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD); <script>window.__BUILD='2026-07-14-batch79';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>
@@ -1225,13 +1225,13 @@ function openSettings(){
+sw('setDm','Direct message notifications', notifOn('dm')) +sw('setDm','Direct message notifications', notifOn('dm'))
+'<label class="gi-setting"><span>Notifications <span class="perm-state '+perm+'">'+permLabel+'</span><div style="font-size:.72rem;color:var(--muted);font-weight:400;margin-top:.15rem">Pop up messages &amp; calls even when this tab isnt open</div></span>'+(granted?'':'<button class="btn sm" id="setPerm">'+(perm==='denied'?'Allow':'Enable')+'</button>')+'</label>' +'<label class="gi-setting"><span>Notifications <span class="perm-state '+perm+'">'+permLabel+'</span><div style="font-size:.72rem;color:var(--muted);font-weight:400;margin-top:.15rem">Pop up messages &amp; calls even when this tab isnt open</div></span>'+(granted?'':'<button class="btn sm" id="setPerm">'+(perm==='denied'?'Allow':'Enable')+'</button>')+'</label>'
+(window.bizConnectNative?('<label class="gi-setting"><span>Biz Connect for Desktop<div style="font-size:.72rem;color:var(--muted);font-weight:400;margin-top:.15rem" id="setVer">Version '+pEsc(window.bizConnectNative.version||'—')+(_pendingUpdate?('<span class="set-ver-i" title="Update available">i</span> '+(_pendingUpdate.phase==='ready'?'Update ready':'Update '+pEsc(_pendingUpdate.version||'')+' available')):'')+'</div></span><button class="btn sm'+(_pendingUpdate?' has-update':'')+'" id="setUpd">'+(_pendingUpdate?(_pendingUpdate.phase==='ready'?'Restart now':'Downloading…'):'Check for updates')+'</button></label>'):'') +(window.bizConnectNative?('<label class="gi-setting"><span>Biz Connect for Desktop<div style="font-size:.72rem;color:var(--muted);font-weight:400;margin-top:.15rem" id="setVer">Version '+pEsc(window.bizConnectNative.version||'—')+(_pendingUpdate?('<span class="set-ver-i" title="Update available">i</span> '+(_pendingUpdate.phase==='ready'?'Update ready':'Update '+pEsc(_pendingUpdate.version||'')+' available')):'')+'</div></span><button class="btn sm'+(_pendingUpdate?' has-update':'')+'" id="setUpd">'+(_pendingUpdate?(_pendingUpdate.phase==='ready'?'Restart now':'Downloading…'):'Check for updates')+'</button></label>'):'')
// Always-available escape hatch: force the newest web build (clears cache / service worker). // NOTE: deliberately NO "web build" row here — users must not have to reason about an app version vs a
+'<label class="gi-setting"><span>App version<div style="font-size:.72rem;color:var(--muted);font-weight:400;margin-top:.15rem">Build '+pEsc(window.__BUILD||'—')+(_newBuild?' <span class="set-ver-i" title="A newer build is available">i</span> update available':'')+'</div></span><button class="btn sm'+(_newBuild?' has-update':'')+'" id="setRefresh">Refresh app</button></label>' // web build. New code applies itself (see checkWebBuild/scheduleAutoRefresh); the only version shown is
// the desktop app's, which is what the auto-updater manages.
+'<div class="hint" style="margin-top:.4rem">These preferences are saved on this device.</div></div>'; +'<div class="hint" style="margin-top:.4rem">These preferences are saved on this device.</div></div>';
document.body.appendChild(ov); document.body.appendChild(ov);
ov.onclick=e=>{ if(e.target===ov) ov.remove(); }; ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
ov.querySelector('#setClose').onclick=()=>ov.remove(); ov.querySelector('#setClose').onclick=()=>ov.remove();
{ const rb=ov.querySelector('#setRefresh'); if(rb) rb.onclick=()=>{ ov.remove(); hardReloadApp(); }; }
const updBtn=ov.querySelector('#setUpd'); // #12: desktop version + manual update check const updBtn=ov.querySelector('#setUpd'); // #12: desktop version + manual update check
if(updBtn) updBtn.onclick=async()=>{ if(updBtn) updBtn.onclick=async()=>{
if(_pendingUpdate&&_pendingUpdate.phase==='ready'){ try{ window.bizConnectNative.restartToUpdate&&window.bizConnectNative.restartToUpdate(); }catch(_){} return; } // update already downloaded → restart if(_pendingUpdate&&_pendingUpdate.phase==='ready'){ try{ window.bizConnectNative.restartToUpdate&&window.bizConnectNative.restartToUpdate(); }catch(_){} return; } // update already downloaded → restart
@@ -2782,34 +2782,57 @@ function wireUpdateBanner(){
// The desktop app closes to TRAY, so it can stay open for weeks and keep running the page it loaded on // The desktop app closes to TRAY, so it can stay open for weeks and keep running the page it loaded on
// day one — a deploy would never reach it (that's why fixes "worked on mobile but not on desktop"). // day one — a deploy would never reach it (that's why fixes "worked on mobile but not on desktop").
// Poll the server's build marker; when it changes, offer a Refresh. Also exposed in Settings. // Poll the server's build marker; when it changes, offer a Refresh. Also exposed in Settings.
let _newBuild=null; // The user should NEVER have to think about "web build" vs "app version" — a new deploy applies ITSELF.
// We poll the server's build marker and, as soon as it's SAFE, silently reload onto the new code. Safe =
// not in a call, no live screen session, nothing half-typed, no dialog open. If the user is busy we simply
// keep waiting and apply it the moment they're free (or when the window is next hidden).
let _newBuild=null, _autoRefreshTimer=null;
async function checkWebBuild(){ async function checkWebBuild(){
try{ try{
const r=await fetch('/api/build',{cache:'no-store'}); if(!r.ok) return; const r=await fetch('/api/build',{cache:'no-store'}); if(!r.ok) return;
const d=await r.json(); const d=await r.json();
if(d && d.build && window.__BUILD && d.build!==window.__BUILD && d.build!==_newBuild){ _newBuild=d.build; showRefreshBanner(); } if(d && d.build && window.__BUILD && d.build!==window.__BUILD){
if(_newBuild!==d.build){ _newBuild=d.build; console.log('[build] new version on server:', d.build, '(running', window.__BUILD+')'); }
scheduleAutoRefresh();
}
}catch(_){} }catch(_){}
} }
function showRefreshBanner(){ // Never yank the page out from under someone mid-task.
if(document.getElementById('refreshBanner')) return; function safeToRefresh(){
const el=document.createElement('div'); el.id='refreshBanner'; el.className='upd-banner show ready'; try{
el.innerHTML=ic('download',15)+' <span>A new version of Biz Connect is available</span> <button id="rbGo">Refresh</button>'; if(typeof meetState!=='undefined' && meetState==='call') return false; // in a meeting/call
document.body.appendChild(el); if(document.querySelector('.railbtn.live')) return false; // live share/connect session
el.querySelector('#rbGo').onclick=()=>hardReloadApp(); if(document.querySelector('.modal-ov, .call-invite, .guest-prejoin, .lightbox')) return false; // a dialog is open
const inp=document.getElementById('msgInput'); if(inp && inp.value.trim()) return false; // half-typed message
const mc=document.getElementById('mcInput'); if(mc && mc.value.trim()) return false;
}catch(_){}
return true;
}
function scheduleAutoRefresh(){
if(_autoRefreshTimer) return;
const tryNow=()=>{
if(!_newBuild) return;
if(!safeToRefresh()) return; // busy → try again shortly
clearInterval(_autoRefreshTimer); _autoRefreshTimer=null;
try{ sessionStorage.setItem('bzc_autoref','1'); }catch(_){} // so we can confirm after reload
hardReloadApp();
};
_autoRefreshTimer=setInterval(tryNow, 15000);
setTimeout(tryNow, 1200); // usually applies right away
} }
// Hard refresh: on desktop this clears the shell's HTTP cache and reloads ignoring cache. In a browser we // Hard refresh: on desktop this clears the shell's HTTP cache and reloads ignoring cache. In a browser we
// also drop any service-worker + CacheStorage entries, then reload — so there's no stale-code dead end. // also drop any service-worker + CacheStorage entries, then reload — so there's no stale-code dead end.
async function hardReloadApp(){ async function hardReloadApp(){
if(meetState==='call' && !(await bzConfirm('You are in a call. Refreshing will leave it.', {title:'Refresh now?', okText:'Refresh'}))) return;
try{ const n=window.bizConnectNative; if(n && n.hardReload){ n.hardReload(); return; } }catch(_){} try{ const n=window.bizConnectNative; if(n && n.hardReload){ n.hardReload(); return; } }catch(_){}
try{ if('serviceWorker' in navigator){ const rs=await navigator.serviceWorker.getRegistrations(); await Promise.all(rs.map(r=>r.unregister().catch(()=>{}))); } }catch(_){} try{ if('serviceWorker' in navigator){ const rs=await navigator.serviceWorker.getRegistrations(); await Promise.all(rs.map(r=>r.unregister().catch(()=>{}))); } }catch(_){}
try{ if(window.caches && caches.keys){ const ks=await caches.keys(); await Promise.all(ks.map(k=>caches.delete(k).catch(()=>{}))); } }catch(_){} try{ if(window.caches && caches.keys){ const ks=await caches.keys(); await Promise.all(ks.map(k=>caches.delete(k).catch(()=>{}))); } }catch(_){}
try{ location.replace(location.pathname+'?_r='+Date.now()); }catch(_){ location.reload(); } try{ location.replace(location.pathname+location.search); }catch(_){ location.reload(); }
} }
setInterval(checkWebBuild, 10*60*1000); // every 10 min setInterval(checkWebBuild, 5*60*1000); // every 5 min
window.addEventListener('focus', checkWebBuild); // and whenever the user comes back to the app window.addEventListener('focus', checkWebBuild); // and whenever the user comes back to the app
document.addEventListener('visibilitychange', ()=>{ if(!document.hidden) checkWebBuild(); }); document.addEventListener('visibilitychange', ()=>{ if(!document.hidden) checkWebBuild(); });
setTimeout(checkWebBuild, 4000); // shortly after boot setTimeout(checkWebBuild, 3000); // shortly after boot
try{ if(sessionStorage.getItem('bzc_autoref')){ sessionStorage.removeItem('bzc_autoref'); setTimeout(()=>toast('Updated to the latest version'),1200); } }catch(_){}
// Small 'i' badge on the profile button when an update is pending; clicking opens Settings. // Small 'i' badge on the profile button when an update is pending; clicking opens Settings.
function applyUpdateBadge(){ function applyUpdateBadge(){
document.querySelectorAll('.upd-dot').forEach(x=>x.remove()); document.querySelectorAll('.upd-dot').forEach(x=>x.remove());
+31 -7
View File
@@ -13,6 +13,23 @@ const parseMentions = (s) => { if (!s) return []; try { const a = JSON.parse(s);
const SYSTEM_SENDER = '__system__'; const SYSTEM_SENDER = '__system__';
const msgDTO = (m) => ({ id: m.id, from: m.sender_id, to: m.recipient_id, conversation_id: m.conversation_id || null, body: m.deleted ? '' : m.body, created_at: m.created_at, read_at: m.read_at, delivered_at: m.delivered_at || null, reply_to: m.deleted ? null : (m.reply_to || null), mentions: parseMentions(m.mentions), evt: m.msg_type || null, fwd_from: m.deleted ? null : (m.fwd_from || null), deleted: !!m.deleted, system: m.sender_id === SYSTEM_SENDER || !!m.msg_type }); const msgDTO = (m) => ({ id: m.id, from: m.sender_id, to: m.recipient_id, conversation_id: m.conversation_id || null, body: m.deleted ? '' : m.body, created_at: m.created_at, read_at: m.read_at, delivered_at: m.delivered_at || null, reply_to: m.deleted ? null : (m.reply_to || null), mentions: parseMentions(m.mentions), evt: m.msg_type || null, fwd_from: m.deleted ? null : (m.fwd_from || null), deleted: !!m.deleted, system: m.sender_id === SYSTEM_SENDER || !!m.msg_type });
function namesFor(teamId){ const o = {}; for (const x of R.users.listByTenant(teamId)) o[x.id] = x.name || x.email; return o; } function namesFor(teamId){ const o = {}; for (const x of R.users.listByTenant(teamId)) o[x.id] = x.name || x.email; return o; }
// id -> profile photo, with a fallback across DUPLICATE rows for the same person.
//
// A person can end up with more than one row (signed in by email once and by mobile another time, before
// the bizgaze_user_id merge landed). Only one of those rows carries the DP. Groups happened to reference
// the row WITH the photo while a DM referenced the one without — so the same contact showed their picture
// in a group but fell back to initials in the 1:1. Key each row by its stable person identity (BizGaze
// person id, else email, else name) and let a photo-less row borrow its twin's photo.
function avatarsFor(teamId) {
const users = R.users.listByTenant(teamId);
const personKey = (x) => (x.bizgaze_user_id ? 'bz:' + x.bizgaze_user_id
: (x.email ? 'em:' + String(x.email).toLowerCase() : 'nm:' + String(x.name || '').trim().toLowerCase()));
const byPerson = {};
for (const x of users) { if (x.avatar_url) { const k = personKey(x); if (!byPerson[k]) byPerson[k] = x.avatar_url; } }
const out = {};
for (const x of users) out[x.id] = x.avatar_url || byPerson[personKey(x)] || null;
return out;
}
// Next future occurrence (same time-of-day) of a weekly-recurring meeting; searches 14 days ahead. // Next future occurrence (same time-of-day) of a weekly-recurring meeting; searches 14 days ahead.
function nextOccurrence(baseTs, days, nowTs){ const b = new Date(baseTs); const hh = b.getHours(), mm = b.getMinutes(); const s = new Date(nowTs); for (let i = 0; i <= 14; i++){ const d = new Date(s.getFullYear(), s.getMonth(), s.getDate() + i, hh, mm, 0, 0); if (days.indexOf(d.getDay()) >= 0 && d.getTime() > nowTs) return d.getTime(); } return baseTs; } function nextOccurrence(baseTs, days, nowTs){ const b = new Date(baseTs); const hh = b.getHours(), mm = b.getMinutes(); const s = new Date(nowTs); for (let i = 0; i <= 14; i++){ const d = new Date(s.getFullYear(), s.getMonth(), s.getDate() + i, hh, mm, 0, 0); if (days.indexOf(d.getDay()) >= 0 && d.getTime() > nowTs) return d.getTime(); } return baseTs; }
const RDAY = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const RDAY = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
@@ -685,7 +702,8 @@ route('GET', '/api/messages/contacts', async (req, res) => {
const u = currentUser(req); const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' }); if (!u) return json(res, 401, { error: 'unauthorized' });
const rows = R.users.listByTenant(u.team_id).filter((x) => x.id !== u.id && x.active !== 0); const rows = R.users.listByTenant(u.team_id).filter((x) => x.id !== u.id && x.active !== 0);
json(res, 200, rows.map((x) => ({ id: x.id, name: x.name || x.email, email: x.email, online: CHAT.isOnline(x.id), avatar: x.avatar_url || null }))); const cAv = avatarsFor(u.team_id); // duplicate-row DP fallback
json(res, 200, rows.map((x) => ({ id: x.id, name: x.name || x.email, email: x.email, online: CHAT.isOnline(x.id), avatar: cAv[x.id] || null })));
}); });
// Cross-tenant people search via the BizGaze directory (token stays server-side). Results are // Cross-tenant people search via the BizGaze directory (token stays server-side). Results are
@@ -714,17 +732,21 @@ route('GET', '/api/messages/conversations', async (req, res) => {
const names = {}; const names = {};
const avatars = {}; const avatars = {};
const statuses = {}; const statuses = {};
for (const x of R.users.listByTenant(u.team_id)) { names[x.id] = x.name || x.email; avatars[x.id] = x.avatar_url || null; statuses[x.id] = x.status || 'active'; } for (const x of R.users.listByTenant(u.team_id)) { names[x.id] = x.name || x.email; statuses[x.id] = x.status || 'active'; }
Object.assign(avatars, avatarsFor(u.team_id)); // same person / two rows → borrow the DP (see avatarsFor)
const favs = new Set(R.favorites.forUser(u.id)); const favs = new Set(R.favorites.forUser(u.id));
const inCall = new Set(); const inCall = new Set();
for (const [, peers] of meetingRooms) { for (const [, p] of peers) { if (p.ws && p.ws._meetingUserId) inCall.add(p.ws._meetingUserId); } } for (const [, peers] of meetingRooms) { for (const [, p] of peers) { if (p.ws && p.ws._meetingUserId) inCall.add(p.ws._meetingUserId); } }
// DMs // DMs
const byOther = new Map(); const byOther = new Map();
for (const m of R.messages.recentFor(u.team_id, u.id)) { for (const m of R.messages.recentFor(u.team_id, u.id)) {
const other = m.sender_id === u.id ? m.recipient_id : m.sender_id; const raw = m.sender_id === u.id ? m.recipient_id : m.sender_id;
if (!other) continue; if (!raw) continue;
// If this counterparty was merged away, key the row by the SURVIVING account, so the thread carries
// that account's name/photo/presence (and two half-threads for one person collapse into one row).
const other = (() => { try { return R.users.resolve(raw) || raw; } catch (_) { return raw; } })();
if (!byOther.has(other)) byOther.set(other, { other, last: m, unread: 0 }); if (!byOther.has(other)) byOther.set(other, { other, last: m, unread: 0 });
if (m.recipient_id === u.id && m.sender_id === other && !m.read_at) byOther.get(other).unread++; if (m.recipient_id === u.id && (m.sender_id === raw || m.sender_id === other) && !m.read_at) byOther.get(other).unread++;
} }
const dmItems = [...byOther.values()].map((c) => { const dmItems = [...byOther.values()].map((c) => {
const dc = dmCalls.get(CALLS.pairKey(u.id, c.other)); const dc = dmCalls.get(CALLS.pairKey(u.id, c.other));
@@ -839,7 +861,8 @@ route('GET', '/api/groups/members', async (req, res) => {
const gid = new URLSearchParams(req.url.split('?')[1] || '').get('group'); const gid = new URLSearchParams(req.url.split('?')[1] || '').get('group');
if (!gid || !R.conversations.isMember(gid, u.id)) return json(res, 403, { error: 'not a member' }); if (!gid || !R.conversations.isMember(gid, u.id)) return json(res, 403, { error: 'not a member' });
const names = {}; const avatars = {}; const names = {}; const avatars = {};
for (const x of R.users.listByTenant(u.team_id)) { names[x.id] = x.name || x.email; avatars[x.id] = x.avatar_url || null; } for (const x of R.users.listByTenant(u.team_id)) { names[x.id] = x.name || x.email; }
Object.assign(avatars, avatarsFor(u.team_id));
const adminSet = new Set(R.conversations.admins(gid)); const adminSet = new Set(R.conversations.admins(gid));
json(res, 200, R.conversations.members(gid).map((mid) => ({ id: mid, name: names[mid] || 'Unknown', avatar: avatars[mid] || null, admin: adminSet.has(mid) }))); json(res, 200, R.conversations.members(gid).map((mid) => ({ id: mid, name: names[mid] || 'Unknown', avatar: avatars[mid] || null, admin: adminSet.has(mid) })));
}); });
@@ -853,7 +876,8 @@ route('GET', '/api/groups/info', async (req, res) => {
const g = R.conversations.byId(gid); const g = R.conversations.byId(gid);
const tenantUsers = R.users.listByTenant(u.team_id); const tenantUsers = R.users.listByTenant(u.team_id);
const names = {}; const avatars = {}; const names = {}; const avatars = {};
for (const x of tenantUsers) { names[x.id] = x.name || x.email; avatars[x.id] = x.avatar_url || null; } for (const x of tenantUsers) { names[x.id] = x.name || x.email; }
Object.assign(avatars, avatarsFor(u.team_id));
const adminSet = new Set(R.conversations.admins(gid)); const adminSet = new Set(R.conversations.admins(gid));
json(res, 200, { json(res, 200, {
id: gid, name: g.name || 'Group', createdBy: g.created_by, isCreator: g.created_by === u.id, id: gid, name: g.name || 'Group', createdBy: g.created_by, isCreator: g.created_by === u.id,