@protobase/ui 0.45.19 → 0.45.21
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/dist/app/app.js +4 -1
- package/dist/app/pages/merge-ui.d.ts +1 -1
- package/dist/app/pages/merge-ui.js +5 -3
- package/dist/app/pages/project-ui.d.ts +5 -2
- package/dist/app/shell-bell.js +12 -0
- package/dist/auth/auth-provider.js +47 -4
- package/dist/data/api-provider.d.ts +26 -0
- package/dist/data/meta-data.d.ts +2 -0
- package/dist/data/query-keys.js +6 -0
- package/dist/data/use-meta.js +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/notifications/bell/messages.js +40 -0
- package/dist/notifications/bell/notification-bell.js +91 -0
- package/dist/notifications/bell/notification-item.js +75 -0
- package/dist/notifications/bell/notification-panel.js +71 -0
- package/dist/notifications/bell/notification-target.js +28 -0
- package/dist/notifications/bell/push-control.js +41 -0
- package/dist/notifications/bell/unread-label.js +12 -0
- package/dist/notifications/bell/use-inbox.d.ts +239 -0
- package/dist/notifications/bell/use-inbox.js +91 -0
- package/dist/notifications/push/push-browser.js +23 -0
- package/dist/notifications/push/push-notifications.js +27 -0
- package/dist/notifications/push/push-records.js +23 -0
- package/dist/notifications/push/use-push-notifications.d.ts +16 -0
- package/dist/notifications/push/use-push-notifications.js +37 -0
- package/dist/notifications/push/watch-permission.js +31 -0
- package/dist/notifications/push/web-push.d.ts +5 -0
- package/dist/notifications/push/web-push.js +165 -0
- package/dist/package.js +1 -1
- package/dist/plugins/plugin-ui.js +2 -1
- package/dist/primitives/button.d.ts +4 -2
- package/dist/primitives/button.js +2 -1
- package/dist/primitives/popover-menu.js +14 -8
- package/dist/shell/assets/app-BRrWBdcb.js +9 -0
- package/dist/shell/assets/{index-5S96usXO.js → index-9vOkVIyi.js} +2 -2
- package/dist/shell/assets/index-BZXw3deq.css +2 -0
- package/dist/shell/assets/{records-map-Cck4zwvz.js → records-map-CXXkyoSM.js} +2 -2
- package/dist/shell/assets/shared-protobase-ui-C_KpcuOu.js +2 -0
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/dist/shell/protobase.sw.js +99 -2
- package/dist/shell-source-maps/assets/app-BRrWBdcb.js.map +1 -0
- package/dist/shell-source-maps/assets/{index-5S96usXO.js.map → index-9vOkVIyi.js.map} +1 -1
- package/dist/shell-source-maps/assets/{records-map-Cck4zwvz.js.map → records-map-CXXkyoSM.js.map} +1 -1
- package/dist/shell-source-maps/assets/shared-protobase-ui-C_KpcuOu.js.map +1 -0
- package/package.json +4 -4
- package/dist/shell/assets/app-CYu7KSRV.js +0 -9
- package/dist/shell/assets/index-DVdwieyN.css +0 -2
- package/dist/shell/assets/shared-protobase-ui-CU75swuU.js +0 -2
- package/dist/shell-source-maps/assets/app-CYu7KSRV.js.map +0 -1
- package/dist/shell-source-maps/assets/shared-protobase-ui-CU75swuU.js.map +0 -1
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { ApiError } from "@protobase/client";
|
|
2
|
+
//#region src/notifications/push/web-push.ts
|
|
3
|
+
const keyBytes = (key) => Uint8Array.from(atob(key.replace(/-/g, "+").replace(/_/g, "/")), (char) => char.charCodeAt(0));
|
|
4
|
+
const within = (ms, promise) => Promise.race([promise, new Promise((resolve) => setTimeout(resolve, ms))]);
|
|
5
|
+
/** Someone else signed in while push was being turned on. */
|
|
6
|
+
var SignedInChanged = class extends Error {};
|
|
7
|
+
const isRefusal = (error, slug) => error instanceof ApiError && error.status === 409 && error.slug === slug;
|
|
8
|
+
/**
|
|
9
|
+
* Web Push for the signed-in user in this browser. It registers only from a click by someone signed in as themselves,
|
|
10
|
+
* never on its own, except to renew the registration this browser already has for that same user. Signing out, or
|
|
11
|
+
* someone else signing in, forgets the registration here first, closing what it showed, then unsubscribes and removes
|
|
12
|
+
* the device, without waiting long on either.
|
|
13
|
+
*/
|
|
14
|
+
const webPush = ({ browser, records, devices, meta, user, waitMs = 3e3 }) => {
|
|
15
|
+
let metaRead;
|
|
16
|
+
const appMeta = () => metaRead ??= meta();
|
|
17
|
+
const forgetHere = async (registration) => {
|
|
18
|
+
await records.clear();
|
|
19
|
+
for (const shown of registration ? await registration.getNotifications() : []) shown.close();
|
|
20
|
+
await browser.clearBadge();
|
|
21
|
+
};
|
|
22
|
+
const unsubscribe = async (registration) => {
|
|
23
|
+
const subscription = await registration?.pushManager.getSubscription();
|
|
24
|
+
if (subscription) await subscription.unsubscribe();
|
|
25
|
+
};
|
|
26
|
+
const drop = async (registration) => {
|
|
27
|
+
await forgetHere(registration);
|
|
28
|
+
await unsubscribe(registration);
|
|
29
|
+
};
|
|
30
|
+
const register = async (subscription, record) => {
|
|
31
|
+
const keys = subscription.toJSON().keys ?? {};
|
|
32
|
+
const device = await devices.register({
|
|
33
|
+
kind: "web",
|
|
34
|
+
endpoint: subscription.endpoint,
|
|
35
|
+
keys: {
|
|
36
|
+
p256dh: keys.p256dh ?? "",
|
|
37
|
+
auth: keys.auth ?? ""
|
|
38
|
+
},
|
|
39
|
+
userAgent: browser.userAgent,
|
|
40
|
+
userId: record.userId
|
|
41
|
+
});
|
|
42
|
+
await records.write({
|
|
43
|
+
...record,
|
|
44
|
+
deviceId: device.id
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
const state = async () => {
|
|
48
|
+
if (!browser.supported || !(await appMeta()).webPushKey) return "unsupported";
|
|
49
|
+
if (browser.permission() === "denied") return "blocked";
|
|
50
|
+
const signedIn = user();
|
|
51
|
+
const registration = await browser.existingRegistration();
|
|
52
|
+
const [subscription, record] = await Promise.all([registration?.pushManager.getSubscription(), records.read()]);
|
|
53
|
+
return signedIn && subscription && record?.userId === signedIn.id && record.endpoint === subscription.endpoint ? "on" : "off";
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Turns push on for the signed-in user, from their click: call it in the click handler itself. Asks for permission
|
|
57
|
+
* when the user has not answered yet, subscribes and registers the device. Does nothing without a click, for staff
|
|
58
|
+
* signed in as the user or for a visitor; a refusal or someone else signing in meanwhile leaves push off.
|
|
59
|
+
*/
|
|
60
|
+
const turnOn = async () => {
|
|
61
|
+
const signedIn = user();
|
|
62
|
+
if (!browser.supported || !browser.activated() || !signedIn?.own) return state();
|
|
63
|
+
if (browser.permission() === "default" && await browser.requestPermission() !== "granted") return state();
|
|
64
|
+
if (browser.permission() !== "granted") return state();
|
|
65
|
+
const { appName, webPushKey } = await appMeta();
|
|
66
|
+
if (!webPushKey) return "unsupported";
|
|
67
|
+
const same = () => user()?.id === signedIn.id && user()?.own === true;
|
|
68
|
+
const registration = await browser.registration();
|
|
69
|
+
if (!same()) return state();
|
|
70
|
+
const [held, earlier] = await Promise.all([registration.pushManager.getSubscription(), records.read()]);
|
|
71
|
+
if (held && (earlier?.userId !== signedIn.id || earlier.endpoint !== held.endpoint)) await drop(registration);
|
|
72
|
+
const subscribeAndRegister = async () => {
|
|
73
|
+
if (!same()) throw new SignedInChanged();
|
|
74
|
+
const subscription = await registration.pushManager.getSubscription() ?? await registration.pushManager.subscribe({
|
|
75
|
+
userVisibleOnly: true,
|
|
76
|
+
applicationServerKey: keyBytes(webPushKey)
|
|
77
|
+
});
|
|
78
|
+
const record = {
|
|
79
|
+
userId: signedIn.id,
|
|
80
|
+
endpoint: subscription.endpoint,
|
|
81
|
+
appName
|
|
82
|
+
};
|
|
83
|
+
await records.write(record);
|
|
84
|
+
if (!same()) throw new SignedInChanged();
|
|
85
|
+
await register(subscription, record);
|
|
86
|
+
};
|
|
87
|
+
try {
|
|
88
|
+
await subscribeAndRegister();
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (isRefusal(error, "device-held")) {
|
|
91
|
+
await drop(registration);
|
|
92
|
+
return retryOnce(registration, subscribeAndRegister);
|
|
93
|
+
}
|
|
94
|
+
await drop(registration);
|
|
95
|
+
if (error instanceof SignedInChanged || isRefusal(error, "other-user")) return "off";
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
return "on";
|
|
99
|
+
};
|
|
100
|
+
const retryOnce = async (registration, attempt) => {
|
|
101
|
+
try {
|
|
102
|
+
await attempt();
|
|
103
|
+
} catch (error) {
|
|
104
|
+
await drop(registration);
|
|
105
|
+
if (error instanceof SignedInChanged || isRefusal(error, "device-held") || isRefusal(error, "other-user")) return "off";
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
return "on";
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* Turns push off in this browser: forgets it here, unsubscribes and removes the device, waiting at most `waitMs` on
|
|
112
|
+
* the push service and the server. A removal that fails is left to the server, which drops the endpoint once the push
|
|
113
|
+
* service reports it gone.
|
|
114
|
+
*/
|
|
115
|
+
const turnOff = async () => {
|
|
116
|
+
if (!browser.supported) return;
|
|
117
|
+
const registration = await browser.existingRegistration();
|
|
118
|
+
const record = await records.read();
|
|
119
|
+
await forgetHere(registration);
|
|
120
|
+
await within(waitMs, Promise.allSettled([unsubscribe(registration), record?.deviceId ? devices.remove(record.deviceId) : Promise.resolve()]));
|
|
121
|
+
};
|
|
122
|
+
/** As `turnOff`, for a session already over, whose device the server can no longer be asked to remove. */
|
|
123
|
+
const forget = async () => {
|
|
124
|
+
if (!browser.supported) return;
|
|
125
|
+
const registration = await browser.existingRegistration();
|
|
126
|
+
await forgetHere(registration);
|
|
127
|
+
await within(waitMs, Promise.allSettled([unsubscribe(registration)]));
|
|
128
|
+
};
|
|
129
|
+
/**
|
|
130
|
+
* Takes the signed-in user in, on every load and sign-in: a registration here for anyone else, or without a record,
|
|
131
|
+
* is forgotten and unsubscribed; the user's own, when they signed in as themselves and the permission stands, is
|
|
132
|
+
* registered again, so the device follows their session and a renewed subscription. Nothing is ever registered anew.
|
|
133
|
+
*/
|
|
134
|
+
const adopt = async (signedIn) => {
|
|
135
|
+
if (!browser.supported) return;
|
|
136
|
+
const registration = await browser.existingRegistration();
|
|
137
|
+
const [subscription, record] = await Promise.all([registration?.pushManager.getSubscription(), records.read()]);
|
|
138
|
+
if (!registration) return record ? records.clear() : void 0;
|
|
139
|
+
if (!record || record.userId !== signedIn.id) return subscription || record ? drop(registration) : void 0;
|
|
140
|
+
if (!signedIn.own) return;
|
|
141
|
+
if (!subscription || browser.permission() !== "granted") return drop(registration);
|
|
142
|
+
if (record.endpoint !== subscription.endpoint && !record.renew) return drop(registration);
|
|
143
|
+
const renewed = {
|
|
144
|
+
userId: record.userId,
|
|
145
|
+
endpoint: subscription.endpoint,
|
|
146
|
+
appName: record.appName
|
|
147
|
+
};
|
|
148
|
+
await records.write(renewed);
|
|
149
|
+
try {
|
|
150
|
+
await register(subscription, renewed);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (!isRefusal(error, "device-held") && !isRefusal(error, "other-user")) throw error;
|
|
153
|
+
await drop(registration);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
return {
|
|
157
|
+
state,
|
|
158
|
+
turnOn,
|
|
159
|
+
turnOff,
|
|
160
|
+
forget,
|
|
161
|
+
adopt
|
|
162
|
+
};
|
|
163
|
+
};
|
|
164
|
+
//#endregion
|
|
165
|
+
export { webPush };
|
package/dist/package.js
CHANGED
|
@@ -32,7 +32,8 @@ const pluginUi = (plugin, module) => {
|
|
|
32
32
|
const shell = ui.shell && {
|
|
33
33
|
...ui.shell.actions && { actions: within(plugin, ui.shell.actions, "shell.actions") },
|
|
34
34
|
...ui.shell.rightPanel && { rightPanel: within(plugin, ui.shell.rightPanel, "shell.rightPanel") },
|
|
35
|
-
...ui.shell.logo && { logo: within(plugin, ui.shell.logo, "shell.logo") }
|
|
35
|
+
...ui.shell.logo && { logo: within(plugin, ui.shell.logo, "shell.logo") },
|
|
36
|
+
...ui.shell.bell && { bell: within(plugin, ui.shell.bell, "shell.bell") }
|
|
36
37
|
};
|
|
37
38
|
const beforeSignIn = ui.beforeSignIn && within(plugin, ui.beforeSignIn, "beforeSignIn");
|
|
38
39
|
return {
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { ButtonHTMLAttributes } from "react";
|
|
1
|
+
import { ButtonHTMLAttributes, Ref } from "react";
|
|
2
2
|
//#region src/primitives/button.d.ts
|
|
3
3
|
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
4
4
|
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
|
5
|
-
|
|
5
|
+
/** `icon` is a square of the height of `sm`, for a button with an icon alone. */
|
|
6
|
+
size?: 'sm' | 'md' | 'icon';
|
|
6
7
|
loading?: boolean;
|
|
8
|
+
ref?: Ref<HTMLButtonElement>;
|
|
7
9
|
};
|
|
8
10
|
/** A button's classes, for a link that looks like one. */
|
|
9
11
|
export declare const buttonClass: ({ variant, size }?: Pick<ButtonProps, "variant" | "size">) => string;
|
|
@@ -10,7 +10,8 @@ const variants = {
|
|
|
10
10
|
};
|
|
11
11
|
const sizes = {
|
|
12
12
|
sm: "h-7 px-2.5 text-body gap-1.5",
|
|
13
|
-
md: "h-8 px-3 text-body gap-2"
|
|
13
|
+
md: "h-8 px-3 text-body gap-2",
|
|
14
|
+
icon: "size-7 text-body"
|
|
14
15
|
};
|
|
15
16
|
/** A button's classes, for a link that looks like one. */
|
|
16
17
|
const buttonClass = ({ variant = "secondary", size = "md" } = {}) => cn("inline-flex shrink-0 items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:opacity-60 disabled:cursor-default", variants[variant], sizes[size]);
|
|
@@ -3,19 +3,21 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
|
3
3
|
const gap = 4;
|
|
4
4
|
/**
|
|
5
5
|
* Below the button, or above it when the viewport has no room underneath; its right edge at the button's, or further
|
|
6
|
-
* right when that would push its left edge off the viewport, as for a button near the left edge.
|
|
6
|
+
* right when that would push its left edge off the viewport, as for a button near the left edge. `width` is where a
|
|
7
|
+
* fixed element's `right` counts from.
|
|
7
8
|
*/
|
|
8
|
-
const placeMenu = (button, menu) => {
|
|
9
|
+
const placeMenu = (button, menu, width) => {
|
|
9
10
|
const below = button.bottom + gap;
|
|
10
11
|
return {
|
|
11
12
|
top: below + menu.height > window.innerHeight - gap ? Math.max(gap, button.top - gap - menu.height) : below,
|
|
12
|
-
right: Math.max(gap, Math.min(
|
|
13
|
+
right: Math.max(gap, Math.min(width - button.right, width - gap - menu.width))
|
|
13
14
|
};
|
|
14
15
|
};
|
|
15
16
|
/**
|
|
16
17
|
* A menu opened by a button and drawn fixed, portalled to the body, under or over the button: it closes on a click
|
|
17
|
-
* outside, Escape, and a scroll or resize, which would move the button away from it
|
|
18
|
-
* menu is measured, so it is drawn hidden for that first frame.
|
|
18
|
+
* outside, Escape, and a scroll or resize, which would move the button away from it; a scroll inside the menu keeps it.
|
|
19
|
+
* `place` is undefined until the menu is measured, so it is drawn hidden for that first frame. The menu is a `ul` unless
|
|
20
|
+
* `T` names another element, such as a panel's `div`.
|
|
19
21
|
*/
|
|
20
22
|
const usePopoverMenu = () => {
|
|
21
23
|
const [open, setOpen] = useState(false);
|
|
@@ -27,18 +29,22 @@ const usePopoverMenu = () => {
|
|
|
27
29
|
setPlace(void 0);
|
|
28
30
|
return;
|
|
29
31
|
}
|
|
32
|
+
menu.current.style.right = "0px";
|
|
33
|
+
const width = menu.current.getBoundingClientRect().right;
|
|
30
34
|
setPlace(placeMenu(button.current.getBoundingClientRect(), {
|
|
31
35
|
width: menu.current.offsetWidth,
|
|
32
36
|
height: menu.current.offsetHeight
|
|
33
|
-
}));
|
|
37
|
+
}, width));
|
|
34
38
|
}, [open]);
|
|
35
39
|
useEffect(() => {
|
|
36
40
|
if (!open) return;
|
|
37
|
-
const inside = (target) => button.current?.contains(target) || menu.current?.contains(target);
|
|
41
|
+
const inside = (target) => target instanceof Node && Boolean(button.current?.contains(target) || menu.current?.contains(target));
|
|
38
42
|
const close = (event) => {
|
|
39
43
|
if (event instanceof KeyboardEvent ? event.key === "Escape" : !inside(event.target)) setOpen(false);
|
|
40
44
|
};
|
|
41
|
-
const dismiss = () =>
|
|
45
|
+
const dismiss = (event) => {
|
|
46
|
+
if (!inside(event.target)) setOpen(false);
|
|
47
|
+
};
|
|
42
48
|
document.addEventListener("mousedown", close);
|
|
43
49
|
document.addEventListener("keydown", close);
|
|
44
50
|
document.addEventListener("scroll", dismiss, true);
|