@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.
- package/LICENSE +382 -0
- package/README.md +38 -0
- package/apps/meet/[token].tsx +144 -0
- package/apps/meet/_CallControls.tsx +69 -0
- package/apps/meet/_CallView.tsx +338 -0
- package/apps/meet/_MeetChrome.tsx +30 -0
- package/apps/meet/_MeetLobby.tsx +223 -0
- package/apps/meet/_ParticipantTile.tsx +66 -0
- package/apps/meet/_layout.tsx +34 -0
- package/apps/meet/_meetApi.ts +66 -0
- package/apps/meet/index.tsx +22 -0
- package/apps/settings-video-conferencing/_PersonalRoomCard.tsx +140 -0
- package/apps/settings-video-conferencing/_layout.tsx +47 -0
- package/apps/settings-video-conferencing/index.tsx +192 -0
- package/apps/shared/media/activeSpeaker.ts +45 -0
- package/apps/shared/media/deviceMedia.ts +141 -0
- package/apps/shared/media/levelMeter.ts +91 -0
- package/apps/shared/push/GuestSignalingClient.ts +338 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +350 -0
- package/apps/shared/webrtc/realPeerConnection.ts +37 -0
- package/apps/shared/webrtc/types.ts +90 -0
- package/dist/apps/meet/[token].d.ts +33 -0
- package/dist/apps/meet/[token].js +101 -0
- package/dist/apps/meet/_CallControls.d.ts +18 -0
- package/dist/apps/meet/_CallControls.js +6 -0
- package/dist/apps/meet/_CallView.d.ts +41 -0
- package/dist/apps/meet/_CallView.js +225 -0
- package/dist/apps/meet/_MeetChrome.d.ts +13 -0
- package/dist/apps/meet/_MeetChrome.js +10 -0
- package/dist/apps/meet/_MeetLobby.d.ts +29 -0
- package/dist/apps/meet/_MeetLobby.js +120 -0
- package/dist/apps/meet/_ParticipantTile.d.ts +18 -0
- package/dist/apps/meet/_ParticipantTile.js +25 -0
- package/dist/apps/meet/_layout.d.ts +9 -0
- package/dist/apps/meet/_layout.js +8 -0
- package/dist/apps/meet/_meetApi.d.ts +47 -0
- package/dist/apps/meet/_meetApi.js +23 -0
- package/dist/apps/meet/index.d.ts +5 -0
- package/dist/apps/meet/index.js +8 -0
- package/dist/apps/settings-video-conferencing/_PersonalRoomCard.d.ts +24 -0
- package/dist/apps/settings-video-conferencing/_PersonalRoomCard.js +77 -0
- package/dist/apps/settings-video-conferencing/_layout.d.ts +16 -0
- package/dist/apps/settings-video-conferencing/_layout.js +8 -0
- package/dist/apps/settings-video-conferencing/index.d.ts +16 -0
- package/dist/apps/settings-video-conferencing/index.js +92 -0
- package/dist/apps/shared/media/activeSpeaker.d.ts +20 -0
- package/dist/apps/shared/media/activeSpeaker.js +39 -0
- package/dist/apps/shared/media/deviceMedia.d.ts +65 -0
- package/dist/apps/shared/media/deviceMedia.js +110 -0
- package/dist/apps/shared/media/levelMeter.d.ts +40 -0
- package/dist/apps/shared/media/levelMeter.js +61 -0
- package/dist/apps/shared/push/GuestSignalingClient.d.ts +89 -0
- package/dist/apps/shared/push/GuestSignalingClient.js +291 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +97 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +262 -0
- package/dist/apps/shared/webrtc/realPeerConnection.d.ts +7 -0
- package/dist/apps/shared/webrtc/realPeerConnection.js +26 -0
- package/dist/apps/shared/webrtc/types.d.ts +94 -0
- package/dist/apps/shared/webrtc/types.js +9 -0
- package/dist/lib/index.js +25 -0
- package/dist/lib/index.js.map +1 -0
- package/dist/lib/models/mongo/VideoMeetingInviteeMongo.js +87 -0
- package/dist/lib/models/mongo/VideoMeetingInviteeMongo.js.map +1 -0
- package/dist/lib/models/mongo/VideoMeetingMongo.js +126 -0
- package/dist/lib/models/mongo/VideoMeetingMongo.js.map +1 -0
- package/dist/lib/models/sql/VideoMeetingInviteeSQL.js +87 -0
- package/dist/lib/models/sql/VideoMeetingInviteeSQL.js.map +1 -0
- package/dist/lib/models/sql/VideoMeetingSQL.js +127 -0
- package/dist/lib/models/sql/VideoMeetingSQL.js.map +1 -0
- package/dist/lib/models/types.js +19 -0
- package/dist/lib/models/types.js.map +1 -0
- package/dist/lib/mongo.js +13 -0
- package/dist/lib/mongo.js.map +1 -0
- package/dist/lib/routes/BaseVideoMeetingRoute.js +743 -0
- package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -0
- package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js +31 -0
- package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js.map +1 -0
- package/dist/lib/routes/sql/VideoMeetingRouteSQL.js +31 -0
- package/dist/lib/routes/sql/VideoMeetingRouteSQL.js.map +1 -0
- package/dist/lib/sql.js +13 -0
- package/dist/lib/sql.js.map +1 -0
- package/dist/lib/util/BookingIntegrationUtils.js +115 -0
- package/dist/lib/util/BookingIntegrationUtils.js.map +1 -0
- package/dist/lib/util/IceServerUtils.js +68 -0
- package/dist/lib/util/IceServerUtils.js.map +1 -0
- package/dist/lib/util/PublicUrlUtils.js +37 -0
- package/dist/lib/util/PublicUrlUtils.js.map +1 -0
- package/dist/lib/util/RouteAccessUtils.js +32 -0
- package/dist/lib/util/RouteAccessUtils.js.map +1 -0
- package/dist/lib/util/TokenUtils.js +33 -0
- package/dist/lib/util/TokenUtils.js.map +1 -0
- package/dist/types/index.d.ts +20 -0
- package/dist/types/models/mongo/VideoMeetingInviteeMongo.d.ts +16 -0
- package/dist/types/models/mongo/VideoMeetingMongo.d.ts +23 -0
- package/dist/types/models/sql/VideoMeetingInviteeSQL.d.ts +16 -0
- package/dist/types/models/sql/VideoMeetingSQL.d.ts +23 -0
- package/dist/types/models/types.d.ts +131 -0
- package/dist/types/mongo.d.ts +8 -0
- package/dist/types/routes/BaseVideoMeetingRoute.d.ts +368 -0
- package/dist/types/routes/mongo/VideoMeetingRouteMongo.d.ts +10 -0
- package/dist/types/routes/sql/VideoMeetingRouteSQL.d.ts +10 -0
- package/dist/types/sql.d.ts +8 -0
- package/dist/types/util/BookingIntegrationUtils.d.ts +51 -0
- package/dist/types/util/IceServerUtils.d.ts +57 -0
- package/dist/types/util/PublicUrlUtils.d.ts +11 -0
- package/dist/types/util/RouteAccessUtils.d.ts +8 -0
- package/dist/types/util/TokenUtils.d.ts +23 -0
- 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'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…</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
|
+
}
|