feat(chat/notif): single hover action-pill; sender DP in desktop+mobile notifications

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 18:01:32 +05:30
parent ab48642cf6
commit a1887064eb
5 changed files with 54 additions and 32 deletions
+29 -12
View File
@@ -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.<name>"; setting it to the installer's appId makes Windows resolve the