From a1887064eb26dcfee575ee252e888915cbe44652 Mon Sep 17 00:00:00 2001 From: sravan Date: Thu, 2 Jul 2026 18:01:32 +0530 Subject: [PATCH] feat(chat/notif): single hover action-pill; sender DP in desktop+mobile notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Message actions (reply/react/edit/delete) consolidated into ONE hover pill anchored to the bubble's top-right, overlapping it so on short messages the icons no longer float off into empty space and vanish before you can click. - Desktop toast + mobile(FCM)/web-background push now show the sender's real DP: * renderer passes the DP URL through; desktop shell downloads it for the toast icon (canvas-drawing an external DP tainted it → initials). Desktop bumped to 0.1.4. * DM push payload carries icon=sender avatar; sw.js already uses it (web background), sendFcm sets notification.image (Android). Co-Authored-By: Claude Opus 4.8 --- desktop/main.js | 41 +++++++++++++++++++++++++++++------------ desktop/package.json | 2 +- server/public/home.html | 37 ++++++++++++++++++++----------------- server/push.js | 4 +++- server/routes.js | 2 +- 5 files changed, 54 insertions(+), 32 deletions(-) diff --git a/desktop/main.js b/desktop/main.js index 30154cc..49b572c 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -39,20 +39,36 @@ try { ({ autoUpdater } = require('electron-updater')); } catch (_) { /* not inst // Uses Electron's OWN Notification (native, no SnoreToast) whose 'click' event fires reliably. const APP_ID = 'com.bizgaze.connect.desktop'; -// Write the avatar the renderer drew (a data: URL) to a temp PNG for the toast icon. -function writeTempPng(dataUrl) { - try { - if (!dataUrl || !/^data:image\/png;base64,/.test(dataUrl)) return null; - const p = path.join(app.getPath('temp'), 'bizc-toast-' + crypto.randomBytes(4).toString('hex') + '.png'); - fs.writeFileSync(p, Buffer.from(dataUrl.split(',')[1], 'base64')); - return p; - } catch (_) { return null; } +// Resolve the sender/group avatar to a local temp PNG for the toast icon. Accepts either a data: +// 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'); } +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); } + 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))); + }); + req.on('error', () => resolve(null)); + req.setTimeout(2500, () => { try { req.destroy(); } catch (_) {} resolve(null); }); + return; + } + resolve(null); + } catch (_) { resolve(null); } + }); } // Resolves {open} when the toast is clicked (renderer then opens that chat), else null. -ipcMain.handle('reply-notification', (_e, payload = {}) => new Promise((resolve) => { - if (!Notification.isSupported()) return resolve(null); - const img = writeTempPng(payload.avatar); +ipcMain.handle('reply-notification', async (_e, payload = {}) => { + if (!Notification.isSupported()) return null; + const img = await avatarToTempPng(payload.avatar); + return await new Promise((resolve) => { let done = false; const finish = (v) => { if (!done) { done = true; if (img) { try { fs.unlinkSync(img); } catch (_) {} } resolve(v); } }; try { @@ -70,7 +86,8 @@ ipcMain.handle('reply-notification', (_e, payload = {}) => new Promise((resolve) n.show(); setTimeout(() => finish(null), 30000); // don't leave the promise pending forever } catch (_) { finish(null); } -})); + }); +}); // Windows attributes notifications to the AppUserModelID. Without setting it, toasts read // "electron.app."; setting it to the installer's appId makes Windows resolve the diff --git a/desktop/package.json b/desktop/package.json index d38af5d..a44c8b6 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "biz-connect-desktop", - "version": "0.1.3", + "version": "0.1.4", "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 d065eee..04dd9ae 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -430,13 +430,19 @@ .convo{position:relative;} .bubble{position:relative;} .bubble .quote{border-left:3px solid var(--line);padding:.22rem .5rem;margin-bottom:.3rem;font-size:.78rem;border-radius:6px;color:#33384a;} - .reply-btn{position:absolute;top:-9px;right:6px;background:var(--card);color:var(--blue);border:1px solid var(--line);border-radius:50%;width:22px;height:22px;font-size:.8rem;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transition:opacity .12s;box-shadow:0 1px 3px rgba(0,0,0,.12);} - .bubble:hover .reply-btn,.bubble.show-actions .reply-btn{opacity:1;pointer-events:auto;} + /* All message actions live in ONE hover pill anchored to the bubble's top-right corner. It + overlaps the bubble slightly so there's no dead gap — on a short message the icons can't float + off into empty space where the hover (and the buttons) would vanish before you can click. */ + .msg-actions{position:absolute;top:-14px;right:6px;z-index:6;display:flex;gap:1px;background:var(--card);border:1px solid var(--line);border-radius:999px;padding:2px;box-shadow:0 2px 8px rgba(20,30,60,.18);opacity:0;pointer-events:none;transition:opacity .12s;} + .bubble:hover .msg-actions,.bubble.show-actions .msg-actions{opacity:1;pointer-events:auto;} + .msg-actions button{position:static;width:26px;height:26px;border:none;background:none;border-radius:50%;display:grid;place-items:center;cursor:pointer;color:var(--blue);opacity:1;pointer-events:auto;box-shadow:none;padding:0;transition:background .12s;} + .msg-actions button:hover{background:var(--blue-soft);} + .msg-actions .del-btn{color:var(--red);} .msg-actions .del-btn:hover{background:#fee2e2;} .reply-bar{display:flex;align-items:center;gap:.5rem;padding:.45rem .8rem;border-top:1px solid var(--line);background:#eef3fb;font-size:.82rem;color:var(--muted);} .reply-bar b{color:var(--ink);} .reply-bar .rx{margin-left:auto;cursor:pointer;font-size:1rem;} .reply-bar .rx:hover{color:var(--red);} - .reply-btn,.react-btn{display:grid;place-items:center;} + /* (message action button styling is under .msg-actions) */ .emoji-pop{position:absolute;bottom:64px;left:12px;width:330px;height:300px;background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:0 10px 28px rgba(0,0,0,.18);z-index:50;display:flex;flex-direction:column;overflow:hidden;} .mention-pop{position:absolute;left:12px;right:12px;bottom:64px;max-height:240px;overflow:auto;background:var(--card);border:1px solid var(--line);border-radius:12px;box-shadow:0 10px 28px rgba(0,0,0,.18);z-index:60;padding:.3rem;} .mention-pop .mrow{display:flex;align-items:center;gap:.55rem;padding:.4rem .55rem;border-radius:8px;cursor:pointer;} @@ -467,12 +473,6 @@ .emoji-grid{flex:1;overflow-y:auto;display:grid;grid-template-columns:repeat(8,1fr);gap:.1rem;padding:.4rem;align-content:start;} .emoji-grid button{border:none;background:transparent;font-size:1.25rem;cursor:pointer;padding:.2rem;border-radius:6px;line-height:1.15;} .emoji-grid button:hover{background:var(--blue-soft);} - .react-btn{position:absolute;top:-9px;right:32px;background:var(--card);color:var(--blue);border:1px solid var(--line);border-radius:50%;width:22px;height:22px;font-size:.8rem;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transition:opacity .12s;box-shadow:0 1px 3px rgba(0,0,0,.12);} - .bubble:hover .react-btn,.bubble.show-actions .react-btn{opacity:1;pointer-events:auto;} - .del-btn{position:absolute;top:-9px;right:58px;background:var(--card);color:var(--red);border:1px solid var(--line);border-radius:50%;width:22px;height:22px;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transition:opacity .12s;box-shadow:0 1px 3px rgba(0,0,0,.12);display:grid;place-items:center;} - .bubble:hover .del-btn,.bubble.show-actions .del-btn{opacity:1;pointer-events:auto;} - .edit-btn{position:absolute;top:-9px;right:84px;background:var(--card);color:var(--blue);border:1px solid var(--line);border-radius:50%;width:22px;height:22px;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transition:opacity .12s;box-shadow:0 1px 3px rgba(0,0,0,.12);display:grid;place-items:center;} - .bubble:hover .edit-btn,.bubble.show-actions .edit-btn{opacity:1;pointer-events:auto;} .bubble .t .edited{font-style:italic;opacity:.8;} .bubble.deleted{opacity:.85;} .bubble.deleted .del-msg{font-style:italic;color:var(--muted);font-size:.9rem;display:inline-flex;align-items:center;gap:.3rem;} @@ -767,7 +767,7 @@ - @@ -1291,10 +1291,12 @@ function bubbleHTML(m){ if(mine && convoIsGroup && m.id===_lastMineId && Array.isArray(m.seenBy) && m.seenBy.length){ const ns=m.seenBy, head=ns.slice(0,3).join(', '), more=ns.length>3?(' +'+(ns.length-3)+' more'):''; seen=''; } return '
' + sender + quote + att + renderMsgBody(m) + pollHTML(m) - + '' - + '' - + ((mine && !m.deleted && m.body && !m.poll)?'':'') - + (mine?'':'') + + (m.deleted?'':'
' + + '' + + '' + + ((mine && m.body && !m.poll)?'':'') + + (mine?'':'') + + '
') + ''+pEsc(fmtClock(m.created_at))+(m.edited_at?' · edited':'')+rcpt+'' + reacts + seen + '
'; } @@ -2046,10 +2048,11 @@ function notifAvatarDataUrl(kind,id,fallbackName){ async function sendReplyTo(kind,id,text){ try{ await postJSON('/api/messages', kind==='group'?{group:id,body:text}:{to:id,body:text}); }catch(_){} } function notify(title, body, kind, id){ try{ - // Desktop app (Phase D): native Windows toast with an inline reply box + sender/group avatar. + // Desktop app: native toast with the sender/group DP. Pass the DP URL directly — the shell + // downloads it for the icon (drawing an external DP to a canvas here tainted it → initials). if(window.bizConnectNative && window.bizConnectNative.replyNotify){ - notifAvatarDataUrl(kind, id, title) - .then(avatar=>window.bizConnectNative.replyNotify({title, body, kind, id, avatar})) + const _av=(rowFor(kind,id)||{}).avatar||null; + Promise.resolve(window.bizConnectNative.replyNotify({title, body, kind, id, avatar:_av})) .then(r=>{ if(!r) return; if(r.text) sendReplyTo(kind, id, r.text); // replied from the toast else if(r.open) openFromNotif(kind, id); // clicked the toast diff --git a/server/push.js b/server/push.js index a27bf5d..2e695b4 100644 --- a/server/push.js +++ b/server/push.js @@ -53,7 +53,9 @@ async function fcmAccessToken() { } async function sendFcm(token, payload) { const at = await fcmAccessToken(); - const msg = { message: { token, notification: { title: payload.title || 'Biz Connect', body: payload.body || '' }, data: strData(payload.data || {}) } }; + const notif = { title: payload.title || 'Biz Connect', body: payload.body || '' }; + if (payload.icon) notif.image = payload.icon; // sender/group DP shown by Android + const msg = { message: { token, notification: notif, data: strData(payload.data || {}) } }; const res = await fetch('https://fcm.googleapis.com/v1/projects/' + fcmSA.project_id + '/messages:send', { method: 'POST', headers: { Authorization: 'Bearer ' + at, 'Content-Type': 'application/json' }, body: JSON.stringify(msg), }); diff --git a/server/routes.js b/server/routes.js index 133536b..59affcb 100644 --- a/server/routes.js +++ b/server/routes.js @@ -1263,7 +1263,7 @@ route('POST', '/api/messages', async (req, res) => { try { CHAT.pushToUser(to, push); } catch (_) {} if (to !== u.id) try { CHAT.pushToUser(u.id, push); } catch (_) {} // sync the sender's other devices (skip for self-notes) // Background/closed-tab push to the recipient (opens the DM). Not for a note-to-self. - if (to !== u.id) PUSH.sendToUser(to, { title: (u.name || u.email), body: (text ? (text.length > 80 ? text.slice(0, 80) + '…' : text) : '📎 Attachment'), kind: 'dm', id: u.id, tag: 'dm:' + u.id }); + if (to !== u.id) PUSH.sendToUser(to, { title: (u.name || u.email), body: (text ? (text.length > 80 ? text.slice(0, 80) + '…' : text) : '📎 Attachment'), kind: 'dm', id: u.id, tag: 'dm:' + u.id, icon: u.avatar_url || undefined }); json(res, 200, dto); });