diff --git a/desktop/main.js b/desktop/main.js index 52bf88a..9ed7696 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -198,6 +198,11 @@ function createWindow() { nodeIntegration: false, // Persist cookies/localStorage so the technician stays logged in between launches. partition: 'persist:bizconnect', + // Keep the renderer at full speed when the window is in the BACKGROUND/minimized. Electron + // throttles hidden windows by default, which stalled the chat WebSocket's onmessage + timers — + // so incoming messages and their notifications only landed when you refocused the app (the + // "notifications slow / messages don't update live" report). Off = real-time even in the tray. + backgroundThrottling: false, }, }); diff --git a/desktop/package.json b/desktop/package.json index 0bf034a..398d190 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "biz-connect-desktop", - "version": "0.1.9", + "version": "0.1.10", "description": "Biz Connect technician desktop client — loads the Connect web UI with native screen capture", "author": { "name": "BizGaze", diff --git a/server/public/home.html b/server/public/home.html index e060d69..15a2ccb 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -553,10 +553,16 @@ .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;} + .seen-modal .seen-list{max-height:38vh;overflow-y:auto;display:flex;flex-direction:column;gap:.15rem;} + .seen-modal .seen-row{display:flex;align-items:center;gap:.6rem;padding:.32rem .3rem;} + /* 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 .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-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.dim{color:var(--muted);} + .seen-modal .seen-sec .dot{width:7px;height:7px;border-radius:50%;background:#cbd5e1;} + .seen-modal .seen-sec .dot.ok{background:#22c55e;} .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);} @@ -840,7 +846,7 @@ - @@ -1372,11 +1378,18 @@ function setDesktopBadge(count){ if(!(window.bizConnectNative && window.bizConnectNative.setUnread)) return; let dataUrl=null; if(count>0){ - const s=32, c=document.createElement('canvas'); c.width=s; c.height=s; const g=c.getContext('2d'); - g.fillStyle='#ef4444'; g.beginPath(); g.arc(s/2,s/2,s/2,0,2*Math.PI); g.fill(); + // Draw at 2× for a crisp taskbar overlay. A vertical red→pink gradient pill with a white ring + // and a soft drop shadow reads as a modern notification badge (was a flat red disc). + 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 cx=s/2, cy=s/2, r=s/2-4; + g.save(); g.shadowColor='rgba(0,0,0,.35)'; g.shadowBlur=5; g.shadowOffsetY=2; + const grad=g.createLinearGradient(0,cy-r,0,cy+r); grad.addColorStop(0,'#ff5b6e'); grad.addColorStop(1,'#e11d48'); + g.fillStyle=grad; g.beginPath(); g.arc(cx,cy,r,0,2*Math.PI); g.fill(); g.restore(); + 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.fillStyle='#fff'; g.textAlign='center'; g.textBaseline='middle'; - const label=count>99?'99+':String(count); g.font='bold '+(label.length>2?15:20)+'px system-ui,Segoe UI,sans-serif'; - g.fillText(label, s/2, s/2+1); + g.font='800 '+(label.length>2?26:(label.length>1?32:38))+'px system-ui,Segoe UI,sans-serif'; + g.fillText(label, cx, cy+2); dataUrl=c.toDataURL('image/png'); } window.bizConnectNative.setUnread(count, dataUrl); @@ -1692,11 +1705,26 @@ function onChatRead(d){ if(!d||!d.by) return; // desktop). Returns a Promise. 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){ +function showSeenByModal(msg){ if(document.getElementById('seenModal')) return; + // Accept either a message (rich: DPs + "not seen yet") or a bare names[] (fallback). + const seenNames = Array.isArray(msg) ? msg : (Array.isArray(msg&&msg.seenBy) ? msg.seenBy.slice() : []); + const senderId = (msg && !Array.isArray(msg)) ? msg.from : null; + const members = (convoIsGroup && Array.isArray(convoMembers)) ? convoMembers : []; + // name → real DP (match member by name, since seenBy carries names). + const avByName={}; members.forEach(m=>{ avByName[(m.name||'').trim().toLowerCase()] = m.avatar||null; }); + // Who hasn't seen it yet = members minus the sender, minus me (I'm reading it), minus those who have. + const seenSet=new Set(seenNames.map(n=>String(n).trim().toLowerCase())); + const notSeen = members.filter(m=> m.id!==senderId && m.id!==(ME&&ME.id) && !seenSet.has((m.name||'').trim().toLowerCase())); + const chip=(name,avatar)=>''+(avatar?(''):'')+pEsc(initials(name))+''; + const rowsFromNames=(arr)=>arr.map(n=>'
'+chip(n, avByName[String(n).trim().toLowerCase()]||null)+''+pEsc(n)+'
').join(''); + const rowsFromMembers=(arr)=>arr.map(m=>'
'+chip(m.name, m.avatar||null)+''+pEsc(m.name)+'
').join(''); + let body=''; + if(seenNames.length) body+='
Seen · '+seenNames.length+'
'+rowsFromNames(seenNames)+'
'; + else body+='
No one has seen this yet.
'; + if(convoIsGroup && notSeen.length) body+='
Not seen yet · '+notSeen.length+'
'+rowsFromMembers(notSeen)+'
'; const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='seenModal'; - const body=(names&&names.length)?('
'+names.map(n=>'
'+pEsc(initials(n))+''+pEsc(n)+'
').join('')+'
'):'
No one has seen this yet.
'; - ov.innerHTML=''; + ov.innerHTML=''; document.body.appendChild(ov); ov.addEventListener('mousedown',e=>{ if(e.target===ov) ov.remove(); }); ov.querySelector('#seenClose').onclick=()=>ov.remove(); @@ -2013,7 +2041,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); showSeenByModal(ns); return; } + const sb=e.target.closest('.seenby,.rcpt.grp'); if(sb){ const bub=sb.closest('.bubble'); const mid=bub&&bub.dataset.id; const msg=(mid&&THREAD.find(x=>x.id===mid))||{ seenBy:(sb.dataset.seen||'').split('|').filter(Boolean) }; showSeenByModal(msg); 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. @@ -3270,6 +3298,11 @@ function updateTransBtn(){ const b=document.getElementById('meetTransBtn'); if(! function transcribeNotice(on){ let el=document.getElementById('txNotice'); if(on){ if(!el){ el=document.createElement('div'); el.id='txNotice'; el.className='tx-notice'; el.innerHTML=ic('fileText',12)+' Transcribing'; document.body.appendChild(el); } } else if(el){ el.remove(); } } async function enterMeeting(code, audioOnly){ if(meetState==='call'){ switchTab('meeting'); return; } // already in a call — ignore double-join + // Joining this room → clear any lingering incoming-call invite popup for it (and stop its ring). + // Fixes: joining via the header "Join" button left the Join/Decline popup on screen. Belt-and-braces + // we clear ALL open invites, since you can only be in one call at a time. + if(code) dismissCallInvite(code); + document.querySelectorAll('.call-invite').forEach(el=>{ try{ el.remove(); }catch(_){} }); stopRing(true); meetAudioOnly=!!audioOnly; // Start with NO media — mic & cam OFF by default (no permission prompt until the user // turns one on). Tracks are acquired on demand by toggleMic / toggleCam.