feat(desktop): real Windows inline reply via SnoreToast -tb + sender/group avatar
- node-notifier's WindowsToaster forwards raw opts to SnoreToast, so inject -tb (reply box) + -p (image). Reuses its named-pipe + result parsing (exit 5 = TextEntered). No pwsh needed. Logs the raw toast result to userData/toast-debug.log to confirm the reply field on real HW. - home.html: notifAvatarDataUrl draws the DM sender's pic / group's DP (else colored initials) to a round PNG and passes it as the toast image. Reply -> sendReplyTo; click -> open chat. - dropped powertoast (ESM + needs pwsh 7, absent here). build batch22. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+40
-19
@@ -35,29 +35,50 @@ ipcMain.on('get-install-info', (e) => {
|
||||
// on the next restart. No-op in dev (unpackaged).
|
||||
let autoUpdater = null;
|
||||
try { ({ autoUpdater } = require('electron-updater')); } catch (_) { /* not installed in dev */ }
|
||||
// node-notifier bundles SnoreToast, which renders a native Windows toast WITH a reply box
|
||||
// (Electron's own Notification can't do Windows inline reply). Only works in the installed app
|
||||
// (needs the AppUserModelID shortcut the NSIS installer registers).
|
||||
let notifier = null;
|
||||
try { notifier = require('node-notifier'); } catch (_) { /* optional */ }
|
||||
// Native Windows toast WITH a reply box, via node-notifier's bundled SnoreToast engine.
|
||||
// node-notifier's API doesn't expose the reply text box, but its WindowsToaster forwards raw
|
||||
// options to SnoreToast — so we inject `-tb` (text box) + `-p` (avatar image) and reuse its
|
||||
// named-pipe + result parsing (exit 5 = TextEntered). Installed-app only (needs the
|
||||
// AppUserModelID shortcut the NSIS installer registers). No PowerShell 7 required.
|
||||
let WindowsToaster = null;
|
||||
try { WindowsToaster = require('node-notifier').WindowsToaster; } catch (_) { /* optional */ }
|
||||
|
||||
// Show a chat notification with an inline reply box. Resolves with the typed reply, an "open"
|
||||
// intent (toast clicked), or null (dismissed/timeout). The renderer sends the reply / opens chat.
|
||||
ipcMain.handle('reply-notification', (_e, payload = {}) => new Promise((resolve) => {
|
||||
if (!notifier) return resolve(null);
|
||||
let done = false; const finish = (v) => { if (!done) { done = true; resolve(v); } };
|
||||
// Write the avatar the renderer drew (a data: URL) to a temp PNG for SnoreToast's -p image.
|
||||
function writeTempPng(dataUrl) {
|
||||
try {
|
||||
notifier.notify({
|
||||
appID: 'com.bizgaze.connect.desktop',
|
||||
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; }
|
||||
}
|
||||
|
||||
// Resolves with {text} (replied), {open} (toast clicked), or null (dismissed/timeout).
|
||||
ipcMain.handle('reply-notification', (_e, payload = {}) => new Promise((resolve) => {
|
||||
if (!WindowsToaster) return resolve(null);
|
||||
const img = writeTempPng(payload.avatar);
|
||||
let done = false;
|
||||
const finish = (v) => { if (!done) { done = true; if (img) { try { fs.unlinkSync(img); } catch (_) {} } resolve(v); } };
|
||||
try {
|
||||
const toaster = new WindowsToaster({ withFallback: false });
|
||||
toaster.notify({
|
||||
title: payload.title || 'Biz Connect',
|
||||
message: payload.body || '',
|
||||
reply: true, wait: true, timeout: 20,
|
||||
message: payload.body || ' ',
|
||||
appID: 'com.bizgaze.connect.desktop',
|
||||
tb: true, // -tb : reply text box
|
||||
w: true, // -w : wait for the user to reply / click before returning
|
||||
...(img ? { icon: img } : {}), // mapped to -p (image)
|
||||
}, (err, response, metadata) => {
|
||||
if (err) return finish(null);
|
||||
const text = (metadata && metadata.activationValue) ? String(metadata.activationValue).trim() : '';
|
||||
const resp = String(response || '').toLowerCase();
|
||||
if (text && resp !== 'activate') finish({ kind: payload.kind, id: payload.id, text });
|
||||
else if (resp === 'activate' || resp === 'clicked') finish({ kind: payload.kind, id: payload.id, open: true });
|
||||
// Log the raw result once so the exact reply field can be confirmed on a real machine.
|
||||
try { fs.appendFileSync(path.join(app.getPath('userData'), 'toast-debug.log'), JSON.stringify({ t: Date.now(), err: err && err.message, response, metadata }) + '\n'); } catch (_) {}
|
||||
const meta = metadata || {};
|
||||
const known = ['click', 'activate', 'activated', 'timeout', 'timedout', 'dismissed'];
|
||||
const respStr = String(response || '').trim();
|
||||
let text = String(meta.text || meta.value || meta.reply || '').trim();
|
||||
if (!text && respStr && !known.includes(respStr.toLowerCase())) text = respStr; // some builds return the reply as the response
|
||||
const act = String(response || meta.action || meta.activationType || '').toLowerCase();
|
||||
if (text) finish({ kind: payload.kind, id: payload.id, text });
|
||||
else if (act.includes('activat') || act === 'click') finish({ kind: payload.kind, id: payload.id, open: true });
|
||||
else finish(null);
|
||||
});
|
||||
} catch (_) { finish(null); }
|
||||
|
||||
Reference in New Issue
Block a user