@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.
Files changed (52) hide show
  1. package/dist/app/app.js +4 -1
  2. package/dist/app/pages/merge-ui.d.ts +1 -1
  3. package/dist/app/pages/merge-ui.js +5 -3
  4. package/dist/app/pages/project-ui.d.ts +5 -2
  5. package/dist/app/shell-bell.js +12 -0
  6. package/dist/auth/auth-provider.js +47 -4
  7. package/dist/data/api-provider.d.ts +26 -0
  8. package/dist/data/meta-data.d.ts +2 -0
  9. package/dist/data/query-keys.js +6 -0
  10. package/dist/data/use-meta.js +1 -0
  11. package/dist/index.d.ts +4 -1
  12. package/dist/index.js +3 -1
  13. package/dist/notifications/bell/messages.js +40 -0
  14. package/dist/notifications/bell/notification-bell.js +91 -0
  15. package/dist/notifications/bell/notification-item.js +75 -0
  16. package/dist/notifications/bell/notification-panel.js +71 -0
  17. package/dist/notifications/bell/notification-target.js +28 -0
  18. package/dist/notifications/bell/push-control.js +41 -0
  19. package/dist/notifications/bell/unread-label.js +12 -0
  20. package/dist/notifications/bell/use-inbox.d.ts +239 -0
  21. package/dist/notifications/bell/use-inbox.js +91 -0
  22. package/dist/notifications/push/push-browser.js +23 -0
  23. package/dist/notifications/push/push-notifications.js +27 -0
  24. package/dist/notifications/push/push-records.js +23 -0
  25. package/dist/notifications/push/use-push-notifications.d.ts +16 -0
  26. package/dist/notifications/push/use-push-notifications.js +37 -0
  27. package/dist/notifications/push/watch-permission.js +31 -0
  28. package/dist/notifications/push/web-push.d.ts +5 -0
  29. package/dist/notifications/push/web-push.js +165 -0
  30. package/dist/package.js +1 -1
  31. package/dist/plugins/plugin-ui.js +2 -1
  32. package/dist/primitives/button.d.ts +4 -2
  33. package/dist/primitives/button.js +2 -1
  34. package/dist/primitives/popover-menu.js +14 -8
  35. package/dist/shell/assets/app-BRrWBdcb.js +9 -0
  36. package/dist/shell/assets/{index-5S96usXO.js → index-9vOkVIyi.js} +2 -2
  37. package/dist/shell/assets/index-BZXw3deq.css +2 -0
  38. package/dist/shell/assets/{records-map-Cck4zwvz.js → records-map-CXXkyoSM.js} +2 -2
  39. package/dist/shell/assets/shared-protobase-ui-C_KpcuOu.js +2 -0
  40. package/dist/shell/index.html +4 -4
  41. package/dist/shell/protobase.shell.json +1 -1
  42. package/dist/shell/protobase.sw.js +99 -2
  43. package/dist/shell-source-maps/assets/app-BRrWBdcb.js.map +1 -0
  44. package/dist/shell-source-maps/assets/{index-5S96usXO.js.map → index-9vOkVIyi.js.map} +1 -1
  45. package/dist/shell-source-maps/assets/{records-map-Cck4zwvz.js.map → records-map-CXXkyoSM.js.map} +1 -1
  46. package/dist/shell-source-maps/assets/shared-protobase-ui-C_KpcuOu.js.map +1 -0
  47. package/package.json +4 -4
  48. package/dist/shell/assets/app-CYu7KSRV.js +0 -9
  49. package/dist/shell/assets/index-DVdwieyN.css +0 -2
  50. package/dist/shell/assets/shared-protobase-ui-CU75swuU.js +0 -2
  51. package/dist/shell-source-maps/assets/app-CYu7KSRV.js.map +0 -1
  52. package/dist/shell-source-maps/assets/shared-protobase-ui-CU75swuU.js.map +0 -1
