From 9a6be8b6a3a8c39f275e74ce695eec1ab838420b Mon Sep 17 00:00:00 2001 From: ClickAndGoScript Date: Sun, 14 Jun 2026 10:32:38 +0300 Subject: [PATCH 1/3] 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/3] 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)); }); From 5d39437d09691e8b7bcff74122ed3d0245d32b1f Mon Sep 17 00:00:00 2001 From: ClickAndGoScript Date: Sun, 14 Jun 2026 10:36:54 +0300 Subject: [PATCH 3/3] feat: add opt-in banner for push notifications - Corner banner invites logged-in users to subscribe after a configurable number of page visits; dismissal and visit count stored in localStorage - Banner is shown only on supported devices (serviceWorker + PushManager available, permission not denied, SW registered) - ACP setting to enable/disable the banner and configure the visit threshold - Banner and all admin strings fully translated (en-GB, he, zh-CN) --- library.js | 5 +- plugin.json | 3 + public/languages/en-GB/web-push.json | 9 +++ public/languages/he/web-push.json | 9 +++ public/languages/zh-CN/web-push.json | 9 +++ public/lib/prompt.js | 85 ++++++++++++++++++++++++++ templates/admin/plugins/web-push.tpl | 12 ++++ templates/partials/web-push/prompt.tpl | 10 +++ 8 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 public/lib/prompt.js create mode 100644 templates/partials/web-push/prompt.tpl diff --git a/library.js b/library.js index 38a4c95..0876aa9 100644 --- a/library.js +++ b/library.js @@ -38,9 +38,12 @@ plugin.init = async (params) => { }; plugin.appendConfig = async (config) => { - const { publicKey } = await meta.settings.get('web-push'); + const { publicKey, promptEnabled, promptDelay } = await meta.settings.get('web-push'); config['web-push'] = { vapidKey: publicKey, + // checkbox serialization varies by database and core version + promptEnabled: [true, 'true', 'on', 1, '1'].includes(promptEnabled), + promptDelay: Math.max(parseInt(promptDelay, 10) || 3, 1), }; return config; diff --git a/plugin.json b/plugin.json index 85ee949..661a15a 100644 --- a/plugin.json +++ b/plugin.json @@ -13,6 +13,9 @@ { "hook": "filter:service-worker.scripts", "method": "registerServiceWorker" } ], "languages": "public/languages", + "scripts": [ + "public/lib/prompt.js" + ], "modules": { "../client/account/web-push.js": "./public/lib/settings.js", "../admin/plugins/web-push.js": "./public/lib/admin.js" diff --git a/public/languages/en-GB/web-push.json b/public/languages/en-GB/web-push.json index 9a8bd50..026739e 100644 --- a/public/languages/en-GB/web-push.json +++ b/public/languages/en-GB/web-push.json @@ -28,6 +28,15 @@ "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.", + "prompt.title": "Stay in the loop", + "prompt.body": "Enable push notifications to get alerted even when you're not on the site.", + "prompt.confirm": "Enable notifications", + "prompt.dismiss": "No thanks", + + "admin.prompt-enabled": "Show opt-in prompt to logged-in users", + "admin.prompt-enabled-help": "When enabled, a small corner banner inviting users to enable push notifications appears after the configured number of page visits. It does not block the page, and is never shown again once the user dismisses it or subscribes.", + "admin.prompt-delay": "Show prompt after (page visits)", + "admin.prompt-delay-help": "Number of page visits a logged-in user must make before the opt-in prompt appears. Minimum 1.", "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 index 2520e87..a14f31c 100644 --- a/public/languages/he/web-push.json +++ b/public/languages/he/web-push.json @@ -28,6 +28,15 @@ "admin.badge-help": "אופציונלי — מחליף את התג בהודעות הנשלחות (מוצג בדרך כלל בשורת ההתראות במכשירים ניידים). כברירת מחדל נשלח סמל ה־touch icon המוגדר באתר.", "admin.icon": "כתובת סמל (Icon)", "admin.icon-help": "אופציונלי — מחליף את הסמל בהודעות הנשלחות (שימושי למיתוג וכדומה). כברירת מחדל נשלח סמל ה־touch icon המוגדר באתר.", + "prompt.title": "הישארו מעודכנים", + "prompt.body": "הפעילו התראות דחיפה וקבלו עדכונים גם כשאתם לא באתר.", + "prompt.confirm": "הפעלת התראות", + "prompt.dismiss": "לא תודה", + + "admin.prompt-enabled": "הצגת הצעת הרשמה למשתמשים מחוברים", + "admin.prompt-enabled-help": "כאשר מופעל, באנר קטן בפינת המסך המזמין משתמשים להפעיל התראות דחיפה יוצג לאחר מספר הביקורים שהוגדר. הבאנר אינו חוסם את הדף, ולא יוצג שוב לאחר שהמשתמש דחה אותו או נרשם.", + "admin.prompt-delay": "הצגת ההצעה לאחר (מספר ביקורים)", + "admin.prompt-delay-help": "מספר הביקורים שמשתמש מחובר צריך לבצע לפני שהצעת ההרשמה תוצג. מינימום 1.", "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 b11357d..938056c 100644 --- a/public/languages/zh-CN/web-push.json +++ b/public/languages/zh-CN/web-push.json @@ -28,6 +28,15 @@ "admin.badge-help": "可选 — 覆盖所发送消息的徽章(通常显示在移动设备的通知栏中)。默认发送站点配置的"touch icon"。", "admin.icon": "图标 URL", "admin.icon-help": "可选 — 覆盖所发送消息的图标(可用于品牌宣传等)。默认发送站点配置的"touch icon"。", + "prompt.title": "保持最新动态", + "prompt.body": "启用推送通知,即使不在网站时也能及时收到提醒。", + "prompt.confirm": "启用通知", + "prompt.dismiss": "不,谢谢", + + "admin.prompt-enabled": "向已登录用户显示订阅提示", + "admin.prompt-enabled-help": "启用后,在达到设定的访问次数后,会在屏幕角落显示一个邀请用户启用推送通知的小横幅。它不会阻挡页面,用户关闭或订阅后将不再显示。", + "admin.prompt-delay": "显示提示前的访问次数", + "admin.prompt-delay-help": "已登录用户需要访问多少次页面后才会显示订阅提示。最小值为 1。", "admin.users": "用户", "admin.user": "用户", "admin.devices": "设备" diff --git a/public/lib/prompt.js b/public/lib/prompt.js new file mode 100644 index 0000000..dc41178 --- /dev/null +++ b/public/lib/prompt.js @@ -0,0 +1,85 @@ +'use strict'; + +(async () => { + const [hooks, api, alerts, storage] = await app.require(['hooks', 'api', 'alerts', 'storage']); + + const visitsKey = 'web-push:visits'; + const dismissedKey = 'web-push:prompt-dismissed'; + + hooks.on('action:app.load', async () => { + const { promptEnabled, promptDelay, vapidKey } = config['web-push'] || {}; + if (!promptEnabled || !vapidKey || !app.user.uid || storage.getItem(dismissedKey)) { + return; + } + + if (!('serviceWorker' in navigator) || !('PushManager' in window) || + Notification.permission === 'denied') { + return; + } + + const registration = await navigator.serviceWorker.getRegistration(); + if (!registration || await registration.pushManager.getSubscription()) { + return; + } + + const visits = (parseInt(storage.getItem(visitsKey), 10) || 0) + 1; + storage.setItem(visitsKey, visits); + if (visits < promptDelay) { + return; + } + + showBanner(registration); + }); + + async function showBanner(registration) { + const $banner = await app.parseAndTranslate('partials/web-push/prompt', {}); + const banner = $banner.get(0); + document.body.append(banner); + + banner.addEventListener('click', (e) => { + const btn = e.target.closest('[data-action]'); + if (!btn) { + return; + } + + if (btn.dataset.action === 'subscribe') { + subscribe(registration); + } + + storage.setItem(dismissedKey, '1'); + banner.remove(); + }); + } + + async function subscribe(registration) { + try { + if (Notification.permission !== 'granted' && + await Notification.requestPermission() !== 'granted') { + alerts.warning('[[web-push:toast.permission_denied]]'); + return; + } + + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(config['web-push'].vapidKey), + }); + await api.post('/plugins/web-push/subscription', { subscription: subscription.toJSON() }); + alerts.success('[[web-push:toast.subscribe_success]]'); + } catch (err) { + alerts.warning('[[web-push:toast.subscribe_failed]]'); + } + } + + // https://bugs.chromium.org/p/chromium/issues/detail?id=802280 + function urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; + } +})(); diff --git a/templates/admin/plugins/web-push.tpl b/templates/admin/plugins/web-push.tpl index 8eb4f52..f7ff3c3 100644 --- a/templates/admin/plugins/web-push.tpl +++ b/templates/admin/plugins/web-push.tpl @@ -24,6 +24,18 @@

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

+ +
+ + +

[[web-push:admin.prompt-enabled-help]]

+
+ +
+ + +

[[web-push:admin.prompt-delay-help]]

+
diff --git a/templates/partials/web-push/prompt.tpl b/templates/partials/web-push/prompt.tpl new file mode 100644 index 0000000..13eb145 --- /dev/null +++ b/templates/partials/web-push/prompt.tpl @@ -0,0 +1,10 @@ +
+
+
[[web-push:prompt.title]]
+

[[web-push:prompt.body]]

+
+ + +
+
+
UserDevices[[web-push:admin.user]][[web-push:admin.devices]]