@protobase/ui 0.60.1 → 0.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/app/app.js +7 -3
  2. package/dist/app/marks/mark-people.js +24 -0
  3. package/dist/app/pages/merge-ui.d.ts +2 -1
  4. package/dist/app/pages/merge-ui.js +4 -1
  5. package/dist/app/pages/project-ui.d.ts +7 -1
  6. package/dist/app/use-nav-recent.js +7 -2
  7. package/dist/app-shell/nav.d.ts +4 -1
  8. package/dist/app-shell/record-marks.js +21 -2
  9. package/dist/app-shell/sidebar-records.js +7 -1
  10. package/dist/index.d.ts +3 -2
  11. package/dist/index.js +3 -2
  12. package/dist/organizations/members-page.js +11 -5
  13. package/dist/package.js +1 -1
  14. package/dist/presence/presence-dot.js +17 -0
  15. package/dist/presence/presence-provider.js +36 -0
  16. package/dist/presence/presence.d.ts +14 -0
  17. package/dist/presence/presence.js +31 -0
  18. package/dist/shell/assets/{app-DTBU_zpR.js → app-Dk6Wz4uU.js} +7 -7
  19. package/dist/shell/assets/index-Iii7afo2.css +2 -0
  20. package/dist/shell/assets/{index-Jqqke1wV.js → index-Qko7VXeJ.js} +2 -2
  21. package/dist/shell/assets/{records-map-BbFggRwi.js → records-map-DMtWYUIm.js} +2 -2
  22. package/dist/shell/assets/shared-protobase-ui-DJI957cu.js +2 -0
  23. package/dist/shell/index.html +4 -4
  24. package/dist/shell/protobase.shell.json +2 -2
  25. package/dist/shell/protobase.sw.js +2 -2
  26. package/dist/shell-source-maps/assets/app-Dk6Wz4uU.js.map +1 -0
  27. package/dist/shell-source-maps/assets/{index-Jqqke1wV.js.map → index-Qko7VXeJ.js.map} +1 -1
  28. package/dist/shell-source-maps/assets/{records-map-BbFggRwi.js.map → records-map-DMtWYUIm.js.map} +1 -1
  29. package/dist/shell-source-maps/assets/shared-protobase-ui-DJI957cu.js.map +1 -0
  30. package/dist/status/messages.js +6 -2
  31. package/dist/status/status-mark.d.ts +4 -4
  32. package/dist/status/status-mark.js +3 -2
  33. package/package.json +4 -4
  34. package/dist/shell/assets/index-D0sx7n9i.css +0 -2
  35. package/dist/shell/assets/shared-protobase-ui-BgSucvf-.js +0 -2
  36. package/dist/shell-source-maps/assets/app-DTBU_zpR.js.map +0 -1
  37. package/dist/shell-source-maps/assets/shared-protobase-ui-BgSucvf-.js.map +0 -1
package/dist/app/app.js CHANGED
@@ -41,8 +41,10 @@ import { CreateRoute } from "./create/create-route.js";
41
41
  import { userMenuFromMeta } from "./user-menu-from-meta.js";
42
42
  import { navFromMeta, navItemId } from "./nav-from-meta.js";
43
43
  import { ListPage } from "./list-page.js";
44
+ import { usePresence } from "../presence/presence.js";
44
45
  import { MembersPage } from "../organizations/members-page.js";
45
46
  import { InvitationsPage } from "../organizations/invitations-page.js";
47
+ import { PresenceProvider } from "../presence/presence-provider.js";
46
48
  import { reopenIn, useOrganizations } from "../organizations/use-organizations.js";
47
49
  import { OrganizationPage } from "../organizations/organization-page.js";
48
50
  import { OrganizationsPage } from "../organizations/organizations-page.js";
@@ -118,6 +120,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
118
120
  const bell = meta.bell && user && !staff && /* @__PURE__ */ jsx(ShellBell, { own: shell?.bell });
119
121
  const status = useStatusMenu(Boolean(meta.status && user && !staff));
120
122
  const statuses = useStatuses().data;
123
+ const presence = usePresence();
121
124
  const [assistantOpen, setAssistantOpen] = useState(false);
122
125
  const assistantUrl = meta.assistant?.url;
