@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,41 @@
1
+ /**
2
+ * The in-call view: connects the full-mesh `MeshConnectionManager` to the meeting's signaling channel
3
+ * (`GuestSignalingClient`), mirrors its events into React state, and renders the grid/focused/presentation
4
+ * layouts - see `apps/shared/webrtc/MeshConnectionManager.ts`'s doc comment for the who-calls-whom and
5
+ * single-presenter rules this view relies on rather than re-deciding. These reusable, non-UI modules live under
6
+ * `apps/shared/` rather than this plugin's backend `src/` - `tsconfig.apps.json` builds `apps/` as its own
7
+ * program rooted at `apps/`, which cannot reference files outside it (`TS6059`), matching `booking-plugin`'s own
8
+ * identical `apps/shared/` convention for reusable frontend-only code.
9
+ *
10
+ * ## Layout precedence
11
+ *
12
+ * `computeMainUid()` decides who is shown large, in this order: an active presenter always wins (presentation
13
+ * mode, forcing a focused-like layout regardless of `viewMode`); otherwise a manually pinned participant; else the
14
+ * auto-detected active speaker (silently ignored while presenting, so it never fights the presenter for the main
15
+ * slot); else, in focus mode with nobody yet speaking, the first other participant - so focus mode never shows an
16
+ * empty main slot once someone else has joined.
17
+ */
18
+ import React from "react";
19
+ export interface CallViewProps {
20
+ channel: string;
21
+ /** Omitted when the caller already authenticated as a real RapidMX identity in `join()` - there is then no
22
+ * guest token to hand `GuestSignalingClient`, which relies entirely on the browser's own already-existing
23
+ * `jwt` session cookie instead (see its own doc comment). */
24
+ token?: string;
25
+ selfUid: string;
26
+ selfName: string;
27
+ iceServers: RTCIceServer[];
28
+ initialStream: MediaStream;
29
+ initialMicOn: boolean;
30
+ initialCameraOn: boolean;
31
+ onLeave: () => void;
32
+ }
33
+ /** Who is shown large - see this module's doc comment. `undefined` when nobody but the local participant has
34
+ * joined yet (the grid/focus toggle still works, there's just nobody else to focus on). */
35
+ export declare function computeMainUid(options: {
36
+ presenterUid?: string;
37
+ pinnedUid?: string;
38
+ activeSpeakerUid?: string;
39
+ otherUids: readonly string[];
40
+ }): string | undefined;
41
+ export default function CallView({ channel, token, selfUid, selfName, iceServers, initialStream, initialMicOn, initialCameraOn, onLeave, }: CallViewProps): React.JSX.Element;
@@ -0,0 +1,225 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ /**
7
+ * The in-call view: connects the full-mesh `MeshConnectionManager` to the meeting's signaling channel
8
+ * (`GuestSignalingClient`), mirrors its events into React state, and renders the grid/focused/presentation
9
+ * layouts - see `apps/shared/webrtc/MeshConnectionManager.ts`'s doc comment for the who-calls-whom and
10
+ * single-presenter rules this view relies on rather than re-deciding. These reusable, non-UI modules live under
11
+ * `apps/shared/` rather than this plugin's backend `src/` - `tsconfig.apps.json` builds `apps/` as its own
12
+ * program rooted at `apps/`, which cannot reference files outside it (`TS6059`), matching `booking-plugin`'s own
13
+ * identical `apps/shared/` convention for reusable frontend-only code.
14
+ *
15
+ * ## Layout precedence
16
+ *
17
+ * `computeMainUid()` decides who is shown large, in this order: an active presenter always wins (presentation
18
+ * mode, forcing a focused-like layout regardless of `viewMode`); otherwise a manually pinned participant; else the
19
+ * auto-detected active speaker (silently ignored while presenting, so it never fights the presenter for the main
20
+ * slot); else, in focus mode with nobody yet speaking, the first other participant - so focus mode never shows an
21
+ * empty main slot once someone else has joined.
22
+ */
23
+ import { useEffect, useRef, useState } from "react";
24
+ import { pickActiveSpeaker } from "../shared/media/activeSpeaker.js";
25
+ import { startLevelMeter } from "../shared/media/levelMeter.js";
26
+ import { requestDisplayMedia, setTracksEnabled, stopStream } from "../shared/media/deviceMedia.js";
27
+ import { createBrowserPeerConnection } from "../shared/webrtc/realPeerConnection.js";
28
+ import { MeshConnectionManager } from "../shared/webrtc/MeshConnectionManager.js";
29
+ import { GuestSignalingClient } from "../shared/push/GuestSignalingClient.js";
30
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
31
+ import CallControls from "./_CallControls.js";
32
+ import ParticipantTile from "./_ParticipantTile.js";
33
+ /** Who is shown large - see this module's doc comment. `undefined` when nobody but the local participant has
34
+ * joined yet (the grid/focus toggle still works, there's just nobody else to focus on). */
35
+ export function computeMainUid(options) {
36
+ if (options.presenterUid) {
37
+ return options.presenterUid;
38
+ }
39
+ if (options.pinnedUid) {
40
+ return options.pinnedUid;
41
+ }
42
+ if (options.activeSpeakerUid) {
43
+ return options.activeSpeakerUid;
44
+ }
45
+ return options.otherUids[0];
46
+ }
47
+ export default function CallView({ channel, token, selfUid, selfName, iceServers, initialStream, initialMicOn, initialCameraOn, onLeave, }) {
48
+ const [connectError, setConnectError] = useState(null);
49
+ const [participants, setParticipants] = useState([]);
50
+ const [remoteStreams, setRemoteStreams] = useState({});
51
+ const [levels, setLevels] = useState({});
52
+ const [presenterUid, setPresenterUid] = useState(undefined);
53
+ const [micOn, setMicOn] = useState(initialMicOn);
54
+ const [cameraOn, setCameraOn] = useState(initialCameraOn);
55
+ const [isPresenting, setIsPresenting] = useState(false);
56
+ const [viewMode, setViewMode] = useState("grid");
57
+ const [pinnedUid, setPinnedUid] = useState(undefined);
58
+ const [activeSpeakerUid, setActiveSpeakerUid] = useState(undefined);
59
+ const managerRef = useRef(null);
60
+ const clientRef = useRef(null);
61
+ const localStreamRef = useRef(initialStream);
62
+ const screenStreamRef = useRef(null);
63
+ const cameraTrackRef = useRef(initialStream.getVideoTracks()[0] ?? null);
64
+ const levelMetersRef = useRef({});
65
+ const isPresentingRef = useRef(false);
66
+ useEffect(() => {
67
+ let cancelled = false;
68
+ const client = new GuestSignalingClient({ channel, token });
69
+ clientRef.current = client;
70
+ const manager = new MeshConnectionManager({
71
+ selfUid,
72
+ selfName,
73
+ iceServers,
74
+ channel: client,
75
+ createPeerConnection: createBrowserPeerConnection,
76
+ localStream: localStreamRef.current,
77
+ });
78
+ managerRef.current = manager;
79
+ const unsubscribe = manager.onEvent((event) => {
80
+ switch (event.type) {
81
+ case "participant-joined":
82
+ setParticipants((prev) => [...prev.filter((p) => p.uid !== event.participant.uid), event.participant]);
83
+ return;
84
+ case "participant-left":
85
+ setParticipants((prev) => prev.filter((p) => p.uid !== event.uid));
86
+ setRemoteStreams((prev) => {
87
+ const { [event.uid]: _removed, ...rest } = prev;
88
+ return rest;
89
+ });
90
+ stopLevelMeter(levelMetersRef, event.uid);
91
+ setLevels((prev) => {
92
+ const { [event.uid]: _removed, ...rest } = prev;
93
+ return rest;
94
+ });
95
+ setPinnedUid((prev) => (prev === event.uid ? undefined : prev));
96
+ return;
97
+ case "remote-stream":
98
+ setRemoteStreams((prev) => ({ ...prev, [event.uid]: event.stream }));
99
+ startTrackingLevel(levelMetersRef, event.uid, event.stream, (level) => setLevels((prev) => ({ ...prev, [event.uid]: level })));
100
+ return;
101
+ case "presenter-changed":
102
+ setPresenterUid(event.uid);
103
+ if (event.uid !== selfUid && isPresentingRef.current) {
104
+ // Lost a presenter-claim collision (see `MeshConnectionManager`'s doc comment) - stop our
105
+ // own capture without re-sending a release the manager already handled internally.
106
+ stopLocalPresentation(false);
107
+ }
108
+ return;
109
+ }
110
+ });
111
+ client
112
+ .connect()
113
+ .then(() => {
114
+ if (!cancelled) {
115
+ manager.start();
116
+ }
117
+ })
118
+ .catch((err) => {
119
+ if (!cancelled) {
120
+ setConnectError(err.message);
121
+ }
122
+ });
123
+ return () => {
124
+ cancelled = true;
125
+ unsubscribe();
126
+ manager.stop();
127
+ client.close();
128
+ for (const handle of Object.values(levelMetersRef.current)) {
129
+ handle.stop();
130
+ }
131
+ levelMetersRef.current = {};
132
+ stopStream(localStreamRef.current);
133
+ stopStream(screenStreamRef.current);
134
+ };
135
+ // Deliberately runs once - the call's identity (channel/token/selfUid) never changes for the life of this
136
+ // component; a real identity change is a new call, which unmounts/remounts this view from `[token].tsx`.
137
+ }, []);
138
+ useEffect(() => {
139
+ if (!presenterUid) {
140
+ setActiveSpeakerUid((prev) => pickActiveSpeaker(levels, prev));
141
+ }
142
+ }, [levels, presenterUid]);
143
+ function stopLocalPresentation(alsoRelease) {
144
+ isPresentingRef.current = false;
145
+ setIsPresenting(false);
146
+ if (alsoRelease) {
147
+ managerRef.current?.releasePresenter();
148
+ }
149
+ stopStream(screenStreamRef.current);
150
+ screenStreamRef.current = null;
151
+ // Restores the camera track as the outgoing video sender - its own `enabled` flag (toggled by
152
+ // `handleToggleCamera`, untouched by presenting) already governs whether it actually sends frames, so
153
+ // this is correct whether or not the camera happens to be off right now.
154
+ managerRef.current?.replaceLocalVideoTrack(cameraTrackRef.current);
155
+ }
156
+ async function handleToggleShare() {
157
+ if (isPresenting) {
158
+ stopLocalPresentation(true);
159
+ return;
160
+ }
161
+ const result = await requestDisplayMedia();
162
+ if (!result.ok) {
163
+ setConnectError(result.error.message);
164
+ return;
165
+ }
166
+ const claimed = managerRef.current?.claimPresenter() ?? false;
167
+ if (!claimed) {
168
+ stopStream(result.value);
169
+ return;
170
+ }
171
+ screenStreamRef.current = result.value;
172
+ const [screenTrack] = result.value.getVideoTracks();
173
+ managerRef.current?.replaceLocalVideoTrack(screenTrack);
174
+ isPresentingRef.current = true;
175
+ setIsPresenting(true);
176
+ screenTrack.onended = () => stopLocalPresentation(true);
177
+ }
178
+ function handleToggleMic() {
179
+ setMicOn((prev) => {
180
+ const next = !prev;
181
+ setTracksEnabled(localStreamRef.current, "audio", next);
182
+ return next;
183
+ });
184
+ }
185
+ function handleToggleCamera() {
186
+ setCameraOn((prev) => {
187
+ const next = !prev;
188
+ // Just the track's own `enabled` flag (see `deviceMedia.ts`'s `setTracksEnabled()` doc comment) -
189
+ // whether that track is actually the one being sent (camera) or has been swapped out for a screen
190
+ // share (`replaceLocalVideoTrack()`) is an orthogonal concern this toggle never touches.
191
+ setTracksEnabled(localStreamRef.current, "video", next);
192
+ return next;
193
+ });
194
+ }
195
+ function handleLeave() {
196
+ onLeave();
197
+ }
198
+ function togglePin(uid) {
199
+ setPinnedUid((prev) => (prev === uid ? undefined : uid));
200
+ }
201
+ const otherUids = participants.map((p) => p.uid);
202
+ const showFocusLayout = !!presenterUid || viewMode === "focus";
203
+ const mainUid = showFocusLayout ? computeMainUid({ presenterUid, pinnedUid, activeSpeakerUid, otherUids }) : undefined;
204
+ const presenterName = presenterUid ? (presenterUid === selfUid ? selfName : (participants.find((p) => p.uid === presenterUid)?.name ?? "Someone")) : undefined;
205
+ const localTile = (_jsx(ParticipantTile, { name: selfName, stream: isPresenting ? null : localStreamRef.current, isLocal: true, cameraOff: !cameraOn && !isPresenting, micMuted: !micOn, isFocused: mainUid === selfUid, onClick: () => togglePin(selfUid) }, "__self"));
206
+ const remoteTiles = participants.map((p) => (_jsx(ParticipantTile, { name: p.name, stream: remoteStreams[p.uid] ?? null, cameraOff: !remoteStreams[p.uid], isFocused: mainUid === p.uid, onClick: () => togglePin(p.uid) }, p.uid)));
207
+ const allTiles = [localTile, ...remoteTiles];
208
+ // The presenter's own screen content is rendered from their local capture, not their (replaced) outgoing
209
+ // video sender - `mainStream` picks whichever is right for `mainUid`.
210
+ const mainStream = mainUid === selfUid ? (isPresenting ? screenStreamRef.current : localStreamRef.current) : mainUid ? (remoteStreams[mainUid] ?? null) : null;
211
+ const mainName = mainUid === selfUid ? selfName : (participants.find((p) => p.uid === mainUid)?.name ?? "");
212
+ const thumbnailTiles = showFocusLayout ? allTiles.filter((tile) => tile.key !== (mainUid === selfUid ? "__self" : mainUid)) : allTiles;
213
+ return (_jsxs("div", { className: "min-h-screen flex flex-col bg-surface-alt", children: [connectError && _jsx(Alert, { children: connectError }), _jsx("div", { className: "flex-1 p-3 flex flex-col gap-3 min-h-0", children: showFocusLayout && mainUid ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "flex-1 min-h-0", children: _jsx(ParticipantTile, { name: mainName, stream: mainStream, isLocal: mainUid === selfUid, isFocused: true, className: "h-full" }) }), _jsx("div", { className: "flex gap-2 overflow-x-auto h-24 shrink-0", children: thumbnailTiles.map((tile) => (_jsx("div", { className: "w-32 shrink-0", children: tile }, tile.key))) })] })) : (_jsx("div", { className: "flex-1 grid gap-2 [grid-template-columns:repeat(auto-fit,minmax(180px,1fr))]", children: allTiles })) }), _jsx(CallControls, { micOn: micOn, onToggleMic: handleToggleMic, cameraOn: cameraOn, onToggleCamera: handleToggleCamera, isPresenting: isPresenting, presentingElsewhereName: presenterUid && presenterUid !== selfUid ? presenterName : undefined, onToggleShare: handleToggleShare, viewMode: viewMode, onToggleViewMode: () => setViewMode((prev) => (prev === "grid" ? "focus" : "grid")), onLeave: handleLeave })] }));
214
+ }
215
+ function stopLevelMeter(ref, uid) {
216
+ ref.current[uid]?.stop();
217
+ delete ref.current[uid];
218
+ }
219
+ function startTrackingLevel(ref, uid, stream, onLevel) {
220
+ stopLevelMeter(ref, uid);
221
+ const handle = startLevelMeter(stream, onLevel);
222
+ if (handle) {
223
+ ref.current[uid] = handle;
224
+ }
225
+ }
@@ -0,0 +1,13 @@
1
+ /** The frame shared by every page in this app: the deployment's branding header/footer around a centered card, the
2
+ * same `booking-plugin`'s `_BookingChrome.tsx` shell-component pattern this plugin's Phase 2 spec calls for. Not a
3
+ * page itself: `_`-prefixed files in `apps/` aren't routed. */
4
+ import React, { PropsWithChildren } from "react";
5
+ import { Branding } from "@rapidmx/react-shared/branding/brandingApi.js";
6
+ export declare function MeetPageShell({ branding, children }: PropsWithChildren<{
7
+ branding: Branding | null;
8
+ }>): React.JSX.Element;
9
+ /** The white card the lobby (and the "meeting not found" state) is drawn in - the in-call view (`_CallView.tsx`)
10
+ * deliberately does NOT use this: a video call wants the full viewport, not a centered card. */
11
+ export declare function MeetCard({ maxWidth, children }: PropsWithChildren<{
12
+ maxWidth?: string;
13
+ }>): React.JSX.Element;
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { BrandingFooter, BrandingHeader } from "@rapidmx/web-client/shared/components/layout/BrandingChrome.js";
3
+ export function MeetPageShell({ branding, children }) {
4
+ return (_jsxs("div", { className: "min-h-screen flex flex-col", children: [_jsx(BrandingHeader, { branding: branding }), _jsx("main", { className: "flex-1 bg-surface-alt px-4 py-6 sm:py-10", children: children }), _jsx(BrandingFooter, { branding: branding })] }));
5
+ }
6
+ /** The white card the lobby (and the "meeting not found" state) is drawn in - the in-call view (`_CallView.tsx`)
7
+ * deliberately does NOT use this: a video call wants the full viewport, not a centered card. */
8
+ export function MeetCard({ maxWidth = "max-w-2xl", children }) {
9
+ return (_jsx("div", { className: `mx-auto w-full ${maxWidth} overflow-hidden rounded-lg border border-border bg-surface shadow-sm p-6 sm:p-10`, children: children }));
10
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The join/lobby step: name entry, device preview and pickers, mute/camera-off starting preferences, and the
3
+ * "Join" button. Per this plugin's Phase 2 spec, the name field is always editable - it is only ever *prefilled*
4
+ * when a session is genuinely known (see this component's `initialName` prop and `[token].tsx`'s own doc comment
5
+ * on why that never actually happens on this page today).
6
+ *
7
+ * Device support/permission errors (`navigator.mediaDevices` missing entirely - e.g. a non-HTTPS context in some
8
+ * browsers - a denied prompt, or no camera/microphone at all) are shown as a plain message rather than crashing;
9
+ * joining is still allowed with no local media in that case (a signaling-only participant - see this plugin's
10
+ * Phase 2 report's known-limitations note on why that participant's peer connections can carry no media at all
11
+ * without this design's simpler, renegotiation-free mesh needing to grow one).
12
+ */
13
+ import React from "react";
14
+ import type { PublicVideoMeeting } from "./_meetApi.js";
15
+ export interface JoinPreferences {
16
+ name: string;
17
+ micOn: boolean;
18
+ cameraOn: boolean;
19
+ /** Never `null` - an empty `MediaStream` when no device access was ever obtained, so the call view always has
20
+ * a stream to work with. */
21
+ stream: MediaStream;
22
+ }
23
+ export interface MeetLobbyProps {
24
+ meeting: PublicVideoMeeting;
25
+ /** Prefilled from `Profile.givenName` when a session was detected - still always editable. */
26
+ initialName?: string;
27
+ onJoin: (preferences: JoinPreferences) => void;
28
+ }
29
+ export default function MeetLobby({ meeting, initialName, onJoin }: MeetLobbyProps): React.JSX.Element;
@@ -0,0 +1,120 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ /**
7
+ * The join/lobby step: name entry, device preview and pickers, mute/camera-off starting preferences, and the
8
+ * "Join" button. Per this plugin's Phase 2 spec, the name field is always editable - it is only ever *prefilled*
9
+ * when a session is genuinely known (see this component's `initialName` prop and `[token].tsx`'s own doc comment
10
+ * on why that never actually happens on this page today).
11
+ *
12
+ * Device support/permission errors (`navigator.mediaDevices` missing entirely - e.g. a non-HTTPS context in some
13
+ * browsers - a denied prompt, or no camera/microphone at all) are shown as a plain message rather than crashing;
14
+ * joining is still allowed with no local media in that case (a signaling-only participant - see this plugin's
15
+ * Phase 2 report's known-limitations note on why that participant's peer connections can carry no media at all
16
+ * without this design's simpler, renegotiation-free mesh needing to grow one).
17
+ */
18
+ import { useEffect, useRef, useState } from "react";
19
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
20
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
21
+ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
22
+ import { isMediaDevicesSupported, listDevices, requestUserMedia, setTracksEnabled, stopStream, } from "../shared/media/deviceMedia.js";
23
+ const INPUT_CLASS = "w-full text-base py-2.5 px-3.5 border border-border rounded-md bg-surface text-text focus:outline-none focus:border-primary";
24
+ export default function MeetLobby({ meeting, initialName, onJoin }) {
25
+ const [name, setName] = useState(initialName ?? "");
26
+ const [micOn, setMicOn] = useState(true);
27
+ const [cameraOn, setCameraOn] = useState(true);
28
+ const [stream, setStream] = useState(null);
29
+ const [devices, setDevices] = useState({ cameras: [], microphones: [] });
30
+ const [error, setError] = useState(null);
31
+ const [supported, setSupported] = useState(true);
32
+ const videoRef = useRef(null);
33
+ // Requests device access once, on mount - never re-requested just because `micOn`/`cameraOn` toggle (those
34
+ // toggles disable/enable the already-granted tracks instead, see `handleToggleMic`/`handleToggleCamera`).
35
+ useEffect(() => {
36
+ let cancelled = false;
37
+ if (!isMediaDevicesSupported()) {
38
+ setSupported(false);
39
+ return;
40
+ }
41
+ void requestUserMedia({ audio: true, video: true }).then(async (result) => {
42
+ if (cancelled) {
43
+ return;
44
+ }
45
+ if (!result.ok) {
46
+ setError(result.error);
47
+ return;
48
+ }
49
+ setStream(result.value);
50
+ const list = await listDevices();
51
+ if (!cancelled && list.ok) {
52
+ setDevices(list.value);
53
+ }
54
+ });
55
+ return () => {
56
+ cancelled = true;
57
+ };
58
+ // Intentionally empty deps - see this effect's own comment on why this never re-runs.
59
+ }, []);
60
+ // Releases the camera/microphone the moment this component unmounts (leaving the lobby without joining, or
61
+ // transitioning into the call - `[token].tsx` requests its own continuation of these tracks first).
62
+ useEffect(() => stopOnUnmount(stream), [stream]);
63
+ useEffect(() => {
64
+ if (videoRef.current) {
65
+ videoRef.current.srcObject = stream;
66
+ }
67
+ }, [stream]);
68
+ function handleToggleMic() {
69
+ setMicOn((prev) => {
70
+ const next = !prev;
71
+ setTracksEnabled(stream, "audio", next);
72
+ return next;
73
+ });
74
+ }
75
+ function handleToggleCamera() {
76
+ setCameraOn((prev) => {
77
+ const next = !prev;
78
+ setTracksEnabled(stream, "video", next);
79
+ return next;
80
+ });
81
+ }
82
+ async function handleSwitchDevice(kind, deviceId) {
83
+ const result = await requestUserMedia({ [kind]: { deviceId: { exact: deviceId } }, [kind === "audio" ? "video" : "audio"]: false });
84
+ if (!result.ok) {
85
+ setError(result.error);
86
+ return;
87
+ }
88
+ const [newTrack] = kind === "audio" ? result.value.getAudioTracks() : result.value.getVideoTracks();
89
+ // `stream` is always set by the time this can be called - the device picker this is wired to only ever
90
+ // renders once `devices` is populated, which itself only happens after an initial successful
91
+ // `requestUserMedia()` call has already set `stream` (see the mount effect above).
92
+ if (!newTrack) {
93
+ return;
94
+ }
95
+ const [oldTrack] = kind === "audio" ? stream.getAudioTracks() : stream.getVideoTracks();
96
+ if (oldTrack) {
97
+ stream.removeTrack(oldTrack);
98
+ oldTrack.stop();
99
+ }
100
+ newTrack.enabled = kind === "audio" ? micOn : cameraOn;
101
+ stream.addTrack(newTrack);
102
+ // Re-triggers the preview `<video>` binding and any downstream consumer without minting a whole new
103
+ // `MediaStream` object (the mesh's already-negotiated connections keep the same stream identity).
104
+ setStream((current) => current);
105
+ }
106
+ function handleJoin() {
107
+ onJoin({
108
+ name: name.trim(),
109
+ micOn,
110
+ cameraOn,
111
+ stream: stream ?? new MediaStream(),
112
+ });
113
+ }
114
+ return (_jsxs("div", { children: [_jsx("h1", { className: "text-2xl font-bold tracking-tight", children: meeting.title }), meeting.hostDisplayName && _jsxs("p", { className: "text-sm text-text-muted mt-1", children: ["Hosted by ", meeting.hostDisplayName] }), _jsxs("div", { className: "mt-6 grid sm:grid-cols-2 gap-6", children: [_jsxs("div", { children: [_jsx("div", { className: "aspect-video bg-black/80 rounded-md overflow-hidden flex items-center justify-center", children: stream && cameraOn ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: true, className: "w-full h-full object-cover" })) : (_jsx("p", { className: "text-white/70 text-sm", children: cameraOn ? "No camera preview" : "Camera is off" })) }), _jsxs("div", { className: "flex gap-2 mt-3", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: handleToggleMic, disabled: !stream, children: micOn ? "Mute mic" : "Unmute mic" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: handleToggleCamera, disabled: !stream, children: cameraOn ? "Turn camera off" : "Turn camera on" })] }), !supported && _jsx(Alert, { children: "This browser (or this page's connection) doesn't support camera/microphone access." }), error && _jsx(Alert, { children: error.message }), devices.cameras.length > 1 && (_jsxs("label", { className: "block text-sm mt-3", children: ["Camera", _jsx("select", { className: `${INPUT_CLASS} mt-1`, onChange: (e) => handleSwitchDevice("video", e.target.value), children: devices.cameras.map((d) => (_jsx("option", { value: d.deviceId, children: d.label || "Camera" }, d.deviceId))) })] })), devices.microphones.length > 1 && (_jsxs("label", { className: "block text-sm mt-3", children: ["Microphone", _jsx("select", { className: `${INPUT_CLASS} mt-1`, onChange: (e) => handleSwitchDevice("audio", e.target.value), children: devices.microphones.map((d) => (_jsx("option", { value: d.deviceId, children: d.label || "Microphone" }, d.deviceId))) })] }))] }), _jsxs("div", { className: "flex flex-col justify-center gap-3", children: [_jsx(FormField, { label: "Your name", htmlFor: "meet-name", children: _jsx("input", { id: "meet-name", type: "text", className: INPUT_CLASS, value: name, onChange: (e) => setName(e.target.value), placeholder: "Enter your name" }) }), _jsx(Button, { type: "button", className: "!w-auto", disabled: !name.trim(), onClick: handleJoin, children: "Join meeting" })] })] })] }));
115
+ }
116
+ /** `useEffect` cleanup helper for stopping a lobby preview stream on unmount/replacement - a named function
117
+ * (rather than an inline arrow) purely so its intent reads clearly at the call site. */
118
+ function stopOnUnmount(stream) {
119
+ return () => stopStream(stream);
120
+ }
@@ -0,0 +1,18 @@
1
+ /** One participant's video tile - used by both the grid and the focused/thumbnail-strip layouts in `_CallView.tsx`.
2
+ * Renders a placeholder (the participant's initial) instead of a `<video>` element whenever there's no stream or
3
+ * their camera is off, rather than showing a black/frozen frame. */
4
+ import React from "react";
5
+ export interface ParticipantTileProps {
6
+ name: string;
7
+ stream?: MediaStream | null;
8
+ /** The local participant's own tile must be `muted` (never plays back its own audio) - a real remote tile
9
+ * never sets this. */
10
+ isLocal?: boolean;
11
+ cameraOff?: boolean;
12
+ micMuted?: boolean;
13
+ /** Highlights this tile as the current presenter/focus. */
14
+ isFocused?: boolean;
15
+ onClick?: () => void;
16
+ className?: string;
17
+ }
18
+ export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, isFocused, onClick, className }: ParticipantTileProps): React.JSX.Element;
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ /** One participant's video tile - used by both the grid and the focused/thumbnail-strip layouts in `_CallView.tsx`.
7
+ * Renders a placeholder (the participant's initial) instead of a `<video>` element whenever there's no stream or
8
+ * their camera is off, rather than showing a black/frozen frame. */
9
+ import { useEffect, useRef } from "react";
10
+ function initialOf(name) {
11
+ return (Array.from(name.trim())[0] ?? "?").toUpperCase();
12
+ }
13
+ export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, isFocused, onClick, className }) {
14
+ const videoRef = useRef(null);
15
+ const showVideo = !!stream && !cameraOff;
16
+ useEffect(() => {
17
+ if (videoRef.current) {
18
+ /* v8 ignore next -- unreachable via real usage: the `<video>` element only ever renders at all when
19
+ `showVideo` (`!!stream && !cameraOff`) is true, so `stream` is always truthy whenever `videoRef.current`
20
+ is non-null here; the `?? null` exists purely to satisfy `srcObject`'s type, not for a real code path. */
21
+ videoRef.current.srcObject = stream ?? null;
22
+ }
23
+ }, [stream]);
24
+ return (_jsxs("div", { className: `relative overflow-hidden rounded-md bg-black/80 flex items-center justify-center min-h-[90px] ${isFocused ? "ring-2 ring-accent" : ""} ${className ?? ""}`, onClick: onClick, role: onClick ? "button" : undefined, tabIndex: onClick ? 0 : undefined, children: [showVideo ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: isLocal, className: "w-full h-full object-cover" })) : (_jsx("div", { className: "w-14 h-14 rounded-full bg-primary text-white flex items-center justify-center text-xl font-bold", "aria-hidden": "true", children: initialOf(name) })), _jsxs("div", { className: "absolute bottom-1.5 left-1.5 flex items-center gap-1.5 px-2 py-0.5 rounded bg-black/60 text-white text-xs", children: [micMuted && _jsx("span", { title: "Muted", children: "(muted)" }), _jsxs("span", { children: [name, isLocal ? " (you)" : ""] })] })] }));
25
+ }
@@ -0,0 +1,9 @@
1
+ import React, { PropsWithChildren } from "react";
2
+ import { Branding } from "@rapidmx/react-shared/branding/brandingApi.js";
3
+ export interface LayoutProps {
4
+ /** Supplied by the server's public page route (`PublicPageRoute`), which every `public` plugin app's route
5
+ * extends - see `booking-plugin`'s `apps/book/_layout.tsx`'s identical `LayoutProps` doc comment for the
6
+ * mechanism this mirrors. */
7
+ branding?: Branding;
8
+ }
9
+ export default function Layout({ children, branding }: PropsWithChildren<LayoutProps>): React.JSX.Element;
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { CUSTOM_STYLESHEET_LINK_ID } from "@rapidmx/react-shared/branding/useBranding.js";
3
+ export default function Layout({ children, branding }) {
4
+ const title = branding?.title || branding?.companyName ? `${branding?.title || branding?.companyName}: Meet` : "RapidMX: Meet";
5
+ const iconHref = branding?.iconUrl || branding?.logoUrl || "/images/logo.svg";
6
+ const stylesheetHref = branding?.stylesheetUrl;
7
+ return (_jsxs("html", { lang: "en", children: [_jsxs("head", { children: [_jsx("meta", { charSet: "utf-8" }), _jsx("meta", { name: "viewport", content: "width=device-width, initial-scale=1" }), _jsx("title", { children: title }), _jsx("link", { rel: "icon", type: "image/svg+xml", href: iconHref }), _jsx("link", { rel: "alternate icon", href: "/favicon.ico" }), stylesheetHref && _jsx("link", { rel: "stylesheet", href: stylesheetHref, id: CUSTOM_STYLESHEET_LINK_ID })] }), _jsx("body", { children: children })] }));
8
+ }
@@ -0,0 +1,47 @@
1
+ export type VideoMeetingVisibility = "private" | "public";
2
+ export type VideoMeetingStatus = "scheduled" | "active" | "ended" | "cancelled";
3
+ /** Mirrors `BaseVideoMeetingRoute.ts`'s `IceServerConfig` - already the exact shape `RTCPeerConnection`'s
4
+ * `iceServers` constructor option expects, passed straight through with no reshaping. */
5
+ export interface IceServerConfig {
6
+ urls: string;
7
+ username?: string;
8
+ credential?: string;
9
+ }
10
+ /** Mirrors `BaseVideoMeetingRoute.ts`'s `PublicVideoMeeting`. */
11
+ export interface PublicVideoMeeting {
12
+ uid: string;
13
+ title: string;
14
+ visibility: VideoMeetingVisibility;
15
+ status: VideoMeetingStatus;
16
+ hostDisplayName?: string;
17
+ }
18
+ /**
19
+ * Mirrors `BaseVideoMeetingRoute.ts`'s `VideoMeetingJoinResult`. `authenticated: true` means the visiting browser
20
+ * already held a valid session for a real RapidMX identity when it called `join()` - `token`/`expiresAt` are then
21
+ * omitted entirely (there is no guest token to hand over), and the caller (`[token].tsx`) must not write any
22
+ * cookie of its own before connecting to `/push`: the browser's own already-existing `jwt` session cookie already
23
+ * authenticates it there, with zero new client-side auth handling. `authenticated: false` (the common anonymous
24
+ * case) is unchanged from Phase 1: `token`/`expiresAt` are present, and `GuestSignalingClient` applies `token` as a
25
+ * cookie before connecting.
26
+ */
27
+ export interface VideoMeetingJoinResult {
28
+ meeting: PublicVideoMeeting;
29
+ iceServers: IceServerConfig[];
30
+ authenticated: boolean;
31
+ /** The identity to identify as on the signaling channel/mesh - the caller's own real uid when `authenticated`
32
+ * is `true`, otherwise a freshly minted synthetic `guest:<random>` uid. Always present. */
33
+ selfUid: string;
34
+ /** A short-lived guest JWT, usable against `/push`. Present only when `authenticated` is `false`. */
35
+ token?: string;
36
+ /** ISO 8601 instant `token` expires at. Present only when `authenticated` is `false`. */
37
+ expiresAt?: string;
38
+ }
39
+ /** Resolves a join token (an invitee's own link) or a public meeting's slug to its meeting info, ICE servers, and
40
+ * either a short-lived guest signaling token (the common anonymous case) or confirmation that the caller's own
41
+ * existing session already grants them signaling access (`authenticated: true` - see
42
+ * `VideoMeetingJoinResult`'s doc comment) - see `BaseVideoMeetingRoute.join()`'s own doc comment. Rejects with
43
+ * `ApiRequestError` (status `404`) for a stale/unknown/malformed token, cancelled meeting, or the wrong
44
+ * private/public token shape for what it's being used against - the caller (`[token].tsx`) shows a single,
45
+ * friendly "this meeting link isn't valid" state for all of those rather than trying to distinguish them, matching
46
+ * `BaseVideoMeetingRoute.requireMeetingByToken()`'s own explicit "never leak which almost-matched" posture. */
47
+ export declare function joinMeeting(token: string): Promise<VideoMeetingJoinResult>;
@@ -0,0 +1,23 @@
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
+ /** Resolves a join token (an invitee's own link) or a public meeting's slug to its meeting info, ICE servers, and
14
+ * either a short-lived guest signaling token (the common anonymous case) or confirmation that the caller's own
15
+ * existing session already grants them signaling access (`authenticated: true` - see
16
+ * `VideoMeetingJoinResult`'s doc comment) - see `BaseVideoMeetingRoute.join()`'s own doc comment. Rejects with
17
+ * `ApiRequestError` (status `404`) for a stale/unknown/malformed token, cancelled meeting, or the wrong
18
+ * private/public token shape for what it's being used against - the caller (`[token].tsx`) shows a single,
19
+ * friendly "this meeting link isn't valid" state for all of those rather than trying to distinguish them, matching
20
+ * `BaseVideoMeetingRoute.requireMeetingByToken()`'s own explicit "never leak which almost-matched" posture. */
21
+ export function joinMeeting(token) {
22
+ return apiFetch(`/mail/video-meetings/join/${encodeURIComponent(token)}`);
23
+ }
@@ -0,0 +1,5 @@
1
+ /** Reached only by a bare `/meet` visit with no token (e.g. a mistyped/incomplete link) - the real join/lobby flow
2
+ * lives at `apps/meet/[token].tsx`, `GET /meet/:token`. Mirrors `booking-plugin`'s identical `apps/book/index.tsx`.
3
+ * Replaces this plugin's Phase 1 one-line placeholder page (see `.claude/NOTES.md`). */
4
+ import React from "react";
5
+ export default function NoMeetingTokenPage(): React.JSX.Element;
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
3
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
4
+ import { MeetCard, MeetPageShell } from "./_MeetChrome.js";
5
+ export default function NoMeetingTokenPage() {
6
+ const { branding } = useBranding();
7
+ return (_jsx(MeetPageShell, { branding: branding, children: _jsx(MeetCard, { children: _jsx(Alert, { children: "No meeting link specified." }) }) }));
8
+ }
@@ -0,0 +1,24 @@
1
+ import React from "react";
2
+ import { VideoMeetingDetail } from "@rapidmx/react-shared/videoconf/videoMeetingsApi.js";
3
+ /** The title a freshly created personal room starts with - editable afterward like any other meeting's title. */
4
+ export declare const PERSONAL_ROOM_TITLE = "Personal Meeting Room";
5
+ export interface PersonalRoomCardProps {
6
+ mailboxUid: string;
7
+ /** The mailbox's personal room, as `index.tsx`'s `findPersonalRoom()` identified it - `undefined` when the
8
+ * mailbox has none yet. See that function's own doc comment for the "oldest non-cancelled public meeting"
9
+ * convention this plugin uses instead of a dedicated field/route (`.claude/NOTES.md`'s Phase 4 entry). */
10
+ room: VideoMeetingDetail | undefined;
11
+ /** Called with the freshly created room (`createVideoMeeting()`'s own response, reshaped to a
12
+ * `VideoMeetingDetail`) so the parent's meeting list picks it up without a full reload. */
13
+ onCreated: (meeting: VideoMeetingDetail) => void;
14
+ /** Called after a successful rename, so the parent's meeting list (and this card, on its next render) show
15
+ * the new title without a full reload. */
16
+ onRenamed: (uid: string, title: string) => void;
17
+ }
18
+ /**
19
+ * The "get and manage your own persistent public link" card - see this plugin's `.claude/NOTES.md` Phase 4 entry
20
+ * for why a personal room is simply the mailbox's own oldest non-cancelled `PUBLIC` `VideoMeeting`, not a distinct
21
+ * model concept. Offers to create one when there is none, and otherwise shows its (editable) title and its
22
+ * shareable link, exactly as `create()`/`findById()` compute it server-side.
23
+ */
24
+ export default function PersonalRoomCard({ mailboxUid, room, onCreated, onRenamed }: PersonalRoomCardProps): React.JSX.Element;