From 9a6be8b6a3a8c39f275e74ce695eec1ab838420b Mon Sep 17 00:00:00 2001 From: ClickAndGoScript Date: Sun, 14 Jun 2026 10:32:38 +0300 Subject: [PATCH 1/2] feat: add Hebrew translation and move hardcoded strings to i18n - Add he (Hebrew) translation for all existing strings - Complete zh-CN translation (missing strings added) - Move hardcoded test notification text in library.js to [[web-push:test.*]] - Move hardcoded admin nav label in library.js to [[web-push:admin.menu-label]] - Replace hardcoded strings in ACP template with [[web-push:admin.*]] keys --- library.js | 6 ++--- public/languages/en-GB/web-push.json | 19 +++++++++++++-- public/languages/he/web-push.json | 26 +++++++++++++++++++++ public/languages/zh-CN/web-push.json | 19 +++++++++++++-- templates/admin/plugins/web-push.tpl | 35 +++++++++++----------------- 5 files changed, 76 insertions(+), 29 deletions(-) create mode 100644 public/languages/he/web-push.json diff --git a/library.js b/library.js index 89b54f2..647953f 100644 --- a/library.js +++ b/library.js @@ -104,8 +104,8 @@ plugin.addRoutes = async ({ router, middleware, helpers }) => { const { subscription } = req.body; const payload = await constructPayload({ nid: utils.generateUUID(), - bodyShort: 'Test notification', - bodyLong: 'This is a test message sent from NodeBB', + bodyShort: '[[web-push:test.title]]', + bodyLong: '[[web-push:test.body]]', path: `/me/web-push`, }, req.uid, userLang); await webPush.sendNotification(subscription, JSON.stringify(payload)); @@ -116,7 +116,7 @@ plugin.addAdminNavigation = (header) => { header.plugins.push({ route: '/plugins/web-push', icon: 'fa-tint', - name: 'Push Notifications (via Push API)', + name: '[[web-push:admin.menu-label]]', }); return header; diff --git a/public/languages/en-GB/web-push.json b/public/languages/en-GB/web-push.json index 2e8f3ab..30fb177 100644 --- a/public/languages/en-GB/web-push.json +++ b/public/languages/en-GB/web-push.json @@ -7,5 +7,20 @@ "profile.send-test": "Send Test Notification", "toast.test_success": "Test notification sent.", - "toast.test_unavailable": "Cannot send test notification as push notifications are not enabled on this device." -} \ No newline at end of file + "toast.test_unavailable": "Cannot send test notification as push notifications are not enabled on this device.", + + "test.title": "Test notification", + "test.body": "This is a test message sent from NodeBB", + + "admin.menu-label": "Push Notifications (via Push API)", + "admin.settings": "Settings", + "admin.max-length": "Maximum length", + "admin.max-length-help": "Additional characters beyond this specified length will be truncated. Due to a software limitation, if the message body is greater than 4096 bytes, the message itself will be an attachment in the push notification.", + "admin.badge": "Badge URL", + "admin.badge-help": "Optional — overrides the badge for messages sent (usually seen in the notification bar on mobile devices). By default, the site's configured \"touch icon\" is sent.", + "admin.icon": "Icon URL", + "admin.icon-help": "Optional — overrides the icon for messages sent (can be used for branding, etc.). By default, the site's configured \"touch icon\" is sent.", + "admin.users": "Users", + "admin.user": "User", + "admin.devices": "Devices" +} diff --git a/public/languages/he/web-push.json b/public/languages/he/web-push.json new file mode 100644 index 0000000..88d96a6 --- /dev/null +++ b/public/languages/he/web-push.json @@ -0,0 +1,26 @@ +{ + "profile.label": "התראות דחיפה", + "profile.introduction": "בנוסף להתראות בתוך האפליקציה ולהתראות בדוא״ל, ניתן לבחור לקבל גם התראות דחיפה. כך תוכלו לקבל התראות גם כשהאפליקציה אינה פתוחה במכשיר.", + "profile.option": "הפעלת התראות דחיפה במכשיר זה", + "profile.devices": "כרגע נשלחות התראות ל־ %1 מכשיר(ים).", + "profile.permissionBlocked": "המכשיר שלך אינו מאפשר כרגע לקבל התראות מאתר זה. יש לאשר את הרשאת ההתראות כדי להמשיך.", + "profile.send-test": "שליחת התראת בדיקה", + + "toast.test_success": "התראת הבדיקה נשלחה.", + "toast.test_unavailable": "לא ניתן לשלוח התראת בדיקה כי התראות דחיפה אינן מופעלות במכשיר זה.", + + "test.title": "התראת בדיקה", + "test.body": "זוהי הודעת בדיקה שנשלחה מ־NodeBB", + + "admin.menu-label": "התראות דחיפה (Push API)", + "admin.settings": "הגדרות", + "admin.max-length": "אורך מרבי", + "admin.max-length-help": "תווים מעבר לאורך שצוין ייחתכו. בשל מגבלת תוכנה, אם גוף ההודעה גדול מ־4096 בתים, ההודעה עצמה תישלח כקובץ מצורף בהתראת הדחיפה.", + "admin.badge": "כתובת תג (Badge)", + "admin.badge-help": "אופציונלי — מחליף את התג בהודעות הנשלחות (מוצג בדרך כלל בשורת ההתראות במכשירים ניידים). כברירת מחדל נשלח סמל ה־touch icon המוגדר באתר.", + "admin.icon": "כתובת סמל (Icon)", + "admin.icon-help": "אופציונלי — מחליף את הסמל בהודעות הנשלחות (שימושי למיתוג וכדומה). כברירת מחדל נשלח סמל ה־touch icon המוגדר באתר.", + "admin.users": "משתמשים", + "admin.user": "משתמש", + "admin.devices": "מכשירים" +} diff --git a/public/languages/zh-CN/web-push.json b/public/languages/zh-CN/web-push.json index ab3abcd..d5e044c 100644 --- a/public/languages/zh-CN/web-push.json +++ b/public/languages/zh-CN/web-push.json @@ -7,5 +7,20 @@ "profile.send-test": "发送测试通知", "toast.test_success": "测试通知已发送。", - "toast.test_unavailable": "由于此设备未启用推送通知,因此无法发送测试通知。" -} \ No newline at end of file + "toast.test_unavailable": "由于此设备未启用推送通知,因此无法发送测试通知。", + + "test.title": "测试通知", + "test.body": "这是一条来自 NodeBB 的测试消息", + + "admin.menu-label": "推送通知(Push API)", + "admin.settings": "设置", + "admin.max-length": "最大长度", + "admin.max-length-help": "超出指定长度的字符将被截断。由于软件限制,如果消息正文超过 4096 字节,消息本身将作为附件随推送通知发送。", + "admin.badge": "徽章 URL", + "admin.badge-help": "可选 — 覆盖所发送消息的徽章(通常显示在移动设备的通知栏中)。默认发送站点配置的"touch icon"。", + "admin.icon": "图标 URL", + "admin.icon-help": "可选 — 覆盖所发送消息的图标(可用于品牌宣传等)。默认发送站点配置的"touch icon"。", + "admin.users": "用户", + "admin.user": "用户", + "admin.devices": "设备" +} diff --git a/templates/admin/plugins/web-push.tpl b/templates/admin/plugins/web-push.tpl index e7fa8f2..8eb4f52 100644 --- a/templates/admin/plugins/web-push.tpl +++ b/templates/admin/plugins/web-push.tpl @@ -5,44 +5,35 @@
-
Settings
+
[[web-push:admin.settings]]
- - -

