mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-21 04:39: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,
|
||||
VStack,
|
||||
HStack,
|
||||
Badge,
|
||||
} from "@chakra-ui/react";
|
||||
import { LuLink } from "react-icons/lu";
|
||||
import { RoomDetails } from "../../../api";
|
||||
import { RoomActionsMenu } from "./RoomActionsMenu";
|
||||
import {
|
||||
getPlatformDisplayName,
|
||||
getPlatformColor,
|
||||
} from "../../../lib/videoPlatforms";
|
||||
|
||||
interface RoomCardsProps {
|
||||
rooms: RoomDetails[];
|
||||
@@ -93,6 +98,15 @@ export function RoomCards({
|
||||
/>
|
||||
</Flex>
|
||||
<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 && (
|
||||
<HStack gap={2}>
|
||||
<Text fontWeight="500">Zulip:</Text>
|
||||
|
||||
@@ -7,10 +7,15 @@ import {
|
||||
IconButton,
|
||||
Text,
|
||||
Spinner,
|
||||
Badge,
|
||||
} from "@chakra-ui/react";
|
||||
import { LuLink } from "react-icons/lu";
|
||||
import { RoomDetails } from "../../../api";
|
||||
import { RoomActionsMenu } from "./RoomActionsMenu";
|
||||
import {
|
||||
getPlatformDisplayName,
|
||||
getPlatformColor,
|
||||
} from "../../../lib/videoPlatforms";
|
||||
|
||||
interface RoomTableProps {
|
||||
rooms: RoomDetails[];
|
||||
@@ -92,16 +97,19 @@ export function RoomTable({
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader width="250px" fontWeight="600">
|
||||
<Table.ColumnHeader width="200px" fontWeight="600">
|
||||
Room Name
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader width="250px" fontWeight="600">
|
||||
Zulip
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader width="150px" fontWeight="600">
|
||||
Room Size
|
||||
<Table.ColumnHeader width="120px" fontWeight="600">
|
||||
Platform
|
||||
</Table.ColumnHeader>
|
||||
<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
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader
|
||||
@@ -116,6 +124,14 @@ export function RoomTable({
|
||||
<Table.Cell>
|
||||
<Link href={`/${room.name}`}>{room.name}</Link>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge
|
||||
colorPalette={getPlatformColor(room.platform)}
|
||||
size="sm"
|
||||
>
|
||||
{getPlatformDisplayName(room.platform)}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{getZulipDisplay(
|
||||
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";
|
||||
|
||||
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 { useCallback, useEffect, useState } from "react";
|
||||
import { Box, Spinner } from "@chakra-ui/react";
|
||||
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";
|
||||
import VideoPlatformEmbed from "../lib/videoPlatforms/VideoPlatformEmbed";
|
||||
|
||||
export type RoomDetails = {
|
||||
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) {
|
||||
const wherebyLoaded = useWhereby();
|
||||
const wherebyRef = useRef<HTMLElement>(null);
|
||||
const [platformReady, setPlatformReady] = useState(false);
|
||||
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]);
|
||||
|
||||
const handlePlatformReady = useCallback(() => {
|
||||
setPlatformReady(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isLoading &&
|
||||
@@ -279,16 +40,6 @@ export default function Room(details: RoomDetails) {
|
||||
}
|
||||
}, [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
|
||||
@@ -304,23 +55,15 @@ export default function Room(details: RoomDetails) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!meeting?.response || !isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{roomUrl && meetingId && wherebyLoaded && (
|
||||
<>
|
||||
<whereby-embed
|
||||
ref={wherebyRef}
|
||||
room={roomUrl}
|
||||
style={{ width: "100vw", height: "100vh" }}
|
||||
/>
|
||||
{recordingType && recordingTypeRequiresConsent(recordingType) && (
|
||||
<ConsentDialogButton
|
||||
meetingId={meetingId}
|
||||
wherebyRef={wherebyRef}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
<VideoPlatformEmbed
|
||||
meeting={meeting.response}
|
||||
onLeave={handleLeave}
|
||||
onReady={handlePlatformReady}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,6 +99,9 @@ export const $CreateRoom = {
|
||||
type: "string",
|
||||
title: "Webhook Secret",
|
||||
},
|
||||
platform: {
|
||||
$ref: "#/components/schemas/VideoPlatform",
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
required: [
|
||||
@@ -113,6 +116,7 @@ export const $CreateRoom = {
|
||||
"is_shared",
|
||||
"webhook_url",
|
||||
"webhook_secret",
|
||||
"platform",
|
||||
],
|
||||
title: "CreateRoom",
|
||||
} as const;
|
||||
@@ -697,6 +701,58 @@ export const $HTTPValidationError = {
|
||||
title: "HTTPValidationError",
|
||||
} 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 = {
|
||||
properties: {
|
||||
id: {
|
||||
@@ -960,6 +1016,10 @@ export const $Room = {
|
||||
type: "boolean",
|
||||
title: "Is Shared",
|
||||
},
|
||||
platform: {
|
||||
$ref: "#/components/schemas/VideoPlatform",
|
||||
default: "whereby",
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
required: [
|
||||
@@ -1030,12 +1090,30 @@ export const $RoomDetails = {
|
||||
type: "boolean",
|
||||
title: "Is Shared",
|
||||
},
|
||||
platform: {
|
||||
$ref: "#/components/schemas/VideoPlatform",
|
||||
default: "whereby",
|
||||
},
|
||||
webhook_url: {
|
||||
type: "string",
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Webhook Url",
|
||||
},
|
||||
webhook_secret: {
|
||||
type: "string",
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Webhook Secret",
|
||||
},
|
||||
},
|
||||
@@ -1091,10 +1169,17 @@ export const $SearchResponse = {
|
||||
description: "Total number of search results",
|
||||
},
|
||||
query: {
|
||||
type: "string",
|
||||
minLength: 0,
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
description: "Search query text",
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Query",
|
||||
description: "Search query text",
|
||||
},
|
||||
limit: {
|
||||
type: "integer",
|
||||
@@ -1111,7 +1196,7 @@ export const $SearchResponse = {
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
required: ["results", "total", "query", "limit", "offset"],
|
||||
required: ["results", "total", "limit", "offset"],
|
||||
title: "SearchResponse",
|
||||
} as const;
|
||||
|
||||
@@ -1449,6 +1534,9 @@ export const $UpdateRoom = {
|
||||
type: "string",
|
||||
title: "Webhook Secret",
|
||||
},
|
||||
platform: {
|
||||
$ref: "#/components/schemas/VideoPlatform",
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
required: [
|
||||
@@ -1463,6 +1551,7 @@ export const $UpdateRoom = {
|
||||
"is_shared",
|
||||
"webhook_url",
|
||||
"webhook_secret",
|
||||
"platform",
|
||||
],
|
||||
title: "UpdateRoom",
|
||||
} as const;
|
||||
@@ -1641,6 +1730,12 @@ export const $ValidationError = {
|
||||
title: "ValidationError",
|
||||
} as const;
|
||||
|
||||
export const $VideoPlatform = {
|
||||
type: "string",
|
||||
enum: ["whereby", "jitsi"],
|
||||
title: "VideoPlatform",
|
||||
} as const;
|
||||
|
||||
export const $WebhookTestResult = {
|
||||
properties: {
|
||||
success: {
|
||||
|
||||
@@ -74,6 +74,11 @@ import type {
|
||||
V1ZulipGetTopicsResponse,
|
||||
V1WherebyWebhookData,
|
||||
V1WherebyWebhookResponse,
|
||||
V1JitsiEventsWebhookData,
|
||||
V1JitsiEventsWebhookResponse,
|
||||
V1JibriRecordingCompleteData,
|
||||
V1JibriRecordingCompleteResponse,
|
||||
V1JitsiHealthCheckResponse,
|
||||
} from "./types.gen";
|
||||
|
||||
export class DefaultService {
|
||||
@@ -255,7 +260,6 @@ export class DefaultService {
|
||||
|
||||
/**
|
||||
* Rooms Test Webhook
|
||||
* Test webhook configuration by sending a sample payload.
|
||||
* @param data The data for the request.
|
||||
* @param data.roomId
|
||||
* @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;
|
||||
webhook_url: string;
|
||||
webhook_secret: string;
|
||||
platform: VideoPlatform;
|
||||
};
|
||||
|
||||
export type CreateTranscript = {
|
||||
@@ -125,6 +126,22 @@ export type HTTPValidationError = {
|
||||
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 = {
|
||||
id: string;
|
||||
room_name: string;
|
||||
@@ -176,6 +193,7 @@ export type Room = {
|
||||
recording_type: string;
|
||||
recording_trigger: string;
|
||||
is_shared: boolean;
|
||||
platform?: VideoPlatform;
|
||||
};
|
||||
|
||||
export type RoomDetails = {
|
||||
@@ -191,8 +209,9 @@ export type RoomDetails = {
|
||||
recording_type: string;
|
||||
recording_trigger: string;
|
||||
is_shared: boolean;
|
||||
webhook_url: string;
|
||||
webhook_secret: string;
|
||||
platform?: VideoPlatform;
|
||||
webhook_url: string | null;
|
||||
webhook_secret: string | null;
|
||||
};
|
||||
|
||||
export type RtcOffer = {
|
||||
@@ -206,10 +225,7 @@ export type SearchResponse = {
|
||||
* Total number of search results
|
||||
*/
|
||||
total: number;
|
||||
/**
|
||||
* Search query text
|
||||
*/
|
||||
query: string;
|
||||
query?: string | null;
|
||||
/**
|
||||
* Results per page
|
||||
*/
|
||||
@@ -302,6 +318,7 @@ export type UpdateRoom = {
|
||||
is_shared: boolean;
|
||||
webhook_url: string;
|
||||
webhook_secret: string;
|
||||
platform: VideoPlatform;
|
||||
};
|
||||
|
||||
export type UpdateTranscript = {
|
||||
@@ -328,6 +345,8 @@ export type ValidationError = {
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type VideoPlatform = "whereby" | "jitsi";
|
||||
|
||||
export type WebhookTestResult = {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
@@ -621,6 +640,20 @@ export type V1WherebyWebhookData = {
|
||||
|
||||
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 = {
|
||||
"/metrics": {
|
||||
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",
|
||||
auth_callback_url: "http://localhost:3000/auth-callback",
|
||||
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