123
126
  const assistantButton = assistantUrl && /* @__PURE__ */ jsx(AssistantButton, {
@@ -193,7 +196,8 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
193
196
  children: text("onlyOwners")
194
197
  }) : path === "/-/organization/members" ? /* @__PURE__ */ jsx(MembersPage, {
195
198
  current,
196
- statuses
199
+ statuses,
200
+ presence
197
201
  }) : /* @__PURE__ */ jsx(InvitationsPage, { current }) : organizationPage && path === "/-/organizations" || outsideOrganizations ? /* @__PURE__ */ jsx(OrganizationsPage, {
198
202
  state: organizations,
199
203
  basePath
@@ -302,12 +306,12 @@ const Authenticated = ({ baseUrl, client, sources, workspace, sidebarMode, ui, a
302
306
  onUnauthenticated: markSignedOut,
303
307
  children: /* @__PURE__ */ jsx(MetaGate, { children: /* @__PURE__ */ jsxs(ToastProvider, { children: [!sidebarBanner && /* @__PURE__ */ jsx(NewVersionToast, {}), path === connectRoute ? /* @__PURE__ */ jsx(ConnectRoute, {}) : /* @__PURE__ */ jsx(PluginGate, {
304
308
  ui,
305
- children: /* @__PURE__ */ jsx(RecordTitles, { children: /* @__PURE__ */ jsx(Routes, {
309
+ children: /* @__PURE__ */ jsx(PresenceProvider, { children: /* @__PURE__ */ jsx(RecordTitles, { children: /* @__PURE__ */ jsx(Routes, {
306
310
  workspace,
307
311
  sidebarMode,
308
312
  assistant,
309
313
  runtime
310
- }) })
314
+ }) }) })
311
315
  })] }) })
312
316
  });
313
317
  };
@@ -0,0 +1,24 @@
1
+ import { presenceOf } from "../../presence/presence.js";
2
+ //#region src/app/marks/mark-people.ts
3
+ /**
4
+ * The marks as the sidebar draws them: each mark that names a `person` carries that person's presence and status, by
5
+ * their user id, each left out when the app gives none for them.
6
+ */
7
+ const markPeople = (marks, presence, statuses) => {
8
+ if (!marks) return;
9
+ const statusOf = new Map((statuses ?? []).map((status) => [status.userId, status]));
10
+ return Object.fromEntries(Object.entries(marks).map(([key, { person, ...mark }]) => {
11
+ if (person === void 0) return [key, mark];
12
+ const status = statusOf.get(person);
13
+ const shown = presenceOf(presence, person);
14
+ return [key, {
15
+ ...mark,
16
+ person: {
17
+ ...status && { status },
18
+ ...shown && { presence: shown }
19
+ }
20
+ }];
21
+ }));
22
+ };
23
+ //#endregion
24
+ export { markPeople };
@@ -3,7 +3,8 @@ 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, board drops, title rules and marks hooks 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 and the bell, 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
+ * as is the presence hook.
7
8
  */
8
9
  export declare const mergeUi: (...uis: ProjectUi[]) => ProjectUi;
9
10
  //#endregion
@@ -36,7 +36,8 @@ 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, board drops, title rules and marks hooks 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 and the bell, 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
+ * as is the presence hook.
40
41
  */
41
42
  const mergeUi = (...uis) => {
42
43
  const named = uis.flatMap((ui, index) => flatten(ui, ui.name ?? `UI config ${index + 1}`));
@@ -51,6 +52,7 @@ const mergeUi = (...uis) => {
51
52
  const rightPanel = slot(named.flatMap(({ ui }) => ui.shell?.rightPanel ? [ui.shell.rightPanel] : []));
52
53
  const logo = named.findLast(({ ui }) => ui.shell?.logo)?.ui.shell?.logo;
53
54
  const bell = named.findLast(({ ui }) => ui.shell?.bell)?.ui.shell?.bell;
55
+ const presence = named.findLast(({ ui }) => ui.presence)?.ui.presence;
54
56
  const beforeSignIn = around(named.flatMap(({ ui }) => ui.beforeSignIn ? [ui.beforeSignIn] : []));
55
57
  const name = uis.at(-1)?.name;
56
58
  return {
@@ -68,6 +70,7 @@ const mergeUi = (...uis) => {
68
70
  ...logo && { logo },
69
71
  ...bell && { bell }
70
72
  } },
73
+ ...presence && { presence },
71
74
  ...beforeSignIn && { beforeSignIn }
72
75
  };
73
76
  };
@@ -1,3 +1,4 @@
1
+ import { PresenceHook } from "../../presence/presence.js";
1
2
  import { ShellLogoProps } from "../../app-shell/logo.js";
2
3
  import { FieldWidget } from "../field-types/field-widget.js";
3
4
  import { BoardDrop } from "../displays/board-drop.js";
@@ -48,7 +49,7 @@ export type BeforeSignInProps = {
48
49
  * The project's own React code for composed pages: custom components by the name their layout declares
49
50
  * (`component('UsageChart')`) or a widget part names, handlers for named actions that have no built-in behaviour,
50
51
  * field types by the name a view's `.widget(name)` chooses, drop rules by the name a list's `drag.rule` gives, board
51
- * drops by the name a list's `drag.board` gives, title rules by the name a view's `.titleFrom` gives, marks hooks by the name a view's `nav.recent.marks` gives, and the shell's slots. `extends` lists UI configs merged in before it (see `mergeUi`), or whole `protobase.ui.tsx` modules
52
+ * drops by the name a list's `drag.board` gives, title rules by the name a view's `.titleFrom` gives, marks hooks by the name a view's `nav.recent.marks` gives, the app's presence, and the shell's slots. `extends` lists UI configs merged in before it (see `mergeUi`), or whole `protobase.ui.tsx` modules
52
53
  * (`import * as tasksUi from '...'`), their exported components included; `name` names it in their errors.
53
54
  */
54
55
  export type ProjectUi = {
@@ -66,6 +67,11 @@ export type ProjectUi = {
66
67
  titles?: Record<string, TitleRule>;
67
68
  /** How the sidebar marks a resource's records for the person reading, by the name a view's `nav.recent.marks` gives (see `MarksHook`). */
68
69
  marks?: Record<string, MarksHook>;
70
+ /**
71
+ * Who is online or away, by user id, from the app's own presence (see `PresenceHook`): the members page and the
72
+ * sidebar rows whose mark names a `person` show it beside each person's status.
73
+ */
74
+ presence?: PresenceHook;
69
75
  /**
70
76
  * Rendered in the shell on every page: `actions` at the end of the top bar, `rightPanel` beside the page, `logo`
71
77
  * at the top of the sidebar in place of Protobase's (see `ShellLogoProps`), and `bell` in the top bar in place of the
@@ -1,12 +1,16 @@
1
1
  import { keys } from "../data/query-keys.js";
2
2
  import { useClient } from "../data/api-provider.js";
3
+ import { useStatuses } from "../status/use-statuses.js";
3
4
  import { useProjectUi } from "./pages/project-ui.js";
4
5
  import { useRecordTitle } from "./titles/record-titles.js";
5
6
  import { userMenuResources } from "./user-menu-from-meta.js";
7
+ import { usePresence } from "../presence/presence.js";
8
+ import { markPeople } from "./marks/mark-people.js";
6
9
  import { recentMarks, recentRecords, recentRequest, recentRowDiffers } from "./nav-recent.js";
7
10
  import { useEffect, useMemo } from "react";
8
11
  import { useQueries, useQueryClient } from "@tanstack/react-query";
9
12
  //#region src/app/use-nav-recent.ts
13
+ const withPeople = (useMarks) => useMarks && (() => markPeople(useMarks(), usePresence(), useStatuses().data));
10
14
  const groupKey = ({ model, request }) => keys.navRecent(model.name, request.filter, request.orderBy, request.pageSize);
11
15
  /**
12
16
  * Refetches a group when a record of its resource is read anew (a record page, or an app following one) and shows
@@ -32,7 +36,8 @@ const useFollowRecords = (groups) => {
32
36
  /**
33
37
  * The records of every sidebar group (`nav.recent`), one list request each, under the `list` query key so that any
34
38
  * write to the resource refreshes them, as does reading one of their records anew. The server applies the caller's
35
- * access, as on the list page. A group that names `marks` gets the project UI's hook, which the sidebar calls.
39
+ * access, as on the list page. A group that names `marks` gets the project UI's hook, which the sidebar calls, its
40
+ * marks' people given their presence and status.
36
41
  */
37
42
  const useNavRecent = (meta, basePath, active) => {
38
43
  const client = useClient();
@@ -48,7 +53,7 @@ const useNavRecent = (meta, basePath, active) => {
48
53
  view,
49
54
  recent,
50
55
  request: recentRequest(model, view, recent),
51
- useMarks: recentMarks(model.name, recent, marks)
56
+ useMarks: withPeople(recentMarks(model.name, recent, marks))
52
57
  }] : [];
53
58
  });
54
59
  }, [meta, marks]);
