@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 CHANGED
@@ -1,9 +1,9 @@
1
1
  # @phoenix-iq/notifications (web SDK)
2
2
 
3
- Browser push for apps on `notifications.phnx-iq.com`, our replacement for OneSignal's web SDK. It subscribes the browser
4
- (standard Web Push with the app's VAPID key), binds it to your signed-in user with an identity token your backend signs,
5
- hands notification clicks to your app, and shows the subscription bell and banner when the app has them on. TypeScript, no
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 show the prompt on a user gesture
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. Nothing throws on an unsupported browser; check
40
- `Notifications.isSupported()` and `Notifications.permission()` to decide what to show.
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
- ## The subscription bell
43
-
44
- Switch it on in HQ (the app → Web push → Subscription bell) and `init` draws it: a round bell in the bottom right or left
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
- A site with its own "Get notifications" button passes `bell: false` and `banner: false`, and uses `requestPermission()`,
62
- `unsubscribe()` and the state below.
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 bell's language |
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 set it | `false` never draws the subscription bell (a site with its own button) |
98
- | `banner` | as HQ set it | `false` never shows the subscription banner |
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 banner in every state without a server (serve `sdk/web`, open `/example/bell.html`).
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 subscription banner (OneSignal's slide prompt): a card that slides in at the top or the bottom of the page with a short
4
- * message, "Subscribe" and "Later". "Subscribe" opens the browser's own question; "Later" keeps it away for some days. The SDK
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 bell it lives in a shadow root, takes the page's font and language and a light or dark look to suit the page.
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
- export interface BannerPlacement {
35
- /** The app's icon, shown beside the message (else a bell in the banner's colour). */
36
- iconUrl?: string | null;
37
- /** The bell is on the page: a bottom banner keeps clear of it on narrow screens. */
38
- aboveBell?: boolean;
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, as OneSignal's does */
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
- /** Slides the banner in; `destroy()` takes it away at once. */
71
- export function mountBanner(api, settings, language, placement = {}) {
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 subscription bell (OneSignal's "subscription bell"): a round button in a bottom corner of the site that subscribes this
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. HQ switches it on per app; the SDK draws it from the web config.
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
- /** The bell as HQ set it up (the web config's `bell`). */
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 bell into the page; `destroy()` takes it away again. */
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 bell into the page; `destroy()` takes it away again. */
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
- icon.innerHTML = svg(view === 'blocked' ? ICONS.bellOff : ICONS.bell, 24);
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 to the app. When the
5
- * app has its subscription bell or banner switched on in HQ, it also draws them, so a site needs no button of its own.
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 bell's language); defaults to the page's, then the browser's. */
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 subscription bell, drawn when the app has it switched on in HQ (the app → Web push → Subscription bell). `false`
24
- * never draws it, for a site with its own "Get notifications" button.
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 subscription banner, shown when the app has it switched on in HQ (the app → Web push → Subscription banner). `false` never shows it. */
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 subscription bell and the banner when the app has them on. When the person
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 to the app. When the
7
- * app has its subscription bell or banner switched on in HQ, it also draws them, so a site needs no button of its own.
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
- function listenForClicks() {
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
- for (const handler of clickHandlers)
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
- await store.remove(keys.login(options.appId));
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
- async function applyPendingLogin(device) {
278
+ function applyPendingLogin(device) {
245
279
  if (!device)
246
- return;
247
- const pending = await store.get(keys.login(options.appId));
248
- if (!pending || pending.at !== 0)
249
- return;
250
- try {
251
- await Notifications.login(pending.token);
252
- }
253
- catch (error) {
254
- if (error instanceof NotificationsError && error.status === 401)
255
- await store.remove(keys.login(options.appId));
256
- console.warn('[notifications] the pending login failed', error);
257
- }
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 once the app's config says so. A failure only means no 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
- if (bell?.appId !== appId || !web?.bell?.enabled)
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 subscription bell is not available', error);
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 banner in when its time has come: only for a browser never asked (the permission still to be given, and no
310
- * unsubscribe here), from the page view HQ set, not within the days after a "Later", and after the delay. Each call is one page
311
- * view. A failure only means no banner.
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?.enabled)
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 bell, or answered the browser
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(), { iconUrl: web?.iconUrl, aboveBell: !!bell?.handle });
384
+ }, settings, language(), web?.iconUrl, options.offset);
345
385
  }
346
386
  catch (error) {
347
- console.warn('[notifications] the subscription banner is not available', error);
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 subscription bell and the banner when the app has them on. When the person
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
- listenForClicks();
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.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.",
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, 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.
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(self.registration.showNotification(payload.title || '', options));
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
- await self.clients.openWindow(details.url || self.registration.scope);
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
  });