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(_){} }