@phoenix-iq/notifications 0.0.0-stage → 1.1.0

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/dist/index.js ADDED
@@ -0,0 +1,501 @@
1
+ import { mountBanner } from './banner.js';
2
+ import { mountBell } from './bell.js';
3
+ import { keys, store } from './store.js';
4
+ /**
5
+ * Phoenix Notifications web SDK: subscribes the browser to web push for an app on notifications.phnx-iq.com, binds it to
6
+ * the signed-in user with an identity token from the app's own backend, and hands notification clicks to the app. When the
7
+ * app has its subscription bell or banner switched on in HQ, it also draws them, so a site needs no button of its own.
8
+ *
9
+ * Nothing here throws for an unsupported browser or a denied permission: check `isSupported()` / `permission()`.
10
+ * The service worker file (phoenix-notifications-sw.js) must be served from the site, at `serviceWorkerPath`.
11
+ */
12
+ export const DEFAULT_SERVER_URL = 'https://notifications.phnx-iq.com';
13
+ export const DEFAULT_SERVICE_WORKER_PATH = '/phoenix-notifications-sw.js';
14
+ export class NotificationsError extends Error {
15
+ status;
16
+ constructor(status, message) {
17
+ super(message);
18
+ this.name = 'NotificationsError';
19
+ this.status = status;
20
+ }
21
+ }
22
+ const CLICK_MESSAGE = 'phoenix-notifications:click';
23
+ const DAY_MS = 24 * 60 * 60 * 1000;
24
+ const RESYNC_AFTER_MS = DAY_MS;
25
+ let options = {
26
+ appId: '',
27
+ serverUrl: DEFAULT_SERVER_URL,
28
+ serviceWorkerPath: DEFAULT_SERVICE_WORKER_PATH,
29
+ serviceWorkerScope: '/',
30
+ language: '',
31
+ };
32
+ let registration = null;
33
+ let ready = null;
34
+ let syncing = null;
35
+ /** The last successful sync of this page, so init and login on the same load ask the server once. */
36
+ let synced = null;
37
+ const SYNC_REUSE_MS = 5 * 60 * 1000;
38
+ /** The app's web config, read once per page: the subscription and the bell both need it. */
39
+ let config = null;
40
+ const clickHandlers = new Set();
41
+ const changeHandlers = new Set();
42
+ let lastState = null;
43
+ let listening = false;
44
+ let watchingPermission = false;
45
+ let bell = null;
46
+ let banner = null;
47
+ function supported() {
48
+ return (typeof window !== 'undefined' &&
49
+ window.isSecureContext &&
50
+ 'serviceWorker' in navigator &&
51
+ 'PushManager' in window &&
52
+ 'Notification' in window &&
53
+ 'indexedDB' in window);
54
+ }
55
+ function url(path) {
56
+ return `${options.serverUrl.replace(/\/+$/, '')}${path}`;
57
+ }
58
+ async function call(method, path, body, deviceSecret) {
59
+ const headers = {};
60
+ if (body !== undefined)
61
+ headers['Content-Type'] = 'application/json';
62
+ if (deviceSecret)
63
+ headers.Authorization = `Device ${deviceSecret}`;
64
+ const response = await fetch(url(path), { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
65
+ if (response.status === 204)
66
+ return undefined;
67
+ let payload;
68
+ try {
69
+ payload = (await response.json());
70
+ }
71
+ catch {
72
+ payload = undefined;
73
+ }
74
+ if (!response.ok)
75
+ throw new NotificationsError(response.status, payload?.errors?.join(' ') || `Request failed with ${response.status}`);
76
+ return payload?.result;
77
+ }
78
+ function webConfig(force = false) {
79
+ if (force || !config || config.appId !== options.appId) {
80
+ const appId = options.appId;
81
+ const value = call('GET', `/v1/apps/${appId}/web`);
82
+ // a failed read is not kept: the next call asks again
83
+ value.catch(() => {
84
+ if (config?.value === value)
85
+ config = null;
86
+ });
87
+ config = { appId, value };
88
+ }
89
+ return config.value;
90
+ }
91
+ function toBytes(base64Url) {
92
+ const padded = base64Url.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (base64Url.length % 4)) % 4);
93
+ const raw = atob(padded);
94
+ const bytes = new Uint8Array(new ArrayBuffer(raw.length));
95
+ for (let i = 0; i < raw.length; i++)
96
+ bytes[i] = raw.charCodeAt(i);
97
+ return bytes;
98
+ }
99
+ function toBase64Url(buffer) {
100
+ if (!buffer)
101
+ return '';
102
+ let raw = '';
103
+ for (const byte of new Uint8Array(buffer))
104
+ raw += String.fromCharCode(byte);
105
+ return btoa(raw).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
106
+ }
107
+ function sameKey(subscription, vapidKey) {
108
+ return toBase64Url(subscription.options.applicationServerKey) === vapidKey.replace(/=+$/, '');
109
+ }
110
+ function subscriptionBody(subscription, vapidKey) {
111
+ return {
112
+ endpoint: subscription.endpoint,
113
+ p256dh: toBase64Url(subscription.getKey('p256dh')),
114
+ auth: toBase64Url(subscription.getKey('auth')),
115
+ applicationServerKey: vapidKey,
116
+ };
117
+ }
118
+ function language() {
119
+ return options.language || document.documentElement.lang || navigator.language;
120
+ }
121
+ function deviceInfo() {
122
+ return {
123
+ language: language(),
124
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
125
+ appVersion: options.appVersion,
126
+ os: navigator.platform || undefined,
127
+ deviceModel: navigator.userAgent.slice(0, 200),
128
+ };
129
+ }
130
+ function listenForClicks() {
131
+ if (listening)
132
+ return;
133
+ listening = true;
134
+ navigator.serviceWorker.addEventListener('message', (event) => {
135
+ const message = event.data;
136
+ if (message?.type !== CLICK_MESSAGE)
137
+ return;
138
+ const click = { url: message.url, data: message.data ?? {}, messageId: message.messageId };
139
+ if (clickHandlers.size === 0) {
140
+ if (click.url)
141
+ window.location.assign(click.url);
142
+ return;
143
+ }
144
+ for (const handler of clickHandlers)
145
+ handler(click);
146
+ });
147
+ // messages from the worker wait in a queue until the page says it is listening
148
+ navigator.serviceWorker.startMessages();
149
+ }
150
+ async function optedOut() {
151
+ return !!options.appId && !!(await store.get(keys.optOut(options.appId)));
152
+ }
153
+ async function currentState() {
154
+ if (!supported())
155
+ return { permission: 'unsupported', subscribed: false };
156
+ const permission = Notification.permission;
157
+ if (permission !== 'granted' || !options.appId)
158
+ return { permission, subscribed: false };
159
+ const [device, out] = await Promise.all([store.get(keys.device(options.appId)), optedOut()]);
160
+ return { permission, subscribed: !!device && !out };
161
+ }
162
+ /** Tells the listeners when the state changed; never throws. */
163
+ async function announce() {
164
+ try {
165
+ const state = await currentState();
166
+ const key = `${options.appId}:${state.permission}:${state.subscribed}`;
167
+ if (key === lastState)
168
+ return;
169
+ lastState = key;
170
+ for (const handler of changeHandlers)
171
+ handler(state);
172
+ }
173
+ catch {
174
+ // a listener's failure or a blocked IndexedDB must not break the caller
175
+ }
176
+ }
177
+ /** Subscribes (or renews the subscription) and registers or updates the device. Needs the permission granted, and no opt-out. */
178
+ async function sync() {
179
+ if (!registration || Notification.permission !== 'granted' || (await optedOut()))
180
+ return null;
181
+ const web = await webConfig();
182
+ if (!web)
183
+ return null;
184
+ const vapidKey = web.vapidPublicKey;
185
+ await store.set(keys.config, { appId: options.appId, serverUrl: options.serverUrl, vapidKey });
186
+ let subscription = await registration.pushManager.getSubscription();
187
+ // a subscription made with another key (an earlier key of this app, or another push provider) cannot receive our pushes
188
+ if (subscription && !sameKey(subscription, vapidKey)) {
189
+ await subscription.unsubscribe();
190
+ subscription = null;
191
+ }
192
+ subscription ??= await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: toBytes(vapidKey) });
193
+ const stored = await store.get(keys.device(options.appId));
194
+ const body = { subscription: subscriptionBody(subscription, vapidKey), ...deviceInfo() };
195
+ if (stored) {
196
+ const changed = stored.endpoint !== subscription.endpoint || stored.vapidKey !== vapidKey;
197
+ if (!changed && Date.now() - stored.syncedAt < RESYNC_AFTER_MS)
198
+ return stored;
199
+ try {
200
+ await call('PUT', `/v1/devices/${stored.id}`, changed ? body : deviceInfo(), stored.secret);
201
+ const updated = { ...stored, endpoint: subscription.endpoint, vapidKey, syncedAt: Date.now() };
202
+ await store.set(keys.device(options.appId), updated);
203
+ return updated;
204
+ }
205
+ catch (error) {
206
+ // the server no longer knows this device (or another record owns the subscription): register it again
207
+ if (!(error instanceof NotificationsError) || ![401, 404, 409].includes(error.status))
208
+ throw error;
209
+ }
210
+ }
211
+ const registered = await call('POST', `/v1/apps/${options.appId}/devices`, body);
212
+ if (!registered)
213
+ return null;
214
+ const device = {
215
+ appId: options.appId,
216
+ id: registered.deviceId,
217
+ secret: registered.deviceSecret,
218
+ endpoint: subscription.endpoint,
219
+ vapidKey,
220
+ syncedAt: Date.now(),
221
+ };
222
+ await store.set(keys.device(options.appId), device);
223
+ await store.remove(keys.login(options.appId));
224
+ return device;
225
+ }
226
+ function syncOnce(force = false) {
227
+ if (!force && synced && synced.device.appId === options.appId && Date.now() - synced.at < SYNC_REUSE_MS)
228
+ return Promise.resolve(synced.device);
229
+ syncing ??= sync()
230
+ .then((device) => {
231
+ synced = device ? { device, at: Date.now() } : null;
232
+ return device;
233
+ })
234
+ .finally(() => {
235
+ syncing = null;
236
+ void announce();
237
+ });
238
+ return syncing;
239
+ }
240
+ /**
241
+ * A login made before the person allowed notifications (or kept through an unsubscribe) is applied once there is a device.
242
+ * Its failure does not undo the subscription: an expired token is dropped, and the site logs in again on its next load.
243
+ */
244
+ async function applyPendingLogin(device) {
245
+ if (!device)
246
+ return;
247
+ const pending = await store.get(keys.login(options.appId));
248
+ if (!pending || pending.at !== 0)
249
+ return;
250
+ try {
251
+ await Notifications.login(pending.token);
252
+ }
253
+ catch (error) {
254
+ if (error instanceof NotificationsError && error.status === 401)
255
+ await store.remove(keys.login(options.appId));
256
+ console.warn('[notifications] the pending login failed', error);
257
+ }
258
+ }
259
+ /** Follows permission changes made outside the page (the site settings next to the address bar). */
260
+ function watchPermission() {
261
+ if (watchingPermission)
262
+ return;
263
+ watchingPermission = true;
264
+ let known = Notification.permission;
265
+ const changed = () => {
266
+ if (Notification.permission === known)
267
+ return;
268
+ known = Notification.permission;
269
+ if (known === 'granted')
270
+ void syncOnce(true).catch(() => undefined);
271
+ void announce();
272
+ };
273
+ window.addEventListener('focus', changed);
274
+ void navigator.permissions
275
+ ?.query({ name: 'notifications' })
276
+ .then((status) => status.addEventListener('change', changed))
277
+ .catch(() => undefined);
278
+ }
279
+ /** Draws the bell once the app's config says so. A failure only means no bell. */
280
+ async function showBell(appId) {
281
+ try {
282
+ const web = await webConfig();
283
+ if (bell?.appId !== appId || !web?.bell?.enabled)
284
+ return;
285
+ if (document.readyState === 'loading')
286
+ await new Promise((resolve) => document.addEventListener('DOMContentLoaded', resolve, { once: true }));
287
+ if (bell?.appId !== appId || bell.handle)
288
+ return;
289
+ bell.handle = mountBell({
290
+ state: currentState,
291
+ subscribe: async () => {
292
+ await Notifications.requestPermission();
293
+ return currentState();
294
+ },
295
+ unsubscribe: () => Notifications.unsubscribe(),
296
+ onChange: (handler) => Notifications.onSubscriptionChange(handler),
297
+ }, web.bell, language());
298
+ }
299
+ catch (error) {
300
+ console.warn('[notifications] the subscription bell is not available', error);
301
+ }
302
+ }
303
+ function whenDocumentReady() {
304
+ if (document.readyState !== 'loading')
305
+ return Promise.resolve();
306
+ return new Promise((resolve) => document.addEventListener('DOMContentLoaded', () => resolve(), { once: true }));
307
+ }
308
+ /**
309
+ * Slides the banner in when its time has come: only for a browser never asked (the permission still to be given, and no
310
+ * unsubscribe here), from the page view HQ set, not within the days after a "Later", and after the delay. Each call is one page
311
+ * view. A failure only means no banner.
312
+ */
313
+ async function showBanner(appId) {
314
+ try {
315
+ const web = await webConfig();
316
+ const settings = web?.banner;
317
+ if (banner?.appId !== appId || !settings?.enabled)
318
+ return;
319
+ if (Notification.permission !== 'default' || (await optedOut()))
320
+ return;
321
+ const key = keys.banner(appId);
322
+ const stored = (await store.get(key)) ?? { appId, views: 0 };
323
+ const views = Math.min(stored.views + 1, 1000);
324
+ await store.set(key, { ...stored, views });
325
+ if (views < settings.pageViews)
326
+ return;
327
+ if (stored.dismissedAt && Date.now() - stored.dismissedAt < settings.repromptDays * DAY_MS)
328
+ return;
329
+ await new Promise((resolve) => setTimeout(resolve, Math.max(0, settings.delaySeconds) * 1000));
330
+ await whenDocumentReady();
331
+ // the page may have moved on meanwhile: another app, the person subscribed with the bell, or answered the browser
332
+ if (banner?.appId !== appId || banner.handle || Notification.permission !== 'default' || (await optedOut()))
333
+ return;
334
+ banner.handle = mountBanner({
335
+ subscribe: async () => {
336
+ await Notifications.requestPermission();
337
+ return currentState();
338
+ },
339
+ dismiss: async () => {
340
+ const latest = (await store.get(key)) ?? { appId, views };
341
+ await store.set(key, { ...latest, dismissedAt: Date.now() });
342
+ },
343
+ onChange: (handler) => Notifications.onSubscriptionChange(handler),
344
+ }, settings, language(), { iconUrl: web?.iconUrl, aboveBell: !!bell?.handle });
345
+ }
346
+ catch (error) {
347
+ console.warn('[notifications] the subscription banner is not available', error);
348
+ }
349
+ }
350
+ export const Notifications = {
351
+ /** True when this browser can receive web push here (a secure context with service workers and the Push API). */
352
+ isSupported() {
353
+ return supported();
354
+ },
355
+ /**
356
+ * Registers the service worker, and draws the subscription bell and the banner when the app has them on. When the person
357
+ * already allowed notifications (and did not unsubscribe here), subscribes quietly.
358
+ */
359
+ async init(init) {
360
+ if (!supported())
361
+ return;
362
+ if (!init.appId)
363
+ throw new Error('Notifications.init needs the appId.');
364
+ options = {
365
+ appId: init.appId,
366
+ serverUrl: init.serverUrl ?? DEFAULT_SERVER_URL,
367
+ serviceWorkerPath: init.serviceWorkerPath ?? DEFAULT_SERVICE_WORKER_PATH,
368
+ serviceWorkerScope: init.serviceWorkerScope ?? '/',
369
+ language: init.language ?? '',
370
+ appVersion: init.appVersion,
371
+ };
372
+ // one bell per page, for the app last initialised
373
+ const wantsBell = init.bell !== false;
374
+ if (bell && (bell.appId !== init.appId || !wantsBell)) {
375
+ bell.handle?.destroy();
376
+ bell = null;
377
+ }
378
+ if (wantsBell && !bell) {
379
+ bell = { appId: init.appId, handle: null };
380
+ void showBell(init.appId);
381
+ }
382
+ // the banner likewise; a second init of the same app on one page (React's strict mode) is no second page view
383
+ const wantsBanner = init.banner !== false;
384
+ if (banner && (banner.appId !== init.appId || !wantsBanner)) {
385
+ banner.handle?.destroy();
386
+ banner = null;
387
+ }
388
+ if (wantsBanner && !banner) {
389
+ banner = { appId: init.appId, handle: null };
390
+ void showBanner(init.appId);
391
+ }
392
+ ready = (async () => {
393
+ registration = await navigator.serviceWorker.register(options.serviceWorkerPath, { scope: options.serviceWorkerScope });
394
+ listenForClicks();
395
+ })();
396
+ await ready;
397
+ watchPermission();
398
+ if (Notification.permission === 'granted')
399
+ await applyPendingLogin(await syncOnce(true));
400
+ else
401
+ void announce();
402
+ },
403
+ permission() {
404
+ return supported() ? Notification.permission : 'unsupported';
405
+ },
406
+ /** Whether this browser gets the app's notifications now. */
407
+ getState() {
408
+ return currentState();
409
+ },
410
+ /**
411
+ * Asks the person (call it from a click: browsers only ask on a user gesture) and subscribes when they allow. It also
412
+ * subscribes again after `unsubscribe()`, without asking, since the browser keeps the permission.
413
+ */
414
+ async requestPermission() {
415
+ if (!supported())
416
+ return 'unsupported';
417
+ const result = await Notification.requestPermission();
418
+ if (result !== 'granted') {
419
+ void announce();
420
+ return result;
421
+ }
422
+ if (options.appId)
423
+ await store.remove(keys.optOut(options.appId));
424
+ await ready;
425
+ await applyPendingLogin(await syncOnce(true));
426
+ return result;
427
+ },
428
+ /**
429
+ * Binds this browser to the user an identity token from the app's backend vouches for. Before the person allows
430
+ * notifications there is no device yet: the token is kept and used once they do.
431
+ */
432
+ async login(identityToken) {
433
+ if (!supported())
434
+ return;
435
+ await ready;
436
+ const loginKey = keys.login(options.appId);
437
+ const previous = await store.get(loginKey);
438
+ const device = await syncOnce();
439
+ if (!device) {
440
+ await store.set(loginKey, { appId: options.appId, token: identityToken, at: 0 });
441
+ return;
442
+ }
443
+ if (previous && previous.token === identityToken && previous.at > 0 && Date.now() - previous.at < RESYNC_AFTER_MS)
444
+ return;
445
+ await call('POST', `/v1/devices/${device.id}/login`, { identityToken }, device.secret);
446
+ await store.set(loginKey, { appId: options.appId, token: identityToken, at: Date.now() });
447
+ },
448
+ /** The browser keeps receiving the app's broadcasts as a guest, but no longer this user's notifications. */
449
+ async logout() {
450
+ if (!supported() || !options.appId)
451
+ return;
452
+ await store.remove(keys.login(options.appId));
453
+ const device = await store.get(keys.device(options.appId));
454
+ if (device)
455
+ await call('POST', `/v1/devices/${device.id}/logout`, undefined, device.secret);
456
+ },
457
+ /**
458
+ * Stops notifications in this browser altogether, and remembers it: later page loads do not subscribe again although the
459
+ * permission stays granted, until `requestPermission()` (the bell's "Subscribe").
460
+ */
461
+ async unsubscribe() {
462
+ if (!supported() || !options.appId)
463
+ return;
464
+ await store.set(keys.optOut(options.appId), { appId: options.appId, at: Date.now() });
465
+ synced = null;
466
+ try {
467
+ const device = await store.get(keys.device(options.appId));
468
+ if (device) {
469
+ await call('DELETE', `/v1/devices/${device.id}`, undefined, device.secret);
470
+ await store.remove(keys.device(options.appId));
471
+ }
472
+ // the user is kept for a later subscription, like a login made before the permission
473
+ const login = await store.get(keys.login(options.appId));
474
+ if (login)
475
+ await store.set(keys.login(options.appId), { ...login, at: 0 });
476
+ const subscription = await registration?.pushManager.getSubscription();
477
+ await subscription?.unsubscribe();
478
+ }
479
+ finally {
480
+ void announce();
481
+ }
482
+ },
483
+ /** A notification was clicked while the site was open. Without a handler the SDK opens its link. Returns an unsubscribe function. */
484
+ onClick(handler) {
485
+ clickHandlers.add(handler);
486
+ return () => clickHandlers.delete(handler);
487
+ },
488
+ /** Called when this browser subscribes, unsubscribes or the permission changes. Returns an unsubscribe function. */
489
+ onSubscriptionChange(handler) {
490
+ changeHandlers.add(handler);
491
+ return () => changeHandlers.delete(handler);
492
+ },
493
+ /** Re-runs the subscription check now (e.g. after the person changed the site's permission). */
494
+ async refresh() {
495
+ if (!supported())
496
+ return;
497
+ await ready;
498
+ void webConfig(true).catch(() => undefined);
499
+ await applyPendingLogin(await syncOnce(true));
500
+ },
501
+ };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The SDK's state in IndexedDB, shared with the service worker (which reads `config` to report clicks and to renew a
3
+ * subscription). Keep the database name, store and keys in step with phoenix-notifications-sw.js.
4
+ */
5
+ /** What the service worker needs: where to report clicks and the key to subscribe with. */
6
+ export interface StoredConfig {
7
+ appId: string;
8
+ serverUrl: string;
9
+ vapidKey: string;
10
+ }
11
+ /** This browser's device record on the server. The secret authenticates every later call for the device. */
12
+ export interface StoredDevice {
13
+ appId: string;
14
+ id: string;
15
+ secret: string;
16
+ endpoint: string;
17
+ vapidKey: string;
18
+ /** When the server last heard from this device (ms), to refresh it about once a day. */
19
+ syncedAt: number;
20
+ }
21
+ /** The user the device was last bound to, so a page load does not log in again with the same token. */
22
+ export interface StoredLogin {
23
+ appId: string;
24
+ token: string;
25
+ at: number;
26
+ }
27
+ export declare const store: {
28
+ get<T>(key: string): Promise<T | undefined>;
29
+ set<T>(key: string, value: T): Promise<void>;
30
+ remove(key: string): Promise<void>;
31
+ };
32
+ /** The person turned notifications off here (the bell's "Unsubscribe", or `unsubscribe()`): a granted permission no longer subscribes. */
33
+ export interface StoredOptOut {
34
+ appId: string;
35
+ at: number;
36
+ }
37
+ /** The banner in this browser: page views counted since it was switched on, and the last "Later". */
38
+ export interface StoredBanner {
39
+ appId: string;
40
+ views: number;
41
+ dismissedAt?: number;
42
+ }
43
+ export declare const keys: {
44
+ config: string;
45
+ device: (appId: string) => string;
46
+ login: (appId: string) => string;
47
+ optOut: (appId: string) => string;
48
+ banner: (appId: string) => string;
49
+ };
package/dist/store.js ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The SDK's state in IndexedDB, shared with the service worker (which reads `config` to report clicks and to renew a
3
+ * subscription). Keep the database name, store and keys in step with phoenix-notifications-sw.js.
4
+ */
5
+ const DB_NAME = 'phoenix-notifications';
6
+ const STORE = 'state';
7
+ function open() {
8
+ return new Promise((resolve, reject) => {
9
+ const request = indexedDB.open(DB_NAME, 1);
10
+ request.onupgradeneeded = () => {
11
+ if (!request.result.objectStoreNames.contains(STORE))
12
+ request.result.createObjectStore(STORE);
13
+ };
14
+ request.onsuccess = () => resolve(request.result);
15
+ request.onerror = () => reject(request.error);
16
+ });
17
+ }
18
+ async function run(mode, action) {
19
+ const db = await open();
20
+ try {
21
+ return await new Promise((resolve, reject) => {
22
+ const request = action(db.transaction(STORE, mode).objectStore(STORE));
23
+ request.onsuccess = () => resolve(request.result);
24
+ request.onerror = () => reject(request.error);
25
+ });
26
+ }
27
+ finally {
28
+ db.close();
29
+ }
30
+ }
31
+ export const store = {
32
+ get(key) {
33
+ return run('readonly', (s) => s.get(key));
34
+ },
35
+ set(key, value) {
36
+ return run('readwrite', (s) => s.put(value, key));
37
+ },
38
+ remove(key) {
39
+ return run('readwrite', (s) => s.delete(key));
40
+ },
41
+ };
42
+ export const keys = {
43
+ config: 'config',
44
+ device: (appId) => `device:${appId}`,
45
+ login: (appId) => `login:${appId}`,
46
+ optOut: (appId) => `opt-out:${appId}`,
47
+ banner: (appId) => `banner:${appId}`,
48
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * What the bell and the banner share: their default colour, the readable text colour on it, and a light or dark look picked
3
+ * from the page itself, so they suit the site without settings of their own.
4
+ */
5
+ export declare const DEFAULT_COLOR = "#2563eb";
6
+ type Rgb = [number, number, number];
7
+ /** `#rrggbb` as given, or the SDK's blue. */
8
+ export declare function colorOr(color: string | null | undefined): string;
9
+ export declare function hexToRgb(hex: string): Rgb;
10
+ /** White or near-black, whichever reads better on the colour. */
11
+ export declare function textOn(color: string): string;
12
+ /** Sets the accent (`--bell`, `--bell-text`, `--ring`) and the page-matched look (`--panel`, `--text`…) on the host. */
13
+ export declare function applyColors(host: HTMLElement, color: string): void;
14
+ /** Again when something opens: the site may have changed its look since. */
15
+ export declare function applyTheme(host: HTMLElement): void;
16
+ /** The language the bell and the banner speak: Arabic for `ar…`, else English. */
17
+ export declare function uiLanguage(language: string): 'ar' | 'en';
18
+ export declare const ICONS: {
19
+ bell: string;
20
+ bellOff: string;
21
+ check: string;
22
+ close: string;
23
+ };
24
+ export declare function svg(paths: string, size: number, stroke?: number): string;
25
+ export {};