@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/theme.js ADDED
@@ -0,0 +1,93 @@
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 const DEFAULT_COLOR = '#2563eb';
6
+ /** `#rrggbb` as given, or the SDK's blue. */
7
+ export function colorOr(color) {
8
+ return /^#[0-9a-f]{6}$/i.test(color ?? '') ? color.toLowerCase() : DEFAULT_COLOR;
9
+ }
10
+ export function hexToRgb(hex) {
11
+ const n = Number.parseInt(hex.slice(1), 16);
12
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
13
+ }
14
+ function luminance([r, g, b]) {
15
+ const [lr, lg, lb] = [r, g, b].map((v) => {
16
+ const c = v / 255;
17
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
18
+ });
19
+ return 0.2126 * lr + 0.7152 * lg + 0.0722 * lb;
20
+ }
21
+ function contrast(a, b) {
22
+ const [high, low] = [luminance(a), luminance(b)].sort((x, y) => y - x);
23
+ return (high + 0.05) / (low + 0.05);
24
+ }
25
+ /** White or near-black, whichever reads better on the colour. */
26
+ export function textOn(color) {
27
+ const rgb = hexToRgb(color);
28
+ return contrast(rgb, [255, 255, 255]) >= contrast(rgb, [17, 17, 17]) ? '#ffffff' : '#111111';
29
+ }
30
+ /** `rgb(…)` / `rgba(…)` as getComputedStyle gives it, unless (almost) transparent. */
31
+ function parseRgb(value) {
32
+ const match = /^rgba?\(\s*([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\s*\)$/.exec(value);
33
+ if (!match || (match[4] !== undefined && Number(match[4]) < 0.5))
34
+ return null;
35
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
36
+ }
37
+ /** The page's own background decides; without one, the colour scheme it declares; else the system's. */
38
+ function pageIsDark() {
39
+ for (const element of [document.body, document.documentElement]) {
40
+ if (!element)
41
+ continue;
42
+ const rgb = parseRgb(getComputedStyle(element).backgroundColor);
43
+ if (rgb)
44
+ return luminance(rgb) < 0.2;
45
+ }
46
+ const meta = document.querySelector('meta[name="color-scheme"]')?.content ?? '';
47
+ const declared = `${getComputedStyle(document.documentElement).colorScheme} ${meta}`;
48
+ const dark = /\bdark\b/.test(declared);
49
+ const light = /\blight\b/.test(declared);
50
+ if (dark !== light)
51
+ return dark;
52
+ return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
53
+ }
54
+ const THEMES = {
55
+ light: { panel: '#ffffff', text: '#111827', muted: '#4b5563', border: 'rgba(17, 24, 39, 0.12)', hover: 'rgba(17, 24, 39, 0.06)', error: '#b91c1c', tip: '#111827', tipText: '#ffffff' },
56
+ dark: { panel: '#1c1c22', text: '#f4f4f5', muted: '#a1a1aa', border: 'rgba(255, 255, 255, 0.14)', hover: 'rgba(255, 255, 255, 0.08)', error: '#fca5a5', tip: '#f4f4f5', tipText: '#18181b' },
57
+ };
58
+ /** Sets the accent (`--bell`, `--bell-text`, `--ring`) and the page-matched look (`--panel`, `--text`…) on the host. */
59
+ export function applyColors(host, color) {
60
+ host.style.setProperty('--bell', color);
61
+ host.style.setProperty('--bell-text', textOn(color));
62
+ host.style.setProperty('--ring', `rgba(${hexToRgb(color).join(', ')}, 0.55)`);
63
+ applyTheme(host);
64
+ }
65
+ /** Again when something opens: the site may have changed its look since. */
66
+ export function applyTheme(host) {
67
+ const theme = THEMES[pageIsDark() ? 'dark' : 'light'];
68
+ for (const [name, value] of Object.entries({
69
+ '--panel': theme.panel,
70
+ '--text': theme.text,
71
+ '--muted': theme.muted,
72
+ '--border': theme.border,
73
+ '--hover': theme.hover,
74
+ '--error': theme.error,
75
+ '--tip': theme.tip,
76
+ '--tip-text': theme.tipText,
77
+ }))
78
+ host.style.setProperty(name, value);
79
+ }
80
+ /** The language the bell and the banner speak: Arabic for `ar…`, else English. */
81
+ export function uiLanguage(language) {
82
+ return language.toLowerCase().startsWith('ar') ? 'ar' : 'en';
83
+ }
84
+ // Tabler icons (MIT).
85
+ export const ICONS = {
86
+ bell: '<path d="M10 5a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2 -3v-3a7 7 0 0 1 4 -6"/><path d="M9 17v1a3 3 0 0 0 6 0v-1"/>',
87
+ bellOff: '<path d="M9.346 5.353c.21 -.129 .428 -.246 .654 -.353a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3m-1 3h-13a4 4 0 0 0 2 -3v-3a6.996 6.996 0 0 1 1.273 -3.707"/><path d="M9 17v1a3 3 0 0 0 6 0v-1"/><path d="M3 3l18 18"/>',
88
+ check: '<path d="M5 12l5 5l10 -10"/>',
89
+ close: '<path d="M18 6l-12 12"/><path d="M6 6l12 12"/>',
90
+ };
91
+ export function svg(paths, size, stroke = 1.8) {
92
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${stroke}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
93
+ }
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@phoenix-iq/notifications",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.0",
4
+ "description": "Phoenix Notifications web SDK: browser push subscriptions, the subscription bell, verified sign-in and click handling for apps on notifications.phnx-iq.com.",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./phoenix-notifications-sw.js": "./phoenix-notifications-sw.js"
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "phoenix-notifications-sw.js",
19
+ "README.md"
20
+ ],
21
+ "sideEffects": false,
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/Phoenix-IQ/phoenix-notifications.git",
25
+ "directory": "sdk/web"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "scripts": {
31
+ "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
32
+ "build": "npm run clean && tsc -p tsconfig.build.json",
33
+ "typecheck": "tsc --noEmit -p tsconfig.json",
34
+ "lint": "eslint .",
35
+ "prepublishOnly": "npm run build"
36
+ },
37
+ "devDependencies": {
38
+ "@eslint/js": "^10.0.1",
39
+ "eslint": "^10.12.0",
40
+ "globals": "^17.13.0",
41
+ "typescript": "^5.9.3",
42
+ "typescript-eslint": "^8.71.1"
43
+ }
44
+ }
@@ -0,0 +1,149 @@
1
+ /*
2
+ * Phoenix Notifications service worker. Serve it from the site's root (default path /phoenix-notifications-sw.js) so it
3
+ * controls the whole site. Plain JavaScript on purpose: copy it as it is, no build step.
4
+ *
5
+ * It shows the pushes the site receives, reports clicks to the notifications API, focuses an open tab of the site (and tells
6
+ * the page, which routes the click) or opens the link, and renews the subscription when the browser replaces it.
7
+ * Its state is shared with the SDK through IndexedDB ("phoenix-notifications" / "state"); keep the two in step.
8
+ */
9
+
10
+ const DB_NAME = 'phoenix-notifications';
11
+ const STORE = 'state';
12
+ const CLICK_MESSAGE = 'phoenix-notifications:click';
13
+
14
+ function openDb() {
15
+ return new Promise((resolve, reject) => {
16
+ const request = indexedDB.open(DB_NAME, 1);
17
+ request.onupgradeneeded = () => {
18
+ if (!request.result.objectStoreNames.contains(STORE)) request.result.createObjectStore(STORE);
19
+ };
20
+ request.onsuccess = () => resolve(request.result);
21
+ request.onerror = () => reject(request.error);
22
+ });
23
+ }
24
+
25
+ async function read(key) {
26
+ const db = await openDb();
27
+ try {
28
+ return await new Promise((resolve, reject) => {
29
+ const request = db.transaction(STORE, 'readonly').objectStore(STORE).get(key);
30
+ request.onsuccess = () => resolve(request.result);
31
+ request.onerror = () => reject(request.error);
32
+ });
33
+ } finally {
34
+ db.close();
35
+ }
36
+ }
37
+
38
+ async function write(key, value) {
39
+ const db = await openDb();
40
+ try {
41
+ await new Promise((resolve, reject) => {
42
+ const request = db.transaction(STORE, 'readwrite').objectStore(STORE).put(value, key);
43
+ request.onsuccess = () => resolve();
44
+ request.onerror = () => reject(request.error);
45
+ });
46
+ } finally {
47
+ db.close();
48
+ }
49
+ }
50
+
51
+ function apiUrl(config, path) {
52
+ return new URL(`${(config.serverUrl || '').replace(/\/+$/, '')}${path}`, self.location.origin).toString();
53
+ }
54
+
55
+ function toBytes(base64Url) {
56
+ const padded = base64Url.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (base64Url.length % 4)) % 4);
57
+ const raw = atob(padded);
58
+ const bytes = new Uint8Array(raw.length);
59
+ for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
60
+ return bytes;
61
+ }
62
+
63
+ function toBase64Url(buffer) {
64
+ if (!buffer) return '';
65
+ let raw = '';
66
+ for (const byte of new Uint8Array(buffer)) raw += String.fromCharCode(byte);
67
+ return btoa(raw).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
68
+ }
69
+
70
+ self.addEventListener('install', () => self.skipWaiting());
71
+
72
+ self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
73
+
74
+ self.addEventListener('push', (event) => {
75
+ let payload;
76
+ try {
77
+ payload = event.data ? event.data.json() : {};
78
+ } catch {
79
+ payload = { title: event.data ? event.data.text() : '' };
80
+ }
81
+
82
+ const options = {
83
+ body: payload.body || '',
84
+ icon: payload.icon || undefined,
85
+ image: payload.image || undefined,
86
+ lang: payload.lang || undefined,
87
+ dir: payload.dir || 'auto',
88
+ // the same message never shows twice
89
+ tag: payload.m || undefined,
90
+ data: { id: payload.id, m: payload.m, url: payload.url, data: payload.data || {} },
91
+ };
92
+ event.waitUntil(self.registration.showNotification(payload.title || '', options));
93
+ });
94
+
95
+ self.addEventListener('notificationclick', (event) => {
96
+ event.notification.close();
97
+ const details = event.notification.data || {};
98
+
99
+ event.waitUntil(
100
+ (async () => {
101
+ if (details.id) {
102
+ try {
103
+ const config = await read('config');
104
+ if (config) await fetch(apiUrl(config, `/v1/opened/${details.id}`), { method: 'POST', keepalive: true });
105
+ } catch {
106
+ // counting the click must never stop it from opening
107
+ }
108
+ }
109
+
110
+ const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
111
+ const open = windows.find((client) => new URL(client.url).origin === self.location.origin);
112
+ if (open) {
113
+ await open.focus();
114
+ open.postMessage({ type: CLICK_MESSAGE, url: details.url, data: details.data || {}, messageId: details.m });
115
+ return;
116
+ }
117
+ await self.clients.openWindow(details.url || self.registration.scope);
118
+ })(),
119
+ );
120
+ });
121
+
122
+ self.addEventListener('pushsubscriptionchange', (event) => {
123
+ event.waitUntil(
124
+ (async () => {
125
+ const config = await read('config');
126
+ if (!config || !config.vapidKey) return;
127
+ // no device: the browser was never registered, or the person unsubscribed (the bell, unsubscribe()); leave it so
128
+ const device = await read(`device:${config.appId}`);
129
+ if (!device || (await read(`opt-out:${config.appId}`))) return;
130
+ const subscription =
131
+ event.newSubscription ||
132
+ (await self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: toBytes(config.vapidKey) }));
133
+
134
+ const response = await fetch(apiUrl(config, `/v1/devices/${device.id}`), {
135
+ method: 'PUT',
136
+ headers: { 'Content-Type': 'application/json', Authorization: `Device ${device.secret}` },
137
+ body: JSON.stringify({
138
+ subscription: {
139
+ endpoint: subscription.endpoint,
140
+ p256dh: toBase64Url(subscription.getKey('p256dh')),
141
+ auth: toBase64Url(subscription.getKey('auth')),
142
+ applicationServerKey: config.vapidKey,
143
+ },
144
+ }),
145
+ });
146
+ if (response.ok) await write(`device:${config.appId}`, { ...device, endpoint: subscription.endpoint, syncedAt: Date.now() });
147
+ })(),
148
+ );
149
+ });