@protobase/ui 0.62.5 → 0.63.1

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 (46) hide show
  1. package/dist/app/app.d.ts +1 -1
  2. package/dist/app/app.js +22 -5
  3. package/dist/calls/call-check.js +2 -2
  4. package/dist/calls/call-controls.js +16 -4
  5. package/dist/calls/call-guests-panel.js +200 -0
  6. package/dist/calls/call-lobby-notice.js +34 -0
  7. package/dist/calls/call-message.js +4 -3
  8. package/dist/calls/call-people.js +3 -2
  9. package/dist/calls/call-stage.js +4 -2
  10. package/dist/calls/call-view.d.ts +9 -3
  11. package/dist/calls/call-view.js +37 -6
  12. package/dist/calls/call-waiting.js +20 -0
  13. package/dist/calls/guest-admission.js +40 -0
  14. package/dist/calls/guest-call-link.js +19 -0
  15. package/dist/calls/guest-call-page.js +82 -0
  16. package/dist/calls/messages.js +69 -3
  17. package/dist/calls/use-call-session.d.ts +7 -0
  18. package/dist/calls/use-call-session.js +27 -2
  19. package/dist/calls/use-guest-request.js +24 -0
  20. package/dist/data/api-provider.d.ts +8 -0
  21. package/dist/index.js +1 -1
  22. package/dist/organizations/messages.js +6 -0
  23. package/dist/organizations/organization-page.js +33 -1
  24. package/dist/package.js +1 -1
  25. package/dist/shell/assets/app-BsTdlWO_.js +9 -0
  26. package/dist/shell/assets/calls-BxifWSFQ.js +2 -0
  27. package/dist/shell/assets/index-Be8nCyRW.css +2 -0
  28. package/dist/shell/assets/{index-C6bDRZz0.js → index-Bl1d-Zon.js} +2 -2
  29. package/dist/shell/assets/{records-map-B5MPNRQc.js → records-map-C29oyYdz.js} +2 -2
  30. package/dist/shell/assets/shared-protobase-ui-DLQLiVpe.js +2 -0
  31. package/dist/shell/index.html +4 -4
  32. package/dist/shell/protobase.shell.json +2 -2
  33. package/dist/shell/protobase.sw.js +2 -2
  34. package/dist/shell-source-maps/assets/app-BsTdlWO_.js.map +1 -0
  35. package/dist/shell-source-maps/assets/calls-BxifWSFQ.js.map +1 -0
  36. package/dist/shell-source-maps/assets/{index-C6bDRZz0.js.map → index-Bl1d-Zon.js.map} +1 -1
  37. package/dist/shell-source-maps/assets/{records-map-B5MPNRQc.js.map → records-map-C29oyYdz.js.map} +1 -1
  38. package/dist/shell-source-maps/assets/shared-protobase-ui-DLQLiVpe.js.map +1 -0
  39. package/package.json +4 -4
  40. package/dist/shell/assets/app-z3__9x3P.js +0 -9
  41. package/dist/shell/assets/calls-C63xSTRj.js +0 -2
  42. package/dist/shell/assets/index-BgcF_WZ0.css +0 -2
  43. package/dist/shell/assets/shared-protobase-ui-COFhNI_R.js +0 -2
  44. package/dist/shell-source-maps/assets/app-z3__9x3P.js.map +0 -1
  45. package/dist/shell-source-maps/assets/calls-C63xSTRj.js.map +0 -1
  46. package/dist/shell-source-maps/assets/shared-protobase-ui-COFhNI_R.js.map +0 -1
package/dist/app/app.d.ts CHANGED
@@ -35,7 +35,7 @@ export type AppProps = {
35
35
  nativeBridge?: NativeBridge;
36
36
  };
37
37
  /**
38
- * The admin: a public form at `/-/forms/<name>` for anyone, the sign-in route first, `/-/sign-in`, or browsing as a
38
+ * The admin: a public form at `/-/forms/<name>` for anyone, a guest link's call at `/-/call-guest` for its guest, the sign-in route first, `/-/sign-in`, or browsing as a
39
39
  * visitor when the project's anonymous role reads, then a sidebar from `/meta`, `/:page` composed pages, `/:resource`
40
40
  * lists and `/:resource/:key` records. Fills its parent, in the theme this browser chose, light, dark or the system's.
41
41
  */
package/dist/app/app.js CHANGED
@@ -32,6 +32,8 @@ import { useLiveUpdates } from "../data/use-live-updates.js";
32
32
  import { useOpenedRecord } from "../data/use-record.js";
33
33
  import { PublicFormPage } from "../forms/public-form-page.js";
34
34
  import { useFormAddress } from "../forms/use-form-address.js";
