diff --git a/desktop/main.js b/desktop/main.js index 627a8ce..52bf88a 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -83,6 +83,13 @@ function avatarToTempPng(src) { }); } +// Pre-warm the DP cache for the renderer's contacts (called after chats load), so the FIRST +// notification from anyone already has their photo — no per-toast download wait. +ipcMain.handle('precache-avatars', async (_e, urls = []) => { + try { for (const u of (Array.isArray(urls) ? urls : []).slice(0, 100)) { try { await avatarToTempPng(u); } catch (_) {} } } catch (_) {} + return true; +}); + // Keep STRONG references to live notifications. Electron/Windows garbage-collects a Notification // with no reference, which closed the toast within ~1s and made clicks do nothing. const activeNotifs = new Set(); @@ -90,14 +97,16 @@ const activeNotifs = new Set(); // Resolves {open} when the toast is clicked (renderer then opens that chat), else null. ipcMain.handle('reply-notification', async (_e, payload = {}) => { if (!Notification.isSupported()) return null; - // Do NOT block the toast on the avatar download (that made desktop notifications lag ~15s vs the - // browser's instant one). Race it against a cap: use the DP only if it's ready in time. Chat toasts - // stay snappy (700ms). CALL toasts (persistent) ring for ~40s, so we can afford to wait longer - // (2.5s) to actually show the caller's photo — the whole point of a call notification. - const img = await Promise.race([ - avatarToTempPng(payload.avatar), - new Promise((r) => setTimeout(() => r(null), payload.persistent ? 2500 : 700)), - ]); + // Fire the toast IMMEDIATELY — NEVER block on a download (waiting made notifications lag; a late + // call/chat alert is worse than one without a photo). Use the DP only if it's ALREADY cached + // (instant). If not, kick off a background fetch so the SAME sender's NEXT notification has it. + // Contacts are also pre-warmed on load (precache-avatars), so the photo is usually already cached. + let img = null; + try { + const c = payload.avatar && avatarCache.get(payload.avatar); + if (c && fs.existsSync(c)) img = c; + else if (payload.avatar) avatarToTempPng(payload.avatar).catch(() => {}); // warm for next time + } catch (_) {} return await new Promise((resolve) => { let done = false; let n; diff --git a/desktop/package.json b/desktop/package.json index d3e8336..0bf034a 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "biz-connect-desktop", - "version": "0.1.8", + "version": "0.1.9", "description": "Biz Connect technician desktop client — loads the Connect web UI with native screen capture", "author": { "name": "BizGaze", diff --git a/desktop/preload.js b/desktop/preload.js index 783ae15..190084f 100644 --- a/desktop/preload.js +++ b/desktop/preload.js @@ -22,6 +22,9 @@ contextBridge.exposeInMainWorld('bizConnectNative', Object.freeze({ // Native Windows toast with an inline reply box. Resolves to {text} (replied), {open} (clicked) // or null. Lets the user reply to a chat straight from the notification. replyNotify: (payload) => ipcRenderer.invoke('reply-notification', payload), + // Pre-warm the notification DP cache with contact photo URLs (called after chats load) so the first + // toast from anyone already has their photo — no per-notification download lag. + precacheAvatars: (urls) => { try { return ipcRenderer.invoke('precache-avatars', urls); } catch (_) { return Promise.resolve(false); } }, // Manual "Check for updates" from Settings. Resolves {status:'available'|'current'|'dev'|'error', version?}. // On 'available' the shell downloads in the background and prompts to restart when ready. checkForUpdates: () => ipcRenderer.invoke('check-updates'), diff --git a/server/public/home.html b/server/public/home.html index b2e68c5..e060d69 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -840,7 +840,7 @@
- @@ -1398,6 +1398,17 @@ async function loadSidebar(){ ROWS=items; renderChats(searchVal()); updateRailUnread(); + prewarmNotifAvatars(); +} +// Desktop only: hand the shell every contact/group DP URL so it caches them up front — the first +// notification (chat OR call) from anyone then shows their photo instantly, with no per-toast wait. +let _avatarsWarmed=false; +function prewarmNotifAvatars(){ + try{ + const n=window.bizConnectNative; if(!n||!n.precacheAvatars||_avatarsWarmed) return; + const urls=[...new Set((ROWS||[]).map(r=>r.avatar).filter(u=>u && /^https?:\/\//i.test(u)))]; + if(urls.length){ _avatarsWarmed=true; n.precacheAvatars(urls); } + }catch(_){} } // ----- conversation view ----- @@ -1794,11 +1805,13 @@ function showCallInvite(room, byName, ret, sub, quiet){ +'' +''; document.body.appendChild(el); - if(!quiet){ try{ notify('📞 '+who, (sub?('Group call · '+sub):'is calling you'), ret&&ret.kind, ret&&ret.id, {persistent:true}); }catch(_){} } // OS notification too (skip when merely re-showing on chat open) let closed=false; const close=()=>{ if(closed) return; closed=true; try{ el.remove(); }catch(_){} stopRing(); }; + // Wire Join/Decline BEFORE firing the OS notification so the buttons are live the instant the popup + // shows (previously notify() ran first — the click did nothing until the toast plumbing settled). el.querySelector('.ci-join').onclick=()=>{ close(); meetReturn=ret||null; switchTab('meeting'); enterMeeting(room); }; el.querySelector('.ci-decline').onclick=()=>{ close(); if(ret&&ret.kind==='dm'){ postJSON('/api/calls/decline',{room}).catch(()=>{}); } }; // 1:1 → notify caller; group → silent + if(!quiet){ try{ notify('📞 '+who, (sub?('Group call · '+sub):'is calling you'), ret&&ret.kind, ret&&ret.id, {persistent:true}); }catch(_){} } // OS notification too (skip when merely re-showing on chat open) setTimeout(close, 45000); } // Scheduled-meeting invitation (toast + the meeting shows up in their list). @@ -2353,7 +2366,9 @@ function openFromNotif(kind,id){ // The app is already running (the notification came from it) → open the chat IN-PLACE. A full-page // reload was slow (#19) and dropped live state: the sidebar hadn't reloaded so the header showed // "Conversation" with no DP (#7), and an incoming call's Join/invite popup was lost (#2). - if(window.ME && ME.id && typeof selectChat==='function'){ + // NB: use bare `ME`, NOT `window.ME` — ME is declared with `let`, which does NOT create a global + // property, so `window.ME` was always undefined and EVERY click hit the reload fallback below. + if(ME && ME.id && typeof selectChat==='function'){ if(id){ try{ switchTab('chat'); }catch(_){} try{ selectChat(kind||'dm', id); }catch(_){} } return; }