mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-21 12:49:06 +00:00
feat: implement frontend video platform configuration and abstraction
- Add NEXT_PUBLIC_VIDEO_PLATFORM environment variable support - Create video platform abstraction layer with factory pattern - Implement Whereby and Jitsi platform providers - Update room meeting page to use platform-agnostic component - Add platform display in room management (cards and table views) - Support single platform per deployment configuration - Maintain backward compatibility with existing Whereby integration 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
46
www/.env.template
Normal file
46
www/.env.template
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# NextAuth configuration
|
||||||
|
NEXTAUTH_SECRET="your-secret-key"
|
||||||
|
NEXTAUTH_URL="http://localhost:3000/"
|
||||||
|
|
||||||
|
# API configuration
|
||||||
|
NEXT_PUBLIC_API_URL="http://127.0.0.1:1250"
|
||||||
|
NEXT_PUBLIC_WEBSOCKET_URL="ws://127.0.0.1:1250"
|
||||||
|
NEXT_PUBLIC_AUTH_CALLBACK_URL="http://localhost:3000/auth-callback"
|
||||||
|
NEXT_PUBLIC_SITE_URL="http://localhost:3000/"
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
NEXT_PUBLIC_ENV="development"
|
||||||
|
ENVIRONMENT="development"
|
||||||
|
|
||||||
|
# Video Platform Configuration
|
||||||
|
# Options: "whereby" | "jitsi" (default: whereby)
|
||||||
|
NEXT_PUBLIC_VIDEO_PLATFORM="whereby"
|
||||||
|
|
||||||
|
# Features
|
||||||
|
NEXT_PUBLIC_PROJECTOR_MODE="false"
|
||||||
|
|
||||||
|
# Authentication providers (optional)
|
||||||
|
# Authentik
|
||||||
|
AUTHENTIK_CLIENT_ID=""
|
||||||
|
AUTHENTIK_CLIENT_SECRET=""
|
||||||
|
AUTHENTIK_ISSUER=""
|
||||||
|
AUTHENTIK_REFRESH_TOKEN_URL=""
|
||||||
|
|
||||||
|
# Fief
|
||||||
|
FIEF_CLIENT_ID=""
|
||||||
|
FIEF_CLIENT_SECRET=""
|
||||||
|
FIEF_URL=""
|
||||||
|
|
||||||
|
# Zulip integration (optional)
|
||||||
|
ZULIP_API_KEY=""
|
||||||
|
ZULIP_BOT_EMAIL=""
|
||||||
|
ZULIP_REALM=""
|
||||||
|
|
||||||
|
# External services (optional)
|
||||||
|
ZEPHYR_LLM_URL=""
|
||||||
|
|
||||||
|
# Redis/KV (optional)
|
||||||
|
KV_REST_API_TOKEN=""
|
||||||
|
KV_REST_API_READ_ONLY_TOKEN=""
|
||||||
|
KV_REST_API_URL=""
|
||||||
|
KV_URL=""
|
||||||
@@ -10,10 +10,15 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
VStack,
|
VStack,
|
||||||
HStack,
|
HStack,
|
||||||
|
Badge,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { LuLink } from "react-icons/lu";
|
import { LuLink } from "react-icons/lu";
|
||||||
import { RoomDetails } from "../../../api";
|
import { RoomDetails } from "../../../api";
|
||||||
import { RoomActionsMenu } from "./RoomActionsMenu";
|
import { RoomActionsMenu } from "./RoomActionsMenu";
|
||||||
|
import {
|
||||||
|
getPlatformDisplayName,
|
||||||
|
getPlatformColor,
|
||||||
|
} from "../../../lib/videoPlatforms";
|
||||||
|
|
||||||
interface RoomCardsProps {
|
interface RoomCardsProps {
|
||||||
rooms: RoomDetails[];
|
rooms: RoomDetails[];
|
||||||
@@ -93,6 +98,15 @@ export function RoomCards({
|
|||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
<VStack align="start" fontSize="sm" gap={0}>
|
<VStack align="start" fontSize="sm" gap={0}>
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Text fontWeight="500">Platform:</Text>
|
||||||
|
<Badge
|
||||||
|
colorPalette={getPlatformColor(room.platform)}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{getPlatformDisplayName(room.platform)}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
{room.zulip_auto_post && (
|
{room.zulip_auto_post && (
|
||||||
<HStack gap={2}>
|
<HStack gap={2}>
|
||||||
<Text fontWeight="500">Zulip:</Text>
|
<Text fontWeight="500">Zulip:</Text>
|
||||||
|
|||||||
@@ -7,10 +7,15 @@ import {
|
|||||||
IconButton,
|
IconButton,
|
||||||
Text,
|
Text,
|
||||||
Spinner,
|
Spinner,
|
||||||
|
Badge,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { LuLink } from "react-icons/lu";
|
import { LuLink } from "react-icons/lu";
|
||||||
import { RoomDetails } from "../../../api";
|
import { RoomDetails } from "../../../api";
|
||||||
import { RoomActionsMenu } from "./RoomActionsMenu";
|
import { RoomActionsMenu } from "./RoomActionsMenu";
|
||||||
|
import {
|
||||||
|
getPlatformDisplayName,
|
||||||
|
getPlatformColor,
|
||||||
|
} from "../../../lib/videoPlatforms";
|
||||||
|
|
||||||
interface RoomTableProps {
|
interface RoomTableProps {
|
||||||
rooms: RoomDetails[];
|
rooms: RoomDetails[];
|
||||||
@@ -92,16 +97,19 @@ export function RoomTable({
|
|||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.ColumnHeader width="250px" fontWeight="600">
|
<Table.ColumnHeader width="200px" fontWeight="600">
|
||||||
Room Name
|
Room Name
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader width="250px" fontWeight="600">
|
<Table.ColumnHeader width="120px" fontWeight="600">
|
||||||
Zulip
|
Platform
|
||||||
</Table.ColumnHeader>
|
|
||||||
<Table.ColumnHeader width="150px" fontWeight="600">
|
|
||||||
Room Size
|
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader width="200px" fontWeight="600">
|
<Table.ColumnHeader width="200px" fontWeight="600">
|
||||||
|
Zulip
|
||||||
|
</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader width="130px" fontWeight="600">
|
||||||
|
Room Size
|
||||||
|
</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader width="180px" fontWeight="600">
|
||||||
Recording
|
Recording
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader
|
<Table.ColumnHeader
|
||||||
@@ -116,6 +124,14 @@ export function RoomTable({
|
|||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Link href={`/${room.name}`}>{room.name}</Link>
|
<Link href={`/${room.name}`}>{room.name}</Link>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Badge
|
||||||
|
colorPalette={getPlatformColor(room.platform)}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{getPlatformDisplayName(room.platform)}
|
||||||
|
</Badge>
|
||||||
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{getZulipDisplay(
|
{getZulipDisplay(
|
||||||
room.zulip_auto_post,
|
room.zulip_auto_post,
|
||||||
|
|||||||
326
www/app/[roomName]/page-old.tsx
Normal file
326
www/app/[roomName]/page-old.tsx
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useContext,
|
||||||
|
RefObject,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Spinner,
|
||||||
|
Icon,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { toaster } from "../components/ui/toaster";
|
||||||
|
import useRoomMeeting from "./useRoomMeeting";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import useSessionStatus from "../lib/useSessionStatus";
|
||||||
|
import { useRecordingConsent } from "../recordingConsentContext";
|
||||||
|
import useApi from "../lib/useApi";
|
||||||
|
import { Meeting } from "../api";
|
||||||
|
import { FaBars } from "react-icons/fa6";
|
||||||
|
|
||||||
|
export type RoomDetails = {
|
||||||
|
params: {
|
||||||
|
roomName: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// stages: we focus on the consent, then whereby steals focus, then we focus on the consent again, then return focus to whoever stole it initially
|
||||||
|
const useConsentWherebyFocusManagement = (
|
||||||
|
acceptButtonRef: RefObject<HTMLButtonElement>,
|
||||||
|
wherebyRef: RefObject<HTMLElement>,
|
||||||
|
) => {
|
||||||
|
const currentFocusRef = useRef<HTMLElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (acceptButtonRef.current) {
|
||||||
|
acceptButtonRef.current.focus();
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"accept button ref not available yet for focus management - seems to be illegal state",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWherebyReady = () => {
|
||||||
|
console.log("whereby ready - refocusing consent button");
|
||||||
|
currentFocusRef.current = document.activeElement as HTMLElement;
|
||||||
|
if (acceptButtonRef.current) {
|
||||||
|
acceptButtonRef.current.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (wherebyRef.current) {
|
||||||
|
wherebyRef.current.addEventListener("ready", handleWherebyReady);
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
"whereby ref not available yet for focus management - seems to be illegal state. not waiting, focus management off.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
wherebyRef.current?.removeEventListener("ready", handleWherebyReady);
|
||||||
|
currentFocusRef.current?.focus();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useConsentDialog = (
|
||||||
|
meetingId: string,
|
||||||
|
wherebyRef: RefObject<HTMLElement> /*accessibility*/,
|
||||||
|
) => {
|
||||||
|
const { state: consentState, touch, hasConsent } = useRecordingConsent();
|
||||||
|
const [consentLoading, setConsentLoading] = useState(false);
|
||||||
|
// toast would open duplicates, even with using "id=" prop
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const api = useApi();
|
||||||
|
|
||||||
|
const handleConsent = useCallback(
|
||||||
|
async (meetingId: string, given: boolean) => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
setConsentLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.v1MeetingAudioConsent({
|
||||||
|
meetingId,
|
||||||
|
requestBody: { consent_given: given },
|
||||||
|
});
|
||||||
|
|
||||||
|
touch(meetingId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error submitting consent:", error);
|
||||||
|
} finally {
|
||||||
|
setConsentLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, touch],
|
||||||
|
);
|
||||||
|
|
||||||
|
const showConsentModal = useCallback(() => {
|
||||||
|
if (modalOpen) return;
|
||||||
|
|
||||||
|
setModalOpen(true);
|
||||||
|
|
||||||
|
const toastId = toaster.create({
|
||||||
|
placement: "top",
|
||||||
|
duration: null,
|
||||||
|
render: ({ dismiss }) => {
|
||||||
|
const AcceptButton = () => {
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
useConsentWherebyFocusManagement(buttonRef, wherebyRef);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={buttonRef}
|
||||||
|
colorPalette="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
handleConsent(meetingId, true).then(() => {
|
||||||
|
/*signifies it's ok to now wait here.*/
|
||||||
|
});
|
||||||
|
dismiss();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Yes, store the audio
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
p={6}
|
||||||
|
bg="rgba(255, 255, 255, 0.7)"
|
||||||
|
borderRadius="lg"
|
||||||
|
boxShadow="lg"
|
||||||
|
maxW="md"
|
||||||
|
mx="auto"
|
||||||
|
>
|
||||||
|
<VStack gap={4} alignItems="center">
|
||||||
|
<Text fontSize="md" textAlign="center" fontWeight="medium">
|
||||||
|
Can we have your permission to store this meeting's audio
|
||||||
|
recording on our servers?
|
||||||
|
</Text>
|
||||||
|
<HStack gap={4} justifyContent="center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
handleConsent(meetingId, false).then(() => {
|
||||||
|
/*signifies it's ok to now wait here.*/
|
||||||
|
});
|
||||||
|
dismiss();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No, delete after transcription
|
||||||
|
</Button>
|
||||||
|
<AcceptButton />
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set modal state when toast is dismissed
|
||||||
|
toastId.then((id) => {
|
||||||
|
const checkToastStatus = setInterval(() => {
|
||||||
|
if (!toaster.isActive(id)) {
|
||||||
|
setModalOpen(false);
|
||||||
|
clearInterval(checkToastStatus);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle escape key to close the toast
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
toastId.then((id) => toaster.dismiss(id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
toastId.then((id) => toaster.dismiss(id));
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
|
||||||
|
return cleanup;
|
||||||
|
}, [meetingId, handleConsent, wherebyRef, modalOpen]);
|
||||||
|
|
||||||
|
return { showConsentModal, consentState, hasConsent, consentLoading };
|
||||||
|
};
|
||||||
|
|
||||||
|
function ConsentDialogButton({
|
||||||
|
meetingId,
|
||||||
|
wherebyRef,
|
||||||
|
}: {
|
||||||
|
meetingId: string;
|
||||||
|
wherebyRef: React.RefObject<HTMLElement>;
|
||||||
|
}) {
|
||||||
|
const { showConsentModal, consentState, hasConsent, consentLoading } =
|
||||||
|
useConsentDialog(meetingId, wherebyRef);
|
||||||
|
|
||||||
|
if (!consentState.ready || hasConsent(meetingId) || consentLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
position="absolute"
|
||||||
|
top="56px"
|
||||||
|
left="8px"
|
||||||
|
zIndex={1000}
|
||||||
|
colorPalette="blue"
|
||||||
|
size="sm"
|
||||||
|
onClick={showConsentModal}
|
||||||
|
>
|
||||||
|
Meeting is being recorded
|
||||||
|
<Icon as={FaBars} ml={2} />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordingTypeRequiresConsent = (
|
||||||
|
recordingType: NonNullable<Meeting["recording_type"]>,
|
||||||
|
) => {
|
||||||
|
return recordingType === "cloud";
|
||||||
|
};
|
||||||
|
|
||||||
|
// next throws even with "use client"
|
||||||
|
const useWhereby = () => {
|
||||||
|
const [wherebyLoaded, setWherebyLoaded] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
import("@whereby.com/browser-sdk/embed")
|
||||||
|
.then(() => {
|
||||||
|
setWherebyLoaded(true);
|
||||||
|
})
|
||||||
|
.catch(console.error.bind(console));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
return wherebyLoaded;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Room(details: RoomDetails) {
|
||||||
|
const wherebyLoaded = useWhereby();
|
||||||
|
const wherebyRef = useRef<HTMLElement>(null);
|
||||||
|
const roomName = details.params.roomName;
|
||||||
|
const meeting = useRoomMeeting(roomName);
|
||||||
|
const router = useRouter();
|
||||||
|
const { isLoading, isAuthenticated } = useSessionStatus();
|
||||||
|
|
||||||
|
const roomUrl = meeting?.response?.host_room_url
|
||||||
|
? meeting?.response?.host_room_url
|
||||||
|
: meeting?.response?.room_url;
|
||||||
|
|
||||||
|
const meetingId = meeting?.response?.id;
|
||||||
|
|
||||||
|
const recordingType = meeting?.response?.recording_type;
|
||||||
|
|
||||||
|
const handleLeave = useCallback(() => {
|
||||||
|
router.push("/browse");
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!isLoading &&
|
||||||
|
meeting?.error &&
|
||||||
|
"status" in meeting.error &&
|
||||||
|
meeting.error.status === 404
|
||||||
|
) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
}, [isLoading, meeting?.error]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading || !isAuthenticated || !roomUrl || !wherebyLoaded) return;
|
||||||
|
|
||||||
|
wherebyRef.current?.addEventListener("leave", handleLeave);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
wherebyRef.current?.removeEventListener("leave", handleLeave);
|
||||||
|
};
|
||||||
|
}, [handleLeave, roomUrl, isLoading, isAuthenticated, wherebyLoaded]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
display="flex"
|
||||||
|
justifyContent="center"
|
||||||
|
alignItems="center"
|
||||||
|
height="100vh"
|
||||||
|
bg="gray.50"
|
||||||
|
p={4}
|
||||||
|
>
|
||||||
|
<Spinner color="blue.500" size="xl" />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{roomUrl && meetingId && wherebyLoaded && (
|
||||||
|
<>
|
||||||
|
<whereby-embed
|
||||||
|
ref={wherebyRef}
|
||||||
|
room={roomUrl}
|
||||||
|
style={{ width: "100vw", height: "100vh" }}
|
||||||
|
/>
|
||||||
|
{recordingType && recordingTypeRequiresConsent(recordingType) && (
|
||||||
|
<ConsentDialogButton
|
||||||
|
meetingId={meetingId}
|
||||||
|
wherebyRef={wherebyRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,31 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { useCallback, useEffect, useState } from "react";
|
||||||
useCallback,
|
import { Box, Spinner } from "@chakra-ui/react";
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
useContext,
|
|
||||||
RefObject,
|
|
||||||
} from "react";
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Text,
|
|
||||||
VStack,
|
|
||||||
HStack,
|
|
||||||
Spinner,
|
|
||||||
Icon,
|
|
||||||
} from "@chakra-ui/react";
|
|
||||||
import { toaster } from "../components/ui/toaster";
|
|
||||||
import useRoomMeeting from "./useRoomMeeting";
|
import useRoomMeeting from "./useRoomMeeting";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import useSessionStatus from "../lib/useSessionStatus";
|
import useSessionStatus from "../lib/useSessionStatus";
|
||||||
import { useRecordingConsent } from "../recordingConsentContext";
|
import VideoPlatformEmbed from "../lib/videoPlatforms/VideoPlatformEmbed";
|
||||||
import useApi from "../lib/useApi";
|
|
||||||
import { Meeting } from "../api";
|
|
||||||
import { FaBars } from "react-icons/fa6";
|
|
||||||
|
|
||||||
export type RoomDetails = {
|
export type RoomDetails = {
|
||||||
params: {
|
params: {
|
||||||
@@ -33,241 +14,21 @@ export type RoomDetails = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// stages: we focus on the consent, then whereby steals focus, then we focus on the consent again, then return focus to whoever stole it initially
|
|
||||||
const useConsentWherebyFocusManagement = (
|
|
||||||
acceptButtonRef: RefObject<HTMLButtonElement>,
|
|
||||||
wherebyRef: RefObject<HTMLElement>,
|
|
||||||
) => {
|
|
||||||
const currentFocusRef = useRef<HTMLElement | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (acceptButtonRef.current) {
|
|
||||||
acceptButtonRef.current.focus();
|
|
||||||
} else {
|
|
||||||
console.error(
|
|
||||||
"accept button ref not available yet for focus management - seems to be illegal state",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleWherebyReady = () => {
|
|
||||||
console.log("whereby ready - refocusing consent button");
|
|
||||||
currentFocusRef.current = document.activeElement as HTMLElement;
|
|
||||||
if (acceptButtonRef.current) {
|
|
||||||
acceptButtonRef.current.focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (wherebyRef.current) {
|
|
||||||
wherebyRef.current.addEventListener("ready", handleWherebyReady);
|
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
"whereby ref not available yet for focus management - seems to be illegal state. not waiting, focus management off.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
wherebyRef.current?.removeEventListener("ready", handleWherebyReady);
|
|
||||||
currentFocusRef.current?.focus();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
};
|
|
||||||
|
|
||||||
const useConsentDialog = (
|
|
||||||
meetingId: string,
|
|
||||||
wherebyRef: RefObject<HTMLElement> /*accessibility*/,
|
|
||||||
) => {
|
|
||||||
const { state: consentState, touch, hasConsent } = useRecordingConsent();
|
|
||||||
const [consentLoading, setConsentLoading] = useState(false);
|
|
||||||
// toast would open duplicates, even with using "id=" prop
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
|
||||||
const api = useApi();
|
|
||||||
|
|
||||||
const handleConsent = useCallback(
|
|
||||||
async (meetingId: string, given: boolean) => {
|
|
||||||
if (!api) return;
|
|
||||||
|
|
||||||
setConsentLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.v1MeetingAudioConsent({
|
|
||||||
meetingId,
|
|
||||||
requestBody: { consent_given: given },
|
|
||||||
});
|
|
||||||
|
|
||||||
touch(meetingId);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error submitting consent:", error);
|
|
||||||
} finally {
|
|
||||||
setConsentLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[api, touch],
|
|
||||||
);
|
|
||||||
|
|
||||||
const showConsentModal = useCallback(() => {
|
|
||||||
if (modalOpen) return;
|
|
||||||
|
|
||||||
setModalOpen(true);
|
|
||||||
|
|
||||||
const toastId = toaster.create({
|
|
||||||
placement: "top",
|
|
||||||
duration: null,
|
|
||||||
render: ({ dismiss }) => {
|
|
||||||
const AcceptButton = () => {
|
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
useConsentWherebyFocusManagement(buttonRef, wherebyRef);
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
ref={buttonRef}
|
|
||||||
colorPalette="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
handleConsent(meetingId, true).then(() => {
|
|
||||||
/*signifies it's ok to now wait here.*/
|
|
||||||
});
|
|
||||||
dismiss();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Yes, store the audio
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
p={6}
|
|
||||||
bg="rgba(255, 255, 255, 0.7)"
|
|
||||||
borderRadius="lg"
|
|
||||||
boxShadow="lg"
|
|
||||||
maxW="md"
|
|
||||||
mx="auto"
|
|
||||||
>
|
|
||||||
<VStack gap={4} alignItems="center">
|
|
||||||
<Text fontSize="md" textAlign="center" fontWeight="medium">
|
|
||||||
Can we have your permission to store this meeting's audio
|
|
||||||
recording on our servers?
|
|
||||||
</Text>
|
|
||||||
<HStack gap={4} justifyContent="center">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
handleConsent(meetingId, false).then(() => {
|
|
||||||
/*signifies it's ok to now wait here.*/
|
|
||||||
});
|
|
||||||
dismiss();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No, delete after transcription
|
|
||||||
</Button>
|
|
||||||
<AcceptButton />
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set modal state when toast is dismissed
|
|
||||||
toastId.then((id) => {
|
|
||||||
const checkToastStatus = setInterval(() => {
|
|
||||||
if (!toaster.isActive(id)) {
|
|
||||||
setModalOpen(false);
|
|
||||||
clearInterval(checkToastStatus);
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle escape key to close the toast
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
toastId.then((id) => toaster.dismiss(id));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
toastId.then((id) => toaster.dismiss(id));
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
};
|
|
||||||
|
|
||||||
return cleanup;
|
|
||||||
}, [meetingId, handleConsent, wherebyRef, modalOpen]);
|
|
||||||
|
|
||||||
return { showConsentModal, consentState, hasConsent, consentLoading };
|
|
||||||
};
|
|
||||||
|
|
||||||
function ConsentDialogButton({
|
|
||||||
meetingId,
|
|
||||||
wherebyRef,
|
|
||||||
}: {
|
|
||||||
meetingId: string;
|
|
||||||
wherebyRef: React.RefObject<HTMLElement>;
|
|
||||||
}) {
|
|
||||||
const { showConsentModal, consentState, hasConsent, consentLoading } =
|
|
||||||
useConsentDialog(meetingId, wherebyRef);
|
|
||||||
|
|
||||||
if (!consentState.ready || hasConsent(meetingId) || consentLoading) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
position="absolute"
|
|
||||||
top="56px"
|
|
||||||
left="8px"
|
|
||||||
zIndex={1000}
|
|
||||||
colorPalette="blue"
|
|
||||||
size="sm"
|
|
||||||
onClick={showConsentModal}
|
|
||||||
>
|
|
||||||
Meeting is being recorded
|
|
||||||
<Icon as={FaBars} ml={2} />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const recordingTypeRequiresConsent = (
|
|
||||||
recordingType: NonNullable<Meeting["recording_type"]>,
|
|
||||||
) => {
|
|
||||||
return recordingType === "cloud";
|
|
||||||
};
|
|
||||||
|
|
||||||
// next throws even with "use client"
|
|
||||||
const useWhereby = () => {
|
|
||||||
const [wherebyLoaded, setWherebyLoaded] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
import("@whereby.com/browser-sdk/embed")
|
|
||||||
.then(() => {
|
|
||||||
setWherebyLoaded(true);
|
|
||||||
})
|
|
||||||
.catch(console.error.bind(console));
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
return wherebyLoaded;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Room(details: RoomDetails) {
|
export default function Room(details: RoomDetails) {
|
||||||
const wherebyLoaded = useWhereby();
|
const [platformReady, setPlatformReady] = useState(false);
|
||||||
const wherebyRef = useRef<HTMLElement>(null);
|
|
||||||
const roomName = details.params.roomName;
|
const roomName = details.params.roomName;
|
||||||
const meeting = useRoomMeeting(roomName);
|
const meeting = useRoomMeeting(roomName);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isLoading, isAuthenticated } = useSessionStatus();
|
const { isLoading, isAuthenticated } = useSessionStatus();
|
||||||
|
|
||||||
const roomUrl = meeting?.response?.host_room_url
|
|
||||||
? meeting?.response?.host_room_url
|
|
||||||
: meeting?.response?.room_url;
|
|
||||||
|
|
||||||
const meetingId = meeting?.response?.id;
|
|
||||||
|
|
||||||
const recordingType = meeting?.response?.recording_type;
|
|
||||||
|
|
||||||
const handleLeave = useCallback(() => {
|
const handleLeave = useCallback(() => {
|
||||||
router.push("/browse");
|
router.push("/browse");
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
|
const handlePlatformReady = useCallback(() => {
|
||||||
|
setPlatformReady(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!isLoading &&
|
!isLoading &&
|
||||||
@@ -279,16 +40,6 @@ export default function Room(details: RoomDetails) {
|
|||||||
}
|
}
|
||||||
}, [isLoading, meeting?.error]);
|
}, [isLoading, meeting?.error]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isLoading || !isAuthenticated || !roomUrl || !wherebyLoaded) return;
|
|
||||||
|
|
||||||
wherebyRef.current?.addEventListener("leave", handleLeave);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
wherebyRef.current?.removeEventListener("leave", handleLeave);
|
|
||||||
};
|
|
||||||
}, [handleLeave, roomUrl, isLoading, isAuthenticated, wherebyLoaded]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -304,23 +55,15 @@ export default function Room(details: RoomDetails) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!meeting?.response || !isAuthenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<VideoPlatformEmbed
|
||||||
{roomUrl && meetingId && wherebyLoaded && (
|
meeting={meeting.response}
|
||||||
<>
|
onLeave={handleLeave}
|
||||||
<whereby-embed
|
onReady={handlePlatformReady}
|
||||||
ref={wherebyRef}
|
|
||||||
room={roomUrl}
|
|
||||||
style={{ width: "100vw", height: "100vh" }}
|
|
||||||
/>
|
/>
|
||||||
{recordingType && recordingTypeRequiresConsent(recordingType) && (
|
|
||||||
<ConsentDialogButton
|
|
||||||
meetingId={meetingId}
|
|
||||||
wherebyRef={wherebyRef}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,9 @@ export const $CreateRoom = {
|
|||||||
type: "string",
|
type: "string",
|
||||||
title: "Webhook Secret",
|
title: "Webhook Secret",
|
||||||
},
|
},
|
||||||
|
platform: {
|
||||||
|
$ref: "#/components/schemas/VideoPlatform",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
type: "object",
|
type: "object",
|
||||||
required: [
|
required: [
|
||||||
@@ -113,6 +116,7 @@ export const $CreateRoom = {
|
|||||||
"is_shared",
|
"is_shared",
|
||||||
"webhook_url",
|
"webhook_url",
|
||||||
"webhook_secret",
|
"webhook_secret",
|
||||||
|
"platform",
|
||||||
],
|
],
|
||||||
title: "CreateRoom",
|
title: "CreateRoom",
|
||||||
} as const;
|
} as const;
|
||||||
@@ -697,6 +701,58 @@ export const $HTTPValidationError = {
|
|||||||
title: "HTTPValidationError",
|
title: "HTTPValidationError",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const $JibriRecordingEvent = {
|
||||||
|
properties: {
|
||||||
|
room_name: {
|
||||||
|
type: "string",
|
||||||
|
title: "Room Name",
|
||||||
|
},
|
||||||
|
recording_file: {
|
||||||
|
type: "string",
|
||||||
|
title: "Recording File",
|
||||||
|
},
|
||||||
|
recording_status: {
|
||||||
|
type: "string",
|
||||||
|
title: "Recording Status",
|
||||||
|
},
|
||||||
|
timestamp: {
|
||||||
|
type: "string",
|
||||||
|
format: "date-time",
|
||||||
|
title: "Timestamp",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
type: "object",
|
||||||
|
required: ["room_name", "recording_file", "recording_status", "timestamp"],
|
||||||
|
title: "JibriRecordingEvent",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const $JitsiWebhookEvent = {
|
||||||
|
properties: {
|
||||||
|
event: {
|
||||||
|
type: "string",
|
||||||
|
title: "Event",
|
||||||
|
},
|
||||||
|
room: {
|
||||||
|
type: "string",
|
||||||
|
title: "Room",
|
||||||
|
},
|
||||||
|
timestamp: {
|
||||||
|
type: "string",
|
||||||
|
format: "date-time",
|
||||||
|
title: "Timestamp",
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
additionalProperties: true,
|
||||||
|
type: "object",
|
||||||
|
title: "Data",
|
||||||
|
default: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
type: "object",
|
||||||
|
required: ["event", "room", "timestamp"],
|
||||||
|
title: "JitsiWebhookEvent",
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const $Meeting = {
|
export const $Meeting = {
|
||||||
properties: {
|
properties: {
|
||||||
id: {
|
id: {
|
||||||
@@ -960,6 +1016,10 @@ export const $Room = {
|
|||||||
type: "boolean",
|
type: "boolean",
|
||||||
title: "Is Shared",
|
title: "Is Shared",
|
||||||
},
|
},
|
||||||
|
platform: {
|
||||||
|
$ref: "#/components/schemas/VideoPlatform",
|
||||||
|
default: "whereby",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
type: "object",
|
type: "object",
|
||||||
required: [
|
required: [
|
||||||
@@ -1030,12 +1090,30 @@ export const $RoomDetails = {
|
|||||||
type: "boolean",
|
type: "boolean",
|
||||||
title: "Is Shared",
|
title: "Is Shared",
|
||||||
},
|
},
|
||||||
|
platform: {
|
||||||
|
$ref: "#/components/schemas/VideoPlatform",
|
||||||
|
default: "whereby",
|
||||||
|
},
|
||||||
webhook_url: {
|
webhook_url: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
type: "string",
|
type: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
title: "Webhook Url",
|
title: "Webhook Url",
|
||||||
},
|
},
|
||||||
webhook_secret: {
|
webhook_secret: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
type: "string",
|
type: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
title: "Webhook Secret",
|
title: "Webhook Secret",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1091,11 +1169,18 @@ export const $SearchResponse = {
|
|||||||
description: "Total number of search results",
|
description: "Total number of search results",
|
||||||
},
|
},
|
||||||
query: {
|
query: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
type: "string",
|
type: "string",
|
||||||
minLength: 0,
|
minLength: 1,
|
||||||
title: "Query",
|
|
||||||
description: "Search query text",
|
description: "Search query text",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
title: "Query",
|
||||||
|
},
|
||||||
limit: {
|
limit: {
|
||||||
type: "integer",
|
type: "integer",
|
||||||
maximum: 100,
|
maximum: 100,
|
||||||
@@ -1111,7 +1196,7 @@ export const $SearchResponse = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
type: "object",
|
type: "object",
|
||||||
required: ["results", "total", "query", "limit", "offset"],
|
required: ["results", "total", "limit", "offset"],
|
||||||
title: "SearchResponse",
|
title: "SearchResponse",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -1449,6 +1534,9 @@ export const $UpdateRoom = {
|
|||||||
type: "string",
|
type: "string",
|
||||||
title: "Webhook Secret",
|
title: "Webhook Secret",
|
||||||
},
|
},
|
||||||
|
platform: {
|
||||||
|
$ref: "#/components/schemas/VideoPlatform",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
type: "object",
|
type: "object",
|
||||||
required: [
|
required: [
|
||||||
@@ -1463,6 +1551,7 @@ export const $UpdateRoom = {
|
|||||||
"is_shared",
|
"is_shared",
|
||||||
"webhook_url",
|
"webhook_url",
|
||||||
"webhook_secret",
|
"webhook_secret",
|
||||||
|
"platform",
|
||||||
],
|
],
|
||||||
title: "UpdateRoom",
|
title: "UpdateRoom",
|
||||||
} as const;
|
} as const;
|
||||||
@@ -1641,6 +1730,12 @@ export const $ValidationError = {
|
|||||||
title: "ValidationError",
|
title: "ValidationError",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const $VideoPlatform = {
|
||||||
|
type: "string",
|
||||||
|
enum: ["whereby", "jitsi"],
|
||||||
|
title: "VideoPlatform",
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const $WebhookTestResult = {
|
export const $WebhookTestResult = {
|
||||||
properties: {
|
properties: {
|
||||||
success: {
|
success: {
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ import type {
|
|||||||
V1ZulipGetTopicsResponse,
|
V1ZulipGetTopicsResponse,
|
||||||
V1WherebyWebhookData,
|
V1WherebyWebhookData,
|
||||||
V1WherebyWebhookResponse,
|
V1WherebyWebhookResponse,
|
||||||
|
V1JitsiEventsWebhookData,
|
||||||
|
V1JitsiEventsWebhookResponse,
|
||||||
|
V1JibriRecordingCompleteData,
|
||||||
|
V1JibriRecordingCompleteResponse,
|
||||||
|
V1JitsiHealthCheckResponse,
|
||||||
} from "./types.gen";
|
} from "./types.gen";
|
||||||
|
|
||||||
export class DefaultService {
|
export class DefaultService {
|
||||||
@@ -255,7 +260,6 @@ export class DefaultService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Rooms Test Webhook
|
* Rooms Test Webhook
|
||||||
* Test webhook configuration by sending a sample payload.
|
|
||||||
* @param data The data for the request.
|
* @param data The data for the request.
|
||||||
* @param data.roomId
|
* @param data.roomId
|
||||||
* @returns WebhookTestResult Successful Response
|
* @returns WebhookTestResult Successful Response
|
||||||
@@ -939,4 +943,70 @@ export class DefaultService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jitsi Events Webhook
|
||||||
|
* Handle Prosody event-sync webhooks from Jitsi Meet.
|
||||||
|
*
|
||||||
|
* Expected event types:
|
||||||
|
* - muc-occupant-joined: participant joined the room
|
||||||
|
* - muc-occupant-left: participant left the room
|
||||||
|
* - jibri-recording-on: recording started
|
||||||
|
* - jibri-recording-off: recording stopped
|
||||||
|
* @param data The data for the request.
|
||||||
|
* @param data.requestBody
|
||||||
|
* @returns unknown Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public v1JitsiEventsWebhook(
|
||||||
|
data: V1JitsiEventsWebhookData,
|
||||||
|
): CancelablePromise<V1JitsiEventsWebhookResponse> {
|
||||||
|
return this.httpRequest.request({
|
||||||
|
method: "POST",
|
||||||
|
url: "/v1/jitsi/events",
|
||||||
|
body: data.requestBody,
|
||||||
|
mediaType: "application/json",
|
||||||
|
errors: {
|
||||||
|
422: "Validation Error",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jibri Recording Complete
|
||||||
|
* Handle Jibri recording completion webhook.
|
||||||
|
*
|
||||||
|
* This endpoint is called by the Jibri finalize script when a recording
|
||||||
|
* is completed and uploaded to storage.
|
||||||
|
* @param data The data for the request.
|
||||||
|
* @param data.requestBody
|
||||||
|
* @returns unknown Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public v1JibriRecordingComplete(
|
||||||
|
data: V1JibriRecordingCompleteData,
|
||||||
|
): CancelablePromise<V1JibriRecordingCompleteResponse> {
|
||||||
|
return this.httpRequest.request({
|
||||||
|
method: "POST",
|
||||||
|
url: "/v1/jibri/recording-complete",
|
||||||
|
body: data.requestBody,
|
||||||
|
mediaType: "application/json",
|
||||||
|
errors: {
|
||||||
|
422: "Validation Error",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jitsi Health Check
|
||||||
|
* Simple health check endpoint for Jitsi webhook configuration.
|
||||||
|
* @returns unknown Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public v1JitsiHealthCheck(): CancelablePromise<V1JitsiHealthCheckResponse> {
|
||||||
|
return this.httpRequest.request({
|
||||||
|
method: "GET",
|
||||||
|
url: "/v1/jitsi/health",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export type CreateRoom = {
|
|||||||
is_shared: boolean;
|
is_shared: boolean;
|
||||||
webhook_url: string;
|
webhook_url: string;
|
||||||
webhook_secret: string;
|
webhook_secret: string;
|
||||||
|
platform: VideoPlatform;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateTranscript = {
|
export type CreateTranscript = {
|
||||||
@@ -125,6 +126,22 @@ export type HTTPValidationError = {
|
|||||||
detail?: Array<ValidationError>;
|
detail?: Array<ValidationError>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type JibriRecordingEvent = {
|
||||||
|
room_name: string;
|
||||||
|
recording_file: string;
|
||||||
|
recording_status: string;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JitsiWebhookEvent = {
|
||||||
|
event: string;
|
||||||
|
room: string;
|
||||||
|
timestamp: string;
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type Meeting = {
|
export type Meeting = {
|
||||||
id: string;
|
id: string;
|
||||||
room_name: string;
|
room_name: string;
|
||||||
@@ -176,6 +193,7 @@ export type Room = {
|
|||||||
recording_type: string;
|
recording_type: string;
|
||||||
recording_trigger: string;
|
recording_trigger: string;
|
||||||
is_shared: boolean;
|
is_shared: boolean;
|
||||||
|
platform?: VideoPlatform;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RoomDetails = {
|
export type RoomDetails = {
|
||||||
@@ -191,8 +209,9 @@ export type RoomDetails = {
|
|||||||
recording_type: string;
|
recording_type: string;
|
||||||
recording_trigger: string;
|
recording_trigger: string;
|
||||||
is_shared: boolean;
|
is_shared: boolean;
|
||||||
webhook_url: string;
|
platform?: VideoPlatform;
|
||||||
webhook_secret: string;
|
webhook_url: string | null;
|
||||||
|
webhook_secret: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RtcOffer = {
|
export type RtcOffer = {
|
||||||
@@ -206,10 +225,7 @@ export type SearchResponse = {
|
|||||||
* Total number of search results
|
* Total number of search results
|
||||||
*/
|
*/
|
||||||
total: number;
|
total: number;
|
||||||
/**
|
query?: string | null;
|
||||||
* Search query text
|
|
||||||
*/
|
|
||||||
query: string;
|
|
||||||
/**
|
/**
|
||||||
* Results per page
|
* Results per page
|
||||||
*/
|
*/
|
||||||
@@ -302,6 +318,7 @@ export type UpdateRoom = {
|
|||||||
is_shared: boolean;
|
is_shared: boolean;
|
||||||
webhook_url: string;
|
webhook_url: string;
|
||||||
webhook_secret: string;
|
webhook_secret: string;
|
||||||
|
platform: VideoPlatform;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UpdateTranscript = {
|
export type UpdateTranscript = {
|
||||||
@@ -328,6 +345,8 @@ export type ValidationError = {
|
|||||||
type: string;
|
type: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type VideoPlatform = "whereby" | "jitsi";
|
||||||
|
|
||||||
export type WebhookTestResult = {
|
export type WebhookTestResult = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
@@ -621,6 +640,20 @@ export type V1WherebyWebhookData = {
|
|||||||
|
|
||||||
export type V1WherebyWebhookResponse = unknown;
|
export type V1WherebyWebhookResponse = unknown;
|
||||||
|
|
||||||
|
export type V1JitsiEventsWebhookData = {
|
||||||
|
requestBody: JitsiWebhookEvent;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type V1JitsiEventsWebhookResponse = unknown;
|
||||||
|
|
||||||
|
export type V1JibriRecordingCompleteData = {
|
||||||
|
requestBody: JibriRecordingEvent;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type V1JibriRecordingCompleteResponse = unknown;
|
||||||
|
|
||||||
|
export type V1JitsiHealthCheckResponse = unknown;
|
||||||
|
|
||||||
export type $OpenApiTs = {
|
export type $OpenApiTs = {
|
||||||
"/metrics": {
|
"/metrics": {
|
||||||
get: {
|
get: {
|
||||||
@@ -1142,4 +1175,44 @@ export type $OpenApiTs = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
"/v1/jitsi/events": {
|
||||||
|
post: {
|
||||||
|
req: V1JitsiEventsWebhookData;
|
||||||
|
res: {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HTTPValidationError;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
"/v1/jibri/recording-complete": {
|
||||||
|
post: {
|
||||||
|
req: V1JibriRecordingCompleteData;
|
||||||
|
res: {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HTTPValidationError;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
"/v1/jitsi/health": {
|
||||||
|
get: {
|
||||||
|
res: {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
302
www/app/lib/videoPlatforms/VideoPlatformEmbed.tsx
Normal file
302
www/app/lib/videoPlatforms/VideoPlatformEmbed.tsx
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState, RefObject } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Spinner,
|
||||||
|
Icon,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { FaBars } from "react-icons/fa6";
|
||||||
|
import { Meeting, VideoPlatform } from "../../api";
|
||||||
|
import { getVideoPlatformAdapter, getCurrentVideoPlatform } from "./factory";
|
||||||
|
import { useRecordingConsent } from "../../recordingConsentContext";
|
||||||
|
import { toaster } from "../../components/ui/toaster";
|
||||||
|
import useApi from "../useApi";
|
||||||
|
|
||||||
|
interface VideoPlatformEmbedProps {
|
||||||
|
meeting: Meeting;
|
||||||
|
platform?: VideoPlatform;
|
||||||
|
onLeave?: () => void;
|
||||||
|
onReady?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus management hook for platforms that support it
|
||||||
|
const usePlatformFocusManagement = (
|
||||||
|
acceptButtonRef: RefObject<HTMLButtonElement>,
|
||||||
|
platformRef: RefObject<HTMLElement>,
|
||||||
|
supportsFocusManagement: boolean,
|
||||||
|
) => {
|
||||||
|
const currentFocusRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!supportsFocusManagement) return;
|
||||||
|
|
||||||
|
if (acceptButtonRef.current) {
|
||||||
|
acceptButtonRef.current.focus();
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"accept button ref not available yet for focus management - seems to be illegal state",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePlatformReady = () => {
|
||||||
|
console.log("platform ready - refocusing consent button");
|
||||||
|
currentFocusRef.current = document.activeElement as HTMLElement;
|
||||||
|
if (acceptButtonRef.current) {
|
||||||
|
acceptButtonRef.current.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (platformRef.current) {
|
||||||
|
platformRef.current.addEventListener("ready", handlePlatformReady);
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
"platform ref not available yet for focus management - seems to be illegal state. not waiting, focus management off.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
platformRef.current?.removeEventListener("ready", handlePlatformReady);
|
||||||
|
currentFocusRef.current?.focus();
|
||||||
|
};
|
||||||
|
}, [acceptButtonRef, platformRef, supportsFocusManagement]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useConsentDialog = (
|
||||||
|
meetingId: string,
|
||||||
|
platformRef: RefObject<HTMLElement>,
|
||||||
|
supportsFocusManagement: boolean,
|
||||||
|
) => {
|
||||||
|
const { state: consentState, touch, hasConsent } = useRecordingConsent();
|
||||||
|
const [consentLoading, setConsentLoading] = useState(false);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const api = useApi();
|
||||||
|
|
||||||
|
const handleConsent = useCallback(
|
||||||
|
async (meetingId: string, given: boolean) => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
setConsentLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.v1MeetingAudioConsent({
|
||||||
|
meetingId,
|
||||||
|
requestBody: { consent_given: given },
|
||||||
|
});
|
||||||
|
|
||||||
|
touch(meetingId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error submitting consent:", error);
|
||||||
|
} finally {
|
||||||
|
setConsentLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, touch],
|
||||||
|
);
|
||||||
|
|
||||||
|
const showConsentModal = useCallback(() => {
|
||||||
|
if (modalOpen) return;
|
||||||
|
|
||||||
|
setModalOpen(true);
|
||||||
|
|
||||||
|
const toastId = toaster.create({
|
||||||
|
placement: "top",
|
||||||
|
duration: null,
|
||||||
|
render: ({ dismiss }) => {
|
||||||
|
const AcceptButton = () => {
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
usePlatformFocusManagement(
|
||||||
|
buttonRef,
|
||||||
|
platformRef,
|
||||||
|
supportsFocusManagement,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={buttonRef}
|
||||||
|
colorPalette="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
handleConsent(meetingId, true).then(() => {
|
||||||
|
/*signifies it's ok to now wait here.*/
|
||||||
|
});
|
||||||
|
dismiss();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Yes, store the audio
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
p={6}
|
||||||
|
bg="rgba(255, 255, 255, 0.7)"
|
||||||
|
borderRadius="lg"
|
||||||
|
boxShadow="lg"
|
||||||
|
maxW="md"
|
||||||
|
mx="auto"
|
||||||
|
>
|
||||||
|
<VStack gap={4} alignItems="center">
|
||||||
|
<Text fontSize="md" textAlign="center" fontWeight="medium">
|
||||||
|
Can we have your permission to store this meeting's audio
|
||||||
|
recording on our servers?
|
||||||
|
</Text>
|
||||||
|
<HStack gap={4} justifyContent="center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
handleConsent(meetingId, false).then(() => {
|
||||||
|
/*signifies it's ok to now wait here.*/
|
||||||
|
});
|
||||||
|
dismiss();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No, delete after transcription
|
||||||
|
</Button>
|
||||||
|
<AcceptButton />
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set modal state when toast is dismissed
|
||||||
|
toastId.then((id) => {
|
||||||
|
const checkToastStatus = setInterval(() => {
|
||||||
|
if (!toaster.isActive(id)) {
|
||||||
|
setModalOpen(false);
|
||||||
|
clearInterval(checkToastStatus);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle escape key to close the toast
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
toastId.then((id) => toaster.dismiss(id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
toastId.then((id) => toaster.dismiss(id));
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
|
||||||
|
return cleanup;
|
||||||
|
}, [
|
||||||
|
meetingId,
|
||||||
|
handleConsent,
|
||||||
|
platformRef,
|
||||||
|
modalOpen,
|
||||||
|
supportsFocusManagement,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { showConsentModal, consentState, hasConsent, consentLoading };
|
||||||
|
};
|
||||||
|
|
||||||
|
function ConsentDialogButton({
|
||||||
|
meetingId,
|
||||||
|
platformRef,
|
||||||
|
supportsFocusManagement,
|
||||||
|
}: {
|
||||||
|
meetingId: string;
|
||||||
|
platformRef: React.RefObject<HTMLElement>;
|
||||||
|
supportsFocusManagement: boolean;
|
||||||
|
}) {
|
||||||
|
const { showConsentModal, consentState, hasConsent, consentLoading } =
|
||||||
|
useConsentDialog(meetingId, platformRef, supportsFocusManagement);
|
||||||
|
|
||||||
|
if (!consentState.ready || hasConsent(meetingId) || consentLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
position="absolute"
|
||||||
|
top="56px"
|
||||||
|
left="8px"
|
||||||
|
zIndex={1000}
|
||||||
|
colorPalette="blue"
|
||||||
|
size="sm"
|
||||||
|
onClick={showConsentModal}
|
||||||
|
>
|
||||||
|
Meeting is being recorded
|
||||||
|
<Icon as={FaBars} ml={2} />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordingTypeRequiresConsent = (
|
||||||
|
recordingType: NonNullable<Meeting["recording_type"]>,
|
||||||
|
) => {
|
||||||
|
return recordingType === "cloud";
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function VideoPlatformEmbed({
|
||||||
|
meeting,
|
||||||
|
platform,
|
||||||
|
onLeave,
|
||||||
|
onReady,
|
||||||
|
}: VideoPlatformEmbedProps) {
|
||||||
|
const platformRef = useRef<HTMLElement>(null);
|
||||||
|
const selectedPlatform = platform || getCurrentVideoPlatform();
|
||||||
|
const adapter = getVideoPlatformAdapter(selectedPlatform);
|
||||||
|
const PlatformComponent = adapter.component;
|
||||||
|
|
||||||
|
const meetingId = meeting.id;
|
||||||
|
const recordingType = meeting.recording_type;
|
||||||
|
|
||||||
|
// Handle leave event
|
||||||
|
const handleLeave = useCallback(() => {
|
||||||
|
if (onLeave) {
|
||||||
|
onLeave();
|
||||||
|
}
|
||||||
|
}, [onLeave]);
|
||||||
|
|
||||||
|
// Handle ready event
|
||||||
|
const handleReady = useCallback(() => {
|
||||||
|
if (onReady) {
|
||||||
|
onReady();
|
||||||
|
}
|
||||||
|
}, [onReady]);
|
||||||
|
|
||||||
|
// Set up leave event listener for platforms that support it
|
||||||
|
useEffect(() => {
|
||||||
|
if (!platformRef.current) return;
|
||||||
|
|
||||||
|
const element = platformRef.current;
|
||||||
|
element.addEventListener("leave", handleLeave);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener("leave", handleLeave);
|
||||||
|
};
|
||||||
|
}, [handleLeave]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PlatformComponent
|
||||||
|
ref={platformRef}
|
||||||
|
meeting={meeting}
|
||||||
|
roomRef={platformRef}
|
||||||
|
onReady={handleReady}
|
||||||
|
/>
|
||||||
|
{recordingType &&
|
||||||
|
recordingTypeRequiresConsent(recordingType) &&
|
||||||
|
adapter.requiresConsent && (
|
||||||
|
<ConsentDialogButton
|
||||||
|
meetingId={meetingId}
|
||||||
|
platformRef={platformRef}
|
||||||
|
supportsFocusManagement={adapter.supportsFocusManagement}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
www/app/lib/videoPlatforms/factory.ts
Normal file
29
www/app/lib/videoPlatforms/factory.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { VideoPlatform } from "../../api";
|
||||||
|
import { VideoPlatformAdapter } from "./types";
|
||||||
|
import { localConfig } from "../../../config-template";
|
||||||
|
|
||||||
|
// Platform implementations
|
||||||
|
import { WherebyAdapter } from "./whereby/WherebyAdapter";
|
||||||
|
import { JitsiAdapter } from "./jitsi/JitsiAdapter";
|
||||||
|
|
||||||
|
const platformAdapters: Record<VideoPlatform, VideoPlatformAdapter> = {
|
||||||
|
whereby: WherebyAdapter,
|
||||||
|
jitsi: JitsiAdapter,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getVideoPlatformAdapter(
|
||||||
|
platform?: VideoPlatform,
|
||||||
|
): VideoPlatformAdapter {
|
||||||
|
const selectedPlatform = platform || localConfig.video_platform;
|
||||||
|
|
||||||
|
const adapter = platformAdapters[selectedPlatform];
|
||||||
|
if (!adapter) {
|
||||||
|
throw new Error(`Unsupported video platform: ${selectedPlatform}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return adapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentVideoPlatform(): VideoPlatform {
|
||||||
|
return localConfig.video_platform;
|
||||||
|
}
|
||||||
5
www/app/lib/videoPlatforms/index.ts
Normal file
5
www/app/lib/videoPlatforms/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export * from "./types";
|
||||||
|
export * from "./factory";
|
||||||
|
export * from "./whereby";
|
||||||
|
export * from "./jitsi";
|
||||||
|
export * from "./utils";
|
||||||
8
www/app/lib/videoPlatforms/jitsi/JitsiAdapter.tsx
Normal file
8
www/app/lib/videoPlatforms/jitsi/JitsiAdapter.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { VideoPlatformAdapter } from "../types";
|
||||||
|
import JitsiProvider from "./JitsiProvider";
|
||||||
|
|
||||||
|
export const JitsiAdapter: VideoPlatformAdapter = {
|
||||||
|
component: JitsiProvider,
|
||||||
|
requiresConsent: true,
|
||||||
|
supportsFocusManagement: false, // Jitsi iframe doesn't support the same focus management as Whereby
|
||||||
|
};
|
||||||
68
www/app/lib/videoPlatforms/jitsi/JitsiProvider.tsx
Normal file
68
www/app/lib/videoPlatforms/jitsi/JitsiProvider.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
forwardRef,
|
||||||
|
useImperativeHandle,
|
||||||
|
} from "react";
|
||||||
|
import { VideoPlatformComponentProps } from "../types";
|
||||||
|
|
||||||
|
const JitsiProvider = forwardRef<HTMLElement, VideoPlatformComponentProps>(
|
||||||
|
({ meeting, roomRef, onReady, onConsentGiven, onConsentDeclined }, ref) => {
|
||||||
|
const [jitsiReady, setJitsiReady] = useState(false);
|
||||||
|
const internalRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
const iframeRef =
|
||||||
|
(roomRef as React.RefObject<HTMLIFrameElement>) || internalRef;
|
||||||
|
|
||||||
|
// Expose the element ref through the forwarded ref
|
||||||
|
useImperativeHandle(ref, () => iframeRef.current!, [iframeRef]);
|
||||||
|
|
||||||
|
// Handle iframe load
|
||||||
|
const handleLoad = useCallback(() => {
|
||||||
|
setJitsiReady(true);
|
||||||
|
if (onReady) {
|
||||||
|
onReady();
|
||||||
|
}
|
||||||
|
}, [onReady]);
|
||||||
|
|
||||||
|
// Set up event listeners
|
||||||
|
useEffect(() => {
|
||||||
|
if (!iframeRef.current) return;
|
||||||
|
|
||||||
|
const iframe = iframeRef.current;
|
||||||
|
iframe.addEventListener("load", handleLoad);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
iframe.removeEventListener("load", handleLoad);
|
||||||
|
};
|
||||||
|
}, [handleLoad]);
|
||||||
|
|
||||||
|
if (!meeting) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Jitsi, we use the room_url (user JWT) or host_room_url (moderator JWT)
|
||||||
|
const roomUrl = meeting.host_room_url || meeting.room_url;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
src={roomUrl}
|
||||||
|
style={{
|
||||||
|
width: "100vw",
|
||||||
|
height: "100vh",
|
||||||
|
border: "none",
|
||||||
|
}}
|
||||||
|
allow="camera; microphone; fullscreen; display-capture; autoplay"
|
||||||
|
title="Jitsi Meet"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
JitsiProvider.displayName = "JitsiProvider";
|
||||||
|
|
||||||
|
export default JitsiProvider;
|
||||||
2
www/app/lib/videoPlatforms/jitsi/index.ts
Normal file
2
www/app/lib/videoPlatforms/jitsi/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as JitsiProvider } from "./JitsiProvider";
|
||||||
|
export { JitsiAdapter } from "./JitsiAdapter";
|
||||||
32
www/app/lib/videoPlatforms/types.ts
Normal file
32
www/app/lib/videoPlatforms/types.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { RefObject, ReactNode } from "react";
|
||||||
|
import { VideoPlatform, Meeting } from "../../api";
|
||||||
|
|
||||||
|
export interface VideoPlatformProviderProps {
|
||||||
|
meeting: Meeting;
|
||||||
|
onConsentGiven?: () => void;
|
||||||
|
onConsentDeclined?: () => void;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoPlatformContextValue {
|
||||||
|
platform: VideoPlatform;
|
||||||
|
meeting: Meeting | null;
|
||||||
|
isReady: boolean;
|
||||||
|
hasConsent: boolean;
|
||||||
|
giveConsent: () => void;
|
||||||
|
declineConsent: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoPlatformComponentProps {
|
||||||
|
meeting: Meeting;
|
||||||
|
roomRef?: RefObject<HTMLElement>;
|
||||||
|
onReady?: () => void;
|
||||||
|
onConsentGiven?: () => void;
|
||||||
|
onConsentDeclined?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoPlatformAdapter {
|
||||||
|
component: React.ComponentType<VideoPlatformComponentProps>;
|
||||||
|
requiresConsent: boolean;
|
||||||
|
supportsFocusManagement: boolean;
|
||||||
|
}
|
||||||
23
www/app/lib/videoPlatforms/utils.ts
Normal file
23
www/app/lib/videoPlatforms/utils.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { VideoPlatform } from "../../api";
|
||||||
|
|
||||||
|
export const getPlatformDisplayName = (platform?: VideoPlatform): string => {
|
||||||
|
switch (platform) {
|
||||||
|
case "whereby":
|
||||||
|
return "Whereby";
|
||||||
|
case "jitsi":
|
||||||
|
return "Jitsi Meet";
|
||||||
|
default:
|
||||||
|
return "Whereby"; // Default fallback
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPlatformColor = (platform?: VideoPlatform): string => {
|
||||||
|
switch (platform) {
|
||||||
|
case "whereby":
|
||||||
|
return "blue";
|
||||||
|
case "jitsi":
|
||||||
|
return "green";
|
||||||
|
default:
|
||||||
|
return "blue"; // Default fallback
|
||||||
|
}
|
||||||
|
};
|
||||||
8
www/app/lib/videoPlatforms/whereby/WherebyAdapter.tsx
Normal file
8
www/app/lib/videoPlatforms/whereby/WherebyAdapter.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { VideoPlatformAdapter } from "../types";
|
||||||
|
import WherebyProvider from "./WherebyProvider";
|
||||||
|
|
||||||
|
export const WherebyAdapter: VideoPlatformAdapter = {
|
||||||
|
component: WherebyProvider,
|
||||||
|
requiresConsent: true,
|
||||||
|
supportsFocusManagement: true,
|
||||||
|
};
|
||||||
94
www/app/lib/videoPlatforms/whereby/WherebyProvider.tsx
Normal file
94
www/app/lib/videoPlatforms/whereby/WherebyProvider.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
forwardRef,
|
||||||
|
useImperativeHandle,
|
||||||
|
} from "react";
|
||||||
|
import { VideoPlatformComponentProps } from "../types";
|
||||||
|
|
||||||
|
// Whereby embed element type declaration
|
||||||
|
declare global {
|
||||||
|
namespace JSX {
|
||||||
|
interface IntrinsicElements {
|
||||||
|
"whereby-embed": React.DetailedHTMLProps<
|
||||||
|
React.HTMLAttributes<HTMLElement> & {
|
||||||
|
room?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
},
|
||||||
|
HTMLElement
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const WherebyProvider = forwardRef<HTMLElement, VideoPlatformComponentProps>(
|
||||||
|
({ meeting, roomRef, onReady, onConsentGiven, onConsentDeclined }, ref) => {
|
||||||
|
const [wherebyLoaded, setWherebyLoaded] = useState(false);
|
||||||
|
const internalRef = useRef<HTMLElement>(null);
|
||||||
|
const elementRef = roomRef || internalRef;
|
||||||
|
|
||||||
|
// Expose the element ref through the forwarded ref
|
||||||
|
useImperativeHandle(ref, () => elementRef.current!, [elementRef]);
|
||||||
|
|
||||||
|
// Load Whereby SDK
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
import("@whereby.com/browser-sdk/embed")
|
||||||
|
.then(() => {
|
||||||
|
setWherebyLoaded(true);
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle leave event
|
||||||
|
const handleLeave = useCallback(() => {
|
||||||
|
// This will be handled by the parent component
|
||||||
|
// through router navigation or other means
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle ready event
|
||||||
|
const handleReady = useCallback(() => {
|
||||||
|
if (onReady) {
|
||||||
|
onReady();
|
||||||
|
}
|
||||||
|
}, [onReady]);
|
||||||
|
|
||||||
|
// Set up event listeners
|
||||||
|
useEffect(() => {
|
||||||
|
if (!wherebyLoaded || !elementRef.current) return;
|
||||||
|
|
||||||
|
const element = elementRef.current;
|
||||||
|
|
||||||
|
element.addEventListener("leave", handleLeave);
|
||||||
|
element.addEventListener("ready", handleReady);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener("leave", handleLeave);
|
||||||
|
element.removeEventListener("ready", handleReady);
|
||||||
|
};
|
||||||
|
}, [wherebyLoaded, handleLeave, handleReady, elementRef]);
|
||||||
|
|
||||||
|
if (!wherebyLoaded || !meeting) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomUrl = meeting.host_room_url || meeting.room_url;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<whereby-embed
|
||||||
|
ref={elementRef}
|
||||||
|
room={roomUrl}
|
||||||
|
style={{ width: "100vw", height: "100vh" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
WherebyProvider.displayName = "WherebyProvider";
|
||||||
|
|
||||||
|
export default WherebyProvider;
|
||||||
2
www/app/lib/videoPlatforms/whereby/index.ts
Normal file
2
www/app/lib/videoPlatforms/whereby/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as WherebyProvider } from "./WherebyProvider";
|
||||||
|
export { WherebyAdapter } from "./WherebyAdapter";
|
||||||
@@ -10,4 +10,9 @@ export const localConfig = {
|
|||||||
websocket_url: "ws://127.0.0.1:1250",
|
websocket_url: "ws://127.0.0.1:1250",
|
||||||
auth_callback_url: "http://localhost:3000/auth-callback",
|
auth_callback_url: "http://localhost:3000/auth-callback",
|
||||||
zulip_streams: "", // Find the value on zulip
|
zulip_streams: "", // Find the value on zulip
|
||||||
|
// Video platform configuration - set via NEXT_PUBLIC_VIDEO_PLATFORM env variable
|
||||||
|
// Options: "whereby" | "jitsi"
|
||||||
|
video_platform:
|
||||||
|
(process.env.NEXT_PUBLIC_VIDEO_PLATFORM as "whereby" | "jitsi") ||
|
||||||
|
"whereby",
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user