@@ -1,14 +1,17 @@
1
+ import "../status/status-mark.js";
1
2
  import "lucide-react";
2
3
  import "@protobase/schema";
3
4
  //#region src/app-shell/nav.d.ts
4
5
  /**
5
6
  * How a record under a sidebar entry stands out for the person reading, such as an unread conversation: `strong` draws
6
7
  * its title bold, `count` a badge at the end of its row (none at 0), and `label`, such as "3 mentions", is added to its
7
- * name for screen readers and its tooltip.
8
+ * name for screen readers and its tooltip. `person`, a user id, names the one person the record stands for, such as
9
+ * the other person in a direct message: the row shows their presence and status beside its title.
8
10
  */
9
11
  export type RecordMark = {
10
12
  strong?: boolean;
11
13
  count?: number;
12
14
  label?: string;
15
+ person?: string;
13
16
  };
14
17
  //#endregion
@@ -1,3 +1,6 @@
1
+ import { text } from "../status/messages.js";
2
+ import { availabilityLabel } from "../status/status-mark.js";
3
+ import { heldStatus } from "@protobase/schema/internal";
1
4
  //#region src/app-shell/record-marks.ts
2
5
  const markRecords = (records, marks) => records.map((record) => {
3
6
  const mark = marks && Object.hasOwn(marks, record.id) ? marks[record.id] : void 0;
@@ -6,9 +9,25 @@ const markRecords = (records, marks) => records.map((record) => {
6
9
  mark
7
10
  } : record;
8
11
  });
9
- /** What a sidebar record is called for screen readers and in its tooltip: its title, with its status and its mark's label. */
12
+ const personWords = (person) => {
13
+ if (!person) return [];
14
+ const held = heldStatus(person.status);
15
+ return [
16
+ person.presence && text(person.presence),
17
+ held && held.availability !== "automatic" ? availabilityLabel(held.availability) : void 0,
18
+ held?.text ?? void 0
19
+ ];
20
+ };
21
+ /**
22
+ * What a sidebar record is called for screen readers and in its tooltip: its title, with its status, its person's
23
+ * presence and status, and its mark's label.
24
+ */
10
25
  const recordNames = (record) => {
11
- const extra = [record.status?.label, record.mark?.label].filter((part) => part !== void 0 && part !== "");
26
+ const extra = [
27
+ record.status?.label,
28
+ ...personWords(record.mark?.person),
29
+ record.mark?.label
30
+ ].filter((part) => part !== void 0 && part !== "");
12
31
  return {
13
32
  ariaLabel: [record.label, ...extra].join(", "),
14
33
  title: extra.length === 0 ? record.label : `${record.label} (${extra.join(", ")})`
@@ -1,8 +1,9 @@
1
1
  import { cn } from "../lib/cn.js";
2
2
  import { subRow } from "./sidebar-rows.js";
3
3
  import { CountBadge } from "../primitives/count-badge.js";
4
- import { markRecords, recordNames } from "./record-marks.js";
5
4
  import { StatusDot } from "./status-dot.js";
5
+ import { StatusMark } from "../status/status-mark.js";
6
+ import { markRecords, recordNames } from "./record-marks.js";
6
7
  import { jsx, jsxs } from "react/jsx-runtime";
7
8
  //#region src/app-shell/sidebar-records.tsx
8
9
  const RecordRows = ({ records, mode, onNavigate }) => records.map((record) => {
@@ -28,6 +29,11 @@ const RecordRows = ({ records, mode, onNavigate }) => records.map((record) => {
28
29
  className: "truncate",
29
30
  children: record.label
30
31
  }),
32
+ record.mark?.person && /* @__PURE__ */ jsx(StatusMark, {
33
+ ...record.mark.person,
34
+ textShown: "hover",
35
+ className: "ml-1.5 shrink-0"
36
+ }),
31
37
  /* @__PURE__ */ jsx(CountBadge, {
32
38
  count: record.mark?.count,
33
39
  className: "ml-auto shrink-0"
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { Presence, PresenceHook, usePresence } from "./presence/presence.js";
2
+ import { AvailabilityMark, StatusMark, StatusMarkProps } from "./status/status-mark.js";
1
3
  import { RecordMark } from "./app-shell/nav.js";
2
4
  import { LogoMark, ShellLogoProps } from "./app-shell/logo.js";
3
5
  import { FieldWidget, FieldWidgetEditorProps, FieldWidgetProps } from "./app/field-types/field-widget.js";
@@ -31,7 +33,6 @@ import { useApiToken } from "./auth/use-api-token.js";
31
33
  import { PushState } from "./notifications/push/web-push.js";
32
34
  import { SignedInUser, useSignedInUser } from "./auth/use-signed-in-user.js";
33
35
  import { Member, MembersResult, useMembers } from "./people/use-members.js";
34
- import { AvailabilityMark, StatusMark, StatusMarkProps } from "./status/status-mark.js";
35
36
  import { statusRefreshMs, useOwnStatus, useStatuses } from "./status/use-statuses.js";
36
37
  import { usePushNotifications } from "./notifications/push/use-push-notifications.js";
37
38
  import { useInbox, useMarkNotificationsRead, useUnreadNotifications } from "./notifications/bell/use-inbox.js";
@@ -86,4 +87,4 @@ import { Section } from "./record-view/section.js";
86
87
  import { Form, FormProps } from "./app/create/form.js";
87
88
  import { RecordPicker, RecordPickerProps } from "./app/record-picker.js";
88
89
  import { Spinner } from "./primitives/spinner.js";
89
- export { ActionCard, type ActionCardProps, type ActionContext, type ActionHandler, ActionInputError, type ActionInputProps, ApiProvider, type ApiProviderProps, App, AppLink, type AppProps, AssistantDock, type AssistantDockProps, AuthLayout, type AuthLayoutProps, AvailabilityMark, type BackendData, type BackendRefresh, type BeforeSignInProps, type BoardDrop, type BoardDropContext, type BoardDropUndo, type BoardDropWrite, Button, type ButtonProps, type CardAction, type CardTone, Carousel, type CarouselImage, type CarouselProps, type ChatAuthor, ChatDivider, type ChatDividerProps, ChatMessage, type ChatMessageProps, 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, type MarksHook, type Member, type MembersResult, MentionBox, type MentionBoxProps, type MentionDraft, MentionedText, MenuSelect, type MenuSelectProps, type MetaData, OtpInput, type OtpInputProps, PartView, type PeopleSearch, type PickedUser, type PluginInfo, type ProjectUi, type PushState, type RecordCardProps, RecordLink, type RecordMark, RecordPicker, type RecordPickerProps, ResendCodeButton, type ResendCodeButtonProps, ResponsiveDialog, type ResponsiveDialogProps, Section, Segmented, type SegmentedOption, type SegmentedProps, type ShellLogoProps, SidePanel, type SidePanelProps, SignInPrompt, type SignInPromptProps, type SignedInUser, Spinner, StatusMark, type StatusMarkProps, type Step, StepList, type StepState, Textarea, type TextareaProps, type TitleReader, type TitleRule, allowDelayMs, buttonClass, chatDayLabel, definePlugin, defineUi, diffStats, emptyDraft, isConflict, isFramed, mentionedIds, mergeUi, parseDiff, pluginApi, protobaseVersion, sameChatDay, statusRefreshMs, uiFromModule, useApiToken, useAppAddress, useArmedAfterFocus, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useFieldControl, useGuardedPress, useHistogram, useInbox, useLayoutRecord, useList, useListDisplay, useMarkNotificationsRead, useMembers, useMeta, useOwnStatus, usePlugin, usePushNotifications, useRecord, useResourceClient, useSeries, useSignedInUser, useStatuses, useUnreadNotifications, useUpdateRecord };
90
+ export { ActionCard, type ActionCardProps, type ActionContext, type ActionHandler, ActionInputError, type ActionInputProps, ApiProvider, type ApiProviderProps, App, AppLink, type AppProps, AssistantDock, type AssistantDockProps, AuthLayout, type AuthLayoutProps, AvailabilityMark, type BackendData, type BackendRefresh, type BeforeSignInProps, type BoardDrop, type BoardDropContext, type BoardDropUndo, type BoardDropWrite, Button, type ButtonProps, type CardAction, type CardTone, Carousel, type CarouselImage, type CarouselProps, type ChatAuthor, ChatDivider, type ChatDividerProps, ChatMessage, type ChatMessageProps, 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, type MarksHook, type Member, type MembersResult, MentionBox, type MentionBoxProps, type MentionDraft, MentionedText, MenuSelect, type MenuSelectProps, type MetaData, OtpInput, type OtpInputProps, PartView, type PeopleSearch, type PickedUser, type PluginInfo, type Presence, type PresenceHook, type ProjectUi, type PushState, type RecordCardProps, RecordLink, type RecordMark, RecordPicker, type RecordPickerProps, ResendCodeButton, type ResendCodeButtonProps, ResponsiveDialog, type ResponsiveDialogProps, Section, Segmented, type SegmentedOption, type SegmentedProps, type ShellLogoProps, SidePanel, type SidePanelProps, SignInPrompt, type SignInPromptProps, type SignedInUser, Spinner, StatusMark, type StatusMarkProps, type Step, StepList, type StepState, Textarea, type TextareaProps, type TitleReader, type TitleRule, allowDelayMs, buttonClass, chatDayLabel, definePlugin, defineUi, diffStats, emptyDraft, isConflict, isFramed, mentionedIds, mergeUi, parseDiff, pluginApi, protobaseVersion, sameChatDay, statusRefreshMs, uiFromModule, useApiToken, useAppAddress, useArmedAfterFocus, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useFieldControl, useGuardedPress, useHistogram, useInbox, useLayoutRecord, useList, useListDisplay, useMarkNotificationsRead, useMembers, useMeta, useOwnStatus, usePlugin, usePresence, usePushNotifications, useRecord, useResourceClient, useSeries, useSignedInUser, useStatuses, useUnreadNotifications, useUpdateRecord };
package/dist/index.js CHANGED
@@ -21,6 +21,7 @@ import { ActionInputError } from "./app/pages/action-inputs/input-errors.js";
21
21
  import { useLayoutRecord } from "./app/pages/record-scope.js";
22
22
  import { useFacets, useHistogram, useSeries } from "./data/use-aggregates.js";
23
23
  import { LogoMark } from "./app-shell/logo.js";
24
+ import { AvailabilityMark, StatusMark } from "./status/status-mark.js";
24
25
  import { SignInPrompt } from "./auth/sign-in-prompt.js";
25
26
  import { useListDisplay } from "./app/displays/display-context.js";
26
27
  import { MenuSelect } from "./primitives/menu-select.js";
@@ -55,7 +56,7 @@ import { AssistantDock } from "./assistant-dock/assistant-dock.js";
55
56
  import { useAssistant } from "./assistant-dock/use-assistant.js";
56
57
  import { useAssistantClient } from "./assistant-dock/use-assistant-client.js";
57
58
  import { Form } from "./app/create/form.js";
58
- import { AvailabilityMark, StatusMark } from "./status/status-mark.js";
59
+ import { usePresence } from "./presence/presence.js";
59
60
  import { usePushNotifications } from "./notifications/push/use-push-notifications.js";
60
61
  import { useInbox, useMarkNotificationsRead, useUnreadNotifications } from "./notifications/bell/use-inbox.js";
61
62
  import { isConflict, useUpdateRecord } from "./data/use-update-record.js";
@@ -65,4 +66,4 @@ import { useSignedInUser } from "./auth/use-signed-in-user.js";
65
66
  import { Carousel } from "./carousel/carousel.js";
66
67
  import { Choices } from "./primitives/choices.js";
67
68
  import { RecordPicker } from "./app/record-picker.js";
68
- export { ActionCard, ActionInputError, ApiProvider, App, AppLink, AssistantDock, AuthLayout, AvailabilityMark, Button, Carousel, ChatDivider, ChatMessage, ChatThread, Choices, CodeForm, CompactTable, Composer, Dialog, DiffView, Field, FieldList, Form, Input, LogoMark, Markdown, MentionBox, MentionedText, MenuSelect, OtpInput, PartView, RecordLink, RecordPicker, ResendCodeButton, ResponsiveDialog, Section, Segmented, SidePanel, SignInPrompt, Spinner, StatusMark, StepList, Textarea, allowDelayMs, buttonClass, chatDayLabel, definePlugin, defineUi, diffStats, emptyDraft, isConflict, isFramed, mentionedIds, mergeUi, parseDiff, pluginApi, protobaseVersion, sameChatDay, statusRefreshMs, uiFromModule, useApiToken, useAppAddress, useArmedAfterFocus, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useFieldControl, useGuardedPress, useHistogram, useInbox, useLayoutRecord, useList, useListDisplay, useMarkNotificationsRead, useMembers, useMeta, useOwnStatus, usePlugin, usePushNotifications, useRecord, useResourceClient, useSeries, useSignedInUser, useStatuses, useUnreadNotifications, useUpdateRecord };
69
+ export { ActionCard, ActionInputError, ApiProvider, App, AppLink, AssistantDock, AuthLayout, AvailabilityMark, Button, Carousel, ChatDivider, ChatMessage, ChatThread, Choices, CodeForm, CompactTable, Composer, Dialog, DiffView, Field, FieldList, Form, Input, LogoMark, Markdown, MentionBox, MentionedText, MenuSelect, OtpInput, PartView, RecordLink, RecordPicker, ResendCodeButton, ResponsiveDialog, Section, Segmented, SidePanel, SignInPrompt, Spinner, StatusMark, StepList, Textarea, allowDelayMs, buttonClass, chatDayLabel, definePlugin, defineUi, diffStats, emptyDraft, isConflict, isFramed, mentionedIds, mergeUi, parseDiff, pluginApi, protobaseVersion, sameChatDay, statusRefreshMs, uiFromModule, useApiToken, useAppAddress, useArmedAfterFocus, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useFieldControl, useGuardedPress, useHistogram, useInbox, useLayoutRecord, useList, useListDisplay, useMarkNotificationsRead, useMembers, useMeta, useOwnStatus, usePlugin, usePresence, usePushNotifications, useRecord, useResourceClient, useSeries, useSignedInUser, useStatuses, useUnreadNotifications, useUpdateRecord };
@@ -1,6 +1,7 @@
1
1
  import { useAuth } from "../auth/auth-provider.js";
2
2
  import { Spinner } from "../primitives/spinner.js";
3
3
  import { Button } from "../primitives/button.js";
4
+ import { StatusMark } from "../status/status-mark.js";
4
5
  import { PageHeader } from "../app-shell/page-header.js";
5
6
  import { AccountSection, SectionError } from "../account/account-section.js";
6
7
  import { authMessage } from "../auth/auth-messages.js";
@@ -9,7 +10,7 @@ import { text } from "./messages.js";
9
10
  import { AppRolesPicker } from "./app-roles-picker.js";
10
11
  import { grantableRoles, organizationRoleLabel } from "./organization-paths.js";
11
12
  import { PersonRow } from "../people/person-row.js";
12
- import { StatusMark } from "../status/status-mark.js";
13
+ import { presenceOf } from "../presence/presence.js";
13
14
  import { useCallback, useEffect, useState } from "react";
14
15
  import { AuthError } from "@protobase/client";
15
16
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
@@ -20,7 +21,7 @@ const assignableOrganizationRoles = (current) => current?.role === "owner" ? [
20
21
  "admin",
21
22
  "member"
22
23
  ] : ["admin", "member"];
23
- const MemberRow = ({ member, current, status, onChanged }) => {
24
+ const MemberRow = ({ member, current, status, presence, onChanged }) => {
24
25
  const { session, organizations } = useAuth();
25
26
  const organizationId = current.organization.id;
26
27
  const { busy, error, run } = useAction();
@@ -34,7 +35,10 @@ const MemberRow = ({ member, current, status, onChanged }) => {
34
35
  email: member.email,
35
36
  self,
36
37
  error,
37
- ...status && { status: /* @__PURE__ */ jsx(StatusMark, { status }) },
38
+ ...(status || presence) && { status: /* @__PURE__ */ jsx(StatusMark, {
39
+ status,
40
+ presence
41
+ }) },
38
42
  actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("select", {
39
43
  "aria-label": text("roleOf", member.name),
40
44
  value: member.role,
@@ -74,9 +78,10 @@ const MemberRow = ({ member, current, status, onChanged }) => {
74
78
  /**
75
79
  * The members of the organization one works in, for its owners and admins: organization roles, which decide who
76
80
  * manages it, and app roles, which decide what each sees and does; only roles the person may give can change. In an
77
- * app with status, `statuses` puts each member's beside their name (see `useStatuses`).
81
+ * app with status, `statuses` puts each member's beside their name (see `useStatuses`), and `presence`, the app's, puts
82
+ * whether they are online or away before it (see `usePresence`).
78
83
  */
79
- const MembersPage = ({ current, statuses }) => {
84
+ const MembersPage = ({ current, statuses, presence }) => {
80
85
  const { session } = useAuth();
81
86
  const [members, setMembers] = useState();
82
87
  const [error, setError] = useState();
@@ -100,6 +105,7 @@ const MembersPage = ({ current, statuses }) => {
100
105
  member,
101
106
  current,
102
107
  status: statuses?.find((status) => status.userId === member.userId),
108
+ presence: presenceOf(presence, member.userId),
103
109
  onChanged: load
104
110
  }, `${member.id}/${member.role}/${member.appRoles.join()}`))
105
111
  }) : !error && /* @__PURE__ */ jsxs("div", {
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.60.1";
2
+ var version = "0.62.0";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };
@@ -0,0 +1,17 @@
1
+ import { StatusDot } from "../app-shell/status-dot.js";
2
+ import { text } from "../status/messages.js";
3
+ import { jsx } from "react/jsx-runtime";
4
+ //#region src/presence/presence-dot.tsx
5
+ /** Online or away as a dot, named for screen readers and on hover. */
6
+ const PresenceDot = ({ presence }) => {
7
+ const label = text(presence);
8
+ return /* @__PURE__ */ jsx("span", {
9
+ role: "img",
10
+ "aria-label": label,
11
+ title: label,
12
+ className: "flex shrink-0",
13
+ children: /* @__PURE__ */ jsx(StatusDot, { tone: presence === "online" ? "success" : "neutral" })
14
+ });
15
+ };
16
+ //#endregion
17
+ export { PresenceDot };
@@ -0,0 +1,36 @@
1
+ import { useProjectUi } from "../app/pages/project-ui.js";
2
+ import { PresenceContext, createPresenceStore } from "./presence.js";
3
+ import { useEffect, useState } from "react";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ //#region src/presence/presence-provider.tsx
6
+ const PresenceReader = ({ usePresence, store }) => {
7
+ const presence = usePresence();
8
+ useEffect(() => store.set(presence), [store, presence]);
9
+ useEffect(() => () => store.set(void 0), [store]);
10
+ return null;
11
+ };
12
+ /**
13
+ * Calls the project UI's `presence` hook once, for every place that shows presence beneath it (see `usePresence`).
14
+ * A hook a plugin brings late, after the app first drew, starts its own reader, so the hooks never mix.
15
+ */
16
+ const PresenceProvider = ({ children }) => {
17
+ const { presence } = useProjectUi();
18
+ const [store] = useState(createPresenceStore);
19
+ const [reader, setReader] = useState({
20
+ hook: presence,
21
+ key: 0
22
+ });
23
+ if (reader.hook !== presence) setReader({
24
+ hook: presence,
25
+ key: reader.key + 1
26
+ });
27
+ return /* @__PURE__ */ jsxs(PresenceContext.Provider, {
28
+ value: store,
29
+ children: [presence && /* @__PURE__ */ jsx(PresenceReader, {
30
+ usePresence: presence,
31
+ store
32
+ }, reader.key), children]
33
+ });
34
+ };
35
+ //#endregion
36
+ export { PresenceProvider };
@@ -0,0 +1,14 @@
1
+ //#region src/presence/presence.d.ts
2
+ /** Whether someone has a page of the app open and in use, `away` when open but idle; by the app's own rule. */
3
+ export type Presence = 'online' | 'away';
4
+ /**
5
+ * The app's presence, by user id, as `presence` in `protobase.ui.tsx` gives it: a React hook, called once while the app
6
+ * shows, so it may use the app's own channels and live updates. Someone it leaves out has no page open, and shows no
7
+ * presence; returning undefined, as while it loads, shows none.
8
+ */
9
+ export type PresenceHook = () => PresenceByUser | undefined;
10
+ /** Presence by user id. */
11
+ export type PresenceByUser = Readonly<Record<string, Presence>>;
12
+ /** The app's presence by user id, undefined in an app that gives none (see `PresenceProvider`). */
13
+ export declare const usePresence: () => Readonly<Record<string, Presence>> | undefined;
14
+ //#endregion
@@ -0,0 +1,31 @@
1
+ import { createContext, useContext, useSyncExternalStore } from "react";
2
+ //#region src/presence/presence.ts
3
+ /** Holds the latest presence the app's hook gave, so the places that show it read it without calling the hook again. */
4
+ const createPresenceStore = () => {
5
+ let latest;
6
+ const listeners = /* @__PURE__ */ new Set();
7
+ return {
8
+ get: () => latest,
9
+ set: (presence) => {
10
+ if (presence === latest) return;
11
+ latest = presence;
12
+ for (const listener of listeners) listener();
13
+ },
14
+ subscribe: (listener) => {
15
+ listeners.add(listener);
16
+ return () => void listeners.delete(listener);
17
+ }
18
+ };
19
+ };
20
+ const PresenceContext = createContext(void 0);
21
+ const nobody = () => () => {};
22
+ const none = () => void 0;
23
+ /** The app's presence by user id, undefined in an app that gives none (see `PresenceProvider`). */
24
+ const usePresence = () => {
25
+ const store = useContext(PresenceContext);
26
+ return useSyncExternalStore(store?.subscribe ?? nobody, store?.get ?? none, store?.get ?? none);
27
+ };
28
+ /** One person's presence, from presence by user id: nothing for someone it leaves out. */
29
+ const presenceOf = (presence, userId) => presence && Object.hasOwn(presence, userId) ? presence[userId] : void 0;
30
+ //#endregion
31
+ export { PresenceContext, createPresenceStore, presenceOf, usePresence };