@rapidmx/meet-plugin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/LICENSE +382 -0
  2. package/README.md +38 -0
  3. package/apps/meet/[token].tsx +144 -0
  4. package/apps/meet/_CallControls.tsx +69 -0
  5. package/apps/meet/_CallView.tsx +338 -0
  6. package/apps/meet/_MeetChrome.tsx +30 -0
  7. package/apps/meet/_MeetLobby.tsx +223 -0
  8. package/apps/meet/_ParticipantTile.tsx +66 -0
  9. package/apps/meet/_layout.tsx +34 -0
  10. package/apps/meet/_meetApi.ts +66 -0
  11. package/apps/meet/index.tsx +22 -0
  12. package/apps/settings-video-conferencing/_PersonalRoomCard.tsx +140 -0
  13. package/apps/settings-video-conferencing/_layout.tsx +47 -0
  14. package/apps/settings-video-conferencing/index.tsx +192 -0
  15. package/apps/shared/media/activeSpeaker.ts +45 -0
  16. package/apps/shared/media/deviceMedia.ts +141 -0
  17. package/apps/shared/media/levelMeter.ts +91 -0
  18. package/apps/shared/push/GuestSignalingClient.ts +338 -0
  19. package/apps/shared/webrtc/MeshConnectionManager.ts +350 -0
  20. package/apps/shared/webrtc/realPeerConnection.ts +37 -0
  21. package/apps/shared/webrtc/types.ts +90 -0
  22. package/dist/apps/meet/[token].d.ts +33 -0
  23. package/dist/apps/meet/[token].js +101 -0
  24. package/dist/apps/meet/_CallControls.d.ts +18 -0
  25. package/dist/apps/meet/_CallControls.js +6 -0
  26. package/dist/apps/meet/_CallView.d.ts +41 -0
  27. package/dist/apps/meet/_CallView.js +225 -0
  28. package/dist/apps/meet/_MeetChrome.d.ts +13 -0
  29. package/dist/apps/meet/_MeetChrome.js +10 -0
  30. package/dist/apps/meet/_MeetLobby.d.ts +29 -0
  31. package/dist/apps/meet/_MeetLobby.js +120 -0
  32. package/dist/apps/meet/_ParticipantTile.d.ts +18 -0
  33. package/dist/apps/meet/_ParticipantTile.js +25 -0
  34. package/dist/apps/meet/_layout.d.ts +9 -0
  35. package/dist/apps/meet/_layout.js +8 -0
  36. package/dist/apps/meet/_meetApi.d.ts +47 -0
  37. package/dist/apps/meet/_meetApi.js +23 -0
  38. package/dist/apps/meet/index.d.ts +5 -0
  39. package/dist/apps/meet/index.js +8 -0
  40. package/dist/apps/settings-video-conferencing/_PersonalRoomCard.d.ts +24 -0
  41. package/dist/apps/settings-video-conferencing/_PersonalRoomCard.js +77 -0
  42. package/dist/apps/settings-video-conferencing/_layout.d.ts +16 -0
  43. package/dist/apps/settings-video-conferencing/_layout.js +8 -0
  44. package/dist/apps/settings-video-conferencing/index.d.ts +16 -0
  45. package/dist/apps/settings-video-conferencing/index.js +92 -0
  46. package/dist/apps/shared/media/activeSpeaker.d.ts +20 -0
  47. package/dist/apps/shared/media/activeSpeaker.js +39 -0
  48. package/dist/apps/shared/media/deviceMedia.d.ts +65 -0
  49. package/dist/apps/shared/media/deviceMedia.js +110 -0
  50. package/dist/apps/shared/media/levelMeter.d.ts +40 -0
  51. package/dist/apps/shared/media/levelMeter.js +61 -0
  52. package/dist/apps/shared/push/GuestSignalingClient.d.ts +89 -0
  53. package/dist/apps/shared/push/GuestSignalingClient.js +291 -0
  54. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +97 -0
  55. package/dist/apps/shared/webrtc/MeshConnectionManager.js +262 -0
  56. package/dist/apps/shared/webrtc/realPeerConnection.d.ts +7 -0
  57. package/dist/apps/shared/webrtc/realPeerConnection.js +26 -0
  58. package/dist/apps/shared/webrtc/types.d.ts +94 -0
  59. package/dist/apps/shared/webrtc/types.js +9 -0
  60. package/dist/lib/index.js +25 -0
  61. package/dist/lib/index.js.map +1 -0
  62. package/dist/lib/models/mongo/VideoMeetingInviteeMongo.js +87 -0
  63. package/dist/lib/models/mongo/VideoMeetingInviteeMongo.js.map +1 -0
  64. package/dist/lib/models/mongo/VideoMeetingMongo.js +126 -0
  65. package/dist/lib/models/mongo/VideoMeetingMongo.js.map +1 -0
  66. package/dist/lib/models/sql/VideoMeetingInviteeSQL.js +87 -0
  67. package/dist/lib/models/sql/VideoMeetingInviteeSQL.js.map +1 -0
  68. package/dist/lib/models/sql/VideoMeetingSQL.js +127 -0
  69. package/dist/lib/models/sql/VideoMeetingSQL.js.map +1 -0
  70. package/dist/lib/models/types.js +19 -0
  71. package/dist/lib/models/types.js.map +1 -0
  72. package/dist/lib/mongo.js +13 -0
  73. package/dist/lib/mongo.js.map +1 -0
  74. package/dist/lib/routes/BaseVideoMeetingRoute.js +743 -0
  75. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -0
  76. package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js +31 -0
  77. package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js.map +1 -0
  78. package/dist/lib/routes/sql/VideoMeetingRouteSQL.js +31 -0
  79. package/dist/lib/routes/sql/VideoMeetingRouteSQL.js.map +1 -0
  80. package/dist/lib/sql.js +13 -0
  81. package/dist/lib/sql.js.map +1 -0
  82. package/dist/lib/util/BookingIntegrationUtils.js +115 -0
  83. package/dist/lib/util/BookingIntegrationUtils.js.map +1 -0
  84. package/dist/lib/util/IceServerUtils.js +68 -0
  85. package/dist/lib/util/IceServerUtils.js.map +1 -0
  86. package/dist/lib/util/PublicUrlUtils.js +37 -0
  87. package/dist/lib/util/PublicUrlUtils.js.map +1 -0
  88. package/dist/lib/util/RouteAccessUtils.js +32 -0
  89. package/dist/lib/util/RouteAccessUtils.js.map +1 -0
  90. package/dist/lib/util/TokenUtils.js +33 -0
  91. package/dist/lib/util/TokenUtils.js.map +1 -0
  92. package/dist/types/index.d.ts +20 -0
  93. package/dist/types/models/mongo/VideoMeetingInviteeMongo.d.ts +16 -0
  94. package/dist/types/models/mongo/VideoMeetingMongo.d.ts +23 -0
  95. package/dist/types/models/sql/VideoMeetingInviteeSQL.d.ts +16 -0
  96. package/dist/types/models/sql/VideoMeetingSQL.d.ts +23 -0
  97. package/dist/types/models/types.d.ts +131 -0
  98. package/dist/types/mongo.d.ts +8 -0
  99. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +368 -0
  100. package/dist/types/routes/mongo/VideoMeetingRouteMongo.d.ts +10 -0
  101. package/dist/types/routes/sql/VideoMeetingRouteSQL.d.ts +10 -0
  102. package/dist/types/sql.d.ts +8 -0
  103. package/dist/types/util/BookingIntegrationUtils.d.ts +51 -0
  104. package/dist/types/util/IceServerUtils.d.ts +57 -0
  105. package/dist/types/util/PublicUrlUtils.d.ts +11 -0
  106. package/dist/types/util/RouteAccessUtils.d.ts +8 -0
  107. package/dist/types/util/TokenUtils.d.ts +23 -0
  108. package/package.json +181 -0