@@ -0,0 +1,41 @@
1
+ import { Button } from "../../primitives/button.js";
2
+ import { text } from "./messages.js";
3
+ import { BellOff, BellRing } from "lucide-react";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ //#region src/notifications/bell/push-control.tsx
6
+ /**
7
+ * Push for this device at the foot of the panel: an offer to turn it on, that it is on with a way to turn it off, or
8
+ * that the browser blocks it and how to allow it. Nothing while it is not known yet, or where this device cannot get it.
9
+ */
10
+ const PushControl = ({ state, onTurnOn, onTurnOff }) => {
11
+ if (state === "loading" || state === "unsupported") return null;
12
+ if (state === "blocked") return /* @__PURE__ */ jsxs("p", {
13
+ className: "flex gap-2 border-t px-3 py-2.5 text-xs text-muted-foreground",
14
+ children: [/* @__PURE__ */ jsx(BellOff, {
15
+ className: "mt-0.5 size-3.5 shrink-0",
16
+ "aria-hidden": true
17
+ }), text("pushBlocked")]
18
+ });
19
+ const on = state === "on";
20
+ return /* @__PURE__ */ jsxs("div", {
21
+ className: "flex items-center gap-2 border-t px-3 py-2.5 text-xs text-muted-foreground",
22
+ children: [
23
+ /* @__PURE__ */ jsx(BellRing, {
24
+ className: "size-3.5 shrink-0",
25
+ "aria-hidden": true
26
+ }),
27
+ /* @__PURE__ */ jsx("span", {
28
+ className: "min-w-0 flex-1",
29
+ children: text(on ? "pushOn" : "pushOff")
30
+ }),
31
+ /* @__PURE__ */ jsx(Button, {
32
+ size: "sm",
33
+ variant: on ? "secondary" : "primary",
34
+ onClick: on ? onTurnOff : onTurnOn,
35
+ children: text(on ? "turnOff" : "turnOn")
36
+ })
37
+ ]
38
+ });
39
+ };
40
+ //#endregion
41
+ export { PushControl };
@@ -0,0 +1,12 @@
1
+ import { text } from "./messages.js";
2
+ //#region src/notifications/bell/unread-label.ts
3
+ const shownMax = 99;
4
+ /** The count over the bell, or none while nothing is unread. */
5
+ const unreadBadge = (count) => count === void 0 || count <= 0 ? void 0 : count > shownMax ? `${shownMax}+` : String(count);
6
+ /** The bell's name for a screen reader, with how many are unread. */
7
+ const bellLabel = (count) => {
8
+ if (count === void 0 || count <= 0) return text("notifications");
9
+ return count > shownMax ? text("notificationsManyUnread") : text("notificationsUnread", String(count));
10
+ };
11
+ //#endregion
12
+ export { bellLabel, unreadBadge };
@@ -0,0 +1,239 @@
1
+ import { InboxNotification, InboxPage } from "@protobase/client";
2
+ import { InfiniteData } from "@tanstack/react-query";
3
+ //#region src/notifications/bell/use-inbox.d.ts
4
+ /**
5
+ * How many of the signed-in user's notifications are unread, at most 100 (show `99+` from there): read on load, every
6
+ * half minute while the page is visible, on a return to it, and after the user opens or marks one read. It stops
7
+ * reading once the app refuses the user their history (`historyRefused`).
8
+ */
9
+ export declare const useUnreadNotifications: ({ enabled }?: {
10
+ enabled?: boolean;
11
+ }) => import("@tanstack/react-query").UseQueryResult<number, Error>;
12
+ /**
13
+ * The signed-in user's notifications, newest first, a page at a time: `items` those loaded so far, `fetchNextPage`
14
+ * the next page while `hasNextPage`. Read only while `enabled`, such as while the panel is open.
15
+ */
16
+ export declare const useInbox: ({ enabled }?: {
17
+ enabled?: boolean;
18
+ }) => {
19
+ items: InboxNotification[];
20
+ data: InfiniteData<InboxPage, unknown>;
21
+ error: Error;
22
+ isError: true;
23
+ isPending: false;
24
+ isLoading: false;
25
+ isLoadingError: false;
26
+ isRefetchError: true;
27
+ isSuccess: false;
28
+ isPlaceholderData: false;
29
+ status: "error";
30
+ fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
31
+ fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
32
+ hasNextPage: boolean;
33
+ hasPreviousPage: boolean;
34
+ isFetchNextPageError: boolean;
35
+ isFetchingNextPage: boolean;
36
+ isFetchPreviousPageError: boolean;
37
+ isFetchingPreviousPage: boolean;
38
+ dataUpdatedAt: number;
39
+ errorUpdatedAt: number;
40
+ failureCount: number;
41
+ failureReason: Error | null;
42
+ errorUpdateCount: number;
43
+ isFetched: boolean;
44
+ isFetchedAfterMount: boolean;
45
+ isFetching: boolean;
46
+ isInitialLoading: boolean;
47
+ isPaused: boolean;
48
+ isRefetching: boolean;
49
+ isStale: boolean;
50
+ isEnabled: boolean;
51
+ refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
52
+ fetchStatus: import("@tanstack/react-query").FetchStatus;
53
+ } | {
54
+ items: InboxNotification[];
55
+ data: InfiniteData<InboxPage, unknown>;
56
+ error: null;
57
+ isError: false;
58
+ isPending: false;
59
+ isLoading: false;
60
+ isLoadingError: false;
61
+ isRefetchError: false;
62
+ isFetchNextPageError: false;
63
+ isFetchPreviousPageError: false;
64
+ isSuccess: true;
65
+ isPlaceholderData: false;
66
+ status: "success";
67
+ fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
68
+ fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
69
+ hasNextPage: boolean;
70
+ hasPreviousPage: boolean;
71
+ isFetchingNextPage: boolean;
72
+ isFetchingPreviousPage: boolean;
73
+ dataUpdatedAt: number;
74
+ errorUpdatedAt: number;
75
+ failureCount: number;
76
+ failureReason: Error | null;
77
+ errorUpdateCount: number;
78
+ isFetched: boolean;
79
+ isFetchedAfterMount: boolean;
80
+ isFetching: boolean;
81
+ isInitialLoading: boolean;
82
+ isPaused: boolean;
83
+ isRefetching: boolean;
84
+ isStale: boolean;
85
+ isEnabled: boolean;
86
+ refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
87
+ fetchStatus: import("@tanstack/react-query").FetchStatus;
88
+ } | {
89
+ items: InboxNotification[];
90
+ data: undefined;
91
+ error: Error;
92
+ isError: true;
93
+ isPending: false;
94
+ isLoading: false;
95
+ isLoadingError: true;
96
+ isRefetchError: false;
97
+ isFetchNextPageError: false;
98
+ isFetchPreviousPageError: false;
99
+ isSuccess: false;
100
+ isPlaceholderData: false;
101
+ status: "error";
102
+ fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
103
+ fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
104
+ hasNextPage: boolean;
105
+ hasPreviousPage: boolean;
106
+ isFetchingNextPage: boolean;
107
+ isFetchingPreviousPage: boolean;
108
+ dataUpdatedAt: number;
109
+ errorUpdatedAt: number;
110
+ failureCount: number;
111
+ failureReason: Error | null;
112
+ errorUpdateCount: number;
113
+ isFetched: boolean;
114
+ isFetchedAfterMount: boolean;
115
+ isFetching: boolean;
116
+ isInitialLoading: boolean;
117
+ isPaused: boolean;
118
+ isRefetching: boolean;
119
+ isStale: boolean;
120
+ isEnabled: boolean;
121
+ refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
122
+ fetchStatus: import("@tanstack/react-query").FetchStatus;
123
+ } | {
124
+ items: InboxNotification[];
125
+ data: undefined;
126
+ error: null;
127
+ isError: false;
128
+ isPending: true;
129
+ isLoading: true;
130
+ isLoadingError: false;
131
+ isRefetchError: false;
132
+ isFetchNextPageError: false;
133
+ isFetchPreviousPageError: false;
134
+ isSuccess: false;
135
+ isPlaceholderData: false;
136
+ status: "pending";
137
+ fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
138
+ fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
139
+ hasNextPage: boolean;
140
+ hasPreviousPage: boolean;
141
+ isFetchingNextPage: boolean;
142
+ isFetchingPreviousPage: boolean;
143
+ dataUpdatedAt: number;
144
+ errorUpdatedAt: number;
145
+ failureCount: number;
146
+ failureReason: Error | null;
147
+ errorUpdateCount: number;
148
+ isFetched: boolean;
149
+ isFetchedAfterMount: boolean;
150
+ isFetching: boolean;
151
+ isInitialLoading: boolean;
152
+ isPaused: boolean;
153
+ isRefetching: boolean;
154
+ isStale: boolean;
155
+ isEnabled: boolean;
156
+ refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
157
+ fetchStatus: import("@tanstack/react-query").FetchStatus;
158
+ } | {
159
+ items: InboxNotification[];
160
+ data: undefined;
161
+ error: null;
162
+ isError: false;
163
+ isPending: true;
164
+ isLoadingError: false;
165
+ isRefetchError: false;
166
+ isFetchNextPageError: false;
167
+ isFetchPreviousPageError: false;
168
+ isSuccess: false;
169
+ isPlaceholderData: false;
170
+ status: "pending";
171
+ fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
172
+ fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
173
+ hasNextPage: boolean;
174
+ hasPreviousPage: boolean;
175
+ isFetchingNextPage: boolean;
176
+ isFetchingPreviousPage: boolean;
177
+ dataUpdatedAt: number;
178
+ errorUpdatedAt: number;
179
+ failureCount: number;
180
+ failureReason: Error | null;
181
+ errorUpdateCount: number;
182
+ isFetched: boolean;
183
+ isFetchedAfterMount: boolean;
184
+ isFetching: boolean;
185
+ isLoading: boolean;
186
+ isInitialLoading: boolean;
187
+ isPaused: boolean;
188
+ isRefetching: boolean;
189
+ isStale: boolean;
190
+ isEnabled: boolean;
191
+ refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
192
+ fetchStatus: import("@tanstack/react-query").FetchStatus;
193
+ } | {
194
+ items: InboxNotification[];
195
+ data: InfiniteData<InboxPage, unknown>;
196
+ isError: false;
197
+ error: null;
198
+ isPending: false;
199
+ isLoading: false;
200
+ isLoadingError: false;
201
+ isRefetchError: false;
202
+ isSuccess: true;
203
+ isPlaceholderData: true;
204
+ isFetchNextPageError: false;
205
+ isFetchPreviousPageError: false;
206
+ status: "success";
207
+ fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
208
+ fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
209
+ hasNextPage: boolean;
210
+ hasPreviousPage: boolean;
211
+ isFetchingNextPage: boolean;
212
+ isFetchingPreviousPage: boolean;
213
+ dataUpdatedAt: number;
214
+ errorUpdatedAt: number;
215
+ failureCount: number;
216
+ failureReason: Error | null;
217
+ errorUpdateCount: number;
218
+ isFetched: boolean;
219
+ isFetchedAfterMount: boolean;
220
+ isFetching: boolean;
221
+ isInitialLoading: boolean;
222
+ isPaused: boolean;
223
+ isRefetching: boolean;
224
+ isStale: boolean;
225
+ isEnabled: boolean;
226
+ refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
227
+ fetchStatus: import("@tanstack/react-query").FetchStatus;
228
+ };
229
+ /**
230
+ * Marks notifications read: `markRead(item)` one, as opening it does, and `markAllRead(newest)` all of them up to the
231
+ * newest one the user was shown, so one that arrives meanwhile stays unread. The panel and the count show it at once,
232
+ * and are read again once the server has it.
233
+ */
234
+ export declare const useMarkNotificationsRead: () => {
235
+ markRead: (item: InboxNotification) => void;
236
+ markAllRead: import("@tanstack/react-query").UseMutateFunction<void, Error, InboxNotification, void>;
237
+ markingAll: boolean;
238
+ };
239
+ //#endregion
@@ -0,0 +1,91 @@
1
+ import { keys } from "../../data/query-keys.js";
2
+ import { useClient } from "../../data/api-provider.js";
3
+ import { useCallback } from "react";
4
+ import { isApiError } from "@protobase/client";
5
+ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
6
+ //#region src/notifications/bell/use-inbox.ts
7
+ const unreadRefreshMs = 3e4;
8
+ /** Whether the app keeps the history from the user, as from a session its own sign-in does not mark as theirs. */
9
+ const historyRefused = (error) => isApiError(error) && error.status === 403;
10
+ /**
11
+ * How many of the signed-in user's notifications are unread, at most 100 (show `99+` from there): read on load, every
12
+ * half minute while the page is visible, on a return to it, and after the user opens or marks one read. It stops
13
+ * reading once the app refuses the user their history (`historyRefused`).
14
+ */
15
+ const useUnreadNotifications = ({ enabled = true } = {}) => {
16
+ const client = useClient();
17
+ return useQuery({
18
+ queryKey: keys.inboxUnread,
19
+ queryFn: () => client.notifications.unread(),
20
+ refetchInterval: (query) => historyRefused(query.state.error) ? false : unreadRefreshMs,
21
+ enabled
22
+ });
23
+ };
24
+ /**
25
+ * The signed-in user's notifications, newest first, a page at a time: `items` those loaded so far, `fetchNextPage`
26
+ * the next page while `hasNextPage`. Read only while `enabled`, such as while the panel is open.
27
+ */
28
+ const useInbox = ({ enabled = true } = {}) => {
29
+ const client = useClient();
30
+ const query = useInfiniteQuery({
31
+ queryKey: keys.inboxPages,
32
+ queryFn: ({ pageParam }) => client.notifications.list(pageParam === "" ? {} : { cursor: pageParam }),
33
+ initialPageParam: "",
34
+ getNextPageParam: (page) => page.next ?? void 0,
35
+ enabled
36
+ });
37
+ const items = query.data?.pages.flatMap((page) => page.items) ?? [];
38
+ return {
39
+ ...query,
40
+ items
41
+ };
42
+ };
43
+ const showRead = (queryClient, read) => {
44
+ const now = (/* @__PURE__ */ new Date()).toISOString();
45
+ let marked = 0;
46
+ queryClient.setQueryData(keys.inboxPages, (data) => data && {
47
+ ...data,
48
+ pages: data.pages.map((page) => ({
49
+ ...page,
50
+ items: page.items.map((item) => {
51
+ if (item.readAt !== null || !read(item)) return item;
52
+ marked += 1;
53
+ return {
54
+ ...item,
55
+ readAt: now
56
+ };
57
+ })
58
+ }))
59
+ });
60
+ queryClient.setQueryData(keys.inboxUnread, (count) => count === void 0 ? count : Math.max(0, count - marked));
61
+ };
62
+ /**
63
+ * Marks notifications read: `markRead(item)` one, as opening it does, and `markAllRead(newest)` all of them up to the
64
+ * newest one the user was shown, so one that arrives meanwhile stays unread. The panel and the count show it at once,
65
+ * and are read again once the server has it.
66
+ */
67
+ const useMarkNotificationsRead = () => {
68
+ const client = useClient();
69
+ const queryClient = useQueryClient();
70
+ const settled = () => queryClient.invalidateQueries({ queryKey: keys.inbox });
71
+ const one = useMutation({
72
+ mutationFn: (item) => client.notifications.markRead(item.id),
73
+ onMutate: (item) => showRead(queryClient, (shown) => shown.id === item.id),
74
+ onSettled: settled
75
+ });
76
+ const all = useMutation({
77
+ mutationFn: (newest) => client.notifications.markAllRead(newest.createdAt),
78
+ onMutate: (newest) => showRead(queryClient, (shown) => shown.createdAt <= newest.createdAt),
79
+ onSettled: settled
80
+ });
81
+ const markOne = one.mutate;
82
+ return {
83
+ markRead: useCallback((item) => {
84
+ if (item.readAt === null) markOne(item);
85
+ }, [markOne]),
86
+ markAllRead: all.mutate,
87
+ markingAll: all.isPending
88
+ };
89
+ };
90
+ //#endregion
91
+ export { historyRefused, useInbox, useMarkNotificationsRead, useUnreadNotifications };
@@ -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,16 @@
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. `state` is read again on a return to the page
9
+ * and when the browser says the permission changed.
10
+ */
11
+ export declare const usePushNotifications: () => {
12
+ state: "loading" | PushState;
13
+ turnOn: () => Promise<void>;
14
+ turnOff: () => Promise<void>;
15
+ };
16
+ //#endregion
@@ -0,0 +1,37 @@
1
+ import { useAuth } from "../../auth/auth-provider.js";
2
+ import { watchNotificationPermission } from "./watch-permission.js";
3
+ import { useCallback, useEffect, useState } from "react";
4
+ //#region src/notifications/push/use-push-notifications.ts
5
+ /**
6
+ * Push in this browser for the signed-in user, for a bell or an app's own button: `state` is `loading` until known,
7
+ * then `unsupported` (hide the button: no Web Push here, such as a tab on iPhone and iPad, or the app has no VAPID key),
8
+ * `off`, `on` or `blocked` (the user refused notifications in the browser's settings). Call `turnOn` straight from a
9
+ * click: the browser asks for permission only then, and without a click by someone signed in as themselves nothing
10
+ * happens. `turnOff` unsubscribes this browser and removes its device. `state` is read again on a return to the page
11
+ * and when the browser says the permission changed.
12
+ */
13
+ const usePushNotifications = () => {
14
+ const { push, state: auth } = useAuth();
15
+ const userId = auth.kind === "signed-in" ? auth.user.id : void 0;
16
+ const [state, setState] = useState("loading");
17
+ useEffect(() => {
18
+ let current = true;
19
+ const read = () => void push.state().then((next) => current && setState(next));
20
+ read();
21
+ const stop = typeof window === "undefined" ? void 0 : watchNotificationPermission(read);
22
+ return () => {
23
+ current = false;
24
+ stop?.();
25
+ };
26
+ }, [push, userId]);
27
+ return {
28
+ state,
29
+ turnOn: useCallback(async () => setState(await push.turnOn()), [push]),
30
+ turnOff: useCallback(async () => {
31
+ await push.turnOff();
32
+ setState(await push.state());
33
+ }, [push])
34
+ };
35
+ };
36
+ //#endregion
37
+ export { usePushNotifications };
@@ -0,0 +1,31 @@
1
+ //#region src/notifications/push/watch-permission.ts
2
+ /**
3
+ * Calls `onChange` whenever the page's notification permission may have changed until the returned function is called:
4
+ * the Permissions API's change event, as when the user allows notifications from the address bar, and a return to the
5
+ * page, as from the browser's or the phone's settings, which also covers a browser whose Permissions API does not know
6
+ * notifications.
7
+ */
8
+ const watchNotificationPermission = (onChange, win = window) => {
9
+ let status;
10
+ let stopped = false;
11
+ const visible = () => {
12
+ if (win.document.visibilityState === "visible") onChange();
13
+ };
14
+ win.addEventListener("focus", onChange);
15
+ win.document.addEventListener("visibilitychange", visible);
16
+ if ("permissions" in win.navigator) win.navigator.permissions.query({ name: "notifications" }).then((answer) => {
17
+ if (stopped) return;
18
+ status = answer;
19
+ answer.addEventListener("change", onChange);
20
+ }, (error) => {
21
+ if (!(error instanceof TypeError)) throw error;
22
+ });
23
+ return () => {
24
+ stopped = true;
25
+ win.removeEventListener("focus", onChange);
26
+ win.document.removeEventListener("visibilitychange", visible);
27
+ status?.removeEventListener("change", onChange);
28
+ };
29
+ };
30
+ //#endregion
31
+ export { watchNotificationPermission };
@@ -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