@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
package/dist/app/app.js CHANGED
@@ -49,6 +49,7 @@ import { actingUser } from "../roles/acting-user.js";
49
49
  import { useActiveRole } from "../roles/use-active-role.js";
50
50
  import { startAddress } from "./start-address.js";
51
51
  import { ProjectLogo } from "./project-logo.js";
52
+ import { ShellBell } from "./shell-bell.js";
52
53
  import { PluginGate, usePluginsOff } from "../plugins/plugin-gate.js";
53
54
  import { PluginNotice } from "../plugins/plugin-notice.js";
54
55
  import { RecordPage } from "./record-page.js";
@@ -114,6 +115,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
114
115
  const { shell } = useProjectUi();
115
116
  const Actions = shell?.actions;
116
117
  const RightPanel = shell?.rightPanel;
118
+ const bell = meta.bell && user && !staff && /* @__PURE__ */ jsx(ShellBell, { own: shell?.bell });
117
119
  const [assistantOpen, setAssistantOpen] = useState(false);
118
120
  const assistantUrl = meta.assistant?.url;
119
121
  const assistantButton = assistantUrl && /* @__PURE__ */ jsx(AssistantButton, {
@@ -254,12 +256,13 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
254
256
  groups: search.groups,
255
257
  onSelect: open
256
258
  } : void 0,
257
- actions: (Actions || reloadButton || runtimeButton || assistantButton) && /* @__PURE__ */ jsxs(Fragment$1, { children: [
259
+ actions: (Actions || bell || reloadButton || runtimeButton || assistantButton) && /* @__PURE__ */ jsxs(Fragment$1, { children: [
258
260
  Actions && /* @__PURE__ */ jsx(CustomBoundary, {
259
261
  name: "shell.actions",
260
262
  fallback: null,
261
263
  children: /* @__PURE__ */ jsx(Actions, {})
262
264
  }),
265
+ bell,
263
266
  reloadButton,
264
267
  runtimeButton,
265
268
  assistantButton
@@ -3,7 +3,7 @@ import { ProjectUi } from "./project-ui.js";
3
3
  /**
4
4
  * One UI config from several, each after the ones it `extends`, the last being the app's own: components, action
5
5
  * handlers, field types, drop rules and board drops are all kept, and one name defined by two configs is an error naming both; every shell slot
6
- * and `beforeSignIn` component is drawn, the extended configs' first, except the logo, the last config's to set one.
6
+ * and `beforeSignIn` component is drawn, the extended configs' first, except the logo and the bell, the last config's to set one.
7
7
  */
8
8
  export declare const mergeUi: (...uis: ProjectUi[]) => ProjectUi;
9
9
  //#endregion
@@ -36,7 +36,7 @@ const around = (wrappers) => {
36
36
  /**
37
37
  * One UI config from several, each after the ones it `extends`, the last being the app's own: components, action
38
38
  * handlers, field types, drop rules and board drops are all kept, and one name defined by two configs is an error naming both; every shell slot
39
- * and `beforeSignIn` component is drawn, the extended configs' first, except the logo, the last config's to set one.
39
+ * and `beforeSignIn` component is drawn, the extended configs' first, except the logo and the bell, the last config's to set one.
40
40
  */
41
41
  const mergeUi = (...uis) => {
42
42
  const named = uis.flatMap((ui, index) => flatten(ui, ui.name ?? `UI config ${index + 1}`));
@@ -48,6 +48,7 @@ const mergeUi = (...uis) => {
48
48
  const actionSlot = slot(named.flatMap(({ ui }) => ui.shell?.actions ? [ui.shell.actions] : []));
49
49
  const rightPanel = slot(named.flatMap(({ ui }) => ui.shell?.rightPanel ? [ui.shell.rightPanel] : []));
50
50
  const logo = named.findLast(({ ui }) => ui.shell?.logo)?.ui.shell?.logo;
51
+ const bell = named.findLast(({ ui }) => ui.shell?.bell)?.ui.shell?.bell;
51
52
  const beforeSignIn = around(named.flatMap(({ ui }) => ui.beforeSignIn ? [ui.beforeSignIn] : []));
52
53
  const name = uis.at(-1)?.name;
53
54
  return {
@@ -57,10 +58,11 @@ const mergeUi = (...uis) => {
57
58
  ...Object.keys(fields).length > 0 && { fields },
58
59
  ...Object.keys(dropRules).length > 0 && { dropRules },
59
60
  ...Object.keys(boardDrops).length > 0 && { boardDrops },
60
- ...(actionSlot || rightPanel || logo) && { shell: {
61
+ ...(actionSlot || rightPanel || logo || bell) && { shell: {
61
62
  ...actionSlot && { actions: actionSlot },
62
63
  ...rightPanel && { rightPanel },
63
- ...logo && { logo }
64
+ ...logo && { logo },
65
+ ...bell && { bell }
64
66
  } },
65
67
  ...beforeSignIn && { beforeSignIn }
66
68
  };
@@ -61,13 +61,16 @@ export type ProjectUi = {
61
61
  /** Which columns of a board take a card and what a drop there writes, by the name a view's `drag.board` gives (see `BoardDrop`). */
62
62
  boardDrops?: Record<string, BoardDrop>;
63
63
  /**
64
- * Rendered in the shell on every page: `actions` at the end of the top bar, `rightPanel` beside the page, and `logo`
65
- * at the top of the sidebar in place of Protobase's (see `ShellLogoProps`).
64
+ * Rendered in the shell on every page: `actions` at the end of the top bar, `rightPanel` beside the page, `logo`
65
+ * at the top of the sidebar in place of Protobase's (see `ShellLogoProps`), and `bell` in the top bar in place of the
66
+ * notification bell, where that would be: with notifications on and their `ui` not false, for someone signed in as
67
+ * themselves.
66
68
  */
67
69
  shell?: {
68
70
  actions?: ComponentType;
69
71
  rightPanel?: ComponentType;
70
72
  logo?: ComponentType<ShellLogoProps>;
73
+ bell?: ComponentType;
71
74
  };
72
75
  /**
73
76
  * Drawn around the pages someone signed out sees, only for a plugin the app loads before sign-in (`beforeSignIn` in
@@ -0,0 +1,12 @@
1
+ import { CustomBoundary } from "./pages/custom-boundary.js";
2
+ import { NotificationBell } from "../notifications/bell/notification-bell.js";
3
+ import { jsx } from "react/jsx-runtime";
4
+ //#region src/app/shell-bell.tsx
5
+ /** The top bar's notification bell, or the project's or a plugin's `shell.bell` in its place. */
6
+ const ShellBell = ({ own: Own }) => Own ? /* @__PURE__ */ jsx(CustomBoundary, {
7
+ name: "shell.bell",
8
+ fallback: null,
9
+ children: /* @__PURE__ */ jsx(Own, {})
10
+ }) : /* @__PURE__ */ jsx(NotificationBell, {});
11
+ //#endregion
12
+ export { ShellBell };
@@ -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 markSignedOut = useCallback(() => setState((current) => current.kind === "signed-in" ? { kind: "signed-out" } : current), []);
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 && { 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
- }, [session]);
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,19 @@ 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
+ };
64
+ notifications: {
65
+ list: ({ cursor, limit }?: {
66
+ cursor?: string;
67
+ limit?: number;
68
+ }) => Promise<import("@protobase/client").InboxPage>;
69
+ unread: () => Promise<number>;
70
+ markRead: (id: string) => Promise<void>;
71
+ markAllRead: (upTo: string) => Promise<void>;
72
+ };
60
73
  };
61
74
  /**
62
75
  * 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 +129,19 @@ export declare const useResourceClient: (resource: string) => {
116
129
  formChallenge: (name: string) => Promise<import("@protobase/client").FormChallenge>;
117
130
  submitForm: (name: string, values: Record<string, unknown>, proof: import("@protobase/client").FormProof) => Promise<void>;
118
131
  live: (handlers: import("@protobase/client").LiveHandlers) => () => void;
132
+ devices: {
133
+ register: (device: import("@protobase/client").WebDeviceRegistration) => Promise<import("@protobase/client").RegisteredDevice>;
134
+ remove: (id: string) => Promise<void>;
135
+ };
136
+ notifications: {
137
+ list: ({ cursor, limit }?: {
138
+ cursor?: string;
139
+ limit?: number;
140
+ }) => Promise<import("@protobase/client").InboxPage>;
141
+ unread: () => Promise<number>;
142
+ markRead: (id: string) => Promise<void>;
143
+ markAllRead: (upTo: string) => Promise<void>;
144
+ };
119
145
  };
120
146
  export type ApiProviderProps = {
121
147
  /** Defaults to `/api/v1` on the current origin. */
@@ -46,6 +46,8 @@ export type MetaData = {
46
46
  style: string;
47
47
  layers?: MapLayerModel[];
48
48
  };
49
+ /** Whether the top bar draws the notification bell: the app has notifications and their `ui` is not false (see `ShellBell`). */
50
+ bell?: boolean;
49
51
  /** The language the app's screens are in for everyone, when it sets one (see `AppLocale`). */
50
52
  locale?: Locale;
51
53
  /** The hosts besides the app's own whose images are drawn with the page's referrer (see `FileImage`). */
@@ -156,6 +156,12 @@ const keys = {
156
156
  key,
157
157
  path
158
158
  ],
159
+ /** Everything of the signed-in user's notification history. */
160
+ inbox: ["inbox"],
161
+ /** How many of the user's notifications are unread. */
162
+ inboxUnread: ["inbox", "unread"],
163
+ /** The pages of the user's notification history. */
164
+ inboxPages: ["inbox", "pages"],
159
165
  /** A public form, as `/-/forms/<name>` draws it. */
160
166
  publicForm: (name) => ["public-form", name]
161
167
  };
@@ -43,6 +43,7 @@ const useMeta = () => {
43
43
  ...result.meta.runtime && { runtime: result.meta.runtime },
44
44
  ...result.meta.errorReporting && { errorReporting: result.meta.errorReporting },
45
45
  ...result.meta.maps && { maps: result.meta.maps },
46
+ ...result.meta.notifications?.bell && { bell: true },
46
47
  ...result.meta.locale && { locale: result.meta.locale },
47
48
  ...result.meta.files && { files: result.meta.files },
48
49
  ...result.meta.plugins && { plugins: result.meta.plugins },
package/dist/index.d.ts CHANGED
@@ -25,6 +25,9 @@ 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";
30
+ import { useInbox, useMarkNotificationsRead, useUnreadNotifications } from "./notifications/bell/use-inbox.js";
28
31
  import { SignInPrompt, SignInPromptProps } from "./auth/sign-in-prompt.js";
29
32
  import { ResponsiveDialog, ResponsiveDialogProps } from "./primitives/responsive-dialog.js";
30
33
  import { OtpInput, OtpInputProps } from "./primitives/otp-input.js";
@@ -66,4 +69,4 @@ import { Section } from "./record-view/section.js";
66
69
  import { Form, FormProps } from "./app/create/form.js";
67
70
  import { RecordPicker, RecordPickerProps } from "./app/record-picker.js";
68
71
  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 };
72
+ 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, useInbox, useLayoutRecord, useList, useListDisplay, useMarkNotificationsRead, useMeta, usePlugin, usePushNotifications, useRecord, useResourceClient, useSeries, useUnreadNotifications, useUpdateRecord };
package/dist/index.js CHANGED
@@ -48,9 +48,11 @@ import { useFacets, useHistogram, useSeries } from "./data/use-aggregates.js";
48
48
  import { useListDisplay } from "./app/displays/display-context.js";
49
49
  import { MenuSelect } from "./primitives/menu-select.js";
50
50
  import { Form } from "./app/create/form.js";
51
+ import { usePushNotifications } from "./notifications/push/use-push-notifications.js";
52
+ import { useInbox, useMarkNotificationsRead, useUnreadNotifications } from "./notifications/bell/use-inbox.js";
51
53
  import { isConflict, useUpdateRecord } from "./data/use-update-record.js";
52
54
  import { App } from "./app/app.js";
53
55
  import { RecordLink } from "./app/displays/record-link.js";
54
56
  import { Choices } from "./primitives/choices.js";
55
57
  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 };
58
+ 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, useInbox, useLayoutRecord, useList, useListDisplay, useMarkNotificationsRead, useMeta, usePlugin, usePushNotifications, useRecord, useResourceClient, useSeries, useUnreadNotifications, useUpdateRecord };
@@ -0,0 +1,40 @@
1
+ import { defineMessages } from "../../locale/messages.js";
2
+ //#region src/notifications/bell/messages.ts
3
+ const text = defineMessages({
4
+ en: {
5
+ notifications: "Notifications",
6
+ notificationsUnread: (count) => `Notifications, ${count} unread`,
7
+ notificationsManyUnread: "Notifications, more than 99 unread",
8
+ markAllRead: "Mark all as read",
9
+ empty: "No notifications yet",
10
+ showMore: "Show more",
11
+ loadFailed: "Your notifications could not be loaded.",
12
+ tryAgain: "Try again",
13
+ unread: "Unread",
14
+ opensNewTab: "(opens in a new tab)",
15
+ pushOff: "Get notifications on this device, even when the app is closed.",
16
+ turnOn: "Turn on",
17
+ pushOn: "Notifications are on for this device.",
18
+ turnOff: "Turn off",
19
+ pushBlocked: "Notifications are blocked on this device. To get them, allow notifications for this site in your browser or device settings."
20
+ },
21
+ nl: {
22
+ notifications: "Meldingen",
23
+ notificationsUnread: (count) => `Meldingen, ${count} ongelezen`,
24
+ notificationsManyUnread: "Meldingen, meer dan 99 ongelezen",
25
+ markAllRead: "Alles als gelezen markeren",
26
+ empty: "Nog geen meldingen",
27
+ showMore: "Meer tonen",
28
+ loadFailed: "Je meldingen konden niet worden geladen.",
29
+ tryAgain: "Opnieuw proberen",
30
+ unread: "Ongelezen",
31
+ opensNewTab: "(opent in een nieuw tabblad)",
32
+ pushOff: "Ontvang meldingen op dit apparaat, ook als de app gesloten is.",
33
+ turnOn: "Inschakelen",
34
+ pushOn: "Meldingen staan op dit apparaat aan.",
35
+ turnOff: "Uitschakelen",
36
+ pushBlocked: "Meldingen zijn op dit apparaat geblokkeerd. Je kunt ze voor deze site toestaan in de instellingen van je browser of apparaat."
37
+ }
38
+ });
39
+ //#endregion
40
+ export { text };
@@ -0,0 +1,91 @@
1
+ import { Badge } from "../../primitives/badge.js";
2
+ import { Button } from "../../primitives/button.js";
3
+ import { useRouter } from "../../app/router.js";
4
+ import { usePopoverMenu } from "../../primitives/popover-menu.js";
5
+ import { usePushNotifications } from "../push/use-push-notifications.js";
6
+ import { NotificationPanel } from "./notification-panel.js";
7
+ import { notificationTarget } from "./notification-target.js";
8
+ import { PushControl } from "./push-control.js";
9
+ import { historyRefused, useInbox, useMarkNotificationsRead, useUnreadNotifications } from "./use-inbox.js";
10
+ import { bellLabel, unreadBadge } from "./unread-label.js";
11
+ import { useState } from "react";
12
+ import { Bell } from "lucide-react";
13
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
14
+ import { createPortal } from "react-dom";
15
+ //#region src/notifications/bell/notification-bell.tsx
16
+ /**
17
+ * The top bar's bell: how many of the signed-in user's notifications are unread, and the panel it opens, their inbox,
18
+ * which ends with push for this device. The panel loads its notifications when it opens; opening one closes it.
19
+ */
20
+ const NotificationBell = () => {
21
+ const { open, setOpen, place, button, menu } = usePopoverMenu();
22
+ const unread = useUnreadNotifications();
23
+ const inbox = useInbox({ enabled: open });
24
+ const { markRead, markAllRead, markingAll } = useMarkNotificationsRead();
25
+ const push = usePushNotifications();
26
+ const { navigate, basePath } = useRouter();
27
+ const [now, setNow] = useState(Date.now);
28
+ const badge = unreadBadge(unread.data);
29
+ const label = bellLabel(unread.data);
30
+ const origin = window.location.origin;
31
+ if (historyRefused(unread.error)) return null;
32
+ const toggle = () => {
33
+ if (!open) {
34
+ setNow(Date.now());
35
+ unread.refetch();
36
+ if (inbox.data) inbox.refetch();
37
+ }
38
+ setOpen(!open);
39
+ };
40
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs(Button, {
41
+ ref: button,
42
+ size: "icon",
43
+ variant: "ghost",
44
+ "aria-label": label,
45
+ title: label,
46
+ "aria-haspopup": "dialog",
47
+ "aria-expanded": open,
48
+ onClick: toggle,
49
+ className: "relative max-md:size-9",
50
+ children: [/* @__PURE__ */ jsx(Bell, { className: "size-4 max-md:size-5" }), badge && /* @__PURE__ */ jsx(Badge, {
51
+ tone: "red",
52
+ dot: false,
53
+ className: "pointer-events-none absolute -top-1 -right-1.5 h-4 min-w-4 justify-center px-1 text-[10px] font-semibold max-md:top-0 max-md:right-0",
54
+ children: badge
55
+ })]
56
+ }), open && createPortal(/* @__PURE__ */ jsx("div", {
57
+ ref: menu,
58
+ role: "dialog",
59
+ "aria-label": bellLabel(void 0),
60
+ style: place ?? { visibility: "hidden" },
61
+ className: "fixed z-50 w-[min(22rem,calc(100%-0.5rem))] rounded-md border bg-background shadow-pop",
62
+ children: /* @__PURE__ */ jsx(NotificationPanel, {
63
+ items: inbox.items,
64
+ loading: inbox.isPending,
65
+ failed: inbox.isError,
66
+ onRetry: () => void inbox.refetch(),
67
+ hasMore: inbox.hasNextPage,
68
+ loadingMore: inbox.isFetchingNextPage,
69
+ onMore: () => void inbox.fetchNextPage(),
70
+ onMarkAllRead: markAllRead,
71
+ markingAll,
72
+ targetOf: (item) => notificationTarget(item.url, {
73
+ origin,
74
+ basePath
75
+ }),
76
+ onNavigate: (path) => navigate(path),
77
+ onOpen: (item) => {
78
+ markRead(item);
79
+ setOpen(false);
80
+ },
81
+ footer: /* @__PURE__ */ jsx(PushControl, {
82
+ state: push.state,
83
+ onTurnOn: () => void push.turnOn(),
84
+ onTurnOff: () => void push.turnOff()
85
+ }),
86
+ now
87
+ })
88
+ }), document.body)] });
89
+ };
90
+ //#endregion
91
+ export { NotificationBell };
@@ -0,0 +1,75 @@
1
+ import { cn } from "../../lib/cn.js";
2
+ import { RouteLink } from "../../primitives/route-link.js";
3
+ import { toneDots } from "../../primitives/badge.js";
4
+ import { formatRelativeTime } from "../../format/date.js";
5
+ import { text } from "./messages.js";
6
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
7
+ //#region src/notifications/bell/notification-item.tsx
8
+ const row = "flex w-full gap-2.5 px-3 py-2.5 text-left";
9
+ /**
10
+ * One notification of the panel: unread ones in bold beside a dot, its text, and how long ago it came. A notification
11
+ * with a url is a link to it, followed in the app for the app's own pages and opened in a new tab for another site;
12
+ * one without is not a link.
13
+ */
14
+ const NotificationItem = ({ item, target, onNavigate, onOpen, now }) => {
15
+ const unread = item.readAt === null;
16
+ const content = /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
17
+ className: cn("mt-1.5 size-2 shrink-0 rounded-full", unread ? toneDots.blue : "bg-transparent"),
18
+ children: unread && /* @__PURE__ */ jsx("span", {
19
+ className: "sr-only",
20
+ children: text("unread")
21
+ })
22
+ }), /* @__PURE__ */ jsxs("span", {
23
+ className: "min-w-0 flex-1",
24
+ children: [
25
+ /* @__PURE__ */ jsx("span", {
26
+ className: cn("block text-body text-foreground", unread && "font-semibold"),
27
+ children: item.title
28
+ }),
29
+ item.body && /* @__PURE__ */ jsx("span", {
30
+ className: "mt-0.5 line-clamp-2 block text-xs text-muted-foreground",
31
+ children: item.body
32
+ }),
33
+ /* @__PURE__ */ jsx("time", {
34
+ dateTime: item.createdAt,
35
+ className: "mt-0.5 block text-xs text-faint-foreground",
36
+ children: formatRelativeTime((now - Date.parse(item.createdAt)) / 1e3)
37
+ }),
38
+ target?.kind === "external" && /* @__PURE__ */ jsx("span", {
39
+ className: "sr-only",
40
+ children: text("opensNewTab")
41
+ })
42
+ ]
43
+ })] });
44
+ const opened = () => onOpen(item);
45
+ const middle = (event) => {
46
+ if (event.button === 1) opened();
47
+ };
48
+ if (!target) return /* @__PURE__ */ jsx("div", {
49
+ className: row,
50
+ children: content
51
+ });
52
+ if (target.kind === "app") return /* @__PURE__ */ jsx(RouteLink, {
53
+ to: {
54
+ href: target.href,
55
+ onFollow: () => onNavigate(target.path)
56
+ },
57
+ onClick: opened,
58
+ onAuxClick: middle,
59
+ className: cn(row, "hover:bg-muted"),
60
+ children: content
61
+ });
62
+ return /* @__PURE__ */ jsx("a", {
63
+ href: target.href,
64
+ onClick: opened,
65
+ onAuxClick: middle,
66
+ className: cn(row, "hover:bg-muted"),
67
+ ...target.kind === "external" && {
68
+ target: "_blank",
69
+ rel: "noopener"
70
+ },
71
+ children: content
72
+ });
73
+ };
74
+ //#endregion
75
+ export { NotificationItem };
@@ -0,0 +1,71 @@
1
+ import { Spinner } from "../../primitives/spinner.js";
2
+ import { Button } from "../../primitives/button.js";
3
+ import { text } from "./messages.js";
4
+ import { NotificationItem } from "./notification-item.js";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ //#region src/notifications/bell/notification-panel.tsx
7
+ /**
8
+ * The bell's panel, the user's inbox: a heading with "Mark all as read", the newest notifications first, "Show more"
9
+ * for older ones, and the footer. The list scrolls inside the panel: the panel is fixed to the viewport under the bell,
10
+ * so the page cannot scroll it.
11
+ */
12
+ const NotificationPanel = ({ items, loading, failed, onRetry, hasMore, loadingMore, onMore, onMarkAllRead, markingAll, targetOf, onNavigate, onOpen, footer, now }) => {
13
+ const newest = items[0];
14
+ const unread = items.some((item) => item.readAt === null);
15
+ return /* @__PURE__ */ jsxs("div", {
16
+ className: "flex max-h-[min(32rem,calc(100dvh-var(--pb-sticky-top,3rem)-1rem))] flex-col",
17
+ children: [
18
+ /* @__PURE__ */ jsxs("div", {
19
+ className: "flex min-h-11 items-center gap-2 border-b px-3",
20
+ children: [/* @__PURE__ */ jsx("h2", {
21
+ className: "flex-1 text-body font-semibold",
22
+ children: text("notifications")
23
+ }), newest && unread && /* @__PURE__ */ jsx(Button, {
24
+ size: "sm",
25
+ variant: "ghost",
26
+ loading: markingAll,
27
+ onClick: () => onMarkAllRead(newest),
28
+ children: text("markAllRead")
29
+ })]
30
+ }),
31
+ /* @__PURE__ */ jsxs("div", {
32
+ className: "min-h-0 flex-1 overflow-y-auto overscroll-contain",
33
+ children: [loading ? /* @__PURE__ */ jsx("div", {
34
+ className: "flex justify-center py-8",
35
+ children: /* @__PURE__ */ jsx(Spinner, { className: "size-4" })
36
+ }) : failed ? /* @__PURE__ */ jsxs("div", {
37
+ className: "flex flex-col items-center gap-2 px-3 py-6 text-center text-body text-muted-foreground",
38
+ children: [/* @__PURE__ */ jsx("p", { children: text("loadFailed") }), /* @__PURE__ */ jsx(Button, {
39
+ size: "sm",
40
+ onClick: onRetry,
41
+ children: text("tryAgain")
42
+ })]
43
+ }) : items.length === 0 ? /* @__PURE__ */ jsx("p", {
44
+ className: "px-3 py-8 text-center text-body text-muted-foreground",
45
+ children: text("empty")
46
+ }) : /* @__PURE__ */ jsx("ul", {
47
+ className: "divide-y",
48
+ children: items.map((item) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(NotificationItem, {
49
+ item,
50
+ target: targetOf(item),
51
+ onNavigate,
52
+ onOpen,
53
+ now
54
+ }) }, item.id))
55
+ }), hasMore && !loading && !failed && /* @__PURE__ */ jsx("div", {
56
+ className: "flex justify-center border-t py-2",
57
+ children: /* @__PURE__ */ jsx(Button, {
58
+ size: "sm",
59
+ variant: "ghost",
60
+ loading: loadingMore,
61
+ onClick: onMore,
62
+ children: text("showMore")
63
+ })
64
+ })]
65
+ }),
66
+ footer
67
+ ]
68
+ });
69
+ };
70
+ //#endregion
71
+ export { NotificationPanel };
@@ -0,0 +1,28 @@
1
+ //#region src/notifications/bell/notification-target.ts
2
+ const notificationTarget = (url, { origin, basePath }) => {
3
+ if (url === null) return void 0;
4
+ if (!URL.canParse(url, origin)) return void 0;
5
+ const target = new URL(url, origin);
6
+ if (target.protocol !== "http:" && target.protocol !== "https:") return void 0;
7
+ if (target.origin !== origin) return {
8
+ kind: "external",
9
+ href: target.href
10
+ };
11
+ const local = `${target.pathname}${target.search}${target.hash}`;
12
+ if (basePath === "") return {
13
+ kind: "app",
14
+ href: local,
15
+ path: local
16
+ };
17
+ if (target.pathname === basePath || target.pathname.startsWith(`${basePath}/`)) return {
18
+ kind: "app",
19
+ href: local,
20
+ path: local.slice(basePath.length) || "/"
21
+ };
22
+ return {
23
+ kind: "page",
24
+ href: local
25
+ };
26
+ };
27
+ //#endregion
28
+ export { notificationTarget };