@phoenix-iq/notifications 1.1.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 +63 -32
- package/dist/banner.d.ts +9 -11
- package/dist/banner.js +11 -12
- package/dist/bell.d.ts +14 -6
- package/dist/bell.js +55 -15
- package/dist/index.d.ts +31 -7
- package/dist/index.js +84 -34
- package/dist/theme.d.ts +21 -0
- package/dist/theme.js +16 -0
- package/package.json +2 -2
- package/phoenix-notifications-sw.js +22 -4
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# @phoenix-iq/notifications (web SDK)
|
|
2
2
|
|
|
3
|
-
Browser push for apps on `notifications.phnx-iq.com
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
dependencies.
|
|
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 (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.
|
|
7
7
|
|
|
8
8
|
How the whole service works, the server API and the identity tokens: [docs/notifications.md](../../docs/notifications.md).
|
|
9
9
|
|
|
@@ -25,7 +25,7 @@ import { Notifications } from '@phoenix-iq/notifications';
|
|
|
25
25
|
|
|
26
26
|
await Notifications.init({ appId: '<app id from HQ: Notification management>' });
|
|
27
27
|
|
|
28
|
-
// from a click ("Get notifications"): browsers only
|
|
28
|
+
// from a click on your own button ("Get notifications"): browsers only ask on a user gesture
|
|
29
29
|
const permission = await Notifications.requestPermission(); // 'granted' | 'denied' | 'default' | 'unsupported'
|
|
30
30
|
|
|
31
31
|
// after your own sign-in, with the identity token your backend returned
|
|
@@ -36,30 +36,44 @@ await Notifications.logout();
|
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
`init` subscribes quietly when the person already allowed notifications (and did not unsubscribe here). `login` before the
|
|
39
|
-
permission is granted is kept and applied once it is.
|
|
40
|
-
`
|
|
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.
|
|
42
|
+
|
|
43
|
+
## The subscribe prompt
|
|
44
|
+
|
|
45
|
+
HQ chooses one per app (the app → Web push → How visitors subscribe) and `init` draws it; a change shows on the next page load.
|
|
46
|
+
|
|
47
|
+
- **Bell** or **logo**: a round button in the bottom right or left corner, in the colour HQ set, showing a bell, or the app's
|
|
48
|
+
icon with a small bell on it (a logo that fails to load gives way to the bell). The first click asks the browser; then the
|
|
49
|
+
button shows that this browser is subscribed and offers "Unsubscribe", or, when notifications were blocked, says how to
|
|
50
|
+
allow them again. With *Hide after subscribing*, only browsers that are not subscribed see it.
|
|
51
|
+
- **Pop-up**: shown, when its time comes, to a browser never asked: a card at the top or bottom with a message, "Subscribe"
|
|
52
|
+
(the browser's own question) and "Later". HQ sets the delay after the page opens, the page view it starts from and how many
|
|
53
|
+
days "Later" keeps it away; the SDK counts these in the browser (IndexedDB). Texts left empty are the SDK's own in the
|
|
54
|
+
page's language. On a phone a top pop-up comes from the bottom, clear of your header; the strip around the card lets
|
|
55
|
+
clicks through.
|
|
56
|
+
- **None**: nothing is drawn. Ask from your own button with `requestPermission()`, and use `unsubscribe()` and the state
|
|
57
|
+
below.
|
|
58
|
+
|
|
59
|
+
They speak the page's language (`<html lang="ar">` → Arabic, right to left; else English, or the `language` option), take
|
|
60
|
+
the page's font, pick a light or dark look from the page's background, live in a shadow root (your CSS and theirs stay apart)
|
|
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
|
|
62
|
+
HQ chose it.
|
|
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:
|
|
41
67
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
corner, in the colour you chose. The first click asks the browser; then the bell shows that this browser is subscribed and
|
|
46
|
-
offers "Unsubscribe", or, when notifications were blocked, says how to allow them again. It speaks the page's language
|
|
47
|
-
(`<html lang="ar">` → Arabic, right to left; else English, or the `language` option), takes the page's font, and picks a
|
|
48
|
-
light or dark panel from the page's background. It sits in a shadow root, so your CSS and its CSS stay apart, and it is
|
|
49
|
-
hidden when printing. A change in HQ shows on the next page load. With *Hide the bell once subscribed*, only browsers that
|
|
50
|
-
are not subscribed see it.
|
|
51
|
-
|
|
52
|
-
## The subscription banner
|
|
53
|
-
|
|
54
|
-
OneSignal's slide prompt. Switch it on in HQ (the app → Web push → Subscription banner) and `init` shows it, when its time
|
|
55
|
-
comes, to a browser never asked: a card at the top or bottom with a message, "Subscribe" (the browser's own question) and
|
|
56
|
-
"Later". HQ sets the delay after the page opens, the page view it starts from and how many days "Later" keeps it away; the
|
|
57
|
-
SDK counts these in the browser (IndexedDB). Texts left empty are the SDK's own in the page's language. On a phone a top
|
|
58
|
-
banner comes from the bottom, clear of your header, and a bottom one sits above the bell. Like the bell it lives in a shadow
|
|
59
|
-
root; the strip around the card lets clicks through.
|
|
68
|
+
```ts
|
|
69
|
+
await Notifications.init({ appId, offset: { bottom: 64 } }); // also `top` (a pop-up at the top) and `side` (the button)
|
|
70
|
+
```
|
|
60
71
|
|
|
61
|
-
|
|
62
|
-
|
|
72
|
+
```css
|
|
73
|
+
@media (max-width: 1023px) {
|
|
74
|
+
:root { --phoenix-notifications-offset-bottom: 64px; } /* and -offset-top, -offset-side */
|
|
75
|
+
}
|
|
76
|
+
```
|
|
63
77
|
|
|
64
78
|
## Subscription state
|
|
65
79
|
|
|
@@ -82,7 +96,18 @@ Notifications.onClick(({ url, data }) => router.navigate(new URL(url ?? '/', loc
|
|
|
82
96
|
```
|
|
83
97
|
|
|
84
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.
|
|
85
|
-
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.
|
|
86
111
|
|
|
87
112
|
## Options
|
|
88
113
|
|
|
@@ -92,17 +117,23 @@ With no tab open, the browser opens the link directly.
|
|
|
92
117
|
| `serverUrl` | `https://notifications.phnx-iq.com` | `''` uses your own origin (development behind a proxy) |
|
|
93
118
|
| `serviceWorkerPath` | `/phoenix-notifications-sw.js` | |
|
|
94
119
|
| `serviceWorkerScope` | `/` | |
|
|
95
|
-
| `language` | the page's (`<html lang>`), then the browser's | Picks the Arabic or English text of a notification, and the
|
|
120
|
+
| `language` | the page's (`<html lang>`), then the browser's | Picks the Arabic or English text of a notification, and the prompt's language |
|
|
96
121
|
| `appVersion` | none | Stored with the device; in-app messages can target versions below one |
|
|
97
|
-
| `bell` | as HQ
|
|
98
|
-
| `banner` | as HQ
|
|
122
|
+
| `bell` | as HQ chose | `false` never draws the floating button (bell or logo) |
|
|
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 |
|
|
99
125
|
|
|
100
126
|
## Notes
|
|
101
127
|
|
|
102
128
|
- iPhone and iPad: web push works only for a site added to the home screen (iOS 16.4+), as with any web push provider.
|
|
103
129
|
- The device secret the server gives the browser is kept in IndexedDB; it only lets this browser manage its own subscription.
|
|
104
130
|
- Your site must be in the app's sites (the app → Web push), or registration is refused.
|
|
105
|
-
- `example/bell.html` draws the bell and the
|
|
131
|
+
- `example/bell.html` draws the button (bell or logo) and the pop-up in every state without a server (serve `sdk/web`, open
|
|
132
|
+
`/example/bell.html`).
|
|
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
|
|
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).
|
|
106
137
|
|
|
107
138
|
## Publishing
|
|
108
139
|
|
package/dist/banner.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { SubscriptionState } from './index.js';
|
|
2
|
+
import { type PromptOffset } from './theme.js';
|
|
2
3
|
/**
|
|
3
|
-
* The
|
|
4
|
-
*
|
|
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,
|
|
5
|
+
* "Subscribe" and "Later". "Subscribe" opens the browser's own question; "Later" keeps it away for some days. The SDK
|
|
5
6
|
* decides when it shows (a browser never asked, from a page view, after a delay); this only draws it and answers its buttons.
|
|
6
7
|
*
|
|
7
|
-
* Like the
|
|
8
|
+
* Like the floating button it lives in a shadow root, takes the page's font and language and a light or dark look to suit the page.
|
|
8
9
|
* Texts HQ wrote are shown as they are; the empty ones are the SDK's own, in the page's language.
|
|
9
10
|
*/
|
|
10
11
|
export type BannerPosition = 'top' | 'bottom';
|
|
@@ -31,11 +32,8 @@ export interface BannerApi {
|
|
|
31
32
|
export interface BannerHandle {
|
|
32
33
|
destroy(): void;
|
|
33
34
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
/** Slides the banner in; `destroy()` takes it away at once. */
|
|
41
|
-
export declare function mountBanner(api: BannerApi, settings: BannerSettings, language: string, placement?: BannerPlacement): BannerHandle;
|
|
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; }
|
|
@@ -41,11 +41,9 @@ button { font: inherit; margin: 0; -webkit-tap-highlight-color: transparent; }
|
|
|
41
41
|
:host([data-position="top"]) .card { transform: translateY(-24px); }
|
|
42
42
|
:host([data-position="bottom"]) .card { transform: translateY(24px); }
|
|
43
43
|
@media (max-width: 600px) {
|
|
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)); }
|
|
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) + ${OFFSET.bottom}); }
|
|
46
46
|
:host([data-position="top"]) .card { transform: translateY(24px); }
|
|
47
|
-
/* and where it would cover the bell in its corner, it sits above it */
|
|
48
|
-
:host([data-above-bell]) { bottom: calc(84px + env(safe-area-inset-bottom, 0px)); }
|
|
49
47
|
}
|
|
50
48
|
:host([data-shown]) .card { opacity: 1; transform: none; }
|
|
51
49
|
.icon {
|
|
@@ -67,16 +65,17 @@ button { font: inherit; margin: 0; -webkit-tap-highlight-color: transparent; }
|
|
|
67
65
|
.card { transition: opacity 0.2s ease; transform: none !important; }
|
|
68
66
|
}
|
|
69
67
|
`;
|
|
70
|
-
/**
|
|
71
|
-
|
|
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) {
|
|
72
73
|
const lang = uiLanguage(language);
|
|
73
74
|
const t = TEXTS[lang];
|
|
74
|
-
const { iconUrl, aboveBell } = placement;
|
|
75
75
|
const host = document.createElement('phoenix-notifications-banner');
|
|
76
76
|
host.dataset.position = settings.position === 'bottom' ? 'bottom' : 'top';
|
|
77
|
-
if (aboveBell)
|
|
78
|
-
host.dataset.aboveBell = '';
|
|
79
77
|
applyColors(host, colorOr(settings.color));
|
|
78
|
+
applyOffset(host, offset);
|
|
80
79
|
const shadow = host.attachShadow({ mode: 'open' });
|
|
81
80
|
// only constants go into this markup; HQ's texts and the icon's address are set as text and a property below
|
|
82
81
|
shadow.innerHTML = `<style>${STYLE}</style>
|
package/dist/bell.d.ts
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
import type { SubscriptionState } from './index.js';
|
|
2
|
+
import { type PromptOffset } from './theme.js';
|
|
2
3
|
/**
|
|
3
|
-
* The
|
|
4
|
-
* browser on a click, shows that it is subscribed and lets the person unsubscribe, or explains how to allow notifications
|
|
5
|
-
* again once they blocked them.
|
|
4
|
+
* The floating subscribe button of the bell and logo prompts: a round button in a bottom corner of the site that subscribes
|
|
5
|
+
* this browser on a click, shows that it is subscribed and lets the person unsubscribe, or explains how to allow notifications
|
|
6
|
+
* again once they blocked them. It shows a bell, or the app's logo with a small bell on it. HQ chooses the prompt per app;
|
|
7
|
+
* the SDK draws it from the web config.
|
|
6
8
|
*
|
|
7
9
|
* It lives in a shadow root, so the site's CSS cannot reach it and its CSS cannot leak out. It takes the page's font and
|
|
8
10
|
* language (Arabic right to left, else English), the colour HQ gave it, and a light or dark panel to suit the page.
|
|
9
11
|
*/
|
|
10
12
|
export type BellPosition = 'bottom_right' | 'bottom_left';
|
|
11
|
-
/**
|
|
13
|
+
/** What the button shows: a bell, or the app's logo (its icon). */
|
|
14
|
+
export type BellFace = 'bell' | 'logo';
|
|
15
|
+
/** The button as HQ set it up (the web config's `bell`). */
|
|
12
16
|
export interface BellSettings {
|
|
13
17
|
position: BellPosition;
|
|
14
18
|
/** `#rrggbb`; null: the SDK's blue. */
|
|
15
19
|
color?: string | null;
|
|
16
20
|
/** Only browsers that are not subscribed see it (after its thanks, a browser that subscribes no longer does). */
|
|
17
21
|
hideWhenSubscribed?: boolean;
|
|
22
|
+
/** `bell` unless the logo prompt is chosen. */
|
|
23
|
+
face?: BellFace;
|
|
24
|
+
/** The logo's address (the app's icon); without one, or when it fails to load, the button shows the bell. */
|
|
25
|
+
logoUrl?: string | null;
|
|
18
26
|
}
|
|
19
27
|
/** What the bell needs from the SDK. */
|
|
20
28
|
export interface BellApi {
|
|
@@ -27,5 +35,5 @@ export interface BellApi {
|
|
|
27
35
|
export interface BellHandle {
|
|
28
36
|
destroy(): void;
|
|
29
37
|
}
|
|
30
|
-
/** Draws the
|
|
31
|
-
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; }
|
|
@@ -66,8 +67,15 @@ button { font: inherit; margin: 0; -webkit-tap-highlight-color: transparent; }
|
|
|
66
67
|
position: absolute; top: -3px; right: -3px; display: grid; place-items: center; width: 18px; height: 18px; border-radius: 50%;
|
|
67
68
|
background: #16a34a; color: #ffffff; border: 2px solid #ffffff;
|
|
68
69
|
}
|
|
70
|
+
/* the logo face: the app's icon on a white disc ringed in the colour; its badge says what the button is for */
|
|
71
|
+
:host([data-face="logo"]) .bell { background: #ffffff; box-shadow: 0 0 0 2px var(--bell), 0 6px 20px rgba(0, 0, 0, 0.28), 0 1px 3px rgba(0, 0, 0, 0.22); }
|
|
72
|
+
:host([data-face="logo"]) .icon { width: 100%; height: 100%; border-radius: 50%; overflow: hidden; }
|
|
73
|
+
.logo { display: block; width: 100%; height: 100%; object-fit: cover; }
|
|
74
|
+
:host([data-face="logo"][data-view="blocked"]) .logo { filter: grayscale(1); }
|
|
75
|
+
.badge[data-kind="bell"] { background: var(--bell); color: var(--bell-text); }
|
|
76
|
+
.badge[data-kind="blocked"] { background: #6b7280; }
|
|
69
77
|
.bubble {
|
|
70
|
-
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;
|
|
71
79
|
background: var(--tip); color: var(--tip-text); font-size: 13px; line-height: 1.45; text-align: start;
|
|
72
80
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25); opacity: 0; transform: translateY(4px); pointer-events: none;
|
|
73
81
|
transition: opacity 0.15s ease, transform 0.15s ease;
|
|
@@ -76,7 +84,7 @@ button { font: inherit; margin: 0; -webkit-tap-highlight-color: transparent; }
|
|
|
76
84
|
:host([data-side="left"]) .bubble { left: 60px; }
|
|
77
85
|
.bubble[data-shown] { opacity: 1; transform: none; }
|
|
78
86
|
.panel {
|
|
79
|
-
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;
|
|
80
88
|
background: var(--panel); color: var(--text); border: 1px solid var(--border); text-align: start;
|
|
81
89
|
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.32); font-size: 14px; line-height: 1.55;
|
|
82
90
|
}
|
|
@@ -105,14 +113,16 @@ button { font: inherit; margin: 0; -webkit-tap-highlight-color: transparent; }
|
|
|
105
113
|
:host([data-attention][data-view="unsubscribed"]) .bell::after { animation: none; }
|
|
106
114
|
}
|
|
107
115
|
`;
|
|
108
|
-
/** Draws the
|
|
109
|
-
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) {
|
|
110
118
|
const lang = uiLanguage(language);
|
|
111
119
|
const t = TEXTS[lang];
|
|
120
|
+
const logoUrl = settings.face === 'logo' && /^https?:\/\//i.test(settings.logoUrl ?? '') ? settings.logoUrl : null;
|
|
112
121
|
const host = document.createElement('phoenix-notifications-bell');
|
|
113
122
|
host.dataset.side = settings.position === 'bottom_left' ? 'left' : 'right';
|
|
114
123
|
host.dataset.view = 'unsubscribed';
|
|
115
124
|
applyColors(host, colorOr(settings.color));
|
|
125
|
+
applyOffset(host, offset);
|
|
116
126
|
const shadow = host.attachShadow({ mode: 'open' });
|
|
117
127
|
// only constants go into this markup (the texts and icons above, lang 'ar' or 'en'); the colour is set as a property
|
|
118
128
|
shadow.innerHTML = `<style>${STYLE}</style>
|
|
@@ -146,18 +156,48 @@ export function mountBell(api, settings, language) {
|
|
|
146
156
|
let iconFor = null;
|
|
147
157
|
/** Hidden until the first read of the state, so it never shows a wrong state (or shows at all, when it hides) first. */
|
|
148
158
|
let settled = false;
|
|
159
|
+
/** The logo while it loads and after; a logo that fails to load gives way to the bell. */
|
|
160
|
+
let face = logoUrl ? 'logo' : 'bell';
|
|
161
|
+
let logo = null;
|
|
149
162
|
const viewOf = (state) => (state.permission === 'denied' ? 'blocked' : state.subscribed ? 'subscribed' : 'unsubscribed');
|
|
163
|
+
function drawFace() {
|
|
164
|
+
if (face === 'bell') {
|
|
165
|
+
icon.innerHTML = svg(view === 'blocked' ? ICONS.bellOff : ICONS.bell, 24);
|
|
166
|
+
badge.innerHTML = svg(ICONS.check, 11, 3);
|
|
167
|
+
delete badge.dataset.kind;
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (!logo) {
|
|
171
|
+
logo = document.createElement('img');
|
|
172
|
+
logo.className = 'logo';
|
|
173
|
+
logo.alt = '';
|
|
174
|
+
logo.src = logoUrl;
|
|
175
|
+
logo.addEventListener('error', () => {
|
|
176
|
+
face = 'bell';
|
|
177
|
+
iconFor = null;
|
|
178
|
+
render();
|
|
179
|
+
}, { once: true });
|
|
180
|
+
}
|
|
181
|
+
icon.replaceChildren(logo);
|
|
182
|
+
// the logo alone does not say "notifications": a bell on it does, a check once subscribed, a struck bell once blocked
|
|
183
|
+
badge.innerHTML = view === 'subscribed' ? svg(ICONS.check, 11, 3) : svg(view === 'blocked' ? ICONS.bellOff : ICONS.bell, 11, 2.4);
|
|
184
|
+
if (view === 'subscribed')
|
|
185
|
+
delete badge.dataset.kind;
|
|
186
|
+
else
|
|
187
|
+
badge.dataset.kind = view === 'blocked' ? 'blocked' : 'bell';
|
|
188
|
+
}
|
|
150
189
|
function render() {
|
|
151
190
|
host.dataset.view = view;
|
|
191
|
+
host.dataset.face = face;
|
|
152
192
|
const hidden = !settled || (!!settings.hideWhenSubscribed && view === 'subscribed' && !open && !busy && !message);
|
|
153
193
|
host.style.display = hidden ? 'none' : '';
|
|
154
194
|
bell.setAttribute('aria-label', t[view]);
|
|
155
195
|
bell.setAttribute('aria-expanded', String(open));
|
|
156
|
-
if (iconFor !== view) {
|
|
157
|
-
|
|
158
|
-
iconFor = view
|
|
196
|
+
if (iconFor !== `${face}:${view}`) {
|
|
197
|
+
drawFace();
|
|
198
|
+
iconFor = `${face}:${view}`;
|
|
159
199
|
}
|
|
160
|
-
badge.hidden = view !== 'subscribed';
|
|
200
|
+
badge.hidden = face === 'bell' && view !== 'subscribed';
|
|
161
201
|
panel.hidden = !open;
|
|
162
202
|
if (!open)
|
|
163
203
|
return;
|
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
|
-
*
|
|
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`.
|
|
@@ -16,17 +18,25 @@ export interface InitOptions {
|
|
|
16
18
|
serverUrl?: string;
|
|
17
19
|
serviceWorkerPath?: string;
|
|
18
20
|
serviceWorkerScope?: string;
|
|
19
|
-
/** Language the device reports (picks Arabic or English text, and the
|
|
21
|
+
/** Language the device reports (picks Arabic or English text, and the prompt's language); defaults to the page's, then the browser's. */
|
|
20
22
|
language?: string;
|
|
21
23
|
appVersion?: string;
|
|
22
24
|
/**
|
|
23
|
-
* The
|
|
24
|
-
* never draws it
|
|
25
|
+
* The floating button (a bell or the app's logo), drawn when HQ chose one of those as the app's prompt (the app → Web push
|
|
26
|
+
* → How visitors subscribe). `false` never draws it. A site with its own button usually has "None" chosen in HQ instead.
|
|
25
27
|
*/
|
|
26
28
|
bell?: boolean;
|
|
27
|
-
/** The
|
|
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
|
}
|
|
38
|
+
/** How the app's sites offer notifications, as HQ chose: one prompt, or none. */
|
|
39
|
+
export type Prompt = 'none' | 'bell' | 'logo' | 'banner';
|
|
30
40
|
export type PushPermission = 'granted' | 'denied' | 'default' | 'unsupported';
|
|
31
41
|
/** Whether this browser gets the app's notifications, for a site's own button. */
|
|
32
42
|
export interface SubscriptionState {
|
|
@@ -39,6 +49,14 @@ export interface NotificationClick {
|
|
|
39
49
|
data: Record<string, string>;
|
|
40
50
|
messageId?: string;
|
|
41
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
|
+
}
|
|
42
60
|
export declare class NotificationsError extends Error {
|
|
43
61
|
readonly status: number;
|
|
44
62
|
constructor(status: number, message: string);
|
|
@@ -47,7 +65,7 @@ export declare const Notifications: {
|
|
|
47
65
|
/** True when this browser can receive web push here (a secure context with service workers and the Push API). */
|
|
48
66
|
isSupported(): boolean;
|
|
49
67
|
/**
|
|
50
|
-
* Registers the service worker, and draws the
|
|
68
|
+
* Registers the service worker, and draws the prompt HQ chose for the app (a bell, the logo or a pop-up). When the person
|
|
51
69
|
* already allowed notifications (and did not unsubscribe here), subscribes quietly.
|
|
52
70
|
*/
|
|
53
71
|
init(init: InitOptions): Promise<void>;
|
|
@@ -73,6 +91,11 @@ export declare const Notifications: {
|
|
|
73
91
|
unsubscribe(): Promise<void>;
|
|
74
92
|
/** A notification was clicked while the site was open. Without a handler the SDK opens its link. Returns an unsubscribe function. */
|
|
75
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;
|
|
76
99
|
/** Called when this browser subscribes, unsubscribes or the permission changes. Returns an unsubscribe function. */
|
|
77
100
|
onSubscriptionChange(handler: (state: SubscriptionState) => void): () => void;
|
|
78
101
|
/** Re-runs the subscription check now (e.g. after the person changed the site's permission). */
|
|
@@ -80,4 +103,5 @@ export declare const Notifications: {
|
|
|
80
103
|
};
|
|
81
104
|
export type { BannerPosition } from './banner.js';
|
|
82
105
|
export type { BellPosition } from './bell.js';
|
|
106
|
+
export type { PromptOffset } from './theme.js';
|
|
83
107
|
export type { StoredDevice };
|
package/dist/index.js
CHANGED
|
@@ -3,14 +3,22 @@ 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
|
-
*
|
|
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`.
|
|
11
12
|
*/
|
|
12
13
|
export const DEFAULT_SERVER_URL = 'https://notifications.phnx-iq.com';
|
|
13
14
|
export const DEFAULT_SERVICE_WORKER_PATH = '/phoenix-notifications-sw.js';
|
|
15
|
+
function promptOf(web) {
|
|
16
|
+
if (!web)
|
|
17
|
+
return 'none';
|
|
18
|
+
if (web.prompt)
|
|
19
|
+
return web.prompt;
|
|
20
|
+
return web.bell?.enabled ? 'bell' : web.banner?.enabled ? 'banner' : 'none';
|
|
21
|
+
}
|
|
14
22
|
export class NotificationsError extends Error {
|
|
15
23
|
status;
|
|
16
24
|
constructor(status, message) {
|
|
@@ -19,7 +27,9 @@ export class NotificationsError extends Error {
|
|
|
19
27
|
this.status = status;
|
|
20
28
|
}
|
|
21
29
|
}
|
|
30
|
+
// what the service worker posts to the site's open tabs
|
|
22
31
|
const CLICK_MESSAGE = 'phoenix-notifications:click';
|
|
32
|
+
const PUSH_MESSAGE = 'phoenix-notifications:push';
|
|
23
33
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
24
34
|
const RESYNC_AFTER_MS = DAY_MS;
|
|
25
35
|
let options = {
|
|
@@ -38,6 +48,7 @@ const SYNC_REUSE_MS = 5 * 60 * 1000;
|
|
|
38
48
|
/** The app's web config, read once per page: the subscription and the bell both need it. */
|
|
39
49
|
let config = null;
|
|
40
50
|
const clickHandlers = new Set();
|
|
51
|
+
const pushHandlers = new Set();
|
|
41
52
|
const changeHandlers = new Set();
|
|
42
53
|
let lastState = null;
|
|
43
54
|
let listening = false;
|
|
@@ -127,12 +138,30 @@ function deviceInfo() {
|
|
|
127
138
|
deviceModel: navigator.userAgent.slice(0, 200),
|
|
128
139
|
};
|
|
129
140
|
}
|
|
130
|
-
|
|
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() {
|
|
131
156
|
if (listening)
|
|
132
157
|
return;
|
|
133
158
|
listening = true;
|
|
134
159
|
navigator.serviceWorker.addEventListener('message', (event) => {
|
|
135
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
|
+
}
|
|
136
165
|
if (message?.type !== CLICK_MESSAGE)
|
|
137
166
|
return;
|
|
138
167
|
const click = { url: message.url, data: message.data ?? {}, messageId: message.messageId };
|
|
@@ -141,8 +170,7 @@ function listenForClicks() {
|
|
|
141
170
|
window.location.assign(click.url);
|
|
142
171
|
return;
|
|
143
172
|
}
|
|
144
|
-
|
|
145
|
-
handler(click);
|
|
173
|
+
tell(clickHandlers, click);
|
|
146
174
|
});
|
|
147
175
|
// messages from the worker wait in a queue until the page says it is listening
|
|
148
176
|
navigator.serviceWorker.startMessages();
|
|
@@ -220,7 +248,11 @@ async function sync() {
|
|
|
220
248
|
syncedAt: Date.now(),
|
|
221
249
|
};
|
|
222
250
|
await store.set(keys.device(options.appId), device);
|
|
223
|
-
|
|
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 });
|
|
224
256
|
return device;
|
|
225
257
|
}
|
|
226
258
|
function syncOnce(force = false) {
|
|
@@ -237,24 +269,31 @@ function syncOnce(force = false) {
|
|
|
237
269
|
});
|
|
238
270
|
return syncing;
|
|
239
271
|
}
|
|
272
|
+
let applying = null;
|
|
240
273
|
/**
|
|
241
274
|
* A login made before the person allowed notifications (or kept through an unsubscribe) is applied once there is a device.
|
|
242
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.
|
|
243
277
|
*/
|
|
244
|
-
|
|
278
|
+
function applyPendingLogin(device) {
|
|
245
279
|
if (!device)
|
|
246
|
-
return;
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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;
|
|
258
297
|
}
|
|
259
298
|
/** Follows permission changes made outside the page (the site settings next to the address bar). */
|
|
260
299
|
function watchPermission() {
|
|
@@ -267,7 +306,7 @@ function watchPermission() {
|
|
|
267
306
|
return;
|
|
268
307
|
known = Notification.permission;
|
|
269
308
|
if (known === 'granted')
|
|
270
|
-
void syncOnce(true).catch(() => undefined);
|
|
309
|
+
void syncOnce(true).then(applyPendingLogin).catch(() => undefined);
|
|
271
310
|
void announce();
|
|
272
311
|
};
|
|
273
312
|
window.addEventListener('focus', changed);
|
|
@@ -276,11 +315,12 @@ function watchPermission() {
|
|
|
276
315
|
.then((status) => status.addEventListener('change', changed))
|
|
277
316
|
.catch(() => undefined);
|
|
278
317
|
}
|
|
279
|
-
/** Draws the bell
|
|
318
|
+
/** Draws the floating button (a bell, or the app's logo) when it is the app's prompt. A failure only means no button. */
|
|
280
319
|
async function showBell(appId) {
|
|
281
320
|
try {
|
|
282
321
|
const web = await webConfig();
|
|
283
|
-
|
|
322
|
+
const prompt = promptOf(web);
|
|
323
|
+
if (bell?.appId !== appId || !web?.bell || (prompt !== 'bell' && prompt !== 'logo'))
|
|
284
324
|
return;
|
|
285
325
|
if (document.readyState === 'loading')
|
|
286
326
|
await new Promise((resolve) => document.addEventListener('DOMContentLoaded', resolve, { once: true }));
|
|
@@ -294,10 +334,10 @@ async function showBell(appId) {
|
|
|
294
334
|
},
|
|
295
335
|
unsubscribe: () => Notifications.unsubscribe(),
|
|
296
336
|
onChange: (handler) => Notifications.onSubscriptionChange(handler),
|
|
297
|
-
}, web.bell, language());
|
|
337
|
+
}, { ...web.bell, face: prompt === 'logo' ? 'logo' : 'bell', logoUrl: web.iconUrl }, language(), options.offset);
|
|
298
338
|
}
|
|
299
339
|
catch (error) {
|
|
300
|
-
console.warn('[notifications] the
|
|
340
|
+
console.warn('[notifications] the subscribe button is not available', error);
|
|
301
341
|
}
|
|
302
342
|
}
|
|
303
343
|
function whenDocumentReady() {
|
|
@@ -306,15 +346,15 @@ function whenDocumentReady() {
|
|
|
306
346
|
return new Promise((resolve) => document.addEventListener('DOMContentLoaded', () => resolve(), { once: true }));
|
|
307
347
|
}
|
|
308
348
|
/**
|
|
309
|
-
* Slides the
|
|
310
|
-
* unsubscribe here), from the page view HQ set, not within the days after a "Later", and after the delay.
|
|
311
|
-
* view. A failure only means no
|
|
349
|
+
* Slides the pop-up in when it is the app's prompt and its time has come: only for a browser never asked (the permission still
|
|
350
|
+
* to be given, and no unsubscribe here), from the page view HQ set, not within the days after a "Later", and after the delay.
|
|
351
|
+
* Each call is one page view. A failure only means no pop-up.
|
|
312
352
|
*/
|
|
313
353
|
async function showBanner(appId) {
|
|
314
354
|
try {
|
|
315
355
|
const web = await webConfig();
|
|
316
356
|
const settings = web?.banner;
|
|
317
|
-
if (banner?.appId !== appId || !settings
|
|
357
|
+
if (banner?.appId !== appId || !settings || promptOf(web) !== 'banner')
|
|
318
358
|
return;
|
|
319
359
|
if (Notification.permission !== 'default' || (await optedOut()))
|
|
320
360
|
return;
|
|
@@ -328,7 +368,7 @@ async function showBanner(appId) {
|
|
|
328
368
|
return;
|
|
329
369
|
await new Promise((resolve) => setTimeout(resolve, Math.max(0, settings.delaySeconds) * 1000));
|
|
330
370
|
await whenDocumentReady();
|
|
331
|
-
// the page may have moved on meanwhile: another app, the person subscribed with the
|
|
371
|
+
// the page may have moved on meanwhile: another app, the person subscribed with the site's own button, or answered the browser
|
|
332
372
|
if (banner?.appId !== appId || banner.handle || Notification.permission !== 'default' || (await optedOut()))
|
|
333
373
|
return;
|
|
334
374
|
banner.handle = mountBanner({
|
|
@@ -341,10 +381,10 @@ async function showBanner(appId) {
|
|
|
341
381
|
await store.set(key, { ...latest, dismissedAt: Date.now() });
|
|
342
382
|
},
|
|
343
383
|
onChange: (handler) => Notifications.onSubscriptionChange(handler),
|
|
344
|
-
}, settings, language(),
|
|
384
|
+
}, settings, language(), web?.iconUrl, options.offset);
|
|
345
385
|
}
|
|
346
386
|
catch (error) {
|
|
347
|
-
console.warn('[notifications] the
|
|
387
|
+
console.warn('[notifications] the subscribe pop-up is not available', error);
|
|
348
388
|
}
|
|
349
389
|
}
|
|
350
390
|
export const Notifications = {
|
|
@@ -353,7 +393,7 @@ export const Notifications = {
|
|
|
353
393
|
return supported();
|
|
354
394
|
},
|
|
355
395
|
/**
|
|
356
|
-
* Registers the service worker, and draws the
|
|
396
|
+
* Registers the service worker, and draws the prompt HQ chose for the app (a bell, the logo or a pop-up). When the person
|
|
357
397
|
* already allowed notifications (and did not unsubscribe here), subscribes quietly.
|
|
358
398
|
*/
|
|
359
399
|
async init(init) {
|
|
@@ -368,6 +408,7 @@ export const Notifications = {
|
|
|
368
408
|
serviceWorkerScope: init.serviceWorkerScope ?? '/',
|
|
369
409
|
language: init.language ?? '',
|
|
370
410
|
appVersion: init.appVersion,
|
|
411
|
+
offset: init.offset,
|
|
371
412
|
};
|
|
372
413
|
// one bell per page, for the app last initialised
|
|
373
414
|
const wantsBell = init.bell !== false;
|
|
@@ -391,7 +432,7 @@ export const Notifications = {
|
|
|
391
432
|
}
|
|
392
433
|
ready = (async () => {
|
|
393
434
|
registration = await navigator.serviceWorker.register(options.serviceWorkerPath, { scope: options.serviceWorkerScope });
|
|
394
|
-
|
|
435
|
+
listenToWorker();
|
|
395
436
|
})();
|
|
396
437
|
await ready;
|
|
397
438
|
watchPermission();
|
|
@@ -434,8 +475,9 @@ export const Notifications = {
|
|
|
434
475
|
return;
|
|
435
476
|
await ready;
|
|
436
477
|
const loginKey = keys.login(options.appId);
|
|
437
|
-
const previous = await store.get(loginKey);
|
|
438
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);
|
|
439
481
|
if (!device) {
|
|
440
482
|
await store.set(loginKey, { appId: options.appId, token: identityToken, at: 0 });
|
|
441
483
|
return;
|
|
@@ -485,6 +527,14 @@ export const Notifications = {
|
|
|
485
527
|
clickHandlers.add(handler);
|
|
486
528
|
return () => clickHandlers.delete(handler);
|
|
487
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
|
+
},
|
|
488
538
|
/** Called when this browser subscribes, unsubscribes or the permission changes. Returns an unsubscribe function. */
|
|
489
539
|
onSubscriptionChange(handler) {
|
|
490
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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phoenix-iq/notifications",
|
|
3
|
-
"version": "1.1
|
|
4
|
-
"description": "Phoenix Notifications web SDK: browser push subscriptions, the
|
|
3
|
+
"version": "1.2.1",
|
|
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",
|
|
7
7
|
"main": "./dist/index.js",
|
|
@@ -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
|
});
|