- Additional characters beyond this specified length will be truncated. - Due to a software limitation, if the message body is greater than 4096 bytes, the message itself will be an attachment in the push notification. -

+ + +

[[web-push:admin.max-length-help]]

- - -

- Optional — overrides the badge for messages sent (usually seen in the notification bar on mobile devices) - By default, the site's configured "touch icon" is sent. -

+ + +

[[web-push:admin.badge-help]]

- - -

- Optional — overrides the icon for messages sent (can be used for branding, etc.) - By default, the site's configured "touch icon" is sent. -

+ + +

[[web-push:admin.icon-help]]

-
Users
+
[[web-push:admin.users]]
- - + + From 190f82c85bf805d198fe43fc37c743788d9f203a Mon Sep 17 00:00:00 2001 From: ClickAndGoScript Date: Sun, 14 Jun 2026 10:34:52 +0300 Subject: [PATCH 2/2] feat: add notification action buttons and improve settings page robustness - Add "Mark as read" and "View notifications" action buttons to push notifications, translated server-side per user language (en-GB, he, zh-CN) - Service worker handles button clicks: mark-read calls the write API directly without needing an open window; view-notifications focuses/opens the notifications page - Pass nid in notification data payload so mark-read can identify the notification - settings.js: add browser support detection (serviceWorker + PushManager) - settings.js: race serviceWorker.ready against a 5s timeout to avoid infinite hang when no SW is registered (common on iOS before PWA install) - settings.js: handle permission denied before and after requestPermission() - settings.js: roll back browser-level subscription on API failure - settings.js: show success toast on subscribe --- library.js | 17 +++++- public/languages/en-GB/web-push.json | 8 +++ public/languages/he/web-push.json | 8 +++ public/languages/zh-CN/web-push.json | 8 +++ public/lib/settings.js | 59 ++++++++++++++----- static/web-push.js | 84 +++++++++++++++++++++------- 6 files changed, 151 insertions(+), 33 deletions(-) diff --git a/library.js b/library.js index 647953f..38a4c95 100644 --- a/library.js +++ b/library.js @@ -239,12 +239,27 @@ async function constructPayload(notification, uid, lang) { badge = `${nconf.get('url')}${meta.config['brand:maskableIcon'] || '/apple-touch-icon'}`; } + const actions = await constructActions(lang); + return { title, body, tag, lang, dir, - data: { url, icon, badge }, + actions, + data: { url, icon, badge, nid }, }; } + +async function constructActions(lang) { + const [markRead, viewNotifications] = await translator.translateKeys([ + '[[web-push:action.mark-read]]', + '[[web-push:action.view-notifications]]', + ], lang); + + return [ + { action: 'mark-read', title: markRead }, + { action: 'view-notifications', title: viewNotifications }, + ]; +} diff --git a/public/languages/en-GB/web-push.json b/public/languages/en-GB/web-push.json index 30fb177..9a8bd50 100644 --- a/public/languages/en-GB/web-push.json +++ b/public/languages/en-GB/web-push.json @@ -7,7 +7,15 @@ "profile.send-test": "Send Test Notification", "toast.test_success": "Test notification sent.", + "toast.subscribe_success": "Push notifications enabled on this device.", "toast.test_unavailable": "Cannot send test notification as push notifications are not enabled on this device.", + "toast.permission_denied": "Notification permission was denied. Please enable notifications for this site in your browser settings.", + "toast.subscribe_failed": "Could not enable push notifications on this device. Please try again.", + "toast.unsupported": "This browser does not support push notifications.", + "toast.sw_not_registered": "Push notifications are unavailable: the background service is not registered. On iOS, install this site to your Home Screen first.", + + "action.mark-read": "Mark as read", + "action.view-notifications": "View notifications", "test.title": "Test notification", "test.body": "This is a test message sent from NodeBB", diff --git a/public/languages/he/web-push.json b/public/languages/he/web-push.json index 88d96a6..2520e87 100644 --- a/public/languages/he/web-push.json +++ b/public/languages/he/web-push.json @@ -7,7 +7,15 @@ "profile.send-test": "שליחת התראת בדיקה", "toast.test_success": "התראת הבדיקה נשלחה.", + "toast.subscribe_success": "התראות דחיפה הופעלו במכשיר זה.", "toast.test_unavailable": "לא ניתן לשלוח התראת בדיקה כי התראות דחיפה אינן מופעלות במכשיר זה.", + "toast.permission_denied": "הרשאת ההתראות נדחתה. יש לאפשר התראות עבור אתר זה בהגדרות הדפדפן.", + "toast.subscribe_failed": "לא ניתן להפעיל התראות דחיפה במכשיר זה. נסו שוב.", + "toast.unsupported": "הדפדפן הזה אינו תומך בהתראות דחיפה.", + "toast.sw_not_registered": "התראות דחיפה אינן זמינות: שירות הרקע לא נרשם. באייפון, יש להתקין את האתר תחילה למסך הבית.", + + "action.mark-read": "סמן כנקרא", + "action.view-notifications": "צפה בהתראות", "test.title": "התראת בדיקה", "test.body": "זוהי הודעת בדיקה שנשלחה מ־NodeBB", diff --git a/public/languages/zh-CN/web-push.json b/public/languages/zh-CN/web-push.json index d5e044c..b11357d 100644 --- a/public/languages/zh-CN/web-push.json +++ b/public/languages/zh-CN/web-push.json @@ -7,7 +7,15 @@ "profile.send-test": "发送测试通知", "toast.test_success": "测试通知已发送。", + "toast.subscribe_success": "已在此设备上启用推送通知。", "toast.test_unavailable": "由于此设备未启用推送通知,因此无法发送测试通知。", + "toast.permission_denied": "通知权限已被拒绝。请在浏览器设置中允许此站点发送通知。", + "toast.subscribe_failed": "无法在此设备上启用推送通知。请重试。", + "toast.unsupported": "此浏览器不支持推送通知。", + "toast.sw_not_registered": "推送通知不可用:后台服务未注册。在 iOS 上,请先将本站添加到主屏幕。", + + "action.mark-read": "标记为已读", + "action.view-notifications": "查看通知", "test.title": "测试通知", "test.body": "这是一条来自 NodeBB 的测试消息", diff --git a/public/lib/settings.js b/public/lib/settings.js index 9ed61e9..b1b7413 100644 --- a/public/lib/settings.js +++ b/public/lib/settings.js @@ -7,10 +7,26 @@ import { success, warning } from 'alerts'; export async function init() { const containerEl = document.querySelector('[component="web-push-form"]'); if (!containerEl) { - console.error('Web Push form container not found'); return; } - const registration = await navigator.serviceWorker.ready; + + if (!('serviceWorker' in navigator) || !('PushManager' in window)) { + warning('[[web-push:toast.unsupported]]'); + return; + } + + // navigator.serviceWorker.ready hangs forever if no SW is registered; race against a timeout. + const registration = await Promise.race([ + navigator.serviceWorker.ready, + new Promise((_, reject) => setTimeout(() => reject(), 5000)), + ]).catch(() => { + warning('[[web-push:toast.sw_not_registered]]'); + return null; + }); + if (!registration) { + return; + } + let subscription = await registration.pushManager.getSubscription(); const convertedVapidKey = urlBase64ToUint8Array(config['web-push'].vapidKey); @@ -33,27 +49,48 @@ export async function init() { case 'toggle': { const countEl = document.querySelector('#deviceCount strong'); if (!subscription) { + if (Notification.permission === 'denied') { + subselector.checked = false; + warning('[[web-push:toast.permission_denied]]'); + document.getElementById('permission-warning').classList.remove('d-none'); + break; + } + try { + if (Notification.permission !== 'granted') { + const permission = await Notification.requestPermission(); + if (permission !== 'granted') { + subselector.checked = false; + warning('[[web-push:toast.permission_denied]]'); + document.getElementById('permission-warning').classList.remove('d-none'); + break; + } + } + subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: convertedVapidKey, }); await post('/plugins/web-push/subscription', { subscription: subscription.toJSON() }); + success('[[web-push:toast.subscribe_success]]'); - // Update count let count = parseInt(countEl.textContent, 10); - count += 1; - countEl.innerText = count; - } catch (e) { + countEl.innerText = count + 1; + } catch (err) { subselector.checked = false; + const stale = await registration.pushManager.getSubscription(); + if (stale) { + await stale.unsubscribe(); + } + subscription = null; + warning('[[web-push:toast.subscribe_failed]]'); } } else { await subscription.unsubscribe(); await del('/plugins/web-push/subscription', { subscription: subscription.toJSON() }); let count = parseInt(countEl.textContent, 10); - count -= 1; - countEl.innerText = count; + countEl.innerText = count - 1; subscription = null; } @@ -68,19 +105,15 @@ export async function init() { enabledEl.checked = true; } - // Show permission warning if applicable const state = await registration.pushManager.permissionState({ userVisibleOnly: true, applicationServerKey: convertedVapidKey, }); if (state === 'denied') { - const warningEl = document.getElementById('permission-warning'); - warningEl.classList.remove('d-none'); + document.getElementById('permission-warning').classList.remove('d-none'); } } -// This function is needed because Chrome doesn't accept a base64 encoded string -// as value for applicationServerKey in pushManager.subscribe yet // https://bugs.chromium.org/p/chromium/issues/detail?id=802280 function urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - (base64String.length % 4)) % 4); diff --git a/static/web-push.js b/static/web-push.js index baa1f34..77302c3 100644 --- a/static/web-push.js +++ b/static/web-push.js @@ -4,7 +4,7 @@ // Register event listener for the 'push' event. self.addEventListener('push', (event) => { // Keep the service worker alive until the notification is created. - const { title, body, tag, data } = event.data.json(); + const { title, body, tag, data, actions } = event.data.json(); if (title && body) { const { icon } = data; @@ -12,8 +12,17 @@ self.addEventListener('push', (event) => { const { badge } = data; delete data.badge; + const options = { body, tag, data, icon, badge }; + + // Action buttons are not supported everywhere (e.g. Firefox, Safari); + // Notification.maxActions only exists where they are. + if (Array.isArray(actions) && typeof Notification !== 'undefined' && + 'maxActions' in Notification && Notification.maxActions > 0) { + options.actions = actions.slice(0, Notification.maxActions); + } + event.waitUntil( - self.registration.showNotification(title, { body, tag, data, icon, badge }) + self.registration.showNotification(title, options) ); } else if (tag) { event.waitUntil( @@ -26,29 +35,66 @@ self.addEventListener('push', (event) => { } }); +// Marks a notification read directly against the API. Push notifications are +// usually acted on when no forum window is open, so this cannot rely on +// postMessage to a client — the session cookie and a freshly-fetched csrf +// token are enough to call the write API from the worker itself. +async function markNotificationRead(nid) { + const base = self.registration.scope; + + const configRes = await fetch(new URL('api/config', base), { credentials: 'same-origin' }); + if (!configRes.ok) { + return; + } + const { csrf_token: csrfToken } = await configRes.json(); + + await fetch(new URL(`api/v3/notifications/${encodeURIComponent(nid)}/read`, base), { + method: 'PUT', + credentials: 'same-origin', + headers: { 'x-csrf-token': csrfToken }, + }); +} + +// Focuses an existing forum window (navigating it via ajaxify) or opens a new one. +function focusOrOpen(target) { + return self.clients + .matchAll({ type: 'window' }) + .then((clientList) => { + for (const client of clientList) { + const { hostname } = new URL(client.url); + if (target && hostname === target.hostname && 'focus' in client) { + client.postMessage({ + action: 'ajaxify', + url: target.pathname, + }); + return client.focus(); + } + } + if (target && self.clients.openWindow) return self.clients.openWindow(target.pathname); + }); +} + self.addEventListener('notificationclick', (event) => { event.notification.close(); + + if (event.action === 'mark-read') { + const nid = event.notification.data && event.notification.data.nid; + if (nid) { + event.waitUntil(markNotificationRead(nid).catch(() => {})); + } + return; + } + + if (event.action === 'view-notifications') { + event.waitUntil(focusOrOpen(new URL('notifications', self.registration.scope))); + return; + } + let target; if (event.notification.data && event.notification.data.url) { target = new URL(event.notification.data.url); } // This looks to see if the current is already open and focuses if it is - event.waitUntil( - self.clients - .matchAll({ type: 'window' }) - .then((clientList) => { - for (const client of clientList) { - const { hostname } = new URL(client.url); - if (target && hostname === target.hostname && 'focus' in client) { - client.postMessage({ - action: 'ajaxify', - url: target.pathname, - }); - return client.focus(); - } - } - if (self.clients.openWindow) return self.clients.openWindow(target.pathname); - }) - ); + event.waitUntil(focusOrOpen(target)); });
UserDevices[[web-push:admin.user]][[web-push:admin.devices]]