fix: seen-by popup (#2), info-view DP preview (#5), notification DP cache + no Close btn, branded update flow (0.1.7)

- #2: 'Seen by' opens an on-screen popup listing readers (was a flash toast).
- #5: the DM contact-info view now shows the DP; clicking it previews full-size.
- Desktop notifications: cache DPs per-sender (fast AND with photo after the first);
  removed timeoutType:'never' which added an unwanted 'Close' button.
- Update flow: dropped the unbranded native restart dialog — the branded web banner
  handles Restart. Banner text clearer ('Downloading update…'), plus an update
  indicator that cascades profile 'i' badge → Settings → version line, with the
  Settings button becoming 'Restart now' when the update is downloaded. desktop 0.1.7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 16:56:39 +05:30
parent 49718e5d37
commit 4e2ccb5d60
3 changed files with 60 additions and 34 deletions
+19 -26
View File
@@ -56,20 +56,26 @@ const APP_ID = 'com.bizgaze.connect.desktop';
// URL (legacy) or an http(s) DP URL, which we download (external photos can't be drawn to a canvas
// in the renderer without tainting it, so the renderer now passes the URL straight through).
function tmpPngPath() { return path.join(app.getPath('temp'), 'bizc-toast-' + crypto.randomBytes(4).toString('hex') + '.png'); }
// Cache downloaded DPs for the session (keyed by URL) so the SAME sender's photo is instant on the
// next notification — the first one may still show without a photo if the download is slow, but after
// that it's cached. Cached files are NOT deleted after use.
const avatarCache = new Map();
function avatarToTempPng(src) {
return new Promise((resolve) => {
try {
if (!src) return resolve(null);
if (/^data:image\/png;base64,/.test(src)) { const p = tmpPngPath(); fs.writeFileSync(p, Buffer.from(src.split(',')[1], 'base64')); return resolve(p); }
const cached = avatarCache.get(src);
if (cached) { try { if (fs.existsSync(cached)) return resolve(cached); } catch (_) {} avatarCache.delete(src); }
if (/^data:image\/png;base64,/.test(src)) { const p = tmpPngPath(); fs.writeFileSync(p, Buffer.from(src.split(',')[1], 'base64')); avatarCache.set(src, p); return resolve(p); }
if (/^https?:\/\//i.test(src)) {
const mod = src.startsWith('https') ? require('https') : require('http');
const p = tmpPngPath(); const file = fs.createWriteStream(p);
const req = mod.get(src, (res) => {
if (res.statusCode !== 200) { res.resume(); file.close(() => { try { fs.unlinkSync(p); } catch (_) {} }); return resolve(null); }
res.pipe(file); file.on('finish', () => file.close(() => resolve(p)));
res.pipe(file); file.on('finish', () => file.close(() => { avatarCache.set(src, p); resolve(p); }));
});
req.on('error', () => resolve(null));
req.setTimeout(2500, () => { try { req.destroy(); } catch (_) {} resolve(null); });
req.setTimeout(4000, () => { try { req.destroy(); } catch (_) {} resolve(null); });
return;
}
resolve(null);
@@ -96,27 +102,26 @@ ipcMain.handle('reply-notification', async (_e, payload = {}) => {
const finish = (v) => {
if (done) return; done = true;
if (n) { activeNotifs.delete(n); }
if (img) { try { fs.unlinkSync(img); } catch (_) {} }
resolve(v);
resolve(v); // note: img is cached, not deleted
};
try {
// No timeoutType:'never' — on Windows that added an unwanted "Close" action button. Windows'
// default toast behavior + our strong reference keep it visible long enough; the in-app call
// popup provides the persistent Join/Decline for calls.
n = new Notification({
title: payload.title || 'Biz Connect',
body: payload.body || '',
icon: img ? nativeImage.createFromPath(img) : undefined,
silent: false,
// A call invite stays on screen until clicked/ended; a chat toast uses the default timeout.
timeoutType: payload.persistent ? 'never' : 'default',
});
activeNotifs.add(n); // strong ref → toast isn't collected; click stays live
n.on('click', () => {
if (win) { if (win.isMinimized()) win.restore(); win.show(); win.focus(); } // raise the app
finish({ kind: payload.kind, id: payload.id, open: true });
});
n.on('close', () => finish(null)); // user/system dismissed it → no action (don't force-close)
n.on('close', () => finish(null)); // user/system dismissed it → no action
n.show();
// Safety timeout so the promise never leaks. Calls get the full ring window; chats shorter.
setTimeout(() => { try { if (n) n.close(); } catch (_) {} finish(null); }, payload.persistent ? 45000 : 25000);
setTimeout(() => finish(null), payload.persistent ? 45000 : 25000); // don't leak the promise
} catch (_) { finish(null); }
});
});
@@ -246,22 +251,10 @@ app.whenReady().then(() => {
autoUpdater.on('update-not-available', () => sendUpdate({ phase: 'current' }));
autoUpdater.on('download-progress', (p) => sendUpdate({ phase: 'downloading', percent: Math.round((p && p.percent) || 0) }));
autoUpdater.on('error', () => sendUpdate({ phase: 'error' }));
// When an update finishes downloading (auto or via the Settings "Check for updates"), offer a
// clear restart prompt instead of only the silent on-next-launch install.
let promptedForUpdate = false;
autoUpdater.on('update-downloaded', async (info) => {
sendUpdate({ phase: 'ready', version: info && info.version });
if (promptedForUpdate) return; promptedForUpdate = true;
try {
const { dialog } = require('electron');
const res = await dialog.showMessageBox(win || undefined, {
type: 'info', buttons: ['Restart now', 'Later'], defaultId: 0, cancelId: 1,
title: 'Update ready', message: 'Biz Connect ' + ((info && info.version) || '') + ' is ready.',
detail: 'Restart the app to finish updating.',
});
if (res.response === 0) autoUpdater.quitAndInstall();
} catch (_) { /* fall back to install-on-next-launch */ }
});
// When an update finishes downloading, tell the web UI so it can show a BRANDED "Update ready —
// Restart now" banner (restartToUpdate IPC does the install). No native dialog — that was
// unbranded. It still installs on next launch if the user never clicks Restart.
autoUpdater.on('update-downloaded', (info) => sendUpdate({ phase: 'ready', version: info && info.version }));
const check = () => autoUpdater.checkForUpdatesAndNotify().catch(() => {});
check();
setInterval(check, 6 * 60 * 60 * 1000);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "biz-connect-desktop",
"version": "0.1.6",
"version": "0.1.7",
"description": "Biz Connect technician desktop client — loads the Connect web UI with native screen capture",
"author": {
"name": "BizGaze",
+40 -7
View File
@@ -449,6 +449,9 @@
.upd-banner{position:fixed;left:50%;bottom:16px;transform:translateX(-50%) translateY(70px);z-index:9500;display:none;align-items:center;gap:.5rem;background:var(--blue);color:#fff;border-radius:999px;padding:.5rem .5rem .5rem .9rem;box-shadow:0 10px 30px rgba(20,30,60,.32);font-size:.85rem;font-weight:600;transition:transform .25s ease;}
.upd-banner.show{display:flex;transform:translateX(-50%) translateY(0);}
.upd-banner button{border:none;background:var(--brand);color:var(--blue-d);border-radius:999px;padding:.35rem .8rem;font-weight:700;cursor:pointer;}
.upd-dot{position:absolute;top:-3px;right:-3px;width:15px;height:15px;border-radius:50%;background:var(--brand);color:var(--blue-d);font-size:.66rem;font-weight:800;font-style:italic;display:grid;place-items:center;border:2px solid var(--blue);pointer-events:none;}
#setUpd.has-update{background:var(--brand);color:var(--blue-d);}
.set-ver-i{display:inline-grid;place-items:center;width:15px;height:15px;border-radius:50%;background:var(--brand);color:var(--blue-d);font-size:.66rem;font-weight:800;font-style:italic;margin-left:.35rem;vertical-align:middle;}
.bubble .msg-link{color:inherit;text-decoration:underline;text-underline-offset:2px;word-break:break-word;}
.bubble.them .msg-link{color:var(--blue);} .bubble.mine .msg-link{color:#dbe9ff;}
.bubble .fwd-label{display:flex;align-items:center;gap:.2rem;font-size:.72rem;font-style:italic;opacity:.7;margin-bottom:.2rem;}
@@ -550,6 +553,10 @@
.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;}
.seen-modal .seen-list{max-height:44vh;overflow-y:auto;display:flex;flex-direction:column;gap:.2rem;}
.seen-modal .seen-row{display:flex;align-items:center;gap:.6rem;padding:.35rem .3rem;}
.seen-modal .seen-row .mini-av{width:30px;height:30px;flex:0 0 auto;border-radius:50%;display:grid;place-items:center;color:#fff;font-weight:700;font-size:.72rem;}
.seen-modal .seen-row .sn{font-size:.92rem;}
.bzc-actions{display:flex;justify-content:flex-end;gap:.5rem;}
.bzc-actions button{border:none;border-radius:10px;padding:.55rem 1rem;font-size:.9rem;font-weight:600;cursor:pointer;font-family:inherit;}
.bzc-cancel{background:var(--blue-soft);color:var(--blue);}
@@ -833,7 +840,7 @@
<body>
<script src="/icons.js?v=5"></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-08-batch59';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
<script>window.__BUILD='2026-07-08-batch60';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>
@@ -1010,13 +1017,15 @@ function openSettings(){
+sw('setGroup','Group message notifications', notifOn('group'))
+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>'
+(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||'—')+'</div></span><button class="btn sm" id="setUpd">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>'):'')
+'<div class="hint" style="margin-top:.4rem">These preferences are saved on this device.</div></div>';
document.body.appendChild(ov);
ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
ov.querySelector('#setClose').onclick=()=>ov.remove();
const updBtn=ov.querySelector('#setUpd'); // #12: desktop version + manual update check
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){ toast('Update '+(_pendingUpdate.version||'')+' is downloading…'); return; }
const ver=ov.querySelector('#setVer'); updBtn.disabled=true; updBtn.textContent='Checking…';
let done=false;
try{
@@ -1244,11 +1253,12 @@ async function openSharedItems(kind,id,name){
if(document.getElementById('sharedModal')) return;
const r0=rowFor(kind,id); const fav=!!(r0&&r0.favorite); const online=!!(r0&&r0.online);
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='sharedModal';
ov.innerHTML='<div class="modal gi"><div class="gi-head" style="margin-bottom:.6rem"><span class="avatar" style="width:46px;height:46px;flex:0 0 46px;background:'+avColor(name)+'">'+pEsc(initials(name||'?'))+'</span><div class="gi-name"><div class="gi-title-row"><span class="gi-title">'+pEsc(name||'')+'</span><button class="fav-star'+(fav?' on':'')+'" id="shFav" title="'+(fav?'Remove from favourites':'Add to favourites')+'">'+ic('star',16)+'</button></div><div class="gi-sub"><span class="st-dot '+statusCls(r0)+'"></span>'+statusLabel(r0)+'</div></div><button class="iconbtn" id="shClose">'+ic('x',18)+'</button></div>'
ov.innerHTML='<div class="modal gi"><div class="gi-head" style="margin-bottom:.6rem"><button class="gi-photo" id="shPhoto" title="View photo"><span class="avatar" style="width:46px;height:46px;flex:0 0 46px;background:'+avColor(name)+'">'+pEsc(initials(name||'?'))+((r0&&r0.avatar)?'<img class="av-img" src="'+pEsc(r0.avatar)+'" alt="" onerror="this.remove()">':'')+'</span></button><div class="gi-name"><div class="gi-title-row"><span class="gi-title">'+pEsc(name||'')+'</span><button class="fav-star'+(fav?' on':'')+'" id="shFav" title="'+(fav?'Remove from favourites':'Add to favourites')+'">'+ic('star',16)+'</button></div><div class="gi-sub"><span class="st-dot '+statusCls(r0)+'"></span>'+statusLabel(r0)+'</div></div><button class="iconbtn" id="shClose">'+ic('x',18)+'</button></div>'
+mediaRowHTML()+'</div>';
document.body.appendChild(ov);
ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
ov.querySelector('#shClose').onclick=()=>ov.remove();
const sp=ov.querySelector('#shPhoto'); if(sp) sp.onclick=()=>{ const r=rowFor(kind,id)||r0; if(r&&r.avatar) openLightbox(r.avatar); else toast('No profile photo'); }; // #5: click the DP in the info view to preview it
const fb=ov.querySelector('#shFav'); if(fb) fb.onclick=async()=>{ const r=rowFor(kind,id); const on=!(r&&r.favorite); if(r) r.favorite=on; fb.classList.toggle('on',on); fb.title=on?'Remove from favourites':'Add to favourites'; try{ await postJSON('/api/favorites',{kind,id,on}); }catch(_){} renderChats(searchVal()); };
wireMediaEntry(ov, kind, id, name);
}
@@ -1670,6 +1680,16 @@ function onChatRead(d){ if(!d||!d.by) return;
// 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).
// #2: "Seen by" as an on-screen popup listing the readers (not a flash toast).
function showSeenByModal(names){
if(document.getElementById('seenModal')) return;
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='seenModal';
const body=(names&&names.length)?('<div class="seen-list">'+names.map(n=>'<div class="seen-row"><span class="mini-av" style="background:'+avColor(n)+'">'+pEsc(initials(n))+'</span><span class="sn">'+pEsc(n)+'</span></div>').join('')+'</div>'):'<div class="gi-noresult" style="padding:.6rem 0">No one has seen this yet.</div>';
ov.innerHTML='<div class="modal seen-modal" style="max-width:320px"><h3>'+ic('checkCheck',16)+' Seen by'+((names&&names.length)?(' <span class="muted" style="font-weight:400;font-size:.8rem">('+names.length+')</span>'):'')+'</h3>'+body+'<div class="bzc-actions" style="margin-top:.9rem"><button type="button" class="bzc-ok" id="seenClose">Close</button></div></div>';
document.body.appendChild(ov);
ov.addEventListener('mousedown',e=>{ if(e.target===ov) ov.remove(); });
ov.querySelector('#seenClose').onclick=()=>ov.remove();
}
function bzConfirm(message, opts){
opts=opts||{};
return new Promise((resolve)=>{
@@ -1972,7 +1992,7 @@ async function openConvo(kind,id){
const dl=e.target.closest('.del-btn'); if(dl){ deleteMessage(dl.dataset.del); return; }
const ab=e.target.closest('.react-btn'); if(ab){ openEmojiForReact(ab.dataset.id, ab); return; }
const ch=e.target.closest('.react-chip'); if(ch){ reactToMessage(ch.dataset.id, ch.dataset.emoji); return; }
const sb=e.target.closest('.seenby,.rcpt.grp'); if(sb){ const ns=(sb.dataset.seen||'').split('|').filter(Boolean); toast(ns.length?('Seen by: '+ns.join(', ')):'Not seen yet'); return; }
const sb=e.target.closest('.seenby,.rcpt.grp'); if(sb){ const ns=(sb.dataset.seen||'').split('|').filter(Boolean); showSeenByModal(ns); return; }
// Tap-to-reveal (mobile): a tap on the bubble body (not an action) reveals its reply/react/delete
// icons; the action only fires on a SECOND tap once they're shown (icons are pointer-events:none
// until revealed). Tapping elsewhere hides them.
@@ -2349,19 +2369,32 @@ function notifAvatarDataUrl(kind,id,fallbackName){
}
// Reply to a conversation without opening it (used by the notification quick-reply).
async function sendReplyTo(kind,id,text){ try{ await postJSON('/api/messages', kind==='group'?{group:id,body:text}:{to:id,body:text}); }catch(_){} }
// #3: desktop auto-update progress — show a banner so the user knows an update is downloading/ready.
// #3: desktop auto-update — a progress banner (branded restart) + an update indicator that cascades
// from the profile → Settings → version, so the user always knows a newer version is available.
let _pendingUpdate=null; // {phase, version}
function wireUpdateBanner(){
const n=window.bizConnectNative; if(!(n&&n.onUpdateEvent)) return;
n.onUpdateEvent((d)=>{
if(!d||!d.phase) return;
if(d.phase==='available'||d.phase==='downloading'||d.phase==='ready') _pendingUpdate={ phase:d.phase, version:d.version||(_pendingUpdate&&_pendingUpdate.version)||'' };
else if(d.phase==='current') _pendingUpdate=null;
applyUpdateBadge();
let el=document.getElementById('updBanner');
const ensure=()=>{ if(!el){ el=document.createElement('div'); el.id='updBanner'; el.className='upd-banner'; document.body.appendChild(el); } return el; };
if(d.phase==='downloading'){ ensure().innerHTML=ic('download',15)+' <span>Downloading update'+(d.percent||0)+'%</span>'; el.classList.add('show'); el.classList.remove('ready'); }
else if(d.phase==='available'){ ensure().innerHTML=ic('download',15)+' <span>Update '+pEsc(d.version||'')+' found — downloading…</span>'; el.classList.add('show'); }
if(d.phase==='available'){ ensure().innerHTML=ic('download',15)+' <span>Downloading update'+(d.version?(' '+pEsc(d.version)):'')+'</span>'; el.classList.add('show'); el.classList.remove('ready'); }
else if(d.phase==='downloading'){ ensure().innerHTML=ic('download',15)+' <span>Downloading update '+(d.percent||0)+'%</span>'; el.classList.add('show'); el.classList.remove('ready'); }
else if(d.phase==='ready'){ ensure().innerHTML=ic('check',15)+' <span>Update ready</span> <button id="updRestart">Restart now</button>'; el.classList.add('show','ready'); const b=el.querySelector('#updRestart'); if(b) b.onclick=()=>{ try{ n.restartToUpdate&&n.restartToUpdate(); }catch(_){} }; }
else if(d.phase==='current'||d.phase==='error'){ if(el){ el.classList.remove('show'); setTimeout(()=>{ if(el&&!el.classList.contains('show')) el.remove(); }, 400); } }
});
}
// Small 'i' badge on the profile button when an update is pending; clicking opens Settings.
function applyUpdateBadge(){
document.querySelectorAll('.upd-dot').forEach(x=>x.remove());
if(!_pendingUpdate) return;
const host=document.getElementById('pbtn'); if(!host) return;
const dot=document.createElement('span'); dot.className='upd-dot'; dot.title='Update available — open Settings'; dot.textContent='i';
host.appendChild(dot);
}
// #13: track shown page-notifications by conversation tag so reading elsewhere can dismiss them.
const _shownNotifs={};
function onNotifClear(d){