diff --git a/desktop/main.js b/desktop/main.js index 0b32c7a..04cbf16 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -103,9 +103,14 @@ ipcMain.handle('reply-notification', async (_e, payload = {}) => { // 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 + const src = payload.avatar; + if (src && /^data:image\//i.test(src)) { + img = await avatarToTempPng(src); // generated icon (initials/group) — synchronous, always use immediately + } else if (src) { + const c = avatarCache.get(src); + if (c && fs.existsSync(c)) img = c; // http DP already warmed (contacts pre-cached on load) → instant + else avatarToTempPng(src).catch(() => {}); // not cached yet → fire now, warm for next time + } } catch (_) {} return await new Promise((resolve) => { let done = false; @@ -222,10 +227,23 @@ function createWindow() { return { action: 'allow' }; }); - // Right-click menu: spelling corrections for the misspelled word under the cursor (+ add to - // dictionary), plus standard cut/copy/paste in editable fields. This is what makes the spell - // checker actionable — click a suggestion to fix the word in place. + // Spell-check menu. Two ways in: + // - RIGHT-click: full editing menu (suggestions + cut/copy/paste), the standard desktop behavior. + // - LEFT-click on a misspelled word: the renderer asks us (spell-suggest) to synthesize a + // right-click at that point, so Chromium hands us the real dictionary suggestions — then we show + // a SUGGESTIONS-ONLY menu. This gives the user corrections on a plain left click. win.webContents.on('context-menu', (_e, params) => { + const fromLeftClick = spellClickPending; spellClickPending = false; + if (fromLeftClick) { + if (!params.misspelledWord) return; // clicked a correctly-spelled word → no menu, don't disturb typing + const menu = new Menu(); + for (const s of (params.dictionarySuggestions || [])) menu.append(new MenuItem({ label: s, click: () => win.webContents.replaceMisspelling(s) })); + if (!menu.items.length) menu.append(new MenuItem({ label: 'No suggestions', enabled: false })); + menu.append(new MenuItem({ type: 'separator' })); + menu.append(new MenuItem({ label: 'Add to dictionary', click: () => win.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord) })); + menu.popup(); + return; + } const menu = new Menu(); for (const s of (params.dictionarySuggestions || [])) { menu.append(new MenuItem({ label: s, click: () => win.webContents.replaceMisspelling(s) })); @@ -244,6 +262,20 @@ function createWindow() { if (menu.items.length) menu.popup(); }); } +// Set just before we synthesize a right-click from a renderer LEFT-click, so the context-menu handler +// knows to show a suggestions-only menu (and to stay silent on correctly-spelled words). +let spellClickPending = false; +ipcMain.on('spell-suggest', (_e, pos) => { + try { + if (!win || win.isDestroyed() || !pos) return; + const x = Math.round(pos.x), y = Math.round(pos.y); + if (!(x >= 0 && y >= 0)) return; + spellClickPending = true; + win.webContents.sendInputEvent({ type: 'mouseDown', x, y, button: 'right', clickCount: 1 }); + win.webContents.sendInputEvent({ type: 'mouseUp', x, y, button: 'right', clickCount: 1 }); + setTimeout(() => { spellClickPending = false; }, 500); // guard: clear if no context-menu fired + } catch (_) { spellClickPending = false; } +}); // The full Connect experience needs several web capabilities that Electron denies by // default. We grant them for our own trusted origin: diff --git a/desktop/package.json b/desktop/package.json index a51fd4e..5e9562e 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "biz-connect-desktop", - "version": "0.1.11", + "version": "0.1.12", "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 190084f..ce35723 100644 --- a/desktop/preload.js +++ b/desktop/preload.js @@ -25,6 +25,9 @@ contextBridge.exposeInMainWorld('bizConnectNative', Object.freeze({ // 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); } }, + // Ask the shell to show native spelling suggestions for the word at page coords (x,y) — used to bring + // up corrections on a LEFT click in the message box (not just right-click). + spellSuggestAt: (x, y) => { try { ipcRenderer.send('spell-suggest', { x, y }); } catch (_) {} }, // 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 6131bbe..aff5ed3 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -856,7 +856,7 @@
- @@ -2034,13 +2034,19 @@ function renderThread(keepScroll){ function appendBubble(m){ if(rendered.has(m.id)) return; rendered.add(m.id); const box=document.getElementById('msgs'); if(!box) return; + // Was the user already at the bottom? If they've scrolled UP to read history, an incoming message + // must NOT yank them back down (that made the thread feel "stuck" — you couldn't stay scrolled up, + // especially now that background messages arrive live). Keep their position + flag "jump to latest". + const atBottom=(box.scrollHeight - box.scrollTop - box.clientHeight) < 160; const empty=box.querySelector('.empty-thread'); if(empty) empty.remove(); // A new message becomes the newest: move the group "Seen by" off the previous last message. if(!m.system && !m.deleted){ if(_lastGroupId){ const pe=box.querySelector('.bubble[data-id="'+((window.CSS&&CSS.escape)?CSS.escape(_lastGroupId):_lastGroupId)+'"] .seenby'); if(pe) pe.remove(); } _lastGroupId=m.id; if(m.from===ME.id) _lastMineId=m.id; } const dk=dayKey(m.created_at); if(dk!==_lastDay){ box.insertAdjacentHTML('beforeend', '