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:
+6
-4
@@ -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 `<input>` 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.
|
||||
|
||||
@@ -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.<name>"; setting it to the installer's appId makes Windows resolve the
|
||||
|
||||
Generated
+79
-5
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
+15
-2
@@ -731,7 +731,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
|
||||
<header>
|
||||
@@ -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(_){}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user