fix: seen-by layout, group-name notif, teams-style badge, excel paste, spellcheck (0.1.11/batch64)

1. Message-info popup: ONE scroll region for the whole list with sticky
   Seen / Not-seen headers (was a separate scrollbar per section), realigned.
2. Group message notifications now title = GROUP name, body = "Sender: text".
3. Unread taskbar badge: solid red rounded-square (Teams-style) + white outline
   instead of the gradient disc.
4. Pasting Excel/Sheets cells no longer uploads a screenshot — when the clipboard
   carries real text, the text is pasted; image-only clipboards still upload.
5. Spell check in the message box (red squiggles) with a right-click menu of
   corrections + add-to-dictionary; spellcheck enabled on the textarea.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 13:25:37 +05:30
parent 70a776fa6c
commit adafc8c960
3 changed files with 76 additions and 24 deletions
+33 -1
View File
@@ -8,7 +8,7 @@
// - external links open in the user's browser, not inside the app // - external links open in the user's browser, not inside the app
// //
// Server origin is configurable so the same build works against prod or a dev server. // Server origin is configurable so the same build works against prod or a dev server.
const { app, BrowserWindow, session, desktopCapturer, shell, Menu, ipcMain, nativeImage, Notification } = require('electron'); const { app, BrowserWindow, session, desktopCapturer, shell, Menu, MenuItem, ipcMain, nativeImage, Notification } = require('electron');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
@@ -221,6 +221,28 @@ function createWindow() {
if (!url.startsWith(SERVER_URL)) { shell.openExternal(url); return { action: 'deny' }; } if (!url.startsWith(SERVER_URL)) { shell.openExternal(url); return { action: 'deny' }; }
return { action: 'allow' }; 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.
win.webContents.on('context-menu', (_e, params) => {
const menu = new Menu();
for (const s of (params.dictionarySuggestions || [])) {
menu.append(new MenuItem({ label: s, click: () => win.webContents.replaceMisspelling(s) }));
}
if (params.misspelledWord) {
if (params.dictionarySuggestions && params.dictionarySuggestions.length) menu.append(new MenuItem({ type: 'separator' }));
menu.append(new MenuItem({ label: 'Add to dictionary', click: () => win.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord) }));
}
if (params.isEditable || params.editFlags.canCopy) {
if (menu.items.length) menu.append(new MenuItem({ type: 'separator' }));
if (params.editFlags.canCut) menu.append(new MenuItem({ role: 'cut' }));
if (params.editFlags.canCopy) menu.append(new MenuItem({ role: 'copy' }));
if (params.isEditable) menu.append(new MenuItem({ role: 'paste' }));
if (params.isEditable && params.editFlags.canSelectAll) menu.append(new MenuItem({ role: 'selectAll' }));
}
if (menu.items.length) menu.popup();
});
} }
// The full Connect experience needs several web capabilities that Electron denies by // The full Connect experience needs several web capabilities that Electron denies by
@@ -294,6 +316,16 @@ function configureSession() {
ses.setPermissionRequestHandler((_wc, permission, callback) => callback(GRANTED.has(permission))); ses.setPermissionRequestHandler((_wc, permission, callback) => callback(GRANTED.has(permission)));
// Sync check (some getUserMedia paths query this before requesting) // Sync check (some getUserMedia paths query this before requesting)
ses.setPermissionCheckHandler((_wc, permission) => GRANTED.has(permission)); ses.setPermissionCheckHandler((_wc, permission) => GRANTED.has(permission));
// Spell check for the message box (red squiggles) with right-click corrections. Uses the OS
// dictionaries; en-US by default plus whatever the OS UI language is, so mixed typing still checks.
try {
ses.setSpellCheckerEnabled(true);
const langs = ['en-US'];
const sys = (app.getLocale && app.getLocale()) || '';
const avail = (ses.availableSpellCheckerLanguages || []);
if (sys && sys !== 'en-US' && (!avail.length || avail.includes(sys))) langs.push(sys);
ses.setSpellCheckerLanguages(langs);
} catch (_) {}
} }
app.whenReady().then(() => { app.whenReady().then(() => {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "biz-connect-desktop", "name": "biz-connect-desktop",
"version": "0.1.10", "version": "0.1.11",
"description": "Biz Connect technician desktop client — loads the Connect web UI with native screen capture", "description": "Biz Connect technician desktop client — loads the Connect web UI with native screen capture",
"author": { "author": {
"name": "BizGaze", "name": "BizGaze",
+42 -22
View File
@@ -553,16 +553,20 @@
.modal input#grpName:focus,.modal input#giName:focus{outline:none;border-color:var(--brand);} .modal input#grpName:focus,.modal input#giName:focus{outline:none;border-color:var(--brand);}
/* Branded confirm dialog (replaces the OS/Electron window.confirm). */ /* 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;} .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:38vh;overflow-y:auto;display:flex;flex-direction:column;gap:.15rem;} .seen-modal{max-width:360px;display:flex;flex-direction:column;max-height:80vh;}
.seen-modal .seen-row{display:flex;align-items:center;gap:.6rem;padding:.32rem .3rem;} .seen-modal>h3{flex:0 0 auto;}
/* ONE scroll region for the whole list; the two section headers stick to the top as you scroll */
.seen-modal .seen-scroll{flex:1 1 auto;overflow-y:auto;margin:.2rem -.4rem 0;padding:0 .4rem;min-height:0;}
.seen-modal .seen-row{display:flex;align-items:center;gap:.6rem;padding:.34rem .2rem;}
/* dark lettering on the pastel avatar (white was invisible on the light background), unless a real DP covers it */ /* dark lettering on the pastel avatar (white was invisible on the light background), unless a real DP covers it */
.seen-modal .seen-row .mini-av{width:32px;height:32px;flex:0 0 auto;border-radius:50%;display:grid;place-items:center;color:#334155;font-weight:700;font-size:.74rem;overflow:hidden;position:relative;} .seen-modal .seen-row .mini-av{width:34px;height:34px;flex:0 0 auto;border-radius:50%;display:grid;place-items:center;color:#334155;font-weight:700;font-size:.76rem;overflow:hidden;position:relative;}
.seen-modal .seen-row .mini-av .av-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;} .seen-modal .seen-row .mini-av .av-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;}
.seen-modal .seen-row .sn{font-size:.92rem;color:var(--ink);} .seen-modal .seen-row .sn{font-size:.92rem;color:var(--ink);}
.seen-modal .seen-sec{display:flex;align-items:center;gap:.4rem;font-size:.72rem;font-weight:700;letter-spacing:.02em;text-transform:uppercase;color:var(--blue);margin:.7rem .2rem .25rem;} .seen-modal .seen-sec{position:sticky;top:0;z-index:1;background:var(--card,#fff);display:flex;align-items:center;gap:.4rem;font-size:.72rem;font-weight:700;letter-spacing:.02em;text-transform:uppercase;color:var(--blue);padding:.5rem .2rem .3rem;}
.seen-modal .seen-sec.dim{color:var(--muted);} .seen-modal .seen-sec.dim{color:var(--muted);}
.seen-modal .seen-sec .dot{width:7px;height:7px;border-radius:50%;background:#cbd5e1;} .seen-modal .seen-sec .dot{width:7px;height:7px;border-radius:50%;background:#cbd5e1;}
.seen-modal .seen-sec .dot.ok{background:#22c55e;} .seen-modal .seen-sec .dot.ok{background:#22c55e;}
.seen-modal .bzc-actions{flex:0 0 auto;margin-top:.7rem;}
.bzc-actions{display:flex;justify-content:flex-end;gap:.5rem;} .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-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);} .bzc-cancel{background:var(--blue-soft);color:var(--blue);}
@@ -846,7 +850,7 @@
<body> <body>
<script src="/icons.js?v=5"></script> <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 src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script>
<script>window.__BUILD='2026-07-10-batch63';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD); <script>window.__BUILD='2026-07-10-batch64';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>
@@ -1378,18 +1382,18 @@ function setDesktopBadge(count){
if(!(window.bizConnectNative && window.bizConnectNative.setUnread)) return; if(!(window.bizConnectNative && window.bizConnectNative.setUnread)) return;
let dataUrl=null; let dataUrl=null;
if(count>0){ if(count>0){
// Draw at 2× for a crisp taskbar overlay. A vertical red→pink gradient pill with a white ring // Teams-style overlay: a solid red rounded-square (squircle) with a bold white count and a thin
// and a soft drop shadow reads as a modern notification badge (was a flat red disc). // white outline so it separates cleanly from the app icon on the taskbar. Drawn at 2× for crispness.
const s=64, c=document.createElement('canvas'); c.width=s; c.height=s; const g=c.getContext('2d'); const s=64, c=document.createElement('canvas'); c.width=s; c.height=s; const g=c.getContext('2d');
const label=count>99?'99+':String(count); const label=count>99?'99+':String(count);
const cx=s/2, cy=s/2, r=s/2-4; const pad=3, rad=17, x=pad, y=pad, w=s-pad*2, h=s-pad*2;
g.save(); g.shadowColor='rgba(0,0,0,.35)'; g.shadowBlur=5; g.shadowOffsetY=2; const rrect=(rx,ry,rw,rh,r)=>{ g.beginPath(); g.moveTo(rx+r,ry); g.arcTo(rx+rw,ry,rx+rw,ry+rh,r); g.arcTo(rx+rw,ry+rh,rx,ry+rh,r); g.arcTo(rx,ry+rh,rx,ry,r); g.arcTo(rx,ry,rx+rw,ry,r); g.closePath(); };
const grad=g.createLinearGradient(0,cy-r,0,cy+r); grad.addColorStop(0,'#ff5b6e'); grad.addColorStop(1,'#e11d48'); g.save(); g.shadowColor='rgba(0,0,0,.28)'; g.shadowBlur=4; g.shadowOffsetY=1.5;
g.fillStyle=grad; g.beginPath(); g.arc(cx,cy,r,0,2*Math.PI); g.fill(); g.restore(); g.fillStyle='#e6394f'; rrect(x,y,w,h,rad); g.fill(); g.restore(); // solid notification red
g.lineWidth=3.5; g.strokeStyle='#fff'; g.beginPath(); g.arc(cx,cy,r,0,2*Math.PI); g.stroke(); // white ring pops on any taskbar g.lineWidth=3; g.strokeStyle='#fff'; rrect(x,y,w,h,rad); g.stroke(); // crisp white outline
g.fillStyle='#fff'; g.textAlign='center'; g.textBaseline='middle'; g.fillStyle='#fff'; g.textAlign='center'; g.textBaseline='middle';
g.font='800 '+(label.length>2?26:(label.length>1?32:38))+'px system-ui,Segoe UI,sans-serif'; g.font='800 '+(label.length>2?26:(label.length>1?33:39))+'px system-ui,Segoe UI,sans-serif';
g.fillText(label, cx, cy+2); g.fillText(label, s/2, s/2+2);
dataUrl=c.toDataURL('image/png'); dataUrl=c.toDataURL('image/png');
} }
window.bizConnectNative.setUnread(count, dataUrl); window.bizConnectNative.setUnread(count, dataUrl);
@@ -1481,7 +1485,7 @@ function convoShellHTML(it){
+ '<div class="composer-row">' + '<div class="composer-row">'
+ '<button type="button" class="ic-btn" id="attachBtn" title="Attach a file">'+ic('paperclip',20)+'</button>' + '<button type="button" class="ic-btn" id="attachBtn" title="Attach a file">'+ic('paperclip',20)+'</button>'
+ '<button type="button" class="ic-btn" id="fmtBtn" title="Formatting">'+ic('type',20)+'</button>' + '<button type="button" class="ic-btn" id="fmtBtn" title="Formatting">'+ic('type',20)+'</button>'
+ '<textarea id="msgInput" placeholder="Type a message…" autocomplete="off" maxlength="4000" rows="1"></textarea>' + '<textarea id="msgInput" placeholder="Type a message…" autocomplete="off" spellcheck="true" autocapitalize="sentences" maxlength="4000" rows="1"></textarea>'
+ (isG?'<button type="button" class="ic-btn" id="pollBtn" title="Create a poll">'+ic('barChart',20)+'</button>':'') + (isG?'<button type="button" class="ic-btn" id="pollBtn" title="Create a poll">'+ic('barChart',20)+'</button>':'')
+ '<button type="button" class="ic-btn" id="emojiBtn" title="Emoji">'+ic('smile',20)+'</button>' + '<button type="button" class="ic-btn" id="emojiBtn" title="Emoji">'+ic('smile',20)+'</button>'
+ '<button type="submit" class="sendbtn" title="Send" aria-label="Send">'+ic('send',18)+'</button>' + '<button type="submit" class="sendbtn" title="Send" aria-label="Send">'+ic('send',18)+'</button>'
@@ -1624,7 +1628,13 @@ function renderAttachBar(){
function hideAttach(){ pendingAttachs=[]; const bar=document.getElementById('attachBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; } const fi=document.getElementById('fileInput'); if(fi) fi.value=''; } function hideAttach(){ pendingAttachs=[]; const bar=document.getElementById('attachBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; } const fi=document.getElementById('fileInput'); if(fi) fi.value=''; }
// Paste an image from the clipboard (e.g. a screenshot) straight into the composer. // Paste an image from the clipboard (e.g. a screenshot) straight into the composer.
function onPaste(e){ function onPaste(e){
const items=(e.clipboardData&&e.clipboardData.items)||[]; const cd=e.clipboardData; if(!cd) return;
// Excel / Google Sheets put BOTH a bitmap AND the cell text on the clipboard. If there's real text,
// let the textarea paste it normally (don't upload the screenshot). Only upload the image when the
// clipboard is image-ONLY (a genuine screenshot / copied picture with no accompanying text).
const plain=(cd.getData&&cd.getData('text/plain'))||'';
if(plain&&plain.trim()) return; // → default paste of the text
const items=cd.items||[];
for(const it of items){ for(const it of items){
if(it.type&&it.type.indexOf('image')===0){ if(it.type&&it.type.indexOf('image')===0){
const blob=it.getAsFile(); const blob=it.getAsFile();
@@ -1719,12 +1729,15 @@ function showSeenByModal(msg){
const chip=(name,avatar)=>'<span class="mini-av" style="background:'+avColor(name)+'">'+(avatar?('<img class="av-img" src="'+pEsc(avatar)+'" onerror="this.remove()">'):'')+pEsc(initials(name))+'</span>'; const chip=(name,avatar)=>'<span class="mini-av" style="background:'+avColor(name)+'">'+(avatar?('<img class="av-img" src="'+pEsc(avatar)+'" onerror="this.remove()">'):'')+pEsc(initials(name))+'</span>';
const rowsFromNames=(arr)=>arr.map(n=>'<div class="seen-row">'+chip(n, avByName[String(n).trim().toLowerCase()]||null)+'<span class="sn">'+pEsc(n)+'</span></div>').join(''); const rowsFromNames=(arr)=>arr.map(n=>'<div class="seen-row">'+chip(n, avByName[String(n).trim().toLowerCase()]||null)+'<span class="sn">'+pEsc(n)+'</span></div>').join('');
const rowsFromMembers=(arr)=>arr.map(m=>'<div class="seen-row">'+chip(m.name, m.avatar||null)+'<span class="sn">'+pEsc(m.name)+'</span></div>').join(''); const rowsFromMembers=(arr)=>arr.map(m=>'<div class="seen-row">'+chip(m.name, m.avatar||null)+'<span class="sn">'+pEsc(m.name)+'</span></div>').join('');
let body=''; let inner='';
if(seenNames.length) body+='<div class="seen-sec"><span class="dot ok"></span>Seen · '+seenNames.length+'</div><div class="seen-list">'+rowsFromNames(seenNames)+'</div>'; if(seenNames.length) inner+='<div class="seen-sec"><span class="dot ok"></span>Seen · '+seenNames.length+'</div>'+rowsFromNames(seenNames);
else body+='<div class="gi-noresult" style="padding:.6rem 0">No one has seen this yet.</div>'; else inner+='<div class="gi-noresult" style="padding:.6rem .2rem">No one has seen this yet.</div>';
if(convoIsGroup && notSeen.length) body+='<div class="seen-sec dim"><span class="dot"></span>Not seen yet · '+notSeen.length+'</div><div class="seen-list">'+rowsFromMembers(notSeen)+'</div>'; if(convoIsGroup && notSeen.length) inner+='<div class="seen-sec dim"><span class="dot"></span>Not seen yet · '+notSeen.length+'</div>'+rowsFromMembers(notSeen);
// ONE scroll container for the whole list (section headers stick as you scroll) — not a scrollbar
// per section, which split the popup confusingly.
const body='<div class="seen-scroll">'+inner+'</div>';
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='seenModal'; const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='seenModal';
ov.innerHTML='<div class="modal seen-modal" style="max-width:340px"><h3>'+ic('checkCheck',16)+' Message info</h3>'+body+'<div class="bzc-actions" style="margin-top:.9rem"><button type="button" class="bzc-ok" id="seenClose">Close</button></div></div>'; ov.innerHTML='<div class="modal seen-modal"><h3>'+ic('checkCheck',16)+' Message info</h3>'+body+'<div class="bzc-actions"><button type="button" class="bzc-ok" id="seenClose">Close</button></div></div>';
document.body.appendChild(ov); document.body.appendChild(ov);
ov.addEventListener('mousedown',e=>{ if(e.target===ov) ov.remove(); }); ov.addEventListener('mousedown',e=>{ if(e.target===ov) ov.remove(); });
ov.querySelector('#seenClose').onclick=()=>ov.remove(); ov.querySelector('#seenClose').onclick=()=>ov.remove();
@@ -2546,7 +2559,14 @@ function onChatMessage(m){
// Popup rule: ping always. In-page popup only when the tab is VISIBLE but you're on another // Popup rule: ping always. In-page popup only when the tab is VISIBLE but you're on another
// chat. When the tab is HIDDEN, let Web Push show it (the SW). If push isn't active, fall // chat. When the tab is HIDDEN, let Web Push show it (the SW). If push isn't active, fall
// back to an in-page popup so hidden-tab users still get alerted. // back to an in-page popup so hidden-tab users still get alerted.
if(notifOn(kind)){ playPing(); const wantPopup=!(isOpen && !document.hidden); if(wantPopup && !(document.hidden && pushActive)) notify((m.fromName||'New message'), m.body?(m.body.length>80?m.body.slice(0,80)+'…':m.body):'Sent an attachment', kind, rid); } if(notifOn(kind)){ playPing(); const wantPopup=!(isOpen && !document.hidden);
if(wantPopup && !(document.hidden && pushActive)){
const prev=m.body?(m.body.length>80?m.body.slice(0,80)+'…':m.body):'Sent an attachment';
// Group: title = GROUP name, body = "Sender: message" (so you know which group it's from).
if(kind==='group') notify((it&&it.name)||m.groupName||'Group', (m.fromName?m.fromName+': ':'')+prev, kind, rid);
else notify((m.fromName||'New message'), prev, kind, rid);
}
}
// Activity-center entries for things easy to miss. // Activity-center entries for things easy to miss.
if(m.poll) addNotif({icon:'barChart', text:pEsc(m.fromName||'Someone')+' created a poll'+(m.poll.question?': '+pEsc(m.poll.question):''), link:{kind, id:rid}}); if(m.poll) addNotif({icon:'barChart', text:pEsc(m.fromName||'Someone')+' created a poll'+(m.poll.question?': '+pEsc(m.poll.question):''), link:{kind, id:rid}});
else if(kind==='dm' && wasNew) addNotif({icon:'chat', text:'New chat from '+pEsc(m.fromName||'someone'), link:{kind:'dm', id:rid}}); else if(kind==='dm' && wasNew) addNotif({icon:'chat', text:'New chat from '+pEsc(m.fromName||'someone'), link:{kind:'dm', id:rid}});