@protobase/ui 0.62.1 → 0.62.3
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.
- package/dist/app/app.js +6 -1
- package/dist/app/current-address.d.ts +1 -0
- package/dist/app/current-address.js +2 -1
- package/dist/calls/call-check.js +78 -0
- package/dist/calls/call-controls.js +50 -0
- package/dist/calls/call-layout.js +31 -0
- package/dist/calls/call-mark.d.ts +13 -0
- package/dist/calls/call-mark.js +57 -0
- package/dist/calls/call-message.js +25 -0
- package/dist/calls/call-people.js +46 -0
- package/dist/calls/call-rings.js +78 -0
- package/dist/calls/call-stage.js +111 -0
- package/dist/calls/call-tile.js +60 -0
- package/dist/calls/call-view.d.ts +20 -0
- package/dist/calls/call-view.js +88 -0
- package/dist/calls/devices.js +55 -0
- package/dist/calls/messages.js +76 -0
- package/dist/calls/record-call.d.ts +12 -0
- package/dist/calls/record-call.js +18 -0
- package/dist/calls/ring-tone.js +35 -0
- package/dist/calls/ringing.js +16 -0
- package/dist/calls/track-media.js +36 -0
- package/dist/calls/use-audio-levels.js +43 -0
- package/dist/calls/use-call-session.js +157 -0
- package/dist/calls/use-record-call-address.d.ts +17 -0
- package/dist/calls/use-record-call-address.js +30 -0
- package/dist/calls/use-record-calls.d.ts +172 -0
- package/dist/calls/use-record-calls.js +43 -0
- package/dist/calls/use-rings.js +52 -0
- package/dist/calls/use-width.js +18 -0
- package/dist/data/api-provider.d.ts +8 -0
- package/dist/data/meta-data.d.ts +2 -0
- package/dist/data/query-keys.js +8 -0
- package/dist/data/use-meta.js +1 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +6 -1
- package/dist/package.js +1 -1
- package/dist/primitives/route-link.d.ts +13 -1
- package/dist/shell/assets/app-C5MP7OgJ.js +9 -0
- package/dist/shell/assets/calls-DwVdIhNM.js +2 -0
- package/dist/shell/assets/index-BgcF_WZ0.css +2 -0
- package/dist/shell/assets/{index-CphPkbp2.js → index-C-RfkRTE.js} +2 -2
- package/dist/shell/assets/{records-map-BVL8Bz3b.js → records-map-C5LoYF8K.js} +2 -2
- package/dist/shell/assets/shared-protobase-ui-DC02tB2e.js +2 -0
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +2 -2
- package/dist/shell/protobase.sw.js +2 -2
- package/dist/shell-source-maps/assets/app-C5MP7OgJ.js.map +1 -0
- package/dist/shell-source-maps/assets/calls-DwVdIhNM.js.map +1 -0
- package/dist/shell-source-maps/assets/{index-CphPkbp2.js.map → index-C-RfkRTE.js.map} +1 -1
- package/dist/shell-source-maps/assets/{records-map-BVL8Bz3b.js.map → records-map-C5LoYF8K.js.map} +1 -1
- package/dist/shell-source-maps/assets/shared-protobase-ui-DC02tB2e.js.map +1 -0
- package/dist/toasts/toast-provider.js +3 -1
- package/dist/toasts/toast.js +6 -4
- package/package.json +4 -4
- package/dist/shell/assets/app-C-udyrvj.js +0 -9
- package/dist/shell/assets/index-Iii7afo2.css +0 -2
- package/dist/shell/assets/shared-protobase-ui-CmaYdEFn.js +0 -2
- package/dist/shell-source-maps/assets/app-C-udyrvj.js.map +0 -1
- 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: [
|
|
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 }) => {
|
|
@@ -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
|