feat(desktop): Phase D — inline-reply notifications (Windows toast reply box)

Reply to a chat straight from the OS notification, no app switch:
- node-notifier (bundles SnoreToast) renders a native Windows toast with a reply box —
  Electron's own Notification can't do Windows inline reply.
- main.js reply-notification handler resolves {text}|{open}|null; preload exposes replyNotify.
- home.html notify() routes chat toasts through it on desktop: a typed reply -> sendReplyTo()
  POSTs to /api/messages without opening the app; a click opens the chat. Web/PWA path unchanged.
- Works only in the installed app (needs the installer's AppUserModelID). desktop 0.1.2, build batch21.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 22:36:02 +05:30
parent 160a66f934
commit 21ae3e1aa5
6 changed files with 133 additions and 13 deletions
+6 -4
View File
@@ -78,10 +78,12 @@ absolute API base if offline-launch or store policy requires it.
### Phase D — Inline-reply notifications (Teams-style) *(right AFTER packaging)* ### Phase D — Inline-reply notifications (Teams-style) *(right AFTER packaging)*
Reply to a chat directly from the OS notification, without opening the app. Cross-platform: Reply to a chat directly from the OS notification, without opening the app. Cross-platform:
- **Desktop (Windows):** native **Windows Toast** with an `<input>` reply box + action, a - [x] **Desktop (Windows):** native Windows toast with a **reply box** via **node-notifier**
registered **AppUserModelID** + Start-menu shortcut (why it needs the installer first), and (bundles SnoreToast). main.js `reply-notification` handler → preload `replyNotify`
a toast-activation handler that POSTs the reply to `/api/messages`. (Web `Notification` and home.html `notify()` routes chat toasts through it: a typed reply calls `sendReplyTo`
Electron's built-in notification can't do Windows text reply; needs a native toast lib.) (POST /api/messages) without opening the app; clicking opens the chat. Uses the installer's
AppUserModelID, so it only works in the **installed** app. *Needs a live test in the
installed app (can't drive a real Windows toast from CI).*
- **Android:** notification action with **`RemoteInput`** (direct reply) on the FCM message. - **Android:** notification action with **`RemoteInput`** (direct reply) on the FCM message.
- **iOS:** **`UNTextInputNotificationAction`** on the APNs notification category. - **iOS:** **`UNTextInputNotificationAction`** on the APNs notification category.
All three hand the typed text to the same send path. Depends on Phase B push + Phase C packaging. All three hand the typed text to the same send path. Depends on Phase B push + Phase C packaging.
+27
View File
@@ -35,6 +35,33 @@ ipcMain.on('get-install-info', (e) => {
// on the next restart. No-op in dev (unpackaged). // on the next restart. No-op in dev (unpackaged).
let autoUpdater = null; let autoUpdater = null;
try { ({ autoUpdater } = require('electron-updater')); } catch (_) { /* not installed in dev */ } 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 */ }
// 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); } };
try {
notifier.notify({
appID: 'com.bizgaze.connect.desktop',
title: payload.title || 'Biz Connect',
message: payload.body || '',
reply: true, wait: true, timeout: 20,
}, (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 });
else finish(null);
});
} catch (_) { finish(null); }
}));
// Windows attributes notifications to the AppUserModelID. Without setting it, toasts read // 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 // "electron.app.<name>"; setting it to the installer's appId makes Windows resolve the
+79 -5
View File
@@ -1,14 +1,15 @@
{ {
"name": "biz-connect-desktop", "name": "biz-connect-desktop",
"version": "0.1.0", "version": "0.1.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "biz-connect-desktop", "name": "biz-connect-desktop",
"version": "0.1.0", "version": "0.1.1",
"dependencies": { "dependencies": {
"electron-updater": "^6.3.9" "electron-updater": "^6.3.9",
"node-notifier": "^10.0.1"
}, },
"devDependencies": { "devDependencies": {
"electron": "^31.0.0", "electron": "^31.0.0",
@@ -2555,6 +2556,12 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/growly": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz",
"integrity": "sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==",
"license": "MIT"
},
"node_modules/has-flag": { "node_modules/has-flag": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -2768,6 +2775,21 @@
"is-ci": "bin.js" "is-ci": "bin.js"
} }
}, },
"node_modules/is-docker": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
"license": "MIT",
"bin": {
"is-docker": "cli.js"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-fullwidth-code-point": { "node_modules/is-fullwidth-code-point": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@@ -2778,6 +2800,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"license": "MIT",
"dependencies": {
"is-docker": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/isarray": { "node_modules/isarray": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
@@ -2803,7 +2837,6 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/jackspeak": { "node_modules/jackspeak": {
@@ -3213,6 +3246,32 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/node-notifier": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-10.0.1.tgz",
"integrity": "sha512-YX7TSyDukOZ0g+gmzjB6abKu+hTGvO8+8+gIFDsRCU2t8fLV/P2unmt+LGFaIa4y64aX98Qksa97rgz4vMNeLQ==",
"license": "MIT",
"dependencies": {
"growly": "^1.3.0",
"is-wsl": "^2.2.0",
"semver": "^7.3.5",
"shellwords": "^0.1.1",
"uuid": "^8.3.2",
"which": "^2.0.2"
}
},
"node_modules/node-notifier/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/normalize-path": { "node_modules/normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -3624,6 +3683,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/shellwords": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz",
"integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==",
"license": "MIT"
},
"node_modules/signal-exit": { "node_modules/signal-exit": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -4017,6 +4082,16 @@
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/verror": { "node_modules/verror": {
"version": "1.10.1", "version": "1.10.1",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz",
@@ -4037,7 +4112,6 @@
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"isexe": "^2.0.0" "isexe": "^2.0.0"
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "biz-connect-desktop", "name": "biz-connect-desktop",
"version": "0.1.1", "version": "0.1.2",
"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": { "name": "BizGaze", "email": "support@bizgaze.com" }, "author": { "name": "BizGaze", "email": "support@bizgaze.com" },
"main": "main.js", "main": "main.js",
@@ -9,7 +9,8 @@
"dist": "electron-builder" "dist": "electron-builder"
}, },
"dependencies": { "dependencies": {
"electron-updater": "^6.3.9" "electron-updater": "^6.3.9",
"node-notifier": "^10.0.1"
}, },
"devDependencies": { "devDependencies": {
"electron": "^31.0.0", "electron": "^31.0.0",
+3
View File
@@ -19,4 +19,7 @@ contextBridge.exposeInMainWorld('bizConnectNative', Object.freeze({
// Show the unread-chat count as a badge on the taskbar icon. count=number of chats with // Show the unread-chat count as a badge on the taskbar icon. count=number of chats with
// unread; dataUrl=a small PNG badge the renderer drew (null to clear). // unread; dataUrl=a small PNG badge the renderer drew (null to clear).
setUnread: (count, dataUrl) => ipcRenderer.send('set-unread', { count, dataUrl }), setUnread: (count, dataUrl) => ipcRenderer.send('set-unread', { count, dataUrl }),
// Native Windows toast with an inline reply box. Resolves to {text} (replied), {open} (clicked)
// or null. Lets the user reply to a chat straight from the notification.
replyNotify: (payload) => ipcRenderer.invoke('reply-notification', payload),
})); }));
+15 -2
View File
@@ -731,7 +731,7 @@
</head> </head>
<body> <body>
<script src="/icons.js?v=4"></script> <script src="/icons.js?v=4"></script>
<script>window.__BUILD='2026-07-01-batch20';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);</script> <script>window.__BUILD='2026-07-01-batch21';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);</script>
<div class="loading" id="loading">Loading…</div> <div class="loading" id="loading">Loading…</div>
<header> <header>
@@ -1894,11 +1894,24 @@ async function unsubscribePush(){
// Open the chat from an in-page notification. Navigation reliably repaints across browsers (a // Open the chat from an in-page notification. Navigation reliably repaints across browsers (a
// notification click is not an in-page gesture, so an in-place open won't paint until you // notification click is not an in-page gesture, so an in-place open won't paint until you
// tap). The reload is made fast by HTTP caching + a boot fast-path that opens the chat first. // tap). The reload is made fast by HTTP caching + a boot fast-path that opens the chat first.
function openFromNotif(kind,id){ try{ if(window.bizConnectNative&&window.bizConnectNative.focusApp) window.bizConnectNative.focusApp(); }catch(_){} try{ window.focus(); }catch(_){} location.assign('/home?openKind='+encodeURIComponent(kind||'')+'&openId='+encodeURIComponent(id||'')); }
// Reply to a conversation without opening it (used by the notification quick-reply).
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){ function notify(title, body, kind, id){
try{ try{
// Desktop app (Phase D): native Windows toast with an inline reply box.
if(window.bizConnectNative && window.bizConnectNative.replyNotify){
window.bizConnectNative.replyNotify({title, body, kind, id}).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
}).catch(()=>{});
return;
}
// Web / PWA: standard Notification.
if(!('Notification' in window) || Notification.permission!=='granted') return; if(!('Notification' in window) || Notification.permission!=='granted') return;
const n=new Notification(title, { body, icon:'/logo.png' }); const n=new Notification(title, { body, icon:'/logo.png' });
n.onclick=()=>{ try{ window.focus(); }catch(_){} try{ if(window.bizConnectNative&&window.bizConnectNative.focusApp) window.bizConnectNative.focusApp(); }catch(_){} const u='/home?openKind='+encodeURIComponent(kind||'')+'&openId='+encodeURIComponent(id||''); n.close(); location.assign(u); }; n.onclick=()=>{ n.close(); openFromNotif(kind, id); };
setTimeout(()=>{ try{ n.close(); }catch(_){} }, 8000); setTimeout(()=>{ try{ n.close(); }catch(_){} }, 8000);
}catch(_){} }catch(_){}
} }