@@ -0,0 +1,34 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { PropsWithChildren } from "react";
6
+ import { Branding } from "@rapidmx/react-shared/branding/brandingApi.js";
7
+ import { CUSTOM_STYLESHEET_LINK_ID } from "@rapidmx/react-shared/branding/useBranding.js";
8
+
9
+ export interface LayoutProps {
10
+ /** Supplied by the server's public page route (`PublicPageRoute`), which every `public` plugin app's route
11
+ * extends - see `booking-plugin`'s `apps/book/_layout.tsx`'s identical `LayoutProps` doc comment for the
12
+ * mechanism this mirrors. */
13
+ branding?: Branding;
14
+ }
15
+
16
+ export default function Layout({ children, branding }: PropsWithChildren<LayoutProps>) {
17
+ const title = branding?.title || branding?.companyName ? `${branding?.title || branding?.companyName}: Meet` : "RapidMX: Meet";
18
+ const iconHref = branding?.iconUrl || branding?.logoUrl || "/images/logo.svg";
19
+ const stylesheetHref = branding?.stylesheetUrl;
20
+
21
+ return (
22
+ <html lang="en">
23
+ <head>
24
+ <meta charSet="utf-8" />
25
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
26
+ <title>{title}</title>
27
+ <link rel="icon" type="image/svg+xml" href={iconHref} />
28
+ <link rel="alternate icon" href="/favicon.ico" />
29
+ {stylesheetHref && <link rel="stylesheet" href={stylesheetHref} id={CUSTOM_STYLESHEET_LINK_ID} />}
30
+ </head>
31
+ <body>{children}</body>
32
+ </html>
33
+ );
34
+ }
@@ -0,0 +1,66 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Typed client for this plugin's one public, unauthenticated endpoint - `GET /api/mail/video-meetings/join/:token`
7
+ * (`BaseVideoMeetingRoute.join()`) - mirroring `booking-plugin`'s own `apps/shared/bookingApi.ts` convention of
8
+ * redefining the response shape locally rather than importing it from `../../src/...`: `apps/` compiles under its
9
+ * own `tsconfig.apps.json` (bundler resolution, DOM-facing), independent of the backend's `tsconfig.json` (NodeNext),
10
+ * and every other plugin app in this codebase keeps that boundary the same way.
11
+ */
12
+ import { apiFetch } from "@rapidmx/react-shared/util/api.js";
13
+
14
+ export type VideoMeetingVisibility = "private" | "public";
15
+ export type VideoMeetingStatus = "scheduled" | "active" | "ended" | "cancelled";
16
+
17
+ /** Mirrors `BaseVideoMeetingRoute.ts`'s `IceServerConfig` - already the exact shape `RTCPeerConnection`'s
18
+ * `iceServers` constructor option expects, passed straight through with no reshaping. */
19
+ export interface IceServerConfig {
20
+ urls: string;
21
+ username?: string;
22
+ credential?: string;
23
+ }
24
+
25
+ /** Mirrors `BaseVideoMeetingRoute.ts`'s `PublicVideoMeeting`. */
26
+ export interface PublicVideoMeeting {
27
+ uid: string;
28
+ title: string;
29
+ visibility: VideoMeetingVisibility;
30
+ status: VideoMeetingStatus;
31
+ hostDisplayName?: string;
32
+ }
33
+
34
+ /**
35
+ * Mirrors `BaseVideoMeetingRoute.ts`'s `VideoMeetingJoinResult`. `authenticated: true` means the visiting browser
36
+ * already held a valid session for a real RapidMX identity when it called `join()` - `token`/`expiresAt` are then
37
+ * omitted entirely (there is no guest token to hand over), and the caller (`[token].tsx`) must not write any
38
+ * cookie of its own before connecting to `/push`: the browser's own already-existing `jwt` session cookie already
39
+ * authenticates it there, with zero new client-side auth handling. `authenticated: false` (the common anonymous
40
+ * case) is unchanged from Phase 1: `token`/`expiresAt` are present, and `GuestSignalingClient` applies `token` as a
41
+ * cookie before connecting.
42
+ */
43
+ export interface VideoMeetingJoinResult {
44
+ meeting: PublicVideoMeeting;
45
+ iceServers: IceServerConfig[];
46
+ authenticated: boolean;
47
+ /** The identity to identify as on the signaling channel/mesh - the caller's own real uid when `authenticated`
48
+ * is `true`, otherwise a freshly minted synthetic `guest:<random>` uid. Always present. */
49
+ selfUid: string;
50
+ /** A short-lived guest JWT, usable against `/push`. Present only when `authenticated` is `false`. */
51
+ token?: string;
52
+ /** ISO 8601 instant `token` expires at. Present only when `authenticated` is `false`. */
53
+ expiresAt?: string;
54
+ }
55
+
56
+ /** Resolves a join token (an invitee's own link) or a public meeting's slug to its meeting info, ICE servers, and
57
+ * either a short-lived guest signaling token (the common anonymous case) or confirmation that the caller's own
58
+ * existing session already grants them signaling access (`authenticated: true` - see
59
+ * `VideoMeetingJoinResult`'s doc comment) - see `BaseVideoMeetingRoute.join()`'s own doc comment. Rejects with
60
+ * `ApiRequestError` (status `404`) for a stale/unknown/malformed token, cancelled meeting, or the wrong
61
+ * private/public token shape for what it's being used against - the caller (`[token].tsx`) shows a single,
62
+ * friendly "this meeting link isn't valid" state for all of those rather than trying to distinguish them, matching
63
+ * `BaseVideoMeetingRoute.requireMeetingByToken()`'s own explicit "never leak which almost-matched" posture. */
64
+ export function joinMeeting(token: string): Promise<VideoMeetingJoinResult> {
65
+ return apiFetch(`/mail/video-meetings/join/${encodeURIComponent(token)}`);
66
+ }
@@ -0,0 +1,22 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /** Reached only by a bare `/meet` visit with no token (e.g. a mistyped/incomplete link) - the real join/lobby flow
6
+ * lives at `apps/meet/[token].tsx`, `GET /meet/:token`. Mirrors `booking-plugin`'s identical `apps/book/index.tsx`.
7
+ * Replaces this plugin's Phase 1 one-line placeholder page (see `.claude/NOTES.md`). */
8
+ import React from "react";
9
+ import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
10
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
+ import { MeetCard, MeetPageShell } from "./_MeetChrome.js";
12
+
13
+ export default function NoMeetingTokenPage() {
14
+ const { branding } = useBranding();
15
+ return (
16
+ <MeetPageShell branding={branding}>
17
+ <MeetCard>
18
+ <Alert>No meeting link specified.</Alert>
19
+ </MeetCard>
20
+ </MeetPageShell>
21
+ );
22
+ }
@@ -0,0 +1,140 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { FormEvent, useEffect, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import { VideoMeetingDetail, createVideoMeeting, updateVideoMeeting } from "@rapidmx/react-shared/videoconf/videoMeetingsApi.js";
8
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
9
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
10
+
11
+ const INPUT_CLASS =
12
+ "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
13
+
14
+ /** The title a freshly created personal room starts with - editable afterward like any other meeting's title. */
15
+ export const PERSONAL_ROOM_TITLE = "Personal Meeting Room";
16
+
17
+ export interface PersonalRoomCardProps {
18
+ mailboxUid: string;
19
+ /** The mailbox's personal room, as `index.tsx`'s `findPersonalRoom()` identified it - `undefined` when the
20
+ * mailbox has none yet. See that function's own doc comment for the "oldest non-cancelled public meeting"
21
+ * convention this plugin uses instead of a dedicated field/route (`.claude/NOTES.md`'s Phase 4 entry). */
22
+ room: VideoMeetingDetail | undefined;
23
+ /** Called with the freshly created room (`createVideoMeeting()`'s own response, reshaped to a
24
+ * `VideoMeetingDetail`) so the parent's meeting list picks it up without a full reload. */
25
+ onCreated: (meeting: VideoMeetingDetail) => void;
26
+ /** Called after a successful rename, so the parent's meeting list (and this card, on its next render) show
27
+ * the new title without a full reload. */
28
+ onRenamed: (uid: string, title: string) => void;
29
+ }
30
+
31
+ /**
32
+ * The "get and manage your own persistent public link" card - see this plugin's `.claude/NOTES.md` Phase 4 entry
33
+ * for why a personal room is simply the mailbox's own oldest non-cancelled `PUBLIC` `VideoMeeting`, not a distinct
34
+ * model concept. Offers to create one when there is none, and otherwise shows its (editable) title and its
35
+ * shareable link, exactly as `create()`/`findById()` compute it server-side.
36
+ */
37
+ export default function PersonalRoomCard({ mailboxUid, room, onCreated, onRenamed }: PersonalRoomCardProps) {
38
+ const [titleDraft, setTitleDraft] = useState(room?.title ?? "");
39
+ const [creating, setCreating] = useState(false);
40
+ const [saving, setSaving] = useState(false);
41
+ const [copied, setCopied] = useState(false);
42
+ const [error, setError] = useState<string | null>(null);
43
+
44
+ // Reset the draft whenever the identified room itself changes (freshly created, or renamed elsewhere) - not
45
+ // on every parent re-render, so an in-progress edit here survives an unrelated state update on the page (e.g.
46
+ // cancelling a different meeting in the list below).
47
+ useEffect(() => {
48
+ setTitleDraft(room?.title ?? "");
49
+ }, [room?.uid, room?.title]);
50
+
51
+ async function handleCreate() {
52
+ setCreating(true);
53
+ setError(null);
54
+ try {
55
+ const result = await createVideoMeeting({ mailboxUid, title: PERSONAL_ROOM_TITLE, visibility: "public" });
56
+ onCreated({ ...result.meeting, publicJoinUrl: result.publicJoinUrl });
57
+ } catch (err) {
58
+ setError(err instanceof ApiRequestError ? err.message : "Could not create your personal room.");
59
+ } finally {
60
+ setCreating(false);
61
+ }
62
+ }
63
+
64
+ async function handleRename(e: FormEvent) {
65
+ e.preventDefault();
66
+ const trimmed = titleDraft.trim();
67
+ if (!room || !trimmed || trimmed === room.title) {
68
+ return;
69
+ }
70
+ setSaving(true);
71
+ setError(null);
72
+ try {
73
+ const updated = await updateVideoMeeting(room.uid, { title: trimmed });
74
+ onRenamed(room.uid, updated.title);
75
+ } catch (err) {
76
+ setError(err instanceof ApiRequestError ? err.message : "Could not rename your personal room.");
77
+ } finally {
78
+ setSaving(false);
79
+ }
80
+ }
81
+
82
+ async function handleCopy(url: string) {
83
+ try {
84
+ await navigator.clipboard.writeText(url);
85
+ setError(null);
86
+ setCopied(true);
87
+ setTimeout(() => setCopied(false), 2000);
88
+ } catch {
89
+ setError("Could not copy the link. Copy it by hand instead.");
90
+ }
91
+ }
92
+
93
+ return (
94
+ <section aria-label="Your personal room" className="border border-border rounded-md p-5 mb-6">
95
+ <h2 className="text-sm font-bold">Your personal room</h2>
96
+ <p className="text-sm text-text-muted mb-3">
97
+ A permanent link you can share any time - anyone with it can join, no account needed on their end.
98
+ </p>
99
+ {error && <Alert>{error}</Alert>}
100
+ {!room ? (
101
+ <Button type="button" className="!w-auto" loading={creating} disabled={creating} onClick={handleCreate}>
102
+ Create my personal room
103
+ </Button>
104
+ ) : (
105
+ <div className="flex flex-col gap-3">
106
+ <form onSubmit={handleRename} className="flex items-end gap-2">
107
+ <label className="flex-1 flex flex-col gap-1">
108
+ <span className="text-xs text-text-muted">Room name</span>
109
+ <input type="text" className={INPUT_CLASS} value={titleDraft} onChange={(e) => setTitleDraft(e.target.value)} />
110
+ </label>
111
+ <Button
112
+ type="submit"
113
+ variant="secondary"
114
+ className="!w-auto shrink-0"
115
+ loading={saving}
116
+ disabled={saving || !titleDraft.trim() || titleDraft.trim() === room.title}
117
+ >
118
+ Save
119
+ </Button>
120
+ </form>
121
+ {room.publicJoinUrl ? (
122
+ <div className="flex items-center gap-2">
123
+ <code className="flex-1 text-xs bg-surface-alt border border-border rounded-sm py-2 px-3 overflow-x-auto whitespace-nowrap">
124
+ {room.publicJoinUrl}
125
+ </code>
126
+ <Button type="button" variant="secondary" className="!w-auto shrink-0" onClick={() => handleCopy(room.publicJoinUrl!)}>
127
+ {copied ? "Copied" : "Copy"}
128
+ </Button>
129
+ </div>
130
+ ) : (
131
+ <p className="text-sm text-text-muted">
132
+ No public join page URL is configured for this deployment yet - ask an administrator to set one in the Video
133
+ Conferencing plugin&apos;s settings before sharing this room.
134
+ </p>
135
+ )}
136
+ </div>
137
+ )}
138
+ </section>
139
+ );
140
+ }
@@ -0,0 +1,47 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { PropsWithChildren } from "react";
6
+ import { Branding } from "@rapidmx/react-shared/branding/brandingApi.js";
7
+ import { CUSTOM_STYLESHEET_LINK_ID } from "@rapidmx/react-shared/branding/useBranding.js";
8
+
9
+ export interface LayoutProps {
10
+ /**
11
+ * Supplied by the server's `WwwRoute` `fetchProps()`, which every `www` plugin app's route extends. This file
12
+ * is a copy of `@rapidmx/web-client`'s `apps/www/_layout.tsx` (mirroring `booking-plugin`'s own
13
+ * `apps/settings-booking-types/_layout.tsx` exactly), since every app directory needs its own layout.
14
+ * `@rapidrest/react` spreads the same merged page props onto this layout as onto the page it wraps, so it
15
+ * renders correctly server-side on the very first byte of the response: no client-side flash from the stock
16
+ * defaults below, and a crawler reading the raw HTML sees the real branding too. `undefined` only on the
17
+ * framework's unwrapped `_500` fallback path, which doesn't receive props at all - falling back to the stock
18
+ * defaults there is acceptable.
19
+ */
20
+ branding?: Branding;
21
+ }
22
+
23
+ export default function Layout({ children, branding }: PropsWithChildren<LayoutProps>) {
24
+ const title = branding?.title || branding?.companyName ? `${branding?.title || branding?.companyName}: Mail` : "RapidMX: Mail";
25
+ const iconHref = branding?.iconUrl || branding?.logoUrl || "/images/logo.svg";
26
+ const stylesheetHref = branding?.stylesheetUrl;
27
+
28
+ return (
29
+ <html lang="en">
30
+ <head>
31
+ <meta charSet="utf-8" />
32
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
33
+ <title>{title}</title>
34
+ <link rel="icon" type="image/svg+xml" href={iconHref} />
35
+ <link rel="alternate icon" href="/favicon.ico" />
36
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
37
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
38
+ <link
39
+ rel="stylesheet"
40
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
41
+ />
42
+ {stylesheetHref && <link rel="stylesheet" href={stylesheetHref} id={CUSTOM_STYLESHEET_LINK_ID} />}
43
+ </head>
44
+ <body>{children}</body>
45
+ </html>
46
+ );
47
+ }
@@ -0,0 +1,192 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useEffect, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import { VideoMeetingDetail, listVideoMeetings, updateVideoMeeting } from "@rapidmx/react-shared/videoconf/videoMeetingsApi.js";
8
+ import SettingsShell, { SettingsShellProps, useSettingsShell } from "@rapidmx/web-client/shared/components/settings/layout/SettingsShell.js";
9
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
+ import PersonalRoomCard from "./_PersonalRoomCard.js";
11
+
12
+ /** However many of a mailbox's meetings this page ever needs to see at once - generous enough that a real user's
13
+ * whole history fits in one request (this is a personal settings page, not a paged archive), matching
14
+ * `SettingsShell`'s own `listMailboxes({ limit: 100 })` precedent for "large enough not to page in practice". */
15
+ const LIST_LIMIT = 200;
16
+
17
+ const STATUS_LABELS: Record<string, string> = {
18
+ scheduled: "Scheduled",
19
+ active: "In progress",
20
+ ended: "Ended",
21
+ cancelled: "Cancelled",
22
+ };
23
+
24
+ export type SettingsVideoConferencingPageProps = Omit<SettingsShellProps, "active">;
25
+
26
+ export default function SettingsVideoConferencingPage(props: SettingsVideoConferencingPageProps) {
27
+ return (
28
+ <SettingsShell {...props} active="video-conferencing">
29
+ <VideoConferencingSettingsContent />
30
+ </SettingsShell>
31
+ );
32
+ }
33
+
34
+ /**
35
+ * Picks a mailbox's "personal room" out of `listVideoMeetings()`'s results: the oldest still-active (non-cancelled)
36
+ * `PUBLIC` meeting. `BaseVideoMeetingRoute` has no field or route naming a "personal room" as a concept distinct
37
+ * from any other public meeting a mailbox happens to have - a public meeting IS just a `VideoMeeting` with
38
+ * `visibility: "public"` (see this plugin's `.claude/NOTES.md` Phase 4 entry for the full reasoning behind this
39
+ * convention, and why no backend addition was needed to support it). Oldest-first keeps the identification stable
40
+ * across reloads, regardless of when in the list a later public meeting (created some other way, e.g. directly
41
+ * through the API) happens to land; skipping a cancelled one means cancelling today's room and creating a new one
42
+ * always finds the fresh one on the next load, never a dead link.
43
+ */
44
+ export function findPersonalRoom(meetings: VideoMeetingDetail[]): VideoMeetingDetail | undefined {
45
+ return meetings
46
+ .filter((m) => m.visibility === "public" && m.status !== "cancelled")
47
+ .sort((a, b) => new Date(a.dateCreated).getTime() - new Date(b.dateCreated).getTime())[0];
48
+ }
49
+
50
+ function VideoConferencingSettingsContent() {
51
+ const { mailboxUid } = useSettingsShell();
52
+ const [meetings, setMeetings] = useState<VideoMeetingDetail[]>([]);
53
+ const [loading, setLoading] = useState(true);
54
+ const [loadError, setLoadError] = useState<string | null>(null);
55
+ const [copiedUid, setCopiedUid] = useState<string | null>(null);
56
+ const [cancellingUid, setCancellingUid] = useState<string | null>(null);
57
+ const [actionError, setActionError] = useState<string | null>(null);
58
+
59
+ // `SettingsShell` only ever renders its children once `mailboxUid` has resolved - same established non-null
60
+ // pattern as `booking-plugin`'s own settings pages.
61
+ useEffect(() => {
62
+ setLoading(true);
63
+ setLoadError(null);
64
+ listVideoMeetings(mailboxUid!, { limit: LIST_LIMIT })
65
+ .then(setMeetings)
66
+ .catch((err) => setLoadError(err instanceof ApiRequestError ? err.message : "Could not load your video meetings."))
67
+ .finally(() => setLoading(false));
68
+ }, [mailboxUid]);
69
+
70
+ const personalRoom = findPersonalRoom(meetings);
71
+ // Every other meeting worth showing here: any other public meeting, or a private one with a linked calendar
72
+ // event (the calendar compose hook's own meetings - Phase 3). A private, calendar-less meeting is an artifact
73
+ // of this plugin's own API used directly (e.g. testing), not something a real user created through this page
74
+ // or a calendar invite, so it's left out. The personal room itself is surfaced above instead of repeated here.
75
+ const otherMeetings = meetings
76
+ .filter((m) => m.uid !== personalRoom?.uid && (m.visibility === "public" || !!m.calendarEventUid))
77
+ .sort((a, b) => new Date(b.dateCreated).getTime() - new Date(a.dateCreated).getTime());
78
+
79
+ async function handleCopy(url: string, uid: string) {
80
+ try {
81
+ await navigator.clipboard.writeText(url);
82
+ setActionError(null);
83
+ setCopiedUid(uid);
84
+ setTimeout(() => setCopiedUid((current) => (current === uid ? null : current)), 2000);
85
+ } catch {
86
+ setActionError("Could not copy the link. Copy it by hand instead.");
87
+ }
88
+ }
89
+
90
+ async function handleCancel(uid: string) {
91
+ setCancellingUid(uid);
92
+ setActionError(null);
93
+ try {
94
+ const updated = await updateVideoMeeting(uid, { status: "cancelled" });
95
+ setMeetings((current) => current.map((m) => (m.uid === uid ? { ...m, status: updated.status } : m)));
96
+ } catch (err) {
97
+ setActionError(err instanceof ApiRequestError ? err.message : "Could not cancel this meeting.");
98
+ } finally {
99
+ setCancellingUid(null);
100
+ }
101
+ }
102
+
103
+ return (
104
+ <div className="flex-1 min-w-0 overflow-y-auto p-6">
105
+ <div className="max-w-4xl">
106
+ <h1 className="text-lg font-bold tracking-tight mb-1">Video Conferencing</h1>
107
+ <p className="text-sm text-text-muted mb-5">
108
+ Your personal meeting room, and every video meeting created for you - from a calendar invite or directly here.
109
+ </p>
110
+
111
+ {loadError && <Alert>{loadError}</Alert>}
112
+ {actionError && <Alert>{actionError}</Alert>}
113
+
114
+ {loading ? (
115
+ <p className="text-sm text-text-muted">Loading&hellip;</p>
116
+ ) : (
117
+ <>
118
+ <PersonalRoomCard
119
+ mailboxUid={mailboxUid!}
120
+ room={personalRoom}
121
+ onCreated={(meeting) => setMeetings((current) => [meeting, ...current])}
122
+ onRenamed={(uid, title) => setMeetings((current) => current.map((m) => (m.uid === uid ? { ...m, title } : m)))}
123
+ />
124
+
125
+ <h2 className="text-sm font-bold mb-2">Meetings</h2>
126
+ {otherMeetings.length === 0 ? (
127
+ <p className="text-sm text-text-muted">No other meetings yet.</p>
128
+ ) : (
129
+ <div className="overflow-x-auto">
130
+ <table className="w-full text-sm border-collapse">
131
+ <thead>
132
+ <tr>
133
+ {["Title", "Visibility", "Status", "Created", ""].map((h) => (
134
+ <th
135
+ key={h}
136
+ className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border"
137
+ >
138
+ {h}
139
+ </th>
140
+ ))}
141
+ </tr>
142
+ </thead>
143
+ <tbody>
144
+ {otherMeetings.map((meeting) => (
145
+ <tr key={meeting.uid}>
146
+ <td className="py-2.5 px-2.5 border-b border-border">{meeting.title}</td>
147
+ <td className="py-2.5 px-2.5 border-b border-border capitalize">{meeting.visibility}</td>
148
+ <td className="py-2.5 px-2.5 border-b border-border">
149
+ {/* v8 ignore next -- unreachable via real usage: `VideoMeetingStatus` has exactly the four keys
150
+ `STATUS_LABELS` defines, and response bodies are typed but not runtime-validated (see
151
+ `videoMeetingsApi.ts`'s own doc comment) - the fallback exists only for forward compatibility
152
+ with a status value this build doesn't know about yet. */}
153
+ {STATUS_LABELS[meeting.status] ?? meeting.status}
154
+ </td>
155
+ <td className="py-2.5 px-2.5 border-b border-border whitespace-nowrap">
156
+ {new Date(meeting.dateCreated).toLocaleString()}
157
+ </td>
158
+ <td className="py-2.5 px-2.5 border-b border-border text-right whitespace-nowrap">
159
+ {meeting.visibility === "public" && meeting.publicJoinUrl && (
160
+ <button
161
+ type="button"
162
+ aria-label={`Copy link to ${meeting.title}`}
163
+ className="text-primary-dark hover:underline font-medium mr-4"
164
+ onClick={() => handleCopy(meeting.publicJoinUrl!, meeting.uid)}
165
+ >
166
+ {copiedUid === meeting.uid ? "Copied" : "Copy link"}
167
+ </button>
168
+ )}
169
+ {meeting.status === "scheduled" && (
170
+ <button
171
+ type="button"
172
+ aria-label={`Cancel ${meeting.title}`}
173
+ className="text-danger hover:underline font-medium disabled:opacity-55"
174
+ disabled={cancellingUid === meeting.uid}
175
+ onClick={() => handleCancel(meeting.uid)}
176
+ >
177
+ {cancellingUid === meeting.uid ? "Cancelling…" : "Cancel"}
178
+ </button>
179
+ )}
180
+ </td>
181
+ </tr>
182
+ ))}
183
+ </tbody>
184
+ </table>
185
+ </div>
186
+ )}
187
+ </>
188
+ )}
189
+ </div>
190
+ </div>
191
+ );
192
+ }
@@ -0,0 +1,45 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Pure active-speaker selection logic, decoupled from `levelMeter.ts`'s `AnalyserNode` wiring so it can be unit
7
+ * tested with plain numbers - no `AudioContext`/DOM at all. A simple volume-threshold heuristic, as this plugin's
8
+ * Phase 2 `.claude/NOTES.md` entry calls for ("this doesn't need to be sophisticated"): the loudest participant
9
+ * currently above `threshold` becomes the pick; below threshold, the previous pick is kept rather than falling
10
+ * back to nobody, so the focused tile doesn't flicker to a blank state during a brief pause in speech.
11
+ */
12
+
13
+ /** 0-100 (`levelMeter.ts`'s own output range). Chosen empirically as "clearly speaking" vs. room noise/silence -
14
+ * not derived from any spec, and the easiest constant to retune later. */
15
+ export const DEFAULT_ACTIVE_SPEAKER_THRESHOLD = 12;
16
+
17
+ /**
18
+ * Picks who should be auto-focused, given every remote participant's current audio level (local participant
19
+ * excluded by the caller - see `_CallView.tsx`, which never lets "yourself" be the auto-detected speaker).
20
+ *
21
+ * @param levels uid -> level (0-100), one entry per participant currently being measured.
22
+ * @param current The currently auto-focused uid, if any - kept when no one is clearly above `threshold`, and used
23
+ * to break an exact tie (staying on the current speaker rather than jittering to another equally-loud one).
24
+ * @param threshold See `DEFAULT_ACTIVE_SPEAKER_THRESHOLD`.
25
+ */
26
+ export function pickActiveSpeaker(
27
+ levels: Readonly<Record<string, number>>,
28
+ current: string | undefined,
29
+ threshold: number = DEFAULT_ACTIVE_SPEAKER_THRESHOLD,
30
+ ): string | undefined {
31
+ let best: string | undefined;
32
+ let bestLevel = threshold;
33
+ for (const [uid, level] of Object.entries(levels)) {
34
+ if (level > bestLevel || (level === bestLevel && uid === current)) {
35
+ best = uid;
36
+ bestLevel = level;
37
+ }
38
+ }
39
+ if (best) {
40
+ return best;
41
+ }
42
+ // Nobody is clearly above threshold: keep the current pick only if they're still a measured participant
43
+ // (they may have left), otherwise there's no reasonable pick at all.
44
+ return current && current in levels ? current : undefined;
45
+ }