@protobase/ui 0.62.1 → 0.62.2

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 (60) hide show
  1. package/dist/app/app.js +6 -1
  2. package/dist/app/current-address.d.ts +1 -0
  3. package/dist/app/current-address.js +2 -1
  4. package/dist/calls/call-check.js +78 -0
  5. package/dist/calls/call-controls.js +50 -0
  6. package/dist/calls/call-layout.js +31 -0
  7. package/dist/calls/call-mark.d.ts +13 -0
  8. package/dist/calls/call-mark.js +57 -0
  9. package/dist/calls/call-message.js +25 -0
  10. package/dist/calls/call-people.js +46 -0
  11. package/dist/calls/call-rings.js +78 -0
  12. package/dist/calls/call-stage.js +111 -0
  13. package/dist/calls/call-tile.js +60 -0
  14. package/dist/calls/call-view.d.ts +20 -0
  15. package/dist/calls/call-view.js +88 -0
  16. package/dist/calls/devices.js +55 -0
  17. package/dist/calls/messages.js +76 -0
  18. package/dist/calls/record-call.d.ts +12 -0
  19. package/dist/calls/record-call.js +18 -0
  20. package/dist/calls/ring-tone.js +35 -0
  21. package/dist/calls/ringing.js +16 -0
  22. package/dist/calls/track-media.js +36 -0
  23. package/dist/calls/use-audio-levels.js +43 -0
  24. package/dist/calls/use-call-session.js +157 -0
  25. package/dist/calls/use-record-call-address.d.ts +17 -0
  26. package/dist/calls/use-record-call-address.js +30 -0
  27. package/dist/calls/use-record-calls.d.ts +172 -0
  28. package/dist/calls/use-record-calls.js +43 -0
  29. package/dist/calls/use-rings.js +52 -0
  30. package/dist/calls/use-width.js +18 -0
  31. package/dist/data/api-provider.d.ts +8 -0
  32. package/dist/data/meta-data.d.ts +2 -0
  33. package/dist/data/query-keys.js +8 -0
  34. package/dist/data/use-meta.js +1 -0
  35. package/dist/index.d.ts +6 -1
  36. package/dist/index.js +6 -1
  37. package/dist/package.js +1 -1
  38. package/dist/primitives/route-link.d.ts +13 -1
  39. package/dist/shell/assets/app-i5V50MJx.js +9 -0
  40. package/dist/shell/assets/calls-DwVdIhNM.js +2 -0
  41. package/dist/shell/assets/index-BgcF_WZ0.css +2 -0
  42. package/dist/shell/assets/{index-CphPkbp2.js → index-jTcckwc-.js} +2 -2
  43. package/dist/shell/assets/{records-map-BVL8Bz3b.js → records-map-idUZ9NK2.js} +2 -2
  44. package/dist/shell/assets/shared-protobase-ui-55TVcKm3.js +2 -0
  45. package/dist/shell/index.html +4 -4
  46. package/dist/shell/protobase.shell.json +2 -2
  47. package/dist/shell/protobase.sw.js +2 -2
  48. package/dist/shell-source-maps/assets/app-i5V50MJx.js.map +1 -0
  49. package/dist/shell-source-maps/assets/calls-DwVdIhNM.js.map +1 -0
  50. package/dist/shell-source-maps/assets/{index-CphPkbp2.js.map → index-jTcckwc-.js.map} +1 -1
  51. package/dist/shell-source-maps/assets/{records-map-BVL8Bz3b.js.map → records-map-idUZ9NK2.js.map} +1 -1
  52. package/dist/shell-source-maps/assets/shared-protobase-ui-55TVcKm3.js.map +1 -0
  53. package/dist/toasts/toast-provider.js +3 -1
  54. package/dist/toasts/toast.js +6 -4
  55. package/package.json +4 -4
  56. package/dist/shell/assets/app-C-udyrvj.js +0 -9
  57. package/dist/shell/assets/index-Iii7afo2.css +0 -2
  58. package/dist/shell/assets/shared-protobase-ui-CmaYdEFn.js +0 -2
  59. package/dist/shell-source-maps/assets/app-C-udyrvj.js.map +0 -1
  60. package/dist/shell-source-maps/assets/shared-protobase-ui-CmaYdEFn.js.map +0 -1
