@phoenix-iq/notifications 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -13
- package/dist/banner.d.ts +35 -0
- package/dist/banner.js +178 -0
- package/dist/bell.d.ts +38 -0
- package/dist/bell.js +369 -0
- package/dist/index.d.ts +37 -7
- package/dist/index.js +244 -24
- package/dist/store.d.ts +13 -1
- package/dist/store.js +2 -1
- package/dist/theme.d.ts +25 -0
- package/dist/theme.js +93 -0
- package/package.json +10 -5
- package/phoenix-notifications-sw.js +3 -2
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/store.d.ts.map +0 -1
- package/dist/store.js.map +0 -1
- package/src/index.ts +0 -331
- package/src/store.ts +0 -74
package/dist/bell.js
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { applyColors, applyTheme, colorOr, ICONS, svg, uiLanguage } from './theme.js';
|
|
2
|
+
const MESSAGE_MS = 3500;
|
|
3
|
+
const ATTENTION_MS = 4000;
|
|
4
|
+
const TEXTS = {
|
|
5
|
+
en: {
|
|
6
|
+
unsubscribed: 'Subscribe to notifications',
|
|
7
|
+
subscribed: "You're subscribed to notifications",
|
|
8
|
+
blocked: "You've blocked notifications",
|
|
9
|
+
thanks: 'Thanks for subscribing!',
|
|
10
|
+
goodbye: "You won't get notifications any more",
|
|
11
|
+
title: 'Notifications',
|
|
12
|
+
subscribedText: "You're getting this site's notifications in this browser.",
|
|
13
|
+
unsubscribedText: "Get this site's news as notifications in this browser.",
|
|
14
|
+
subscribe: 'Subscribe',
|
|
15
|
+
unsubscribe: 'Unsubscribe',
|
|
16
|
+
blockedTitle: 'Notifications are blocked',
|
|
17
|
+
blockedText: 'To get them, allow notifications for this site: open the site settings next to the address bar and set Notifications to Allow.',
|
|
18
|
+
failed: "This browser couldn't be subscribed. Try again later.",
|
|
19
|
+
close: 'Close',
|
|
20
|
+
},
|
|
21
|
+
ar: {
|
|
22
|
+
unsubscribed: 'اشترك في الإشعارات',
|
|
23
|
+
subscribed: 'أنت مشترك في الإشعارات',
|
|
24
|
+
blocked: 'الإشعارات محظورة في هذا المتصفح',
|
|
25
|
+
thanks: 'شكرًا لاشتراكك!',
|
|
26
|
+
goodbye: 'لن تصلك الإشعارات بعد الآن',
|
|
27
|
+
title: 'الإشعارات',
|
|
28
|
+
subscribedText: 'تصلك إشعارات هذا الموقع على هذا المتصفح.',
|
|
29
|
+
unsubscribedText: 'اشترك لتصلك أخبار هذا الموقع وجديده على هذا المتصفح.',
|
|
30
|
+
subscribe: 'اشتراك',
|
|
31
|
+
unsubscribe: 'إلغاء الاشتراك',
|
|
32
|
+
blockedTitle: 'الإشعارات محظورة',
|
|
33
|
+
blockedText: 'لتصلك الإشعارات، اسمح بها لهذا الموقع: افتح إعدادات الموقع بجانب شريط العنوان واختر «السماح» للإشعارات.',
|
|
34
|
+
failed: 'تعذّر الاشتراك من هذا المتصفح. حاول لاحقًا.',
|
|
35
|
+
close: 'إغلاق',
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
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)); }
|
|
42
|
+
@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)); }
|
|
46
|
+
}
|
|
47
|
+
@media print { :host { display: none !important; } }
|
|
48
|
+
* { box-sizing: border-box; }
|
|
49
|
+
[hidden] { display: none !important; }
|
|
50
|
+
.root { position: relative; font-family: inherit; }
|
|
51
|
+
button { font: inherit; margin: 0; -webkit-tap-highlight-color: transparent; }
|
|
52
|
+
.bell {
|
|
53
|
+
position: relative; display: grid; place-items: center; width: 48px; height: 48px; padding: 0; border: 0; border-radius: 50%;
|
|
54
|
+
background: var(--bell); color: var(--bell-text); cursor: pointer;
|
|
55
|
+
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.28), 0 1px 3px rgba(0, 0, 0, 0.22);
|
|
56
|
+
transition: transform 0.15s ease, opacity 0.15s ease;
|
|
57
|
+
}
|
|
58
|
+
.bell:hover { transform: scale(1.06); }
|
|
59
|
+
.bell:active { transform: scale(0.95); }
|
|
60
|
+
.bell:focus-visible { outline: 3px solid var(--bell); outline-offset: 3px; }
|
|
61
|
+
:host([data-view="blocked"]) .bell { opacity: 0.82; }
|
|
62
|
+
.bell::after { content: ""; position: absolute; inset: 0; border-radius: 50%; pointer-events: none; }
|
|
63
|
+
:host([data-attention][data-view="unsubscribed"]) .bell::after { animation: ring 1.6s ease-out 2; }
|
|
64
|
+
@keyframes ring { from { box-shadow: 0 0 0 0 var(--ring); } to { box-shadow: 0 0 0 14px rgba(0, 0, 0, 0); } }
|
|
65
|
+
.badge {
|
|
66
|
+
position: absolute; top: -3px; right: -3px; display: grid; place-items: center; width: 18px; height: 18px; border-radius: 50%;
|
|
67
|
+
background: #16a34a; color: #ffffff; border: 2px solid #ffffff;
|
|
68
|
+
}
|
|
69
|
+
/* the logo face: the app's icon on a white disc ringed in the colour; its badge says what the button is for */
|
|
70
|
+
: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); }
|
|
71
|
+
:host([data-face="logo"]) .icon { width: 100%; height: 100%; border-radius: 50%; overflow: hidden; }
|
|
72
|
+
.logo { display: block; width: 100%; height: 100%; object-fit: cover; }
|
|
73
|
+
:host([data-face="logo"][data-view="blocked"]) .logo { filter: grayscale(1); }
|
|
74
|
+
.badge[data-kind="bell"] { background: var(--bell); color: var(--bell-text); }
|
|
75
|
+
.badge[data-kind="blocked"] { background: #6b7280; }
|
|
76
|
+
.bubble {
|
|
77
|
+
position: absolute; bottom: 7px; width: max-content; max-width: min(260px, calc(100vw - 104px)); padding: 7px 11px; border-radius: 8px;
|
|
78
|
+
background: var(--tip); color: var(--tip-text); font-size: 13px; line-height: 1.45; text-align: start;
|
|
79
|
+
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25); opacity: 0; transform: translateY(4px); pointer-events: none;
|
|
80
|
+
transition: opacity 0.15s ease, transform 0.15s ease;
|
|
81
|
+
}
|
|
82
|
+
:host([data-side="right"]) .bubble { right: 60px; }
|
|
83
|
+
:host([data-side="left"]) .bubble { left: 60px; }
|
|
84
|
+
.bubble[data-shown] { opacity: 1; transform: none; }
|
|
85
|
+
.panel {
|
|
86
|
+
position: absolute; bottom: 60px; width: min(300px, calc(100vw - 32px)); padding: 14px 16px 16px; border-radius: 14px;
|
|
87
|
+
background: var(--panel); color: var(--text); border: 1px solid var(--border); text-align: start;
|
|
88
|
+
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.32); font-size: 14px; line-height: 1.55;
|
|
89
|
+
}
|
|
90
|
+
:host([data-side="right"]) .panel { right: 0; }
|
|
91
|
+
:host([data-side="left"]) .panel { left: 0; }
|
|
92
|
+
.head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
|
93
|
+
.title { flex: 1; margin: 0; font-size: 15px; font-weight: 700; }
|
|
94
|
+
.text { margin: 0 0 14px; color: var(--muted); }
|
|
95
|
+
.error { margin: -6px 0 12px; color: var(--error); font-size: 13px; }
|
|
96
|
+
.close {
|
|
97
|
+
display: grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 0; border-radius: 8px; margin-inline-end: -6px;
|
|
98
|
+
background: transparent; color: inherit; opacity: 0.7; cursor: pointer;
|
|
99
|
+
}
|
|
100
|
+
.close:hover { opacity: 1; background: var(--hover); }
|
|
101
|
+
.action {
|
|
102
|
+
width: 100%; min-height: 40px; padding: 8px 12px; border: 1px solid transparent; border-radius: 9px; font-weight: 600; cursor: pointer;
|
|
103
|
+
background: var(--bell); color: var(--bell-text);
|
|
104
|
+
}
|
|
105
|
+
.action[data-kind="secondary"] { background: transparent; color: inherit; border-color: var(--border); }
|
|
106
|
+
.action[data-kind="secondary"]:hover { background: var(--hover); }
|
|
107
|
+
.action:disabled { opacity: 0.6; cursor: progress; }
|
|
108
|
+
.close:focus-visible, .action:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
|
|
109
|
+
@media (prefers-reduced-motion: reduce) {
|
|
110
|
+
.bell, .bubble { transition: none; }
|
|
111
|
+
.bell:hover, .bell:active { transform: none; }
|
|
112
|
+
:host([data-attention][data-view="unsubscribed"]) .bell::after { animation: none; }
|
|
113
|
+
}
|
|
114
|
+
`;
|
|
115
|
+
/** Draws the button into the page; `destroy()` takes it away again. */
|
|
116
|
+
export function mountBell(api, settings, language) {
|
|
117
|
+
const lang = uiLanguage(language);
|
|
118
|
+
const t = TEXTS[lang];
|
|
119
|
+
const logoUrl = settings.face === 'logo' && /^https?:\/\//i.test(settings.logoUrl ?? '') ? settings.logoUrl : null;
|
|
120
|
+
const host = document.createElement('phoenix-notifications-bell');
|
|
121
|
+
host.dataset.side = settings.position === 'bottom_left' ? 'left' : 'right';
|
|
122
|
+
host.dataset.view = 'unsubscribed';
|
|
123
|
+
applyColors(host, colorOr(settings.color));
|
|
124
|
+
const shadow = host.attachShadow({ mode: 'open' });
|
|
125
|
+
// only constants go into this markup (the texts and icons above, lang 'ar' or 'en'); the colour is set as a property
|
|
126
|
+
shadow.innerHTML = `<style>${STYLE}</style>
|
|
127
|
+
<div class="root" dir="${lang === 'ar' ? 'rtl' : 'ltr'}" lang="${lang}">
|
|
128
|
+
<div class="panel" role="dialog" aria-labelledby="phx-bell-title" hidden>
|
|
129
|
+
<div class="head"><p class="title" id="phx-bell-title"></p><button type="button" class="close" aria-label="${t.close}">${svg(ICONS.close, 18, 2)}</button></div>
|
|
130
|
+
<p class="text"></p>
|
|
131
|
+
<p class="error" role="alert" hidden></p>
|
|
132
|
+
<button type="button" class="action"></button>
|
|
133
|
+
</div>
|
|
134
|
+
<div class="bubble" role="status" aria-live="polite"></div>
|
|
135
|
+
<button type="button" class="bell" aria-haspopup="dialog" aria-expanded="false"><span class="icon"></span><span class="badge" hidden>${svg(ICONS.check, 11, 3)}</span></button>
|
|
136
|
+
</div>`;
|
|
137
|
+
const $ = (selector) => shadow.querySelector(selector);
|
|
138
|
+
const bell = $('.bell');
|
|
139
|
+
const icon = $('.icon');
|
|
140
|
+
const badge = $('.badge');
|
|
141
|
+
const bubble = $('.bubble');
|
|
142
|
+
const panel = $('.panel');
|
|
143
|
+
const title = $('.title');
|
|
144
|
+
const text = $('.text');
|
|
145
|
+
const errorLine = $('.error');
|
|
146
|
+
const action = $('.action');
|
|
147
|
+
const close = $('.close');
|
|
148
|
+
let view = 'unsubscribed';
|
|
149
|
+
let open = false;
|
|
150
|
+
let busy = false;
|
|
151
|
+
let failed = false;
|
|
152
|
+
let message = null;
|
|
153
|
+
let bubbleTimer = 0;
|
|
154
|
+
let iconFor = null;
|
|
155
|
+
/** Hidden until the first read of the state, so it never shows a wrong state (or shows at all, when it hides) first. */
|
|
156
|
+
let settled = false;
|
|
157
|
+
/** The logo while it loads and after; a logo that fails to load gives way to the bell. */
|
|
158
|
+
let face = logoUrl ? 'logo' : 'bell';
|
|
159
|
+
let logo = null;
|
|
160
|
+
const viewOf = (state) => (state.permission === 'denied' ? 'blocked' : state.subscribed ? 'subscribed' : 'unsubscribed');
|
|
161
|
+
function drawFace() {
|
|
162
|
+
if (face === 'bell') {
|
|
163
|
+
icon.innerHTML = svg(view === 'blocked' ? ICONS.bellOff : ICONS.bell, 24);
|
|
164
|
+
badge.innerHTML = svg(ICONS.check, 11, 3);
|
|
165
|
+
delete badge.dataset.kind;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (!logo) {
|
|
169
|
+
logo = document.createElement('img');
|
|
170
|
+
logo.className = 'logo';
|
|
171
|
+
logo.alt = '';
|
|
172
|
+
logo.src = logoUrl;
|
|
173
|
+
logo.addEventListener('error', () => {
|
|
174
|
+
face = 'bell';
|
|
175
|
+
iconFor = null;
|
|
176
|
+
render();
|
|
177
|
+
}, { once: true });
|
|
178
|
+
}
|
|
179
|
+
icon.replaceChildren(logo);
|
|
180
|
+
// the logo alone does not say "notifications": a bell on it does, a check once subscribed, a struck bell once blocked
|
|
181
|
+
badge.innerHTML = view === 'subscribed' ? svg(ICONS.check, 11, 3) : svg(view === 'blocked' ? ICONS.bellOff : ICONS.bell, 11, 2.4);
|
|
182
|
+
if (view === 'subscribed')
|
|
183
|
+
delete badge.dataset.kind;
|
|
184
|
+
else
|
|
185
|
+
badge.dataset.kind = view === 'blocked' ? 'blocked' : 'bell';
|
|
186
|
+
}
|
|
187
|
+
function render() {
|
|
188
|
+
host.dataset.view = view;
|
|
189
|
+
host.dataset.face = face;
|
|
190
|
+
const hidden = !settled || (!!settings.hideWhenSubscribed && view === 'subscribed' && !open && !busy && !message);
|
|
191
|
+
host.style.display = hidden ? 'none' : '';
|
|
192
|
+
bell.setAttribute('aria-label', t[view]);
|
|
193
|
+
bell.setAttribute('aria-expanded', String(open));
|
|
194
|
+
if (iconFor !== `${face}:${view}`) {
|
|
195
|
+
drawFace();
|
|
196
|
+
iconFor = `${face}:${view}`;
|
|
197
|
+
}
|
|
198
|
+
badge.hidden = face === 'bell' && view !== 'subscribed';
|
|
199
|
+
panel.hidden = !open;
|
|
200
|
+
if (!open)
|
|
201
|
+
return;
|
|
202
|
+
title.textContent = view === 'blocked' ? t.blockedTitle : t.title;
|
|
203
|
+
text.textContent = view === 'blocked' ? t.blockedText : view === 'subscribed' ? t.subscribedText : t.unsubscribedText;
|
|
204
|
+
errorLine.hidden = !failed;
|
|
205
|
+
errorLine.textContent = t.failed;
|
|
206
|
+
action.hidden = view === 'blocked';
|
|
207
|
+
action.textContent = view === 'subscribed' ? t.unsubscribe : t.subscribe;
|
|
208
|
+
action.dataset.kind = view === 'subscribed' ? 'secondary' : 'primary';
|
|
209
|
+
action.disabled = busy;
|
|
210
|
+
}
|
|
211
|
+
function showBubble(content, ms) {
|
|
212
|
+
window.clearTimeout(bubbleTimer);
|
|
213
|
+
applyTheme(host);
|
|
214
|
+
bubble.textContent = content;
|
|
215
|
+
bubble.dataset.shown = '';
|
|
216
|
+
if (ms)
|
|
217
|
+
bubbleTimer = window.setTimeout(hideBubble, ms);
|
|
218
|
+
}
|
|
219
|
+
function hideBubble() {
|
|
220
|
+
window.clearTimeout(bubbleTimer);
|
|
221
|
+
const told = message !== null;
|
|
222
|
+
message = null;
|
|
223
|
+
delete bubble.dataset.shown;
|
|
224
|
+
// the thanks are over: a bell that hides once subscribed goes now
|
|
225
|
+
if (told)
|
|
226
|
+
render();
|
|
227
|
+
}
|
|
228
|
+
/** Something happened (subscribed, unsubscribed): said in the bubble for a few seconds. */
|
|
229
|
+
function tell(content) {
|
|
230
|
+
message = content;
|
|
231
|
+
showBubble(content, MESSAGE_MS);
|
|
232
|
+
}
|
|
233
|
+
function setOpen(next, focus) {
|
|
234
|
+
open = next;
|
|
235
|
+
failed = next && failed;
|
|
236
|
+
if (open) {
|
|
237
|
+
hideBubble();
|
|
238
|
+
applyTheme(host);
|
|
239
|
+
}
|
|
240
|
+
render();
|
|
241
|
+
if (focus === 'panel')
|
|
242
|
+
(action.hidden ? close : action).focus();
|
|
243
|
+
else if (focus === 'bell')
|
|
244
|
+
bell.focus();
|
|
245
|
+
}
|
|
246
|
+
async function settle() {
|
|
247
|
+
try {
|
|
248
|
+
view = viewOf(await api.state());
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
// keep the last view
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
async function subscribe() {
|
|
255
|
+
if (busy)
|
|
256
|
+
return;
|
|
257
|
+
busy = true;
|
|
258
|
+
failed = false;
|
|
259
|
+
render();
|
|
260
|
+
try {
|
|
261
|
+
// the browser's prompt opens inside this call, while the click still counts as the person's gesture
|
|
262
|
+
const state = await api.subscribe();
|
|
263
|
+
if (state.subscribed) {
|
|
264
|
+
setOpen(false, null);
|
|
265
|
+
tell(t.thanks);
|
|
266
|
+
}
|
|
267
|
+
else if (state.permission === 'granted') {
|
|
268
|
+
failed = true;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
console.warn('[notifications] subscribing failed', error);
|
|
273
|
+
failed = true;
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
busy = false;
|
|
277
|
+
await settle();
|
|
278
|
+
if (failed && !open)
|
|
279
|
+
setOpen(true, 'panel');
|
|
280
|
+
else
|
|
281
|
+
render();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
async function unsubscribe() {
|
|
285
|
+
if (busy)
|
|
286
|
+
return;
|
|
287
|
+
busy = true;
|
|
288
|
+
render();
|
|
289
|
+
try {
|
|
290
|
+
await api.unsubscribe();
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
// the opt-out is kept even when the server could not be told; the browser no longer subscribes
|
|
294
|
+
console.warn('[notifications] unsubscribing failed', error);
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
busy = false;
|
|
298
|
+
await settle();
|
|
299
|
+
setOpen(false, 'bell');
|
|
300
|
+
tell(t.goodbye);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
bell.addEventListener('click', () => {
|
|
304
|
+
if (busy)
|
|
305
|
+
return;
|
|
306
|
+
// first time here: the browser's own prompt is the clearest question, so ask straight away
|
|
307
|
+
if (!open && view === 'unsubscribed' && Notification.permission === 'default') {
|
|
308
|
+
hideBubble();
|
|
309
|
+
void subscribe();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
setOpen(!open, open ? null : 'panel');
|
|
313
|
+
});
|
|
314
|
+
action.addEventListener('click', () => void (view === 'subscribed' ? unsubscribe() : subscribe()));
|
|
315
|
+
close.addEventListener('click', () => setOpen(false, 'bell'));
|
|
316
|
+
// the bell's label as a tooltip, for a mouse or the keyboard (a tap goes straight to the click)
|
|
317
|
+
bell.addEventListener('pointerenter', (event) => {
|
|
318
|
+
if (event.pointerType === 'mouse' && !open && !message)
|
|
319
|
+
showBubble(t[view]);
|
|
320
|
+
});
|
|
321
|
+
bell.addEventListener('pointerleave', () => {
|
|
322
|
+
if (!message)
|
|
323
|
+
hideBubble();
|
|
324
|
+
});
|
|
325
|
+
bell.addEventListener('focus', () => {
|
|
326
|
+
if (bell.matches(':focus-visible') && !open && !message)
|
|
327
|
+
showBubble(t[view]);
|
|
328
|
+
});
|
|
329
|
+
bell.addEventListener('blur', () => {
|
|
330
|
+
if (!message)
|
|
331
|
+
hideBubble();
|
|
332
|
+
});
|
|
333
|
+
const onKey = (event) => {
|
|
334
|
+
if (event.key === 'Escape' && open) {
|
|
335
|
+
event.stopPropagation();
|
|
336
|
+
setOpen(false, 'bell');
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
shadow.addEventListener('keydown', onKey);
|
|
340
|
+
const onOutside = (event) => {
|
|
341
|
+
if (open && !event.composedPath().includes(host))
|
|
342
|
+
setOpen(false, null);
|
|
343
|
+
};
|
|
344
|
+
document.addEventListener('pointerdown', onOutside);
|
|
345
|
+
const stop = api.onChange((state) => {
|
|
346
|
+
view = viewOf(state);
|
|
347
|
+
if (!busy)
|
|
348
|
+
render();
|
|
349
|
+
});
|
|
350
|
+
render();
|
|
351
|
+
document.body.appendChild(host);
|
|
352
|
+
void settle().then(() => {
|
|
353
|
+
settled = true;
|
|
354
|
+
render();
|
|
355
|
+
// a gentle pulse, twice, the first time the bell offers itself on a page
|
|
356
|
+
if (view === 'unsubscribed' && Notification.permission === 'default') {
|
|
357
|
+
host.dataset.attention = '';
|
|
358
|
+
window.setTimeout(() => delete host.dataset.attention, ATTENTION_MS);
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
return {
|
|
362
|
+
destroy() {
|
|
363
|
+
stop();
|
|
364
|
+
window.clearTimeout(bubbleTimer);
|
|
365
|
+
document.removeEventListener('pointerdown', onOutside);
|
|
366
|
+
host.remove();
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type StoredDevice } from './store.js';
|
|
2
2
|
/**
|
|
3
3
|
* 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.
|
|
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
6
|
*
|
|
6
7
|
* Nothing here throws for an unsupported browser or a denied permission: check `isSupported()` / `permission()`.
|
|
7
8
|
* The service worker file (phoenix-notifications-sw.js) must be served from the site, at `serviceWorkerPath`.
|
|
@@ -9,17 +10,32 @@ import { type StoredDevice } from './store.js';
|
|
|
9
10
|
export declare const DEFAULT_SERVER_URL = "https://notifications.phnx-iq.com";
|
|
10
11
|
export declare const DEFAULT_SERVICE_WORKER_PATH = "/phoenix-notifications-sw.js";
|
|
11
12
|
export interface InitOptions {
|
|
12
|
-
/** The app id from
|
|
13
|
+
/** The app id from HQ (Notification management → the app). */
|
|
13
14
|
appId: string;
|
|
14
15
|
/** Where the notifications API is. '' means this site's own origin (development through a proxy). */
|
|
15
16
|
serverUrl?: string;
|
|
16
17
|
serviceWorkerPath?: string;
|
|
17
18
|
serviceWorkerScope?: string;
|
|
18
|
-
/** Language the device reports (picks Arabic or English text); defaults to the browser's. */
|
|
19
|
+
/** Language the device reports (picks Arabic or English text, and the prompt's language); defaults to the page's, then the browser's. */
|
|
19
20
|
language?: string;
|
|
20
21
|
appVersion?: string;
|
|
22
|
+
/**
|
|
23
|
+
* 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
|
|
24
|
+
* → How visitors subscribe). `false` never draws it. A site with its own button usually has "None" chosen in HQ instead.
|
|
25
|
+
*/
|
|
26
|
+
bell?: boolean;
|
|
27
|
+
/** The pop-up, shown when HQ chose it as the app's prompt. `false` never shows it. */
|
|
28
|
+
banner?: boolean;
|
|
21
29
|
}
|
|
30
|
+
/** How the app's sites offer notifications, as HQ chose: one prompt, or none. */
|
|
31
|
+
export type Prompt = 'none' | 'bell' | 'logo' | 'banner';
|
|
22
32
|
export type PushPermission = 'granted' | 'denied' | 'default' | 'unsupported';
|
|
33
|
+
/** Whether this browser gets the app's notifications, for a site's own button. */
|
|
34
|
+
export interface SubscriptionState {
|
|
35
|
+
permission: PushPermission;
|
|
36
|
+
/** Subscribed and registered with the server. False after `unsubscribe()`, even though the permission stays granted. */
|
|
37
|
+
subscribed: boolean;
|
|
38
|
+
}
|
|
23
39
|
export interface NotificationClick {
|
|
24
40
|
url?: string;
|
|
25
41
|
data: Record<string, string>;
|
|
@@ -32,10 +48,18 @@ export declare class NotificationsError extends Error {
|
|
|
32
48
|
export declare const Notifications: {
|
|
33
49
|
/** True when this browser can receive web push here (a secure context with service workers and the Push API). */
|
|
34
50
|
isSupported(): boolean;
|
|
35
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* Registers the service worker, and draws the prompt HQ chose for the app (a bell, the logo or a pop-up). When the person
|
|
53
|
+
* already allowed notifications (and did not unsubscribe here), subscribes quietly.
|
|
54
|
+
*/
|
|
36
55
|
init(init: InitOptions): Promise<void>;
|
|
37
56
|
permission(): PushPermission;
|
|
38
|
-
/**
|
|
57
|
+
/** Whether this browser gets the app's notifications now. */
|
|
58
|
+
getState(): Promise<SubscriptionState>;
|
|
59
|
+
/**
|
|
60
|
+
* Asks the person (call it from a click: browsers only ask on a user gesture) and subscribes when they allow. It also
|
|
61
|
+
* subscribes again after `unsubscribe()`, without asking, since the browser keeps the permission.
|
|
62
|
+
*/
|
|
39
63
|
requestPermission(): Promise<PushPermission>;
|
|
40
64
|
/**
|
|
41
65
|
* Binds this browser to the user an identity token from the app's backend vouches for. Before the person allows
|
|
@@ -44,12 +68,18 @@ export declare const Notifications: {
|
|
|
44
68
|
login(identityToken: string): Promise<void>;
|
|
45
69
|
/** The browser keeps receiving the app's broadcasts as a guest, but no longer this user's notifications. */
|
|
46
70
|
logout(): Promise<void>;
|
|
47
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* Stops notifications in this browser altogether, and remembers it: later page loads do not subscribe again although the
|
|
73
|
+
* permission stays granted, until `requestPermission()` (the bell's "Subscribe").
|
|
74
|
+
*/
|
|
48
75
|
unsubscribe(): Promise<void>;
|
|
49
76
|
/** A notification was clicked while the site was open. Without a handler the SDK opens its link. Returns an unsubscribe function. */
|
|
50
77
|
onClick(handler: (click: NotificationClick) => void): () => void;
|
|
78
|
+
/** Called when this browser subscribes, unsubscribes or the permission changes. Returns an unsubscribe function. */
|
|
79
|
+
onSubscriptionChange(handler: (state: SubscriptionState) => void): () => void;
|
|
51
80
|
/** Re-runs the subscription check now (e.g. after the person changed the site's permission). */
|
|
52
81
|
refresh(): Promise<void>;
|
|
53
82
|
};
|
|
83
|
+
export type { BannerPosition } from './banner.js';
|
|
84
|
+
export type { BellPosition } from './bell.js';
|
|
54
85
|
export type { StoredDevice };
|
|
55
|
-
//# sourceMappingURL=index.d.ts.map
|