@phoenix-iq/notifications 1.2.0 → 1.2.1
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/README.md +35 -5
- package/dist/banner.d.ts +6 -2
- package/dist/banner.js +10 -6
- package/dist/bell.d.ts +3 -2
- package/dist/bell.js +13 -11
- package/dist/index.d.ts +24 -2
- package/dist/index.js +66 -24
- package/dist/theme.d.ts +21 -0
- package/dist/theme.js +16 -0
- package/package.json +1 -1
- package/phoenix-notifications-sw.js +22 -4
package/README.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# @phoenix-iq/notifications (web SDK)
|
|
2
2
|
|
|
3
3
|
Browser push for apps on `notifications.phnx-iq.com`. It subscribes the browser (standard Web Push with the app's VAPID key),
|
|
4
|
-
binds it to your signed-in user with an identity token your backend signs, hands notification clicks
|
|
5
|
-
the subscribe prompt HQ chose for the app: a bell, the app's logo or a
|
|
4
|
+
binds it to your signed-in user with an identity token your backend signs, hands notification clicks (and the pushes that
|
|
5
|
+
arrive while your site is open) to your app, and draws the subscribe prompt HQ chose for the app: a bell, the app's logo or a
|
|
6
|
+
pop-up. TypeScript, no dependencies.
|
|
6
7
|
|
|
7
8
|
How the whole service works, the server API and the identity tokens: [docs/notifications.md](../../docs/notifications.md).
|
|
8
9
|
|
|
@@ -35,8 +36,9 @@ await Notifications.logout();
|
|
|
35
36
|
```
|
|
36
37
|
|
|
37
38
|
`init` subscribes quietly when the person already allowed notifications (and did not unsubscribe here). `login` before the
|
|
38
|
-
permission is granted is kept and applied once it is.
|
|
39
|
-
`
|
|
39
|
+
permission is granted is kept and applied once it is (up to 1.2.0 it was lost when the browser subscribed, and the browser
|
|
40
|
+
stayed a guest until a later `login`). Nothing throws on an unsupported browser; check `Notifications.isSupported()` and
|
|
41
|
+
`Notifications.permission()` to decide what to show.
|
|
40
42
|
|
|
41
43
|
## The subscribe prompt
|
|
42
44
|
|
|
@@ -59,6 +61,20 @@ the page's font, pick a light or dark look from the page's background, live in a
|
|
|
59
61
|
and are hidden when printing. `bell: false` and `banner: false` in `init` keep the button or the pop-up off a page even when
|
|
60
62
|
HQ chose it.
|
|
61
63
|
|
|
64
|
+
**Room for your layout.** The button sits 20 px from its corner (16 px on phones) and the pop-up 16 px from its edge, above
|
|
65
|
+
everything. Keep them clear of a fixed bottom bar or header with `offset` (CSS pixels), or with CSS variables, which win
|
|
66
|
+
over it and can sit in a media query for a bar only phones have:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
await Notifications.init({ appId, offset: { bottom: 64 } }); // also `top` (a pop-up at the top) and `side` (the button)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```css
|
|
73
|
+
@media (max-width: 1023px) {
|
|
74
|
+
:root { --phoenix-notifications-offset-bottom: 64px; } /* and -offset-top, -offset-side */
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
62
78
|
## Subscription state
|
|
63
79
|
|
|
64
80
|
```ts
|
|
@@ -80,7 +96,18 @@ Notifications.onClick(({ url, data }) => router.navigate(new URL(url ?? '/', loc
|
|
|
80
96
|
```
|
|
81
97
|
|
|
82
98
|
A click on a notification focuses an open tab of your site and calls your handler; without a handler the SDK opens the link.
|
|
83
|
-
With no tab open, the browser opens the link directly
|
|
99
|
+
With no tab open, the browser opens the link directly; with no link (none in the push, no default link in HQ), your site's
|
|
100
|
+
home page.
|
|
101
|
+
|
|
102
|
+
## 4. Pushes while your site is open
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
Notifications.onPush(({ title, body, url, data, messageId }) => refreshInbox());
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The browser shows every push as a notification, open tab or not. `onPush` also tells your open tabs (each of them) that one
|
|
109
|
+
arrived, so they can refresh a list or a count. It returns an unsubscribe function. Up to 1.2.0 this needed a script of
|
|
110
|
+
your own appended to the service worker.
|
|
84
111
|
|
|
85
112
|
## Options
|
|
86
113
|
|
|
@@ -94,6 +121,7 @@ With no tab open, the browser opens the link directly.
|
|
|
94
121
|
| `appVersion` | none | Stored with the device; in-app messages can target versions below one |
|
|
95
122
|
| `bell` | as HQ chose | `false` never draws the floating button (bell or logo) |
|
|
96
123
|
| `banner` | as HQ chose | `false` never shows the pop-up |
|
|
124
|
+
| `offset` | none | `{ top, bottom, side }` in CSS pixels: room the prompt leaves for your fixed header or bottom bar |
|
|
97
125
|
|
|
98
126
|
## Notes
|
|
99
127
|
|
|
@@ -104,6 +132,8 @@ With no tab open, the browser opens the link directly.
|
|
|
104
132
|
`/example/bell.html`).
|
|
105
133
|
- Up to 1.1.0 the SDK read a switch on the bell and one on the banner; from 1.2.0 it follows the one prompt HQ chose and can
|
|
106
134
|
draw the logo. An older SDK still works with a newer server: it draws a bell where HQ chose the logo.
|
|
135
|
+
- 1.2.1 needs no server change: it applies a login made before the permission, adds `onPush` and `offset`, and opens the
|
|
136
|
+
home page for a click with no link (1.2.0 opened the service worker's scope, a folder of its own on some sites).
|
|
107
137
|
|
|
108
138
|
## Publishing
|
|
109
139
|
|
package/dist/banner.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SubscriptionState } from './index.js';
|
|
2
|
+
import { type PromptOffset } from './theme.js';
|
|
2
3
|
/**
|
|
3
4
|
* The pop-up of the banner prompt: a card that slides in at the top or the bottom of the page with a short message,
|
|
4
5
|
* "Subscribe" and "Later". "Subscribe" opens the browser's own question; "Later" keeps it away for some days. The SDK
|
|
@@ -31,5 +32,8 @@ export interface BannerApi {
|
|
|
31
32
|
export interface BannerHandle {
|
|
32
33
|
destroy(): void;
|
|
33
34
|
}
|
|
34
|
-
/**
|
|
35
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Slides the banner in, with the app's icon beside the message (else a bell in its colour), clear of the site's fixed parts by
|
|
37
|
+
* `offset`; `destroy()` takes it away at once.
|
|
38
|
+
*/
|
|
39
|
+
export declare function mountBanner(api: BannerApi, settings: BannerSettings, language: string, iconUrl?: string | null, offset?: PromptOffset): BannerHandle;
|
package/dist/banner.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { applyColors, applyTheme, colorOr, ICONS, svg, uiLanguage } from './theme.js';
|
|
1
|
+
import { applyColors, applyOffset, applyTheme, colorOr, ICONS, OFFSET, svg, uiLanguage } from './theme.js';
|
|
2
2
|
const THANKS_MS = 2500;
|
|
3
3
|
const LEAVE_MS = 400;
|
|
4
4
|
const TEXTS = {
|
|
@@ -26,8 +26,8 @@ const STYLE = `
|
|
|
26
26
|
all: initial; position: fixed; z-index: 2147483001; left: 0; right: 0; pointer-events: none; font-family: inherit;
|
|
27
27
|
}
|
|
28
28
|
.wrap { max-width: 484px; margin: 0 auto; padding: 0 12px; }
|
|
29
|
-
:host([data-position="top"]) { top: calc(16px + env(safe-area-inset-top, 0px)); }
|
|
30
|
-
:host([data-position="bottom"]) { bottom: calc(16px + env(safe-area-inset-bottom, 0px)); }
|
|
29
|
+
:host([data-position="top"]) { top: calc(16px + env(safe-area-inset-top, 0px) + ${OFFSET.top}); }
|
|
30
|
+
:host([data-position="bottom"]) { bottom: calc(16px + env(safe-area-inset-bottom, 0px) + ${OFFSET.bottom}); }
|
|
31
31
|
@media print { :host { display: none !important; } }
|
|
32
32
|
* { box-sizing: border-box; }
|
|
33
33
|
[hidden] { display: none !important; }
|
|
@@ -42,7 +42,7 @@ button { font: inherit; margin: 0; -webkit-tap-highlight-color: transparent; }
|
|
|
42
42
|
:host([data-position="bottom"]) .card { transform: translateY(24px); }
|
|
43
43
|
@media (max-width: 600px) {
|
|
44
44
|
/* on a phone the top of the page is the site's header and menu: the banner comes from the bottom */
|
|
45
|
-
:host([data-position="top"]) { top: auto; bottom: calc(16px + env(safe-area-inset-bottom, 0px)); }
|
|
45
|
+
:host([data-position="top"]) { top: auto; bottom: calc(16px + env(safe-area-inset-bottom, 0px) + ${OFFSET.bottom}); }
|
|
46
46
|
:host([data-position="top"]) .card { transform: translateY(24px); }
|
|
47
47
|
}
|
|
48
48
|
:host([data-shown]) .card { opacity: 1; transform: none; }
|
|
@@ -65,13 +65,17 @@ button { font: inherit; margin: 0; -webkit-tap-highlight-color: transparent; }
|
|
|
65
65
|
.card { transition: opacity 0.2s ease; transform: none !important; }
|
|
66
66
|
}
|
|
67
67
|
`;
|
|
68
|
-
/**
|
|
69
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Slides the banner in, with the app's icon beside the message (else a bell in its colour), clear of the site's fixed parts by
|
|
70
|
+
* `offset`; `destroy()` takes it away at once.
|
|
71
|
+
*/
|
|
72
|
+
export function mountBanner(api, settings, language, iconUrl, offset) {
|
|
70
73
|
const lang = uiLanguage(language);
|
|
71
74
|
const t = TEXTS[lang];
|
|
72
75
|
const host = document.createElement('phoenix-notifications-banner');
|
|
73
76
|
host.dataset.position = settings.position === 'bottom' ? 'bottom' : 'top';
|
|
74
77
|
applyColors(host, colorOr(settings.color));
|
|
78
|
+
applyOffset(host, offset);
|
|
75
79
|
const shadow = host.attachShadow({ mode: 'open' });
|
|
76
80
|
// only constants go into this markup; HQ's texts and the icon's address are set as text and a property below
|
|
77
81
|
shadow.innerHTML = `<style>${STYLE}</style>
|
package/dist/bell.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SubscriptionState } from './index.js';
|
|
2
|
+
import { type PromptOffset } from './theme.js';
|
|
2
3
|
/**
|
|
3
4
|
* The floating subscribe button of the bell and logo prompts: a round button in a bottom corner of the site that subscribes
|
|
4
5
|
* this browser on a click, shows that it is subscribed and lets the person unsubscribe, or explains how to allow notifications
|
|
@@ -34,5 +35,5 @@ export interface BellApi {
|
|
|
34
35
|
export interface BellHandle {
|
|
35
36
|
destroy(): void;
|
|
36
37
|
}
|
|
37
|
-
/** Draws the button into the page
|
|
38
|
-
export declare function mountBell(api: BellApi, settings: BellSettings, language: string): BellHandle;
|
|
38
|
+
/** Draws the button into the page, clear of the site's fixed parts by `offset`; `destroy()` takes it away again. */
|
|
39
|
+
export declare function mountBell(api: BellApi, settings: BellSettings, language: string, offset?: PromptOffset): BellHandle;
|
package/dist/bell.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { applyColors, applyTheme, colorOr, ICONS, svg, uiLanguage } from './theme.js';
|
|
1
|
+
import { applyColors, applyOffset, applyTheme, colorOr, ICONS, OFFSET, svg, uiLanguage } from './theme.js';
|
|
2
2
|
const MESSAGE_MS = 3500;
|
|
3
3
|
const ATTENTION_MS = 4000;
|
|
4
4
|
const TEXTS = {
|
|
@@ -35,14 +35,15 @@ const TEXTS = {
|
|
|
35
35
|
close: 'إغلاق',
|
|
36
36
|
},
|
|
37
37
|
};
|
|
38
|
+
// the room the site asked for (OFFSET) comes on top of the usual distance from the corner
|
|
38
39
|
const STYLE = `
|
|
39
|
-
:host { all: initial; position: fixed; z-index: 2147483000; bottom: calc(20px + env(safe-area-inset-bottom, 0px)); font-family: inherit; }
|
|
40
|
-
:host([data-side="right"]) { right: calc(20px + env(safe-area-inset-right, 0px)); }
|
|
41
|
-
:host([data-side="left"]) { left: calc(20px + env(safe-area-inset-left, 0px)); }
|
|
40
|
+
:host { all: initial; position: fixed; z-index: 2147483000; bottom: calc(20px + env(safe-area-inset-bottom, 0px) + ${OFFSET.bottom}); font-family: inherit; }
|
|
41
|
+
:host([data-side="right"]) { right: calc(20px + env(safe-area-inset-right, 0px) + ${OFFSET.side}); }
|
|
42
|
+
:host([data-side="left"]) { left: calc(20px + env(safe-area-inset-left, 0px) + ${OFFSET.side}); }
|
|
42
43
|
@media (max-width: 480px) {
|
|
43
|
-
:host { bottom: calc(16px + env(safe-area-inset-bottom, 0px)); }
|
|
44
|
-
:host([data-side="right"]) { right: calc(16px + env(safe-area-inset-right, 0px)); }
|
|
45
|
-
:host([data-side="left"]) { left: calc(16px + env(safe-area-inset-left, 0px)); }
|
|
44
|
+
:host { bottom: calc(16px + env(safe-area-inset-bottom, 0px) + ${OFFSET.bottom}); }
|
|
45
|
+
:host([data-side="right"]) { right: calc(16px + env(safe-area-inset-right, 0px) + ${OFFSET.side}); }
|
|
46
|
+
:host([data-side="left"]) { left: calc(16px + env(safe-area-inset-left, 0px) + ${OFFSET.side}); }
|
|
46
47
|
}
|
|
47
48
|
@media print { :host { display: none !important; } }
|
|
48
49
|
* { box-sizing: border-box; }
|
|
@@ -74,7 +75,7 @@ button { font: inherit; margin: 0; -webkit-tap-highlight-color: transparent; }
|
|
|
74
75
|
.badge[data-kind="bell"] { background: var(--bell); color: var(--bell-text); }
|
|
75
76
|
.badge[data-kind="blocked"] { background: #6b7280; }
|
|
76
77
|
.bubble {
|
|
77
|
-
position: absolute; bottom: 7px; width: max-content; max-width: min(260px, calc(100vw - 104px)); padding: 7px 11px; border-radius: 8px;
|
|
78
|
+
position: absolute; bottom: 7px; width: max-content; max-width: min(260px, calc(100vw - 104px - ${OFFSET.side})); padding: 7px 11px; border-radius: 8px;
|
|
78
79
|
background: var(--tip); color: var(--tip-text); font-size: 13px; line-height: 1.45; text-align: start;
|
|
79
80
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25); opacity: 0; transform: translateY(4px); pointer-events: none;
|
|
80
81
|
transition: opacity 0.15s ease, transform 0.15s ease;
|
|
@@ -83,7 +84,7 @@ button { font: inherit; margin: 0; -webkit-tap-highlight-color: transparent; }
|
|
|
83
84
|
:host([data-side="left"]) .bubble { left: 60px; }
|
|
84
85
|
.bubble[data-shown] { opacity: 1; transform: none; }
|
|
85
86
|
.panel {
|
|
86
|
-
position: absolute; bottom: 60px; width: min(300px, calc(100vw - 32px)); padding: 14px 16px 16px; border-radius: 14px;
|
|
87
|
+
position: absolute; bottom: 60px; width: min(300px, calc(100vw - 32px - ${OFFSET.side})); padding: 14px 16px 16px; border-radius: 14px;
|
|
87
88
|
background: var(--panel); color: var(--text); border: 1px solid var(--border); text-align: start;
|
|
88
89
|
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.32); font-size: 14px; line-height: 1.55;
|
|
89
90
|
}
|
|
@@ -112,8 +113,8 @@ button { font: inherit; margin: 0; -webkit-tap-highlight-color: transparent; }
|
|
|
112
113
|
:host([data-attention][data-view="unsubscribed"]) .bell::after { animation: none; }
|
|
113
114
|
}
|
|
114
115
|
`;
|
|
115
|
-
/** Draws the button into the page
|
|
116
|
-
export function mountBell(api, settings, language) {
|
|
116
|
+
/** Draws the button into the page, clear of the site's fixed parts by `offset`; `destroy()` takes it away again. */
|
|
117
|
+
export function mountBell(api, settings, language, offset) {
|
|
117
118
|
const lang = uiLanguage(language);
|
|
118
119
|
const t = TEXTS[lang];
|
|
119
120
|
const logoUrl = settings.face === 'logo' && /^https?:\/\//i.test(settings.logoUrl ?? '') ? settings.logoUrl : null;
|
|
@@ -121,6 +122,7 @@ export function mountBell(api, settings, language) {
|
|
|
121
122
|
host.dataset.side = settings.position === 'bottom_left' ? 'left' : 'right';
|
|
122
123
|
host.dataset.view = 'unsubscribed';
|
|
123
124
|
applyColors(host, colorOr(settings.color));
|
|
125
|
+
applyOffset(host, offset);
|
|
124
126
|
const shadow = host.attachShadow({ mode: 'open' });
|
|
125
127
|
// only constants go into this markup (the texts and icons above, lang 'ar' or 'en'); the colour is set as a property
|
|
126
128
|
shadow.innerHTML = `<style>${STYLE}</style>
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { type StoredDevice } from './store.js';
|
|
2
|
+
import type { PromptOffset } from './theme.js';
|
|
2
3
|
/**
|
|
3
4
|
* Phoenix Notifications web SDK: subscribes the browser to web push for an app on notifications.phnx-iq.com, binds it to
|
|
4
|
-
* the signed-in user with an identity token from the app's own backend, and hands notification clicks
|
|
5
|
-
* draws the prompt HQ chose for the app (a bell, the app's logo or a
|
|
5
|
+
* the signed-in user with an identity token from the app's own backend, and hands notification clicks (and the pushes that
|
|
6
|
+
* arrive while the site is open) to the app. It also draws the prompt HQ chose for the app (a bell, the app's logo or a
|
|
7
|
+
* pop-up), so a site needs no button of its own.
|
|
6
8
|
*
|
|
7
9
|
* Nothing here throws for an unsupported browser or a denied permission: check `isSupported()` / `permission()`.
|
|
8
10
|
* The service worker file (phoenix-notifications-sw.js) must be served from the site, at `serviceWorkerPath`.
|
|
@@ -26,6 +28,12 @@ export interface InitOptions {
|
|
|
26
28
|
bell?: boolean;
|
|
27
29
|
/** The pop-up, shown when HQ chose it as the app's prompt. `false` never shows it. */
|
|
28
30
|
banner?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Room (CSS pixels) the button and the pop-up leave for the site's fixed parts: `bottom` above a bottom bar, `top` below a
|
|
33
|
+
* header (a pop-up at the top), `side` beside the button. CSS variables on the page win over it, e.g. in a media query for
|
|
34
|
+
* a bar only phones have: `--phoenix-notifications-offset-bottom`, `-top`, `-side`.
|
|
35
|
+
*/
|
|
36
|
+
offset?: PromptOffset;
|
|
29
37
|
}
|
|
30
38
|
/** How the app's sites offer notifications, as HQ chose: one prompt, or none. */
|
|
31
39
|
export type Prompt = 'none' | 'bell' | 'logo' | 'banner';
|
|
@@ -41,6 +49,14 @@ export interface NotificationClick {
|
|
|
41
49
|
data: Record<string, string>;
|
|
42
50
|
messageId?: string;
|
|
43
51
|
}
|
|
52
|
+
/** A push that arrived while the site was open. The browser shows it as a notification all the same. */
|
|
53
|
+
export interface ReceivedNotification {
|
|
54
|
+
title: string;
|
|
55
|
+
body: string;
|
|
56
|
+
url?: string;
|
|
57
|
+
data: Record<string, string>;
|
|
58
|
+
messageId?: string;
|
|
59
|
+
}
|
|
44
60
|
export declare class NotificationsError extends Error {
|
|
45
61
|
readonly status: number;
|
|
46
62
|
constructor(status: number, message: string);
|
|
@@ -75,6 +91,11 @@ export declare const Notifications: {
|
|
|
75
91
|
unsubscribe(): Promise<void>;
|
|
76
92
|
/** A notification was clicked while the site was open. Without a handler the SDK opens its link. Returns an unsubscribe function. */
|
|
77
93
|
onClick(handler: (click: NotificationClick) => void): () => void;
|
|
94
|
+
/**
|
|
95
|
+
* A push arrived while the site was open (every open tab hears it), e.g. to refresh a list or a count; the browser shows
|
|
96
|
+
* the notification as well. Returns an unsubscribe function.
|
|
97
|
+
*/
|
|
98
|
+
onPush(handler: (push: ReceivedNotification) => void): () => void;
|
|
78
99
|
/** Called when this browser subscribes, unsubscribes or the permission changes. Returns an unsubscribe function. */
|
|
79
100
|
onSubscriptionChange(handler: (state: SubscriptionState) => void): () => void;
|
|
80
101
|
/** Re-runs the subscription check now (e.g. after the person changed the site's permission). */
|
|
@@ -82,4 +103,5 @@ export declare const Notifications: {
|
|
|
82
103
|
};
|
|
83
104
|
export type { BannerPosition } from './banner.js';
|
|
84
105
|
export type { BellPosition } from './bell.js';
|
|
106
|
+
export type { PromptOffset } from './theme.js';
|
|
85
107
|
export type { StoredDevice };
|
package/dist/index.js
CHANGED
|
@@ -3,8 +3,9 @@ import { mountBell } from './bell.js';
|
|
|
3
3
|
import { keys, store } from './store.js';
|
|
4
4
|
/**
|
|
5
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
|
|
7
|
-
* draws the prompt HQ chose for the app (a bell, the app's logo or a
|
|
6
|
+
* the signed-in user with an identity token from the app's own backend, and hands notification clicks (and the pushes that
|
|
7
|
+
* arrive while the site is open) to the app. It also draws the prompt HQ chose for the app (a bell, the app's logo or a
|
|
8
|
+
* pop-up), so a site needs no button of its own.
|
|
8
9
|
*
|
|
9
10
|
* Nothing here throws for an unsupported browser or a denied permission: check `isSupported()` / `permission()`.
|
|
10
11
|
* The service worker file (phoenix-notifications-sw.js) must be served from the site, at `serviceWorkerPath`.
|
|
@@ -26,7 +27,9 @@ export class NotificationsError extends Error {
|
|
|
26
27
|
this.status = status;
|
|
27
28
|
}
|
|
28
29
|
}
|
|
30
|
+
// what the service worker posts to the site's open tabs
|
|
29
31
|
const CLICK_MESSAGE = 'phoenix-notifications:click';
|
|
32
|
+
const PUSH_MESSAGE = 'phoenix-notifications:push';
|
|
30
33
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
31
34
|
const RESYNC_AFTER_MS = DAY_MS;
|
|
32
35
|
let options = {
|
|
@@ -45,6 +48,7 @@ const SYNC_REUSE_MS = 5 * 60 * 1000;
|
|
|
45
48
|
/** The app's web config, read once per page: the subscription and the bell both need it. */
|
|
46
49
|
let config = null;
|
|
47
50
|
const clickHandlers = new Set();
|
|
51
|
+
const pushHandlers = new Set();
|
|
48
52
|
const changeHandlers = new Set();
|
|
49
53
|
let lastState = null;
|
|
50
54
|
let listening = false;
|
|
@@ -134,12 +138,30 @@ function deviceInfo() {
|
|
|
134
138
|
deviceModel: navigator.userAgent.slice(0, 200),
|
|
135
139
|
};
|
|
136
140
|
}
|
|
137
|
-
|
|
141
|
+
/** Calls every handler; one that throws does not keep the others from hearing it, and still reaches the site's error reporting. */
|
|
142
|
+
function tell(handlers, value) {
|
|
143
|
+
for (const handler of handlers) {
|
|
144
|
+
try {
|
|
145
|
+
handler(value);
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
queueMicrotask(() => {
|
|
149
|
+
throw error;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** What the service worker tells the site's open tabs: a click on a notification, or a push that arrived. */
|
|
155
|
+
function listenToWorker() {
|
|
138
156
|
if (listening)
|
|
139
157
|
return;
|
|
140
158
|
listening = true;
|
|
141
159
|
navigator.serviceWorker.addEventListener('message', (event) => {
|
|
142
160
|
const message = event.data;
|
|
161
|
+
if (message?.type === PUSH_MESSAGE) {
|
|
162
|
+
tell(pushHandlers, { title: message.title ?? '', body: message.body ?? '', url: message.url, data: message.data ?? {}, messageId: message.messageId });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
143
165
|
if (message?.type !== CLICK_MESSAGE)
|
|
144
166
|
return;
|
|
145
167
|
const click = { url: message.url, data: message.data ?? {}, messageId: message.messageId };
|
|
@@ -148,8 +170,7 @@ function listenForClicks() {
|
|
|
148
170
|
window.location.assign(click.url);
|
|
149
171
|
return;
|
|
150
172
|
}
|
|
151
|
-
|
|
152
|
-
handler(click);
|
|
173
|
+
tell(clickHandlers, click);
|
|
153
174
|
});
|
|
154
175
|
// messages from the worker wait in a queue until the page says it is listening
|
|
155
176
|
navigator.serviceWorker.startMessages();
|
|
@@ -227,7 +248,11 @@ async function sync() {
|
|
|
227
248
|
syncedAt: Date.now(),
|
|
228
249
|
};
|
|
229
250
|
await store.set(keys.device(options.appId), device);
|
|
230
|
-
|
|
251
|
+
// a new device belongs to nobody yet: the user logged in here (before the permission, or on the device it replaces) is
|
|
252
|
+
// bound to it by applyPendingLogin. Up to 1.2.0 that login was dropped, so the browser stayed a guest.
|
|
253
|
+
const login = await store.get(keys.login(options.appId));
|
|
254
|
+
if (login && login.at !== 0)
|
|
255
|
+
await store.set(keys.login(options.appId), { ...login, at: 0 });
|
|
231
256
|
return device;
|
|
232
257
|
}
|
|
233
258
|
function syncOnce(force = false) {
|
|
@@ -244,24 +269,31 @@ function syncOnce(force = false) {
|
|
|
244
269
|
});
|
|
245
270
|
return syncing;
|
|
246
271
|
}
|
|
272
|
+
let applying = null;
|
|
247
273
|
/**
|
|
248
274
|
* A login made before the person allowed notifications (or kept through an unsubscribe) is applied once there is a device.
|
|
249
275
|
* Its failure does not undo the subscription: an expired token is dropped, and the site logs in again on its next load.
|
|
276
|
+
* One at a time: when the person allows, the permission's watcher and `requestPermission` both get here.
|
|
250
277
|
*/
|
|
251
|
-
|
|
278
|
+
function applyPendingLogin(device) {
|
|
252
279
|
if (!device)
|
|
253
|
-
return;
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
280
|
+
return Promise.resolve();
|
|
281
|
+
applying ??= (async () => {
|
|
282
|
+
const pending = await store.get(keys.login(options.appId));
|
|
283
|
+
if (!pending || pending.at !== 0)
|
|
284
|
+
return;
|
|
285
|
+
try {
|
|
286
|
+
await Notifications.login(pending.token);
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
if (error instanceof NotificationsError && error.status === 401)
|
|
290
|
+
await store.remove(keys.login(options.appId));
|
|
291
|
+
console.warn('[notifications] the pending login failed', error);
|
|
292
|
+
}
|
|
293
|
+
})().finally(() => {
|
|
294
|
+
applying = null;
|
|
295
|
+
});
|
|
296
|
+
return applying;
|
|
265
297
|
}
|
|
266
298
|
/** Follows permission changes made outside the page (the site settings next to the address bar). */
|
|
267
299
|
function watchPermission() {
|
|
@@ -274,7 +306,7 @@ function watchPermission() {
|
|
|
274
306
|
return;
|
|
275
307
|
known = Notification.permission;
|
|
276
308
|
if (known === 'granted')
|
|
277
|
-
void syncOnce(true).catch(() => undefined);
|
|
309
|
+
void syncOnce(true).then(applyPendingLogin).catch(() => undefined);
|
|
278
310
|
void announce();
|
|
279
311
|
};
|
|
280
312
|
window.addEventListener('focus', changed);
|
|
@@ -302,7 +334,7 @@ async function showBell(appId) {
|
|
|
302
334
|
},
|
|
303
335
|
unsubscribe: () => Notifications.unsubscribe(),
|
|
304
336
|
onChange: (handler) => Notifications.onSubscriptionChange(handler),
|
|
305
|
-
}, { ...web.bell, face: prompt === 'logo' ? 'logo' : 'bell', logoUrl: web.iconUrl }, language());
|
|
337
|
+
}, { ...web.bell, face: prompt === 'logo' ? 'logo' : 'bell', logoUrl: web.iconUrl }, language(), options.offset);
|
|
306
338
|
}
|
|
307
339
|
catch (error) {
|
|
308
340
|
console.warn('[notifications] the subscribe button is not available', error);
|
|
@@ -349,7 +381,7 @@ async function showBanner(appId) {
|
|
|
349
381
|
await store.set(key, { ...latest, dismissedAt: Date.now() });
|
|
350
382
|
},
|
|
351
383
|
onChange: (handler) => Notifications.onSubscriptionChange(handler),
|
|
352
|
-
}, settings, language(), web?.iconUrl);
|
|
384
|
+
}, settings, language(), web?.iconUrl, options.offset);
|
|
353
385
|
}
|
|
354
386
|
catch (error) {
|
|
355
387
|
console.warn('[notifications] the subscribe pop-up is not available', error);
|
|
@@ -376,6 +408,7 @@ export const Notifications = {
|
|
|
376
408
|
serviceWorkerScope: init.serviceWorkerScope ?? '/',
|
|
377
409
|
language: init.language ?? '',
|
|
378
410
|
appVersion: init.appVersion,
|
|
411
|
+
offset: init.offset,
|
|
379
412
|
};
|
|
380
413
|
// one bell per page, for the app last initialised
|
|
381
414
|
const wantsBell = init.bell !== false;
|
|
@@ -399,7 +432,7 @@ export const Notifications = {
|
|
|
399
432
|
}
|
|
400
433
|
ready = (async () => {
|
|
401
434
|
registration = await navigator.serviceWorker.register(options.serviceWorkerPath, { scope: options.serviceWorkerScope });
|
|
402
|
-
|
|
435
|
+
listenToWorker();
|
|
403
436
|
})();
|
|
404
437
|
await ready;
|
|
405
438
|
watchPermission();
|
|
@@ -442,8 +475,9 @@ export const Notifications = {
|
|
|
442
475
|
return;
|
|
443
476
|
await ready;
|
|
444
477
|
const loginKey = keys.login(options.appId);
|
|
445
|
-
const previous = await store.get(loginKey);
|
|
446
478
|
const device = await syncOnce();
|
|
479
|
+
// read after the sync: a device it had to register anew belongs to nobody yet, however recent the last login
|
|
480
|
+
const previous = await store.get(loginKey);
|
|
447
481
|
if (!device) {
|
|
448
482
|
await store.set(loginKey, { appId: options.appId, token: identityToken, at: 0 });
|
|
449
483
|
return;
|
|
@@ -493,6 +527,14 @@ export const Notifications = {
|
|
|
493
527
|
clickHandlers.add(handler);
|
|
494
528
|
return () => clickHandlers.delete(handler);
|
|
495
529
|
},
|
|
530
|
+
/**
|
|
531
|
+
* A push arrived while the site was open (every open tab hears it), e.g. to refresh a list or a count; the browser shows
|
|
532
|
+
* the notification as well. Returns an unsubscribe function.
|
|
533
|
+
*/
|
|
534
|
+
onPush(handler) {
|
|
535
|
+
pushHandlers.add(handler);
|
|
536
|
+
return () => pushHandlers.delete(handler);
|
|
537
|
+
},
|
|
496
538
|
/** Called when this browser subscribes, unsubscribes or the permission changes. Returns an unsubscribe function. */
|
|
497
539
|
onSubscriptionChange(handler) {
|
|
498
540
|
changeHandlers.add(handler);
|
package/dist/theme.d.ts
CHANGED
|
@@ -13,6 +13,27 @@ export declare function textOn(color: string): string;
|
|
|
13
13
|
export declare function applyColors(host: HTMLElement, color: string): void;
|
|
14
14
|
/** Again when something opens: the site may have changed its look since. */
|
|
15
15
|
export declare function applyTheme(host: HTMLElement): void;
|
|
16
|
+
/**
|
|
17
|
+
* Room the bell and the banner leave for the site's own fixed parts, in CSS pixels, besides their usual distance from the edge:
|
|
18
|
+
* `bottom` for a bottom bar (the button, and a pop-up at the bottom), `top` for a header (a pop-up at the top), `side` for the
|
|
19
|
+
* button's side of the page.
|
|
20
|
+
*/
|
|
21
|
+
export interface PromptOffset {
|
|
22
|
+
top?: number;
|
|
23
|
+
bottom?: number;
|
|
24
|
+
side?: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The room on each edge, for the hosts' CSS: the page's own CSS variable when it sets one (it can, in a media query, for a bar
|
|
28
|
+
* only phones have), else the `offset` option (`applyOffset`), else none.
|
|
29
|
+
*/
|
|
30
|
+
export declare const OFFSET: {
|
|
31
|
+
top: string;
|
|
32
|
+
bottom: string;
|
|
33
|
+
side: string;
|
|
34
|
+
};
|
|
35
|
+
/** Puts the `offset` option on the host, under the page's CSS variables. */
|
|
36
|
+
export declare function applyOffset(host: HTMLElement, offset: PromptOffset | undefined): void;
|
|
16
37
|
/** The language the bell and the banner speak: Arabic for `ar…`, else English. */
|
|
17
38
|
export declare function uiLanguage(language: string): 'ar' | 'en';
|
|
18
39
|
export declare const ICONS: {
|
package/dist/theme.js
CHANGED
|
@@ -77,6 +77,22 @@ export function applyTheme(host) {
|
|
|
77
77
|
}))
|
|
78
78
|
host.style.setProperty(name, value);
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* The room on each edge, for the hosts' CSS: the page's own CSS variable when it sets one (it can, in a media query, for a bar
|
|
82
|
+
* only phones have), else the `offset` option (`applyOffset`), else none.
|
|
83
|
+
*/
|
|
84
|
+
export const OFFSET = {
|
|
85
|
+
top: 'var(--phoenix-notifications-offset-top, var(--phx-offset-top, 0px))',
|
|
86
|
+
bottom: 'var(--phoenix-notifications-offset-bottom, var(--phx-offset-bottom, 0px))',
|
|
87
|
+
side: 'var(--phoenix-notifications-offset-side, var(--phx-offset-side, 0px))',
|
|
88
|
+
};
|
|
89
|
+
/** Puts the `offset` option on the host, under the page's CSS variables. */
|
|
90
|
+
export function applyOffset(host, offset) {
|
|
91
|
+
for (const edge of ['top', 'bottom', 'side']) {
|
|
92
|
+
const value = offset?.[edge];
|
|
93
|
+
host.style.setProperty(`--phx-offset-${edge}`, `${typeof value === 'number' && Number.isFinite(value) ? value : 0}px`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
80
96
|
/** The language the bell and the banner speak: Arabic for `ar…`, else English. */
|
|
81
97
|
export function uiLanguage(language) {
|
|
82
98
|
return language.toLowerCase().startsWith('ar') ? 'ar' : 'en';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phoenix-iq/notifications",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Phoenix Notifications web SDK: browser push subscriptions, the subscribe prompt (a bell, the app's logo or a pop-up), verified sign-in and click handling for apps on notifications.phnx-iq.com.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -2,14 +2,16 @@
|
|
|
2
2
|
* Phoenix Notifications service worker. Serve it from the site's root (default path /phoenix-notifications-sw.js) so it
|
|
3
3
|
* controls the whole site. Plain JavaScript on purpose: copy it as it is, no build step.
|
|
4
4
|
*
|
|
5
|
-
* It shows the pushes the site receives
|
|
6
|
-
* the page, which routes the click) or opens the link, and
|
|
5
|
+
* It shows the pushes the site receives (and tells the site's open tabs, for Notifications.onPush), reports clicks to the
|
|
6
|
+
* notifications API, focuses an open tab of the site (and tells the page, which routes the click) or opens the link, and
|
|
7
|
+
* renews the subscription when the browser replaces it.
|
|
7
8
|
* Its state is shared with the SDK through IndexedDB ("phoenix-notifications" / "state"); keep the two in step.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
const DB_NAME = 'phoenix-notifications';
|
|
11
12
|
const STORE = 'state';
|
|
12
13
|
const CLICK_MESSAGE = 'phoenix-notifications:click';
|
|
14
|
+
const PUSH_MESSAGE = 'phoenix-notifications:push';
|
|
13
15
|
|
|
14
16
|
function openDb() {
|
|
15
17
|
return new Promise((resolve, reject) => {
|
|
@@ -89,9 +91,24 @@ self.addEventListener('push', (event) => {
|
|
|
89
91
|
tag: payload.m || undefined,
|
|
90
92
|
data: { id: payload.id, m: payload.m, url: payload.url, data: payload.data || {} },
|
|
91
93
|
};
|
|
92
|
-
event.waitUntil(
|
|
94
|
+
event.waitUntil(
|
|
95
|
+
Promise.all([
|
|
96
|
+
self.registration.showNotification(payload.title || '', options),
|
|
97
|
+
tellOpenTabs({ type: PUSH_MESSAGE, title: payload.title || '', body: payload.body || '', url: payload.url, data: payload.data || {}, messageId: payload.m }),
|
|
98
|
+
]),
|
|
99
|
+
);
|
|
93
100
|
});
|
|
94
101
|
|
|
102
|
+
/** Every open tab of the site hears of the push (the SDK's onPush), e.g. to refresh a list; the notification shows regardless. */
|
|
103
|
+
async function tellOpenTabs(message) {
|
|
104
|
+
try {
|
|
105
|
+
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
|
106
|
+
for (const client of windows) client.postMessage(message);
|
|
107
|
+
} catch {
|
|
108
|
+
// the tabs only miss a refresh
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
95
112
|
self.addEventListener('notificationclick', (event) => {
|
|
96
113
|
event.notification.close();
|
|
97
114
|
const details = event.notification.data || {};
|
|
@@ -114,7 +131,8 @@ self.addEventListener('notificationclick', (event) => {
|
|
|
114
131
|
open.postMessage({ type: CLICK_MESSAGE, url: details.url, data: details.data || {}, messageId: details.m });
|
|
115
132
|
return;
|
|
116
133
|
}
|
|
117
|
-
|
|
134
|
+
// no link in the push nor a default one in HQ: the site's home page (the worker's scope may be a folder of its own)
|
|
135
|
+
await self.clients.openWindow(details.url || new URL('/', self.location.origin).href);
|
|
118
136
|
})(),
|
|
119
137
|
);
|
|
120
138
|
});
|