@modernman00/shared-js-lib 1.2.90 → 1.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AddressAutocomplete.js +164 -15
- package/AiBillSplitter.js +230 -0
- package/AiEntertainmentPrompt.js +130 -0
- package/AiFlyerStudio.js +215 -0
- package/AiMediaGuard.js +95 -0
- package/AiPrivacyGuard.js +63 -0
- package/AiSemanticSearch.js +107 -0
- package/AiVoiceRecorder.js +196 -0
- package/CanvasPhysicsEngine.js +568 -0
- package/FileUpload.js +182 -1
- package/HyperReality3DEngine.js +404 -0
- package/Loader.js +6 -2
- package/PwaInstall.js +20 -2
- package/README.md +78 -0
- package/SovereignBroadcasterClient.js +325 -0
- package/VisualSupremacyRuntime.js +312 -0
- package/WebAuthnClient.js +346 -1
- package/WebPushClient.js +308 -0
- package/axiosWrapper.js +1 -0
- package/index.js +13 -1
- package/package.json +13 -1
- package/templates/sw.template.js +105 -57
package/WebPushClient.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @modernman00/shared-js-lib — WebPushClient
|
|
3
|
+
*
|
|
4
|
+
* Enterprise Web Push & Service Worker Lifecycle Engine (RFC-0009):
|
|
5
|
+
* - Bounded Watchdog Promises (8s SW ready, 20s permission prompt, 10s PushManager subscribe, 8s backend sync)
|
|
6
|
+
* - Automatic VAPID Key Rotation Recovery (catches InvalidStateError, unsubscribes stale endpoint, retries once)
|
|
7
|
+
* - Smart 3-Day (72-Hour) Dismissal Cooldown (re-prompts unsubscribed users after 72h instead of permanent suppression)
|
|
8
|
+
* - Cross-Tab BroadcastChannel Deduplication ('fp_notification_sync')
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_DISMISS_COOLDOWN_MS = 3 * 24 * 60 * 60 * 1000; // 72 hours (3 days)
|
|
12
|
+
export const DEFAULT_DISMISS_KEY = 'fp_push_banner_dismissed';
|
|
13
|
+
export const DEFAULT_SUBSCRIBED_KEY = 'fp_push_subscribed';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Wraps any Promise or thenable with a deterministic watchdog timer so UI spinners never hang indefinitely.
|
|
17
|
+
*
|
|
18
|
+
* @param {Promise<any>} promise
|
|
19
|
+
* @param {number} ms
|
|
20
|
+
* @param {string} [timeoutMessage]
|
|
21
|
+
* @returns {Promise<any>}
|
|
22
|
+
*/
|
|
23
|
+
export function withPushTimeout(promise, ms, timeoutMessage = 'Push operation timed out') {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const timer = setTimeout(() => {
|
|
26
|
+
reject(new Error(timeoutMessage));
|
|
27
|
+
}, ms);
|
|
28
|
+
Promise.resolve(promise).then(
|
|
29
|
+
(val) => {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
resolve(val);
|
|
32
|
+
},
|
|
33
|
+
(err) => {
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
reject(err);
|
|
36
|
+
}
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Converts a URL-safe base64 VAPID public key string into a Uint8Array for PushManager.subscribe().
|
|
43
|
+
*
|
|
44
|
+
* @param {string} base64String
|
|
45
|
+
* @returns {Uint8Array}
|
|
46
|
+
*/
|
|
47
|
+
export function urlBase64ToUint8Array(base64String) {
|
|
48
|
+
const clean = String(base64String || '').trim();
|
|
49
|
+
const padding = '='.repeat((4 - (clean.length % 4)) % 4);
|
|
50
|
+
const base64 = (clean + padding).replace(/-/g, '+').replace(/_/g, '/');
|
|
51
|
+
const rawData = typeof window !== 'undefined' && typeof window.atob === 'function'
|
|
52
|
+
? window.atob(base64)
|
|
53
|
+
: Buffer.from(base64, 'base64').toString('binary');
|
|
54
|
+
const outputArray = new Uint8Array(rawData.length);
|
|
55
|
+
for (let i = 0; i < rawData.length; ++i) {
|
|
56
|
+
outputArray[i] = rawData.charCodeAt(i);
|
|
57
|
+
}
|
|
58
|
+
return outputArray;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Checks whether the user dismissed the push prompt within the 3-day (72-hour) cooldown window.
|
|
63
|
+
* Automatically migrates legacy boolean `'true'` values to a timestamp and clears expired timestamps.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} [storageKey=DEFAULT_DISMISS_KEY]
|
|
66
|
+
* @param {number} [cooldownMs=DEFAULT_DISMISS_COOLDOWN_MS]
|
|
67
|
+
* @param {Storage} [storage]
|
|
68
|
+
* @returns {boolean} True if dismissed within the cooldown window; false if eligible to prompt again.
|
|
69
|
+
*/
|
|
70
|
+
export function isPushDismissedWithinCooldown(
|
|
71
|
+
storageKey = DEFAULT_DISMISS_KEY,
|
|
72
|
+
cooldownMs = DEFAULT_DISMISS_COOLDOWN_MS,
|
|
73
|
+
storage = typeof localStorage !== 'undefined' ? localStorage : null
|
|
74
|
+
) {
|
|
75
|
+
if (!storage) return false;
|
|
76
|
+
try {
|
|
77
|
+
const raw = storage.getItem(storageKey);
|
|
78
|
+
if (!raw) return false;
|
|
79
|
+
|
|
80
|
+
// Legacy migration: if 'true' was stored before RFC-0009, convert to current timestamp
|
|
81
|
+
if (raw === 'true') {
|
|
82
|
+
storage.setItem(storageKey, String(Date.now()));
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const dismissedAt = parseInt(raw, 10);
|
|
87
|
+
if (Number.isNaN(dismissedAt) || dismissedAt <= 0) {
|
|
88
|
+
storage.removeItem(storageKey);
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (Date.now() - dismissedAt < cooldownMs) {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Cooldown expired (>= 3 days): clear key so user is re-prompted
|
|
97
|
+
storage.removeItem(storageKey);
|
|
98
|
+
return false;
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Records the current timestamp when a user clicks "Not Now" or closes the push prompt.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} [storageKey=DEFAULT_DISMISS_KEY]
|
|
108
|
+
* @param {Storage} [storage]
|
|
109
|
+
*/
|
|
110
|
+
export function recordPushDismissal(
|
|
111
|
+
storageKey = DEFAULT_DISMISS_KEY,
|
|
112
|
+
storage = typeof localStorage !== 'undefined' ? localStorage : null
|
|
113
|
+
) {
|
|
114
|
+
if (!storage) return;
|
|
115
|
+
try {
|
|
116
|
+
storage.setItem(storageKey, String(Date.now()));
|
|
117
|
+
} catch {
|
|
118
|
+
// Ignore storage quota errors
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Clears any recorded push dismissal timestamp (e.g., once the user enables notifications).
|
|
124
|
+
*
|
|
125
|
+
* @param {string} [storageKey=DEFAULT_DISMISS_KEY]
|
|
126
|
+
* @param {Storage} [storage]
|
|
127
|
+
*/
|
|
128
|
+
export function clearPushDismissal(
|
|
129
|
+
storageKey = DEFAULT_DISMISS_KEY,
|
|
130
|
+
storage = typeof localStorage !== 'undefined' ? localStorage : null
|
|
131
|
+
) {
|
|
132
|
+
if (!storage) return;
|
|
133
|
+
try {
|
|
134
|
+
storage.removeItem(storageKey);
|
|
135
|
+
} catch {
|
|
136
|
+
// Ignore storage errors
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Ensures the Service Worker is registered and active within a bounded timeout (default 8s),
|
|
142
|
+
* preventing `navigator.serviceWorker.ready` from hanging forever when registration fails.
|
|
143
|
+
*
|
|
144
|
+
* @param {object} [options]
|
|
145
|
+
* @param {string} [options.swUrl='/sw.js']
|
|
146
|
+
* @param {string} [options.scope='/']
|
|
147
|
+
* @param {number} [options.timeoutMs=8000]
|
|
148
|
+
* @returns {Promise<ServiceWorkerRegistration>}
|
|
149
|
+
*/
|
|
150
|
+
export async function ensureServiceWorkerReady(options = {}) {
|
|
151
|
+
const swUrl = options.swUrl || '/sw.js';
|
|
152
|
+
const scope = options.scope || '/';
|
|
153
|
+
const timeoutMs = options.timeoutMs || 8000;
|
|
154
|
+
|
|
155
|
+
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
|
|
156
|
+
throw new Error('Service Workers are not supported in this browser.');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let reg = await navigator.serviceWorker.getRegistration(scope);
|
|
160
|
+
if (!reg) {
|
|
161
|
+
reg = await navigator.serviceWorker.register(swUrl, { scope });
|
|
162
|
+
}
|
|
163
|
+
if (reg && reg.active) {
|
|
164
|
+
return reg;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return withPushTimeout(
|
|
168
|
+
navigator.serviceWorker.ready,
|
|
169
|
+
timeoutMs,
|
|
170
|
+
'Service Worker activation timed out. Please reload the page and try again.'
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Subscribes the browser to Web Push via PushManager with automatic InvalidStateError
|
|
176
|
+
* VAPID key rotation recovery and bounded timeouts.
|
|
177
|
+
*
|
|
178
|
+
* @param {object} options
|
|
179
|
+
* @param {string} options.vapidPublicKey
|
|
180
|
+
* @param {(subscription: PushSubscription) => Promise<any>} [options.syncToServer]
|
|
181
|
+
* @param {string} [options.swUrl='/sw.js']
|
|
182
|
+
* @param {string} [options.scope='/']
|
|
183
|
+
* @param {number} [options.swTimeoutMs=8000]
|
|
184
|
+
* @param {number} [options.subscribeTimeoutMs=10000]
|
|
185
|
+
* @param {number} [options.syncTimeoutMs=8000]
|
|
186
|
+
* @returns {Promise<PushSubscription>}
|
|
187
|
+
*/
|
|
188
|
+
export async function subscribeWithKeyRotation(options = {}) {
|
|
189
|
+
const {
|
|
190
|
+
vapidPublicKey,
|
|
191
|
+
syncToServer,
|
|
192
|
+
swUrl = '/sw.js',
|
|
193
|
+
scope = '/',
|
|
194
|
+
swTimeoutMs = 8000,
|
|
195
|
+
subscribeTimeoutMs = 10000,
|
|
196
|
+
syncTimeoutMs = 8000,
|
|
197
|
+
} = options;
|
|
198
|
+
|
|
199
|
+
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator) || typeof window === 'undefined' || !('PushManager' in window)) {
|
|
200
|
+
throw new Error('Push notifications are not supported in this browser.');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!vapidPublicKey || String(vapidPublicKey).trim() === '') {
|
|
204
|
+
throw new Error('VAPID public key is not configured.');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const registration = await ensureServiceWorkerReady({ swUrl, scope, timeoutMs: swTimeoutMs });
|
|
208
|
+
let subscription = await registration.pushManager.getSubscription();
|
|
209
|
+
|
|
210
|
+
if (!subscription) {
|
|
211
|
+
const appServerKey = urlBase64ToUint8Array(vapidPublicKey);
|
|
212
|
+
const attemptSubscribe = async () => {
|
|
213
|
+
try {
|
|
214
|
+
return await registration.pushManager.subscribe({
|
|
215
|
+
userVisibleOnly: true,
|
|
216
|
+
applicationServerKey: appServerKey,
|
|
217
|
+
});
|
|
218
|
+
} catch (subErr) {
|
|
219
|
+
if (subErr && subErr.name === 'InvalidStateError') {
|
|
220
|
+
const staleSub = await registration.pushManager.getSubscription();
|
|
221
|
+
if (staleSub) {
|
|
222
|
+
await staleSub.unsubscribe();
|
|
223
|
+
}
|
|
224
|
+
return await registration.pushManager.subscribe({
|
|
225
|
+
userVisibleOnly: true,
|
|
226
|
+
applicationServerKey: appServerKey,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
throw subErr;
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
subscription = await withPushTimeout(
|
|
234
|
+
attemptSubscribe(),
|
|
235
|
+
subscribeTimeoutMs,
|
|
236
|
+
'Push service connection timed out. Check your internet connection or browser push settings.'
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (typeof syncToServer === 'function') {
|
|
241
|
+
await withPushTimeout(
|
|
242
|
+
syncToServer(subscription),
|
|
243
|
+
syncTimeoutMs,
|
|
244
|
+
'Server subscription sync timed out.'
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return subscription;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Cross-tab BroadcastChannel coordinator so multiple open tabs share read-state and badge updates
|
|
253
|
+
* without duplicate network polling.
|
|
254
|
+
*
|
|
255
|
+
* @param {(payload: any) => void} onMessage
|
|
256
|
+
* @param {string} [channelName='fp_notification_sync']
|
|
257
|
+
* @returns {{ broadcast: (data: any) => void, close: () => void }}
|
|
258
|
+
*/
|
|
259
|
+
export function createNotificationSyncChannel(onMessage, channelName = 'fp_notification_sync') {
|
|
260
|
+
if (typeof window === 'undefined' || typeof window.BroadcastChannel !== 'function') {
|
|
261
|
+
return {
|
|
262
|
+
broadcast: () => {},
|
|
263
|
+
close: () => {},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const channel = new window.BroadcastChannel(channelName);
|
|
268
|
+
if (typeof onMessage === 'function') {
|
|
269
|
+
channel.onmessage = (event) => {
|
|
270
|
+
if (event && event.data) {
|
|
271
|
+
onMessage(event.data);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
broadcast: (data) => {
|
|
278
|
+
try {
|
|
279
|
+
channel.postMessage(data);
|
|
280
|
+
} catch {
|
|
281
|
+
// Ignore closed channel errors
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
close: () => {
|
|
285
|
+
try {
|
|
286
|
+
channel.close();
|
|
287
|
+
} catch {
|
|
288
|
+
// Ignore
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export const WebPushClient = {
|
|
295
|
+
DEFAULT_DISMISS_COOLDOWN_MS,
|
|
296
|
+
DEFAULT_DISMISS_KEY,
|
|
297
|
+
DEFAULT_SUBSCRIBED_KEY,
|
|
298
|
+
withPushTimeout,
|
|
299
|
+
urlBase64ToUint8Array,
|
|
300
|
+
isPushDismissedWithinCooldown,
|
|
301
|
+
recordPushDismissal,
|
|
302
|
+
clearPushDismissal,
|
|
303
|
+
ensureServiceWorkerReady,
|
|
304
|
+
subscribeWithKeyRotation,
|
|
305
|
+
createNotificationSyncChannel,
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
export default WebPushClient;
|
package/axiosWrapper.js
CHANGED
package/index.js
CHANGED
|
@@ -32,4 +32,16 @@ export * from './ShareManager.js';
|
|
|
32
32
|
export * from './WebAuthnClient.js';
|
|
33
33
|
export * from './OtpInputManager.js';
|
|
34
34
|
export * from './AddressAutocomplete.js';
|
|
35
|
-
export * from './DigitalSignaturePad.js';
|
|
35
|
+
export * from './DigitalSignaturePad.js';
|
|
36
|
+
export * from './AiVoiceRecorder.js';
|
|
37
|
+
export * from './AiMediaGuard.js';
|
|
38
|
+
export * from './AiSemanticSearch.js';
|
|
39
|
+
export * from './AiFlyerStudio.js';
|
|
40
|
+
export * from './AiBillSplitter.js';
|
|
41
|
+
export * from './AiEntertainmentPrompt.js';
|
|
42
|
+
export * from './AiPrivacyGuard.js';
|
|
43
|
+
export * from './WebPushClient.js';
|
|
44
|
+
export * from './FileUpload.js';
|
|
45
|
+
export * from './CanvasPhysicsEngine.js';
|
|
46
|
+
export * from './SovereignBroadcasterClient.js';
|
|
47
|
+
export * from './HyperReality3DEngine.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modernman00/shared-js-lib",
|
|
3
|
-
"version": "1.2
|
|
3
|
+
"version": "1.6.2",
|
|
4
4
|
"description": "Reusable JS utilities for numerous js problems",
|
|
5
5
|
"homepage": "https://github.com/modernman00/shared-js-lib#readme",
|
|
6
6
|
"keywords": [
|
|
@@ -66,6 +66,18 @@
|
|
|
66
66
|
"OtpInputManager.js",
|
|
67
67
|
"AddressAutocomplete.js",
|
|
68
68
|
"DigitalSignaturePad.js",
|
|
69
|
+
"AiVoiceRecorder.js",
|
|
70
|
+
"AiMediaGuard.js",
|
|
71
|
+
"AiSemanticSearch.js",
|
|
72
|
+
"AiFlyerStudio.js",
|
|
73
|
+
"AiBillSplitter.js",
|
|
74
|
+
"AiEntertainmentPrompt.js",
|
|
75
|
+
"AiPrivacyGuard.js",
|
|
76
|
+
"WebPushClient.js",
|
|
77
|
+
"CanvasPhysicsEngine.js",
|
|
78
|
+
"HyperReality3DEngine.js",
|
|
79
|
+
"SovereignBroadcasterClient.js",
|
|
80
|
+
"VisualSupremacyRuntime.js",
|
|
69
81
|
"templates/"
|
|
70
82
|
],
|
|
71
83
|
"devDependencies": {
|
package/templates/sw.template.js
CHANGED
|
@@ -138,7 +138,7 @@ if (typeof workbox !== 'undefined') {
|
|
|
138
138
|
]);
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
// Push Notifications & Badging Engine
|
|
141
|
+
// Push Notifications & Badging Engine — Meta/Facebook Tier-1 Standard
|
|
142
142
|
self.addEventListener('push', (event) => {
|
|
143
143
|
let data = {
|
|
144
144
|
title: APP_NAME,
|
|
@@ -157,83 +157,131 @@ self.addEventListener('push', (event) => {
|
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
data: { url: data.url, id: data.targetNotificationId },
|
|
169
|
-
tag: data.tag
|
|
170
|
-
})
|
|
171
|
-
);
|
|
172
|
-
|
|
173
|
-
// 2. Web Badging API Sync (Phone App Icon Red Counter)
|
|
174
|
-
if ('setAppBadge' in navigator) {
|
|
175
|
-
let count = null;
|
|
176
|
-
if (typeof data.badgeCount === 'number') {
|
|
177
|
-
count = Math.max(0, Math.floor(data.badgeCount));
|
|
178
|
-
} else if (data.badgeCount !== null && data.badgeCount !== undefined && !isNaN(parseInt(data.badgeCount, 10))) {
|
|
179
|
-
count = Math.max(0, parseInt(data.badgeCount, 10));
|
|
160
|
+
event.waitUntil((async () => {
|
|
161
|
+
// 1. Web Badging API Sync (App Icon Counter on Home Screen / Dock)
|
|
162
|
+
let badgeCount = null;
|
|
163
|
+
if (typeof data.unread_count === 'number' || typeof data.unreadCount === 'number' || typeof data.badgeCount === 'number') {
|
|
164
|
+
badgeCount = Math.max(0, Math.floor(data.unread_count ?? data.unreadCount ?? data.badgeCount));
|
|
165
|
+
} else if (data.unread_count || data.unreadCount || data.badgeCount) {
|
|
166
|
+
const parsed = parseInt(data.unread_count || data.unreadCount || data.badgeCount, 10);
|
|
167
|
+
if (!isNaN(parsed)) badgeCount = Math.max(0, parsed);
|
|
180
168
|
}
|
|
181
169
|
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
170
|
+
if ('setAppBadge' in navigator) {
|
|
171
|
+
try {
|
|
172
|
+
if (badgeCount !== null) {
|
|
173
|
+
if (badgeCount > 0) {
|
|
174
|
+
await navigator.setAppBadge(badgeCount);
|
|
175
|
+
} else {
|
|
176
|
+
await navigator.clearAppBadge();
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
await navigator.setAppBadge(1);
|
|
180
|
+
}
|
|
181
|
+
} catch (err) {}
|
|
191
182
|
}
|
|
192
|
-
}
|
|
193
183
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
184
|
+
// 2. Client-Focus Awareness (Meta Standard: Suppress system banner when app is actively focused)
|
|
185
|
+
const clientList = await clients.matchAll({ type: 'window', includeUncontrolled: true });
|
|
186
|
+
const activeFocusedClient = clientList.find(c => c.focused && 'postMessage' in c);
|
|
187
|
+
|
|
188
|
+
if (activeFocusedClient) {
|
|
189
|
+
// User is actively looking at the screen: broadcast to DOM toast, update tab badge, and SUPPRESS OS banner
|
|
197
190
|
clientList.forEach((client) => {
|
|
198
191
|
client.postMessage({
|
|
199
192
|
type: 'ON_APP_NOTIFICATION',
|
|
200
|
-
payload: data
|
|
193
|
+
payload: { ...data, badgeCount: badgeCount }
|
|
201
194
|
});
|
|
202
195
|
});
|
|
203
|
-
|
|
204
|
-
|
|
196
|
+
return; // Suppress OS lock-screen popup to eliminate double-alert noise
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Broadcast to any backgrounded open windows so their in-DOM tabs update
|
|
200
|
+
clientList.forEach((client) => {
|
|
201
|
+
client.postMessage({
|
|
202
|
+
type: 'ON_APP_NOTIFICATION',
|
|
203
|
+
payload: { ...data, badgeCount: badgeCount }
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// 3. User is backgrounded or app is closed: Display Rich System Notification Banner
|
|
208
|
+
const options = {
|
|
209
|
+
body: data.body,
|
|
210
|
+
icon: data.icon || '/public/resources/assets/images/pwa/icons/icon-192x192.png',
|
|
211
|
+
badge: data.badge || '/public/resources/assets/images/pwa/icons/icon-72x72.png',
|
|
212
|
+
tag: data.tag || `${APP_NAME}-notification`,
|
|
213
|
+
vibrate: data.vibrate || [150, 80, 150],
|
|
214
|
+
renotify: data.renotify !== false,
|
|
215
|
+
data: {
|
|
216
|
+
url: data.url || '/',
|
|
217
|
+
id: data.targetNotificationId || null,
|
|
218
|
+
unreadCount: badgeCount
|
|
219
|
+
}
|
|
220
|
+
};
|
|
205
221
|
|
|
206
|
-
|
|
222
|
+
if (data.image) {
|
|
223
|
+
options.image = data.image;
|
|
224
|
+
}
|
|
225
|
+
if (Array.isArray(data.actions) && data.actions.length > 0) {
|
|
226
|
+
options.actions = data.actions.slice(0, 2);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return self.registration.showNotification(data.title, options);
|
|
230
|
+
})());
|
|
207
231
|
});
|
|
208
232
|
|
|
209
|
-
// Notification Click & Deep-Linking
|
|
233
|
+
// Notification Click & Deep-Linking — Meta Standard
|
|
210
234
|
self.addEventListener('notificationclick', (event) => {
|
|
211
235
|
event.notification.close();
|
|
212
|
-
const
|
|
236
|
+
const notifData = event.notification.data || {};
|
|
237
|
+
let targetUrl = notifData.url || '/';
|
|
213
238
|
|
|
214
|
-
|
|
239
|
+
// Sanitize targetUrl: must be relative path or same origin (Red Team anti-open-redirect)
|
|
240
|
+
try {
|
|
241
|
+
const parsed = new URL(targetUrl, self.location.origin);
|
|
242
|
+
if (parsed.origin !== self.location.origin) {
|
|
243
|
+
targetUrl = '/';
|
|
244
|
+
} else {
|
|
245
|
+
targetUrl = parsed.pathname + parsed.search + parsed.hash;
|
|
246
|
+
}
|
|
247
|
+
} catch (e) {
|
|
248
|
+
targetUrl = '/';
|
|
249
|
+
}
|
|
215
250
|
|
|
216
|
-
|
|
217
|
-
|
|
251
|
+
// Handle action button clicks (e.g. "mark_read" or "dismiss")
|
|
252
|
+
if (event.action === 'mark_read' || event.action === 'read') {
|
|
253
|
+
if (notifData.id) {
|
|
254
|
+
event.waitUntil(
|
|
255
|
+
fetch('/api/notifications/read', {
|
|
256
|
+
method: 'PUT',
|
|
257
|
+
headers: { 'Content-Type': 'application/json' },
|
|
258
|
+
body: JSON.stringify({ id: notifData.id })
|
|
259
|
+
}).catch(() => {})
|
|
260
|
+
);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
218
263
|
}
|
|
219
264
|
|
|
220
|
-
|
|
221
|
-
clients.matchAll({ type: 'window', includeUncontrolled: true })
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
265
|
+
event.waitUntil((async () => {
|
|
266
|
+
const list = await clients.matchAll({ type: 'window', includeUncontrolled: true });
|
|
267
|
+
for (const client of list) {
|
|
268
|
+
if (client.url.includes(targetUrl) && 'focus' in client) {
|
|
269
|
+
if ('navigate' in client) await client.navigate(targetUrl);
|
|
270
|
+
return client.focus();
|
|
227
271
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
272
|
+
}
|
|
273
|
+
if (clients.openWindow) {
|
|
274
|
+
return clients.openWindow(targetUrl);
|
|
275
|
+
}
|
|
276
|
+
})());
|
|
233
277
|
});
|
|
234
278
|
|
|
235
279
|
// Cross-Tab Client Messages
|
|
236
280
|
self.addEventListener('message', (event) => {
|
|
237
|
-
if (event.data?.action === 'skipWaiting'
|
|
238
|
-
|
|
281
|
+
if (event.data?.action === 'skipWaiting' || event.data?.type === 'SKIP_WAITING') {
|
|
282
|
+
self.skipWaiting();
|
|
283
|
+
}
|
|
284
|
+
if (event.data?.action === 'clearUserCache') {
|
|
285
|
+
event.waitUntil(caches.delete(API_CACHE));
|
|
286
|
+
}
|
|
239
287
|
});
|