35
+ import { useGuestCallAddress } from "../calls/guest-call-link.js";
36
+ import { GuestCallPage } from "../calls/guest-call-page.js";
35
37
  import { ThemeProvider, useTheme } from "../theme/theme-provider.js";
36
38
  import { AssistantButton, AssistantPanel } from "./assistant-shell.js";
37
39
  import { bottomBarFromMeta } from "./bottom-bar-from-meta.js";
@@ -332,8 +334,20 @@ const PublicFormGate = ({ baseUrl, client, ui, children }) => {
332
334
  })
333
335
  });
334
336
  };
337
+ const GuestCallGate = ({ baseUrl, children }) => {
338
+ const address = useGuestCallAddress();
339
+ const { basePath } = useRouter();
340
+ if (!address) return children;
341
+ const api = new URL(baseUrl ?? "/api/v1", window.location.href);
342
+ const systemPath = `${api.origin}${api.pathname.slice(0, api.pathname.lastIndexOf("/"))}`;
343
+ return /* @__PURE__ */ jsx(GuestCallPage, {
344
+ link: address.token,
345
+ systemPath,
346
+ base: `${window.location.origin}${basePath}`
347
+ });
348
+ };
335
349
  /**
336
- * The admin: a public form at `/-/forms/<name>` for anyone, the sign-in route first, `/-/sign-in`, or browsing as a
350
+ * The admin: a public form at `/-/forms/<name>` for anyone, a guest link's call at `/-/call-guest` for its guest, the sign-in route first, `/-/sign-in`, or browsing as a
337
351
  * visitor when the project's anonymous role reads, then a sidebar from `/meta`, `/:page` composed pages, `/:resource`
338
352
  * lists and `/:resource/:key` records. Fills its parent, in the theme this browser chose, light, dark or the system's.
339
353
  */