package/dist/app/app.js CHANGED
@@ -52,6 +52,7 @@ import { actingUser } from "../roles/acting-user.js";
52
52
  import { useActiveRole } from "../roles/use-active-role.js";
53
53
  import { startAddress } from "./start-address.js";
54
54
  import { ProjectLogo } from "./project-logo.js";
55
+ import { CallRings } from "../calls/call-rings.js";
55
56
  import { ShellBell } from "./shell-bell.js";
56
57
  import { PluginGate, usePluginsOff } from "../plugins/plugin-gate.js";
57
58
  import { PluginNotice } from "../plugins/plugin-notice.js";
@@ -291,7 +292,11 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
291
292
  fallback: null,
292
293
  children: /* @__PURE__ */ jsx(RightPanel, {})
293
294
  }), assistantPanel] }),
294
- children: [content, status.dialog]
295
+ children: [
296
+ content,
297
+ status.dialog,
298
+ meta.calls && user && !staff && /* @__PURE__ */ jsx(CallRings, {})
299
+ ]
295
300
  });
296
301
  };
297
302
  const Authenticated = ({ baseUrl, client, sources, workspace, sidebarMode, ui, assistant, runtime }) => {
@@ -26,6 +26,7 @@ export type CurrentAddress = ListAddress | {
26
26
  open?: readonly string[];
27
27
  tab?: string;
28
28
  standard?: true;
29
+ call?: 'join';
29
30
  } | {
30
31
  kind: 'create';
31
32
  resource: string;
@@ -37,7 +37,8 @@ const currentAddress = (read, defaults) => {
37
37
  ...read.edit && { edit: true },
38
38
  ...read.open && { open: read.open },
39
39
  ...read.tab && { tab: read.tab },
40
- ...read.standard && { standard: true }
40
+ ...read.standard && { standard: true },
41
+ ...read.call && { call: read.call }
41
42
  };
42
43
  if (read?.kind !== "list") return read;
43
44
  return {
@@ -0,0 +1,78 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { Button } from "../primitives/button.js";
3
+ import { Avatar } from "../primitives/avatar.js";
4
+ import { RouteLink } from "../primitives/route-link.js";
5
+ import { hueOf, initialsOf } from "../auth/user-to-shell.js";
6
+ import { text } from "./messages.js";
7
+ import { TrackVideo } from "./track-media.js";
8
+ import { Mic, MicOff, Video, VideoOff } from "lucide-react";
9
+ import { jsx, jsxs } from "react/jsx-runtime";
10
+ //#region src/calls/call-check.tsx
11
+ const toggle = (on) => cn("size-10 rounded-full", !on && "bg-danger-soft text-danger-text");
12
+ /**
13
+ * The check of the microphone and camera before joining: the member's own camera as the others will see it, or their
14
+ * initials, microphone and camera on and off, plain messages for a blocked or missing device, and Join.
15
+ */
16
+ const CallCheck = ({ name, member, camera, microphoneOn, cameraOn, microphone, cameraAvailable, notices, onMicrophone, onCamera, onJoin, cancel }) => /* @__PURE__ */ jsxs("section", {
17
+ "aria-label": text("preview"),
18
+ className: "flex size-full min-h-0 flex-col items-center justify-center gap-3 bg-background p-4",
19
+ children: [
20
+ /* @__PURE__ */ jsx("figure", {
21
+ className: "relative flex aspect-video w-full max-w-md items-center justify-center overflow-hidden rounded-lg bg-neutral-900",
22
+ children: camera && cameraOn ? /* @__PURE__ */ jsx(TrackVideo, {
23
+ track: camera,
24
+ "aria-label": text("preview"),
25
+ className: "size-full -scale-x-100 object-cover"
26
+ }) : /* @__PURE__ */ jsx(Avatar, {
27
+ initials: initialsOf(name),
28
+ hue: hueOf(member),
29
+ size: "md",
30
+ className: "size-16 text-xl ring-0"
31
+ })
32
+ }),
33
+ notices.length > 0 && /* @__PURE__ */ jsx("div", {
34
+ role: "status",
35
+ className: "flex w-full max-w-md flex-col gap-1",
36
+ children: notices.map((notice) => /* @__PURE__ */ jsx("p", {
37
+ className: "rounded-md bg-warning-soft px-3 py-1.5 text-xs text-foreground",
38
+ children: notice
39
+ }, notice))
40
+ }),
41
+ /* @__PURE__ */ jsxs("div", {
42
+ className: "flex items-center gap-2",
43
+ children: [
44
+ /* @__PURE__ */ jsx(Button, {
45
+ size: "icon",
46
+ "aria-label": text("microphone"),
47
+ "aria-pressed": microphoneOn,
48
+ disabled: !microphone,
49
+ className: toggle(microphoneOn),
50
+ onClick: onMicrophone,
51
+ children: microphoneOn ? /* @__PURE__ */ jsx(Mic, { className: "size-4" }) : /* @__PURE__ */ jsx(MicOff, { className: "size-4" })
52
+ }),
53
+ /* @__PURE__ */ jsx(Button, {
54
+ size: "icon",
55
+ "aria-label": text("camera"),
56
+ "aria-pressed": cameraOn,
57
+ disabled: !cameraAvailable,
58
+ className: toggle(cameraOn),
59
+ onClick: onCamera,
60
+ children: cameraOn ? /* @__PURE__ */ jsx(Video, { className: "size-4" }) : /* @__PURE__ */ jsx(VideoOff, { className: "size-4" })
61
+ }),
62
+ /* @__PURE__ */ jsx(Button, {
63
+ variant: "primary",
64
+ className: "h-10 rounded-full px-5",
65
+ onClick: onJoin,
66
+ children: text("joinCall")
67
+ }),
68
+ /* @__PURE__ */ jsx(RouteLink, {
69
+ to: cancel,
70
+ className: "px-2 text-sm text-muted-foreground hover:text-foreground",
71
+ children: text("leave")
72
+ })
73
+ ]
74
+ })
75
+ ]
76
+ });
77
+ //#endregion
78
+ export { CallCheck };
@@ -0,0 +1,50 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { Button, buttonClass } from "../primitives/button.js";
3
+ import { RouteLink } from "../primitives/route-link.js";
4
+ import { text } from "./messages.js";
5
+ import { Mic, MicOff, MonitorUp, MonitorX, PhoneOff, Video, VideoOff } from "lucide-react";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ //#region src/calls/call-controls.tsx
8
+ 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", {
11
+ role: "toolbar",
12
+ "aria-label": text("joinCall"),
13
+ className: "flex shrink-0 items-center justify-center gap-2 py-2",
14
+ children: [
15
+ /* @__PURE__ */ jsx(Button, {
16
+ size: "icon",
17
+ "aria-label": text("microphone"),
18
+ "aria-pressed": microphoneOn,
19
+ title: text("microphone"),
20
+ className: toggle(microphoneOn),
21
+ onClick: onMicrophone,
22
+ children: microphoneOn ? /* @__PURE__ */ jsx(Mic, { className: "size-4" }) : /* @__PURE__ */ jsx(MicOff, { className: "size-4" })
23
+ }),
24
+ /* @__PURE__ */ jsx(Button, {
25
+ size: "icon",
26
+ "aria-label": text("camera"),
27
+ "aria-pressed": cameraOn,
28
+ title: text("camera"),
29
+ className: toggle(cameraOn),
30
+ onClick: onCamera,
31
+ children: cameraOn ? /* @__PURE__ */ jsx(Video, { className: "size-4" }) : /* @__PURE__ */ jsx(VideoOff, { className: "size-4" })
32
+ }),
33
+ onShare && /* @__PURE__ */ jsx(Button, {
34
+ size: "icon",
35
+ "aria-label": sharing ? text("stopSharing") : text("shareScreen"),
36
+ "aria-pressed": Boolean(sharing),
37
+ title: sharing ? text("stopSharing") : text("shareScreen"),
38
+ className: "size-10 rounded-full",
39
+ onClick: onShare,
40
+ children: sharing ? /* @__PURE__ */ jsx(MonitorX, { className: "size-4" }) : /* @__PURE__ */ jsx(MonitorUp, { className: "size-4" })
41
+ }),
42
+ /* @__PURE__ */ jsxs(RouteLink, {
43
+ to: leave,
44
+ className: cn(buttonClass({ variant: "danger" }), "h-10 rounded-full px-4"),
45
+ children: [/* @__PURE__ */ jsx(PhoneOff, { className: "size-4" }), text("leave")]
46
+ })
47
+ ]
48
+ });
49
+ //#endregion
50
+ export { CallControls };
@@ -0,0 +1,31 @@
1
+ /** The columns a grid of `count` tiles takes in a view `width` pixels wide: as square as fits, fewer on a phone. */
2
+ const gridColumns = (count, width) => {
3
+ if (width < 640) return count <= 2 ? 1 : 2;
4
+ return Math.min(4, Math.ceil(Math.sqrt(count)));
5
+ };
6
+ /** The layout for these people, the member included, and the member sharing their screen, the first of them if several do. */
7
+ const callLayout = ({ people, sharers, width }) => {
8
+ if (sharers[0] !== void 0) return {
9
+ kind: "share",
10
+ sharer: sharers[0]
11
+ };
12
+ if (people <= 1) return { kind: "alone" };
13
+ return {
14
+ kind: "grid",
15
+ columns: gridColumns(people, width)
16
+ };
17
+ };
18
+ /** How a connection fares, as its tile shows it: poor past a round trip of 400 ms or a loss of 5% of the media. */
19
+ const connectionQuality = ({ roundTripMs, lostRatio }) => (roundTripMs ?? 0) > 400 || (lostRatio ?? 0) > .05 ? "poor" : "good";
20
+ /** How long a connection may stay unconnected before its tile says the browser cannot connect to that person. */
21
+ const connectGiveUpMs = 15e3;
22
+ /** Whether the browser cannot connect to someone: their connection failed, or is not up `connectGiveUpMs` after they came. */
23
+ const cannotConnect = (state, since, now) => state === "failed" || state !== "connected" && now - since >= 15e3;
24
+ /** The loudest of these levels, from 0 to 1, when one is loud enough to be someone speaking. */
25
+ const activeSpeaker = (levels, threshold = .05) => {
26
+ let loudest;
27
+ for (const [member, level] of levels) if (level >= threshold && (!loudest || level > loudest[1])) loudest = [member, level];
28
+ return loudest?.[0];
29
+ };
30
+ //#endregion
31
+ export { activeSpeaker, callLayout, cannotConnect, connectGiveUpMs, connectionQuality, gridColumns };
@@ -0,0 +1,13 @@
1
+ //#region src/calls/call-mark.d.ts
2
+ export type CallMarkProps = {
3
+ resource: string;
4
+ recordKey: string;
5
+ className?: string;
6
+ };
7
+ /**
8
+ * A record's call, for the app to place where it wants, such as a conversation's header: Start call while there is
9
+ * none, and "Call in progress · 3 · Join" while one goes on, live. Start call and Join are links to the record's page
10
+ * that joins its call (`call=join`); on that page itself, Join is left out.
11
+ */
12
+ export declare const CallMark: ({ resource, recordKey, className }: CallMarkProps) => import("react").JSX.Element | null;
13
+ //#endregion
@@ -0,0 +1,57 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { buttonClass } from "../primitives/button.js";
3
+ import { RouteLink } from "../primitives/route-link.js";
4
+ import { text } from "./messages.js";
5
+ import { useRecordCallAddress } from "./use-record-call-address.js";
6
+ import { useRecordCalls } from "./use-record-calls.js";
7
+ import { Phone } from "lucide-react";
8
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
9
+ //#region src/calls/call-mark.tsx
10
+ /**
11
+ * A record's call, for the app to place where it wants, such as a conversation's header: Start call while there is
12
+ * none, and "Call in progress · 3 · Join" while one goes on, live. Start call and Join are links to the record's page
13
+ * that joins its call (`call=join`); on that page itself, Join is left out.
14
+ */
15
+ const CallMark = ({ resource, recordKey, className }) => {
16
+ const { ongoing, isPending } = useRecordCalls(resource, recordKey);
17
+ const { joining, join } = useRecordCallAddress(resource, recordKey);
18
+ if (isPending) return null;
19
+ if (!ongoing) {
20
+ if (joining) return null;
21
+ return /* @__PURE__ */ jsxs(RouteLink, {
22
+ to: join,
23
+ className: cn(buttonClass({ size: "sm" }), className),
24
+ children: [/* @__PURE__ */ jsx(Phone, { className: "size-3.5" }), text("startCall")]
25
+ });
26
+ }
27
+ const count = ongoing.members.length;
28
+ return /* @__PURE__ */ jsxs("span", {
29
+ role: "status",
30
+ className: cn("inline-flex items-center gap-1.5 rounded-md bg-success-soft px-2 py-0.5 text-body text-foreground", className),
31
+ children: [
32
+ /* @__PURE__ */ jsx(Phone, { className: "size-3.5 text-success" }),
33
+ text("inProgress"),
34
+ /* @__PURE__ */ jsx("span", {
35
+ "aria-hidden": true,
36
+ children: "·"
37
+ }),
38
+ /* @__PURE__ */ jsx("span", {
39
+ "aria-label": text("people", count),
40
+ children: count
41
+ }),
42
+ !joining && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
43
+ "aria-hidden": true,
44
+ children: "·"
45
+ }), /* @__PURE__ */ jsx(RouteLink, {
46
+ to: join,
47
+ className: buttonClass({
48
+ variant: "primary",
49
+ size: "sm"
50
+ }),
51
+ children: text("join")
52
+ })] })
53
+ ]
54
+ });
55
+ };
56
+ //#endregion
57
+ export { CallMark };
@@ -0,0 +1,25 @@
1
+ import { Button } from "../primitives/button.js";
2
+ import { text } from "./messages.js";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ //#region src/calls/call-message.tsx
5
+ const said = {
6
+ ended: "ended",
7
+ removed: "removed",
8
+ full: "full",
9
+ refused: "refused",
10
+ unavailable: "unavailable"
11
+ };
12
+ /** Why the member is not in the call, in plain words, and, unless they were removed, a way to join again. */
13
+ const CallMessage = ({ reason, onJoinAgain }) => /* @__PURE__ */ jsxs("section", {
14
+ role: "status",
15
+ className: "flex size-full min-h-0 flex-col items-center justify-center gap-3 bg-background p-4 text-center",
16
+ children: [/* @__PURE__ */ jsx("p", {
17
+ className: "text-sm text-foreground",
18
+ children: text(reason === "rejoin" ? "ended" : said[reason])
19
+ }), onJoinAgain && reason !== "removed" && reason !== "refused" && /* @__PURE__ */ jsx(Button, {
20
+ onClick: onJoinAgain,
21
+ children: text("callAgain")
22
+ })]
23
+ });
24
+ //#endregion
25
+ export { CallMessage };
@@ -0,0 +1,46 @@
1
+ import { cannotConnect, connectionQuality } from "./call-layout.js";
2
+ //#region src/calls/call-people.ts
3
+ const received = (tracks, source) => tracks.find((track) => track.source === source && track.track);
4
+ /** Everyone in the call as the view draws them, in the order they came, the member among them as `self`. */
5
+ const callPeople = ({ members, self, tracks, own, nameOf, speaker, connections, seen, now }) => {
6
+ return (members.some((member) => member.member === self) ? members : [{
7
+ member: self,
8
+ joined_at: ""
9
+ }, ...members]).map(({ member }) => {
10
+ const shared = {
11
+ member,
12
+ name: nameOf(member),
13
+ ...speaker === member && { speaking: true }
14
+ };
15
+ if (member === self) return {
16
+ ...shared,
17
+ self: true,
18
+ ...own.camera && { camera: own.camera },
19
+ ...own.microphone && { microphone: own.microphone },
20
+ ...own.screen && { screen: own.screen },
21
+ microphoneOff: !own.microphone || !own.microphoneOn,
22
+ cameraOff: !own.camera || !own.cameraOn
23
+ };
24
+ const theirs = tracks.filter((track) => track.member === member);
25
+ const [camera, microphone, screen, screenAudio] = [
26
+ "camera",
27
+ "microphone",
28
+ "screen",
29
+ "screen-audio"
30
+ ].map((source) => received(theirs, source));
31
+ const link = connections.find((connection) => connection.member === member);
32
+ const connection = cannotConnect(link?.state, seen.get(member) ?? now, now) ? "failed" : link?.state === "connected" ? connectionQuality(link) : void 0;
33
+ return {
34
+ ...shared,
35
+ ...camera && { camera: camera.track },
36
+ ...microphone && { microphone: microphone.track },
37
+ ...screen && { screen: screen.track },
38
+ ...screenAudio && { screenAudio: screenAudio.track },
39
+ microphoneOff: !microphone || microphone.muted,
40
+ cameraOff: !camera || camera.muted,
41
+ ...connection && { connection }
42
+ };
43
+ });
44
+ };
45
+ //#endregion
46
+ export { callPeople };
@@ -0,0 +1,78 @@
1
+ import { useToast } from "../toasts/toast-provider.js";
2
+ import { useAppAddress } from "../app/use-app-address.js";
3
+ import { text } from "./messages.js";
4
+ import { useRingTone } from "./ring-tone.js";
5
+ import { useRings } from "./use-rings.js";
6
+ import { useEffect, useRef } from "react";
7
+ //#region src/calls/call-rings.tsx
8
+ /** A ring as a sticky toast: Decline, and Accept, a link to the record's page that joins its call on this device. */
9
+ const ringToast = (ring, accept, decline) => ({
10
+ state: "call",
11
+ sticky: true,
12
+ title: ring.title,
13
+ ...ring.body !== null && { description: ring.body },
14
+ actions: [{
15
+ label: text("decline"),
16
+ onClick: () => decline(ring.call)
17
+ }, {
18
+ label: text("accept"),
19
+ ...accept
20
+ }]
21
+ });
22
+ /**
23
+ * Rings the signed-in user on whatever page of the app they have open, over the live stream the page already holds:
24
+ * each call ringing them is a toast with Decline and Accept, with a ring tone, until they answer, here or on another
25
+ * device, decline, or the call ends or stops ringing. Accept opens the record's page, which joins the call after the
26
+ * click on this device only. Someone on do not disturb is not rung; the call is in their bell.
27
+ */
28
+ const CallRings = () => {
29
+ const { ringing, decline, hide } = useRings(true);
30
+ const toast = useToast();
31
+ const { href, open } = useAppAddress();
32
+ const shown = useRef(/* @__PURE__ */ new Map());
33
+ useRingTone(ringing.length > 0);
34
+ useEffect(() => {
35
+ const now = new Set(ringing.map((ring) => ring.call));
36
+ for (const [call, id] of shown.current) {
37
+ if (now.has(call)) continue;
38
+ toast.dismiss(id);
39
+ shown.current.delete(call);
40
+ }
41
+ for (const ring of ringing) {
42
+ if (shown.current.has(ring.call)) continue;
43
+ const accept = {
44
+ href: href({
45
+ kind: "record",
46
+ resource: ring.resource,
47
+ key: ring.key,
48
+ call: "join"
49
+ }),
50
+ onNavigate: (target) => {
51
+ hide(ring.call);
52
+ open(target);
53
+ }
54
+ };
55
+ shown.current.set(ring.call, toast.show({
56
+ ...ringToast(ring, accept, decline),
57
+ onClose: () => decline(ring.call)
58
+ }));
59
+ }
60
+ }, [
61
+ ringing,
62
+ toast,
63
+ href,
64
+ open,
65
+ decline,
66
+ hide
67
+ ]);
68
+ useEffect(() => {
69
+ const own = shown.current;
70
+ return () => {
71
+ for (const id of own.values()) toast.dismiss(id);
72
+ own.clear();
73
+ };
74
+ }, [toast]);
75
+ return null;
76
+ };
77
+ //#endregion
78
+ export { CallRings };
@@ -0,0 +1,111 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { Spinner } from "../primitives/spinner.js";
3
+ import { text } from "./messages.js";
4
+ import { TrackAudio } from "./track-media.js";
5
+ import { callLayout } from "./call-layout.js";
6
+ import { CallControls } from "./call-controls.js";
7
+ import { CallTile } from "./call-tile.js";
8
+ import { useWidth } from "./use-width.js";
9
+ import { jsx, jsxs } from "react/jsx-runtime";
10
+ //#region src/calls/call-stage.tsx
11
+ const stripMax = 4;
12
+ const Grid = ({ people, columns }) => /* @__PURE__ */ jsx("div", {
13
+ className: "grid size-full min-h-0 gap-2",
14
+ style: {
15
+ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
16
+ gridTemplateRows: `repeat(${Math.ceil(people.length / columns)}, minmax(0, 1fr))`
17
+ },
18
+ children: people.map((person) => /* @__PURE__ */ jsx(CallTile, { person }, person.member))
19
+ });
20
+ const Share = ({ people, sharer, narrow }) => {
21
+ const shown = people.slice(0, stripMax);
22
+ const more = people.length - shown.length;
23
+ return /* @__PURE__ */ jsxs("div", {
24
+ className: cn("flex size-full min-h-0 gap-2", narrow ? "flex-col" : "flex-row"),
25
+ children: [/* @__PURE__ */ jsx(CallTile, {
26
+ person: sharer,
27
+ screen: true,
28
+ className: "min-h-0 flex-1"
29
+ }), /* @__PURE__ */ jsxs("div", {
30
+ className: cn("flex shrink-0 gap-2", narrow ? "h-20 flex-row" : "w-40 flex-col"),
31
+ children: [shown.map((person) => /* @__PURE__ */ jsx(CallTile, {
32
+ person,
33
+ className: narrow ? "aspect-video h-full" : "aspect-video w-full"
34
+ }, person.member)), more > 0 && /* @__PURE__ */ jsxs("p", {
35
+ className: "flex items-center justify-center rounded-lg bg-muted px-2 text-xs text-muted-foreground",
36
+ children: ["+", more]
37
+ })]
38
+ })]
39
+ });
40
+ };
41
+ const Tiles = ({ layout, people, narrow }) => {
42
+ if (layout.kind === "share") return /* @__PURE__ */ jsx(Share, {
43
+ people,
44
+ sharer: people.find((person) => person.member === layout.sharer),
45
+ narrow
46
+ });
47
+ if (layout.kind === "grid") return /* @__PURE__ */ jsx(Grid, {
48
+ people,
49
+ columns: layout.columns
50
+ });
51
+ return /* @__PURE__ */ jsxs("div", {
52
+ className: "relative size-full min-h-0",
53
+ children: [/* @__PURE__ */ jsx(CallTile, {
54
+ person: people[0],
55
+ className: "size-full"
56
+ }), /* @__PURE__ */ jsx("p", {
57
+ className: "absolute inset-x-0 top-3 text-center text-sm text-white drop-shadow",
58
+ children: text("alone")
59
+ })]
60
+ });
61
+ };
62
+ /**
63
+ * A call as the member sees it, filling its parent: everyone at the same size, with the one speaking ringed; a screen
64
+ * share large with the people beside it, or under it on a phone; the member alone; then the controls. The others'
65
+ * sound plays unseen.
66
+ */
67
+ const CallStage = ({ people, state, notices = [], controls, className }) => {
68
+ const { ref, width } = useWidth();
69
+ const narrow = width > 0 && width < 640;
70
+ const layout = callLayout({
71
+ people: people.length,
72
+ sharers: people.filter((person) => person.screen).map((person) => person.member),
73
+ width
74
+ });
75
+ const messages = [...state === "resuming" ? [text("reconnecting")] : [], ...notices];
76
+ return /* @__PURE__ */ jsxs("section", {
77
+ ref,
78
+ "aria-label": text("joinCall"),
79
+ "data-layout": layout.kind,
80
+ className: cn("flex size-full min-h-0 flex-col gap-2 bg-background p-2", className),
81
+ children: [
82
+ messages.length > 0 && /* @__PURE__ */ jsx("div", {
83
+ role: "status",
84
+ className: "flex shrink-0 flex-col gap-1",
85
+ children: messages.map((message) => /* @__PURE__ */ jsxs("p", {
86
+ className: "flex items-center gap-2 rounded-md bg-warning-soft px-3 py-1.5 text-xs text-foreground",
87
+ children: [message === text("reconnecting") && /* @__PURE__ */ jsx(Spinner, { className: "size-3" }), message]
88
+ }, message))
89
+ }),
90
+ /* @__PURE__ */ jsx("div", {
91
+ className: "min-h-0 flex-1",
92
+ children: state === "joining" ? /* @__PURE__ */ jsxs("p", {
93
+ role: "status",
94
+ className: "flex size-full items-center justify-center gap-2 text-muted-foreground",
95
+ children: [/* @__PURE__ */ jsx(Spinner, { className: "size-4" }), text("connecting")]
96
+ }) : /* @__PURE__ */ jsx(Tiles, {
97
+ layout,
98
+ people,
99
+ narrow
100
+ })
101
+ }),
102
+ /* @__PURE__ */ jsx(CallControls, { ...controls }),
103
+ people.filter((person) => !person.self).flatMap((person) => [["microphone", person.microphone], ["screen-audio", person.screenAudio]].map(([source, track]) => track && {
104
+ key: `${person.member} ${source}`,
105
+ track
106
+ })).filter((audio) => audio !== void 0).map(({ key, track }) => /* @__PURE__ */ jsx(TrackAudio, { track }, key))
107
+ ]
108
+ });
109
+ };
110
+ //#endregion
111
+ export { CallStage };
@@ -0,0 +1,60 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { Avatar } from "../primitives/avatar.js";
3
+ import { hueOf, initialsOf } from "../auth/user-to-shell.js";
4
+ import { text } from "./messages.js";
5
+ import { TrackVideo } from "./track-media.js";
6
+ import { MicOff, WifiOff } from "lucide-react";
7
+ import { jsx, jsxs } from "react/jsx-runtime";
8
+ //#region src/calls/call-tile.tsx
9
+ /**
10
+ * One person in a call: their camera, or their initials while it sends nothing; their name, a mark while their
11
+ * microphone is off or their connection is poor, a ring while they speak, and a plain message when the member's
12
+ * browser cannot connect to them. `screen` draws their screen share instead, whole.
13
+ */
14
+ const CallTile = ({ person, screen = false, className }) => {
15
+ const track = screen ? person.screen : person.cameraOff ? void 0 : person.camera;
16
+ const label = screen ? person.self ? text("youAreSharing") : text("sharing", person.name) : person.self ? `${person.name} (${text("you")})` : person.name;
17
+ return /* @__PURE__ */ jsxs("figure", {
18
+ "data-member": person.member,
19
+ "data-speaking": person.speaking ? "true" : void 0,
20
+ "aria-label": label,
21
+ className: cn("relative flex min-h-0 min-w-0 items-center justify-center overflow-hidden rounded-lg bg-neutral-900 ring-offset-2 ring-offset-background", person.speaking && !screen && "ring-2 ring-success", className),
22
+ children: [
23
+ track ? /* @__PURE__ */ jsx(TrackVideo, {
24
+ track,
25
+ className: cn("size-full", screen ? "object-contain" : "object-cover", person.self && !screen && "-scale-x-100")
26
+ }) : /* @__PURE__ */ jsx(Avatar, {
27
+ initials: initialsOf(person.name),
28
+ hue: hueOf(person.member),
29
+ size: "md",
30
+ className: "size-16 text-xl ring-0"
31
+ }),
32
+ person.connection === "failed" && !person.self && /* @__PURE__ */ jsx("p", {
33
+ role: "status",
34
+ className: "absolute inset-x-1 top-1 truncate rounded bg-black/70 px-2 py-1 text-center text-xs text-white",
35
+ children: text("cannotConnect", person.name)
36
+ }),
37
+ /* @__PURE__ */ jsxs("figcaption", {
38
+ className: "absolute bottom-1.5 left-1.5 flex max-w-[calc(100%-0.75rem)] items-center gap-1 rounded bg-black/60 px-1.5 py-0.5 text-xs text-white",
39
+ children: [
40
+ !screen && person.microphoneOff && /* @__PURE__ */ jsx(MicOff, {
41
+ role: "img",
42
+ "aria-label": text("muted"),
43
+ className: "size-3 shrink-0"
44
+ }),
45
+ !screen && person.connection === "poor" && /* @__PURE__ */ jsx(WifiOff, {
46
+ role: "img",
47
+ "aria-label": text("poorConnection"),
48
+ className: "size-3 shrink-0 text-warning"
49
+ }),
50
+ /* @__PURE__ */ jsx("span", {
51
+ className: "truncate",
52
+ children: label
53
+ })
54
+ ]
55
+ })
56
+ ]
57
+ });
58
+ };
59
+ //#endregion
60
+ export { CallTile };
@@ -0,0 +1,20 @@
1
+ import { RouteTarget } from "../primitives/route-link.js";
2
+ //#region src/calls/call-view.d.ts
3
+ export type CallViewProps = {
4
+ /** The socket of the app's function that admits the member, as `joinCall` takes it, such as `/api/functions/call?conversation=7`. */
5
+ url: string;
6
+ /** Where Leave goes; leaving the page leaves the call. */
7
+ leave: RouteTarget;
8
+ /** 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
+ nameOf?: (member: string) => string;
10
+ className?: string;
11
+ };
12
+ /**
13
+ * A call, ready-made, that fills its parent, such as a column beside a conversation's messages: a check of the
14
+ * microphone and camera first, then, after Join, everyone in the call with the one speaking ringed, a screen share
15
+ * 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.
18
+ */
19
+ export declare const CallView: ({ url, leave, nameOf, className }: CallViewProps) => import("react").JSX.Element;
20
+ //#endregion