From 9142c509228f90d91a98186e679c35f5ce20a6bc Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 27 Jan 2026 13:59:13 +0200 Subject: [PATCH 01/15] Add Hebrew translation --- public/languages/he-IL/web-push.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 public/languages/he-IL/web-push.json diff --git a/public/languages/he-IL/web-push.json b/public/languages/he-IL/web-push.json new file mode 100644 index 0000000..7d63066 --- /dev/null +++ b/public/languages/he-IL/web-push.json @@ -0,0 +1,11 @@ +{ + "profile.label": "התראות דחיפה", + "profile.introduction": "בנוסף להתראות בתוך האפליקציה ולהתראות בדוא״ל, ניתן לבחור לקבל גם התראות דחיפה. כך תוכלו לקבל התראות גם כשהאפליקציה אינה פתוחה במכשיר.", + "profile.option": "הפעלת התראות דחיפה במכשיר זה", + "profile.devices": "כרגע נשלחות התראות ל־%1 מכשיר(ים).", + "profile.permissionBlocked": "המכשיר שלך אינו מאפשר כרגע לקבל התראות מאתר זה. יש לאשר את הרשאת ההתראות כדי להמשיך.", + "profile.send-test": "שליחת התראת בדיקה", + + "toast.test_success": "התראת הבדיקה נשלחה.", + "toast.test_unavailable": "לא ניתן לשלוח התראת בדיקה כי התראות דחיפה אינן מופעלות במכשיר זה." +} From 552619b815c705563fdc557fe5ff4b8551e8fb5f Mon Sep 17 00:00:00 2001 From: ClickAndGoScript Date: Wed, 4 Feb 2026 13:52:14 +0200 Subject: [PATCH 02/15] =?UTF-8?q?feat:=20=D7=A2=D7=93=D7=9B=D7=95=D7=A0?= =?UTF-8?q?=D7=99=D7=9D=20=D7=9E=D7=99=D7=A0=D7=95=D7=A8=D7=99=D7=99=D7=9D?= =?UTF-8?q?=20=D7=9C=D7=94=D7=92=D7=93=D7=A8=D7=95=D7=AA=20=D7=95=D7=AA?= =?UTF-8?q?=D7=A8=D7=92=D7=95=D7=9D=20web-push?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - הוספת nodebb-plugin-web-push-1.xml ל-.gitignore כדי למנוע מעקב אחר קבצי תצורה או יומנים שנוצרו על ידי הפלאגין, מה שמסייע בשמירה על ניקיון המאגר - תיקון רווח קטן בטקסט ההתראה בקובץ השפה העברית כדי לשפר את הקריאות והעיצוב, ללא שינוי בתוכן הפונקציונלי --- .gitignore | 1 + public/languages/he-IL/web-push.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d77e539..f1ff4ad 100644 --- a/.gitignore +++ b/.gitignore @@ -216,3 +216,4 @@ pip-log.txt sftp-config.json node_modules/ +nodebb-plugin-web-push-1.xml diff --git a/public/languages/he-IL/web-push.json b/public/languages/he-IL/web-push.json index 7d63066..1a5ee43 100644 --- a/public/languages/he-IL/web-push.json +++ b/public/languages/he-IL/web-push.json @@ -2,7 +2,7 @@ "profile.label": "התראות דחיפה", "profile.introduction": "בנוסף להתראות בתוך האפליקציה ולהתראות בדוא״ל, ניתן לבחור לקבל גם התראות דחיפה. כך תוכלו לקבל התראות גם כשהאפליקציה אינה פתוחה במכשיר.", "profile.option": "הפעלת התראות דחיפה במכשיר זה", - "profile.devices": "כרגע נשלחות התראות ל־%1 מכשיר(ים).", + "profile.devices": "כרגע נשלחות התראות ל־ %1 מכשיר(ים).", "profile.permissionBlocked": "המכשיר שלך אינו מאפשר כרגע לקבל התראות מאתר זה. יש לאשר את הרשאת ההתראות כדי להמשיך.", "profile.send-test": "שליחת התראת בדיקה", From 98b9b72c5b2c4c25c2af79cb004b18bacc073b2f Mon Sep 17 00:00:00 2001 From: ClickAndGoScript Date: Mon, 27 Apr 2026 07:50:11 +0300 Subject: [PATCH 03/15] =?UTF-8?q?chore:=20=D7=A9=D7=99=D7=A0=D7=95=D7=99?= =?UTF-8?q?=20=D7=A9=D7=9D=20=D7=AA=D7=99=D7=A7=D7=99=D7=99=D7=AA=20=D7=94?= =?UTF-8?q?=D7=9C=D7=95=D7=A7=D7=9C=D7=99=D7=96=D7=A6=D7=99=D7=94=20=D7=9E?= =?UTF-8?q?-he-IL=20=D7=9C-he?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - שינוי שם התיקיה public/languages/he-IL ל-public/languages/he עבור קבצי השפה העברית (ללא שינוי בתוכן) --- public/languages/{he-IL => he}/web-push.json | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename public/languages/{he-IL => he}/web-push.json (100%) diff --git a/public/languages/he-IL/web-push.json b/public/languages/he/web-push.json similarity index 100% rename from public/languages/he-IL/web-push.json rename to public/languages/he/web-push.json From d1d5e14460960679d683cfbb6f9c5eea76a55327 Mon Sep 17 00:00:00 2001 From: ClickAndGoScript Date: Fri, 1 May 2026 13:09:15 +0300 Subject: [PATCH 04/15] =?UTF-8?q?feat:=20=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20?= =?UTF-8?q?=D7=98=D7=99=D7=A4=D7=95=D7=9C=20=D7=91=D7=94=D7=A8=D7=A9=D7=90?= =?UTF-8?q?=D7=95=D7=AA=20=D7=94=D7=AA=D7=A8=D7=90=D7=95=D7=AA=20=D7=95?= =?UTF-8?q?=D7=A9=D7=92=D7=99=D7=90=D7=95=D7=AA=20=D7=94=D7=A8=D7=A9=D7=9E?= =?UTF-8?q?=D7=94=20=D7=9C=D7=A4=D7=95=D7=A9=20=D7=A0=D7=95=D7=98=D7=99?= =?UTF-8?q?=D7=A4=D7=99=D7=A7=D7=99=D7=99=D7=A9=D7=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - הוספת בדיקת 'denied' לפני ניסיון הרשמה להתראות ב-settings.js - טיפול ב-iOS Safari: שמירה על activation לפני בקשת הרשמה - הוספת rollback אם הרשמה נכשלה אחרי יצירת subscription בדפדפן - הוספת מפתחות תרגום חדשים: permission_denied ו-subscribe_failed באנגלית, עברית וסינית --- public/languages/en-GB/web-push.json | 4 +++- public/languages/he/web-push.json | 4 +++- public/languages/zh-CN/web-push.json | 4 +++- public/lib/settings.js | 33 +++++++++++++++++++++++++++- 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/public/languages/en-GB/web-push.json b/public/languages/en-GB/web-push.json index 2e8f3ab..44f106c 100644 --- a/public/languages/en-GB/web-push.json +++ b/public/languages/en-GB/web-push.json @@ -7,5 +7,7 @@ "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." + "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." } \ No newline at end of file diff --git a/public/languages/he/web-push.json b/public/languages/he/web-push.json index 1a5ee43..c55f550 100644 --- a/public/languages/he/web-push.json +++ b/public/languages/he/web-push.json @@ -7,5 +7,7 @@ "profile.send-test": "שליחת התראת בדיקה", "toast.test_success": "התראת הבדיקה נשלחה.", - "toast.test_unavailable": "לא ניתן לשלוח התראת בדיקה כי התראות דחיפה אינן מופעלות במכשיר זה." + "toast.test_unavailable": "לא ניתן לשלוח התראת בדיקה כי התראות דחיפה אינן מופעלות במכשיר זה.", + "toast.permission_denied": "הרשאת ההתראות נדחתה. יש לאפשר התראות עבור אתר זה בהגדרות הדפדפן.", + "toast.subscribe_failed": "לא ניתן להפעיל התראות דחיפה במכשיר זה. נסו שוב." } diff --git a/public/languages/zh-CN/web-push.json b/public/languages/zh-CN/web-push.json index ab3abcd..be81cf3 100644 --- a/public/languages/zh-CN/web-push.json +++ b/public/languages/zh-CN/web-push.json @@ -7,5 +7,7 @@ "profile.send-test": "发送测试通知", "toast.test_success": "测试通知已发送。", - "toast.test_unavailable": "由于此设备未启用推送通知,因此无法发送测试通知。" + "toast.test_unavailable": "由于此设备未启用推送通知,因此无法发送测试通知。", + "toast.permission_denied": "通知权限已被拒绝。请在浏览器设置中允许此站点发送通知。", + "toast.subscribe_failed": "无法在此设备上启用推送通知。请重试。" } \ No newline at end of file diff --git a/public/lib/settings.js b/public/lib/settings.js index 5c5cee4..6daf084 100644 --- a/public/lib/settings.js +++ b/public/lib/settings.js @@ -33,7 +33,30 @@ export async function init() { case 'toggle': { const countEl = document.querySelector('#deviceCount strong'); if (!subscription) { + // iOS Safari is strict about user activation: subscribe() must be + // called from the same synchronous task as the click. We branch BEFORE + // any await: if permission is already granted, call subscribe() first + // (no awaits in between). Otherwise request permission, which itself + // preserves activation on Chrome but may lose it on iOS — the user can + // just tap again. + 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, @@ -45,8 +68,16 @@ export async function init() { let count = parseInt(countEl.textContent, 10); count += 1; countEl.innerText = count; - } catch (e) { + } catch (err) { + console.error('[web-push] subscribe failed:', err); subselector.checked = false; + // Roll back any browser-level subscription created before the failure. + const stale = await registration.pushManager.getSubscription(); + if (stale) { + await stale.unsubscribe(); + } + subscription = null; + warning('[[web-push:toast.subscribe_failed]]'); } } else { await subscription.unsubscribe(); From 9f1b30f767dfa7fab48428e938449900eaeaafad Mon Sep 17 00:00:00 2001 From: ClickAndGoScript Date: Sun, 3 May 2026 11:12:34 +0300 Subject: [PATCH 05/15] feat: add browser support detection and service worker timeout handling Add feature detection for Service Worker and Push API to display user-friendly error messages when unsupported. Implement 5-second timeout for service worker registration to prevent UI hangs when SW fails to register, particularly common on iOS when the PWA isn't installed to Home Screen. --- public/languages/en-GB/web-push.json | 4 +++- public/languages/he/web-push.json | 4 +++- public/languages/zh-CN/web-push.json | 4 +++- public/lib/settings.js | 26 +++++++++++++++++++++++++- 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/public/languages/en-GB/web-push.json b/public/languages/en-GB/web-push.json index 44f106c..295c83d 100644 --- a/public/languages/en-GB/web-push.json +++ b/public/languages/en-GB/web-push.json @@ -9,5 +9,7 @@ "toast.test_success": "Test notification sent.", "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.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." } \ No newline at end of file diff --git a/public/languages/he/web-push.json b/public/languages/he/web-push.json index c55f550..f82bc76 100644 --- a/public/languages/he/web-push.json +++ b/public/languages/he/web-push.json @@ -9,5 +9,7 @@ "toast.test_success": "התראת הבדיקה נשלחה.", "toast.test_unavailable": "לא ניתן לשלוח התראת בדיקה כי התראות דחיפה אינן מופעלות במכשיר זה.", "toast.permission_denied": "הרשאת ההתראות נדחתה. יש לאפשר התראות עבור אתר זה בהגדרות הדפדפן.", - "toast.subscribe_failed": "לא ניתן להפעיל התראות דחיפה במכשיר זה. נסו שוב." + "toast.subscribe_failed": "לא ניתן להפעיל התראות דחיפה במכשיר זה. נסו שוב.", + "toast.unsupported": "הדפדפן הזה אינו תומך בהתראות דחיפה.", + "toast.sw_not_registered": "התראות דחיפה אינן זמינות: שירות הרקע לא נרשם. באייפון, יש להתקין את האתר תחילה למסך הבית." } diff --git a/public/languages/zh-CN/web-push.json b/public/languages/zh-CN/web-push.json index be81cf3..34f098d 100644 --- a/public/languages/zh-CN/web-push.json +++ b/public/languages/zh-CN/web-push.json @@ -9,5 +9,7 @@ "toast.test_success": "测试通知已发送。", "toast.test_unavailable": "由于此设备未启用推送通知,因此无法发送测试通知。", "toast.permission_denied": "通知权限已被拒绝。请在浏览器设置中允许此站点发送通知。", - "toast.subscribe_failed": "无法在此设备上启用推送通知。请重试。" + "toast.subscribe_failed": "无法在此设备上启用推送通知。请重试。", + "toast.unsupported": "此浏览器不支持推送通知。", + "toast.sw_not_registered": "推送通知不可用:后台服务未注册。在 iOS 上,请先将本站添加到主屏幕。" } \ No newline at end of file diff --git a/public/lib/settings.js b/public/lib/settings.js index 6daf084..d97f9d2 100644 --- a/public/lib/settings.js +++ b/public/lib/settings.js @@ -10,7 +10,31 @@ export async function init() { console.error('Web Push form container not found'); return; } - const registration = await navigator.serviceWorker.ready; + + if (!('serviceWorker' in navigator) || !('PushManager' in window)) { + console.error('[web-push] Service workers or Push API not supported in this browser'); + warning('[[web-push:toast.unsupported]]'); + return; + } + + // navigator.serviceWorker.ready never rejects — it hangs forever if no SW is registered. + // On iOS this is a common failure mode (PWA not installed, scope mismatch, core SW failed + // to register). Race it against a timeout so we surface the problem instead of hanging. + const registration = await Promise.race([ + navigator.serviceWorker.ready, + new Promise((_, reject) => setTimeout( + () => reject(new Error('Service worker not ready after 5s — likely not registered')), + 5000 + )), + ]).catch((err) => { + console.error('[web-push]', err); + 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); From 465fe5044076dc877c53aaff99fdceea44ade10e Mon Sep 17 00:00:00 2001 From: ClickAndGoScript Date: Sun, 3 May 2026 11:27:17 +0300 Subject: [PATCH 06/15] =?UTF-8?q?feat:=20=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20?= =?UTF-8?q?=D7=AA=D7=9E=D7=99=D7=9B=D7=94=20=D7=91-Service=20Worker=20?= =?UTF-8?q?=D7=9C=D7=93=D7=A4=D7=93=D7=A0=D7=99=20Safari=20=D7=95-iOS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - plugin.json: הוספת הגדרת scripts שמפנה לקובץ main.js כדי לטעון את הסקריפט - public/lib/main.js: מימוש רישום Service Worker ידני לדפדני Safari ו-iOS, מאחר ש-NodeBB מדלג על רישום אוטומטי בפלטפורמות אלו וגורם להשתקת דף ההגדרות - הוספת לוג מפורט לכל אירועי מחזור החיים של ה-Service Worker כדי שכשלונות יופיעו ב-Web Inspector במקום להיעלם בשקט - זיהוי Safari ו-iOS דרך User-Agent ותכונות maxTouchPoints, וביצוע רישום ידני רק בפלטפורמות אלו כדי לשמור על תאימות עם דפדפנים אחרים --- plugin.json | 3 ++ public/lib/main.js | 78 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/plugin.json b/plugin.json index 85ee949..713949e 100644 --- a/plugin.json +++ b/plugin.json @@ -13,6 +13,9 @@ { "hook": "filter:service-worker.scripts", "method": "registerServiceWorker" } ], "languages": "public/languages", + "scripts": [ + "public/lib/main.js" + ], "modules": { "../client/account/web-push.js": "./public/lib/settings.js", "../admin/plugins/web-push.js": "./public/lib/admin.js" diff --git a/public/lib/main.js b/public/lib/main.js index d51436f..fc6e500 100644 --- a/public/lib/main.js +++ b/public/lib/main.js @@ -1,11 +1,83 @@ -// this file here as placeholder in case needed. Add back to plugin.json to use - 'use strict'; +// NodeBB core skips serviceWorker.register() on Safari (see public/src/app.js). +// That predates iOS 16.4, where Safari/PWA does support Web Push. Without an SW, +// navigator.serviceWorker.ready hangs forever and our settings page silently fails. +// This script registers the SW ourselves on Safari/iOS, and logs every lifecycle +// event so failures surface in Web Inspector instead of disappearing. + (async () => { const [hooks] = await app.require(['hooks']); hooks.on('action:app.load', async () => { - // ... + if (!('serviceWorker' in navigator)) { + console.warn('[web-push] serviceWorker not supported'); + return; + } + + const ua = navigator.userAgent; + const isSafari = /^((?!chrome|android).)*safari/i.test(ua); + const isIOS = /iPad|iPhone|iPod/.test(ua) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); + + if (!isSafari && !isIOS) { + // Core handles registration on non-Safari browsers. + return; + } + + // Core already tried (and skipped) registration by this point. If something + // is registered, leave it alone. + const existing = await navigator.serviceWorker.getRegistration(); + if (existing) { + console.info('[web-push] SW already registered:', existing); + return; + } + + const swUrl = (config.relative_path || '') + '/service-worker.js'; + const scope = (config.relative_path || '') + '/'; + + console.info('[web-push] Registering service worker (Safari/iOS fallback):', { swUrl, scope }); + + let registration; + try { + registration = await navigator.serviceWorker.register(swUrl, { scope }); + console.info('[web-push] register() resolved:', registration); + } catch (err) { + console.error('[web-push] register() FAILED:', err); + console.error('[web-push] Failure name:', err && err.name); + console.error('[web-push] Failure message:', err && err.message); + return; + } + + const logState = (worker, label) => { + if (!worker) return; + console.info(`[web-push] ${label} initial state:`, worker.state); + worker.addEventListener('statechange', () => { + console.info(`[web-push] ${label} state →`, worker.state); + }); + worker.addEventListener('error', (e) => { + console.error(`[web-push] ${label} error event:`, e); + }); + }; + + logState(registration.installing, 'installing'); + logState(registration.waiting, 'waiting'); + logState(registration.active, 'active'); + + registration.addEventListener('updatefound', () => { + console.info('[web-push] updatefound — new worker installing'); + logState(registration.installing, 'installing(updatefound)'); + }); + + navigator.serviceWorker.addEventListener('error', (e) => { + console.error('[web-push] navigator.serviceWorker error:', e); + }); + + try { + const ready = await navigator.serviceWorker.ready; + console.info('[web-push] serviceWorker.ready resolved:', ready); + } catch (err) { + console.error('[web-push] serviceWorker.ready rejected:', err); + } }); })(); From add9b3f3c28088040847498c5746ea00aa5675be Mon Sep 17 00:00:00 2001 From: ClickAndGoScript Date: Sun, 3 May 2026 13:23:14 +0300 Subject: [PATCH 07/15] =?UTF-8?q?feat:=20=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20?= =?UTF-8?q?=D7=AA=D7=9E=D7=99=D7=9B=D7=94=20=D7=91=D7=9E=D7=99=D7=96=D7=95?= =?UTF-8?q?=D7=92=20=D7=94=D7=AA=D7=A8=D7=90=D7=95=D7=AA=20=D7=91=D7=90?= =?UTF-8?q?=D7=9E=D7=A6=D7=A2=D7=95=D7=AA=20mergeId=20=D7=9C=D7=AA=D7=90?= =?UTF-8?q?=D7=99=D7=9E=D7=95=D7=AA=20=D7=A2=D7=9D=20Safari?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - עדכון library.js: הוספת שדה mergeId לאובייקט הנתונים שנשלח בהתראה - עדכון web-push.js: הוספת לוגיקה לסגירת התראות קיימות עם אותו mergeId לפני הצגת התראה חדשה - פתרון בעיית תאימות ל-Safari שאינו תומך כראוי בתכונת 'tag' להחלפת התראות --- library.js | 2 +- static/web-push.js | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/library.js b/library.js index ce9cea6..13ed45b 100644 --- a/library.js +++ b/library.js @@ -244,7 +244,7 @@ async function constructPayload(notification, uid, lang) { tag, lang, dir, - data: { url, icon, badge }, + data: { url, icon, badge, mergeId }, }; } diff --git a/static/web-push.js b/static/web-push.js index baa1f34..a2967d7 100644 --- a/static/web-push.js +++ b/static/web-push.js @@ -7,13 +7,27 @@ self.addEventListener('push', (event) => { const { title, body, tag, data } = event.data.json(); if (title && body) { - const { icon } = data; + const { icon, mergeId } = data; delete data.icon; const { badge } = data; delete data.badge; + // Close any existing notifications with the same mergeId (for Safari compatibility) + // Safari doesn't properly support the 'tag' property for replacing notifications + const closePromise = mergeId + ? self.registration.getNotifications().then((notifications) => { + notifications.forEach((notification) => { + if (notification.data && notification.data.mergeId === mergeId) { + notification.close(); + } + }); + }) + : Promise.resolve(); + event.waitUntil( - self.registration.showNotification(title, { body, tag, data, icon, badge }) + closePromise.then(() => { + return self.registration.showNotification(title, { body, tag, data, icon, badge }); + }) ); } else if (tag) { event.waitUntil( From bb0113d675377ee4fe360b644c3133ce0aafa08e Mon Sep 17 00:00:00 2001 From: ClickAndGoScript Date: Wed, 10 Jun 2026 21:57:42 +0300 Subject: [PATCH 08/15] =?UTF-8?q?Revert=20"feat:=20=D7=94=D7=95=D7=A1?= =?UTF-8?q?=D7=A4=D7=AA=20=D7=AA=D7=9E=D7=99=D7=9B=D7=94=20=D7=91=D7=9E?= =?UTF-8?q?=D7=99=D7=96=D7=95=D7=92=20=D7=94=D7=AA=D7=A8=D7=90=D7=95=D7=AA?= =?UTF-8?q?=20=D7=91=D7=90=D7=9E=D7=A6=D7=A2=D7=95=D7=AA=20mergeId=20?= =?UTF-8?q?=D7=9C=D7=AA=D7=90=D7=99=D7=9E=D7=95=D7=AA=20=D7=A2=D7=9D=20Saf?= =?UTF-8?q?ari"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit b037aa96e92bf56c834b5787710ff51286e7b26b. --- library.js | 2 +- static/web-push.js | 18 ++---------------- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/library.js b/library.js index 13ed45b..ce9cea6 100644 --- a/library.js +++ b/library.js @@ -244,7 +244,7 @@ async function constructPayload(notification, uid, lang) { tag, lang, dir, - data: { url, icon, badge, mergeId }, + data: { url, icon, badge }, }; } diff --git a/static/web-push.js b/static/web-push.js index a2967d7..baa1f34 100644 --- a/static/web-push.js +++ b/static/web-push.js @@ -7,27 +7,13 @@ self.addEventListener('push', (event) => { const { title, body, tag, data } = event.data.json(); if (title && body) { - const { icon, mergeId } = data; + const { icon } = data; delete data.icon; const { badge } = data; delete data.badge; - // Close any existing notifications with the same mergeId (for Safari compatibility) - // Safari doesn't properly support the 'tag' property for replacing notifications - const closePromise = mergeId - ? self.registration.getNotifications().then((notifications) => { - notifications.forEach((notification) => { - if (notification.data && notification.data.mergeId === mergeId) { - notification.close(); - } - }); - }) - : Promise.resolve(); - event.waitUntil( - closePromise.then(() => { - return self.registration.showNotification(title, { body, tag, data, icon, badge }); - }) + self.registration.showNotification(title, { body, tag, data, icon, badge }) ); } else if (tag) { event.waitUntil( From fc80c609f22db7238af3a19a51e4bce53cb8d6b5 Mon Sep 17 00:00:00 2001 From: ClickAndGoScript Date: Wed, 10 Jun 2026 22:00:09 +0300 Subject: [PATCH 09/15] =?UTF-8?q?chore:=20=D7=94=D7=A1=D7=A8=D7=AA=20?= =?UTF-8?q?=D7=9C=D7=95=D7=92=D7=99=D7=9D=20=D7=9E=D7=99=D7=95=D7=AA=D7=A8?= =?UTF-8?q?=D7=99=D7=9D=20=D7=9E=D7=A8=D7=99=D7=A9=D7=95=D7=9D=20=D7=94-Se?= =?UTF-8?q?rvice=20Worker=20=D7=95=D7=9E=D7=93=D7=A3=20=D7=94=D7=94=D7=92?= =?UTF-8?q?=D7=93=D7=A8=D7=95=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/lib/main.js | 47 +++--------------------------------------- public/lib/settings.js | 5 +---- 2 files changed, 4 insertions(+), 48 deletions(-) diff --git a/public/lib/main.js b/public/lib/main.js index fc6e500..26519c1 100644 --- a/public/lib/main.js +++ b/public/lib/main.js @@ -3,15 +3,13 @@ // NodeBB core skips serviceWorker.register() on Safari (see public/src/app.js). // That predates iOS 16.4, where Safari/PWA does support Web Push. Without an SW, // navigator.serviceWorker.ready hangs forever and our settings page silently fails. -// This script registers the SW ourselves on Safari/iOS, and logs every lifecycle -// event so failures surface in Web Inspector instead of disappearing. +// This script registers the SW ourselves on Safari/iOS. (async () => { const [hooks] = await app.require(['hooks']); hooks.on('action:app.load', async () => { if (!('serviceWorker' in navigator)) { - console.warn('[web-push] serviceWorker not supported'); return; } @@ -29,55 +27,16 @@ // is registered, leave it alone. const existing = await navigator.serviceWorker.getRegistration(); if (existing) { - console.info('[web-push] SW already registered:', existing); return; } const swUrl = (config.relative_path || '') + '/service-worker.js'; const scope = (config.relative_path || '') + '/'; - console.info('[web-push] Registering service worker (Safari/iOS fallback):', { swUrl, scope }); - - let registration; - try { - registration = await navigator.serviceWorker.register(swUrl, { scope }); - console.info('[web-push] register() resolved:', registration); - } catch (err) { - console.error('[web-push] register() FAILED:', err); - console.error('[web-push] Failure name:', err && err.name); - console.error('[web-push] Failure message:', err && err.message); - return; - } - - const logState = (worker, label) => { - if (!worker) return; - console.info(`[web-push] ${label} initial state:`, worker.state); - worker.addEventListener('statechange', () => { - console.info(`[web-push] ${label} state →`, worker.state); - }); - worker.addEventListener('error', (e) => { - console.error(`[web-push] ${label} error event:`, e); - }); - }; - - logState(registration.installing, 'installing'); - logState(registration.waiting, 'waiting'); - logState(registration.active, 'active'); - - registration.addEventListener('updatefound', () => { - console.info('[web-push] updatefound — new worker installing'); - logState(registration.installing, 'installing(updatefound)'); - }); - - navigator.serviceWorker.addEventListener('error', (e) => { - console.error('[web-push] navigator.serviceWorker error:', e); - }); - try { - const ready = await navigator.serviceWorker.ready; - console.info('[web-push] serviceWorker.ready resolved:', ready); + await navigator.serviceWorker.register(swUrl, { scope }); } catch (err) { - console.error('[web-push] serviceWorker.ready rejected:', err); + console.error('[web-push] service worker registration failed:', err); } }); })(); diff --git a/public/lib/settings.js b/public/lib/settings.js index d97f9d2..22a08d6 100644 --- a/public/lib/settings.js +++ b/public/lib/settings.js @@ -12,7 +12,6 @@ export async function init() { } if (!('serviceWorker' in navigator) || !('PushManager' in window)) { - console.error('[web-push] Service workers or Push API not supported in this browser'); warning('[[web-push:toast.unsupported]]'); return; } @@ -26,8 +25,7 @@ export async function init() { () => reject(new Error('Service worker not ready after 5s — likely not registered')), 5000 )), - ]).catch((err) => { - console.error('[web-push]', err); + ]).catch(() => { warning('[[web-push:toast.sw_not_registered]]'); return null; }); @@ -93,7 +91,6 @@ export async function init() { count += 1; countEl.innerText = count; } catch (err) { - console.error('[web-push] subscribe failed:', err); subselector.checked = false; // Roll back any browser-level subscription created before the failure. const stale = await registration.pushManager.getSubscription(); From 6af0d291148378d550ac8477bd4ce09e78a1ace9 Mon Sep 17 00:00:00 2001 From: ClickAndGoScript Date: Wed, 10 Jun 2026 22:00:09 +0300 Subject: [PATCH 10/15] =?UTF-8?q?fix:=20=D7=94=D7=97=D7=9C=D7=A4=D7=AA=20?= =?UTF-8?q?=D7=94=D7=AA=D7=A8=D7=90=D7=95=D7=AA=20=D7=91=D7=A1=D7=A4=D7=90?= =?UTF-8?q?=D7=A8=D7=99=20=D7=91=D7=90=D7=9E=D7=A6=D7=A2=D7=95=D7=AA=20Top?= =?UTF-8?q?ic=20header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit שליחת אופציית topic (גיבוב base64url של ה-tag, מוגבל ל-32 תווים) בכל sendNotification. שירות הפוש של אפל ממפה את הכותרת Topic ל-apns-collapse-id, כך שהתראה חדשה עם אותו topic מחליפה את הקודמת ברמת המערכת — גם בספארי ו-iOS שמתעלמים מתכונת tag של ההתראה. בדפדפנים אחרים ה-Topic גם מחליף הודעות שממתינות בתור כשהמכשיר לא מקוון. --- library.js | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/library.js b/library.js index ce9cea6..75a1922 100644 --- a/library.js +++ b/library.js @@ -1,5 +1,6 @@ 'use strict'; +const crypto = require('crypto'); const nconf = require.main.require('nconf'); const winston = require.main.require('winston'); const webPush = require('web-push'); @@ -141,7 +142,7 @@ plugin.onNotificationPush = async ({ notification, uidsNotified: uids }) => { const targets = subs.get(uid); targets.forEach(async (subscription) => { try { - await webPush.sendNotification(subscription, JSON.stringify(payload)); + await webPush.sendNotification(subscription, JSON.stringify(payload), pushOptions(payload.tag)); } catch (e) { // Errored — remove subscription from user winston.info(`[plugins/web-push] Push failed: ${e.code}; ${e.message}; statusCode: ${e.statusCode}`); @@ -167,7 +168,7 @@ plugin.onNotificationRescind = async ({ nids }) => { if (subs.size) { await Promise.all(Array.from(subs).map(async (subscription) => { try { - await webPush.sendNotification(subscription, JSON.stringify({ tag })); + await webPush.sendNotification(subscription, JSON.stringify({ tag }), pushOptions(tag)); } catch (e) { winston.info(`[plugins/web-push] Push failed: ${e.code}; ${e.message}; statusCode: ${e.statusCode}`); } @@ -195,6 +196,21 @@ plugin.addProfileItem = (data) => { return data; }; +// The Web Push protocol "Topic" header makes a new push replace a previous one +// with the same topic. Apple's push service maps it to apns-collapse-id, so on +// Safari/iOS — which ignore the notification 'tag' — a new notification still +// replaces the one already displayed. Topics are limited to 32 base64url +// characters, so hash the tag down to size. +function pushOptions(tag) { + if (!tag) { + return {}; + } + + return { + topic: crypto.createHash('sha256').update(String(tag)).digest('base64url').slice(0, 32), + }; +} + async function constructPayload(notification, uid, lang) { let { maxLength, icon, badge } = await meta.settings.get('web-push'); maxLength = parseInt(maxLength, 10) || 256; From 78ee02c896895709a8ce63a7f6ef44236182f07e Mon Sep 17 00:00:00 2001 From: ClickAndGoScript Date: Wed, 10 Jun 2026 23:33:44 +0300 Subject: [PATCH 11/15] =?UTF-8?q?feat:=20=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20?= =?UTF-8?q?=D7=9B=D7=A4=D7=AA=D7=95=D7=A8=D7=99=20=D7=A4=D7=A2=D7=95=D7=9C?= =?UTF-8?q?=D7=94=20=D7=9C=D7=94=D7=AA=D7=A8=D7=90=D7=95=D7=AA=20=D7=A4?= =?UTF-8?q?=D7=95=D7=A9=20-=20"=D7=A1=D7=9E=D7=9F=20=D7=9B=D7=A0=D7=A7?= =?UTF-8?q?=D7=A8=D7=90"=20=D7=95"=D7=A6=D7=A4=D7=94=20=D7=91=D7=94=D7=AA?= =?UTF-8?q?=D7=A8=D7=90=D7=95=D7=AA"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - כפתור "סמן כנקרא" מסמן את ההתראה כנקראה ישירות מה-Service Worker באמצעות קריאת API (עובד גם כשאין חלון פתוח), כולל תמיכה בהתראות ממוזגות - כפתור "צפה בהתראות" פותח את עמוד ההתראות (מתחשב בהתקנה בתת-נתיב) - כותרות הכפתורים מתורגמות בצד השרת לפי שפת המשתמש (he, en-GB, zh-CN) - הכפתורים מוצגים רק בדפדפנים התומכים ב-actions (לפי Notification.maxActions) - לחיצה על גוף ההתראה ממשיכה לנווט ליעד כרגיל - תיקון: מניעת שגיאה בלחיצה על התראה ללא כתובת יעד --- library.js | 17 +++++- public/languages/en-GB/web-push.json | 5 +- public/languages/he/web-push.json | 5 +- public/languages/zh-CN/web-push.json | 5 +- static/web-push.js | 84 +++++++++++++++++++++------- 5 files changed, 93 insertions(+), 23 deletions(-) diff --git a/library.js b/library.js index 9d97a2f..752d5b8 100644 --- a/library.js +++ b/library.js @@ -255,12 +255,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 295c83d..bc50371 100644 --- a/public/languages/en-GB/web-push.json +++ b/public/languages/en-GB/web-push.json @@ -11,5 +11,8 @@ "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." + "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" } \ No newline at end of file diff --git a/public/languages/he/web-push.json b/public/languages/he/web-push.json index f82bc76..8670287 100644 --- a/public/languages/he/web-push.json +++ b/public/languages/he/web-push.json @@ -11,5 +11,8 @@ "toast.permission_denied": "הרשאת ההתראות נדחתה. יש לאפשר התראות עבור אתר זה בהגדרות הדפדפן.", "toast.subscribe_failed": "לא ניתן להפעיל התראות דחיפה במכשיר זה. נסו שוב.", "toast.unsupported": "הדפדפן הזה אינו תומך בהתראות דחיפה.", - "toast.sw_not_registered": "התראות דחיפה אינן זמינות: שירות הרקע לא נרשם. באייפון, יש להתקין את האתר תחילה למסך הבית." + "toast.sw_not_registered": "התראות דחיפה אינן זמינות: שירות הרקע לא נרשם. באייפון, יש להתקין את האתר תחילה למסך הבית.", + + "action.mark-read": "סמן כנקרא", + "action.view-notifications": "צפה בהתראות" } diff --git a/public/languages/zh-CN/web-push.json b/public/languages/zh-CN/web-push.json index 34f098d..ef4c9d4 100644 --- a/public/languages/zh-CN/web-push.json +++ b/public/languages/zh-CN/web-push.json @@ -11,5 +11,8 @@ "toast.permission_denied": "通知权限已被拒绝。请在浏览器设置中允许此站点发送通知。", "toast.subscribe_failed": "无法在此设备上启用推送通知。请重试。", "toast.unsupported": "此浏览器不支持推送通知。", - "toast.sw_not_registered": "推送通知不可用:后台服务未注册。在 iOS 上,请先将本站添加到主屏幕。" + "toast.sw_not_registered": "推送通知不可用:后台服务未注册。在 iOS 上,请先将本站添加到主屏幕。", + + "action.mark-read": "标记为已读", + "action.view-notifications": "查看通知" } \ No newline at end of file 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 9d4f38df2b85b9b7d6e212773a7a7830760b14ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20U=C5=9Fakl=C4=B1?= Date: Wed, 10 Jun 2026 17:08:45 -0400 Subject: [PATCH 12/15] Remove nodebb-plugin-web-push-1.xml from .gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f1ff4ad..9b1a035 100644 --- a/.gitignore +++ b/.gitignore @@ -216,4 +216,4 @@ pip-log.txt sftp-config.json node_modules/ -nodebb-plugin-web-push-1.xml + From eccbd03ee4704289b363a8729beed07e8dff2212 Mon Sep 17 00:00:00 2001 From: ClickAndGoScript Date: Thu, 11 Jun 2026 23:08:53 +0300 Subject: [PATCH 13/15] =?UTF-8?q?feat:=20=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20?= =?UTF-8?q?=D7=91=D7=90=D7=A0=D7=A8=20=D7=94=D7=A6=D7=A2=D7=AA=20=D7=94?= =?UTF-8?q?=D7=A8=D7=A9=D7=9E=D7=94=20=D7=9C=D7=94=D7=AA=D7=A8=D7=90=D7=95?= =?UTF-8?q?=D7=AA=20=D7=93=D7=97=D7=99=D7=A4=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - אופציה חדשה בהגדרות הניהול: הצגת הצעת הרשמה למשתמשים מחוברים, כולל הגדרת מספר הביקורים בדף לפני ההצגה - באנר פינתי שאינו חוסם את הדף ונסגר רק דרך הכפתורים שלו; ספירת הביקורים והדחייה נשמרות ב-localStorage לכל מכשיר - הודעת הצלחה לאחר הרשמה, גם מהבאנר וגם מדף ההגדרות - העברת כל המחרוזות הקשיחות לקבצי השפה (תבנית הניהול, פריט התפריט והתראת הבדיקה) בשלוש השפות הנתמכות --- library.js | 11 ++- plugin.json | 3 +- public/languages/en-GB/web-push.json | 29 ++++++- public/languages/he/web-push.json | 27 ++++++- public/languages/zh-CN/web-push.json | 29 ++++++- public/lib/prompt.js | 109 +++++++++++++++++++++++++++ public/lib/settings.js | 1 + templates/admin/plugins/web-push.tpl | 47 ++++++------ 8 files changed, 224 insertions(+), 32 deletions(-) create mode 100644 public/lib/prompt.js diff --git a/library.js b/library.js index 752d5b8..9127b1a 100644 --- a/library.js +++ b/library.js @@ -39,9 +39,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; @@ -105,8 +108,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)); @@ -117,7 +120,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/plugin.json b/plugin.json index 713949e..ccb947a 100644 --- a/plugin.json +++ b/plugin.json @@ -14,7 +14,8 @@ ], "languages": "public/languages", "scripts": [ - "public/lib/main.js" + "public/lib/main.js", + "public/lib/prompt.js" ], "modules": { "../client/account/web-push.js": "./public/lib/settings.js", diff --git a/public/languages/en-GB/web-push.json b/public/languages/en-GB/web-push.json index bc50371..8945797 100644 --- a/public/languages/en-GB/web-push.json +++ b/public/languages/en-GB/web-push.json @@ -7,6 +7,7 @@ "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.", @@ -14,5 +15,29 @@ "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" -} \ No newline at end of file + "action.view-notifications": "View notifications", + + "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", + + "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.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 8670287..820e37e 100644 --- a/public/languages/he/web-push.json +++ b/public/languages/he/web-push.json @@ -7,6 +7,7 @@ "profile.send-test": "שליחת התראת בדיקה", "toast.test_success": "התראת הבדיקה נשלחה.", + "toast.subscribe_success": "התראות דחיפה הופעלו במכשיר זה.", "toast.test_unavailable": "לא ניתן לשלוח התראת בדיקה כי התראות דחיפה אינן מופעלות במכשיר זה.", "toast.permission_denied": "הרשאת ההתראות נדחתה. יש לאפשר התראות עבור אתר זה בהגדרות הדפדפן.", "toast.subscribe_failed": "לא ניתן להפעיל התראות דחיפה במכשיר זה. נסו שוב.", @@ -14,5 +15,29 @@ "toast.sw_not_registered": "התראות דחיפה אינן זמינות: שירות הרקע לא נרשם. באייפון, יש להתקין את האתר תחילה למסך הבית.", "action.mark-read": "סמן כנקרא", - "action.view-notifications": "צפה בהתראות" + "action.view-notifications": "צפה בהתראות", + + "prompt.title": "הישארו מעודכנים", + "prompt.body": "הפעילו התראות דחיפה וקבלו עדכונים גם כשאתם לא באתר.", + "prompt.confirm": "הפעלת התראות", + "prompt.dismiss": "לא תודה", + + "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.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 ef4c9d4..6ff19b6 100644 --- a/public/languages/zh-CN/web-push.json +++ b/public/languages/zh-CN/web-push.json @@ -7,6 +7,7 @@ "profile.send-test": "发送测试通知", "toast.test_success": "测试通知已发送。", + "toast.subscribe_success": "已在此设备上启用推送通知。", "toast.test_unavailable": "由于此设备未启用推送通知,因此无法发送测试通知。", "toast.permission_denied": "通知权限已被拒绝。请在浏览器设置中允许此站点发送通知。", "toast.subscribe_failed": "无法在此设备上启用推送通知。请重试。", @@ -14,5 +15,29 @@ "toast.sw_not_registered": "推送通知不可用:后台服务未注册。在 iOS 上,请先将本站添加到主屏幕。", "action.mark-read": "标记为已读", - "action.view-notifications": "查看通知" -} \ No newline at end of file + "action.view-notifications": "查看通知", + + "prompt.title": "保持最新动态", + "prompt.body": "启用推送通知,即使不在网站时也能及时收到提醒。", + "prompt.confirm": "启用通知", + "prompt.dismiss": "不,谢谢", + + "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.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..c7089cb --- /dev/null +++ b/public/lib/prompt.js @@ -0,0 +1,109 @@ +'use strict'; + +// Invites logged-in users to enable push notifications via a corner banner, +// shown after a configurable number of page loads (see ACP settings). +// Visit count and dismissal are tracked per-device in localStorage. + +(async () => { + const [hooks, api, translator, alerts] = await app.require(['hooks', 'api', 'translator', 'alerts']); + + 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 || localStorage.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(localStorage.getItem(visitsKey), 10) || 0) + 1; + localStorage.setItem(visitsKey, visits); + if (visits < promptDelay) { + return; + } + + showBanner(registration); + }); + + async function showBanner(registration) { + // z-index keeps the banner below bootstrap modals (1055) + const html = await translator.translate(` + + + [[web-push:prompt.title]] + [[web-push:prompt.body]] + + [[web-push:prompt.dismiss]] + [[web-push:prompt.confirm]] + + + + `); + + const wrapper = document.createElement('div'); + wrapper.innerHTML = html; + const banner = wrapper.firstElementChild; + 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); + } + + localStorage.setItem(dismissedKey, '1'); + banner.remove(); + }); + } + + async function subscribe(registration) { + try { + // As in settings.js: when permission is already granted there is no + // await before subscribe(), keeping the call within the click's user + // activation (iOS Safari requires this). + 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) { + console.error('[web-push] subscribing from prompt failed:', err); + alerts.warning('[[web-push:toast.subscribe_failed]]'); + } + } + + // Chrome doesn't accept a base64 string for applicationServerKey + // 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/public/lib/settings.js b/public/lib/settings.js index 72db1fb..0b44b48 100644 --- a/public/lib/settings.js +++ b/public/lib/settings.js @@ -85,6 +85,7 @@ export async function init() { }); await post('/plugins/web-push/subscription', { subscription: subscription.toJSON() }); + success('[[web-push:toast.subscribe_success]]'); // Update count let count = parseInt(countEl.textContent, 10); diff --git a/templates/admin/plugins/web-push.tpl b/templates/admin/plugins/web-push.tpl index e7fa8f2..f7ff3c3 100644 --- a/templates/admin/plugins/web-push.tpl +++ b/templates/admin/plugins/web-push.tpl @@ -5,44 +5,47 @@ - Settings + [[web-push:admin.settings]] - Maximum length - - - 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]] + + [[web-push:admin.max-length-help]] - Badge URL - - - 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]] + + [[web-push:admin.badge-help]] - Icon URL - - - 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]] + + [[web-push:admin.icon-help]] + + + + + [[web-push:admin.prompt-enabled]] + [[web-push:admin.prompt-enabled-help]] + + + + [[web-push:admin.prompt-delay]] + + [[web-push:admin.prompt-delay-help]] - Users + [[web-push:admin.users]] - User - Devices + [[web-push:admin.user]] + [[web-push:admin.devices]] From 9e491823e49f4defbfa1f06cf790783f08d4c8f5 Mon Sep 17 00:00:00 2001 From: ClickAndGoScript Date: Thu, 11 Jun 2026 23:09:20 +0300 Subject: [PATCH 14/15] =?UTF-8?q?chore:=20=D7=94=D7=A1=D7=A8=D7=AA=20?= =?UTF-8?q?=D7=9E=D7=A0=D7=92=D7=A0=D7=95=D7=9F=20=D7=94-Topic=20header=20?= =?UTF-8?q?=D7=9C=D7=94=D7=97=D7=9C=D7=A4=D7=AA=20=D7=94=D7=AA=D7=A8=D7=90?= =?UTF-8?q?=D7=95=D7=AA=20=D7=91=D7=A1=D7=A4=D7=90=D7=A8=D7=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit המנגנון נועד לגרום להתראה חדשה להחליף את הקודמת בספארי/iOS (דרך מיפוי ל-apns-collapse-id), אך בפועל אינו עובד. ההחלפה בכרום ובפיירפוקס ממשיכה לפעול דרך ה-tag שבהתראה עצמה, ומנגנון ה-rescind אינו מושפע. --- library.js | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/library.js b/library.js index 9127b1a..0876aa9 100644 --- a/library.js +++ b/library.js @@ -1,6 +1,5 @@ 'use strict'; -const crypto = require('crypto'); const webPush = require('web-push'); const validator = require('validator'); @@ -146,7 +145,7 @@ plugin.onNotificationPush = async ({ notification, uidsNotified: uids }) => { const targets = subs.get(uid); targets.forEach(async (subscription) => { try { - await webPush.sendNotification(subscription, JSON.stringify(payload), pushOptions(payload.tag)); + await webPush.sendNotification(subscription, JSON.stringify(payload)); } catch (e) { // Errored — remove subscription from user winston.info(`[plugins/web-push] Push failed: ${e.code}; ${e.message}; statusCode: ${e.statusCode}`); @@ -172,7 +171,7 @@ plugin.onNotificationRescind = async ({ nids }) => { if (subs.size) { await Promise.all(Array.from(subs).map(async (subscription) => { try { - await webPush.sendNotification(subscription, JSON.stringify({ tag }), pushOptions(tag)); + await webPush.sendNotification(subscription, JSON.stringify({ tag })); } catch (e) { winston.info(`[plugins/web-push] Push failed: ${e.code}; ${e.message}; statusCode: ${e.statusCode}`); } @@ -200,21 +199,6 @@ plugin.addProfileItem = (data) => { return data; }; -// The Web Push protocol "Topic" header makes a new push replace a previous one -// with the same topic. Apple's push service maps it to apns-collapse-id, so on -// Safari/iOS — which ignore the notification 'tag' — a new notification still -// replaces the one already displayed. Topics are limited to 32 base64url -// characters, so hash the tag down to size. -function pushOptions(tag) { - if (!tag) { - return {}; - } - - return { - topic: crypto.createHash('sha256').update(String(tag)).digest('base64url').slice(0, 32), - }; -} - async function constructPayload(notification, uid, lang) { let { maxLength, icon, badge } = await meta.settings.get('web-push'); maxLength = parseInt(maxLength, 10) || 256; From e676d3f5a53a6cd974847d916ed9fd034982d606 Mon Sep 17 00:00:00 2001 From: ClickAndGoScript Date: Fri, 12 Jun 2026 15:31:20 +0300 Subject: [PATCH 15/15] =?UTF-8?q?refactor:=20=D7=A9=D7=99=D7=9E=D7=95?= =?UTF-8?q?=D7=A9=20=D7=91=D7=AA=D7=A9=D7=AA=D7=99=D7=95=D7=AA=20=D7=94?= =?UTF-8?q?=D7=9E=D7=95=D7=91=D7=A0=D7=95=D7=AA=20=D7=A9=D7=9C=20NodeBB=20?= =?UTF-8?q?=D7=91=D7=A7=D7=95=D7=93=20=D7=94=D7=9C=D7=A7=D7=95=D7=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - מודול storage במקום גישה ישירה ל-localStorage בבאנר ההרשמה, כולל fallback מובנה כשהאחסון חסום בדפדפן - תבנית partials/web-push/prompt.tpl המרונדרת עם app.parseAndTranslate במקום בניית HTML כמחרוזת בתוך הקוד - זיהוי ספארי לפי config.useragent.isSafari - אותו דגל שה-core בודק כשהוא מדלג על רישום ה-Service Worker - במקום regex ידני על ה-UA --- public/lib/main.js | 10 +++------ public/lib/prompt.js | 29 +++++++------------------- templates/partials/web-push/prompt.tpl | 10 +++++++++ 3 files changed, 20 insertions(+), 29 deletions(-) create mode 100644 templates/partials/web-push/prompt.tpl diff --git a/public/lib/main.js b/public/lib/main.js index 26519c1..d403994 100644 --- a/public/lib/main.js +++ b/public/lib/main.js @@ -13,13 +13,9 @@ return; } - const ua = navigator.userAgent; - const isSafari = /^((?!chrome|android).)*safari/i.test(ua); - const isIOS = /iPad|iPhone|iPod/.test(ua) || - (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); - - if (!isSafari && !isIOS) { - // Core handles registration on non-Safari browsers. + // Mirror core's own condition (config.useragent.isSafari) so the two + // checks can never disagree: core skips exactly when we register. + if (!config.useragent || !config.useragent.isSafari) { return; } diff --git a/public/lib/prompt.js b/public/lib/prompt.js index c7089cb..9c94603 100644 --- a/public/lib/prompt.js +++ b/public/lib/prompt.js @@ -5,14 +5,14 @@ // Visit count and dismissal are tracked per-device in localStorage. (async () => { - const [hooks, api, translator, alerts] = await app.require(['hooks', 'api', 'translator', 'alerts']); + 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 || localStorage.getItem(dismissedKey)) { + if (!promptEnabled || !vapidKey || !app.user.uid || storage.getItem(dismissedKey)) { return; } @@ -26,8 +26,8 @@ return; } - const visits = (parseInt(localStorage.getItem(visitsKey), 10) || 0) + 1; - localStorage.setItem(visitsKey, visits); + const visits = (parseInt(storage.getItem(visitsKey), 10) || 0) + 1; + storage.setItem(visitsKey, visits); if (visits < promptDelay) { return; } @@ -36,23 +36,8 @@ }); async function showBanner(registration) { - // z-index keeps the banner below bootstrap modals (1055) - const html = await translator.translate(` - - - [[web-push:prompt.title]] - [[web-push:prompt.body]] - - [[web-push:prompt.dismiss]] - [[web-push:prompt.confirm]] - - - - `); - - const wrapper = document.createElement('div'); - wrapper.innerHTML = html; - const banner = wrapper.firstElementChild; + const $banner = await app.parseAndTranslate('partials/web-push/prompt', {}); + const banner = $banner.get(0); document.body.append(banner); banner.addEventListener('click', (e) => { @@ -65,7 +50,7 @@ subscribe(registration); } - localStorage.setItem(dismissedKey, '1'); + storage.setItem(dismissedKey, '1'); banner.remove(); }); } 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]] + + [[web-push:prompt.dismiss]] + [[web-push:prompt.confirm]] + + +
[[web-push:prompt.body]]
- 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]]
[[web-push:admin.prompt-enabled-help]]
[[web-push:admin.prompt-delay-help]]