diff --git a/CLIENTS.md b/CLIENTS.md
index 398f171..a38936d 100644
--- a/CLIENTS.md
+++ b/CLIENTS.md
@@ -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)*
Reply to a chat directly from the OS notification, without opening the app. Cross-platform:
-- **Desktop (Windows):** native **Windows Toast** with an `` reply box + action, a
- registered **AppUserModelID** + Start-menu shortcut (why it needs the installer first), and
- a toast-activation handler that POSTs the reply to `/api/messages`. (Web `Notification` and
- Electron's built-in notification can't do Windows text reply; needs a native toast lib.)
+- [x] **Desktop (Windows):** native Windows toast with a **reply box** via **node-notifier**
+ (bundles SnoreToast). main.js `reply-notification` handler → preload `replyNotify` →
+ home.html `notify()` routes chat toasts through it: a typed reply calls `sendReplyTo`
+ (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.
- **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.
diff --git a/desktop/main.js b/desktop/main.js
index 942f86e..100fb58 100644
--- a/desktop/main.js
+++ b/desktop/main.js
@@ -35,6 +35,33 @@ 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 */ }
+
+// 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
// "electron.app."; setting it to the installer's appId makes Windows resolve the
diff --git a/desktop/package-lock.json b/desktop/package-lock.json
index 0c1f118..8879ddb 100644
--- a/desktop/package-lock.json
+++ b/desktop/package-lock.json
@@ -1,14 +1,15 @@
{
"name": "biz-connect-desktop",
- "version": "0.1.0",
+ "version": "0.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "biz-connect-desktop",
- "version": "0.1.0",
+ "version": "0.1.1",
"dependencies": {
- "electron-updater": "^6.3.9"
+ "electron-updater": "^6.3.9",
+ "node-notifier": "^10.0.1"
},
"devDependencies": {
"electron": "^31.0.0",
@@ -2555,6 +2556,12 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"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": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -2768,6 +2775,21 @@
"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": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@@ -2778,6 +2800,18 @@
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
@@ -2803,7 +2837,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
- "dev": true,
"license": "ISC"
},
"node_modules/jackspeak": {
@@ -3213,6 +3246,32 @@
"license": "MIT",
"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": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -3624,6 +3683,12 @@
"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": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -4017,6 +4082,16 @@
"license": "MIT",
"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": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz",
@@ -4037,7 +4112,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
- "dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
diff --git a/desktop/package.json b/desktop/package.json
index 3a43767..2f97a68 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -1,6 +1,6 @@
{
"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",
"author": { "name": "BizGaze", "email": "support@bizgaze.com" },
"main": "main.js",
@@ -9,7 +9,8 @@
"dist": "electron-builder"
},
"dependencies": {
- "electron-updater": "^6.3.9"
+ "electron-updater": "^6.3.9",
+ "node-notifier": "^10.0.1"
},
"devDependencies": {
"electron": "^31.0.0",
diff --git a/desktop/preload.js b/desktop/preload.js
index 6d13f59..e6f250a 100644
--- a/desktop/preload.js
+++ b/desktop/preload.js
@@ -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
// unread; dataUrl=a small PNG badge the renderer drew (null to clear).
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),
}));
diff --git a/server/public/home.html b/server/public/home.html
index c7ac4ee..dca48bd 100644
--- a/server/public/home.html
+++ b/server/public/home.html
@@ -731,7 +731,7 @@
-
+
Loading…
@@ -1894,11 +1894,24 @@ async function unsubscribePush(){
// 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
// 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){
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;
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);
}catch(_){}
}