@@ -356,11 +370,14 @@ const App = ({ auth, workspace, basePath, initialUrl, pluginLoader, locale, nati
356
370
  baseUrl: rest.baseUrl,
357
371
  client: rest.client,
358
372
  ui: rest.ui,
359
- children: /* @__PURE__ */ jsx(AuthGate, {
360
- workspace,
361
- children: /* @__PURE__ */ jsx(Authenticated, {
373
+ children: /* @__PURE__ */ jsx(GuestCallGate, {
374
+ ...rest.baseUrl && { baseUrl: rest.baseUrl },
375
+ children: /* @__PURE__ */ jsx(AuthGate, {
362
376
  workspace,
363
- ...rest
377
+ children: /* @__PURE__ */ jsx(Authenticated, {
378
+ workspace,
379
+ ...rest
380
+ })
364
381
  })
365
382
  })
366
383
  }) })
@@ -13,7 +13,7 @@ const toggle = (on) => cn("size-10 rounded-full", !on && "bg-danger-soft text-da
13
13
  * The check of the microphone and camera before joining: the member's own camera as the others will see it, or their
14
14
  * initials, microphone and camera on and off, plain messages for a blocked or missing device, and Join.
15
15
  */
16
- const CallCheck = ({ name, member, camera, microphoneOn, cameraOn, microphone, cameraAvailable, notices, onMicrophone, onCamera, onJoin, cancel }) => /* @__PURE__ */ jsxs("section", {
16
+ const CallCheck = ({ name, member, camera, microphoneOn, cameraOn, microphone, cameraAvailable, notices, onMicrophone, onCamera, onJoin, cancel, joinLabel = text("joinCall") }) => /* @__PURE__ */ jsxs("section", {
17
17
  "aria-label": text("preview"),
18
18
  className: "flex size-full min-h-0 flex-col items-center justify-center gap-3 bg-background p-4",
19
19
  children: [
@@ -63,7 +63,7 @@ const CallCheck = ({ name, member, camera, microphoneOn, cameraOn, microphone, c
63
63
  variant: "primary",
64
64
  className: "h-10 rounded-full px-5",
65
65
  onClick: onJoin,
66
- children: text("joinCall")
66
+ children: joinLabel
67
67
  }),
68
68
  /* @__PURE__ */ jsx(RouteLink, {
69
69
  to: cancel,
@@ -1,16 +1,17 @@
1
1
  import { cn } from "../lib/cn.js";
2
2
  import { Button, buttonClass } from "../primitives/button.js";
3
3
  import { RouteLink } from "../primitives/route-link.js";
4
+ import { CountBadge } from "../primitives/count-badge.js";
4
5
  import { text } from "./messages.js";
5
- import { Mic, MicOff, MonitorUp, MonitorX, PhoneOff, Video, VideoOff } from "lucide-react";
6
+ import { Mic, MicOff, MonitorUp, MonitorX, PhoneOff, UserPlus, Video, VideoOff } from "lucide-react";
6
7
  import { jsx, jsxs } from "react/jsx-runtime";
7
8
  //#region src/calls/call-controls.tsx
8
9
  const toggle = (on) => cn("size-10 rounded-full", !on && "bg-danger-soft text-danger-text");
9
- /** The member's controls: microphone, camera and screen share on and off, and Leave, a link out of the call. */
10
- const CallControls = ({ microphoneOn, cameraOn, sharing, onMicrophone, onCamera, onShare, leave }) => /* @__PURE__ */ jsxs("div", {
10
+ /** The member's controls: microphone, camera and screen share on and off, the guests, and Leave, a link out of the call. */
11
+ const CallControls = ({ microphoneOn, cameraOn, sharing, onMicrophone, onCamera, onShare, guests, leave }) => /* @__PURE__ */ jsxs("div", {
11
12
  role: "toolbar",
12
13
  "aria-label": text("joinCall"),
13
- className: "flex shrink-0 items-center justify-center gap-2 py-2",
14
+ className: "flex shrink-0 flex-wrap items-center justify-center gap-2 py-2",
14
15
  children: [
15
16
  /* @__PURE__ */ jsx(Button, {
16
17
  size: "icon",
@@ -39,6 +40,17 @@ const CallControls = ({ microphoneOn, cameraOn, sharing, onMicrophone, onCamera,
39
40
  onClick: onShare,
40
41
  children: sharing ? /* @__PURE__ */ jsx(MonitorX, { className: "size-4" }) : /* @__PURE__ */ jsx(MonitorUp, { className: "size-4" })
41
42
  }),
43
+ guests && /* @__PURE__ */ jsxs(Button, {
44
+ size: "icon",
45
+ "aria-label": text("guests"),
46
+ title: text("guests"),
47
+ className: "relative size-10 rounded-full",
48
+ onClick: guests.onOpen,
49
+ children: [/* @__PURE__ */ jsx(UserPlus, { className: "size-4" }), /* @__PURE__ */ jsx(CountBadge, {
50
+ count: guests.waiting,
51
+ className: "absolute -top-1 -right-1"
52
+ })]
53
+ }),
42
54
  /* @__PURE__ */ jsxs(RouteLink, {
43
55
  to: leave,
44
56
  className: cn(buttonClass({ variant: "danger" }), "h-10 rounded-full px-4"),
@@ -0,0 +1,200 @@
1
+ import { Button } from "../primitives/button.js";
2
+ import { ResponsiveDialog } from "../primitives/responsive-dialog.js";
3
+ import { Input } from "../primitives/input.js";
4
+ import { Segmented } from "../primitives/segmented.js";
5
+ import { text } from "./messages.js";
6
+ import { useGuestRequest } from "./use-guest-request.js";
7
+ import { useState } from "react";
8
+ import { Check, Copy } from "lucide-react";
9
+ import { jsx, jsxs } from "react/jsx-runtime";
10
+ //#region src/calls/call-guests-panel.tsx
11
+ const sends = {
12
+ all: [
13
+ "camera",
14
+ "microphone",
15
+ "screen",
16
+ "screen-audio"
17
+ ],
18
+ camera: ["camera", "microphone"],
19
+ microphone: ["microphone"]
20
+ };
21
+ const lasting = {
22
+ call: void 0,
23
+ hour: 36e5
24
+ };
25
+ const Row = ({ name, children }) => /* @__PURE__ */ jsxs("li", {
26
+ className: "flex items-center justify-between gap-2 py-1.5",
27
+ children: [/* @__PURE__ */ jsx("span", {
28
+ className: "min-w-0 truncate text-sm text-foreground",
29
+ children: name
30
+ }), /* @__PURE__ */ jsx("span", {
31
+ className: "flex shrink-0 gap-1.5",
32
+ children
33
+ })]
34
+ });
35
+ /**
36
+ * A member's guests of the call: who waits in the lobby, to let in or turn away; the guests in the call, to remove; and
37
+ * the guest links, to make, copy and revoke, with what their guests may send. Names show as the guests wrote them.
38
+ */
39
+ const CallGuestsPanel = ({ lobby, inCall, guests, linkOf, open, onClose }) => {
40
+ const [send, setSend] = useState("camera");
41
+ const [lasts, setLasts] = useState("call");
42
+ const [made, setMade] = useState();
43
+ const [copied, setCopied] = useState(false);
44
+ const { run, refused } = useGuestRequest();
45
+ const create = () => run(async () => {
46
+ const ms = lasting[lasts];
47
+ const { link, token } = await guests.link({
48
+ publish: sends[send],
49
+ ...ms !== void 0 && { expiresAt: new Date(Date.now() + ms) }
50
+ });
51
+ setMade({
52
+ id: link.id,
53
+ url: linkOf(token)
54
+ });
55
+ });
56
+ const copy = (url) => void navigator.clipboard.writeText(url).then(() => {
57
+ setCopied(true);
58
+ setTimeout(() => setCopied(false), 2e3);
59
+ });
60
+ return /* @__PURE__ */ jsx(ResponsiveDialog, {
61
+ title: text("guests"),
62
+ description: text("addressesVisible"),
63
+ open,
64
+ onClose,
65
+ actions: /* @__PURE__ */ jsx(Button, {
66
+ onClick: onClose,
67
+ children: text("close")
68
+ }),
69
+ children: /* @__PURE__ */ jsxs("div", {
70
+ className: "flex flex-col gap-4",
71
+ children: [
72
+ refused && /* @__PURE__ */ jsx("p", {
73
+ role: "alert",
74
+ className: "rounded-md bg-warning-soft px-3 py-1.5 text-xs text-foreground",
75
+ children: text("guestRefused")
76
+ }),
77
+ lobby.guests.length > 0 && /* @__PURE__ */ jsxs("section", {
78
+ "aria-label": text("inLobby"),
79
+ children: [/* @__PURE__ */ jsx("h3", {
80
+ className: "text-xs font-medium text-muted-foreground",
81
+ children: text("inLobby")
82
+ }), /* @__PURE__ */ jsx("ul", { children: lobby.guests.map(({ guest, name }) => /* @__PURE__ */ jsxs(Row, {
83
+ name,
84
+ children: [/* @__PURE__ */ jsx(Button, {
85
+ size: "sm",
86
+ variant: "primary",
87
+ onClick: () => run(() => guests.admit(guest)),
88
+ children: text("admit")
89
+ }), /* @__PURE__ */ jsx(Button, {
90
+ size: "sm",
91
+ onClick: () => run(() => guests.deny(guest)),
92
+ children: text("deny")
93
+ })]
94
+ }, guest)) })]
95
+ }),
96
+ inCall.length > 0 && /* @__PURE__ */ jsxs("section", {
97
+ "aria-label": text("guestsInCall"),
98
+ children: [/* @__PURE__ */ jsx("h3", {
99
+ className: "text-xs font-medium text-muted-foreground",
100
+ children: text("guestsInCall")
101
+ }), /* @__PURE__ */ jsx("ul", { children: inCall.map(({ guest, name }) => /* @__PURE__ */ jsx(Row, {
102
+ name,
103
+ children: /* @__PURE__ */ jsx(Button, {
104
+ size: "sm",
105
+ variant: "danger",
106
+ onClick: () => run(() => guests.remove(guest)),
107
+ children: text("remove")
108
+ })
109
+ }, guest)) })]
110
+ }),
111
+ /* @__PURE__ */ jsxs("section", {
112
+ "aria-label": text("guestLinks"),
113
+ className: "flex flex-col gap-2",
114
+ children: [
115
+ /* @__PURE__ */ jsx("h3", {
116
+ className: "text-xs font-medium text-muted-foreground",
117
+ children: text("guestLinks")
118
+ }),
119
+ lobby.links.length === 0 && /* @__PURE__ */ jsx("p", {
120
+ className: "text-sm text-muted-foreground",
121
+ children: text("noLinks")
122
+ }),
123
+ /* @__PURE__ */ jsx("ul", { children: lobby.links.map((link) => /* @__PURE__ */ jsxs("li", {
124
+ className: "flex flex-col gap-1.5 py-1.5",
125
+ children: [made?.id === link.id && /* @__PURE__ */ jsx(Input, {
126
+ readOnly: true,
127
+ value: made.url,
128
+ "aria-label": text("copyLink"),
129
+ onFocus: (event) => event.currentTarget.select(),
130
+ trailing: /* @__PURE__ */ jsx("button", {
131
+ type: "button",
132
+ "aria-label": copied ? text("copied") : text("copyLink"),
133
+ onClick: () => copy(made.url),
134
+ className: "inline-flex size-8 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted pointer-coarse:size-11",
135
+ children: copied ? /* @__PURE__ */ jsx(Check, { className: "size-4 text-success" }) : /* @__PURE__ */ jsx(Copy, { className: "size-4" })
136
+ })
137
+ }), /* @__PURE__ */ jsxs("span", {
138
+ className: "flex items-center justify-between gap-2 text-xs text-muted-foreground",
139
+ children: [link.expires_at ? text("linkUntil", new Date(link.expires_at).toLocaleString()) : text("linkUntilEnd"), /* @__PURE__ */ jsx(Button, {
140
+ size: "sm",
141
+ onClick: () => run(() => guests.revoke(link.id)),
142
+ children: text("revoke")
143
+ })]
144
+ })]
145
+ }, link.id)) }),
146
+ /* @__PURE__ */ jsxs("div", {
147
+ className: "flex flex-col items-start gap-2",
148
+ children: [
149
+ /* @__PURE__ */ jsxs("span", {
150
+ className: "flex flex-wrap items-center gap-2 text-xs text-muted-foreground",
151
+ children: [text("guestsMaySend"), /* @__PURE__ */ jsx(Segmented, {
152
+ label: text("guestsMaySend"),
153
+ value: send,
154
+ onChange: setSend,
155
+ options: [
156
+ {
157
+ value: "all",
158
+ label: text("sendAll")
159
+ },
160
+ {
161
+ value: "camera",
162
+ label: text("sendCamera")
163
+ },
164
+ {
165
+ value: "microphone",
166
+ label: text("sendMicrophone")
167
+ }
168
+ ]
169
+ })]
170
+ }),
171
+ /* @__PURE__ */ jsxs("span", {
172
+ className: "flex flex-wrap items-center gap-2 text-xs text-muted-foreground",
173
+ children: [text("linkLasts"), /* @__PURE__ */ jsx(Segmented, {
174
+ label: text("linkLasts"),
175
+ value: lasts,
176
+ onChange: setLasts,
177
+ options: [{
178
+ value: "call",
179
+ label: text("linkUntilEnd")
180
+ }, {
181
+ value: "hour",
182
+ label: text("forAnHour")
183
+ }]
184
+ })]
185
+ }),
186
+ /* @__PURE__ */ jsx(Button, {
187
+ variant: "primary",
188
+ onClick: create,
189
+ children: text("newLink")
190
+ })
191
+ ]
192
+ })
193
+ ]
194
+ })
195
+ ]
196
+ })
197
+ });
198
+ };
199
+ //#endregion
200
+ export { CallGuestsPanel };
@@ -0,0 +1,34 @@
1
+ import { Button } from "../primitives/button.js";
2
+ import { text } from "./messages.js";
3
+ import { useGuestRequest } from "./use-guest-request.js";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ //#region src/calls/call-lobby-notice.tsx
6
+ /** Who waits first in the lobby, with Let in and Turn away, so a member sees it without opening the guests. */
7
+ const CallLobbyNotice = ({ waiting, guests }) => {
8
+ const { run, refused } = useGuestRequest();
9
+ const first = waiting[0];
10
+ if (!first) return null;
11
+ return /* @__PURE__ */ jsxs("div", {
12
+ role: "status",
13
+ className: "flex shrink-0 flex-wrap items-center gap-2 rounded-md bg-primary-soft px-3 py-1.5 text-xs text-foreground",
14
+ children: [
15
+ /* @__PURE__ */ jsx("span", {
16
+ className: "min-w-0 flex-1 truncate",
17
+ children: refused ? text("guestRefused") : text("waitingGuest", first.name)
18
+ }),
19
+ /* @__PURE__ */ jsx(Button, {
20
+ size: "sm",
21
+ variant: "primary",
22
+ onClick: () => run(() => guests.admit(first.guest)),
23
+ children: text("admit")
24
+ }),
25
+ /* @__PURE__ */ jsx(Button, {
26
+ size: "sm",
27
+ onClick: () => run(() => guests.deny(first.guest)),
28
+ children: text("deny")
29
+ })
30
+ ]
31
+ });
32
+ };
33
+ //#endregion
34
+ export { CallLobbyNotice };
@@ -7,16 +7,17 @@ const said = {
7
7
  removed: "removed",
8
8
  full: "full",
9
9
  refused: "refused",
10
- unavailable: "unavailable"
10
+ unavailable: "unavailable",
11
+ denied: "denied"
11
12
  };
12
- /** Why the member is not in the call, in plain words, and, unless they were removed, a way to join again. */
13
+ /** Why the member is not in the call, in plain words, and, unless they were removed or refused, a way to join again. */
13
14
  const CallMessage = ({ reason, onJoinAgain }) => /* @__PURE__ */ jsxs("section", {
14
15
  role: "status",
15
16
  className: "flex size-full min-h-0 flex-col items-center justify-center gap-3 bg-background p-4 text-center",
16
17
  children: [/* @__PURE__ */ jsx("p", {
17
18
  className: "text-sm text-foreground",
18
19
  children: text(reason === "rejoin" ? "ended" : said[reason])
19
- }), onJoinAgain && reason !== "removed" && reason !== "refused" && /* @__PURE__ */ jsx(Button, {
20
+ }), onJoinAgain && reason !== "removed" && reason !== "refused" && reason !== "denied" && /* @__PURE__ */ jsx(Button, {
20
21
  onClick: onJoinAgain,
21
22
  children: text("callAgain")
22
23
  })]
@@ -1,3 +1,4 @@
1
+ import { text } from "./messages.js";
1
2
  import { cannotConnect, connectionQuality } from "./call-layout.js";
2
3
  //#region src/calls/call-people.ts
3
4
  const received = (tracks, source) => tracks.find((track) => track.source === source && track.track);
@@ -6,10 +7,10 @@ const callPeople = ({ members, self, tracks, own, nameOf, speaker, connections,
6
7
  return (members.some((member) => member.member === self) ? members : [{
7
8
  member: self,
8
9
  joined_at: ""
9
- }, ...members]).map(({ member }) => {
10
+ }, ...members]).map(({ member, name }) => {
10
11
  const shared = {
11
12
  member,
12
- name: nameOf(member),
13
+ name: member.startsWith("guest:") ? text("guestName", name ?? "") : name ?? nameOf(member),
13
14
  ...speaker === member && { speaking: true }
14
15
  };
15
16
  if (member === self) return {
@@ -64,7 +64,7 @@ const Tiles = ({ layout, people, narrow }) => {
64
64
  * share large with the people beside it, or under it on a phone; the member alone; then the controls. The others'
65
65
  * sound plays unseen.
66
66
  */
67
- const CallStage = ({ people, state, notices = [], controls, className }) => {
67
+ const CallStage = ({ people, state, notices = [], controls, aside, children, className }) => {
68
68
  const { ref, width } = useWidth();
69
69
  const narrow = width > 0 && width < 640;
70
70
  const layout = callLayout({
@@ -87,6 +87,7 @@ const CallStage = ({ people, state, notices = [], controls, className }) => {
87
87
  children: [message === text("reconnecting") && /* @__PURE__ */ jsx(Spinner, { className: "size-3" }), message]
88
88
  }, message))
89
89
  }),
90
+ aside,
90
91
  /* @__PURE__ */ jsx("div", {
91
92
  className: "min-h-0 flex-1",
92
93
  children: state === "joining" ? /* @__PURE__ */ jsxs("p", {
@@ -103,7 +104,8 @@ const CallStage = ({ people, state, notices = [], controls, className }) => {
103
104
  people.filter((person) => !person.self).flatMap((person) => [["microphone", person.microphone], ["screen-audio", person.screenAudio]].map(([source, track]) => track && {
104
105
  key: `${person.member} ${source}`,
105
106
  track
106
- })).filter((audio) => audio !== void 0).map(({ key, track }) => /* @__PURE__ */ jsx(TrackAudio, { track }, key))
107
+ })).filter((audio) => audio !== void 0).map(({ key, track }) => /* @__PURE__ */ jsx(TrackAudio, { track }, key)),
108
+ children
107
109
  ]
108
110
  });
109
111
  };
@@ -1,4 +1,5 @@
1
1
  import { RouteTarget } from "../primitives/route-link.js";
2
+ import { CallSessionOptions } from "./use-call-session.js";
2
3
  //#region src/calls/call-view.d.ts
3
4
  export type CallViewProps = {
4
5
  /** The socket of the app's function that admits the member, as `joinCall` takes it, such as `/api/functions/call?conversation=7`. */
@@ -7,14 +8,19 @@ export type CallViewProps = {
7
8
  leave: RouteTarget;
8
9
  /** A member's name, by the member the app's function admits them as, such as a user id; the member itself by default. */
9
10
  nameOf?: (member: string) => string;
11
+ /** For a guest: their wait in the lobby after Join, which resolves with their token; see `GuestCallPage`. */
12
+ admission?: CallSessionOptions['admission'];
13
+ /** The member's own name before joining, when no one is signed in, as a guest's. */
14
+ name?: string;
10
15
  className?: string;
11
16
  };
12
17
  /**
13
18
  * A call, ready-made, that fills its parent, such as a column beside a conversation's messages: a check of the
14
19
  * microphone and camera first, then, after Join, everyone in the call with the one speaking ringed, a screen share
15
20
  * large, microphone, camera, screen share and Leave, the quality of each connection, Reconnecting while the link is
16
- * restored, and plain words for a full call, a blocked device or someone the browser cannot connect to. Made for small
17
- * peer-to-peer calls, at a desktop's width or a phone's.
21
+ * restored, and plain words for a full call, a blocked device or someone the browser cannot connect to. A member the app
22
+ * lets invite also gets the call's guests: who waits in the lobby, and its guest links. Made for small peer-to-peer
23
+ * calls, at a desktop's width or a phone's.
18
24
  */
19
- export declare const CallView: ({ url, leave, nameOf, className }: CallViewProps) => import("react").JSX.Element;
25
+ export declare const CallView: ({ url, leave, nameOf, admission, name, className }: CallViewProps) => import("react").JSX.Element;
20
26
  //#endregion
@@ -1,9 +1,14 @@
1
1
  import { useOptionalAuth } from "../auth/auth-provider.js";
2
+ import { useRouter } from "../app/router.js";
2
3
  import { text } from "./messages.js";
3
4
  import { CallCheck } from "./call-check.js";
5
+ import { CallGuestsPanel } from "./call-guests-panel.js";
6
+ import { CallLobbyNotice } from "./call-lobby-notice.js";
4
7
  import { CallMessage } from "./call-message.js";
5
8
  import { callPeople } from "./call-people.js";
6
9
  import { CallStage } from "./call-stage.js";
10
+ import { CallWaiting } from "./call-waiting.js";
11
+ import { guestCallLink } from "./guest-call-link.js";
7
12
  import { useActiveSpeaker } from "./use-audio-levels.js";
8
13
  import { useCallSession } from "./use-call-session.js";
9
14
  import { useState } from "react";
@@ -19,11 +24,14 @@ const deviceNotices = (devices, refusedSending) => [
19
24
  * A call, ready-made, that fills its parent, such as a column beside a conversation's messages: a check of the
20
25
  * microphone and camera first, then, after Join, everyone in the call with the one speaking ringed, a screen share
21
26
  * large, microphone, camera, screen share and Leave, the quality of each connection, Reconnecting while the link is
22
- * restored, and plain words for a full call, a blocked device or someone the browser cannot connect to. Made for small
23
- * peer-to-peer calls, at a desktop's width or a phone's.
27
+ * restored, and plain words for a full call, a blocked device or someone the browser cannot connect to. A member the app
28
+ * lets invite also gets the call's guests: who waits in the lobby, and its guest links. Made for small peer-to-peer
29
+ * calls, at a desktop's width or a phone's.
24
30
  */
25
- const CallView = ({ url, leave, nameOf = (member) => member, className }) => {
26
- const session = useCallSession(url);
31
+ const CallView = ({ url, leave, nameOf = (member) => member, admission, name, className }) => {
32
+ const session = useCallSession(url, { ...admission && { admission } });
33
+ const [guestsOpen, setGuestsOpen] = useState(false);
34
+ const { basePath } = useRouter();
27
35
  const auth = useOptionalAuth();
28
36
  const user = auth?.state.kind === "signed-in" ? auth.state.user : void 0;
29
37
  const self = session.self ?? user?.id ?? "";
@@ -43,8 +51,10 @@ const CallView = ({ url, leave, nameOf = (member) => member, className }) => {
43
51
  reason: phase.reason,
44
52
  onJoinAgain: () => void session.join()
45
53
  });
54
+ if (phase.kind === "waiting") return /* @__PURE__ */ jsx(CallWaiting, { leave });
46
55
  if (phase.kind === "check") return /* @__PURE__ */ jsx(CallCheck, {
47
- name: user?.name ?? text("you"),
56
+ name: name ?? user?.name ?? text("you"),
57
+ ...admission && { joinLabel: text("askToJoin") },
48
58
  member: self,
49
59
  ...devices?.camera && { camera: devices.camera },
50
60
  microphoneOn: session.microphoneOn,
@@ -57,6 +67,11 @@ const CallView = ({ url, leave, nameOf = (member) => member, className }) => {
57
67
  onJoin: () => void session.join(),
58
68
  cancel: leave
59
69
  });
70
+ const { lobby, guests } = session;
71
+ const inCall = session.members.filter((member) => member.member.startsWith("guest:")).map((member) => ({
72
+ guest: member.member.slice(6),
73
+ name: member.name ?? ""
74
+ }));
60
75
  const people = callPeople({
61
76
  members: session.members,
62
77
  self,
@@ -73,6 +88,10 @@ const CallView = ({ url, leave, nameOf = (member) => member, className }) => {
73
88
  people,
74
89
  state: phase.kind === "joining" ? "joining" : phase.resuming ? "resuming" : "joined",
75
90
  notices,
91
+ ...lobby && guests && { aside: /* @__PURE__ */ jsx(CallLobbyNotice, {
92
+ waiting: lobby.guests,
93
+ guests
94
+ }) },
76
95
  controls: {
77
96
  microphoneOn: session.microphoneOn && Boolean(devices?.microphone),
78
97
  cameraOn: session.cameraOn && Boolean(devices?.camera),
@@ -80,8 +99,20 @@ const CallView = ({ url, leave, nameOf = (member) => member, className }) => {
80
99
  onMicrophone: session.toggleMicrophone,
81
100
  onCamera: session.toggleCamera,
82
101
  ...session.toggleScreen && { onShare: () => void session.toggleScreen() },
102
+ ...lobby && { guests: {
103
+ waiting: lobby.guests.length,
104
+ onOpen: () => setGuestsOpen(true)
105
+ } },
83
106
  leave
84
- }
107
+ },
108
+ children: lobby && guests && /* @__PURE__ */ jsx(CallGuestsPanel, {
109
+ lobby,
110
+ inCall,
111
+ guests,
112
+ linkOf: (token) => guestCallLink(`${window.location.origin}${basePath}`, token),
113
+ open: guestsOpen,
114
+ onClose: () => setGuestsOpen(false)
115
+ })
85
116
  });
86
117
  };
87
118
  //#endregion
@@ -0,0 +1,20 @@
1
+ import { Spinner } from "../primitives/spinner.js";
2
+ import { RouteLink } from "../primitives/route-link.js";
3
+ import { text } from "./messages.js";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ //#region src/calls/call-waiting.tsx
6
+ /** A guest's wait in the lobby, until someone in the call lets them in, with Leave. */
7
+ const CallWaiting = ({ leave }) => /* @__PURE__ */ jsxs("section", {
8
+ role: "status",
9
+ className: "flex size-full min-h-0 flex-col items-center justify-center gap-3 bg-background p-4 text-center",
10
+ children: [/* @__PURE__ */ jsxs("p", {
11
+ className: "flex items-center gap-2 text-sm text-foreground",
12
+ children: [/* @__PURE__ */ jsx(Spinner, { className: "size-4" }), text("waiting")]
13
+ }), /* @__PURE__ */ jsx(RouteLink, {
14
+ to: leave,
15
+ className: "px-2 text-sm text-muted-foreground hover:text-foreground",
16
+ children: text("leave")
17
+ })]
18
+ });
19
+ //#endregion
20
+ export { CallWaiting };
@@ -0,0 +1,40 @@
1
+ //#region src/calls/guest-admission.ts
2
+ /** Why a guest is not let into the call: turned away, the link or call is over, or the lobby cannot be reached now. */
3
+ var NotLetIn = class extends Error {
4
+ reason;
5
+ constructor(reason) {
6
+ super(`Not let into the call: ${reason}`);
7
+ this.reason = reason;
8
+ this.name = "NotLetIn";
9
+ }
10
+ };
11
+ /** How often a waiting guest asks after their wait. */
12
+ const waitEveryMs = 1e3;
13
+ const later = (ms, signal) => new Promise((resolve) => {
14
+ const timer = setTimeout(resolve, ms);
15
+ signal.addEventListener("abort", () => (clearTimeout(timer), resolve()), { once: true });
16
+ });
17
+ /**
18
+ * A guest's way into the call: they wait in the lobby of the link, by their name, asking once a second, until a member
19
+ * lets them in, with the guest's token for `joinCall`, or turns them away, or the link or call is over. Aborting leaves
20
+ * the lobby.
21
+ */
22
+ const guestAdmission = async (lobby, link, name, signal) => {
23
+ const { LobbyRefused } = await import("@protobase/client/calls");
24
+ const refusal = (error) => {
25
+ if (!(error instanceof LobbyRefused)) return error;
26
+ return new NotLetIn(error.status === 404 ? "refused" : error.status === 429 ? "unavailable" : "ended");
27
+ };
28
+ const { token } = await lobby.enter(link, name).catch((error) => Promise.reject(refusal(error)));
29
+ while (!signal.aborted) {
30
+ const wait = await lobby.wait(token).catch((error) => Promise.reject(refusal(error)));
31
+ if (wait.state === "admitted") return token;
32
+ if (wait.state === "denied") throw new NotLetIn("denied");
33
+ if (wait.state === "ended") throw new NotLetIn("ended");
34
+ await later(waitEveryMs, signal);
35
+ }
36
+ await lobby.leave(token);
37
+ throw new NotLetIn("ended");
38
+ };
39
+ //#endregion
40
+ export { NotLetIn, guestAdmission, waitEveryMs };
@@ -0,0 +1,19 @@
1
+ import { useRouter } from "../app/router.js";
2
+ import { useMemo } from "react";
3
+ //#region src/calls/guest-call-link.ts
4
+ /** The path a guest link opens, below the app's base path: its token follows in the fragment, which no server sees. */
5
+ const guestCallLinkPath = "/-/call-guest";
6
+ /** The guest link of a token, at the app's base, such as `https://chat.example.com`. */
7
+ const guestCallLink = (base, token) => `${base}${guestCallLinkPath}#${token}`;
8
+ /** The guest link of this path and fragment: its token, or `undefined` for any other page. */
9
+ const readGuestCallLink = (path, hash) => {
10
+ const token = hash.replace(/^#/, "");
11
+ return path === "/-/call-guest" && token ? { token } : void 0;
12
+ };
13
+ /** The guest link the address shows, `/-/call-guest#<token>`, as logical values; `undefined` on any other page. */
14
+ const useGuestCallAddress = () => {
15
+ const { path } = useRouter();
16
+ return useMemo(() => readGuestCallLink(path, globalThis.location?.hash ?? ""), [path]);
17
+ };
18
+ //#endregion
19
+ export { guestCallLink, guestCallLinkPath, readGuestCallLink, useGuestCallAddress };