@protobase/ui 0.45.19 → 0.45.20
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/auth/auth-provider.js +47 -4
- package/dist/data/api-provider.d.ts +8 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -1
- 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 +15 -0
- package/dist/notifications/push/use-push-notifications.js +32 -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/shell/assets/app-1nDh53tv.js +9 -0
- package/dist/shell/assets/{index-5S96usXO.js → index-CvTuaRIF.js} +2 -2
- package/dist/shell/assets/{records-map-Cck4zwvz.js → records-map-DXlU-ssW.js} +2 -2
- package/dist/shell/assets/shared-protobase-ui-CUeF-xz_.js +2 -0
- package/dist/shell/index.html +3 -3
- package/dist/shell/protobase.shell.json +1 -1
- package/dist/shell/protobase.sw.js +99 -2
- package/dist/shell-source-maps/assets/app-1nDh53tv.js.map +1 -0
- package/dist/shell-source-maps/assets/{index-5S96usXO.js.map → index-CvTuaRIF.js.map} +1 -1
- package/dist/shell-source-maps/assets/{records-map-Cck4zwvz.js.map → records-map-DXlU-ssW.js.map} +1 -1
- package/dist/shell-source-maps/assets/shared-protobase-ui-CUeF-xz_.js.map +1 -0
- package/package.json +4 -4
- package/dist/shell/assets/app-CYu7KSRV.js +0 -9
- 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
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { nativeAuthFetch } from "../app/native-auth-fetch.js";
|
|
2
|
+
import { pushNotifications } from "../notifications/push/push-notifications.js";
|
|
2
3
|
import { providerName, withoutSignInLink } from "./provider-link.js";
|
|
3
4
|
import { text } from "./messages.js";
|
|
4
5
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
5
|
-
import { createAuthSession } from "@protobase/client";
|
|
6
|
+
import { createAuthSession, createClient } from "@protobase/client";
|
|
6
7
|
import { jsx } from "react/jsx-runtime";
|
|
7
8
|
//#region src/auth/auth-provider.tsx
|
|
8
9
|
const AuthContext = createContext(null);
|
|
@@ -32,7 +33,11 @@ const AuthProvider = ({ session: given, origin, nativeBridge, children }) => {
|
|
|
32
33
|
const [beforeSignIn, setBeforeSignIn] = useState([]);
|
|
33
34
|
const [locale, setLocale] = useState();
|
|
34
35
|
const signedOutState = useCallback(() => anonymous ? { kind: "visitor" } : { kind: "signed-out" }, [anonymous]);
|
|
35
|
-
const
|
|
36
|
+
const forgetPush = useRef(async () => {});
|
|
37
|
+
const markSignedOut = useCallback(() => {
|
|
38
|
+
forgetPush.current();
|
|
39
|
+
setState((current) => current.kind === "signed-in" ? { kind: "signed-out" } : current);
|
|
40
|
+
}, []);
|
|
36
41
|
const signedOut = useRef(markSignedOut);
|
|
37
42
|
signedOut.current = markSignedOut;
|
|
38
43
|
const session = useMemo(() => given ?? createAuthSession({
|
|
@@ -44,6 +49,36 @@ const AuthProvider = ({ session: given, origin, nativeBridge, children }) => {
|
|
|
44
49
|
origin,
|
|
45
50
|
nativeBridge
|
|
46
51
|
]);
|
|
52
|
+
const pushUser = useRef(void 0);
|
|
53
|
+
pushUser.current = state.kind === "signed-in" ? {
|
|
54
|
+
id: state.user.id,
|
|
55
|
+
own: !state.staff
|
|
56
|
+
} : void 0;
|
|
57
|
+
const push = useMemo(() => {
|
|
58
|
+
const client = createClient({
|
|
59
|
+
baseUrl: `${origin ?? globalThis.location?.origin ?? ""}/api/v1`,
|
|
60
|
+
token: ({ refresh }) => session.token({ refresh })
|
|
61
|
+
});
|
|
62
|
+
const meta = async () => {
|
|
63
|
+
const answer = await client.meta();
|
|
64
|
+
if (answer.status !== "modified") throw new Error("The app answered /meta without its content");
|
|
65
|
+
return {
|
|
66
|
+
appName: answer.meta.appName,
|
|
67
|
+
...answer.meta.notifications && { webPushKey: answer.meta.notifications.webPushKey }
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
return pushNotifications({
|
|
71
|
+
...nativeBridge && { nativeBridge },
|
|
72
|
+
devices: client.devices,
|
|
73
|
+
meta,
|
|
74
|
+
user: () => pushUser.current
|
|
75
|
+
});
|
|
76
|
+
}, [
|
|
77
|
+
session,
|
|
78
|
+
origin,
|
|
79
|
+
nativeBridge
|
|
80
|
+
]);
|
|
81
|
+
forgetPush.current = push.forget;
|
|
47
82
|
const enter = useCallback(async (user) => {
|
|
48
83
|
const [{ missing }, staff] = await Promise.all([session.account.signInMethods(), session.staff.current()]);
|
|
49
84
|
setState(missing.length > 0 ? {
|
|
@@ -55,7 +90,11 @@ const AuthProvider = ({ session: given, origin, nativeBridge, children }) => {
|
|
|
55
90
|
user,
|
|
56
91
|
...staff && { staff }
|
|
57
92
|
});
|
|
58
|
-
|
|
93
|
+
if (missing.length === 0) push.adopt({
|
|
94
|
+
id: user.id,
|
|
95
|
+
own: !staff
|
|
96
|
+
});
|
|
97
|
+
}, [session, push]);
|
|
59
98
|
const finish = useCallback(async (result) => result.kind === "two-factor" ? setState({
|
|
60
99
|
kind: "two-factor",
|
|
61
100
|
methods: result.methods
|
|
@@ -134,14 +173,18 @@ const AuthProvider = ({ session: given, origin, nativeBridge, children }) => {
|
|
|
134
173
|
window.location.assign(await session.account.linkProvider(provider, new URL(withoutSignInLink(window.location.href), window.location.href).href));
|
|
135
174
|
},
|
|
136
175
|
stopStaffSession: async () => {
|
|
176
|
+
await push.forget();
|
|
137
177
|
await session.staff.stop();
|
|
138
178
|
setState(signedOutState());
|
|
139
179
|
},
|
|
140
180
|
signOut: async () => {
|
|
181
|
+
await push.turnOff();
|
|
141
182
|
await session.signOut();
|
|
142
183
|
setState(signedOutState());
|
|
143
|
-
}
|
|
184
|
+
},
|
|
185
|
+
push
|
|
144
186
|
}), [
|
|
187
|
+
push,
|
|
145
188
|
state,
|
|
146
189
|
session,
|
|
147
190
|
signInMethods,
|
|
@@ -57,6 +57,10 @@ export declare const useClient: () => {
|
|
|
57
57
|
formChallenge: (name: string) => Promise<import("@protobase/client").FormChallenge>;
|
|
58
58
|
submitForm: (name: string, values: Record<string, unknown>, proof: import("@protobase/client").FormProof) => Promise<void>;
|
|
59
59
|
live: (handlers: import("@protobase/client").LiveHandlers) => () => void;
|
|
60
|
+
devices: {
|
|
61
|
+
register: (device: import("@protobase/client").WebDeviceRegistration) => Promise<import("@protobase/client").RegisteredDevice>;
|
|
62
|
+
remove: (id: string) => Promise<void>;
|
|
63
|
+
};
|
|
60
64
|
};
|
|
61
65
|
/**
|
|
62
66
|
* The client of the API that serves `resource`: a source's for a resource the app draws from one, the app's own
|
|
@@ -116,6 +120,10 @@ export declare const useResourceClient: (resource: string) => {
|
|
|
116
120
|
formChallenge: (name: string) => Promise<import("@protobase/client").FormChallenge>;
|
|
117
121
|
submitForm: (name: string, values: Record<string, unknown>, proof: import("@protobase/client").FormProof) => Promise<void>;
|
|
118
122
|
live: (handlers: import("@protobase/client").LiveHandlers) => () => void;
|
|
123
|
+
devices: {
|
|
124
|
+
register: (device: import("@protobase/client").WebDeviceRegistration) => Promise<import("@protobase/client").RegisteredDevice>;
|
|
125
|
+
remove: (id: string) => Promise<void>;
|
|
126
|
+
};
|
|
119
127
|
};
|
|
120
128
|
export type ApiProviderProps = {
|
|
121
129
|
/** Defaults to `/api/v1` on the current origin. */
|
package/dist/index.d.ts
CHANGED
|
@@ -25,6 +25,8 @@ import { ListDisplayRow, RecordCardProps } from "./app/displays/list-displays.js
|
|
|
25
25
|
import { ListDisplay, useListDisplay } from "./app/displays/display-context.js";
|
|
26
26
|
import { RecordLink } from "./app/displays/record-link.js";
|
|
27
27
|
import { useApiToken } from "./auth/use-api-token.js";
|
|
28
|
+
import { PushState } from "./notifications/push/web-push.js";
|
|
29
|
+
import { usePushNotifications } from "./notifications/push/use-push-notifications.js";
|
|
28
30
|
import { SignInPrompt, SignInPromptProps } from "./auth/sign-in-prompt.js";
|
|
29
31
|
import { ResponsiveDialog, ResponsiveDialogProps } from "./primitives/responsive-dialog.js";
|
|
30
32
|
import { OtpInput, OtpInputProps } from "./primitives/otp-input.js";
|
|
@@ -66,4 +68,4 @@ import { Section } from "./record-view/section.js";
|
|
|
66
68
|
import { Form, FormProps } from "./app/create/form.js";
|
|
67
69
|
import { RecordPicker, RecordPickerProps } from "./app/record-picker.js";
|
|
68
70
|
import { Spinner } from "./primitives/spinner.js";
|
|
69
|
-
export { ActionCard, type ActionCardProps, type ActionContext, type ActionHandler, ActionInputError, type ActionInputProps, ApiProvider, type ApiProviderProps, App, AppLink, type AppProps, AssistantDock, type AssistantDockProps, AuthLayout, type AuthLayoutProps, type BackendData, type BackendRefresh, type BeforeSignInProps, type BoardDrop, type BoardDropContext, type BoardDropUndo, type BoardDropWrite, Button, type ButtonProps, type CardAction, type CardTone, ChatMessage, ChatThread, type ChatThreadProps, type ChoiceOption, Choices, type ChoicesProps, CodeForm, type CodeFormProps, CompactTable, type CompactTableProps, Composer, type ComposerProps, type CurrentAddress, Dialog, type DialogProps, type DiffLine, DiffView, type DiffViewProps, type Drop, type DropRule, Field, FieldList, type FieldProps, type FieldWidget, type FieldWidgetEditorProps, type FieldWidgetProps, Form, type FormProps, Input, type InputProps, type LayoutRecord, type ListAddress, type ListChange, type ListDisplay, type ListDisplayRow, LogoMark, Markdown, type MarkdownContentProps as MarkdownProps, MenuSelect, type MenuSelectProps, type MetaData, OtpInput, type OtpInputProps, PartView, type PluginInfo, type ProjectUi, type RecordCardProps, RecordLink, RecordPicker, type RecordPickerProps, ResendCodeButton, type ResendCodeButtonProps, ResponsiveDialog, type ResponsiveDialogProps, Section, Segmented, type SegmentedOption, type SegmentedProps, type ShellLogoProps, SidePanel, type SidePanelProps, SignInPrompt, type SignInPromptProps, Spinner, type Step, StepList, type StepState, Textarea, type TextareaProps, allowDelayMs, buttonClass, definePlugin, defineUi, diffStats, isConflict, isFramed, mergeUi, parseDiff, pluginApi, protobaseVersion, uiFromModule, useApiToken, useAppAddress, useArmedAfterFocus, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useFieldControl, useGuardedPress, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, usePlugin, useRecord, useResourceClient, useSeries, useUpdateRecord };
|
|
71
|
+
export { ActionCard, type ActionCardProps, type ActionContext, type ActionHandler, ActionInputError, type ActionInputProps, ApiProvider, type ApiProviderProps, App, AppLink, type AppProps, AssistantDock, type AssistantDockProps, AuthLayout, type AuthLayoutProps, type BackendData, type BackendRefresh, type BeforeSignInProps, type BoardDrop, type BoardDropContext, type BoardDropUndo, type BoardDropWrite, Button, type ButtonProps, type CardAction, type CardTone, ChatMessage, ChatThread, type ChatThreadProps, type ChoiceOption, Choices, type ChoicesProps, CodeForm, type CodeFormProps, CompactTable, type CompactTableProps, Composer, type ComposerProps, type CurrentAddress, Dialog, type DialogProps, type DiffLine, DiffView, type DiffViewProps, type Drop, type DropRule, Field, FieldList, type FieldProps, type FieldWidget, type FieldWidgetEditorProps, type FieldWidgetProps, Form, type FormProps, Input, type InputProps, type LayoutRecord, type ListAddress, type ListChange, type ListDisplay, type ListDisplayRow, LogoMark, Markdown, type MarkdownContentProps as MarkdownProps, MenuSelect, type MenuSelectProps, type MetaData, OtpInput, type OtpInputProps, PartView, type PluginInfo, type ProjectUi, type PushState, type RecordCardProps, RecordLink, RecordPicker, type RecordPickerProps, ResendCodeButton, type ResendCodeButtonProps, ResponsiveDialog, type ResponsiveDialogProps, Section, Segmented, type SegmentedOption, type SegmentedProps, type ShellLogoProps, SidePanel, type SidePanelProps, SignInPrompt, type SignInPromptProps, Spinner, type Step, StepList, type StepState, Textarea, type TextareaProps, allowDelayMs, buttonClass, definePlugin, defineUi, diffStats, isConflict, isFramed, mergeUi, parseDiff, pluginApi, protobaseVersion, uiFromModule, useApiToken, useAppAddress, useArmedAfterFocus, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useFieldControl, useGuardedPress, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, usePlugin, usePushNotifications, useRecord, useResourceClient, useSeries, useUpdateRecord };
|
package/dist/index.js
CHANGED
|
@@ -51,6 +51,7 @@ import { Form } from "./app/create/form.js";
|
|
|
51
51
|
import { isConflict, useUpdateRecord } from "./data/use-update-record.js";
|
|
52
52
|
import { App } from "./app/app.js";
|
|
53
53
|
import { RecordLink } from "./app/displays/record-link.js";
|
|
54
|
+
import { usePushNotifications } from "./notifications/push/use-push-notifications.js";
|
|
54
55
|
import { Choices } from "./primitives/choices.js";
|
|
55
56
|
import { RecordPicker } from "./app/record-picker.js";
|
|
56
|
-
export { ActionCard, ActionInputError, ApiProvider, App, AppLink, AssistantDock, AuthLayout, Button, ChatMessage, ChatThread, Choices, CodeForm, CompactTable, Composer, Dialog, DiffView, Field, FieldList, Form, Input, LogoMark, Markdown, MenuSelect, OtpInput, PartView, RecordLink, RecordPicker, ResendCodeButton, ResponsiveDialog, Section, Segmented, SidePanel, SignInPrompt, Spinner, StepList, Textarea, allowDelayMs, buttonClass, definePlugin, defineUi, diffStats, isConflict, isFramed, mergeUi, parseDiff, pluginApi, protobaseVersion, uiFromModule, useApiToken, useAppAddress, useArmedAfterFocus, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useFieldControl, useGuardedPress, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, usePlugin, useRecord, useResourceClient, useSeries, useUpdateRecord };
|
|
57
|
+
export { ActionCard, ActionInputError, ApiProvider, App, AppLink, AssistantDock, AuthLayout, Button, ChatMessage, ChatThread, Choices, CodeForm, CompactTable, Composer, Dialog, DiffView, Field, FieldList, Form, Input, LogoMark, Markdown, MenuSelect, OtpInput, PartView, RecordLink, RecordPicker, ResendCodeButton, ResponsiveDialog, Section, Segmented, SidePanel, SignInPrompt, Spinner, StepList, Textarea, allowDelayMs, buttonClass, definePlugin, defineUi, diffStats, isConflict, isFramed, mergeUi, parseDiff, pluginApi, protobaseVersion, uiFromModule, useApiToken, useAppAddress, useArmedAfterFocus, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useFieldControl, useGuardedPress, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, usePlugin, usePushNotifications, useRecord, useResourceClient, useSeries, useUpdateRecord };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { serviceWorkerFile } from "@protobase/schema/internal";
|
|
2
|
+
//#region src/notifications/push/push-browser.ts
|
|
3
|
+
/** The page's own browser. */
|
|
4
|
+
const windowPushBrowser = (win = window) => {
|
|
5
|
+
const { navigator } = win;
|
|
6
|
+
return {
|
|
7
|
+
supported: "serviceWorker" in navigator && "PushManager" in win && "Notification" in win && "userActivation" in navigator,
|
|
8
|
+
permission: () => win.Notification.permission,
|
|
9
|
+
requestPermission: () => win.Notification.requestPermission(),
|
|
10
|
+
activated: () => navigator.userActivation.isActive,
|
|
11
|
+
existingRegistration: () => navigator.serviceWorker.getRegistration("/"),
|
|
12
|
+
registration: async () => {
|
|
13
|
+
if (!await navigator.serviceWorker.getRegistration("/")) await navigator.serviceWorker.register(`/${serviceWorkerFile}`);
|
|
14
|
+
return navigator.serviceWorker.ready;
|
|
15
|
+
},
|
|
16
|
+
clearBadge: async () => {
|
|
17
|
+
if ("clearAppBadge" in navigator) await navigator.clearAppBadge();
|
|
18
|
+
},
|
|
19
|
+
userAgent: navigator.userAgent.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 512)
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
//#endregion
|
|
23
|
+
export { windowPushBrowser };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { windowPushBrowser } from "./push-browser.js";
|
|
2
|
+
import { indexedDbPushRecords } from "./push-records.js";
|
|
3
|
+
import { webPush } from "./web-push.js";
|
|
4
|
+
//#region src/notifications/push/push-notifications.ts
|
|
5
|
+
const noPush = {
|
|
6
|
+
state: async () => "unsupported",
|
|
7
|
+
turnOn: async () => "unsupported",
|
|
8
|
+
turnOff: async () => {},
|
|
9
|
+
forget: async () => {},
|
|
10
|
+
adopt: async () => {}
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* The push of this page, the one place its transport is chosen: Web Push in a browser; none in the iOS or Android app
|
|
14
|
+
* (`nativeBridge`) yet, nor where the page has no window.
|
|
15
|
+
*/
|
|
16
|
+
const pushNotifications = ({ nativeBridge, ...options }) => {
|
|
17
|
+
if (nativeBridge || typeof window === "undefined") return noPush;
|
|
18
|
+
const browser = windowPushBrowser();
|
|
19
|
+
if (!browser.supported) return noPush;
|
|
20
|
+
return webPush({
|
|
21
|
+
...options,
|
|
22
|
+
browser,
|
|
23
|
+
records: indexedDbPushRecords()
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
//#endregion
|
|
27
|
+
export { pushNotifications };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { pushRecord } from "@protobase/schema/internal";
|
|
2
|
+
//#region src/notifications/push/push-records.ts
|
|
3
|
+
/** The push record in IndexedDB, at the database, store and key the service worker reads (`pushRecord`). */
|
|
4
|
+
const indexedDbPushRecords = (factory = indexedDB) => {
|
|
5
|
+
const request = (mode, act) => new Promise((resolve, reject) => {
|
|
6
|
+
const opened = factory.open(pushRecord.database, 1);
|
|
7
|
+
opened.onupgradeneeded = () => opened.result.createObjectStore(pushRecord.store);
|
|
8
|
+
opened.onerror = () => reject(opened.error);
|
|
9
|
+
opened.onsuccess = () => {
|
|
10
|
+
const db = opened.result;
|
|
11
|
+
const asked = act(db.transaction(pushRecord.store, mode).objectStore(pushRecord.store));
|
|
12
|
+
asked.onsuccess = () => (db.close(), resolve(asked.result));
|
|
13
|
+
asked.onerror = () => (db.close(), reject(asked.error));
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
return {
|
|
17
|
+
read: () => request("readonly", (store) => store.get(pushRecord.key)),
|
|
18
|
+
write: async (record) => void await request("readwrite", (store) => store.put(record, pushRecord.key)),
|
|
19
|
+
clear: async () => void await request("readwrite", (store) => store.delete(pushRecord.key))
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
//#endregion
|
|
23
|
+
export { indexedDbPushRecords };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { PushState } from "./web-push.js";
|
|
2
|
+
//#region src/notifications/push/use-push-notifications.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Push in this browser for the signed-in user, for a bell or an app's own button: `state` is `loading` until known,
|
|
5
|
+
* then `unsupported` (hide the button: no Web Push here, such as a tab on iPhone and iPad, or the app has no VAPID key),
|
|
6
|
+
* `off`, `on` or `blocked` (the user refused notifications in the browser's settings). Call `turnOn` straight from a
|
|
7
|
+
* click: the browser asks for permission only then, and without a click by someone signed in as themselves nothing
|
|
8
|
+
* happens. `turnOff` unsubscribes this browser and removes its device.
|
|
9
|
+
*/
|
|
10
|
+
export declare const usePushNotifications: () => {
|
|
11
|
+
state: "loading" | PushState;
|
|
12
|
+
turnOn: () => Promise<void>;
|
|
13
|
+
turnOff: () => Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
//#endregion
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useAuth } from "../../auth/auth-provider.js";
|
|
2
|
+
import { useCallback, useEffect, useState } from "react";
|
|
3
|
+
//#region src/notifications/push/use-push-notifications.ts
|
|
4
|
+
/**
|
|
5
|
+
* Push in this browser for the signed-in user, for a bell or an app's own button: `state` is `loading` until known,
|
|
6
|
+
* then `unsupported` (hide the button: no Web Push here, such as a tab on iPhone and iPad, or the app has no VAPID key),
|
|
7
|
+
* `off`, `on` or `blocked` (the user refused notifications in the browser's settings). Call `turnOn` straight from a
|
|
8
|
+
* click: the browser asks for permission only then, and without a click by someone signed in as themselves nothing
|
|
9
|
+
* happens. `turnOff` unsubscribes this browser and removes its device.
|
|
10
|
+
*/
|
|
11
|
+
const usePushNotifications = () => {
|
|
12
|
+
const { push, state: auth } = useAuth();
|
|
13
|
+
const userId = auth.kind === "signed-in" ? auth.user.id : void 0;
|
|
14
|
+
const [state, setState] = useState("loading");
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
let current = true;
|
|
17
|
+
push.state().then((read) => current && setState(read));
|
|
18
|
+
return () => {
|
|
19
|
+
current = false;
|
|
20
|
+
};
|
|
21
|
+
}, [push, userId]);
|
|
22
|
+
return {
|
|
23
|
+
state,
|
|
24
|
+
turnOn: useCallback(async () => setState(await push.turnOn()), [push]),
|
|
25
|
+
turnOff: useCallback(async () => {
|
|
26
|
+
await push.turnOff();
|
|
27
|
+
setState(await push.state());
|
|
28
|
+
}, [push])
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
//#endregion
|
|
32
|
+
export { usePushNotifications };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import "@protobase/client";
|
|
2
|
+
//#region src/notifications/push/web-push.d.ts
|
|
3
|
+
/** Whether this browser gets the signed-in user's push: it cannot, it does not, it does, or the user blocked it. */
|
|
4
|
+
export type PushState = 'unsupported' | 'off' | 'on' | 'blocked';
|
|
5
|
+
//#endregion
|
|
@@ -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