@phoenix-iq/notifications 1.2.0 → 1.2.1

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