diff --git a/www/.env.template b/www/.env.template new file mode 100644 index 00000000..d3b6f642 --- /dev/null +++ b/www/.env.template @@ -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="" \ No newline at end of file diff --git a/www/app/(app)/rooms/_components/RoomCards.tsx b/www/app/(app)/rooms/_components/RoomCards.tsx index 16748d90..a917a0c8 100644 --- a/www/app/(app)/rooms/_components/RoomCards.tsx +++ b/www/app/(app)/rooms/_components/RoomCards.tsx @@ -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({ /> + + Platform: + + {getPlatformDisplayName(room.platform)} + + {room.zulip_auto_post && ( Zulip: diff --git a/www/app/(app)/rooms/_components/RoomTable.tsx b/www/app/(app)/rooms/_components/RoomTable.tsx index 93d05b61..80329cd1 100644 --- a/www/app/(app)/rooms/_components/RoomTable.tsx +++ b/www/app/(app)/rooms/_components/RoomTable.tsx @@ -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({ - + Room Name - - Zulip - - - Room Size + + Platform + Zulip + + + Room Size + + Recording {room.name} + + + {getPlatformDisplayName(room.platform)} + + {getZulipDisplay( room.zulip_auto_post, diff --git a/www/app/[roomName]/page-old.tsx b/www/app/[roomName]/page-old.tsx new file mode 100644 index 00000000..b03a7e4f --- /dev/null +++ b/www/app/[roomName]/page-old.tsx @@ -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, + wherebyRef: RefObject, +) => { + const currentFocusRef = useRef(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 /*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(null); + useConsentWherebyFocusManagement(buttonRef, wherebyRef); + return ( + + ); + }; + + return ( + + + + Can we have your permission to store this meeting's audio + recording on our servers? + + + + + + + + ); + }, + }); + + // 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; +}) { + const { showConsentModal, consentState, hasConsent, consentLoading } = + useConsentDialog(meetingId, wherebyRef); + + if (!consentState.ready || hasConsent(meetingId) || consentLoading) { + return null; + } + + return ( + + ); +} + +const recordingTypeRequiresConsent = ( + recordingType: NonNullable, +) => { + 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(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 ( + + + + ); + } + + return ( + <> + {roomUrl && meetingId && wherebyLoaded && ( + <> + + {recordingType && recordingTypeRequiresConsent(recordingType) && ( + + )} + + )} + + ); +} diff --git a/www/app/[roomName]/page.tsx b/www/app/[roomName]/page.tsx index b03a7e4f..b9bb9a86 100644 --- a/www/app/[roomName]/page.tsx +++ b/www/app/[roomName]/page.tsx @@ -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, - wherebyRef: RefObject, -) => { - const currentFocusRef = useRef(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 /*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(null); - useConsentWherebyFocusManagement(buttonRef, wherebyRef); - return ( - - ); - }; - - return ( - - - - Can we have your permission to store this meeting's audio - recording on our servers? - - - - - - - - ); - }, - }); - - // 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; -}) { - const { showConsentModal, consentState, hasConsent, consentLoading } = - useConsentDialog(meetingId, wherebyRef); - - if (!consentState.ready || hasConsent(meetingId) || consentLoading) { - return null; - } - - return ( - - ); -} - -const recordingTypeRequiresConsent = ( - recordingType: NonNullable, -) => { - 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(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 ( - {roomUrl && meetingId && wherebyLoaded && ( - <> - - {recordingType && recordingTypeRequiresConsent(recordingType) && ( - - )} - - )} - + ); } diff --git a/www/app/api/schemas.gen.ts b/www/app/api/schemas.gen.ts index 919040a2..17f18f2d 100644 --- a/www/app/api/schemas.gen.ts +++ b/www/app/api/schemas.gen.ts @@ -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: { diff --git a/www/app/api/services.gen.ts b/www/app/api/services.gen.ts index c9e027fb..d8cbae61 100644 --- a/www/app/api/services.gen.ts +++ b/www/app/api/services.gen.ts @@ -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 { + 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 { + 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 { + return this.httpRequest.request({ + method: "GET", + url: "/v1/jitsi/health", + }); + } } diff --git a/www/app/api/types.gen.ts b/www/app/api/types.gen.ts index e2e7a020..62e07e69 100644 --- a/www/app/api/types.gen.ts +++ b/www/app/api/types.gen.ts @@ -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; }; +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; + }; + }; + }; }; diff --git a/www/app/lib/videoPlatforms/VideoPlatformEmbed.tsx b/www/app/lib/videoPlatforms/VideoPlatformEmbed.tsx new file mode 100644 index 00000000..c0138f3b --- /dev/null +++ b/www/app/lib/videoPlatforms/VideoPlatformEmbed.tsx @@ -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, + platformRef: RefObject, + supportsFocusManagement: boolean, +) => { + const currentFocusRef = useRef(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, + 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(null); + usePlatformFocusManagement( + buttonRef, + platformRef, + supportsFocusManagement, + ); + return ( + + ); + }; + + return ( + + + + Can we have your permission to store this meeting's audio + recording on our servers? + + + + + + + + ); + }, + }); + + // 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; + supportsFocusManagement: boolean; +}) { + const { showConsentModal, consentState, hasConsent, consentLoading } = + useConsentDialog(meetingId, platformRef, supportsFocusManagement); + + if (!consentState.ready || hasConsent(meetingId) || consentLoading) { + return null; + } + + return ( + + ); +} + +const recordingTypeRequiresConsent = ( + recordingType: NonNullable, +) => { + return recordingType === "cloud"; +}; + +export default function VideoPlatformEmbed({ + meeting, + platform, + onLeave, + onReady, +}: VideoPlatformEmbedProps) { + const platformRef = useRef(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 ( + <> + + {recordingType && + recordingTypeRequiresConsent(recordingType) && + adapter.requiresConsent && ( + + )} + + ); +} diff --git a/www/app/lib/videoPlatforms/factory.ts b/www/app/lib/videoPlatforms/factory.ts new file mode 100644 index 00000000..656dd090 --- /dev/null +++ b/www/app/lib/videoPlatforms/factory.ts @@ -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 = { + 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; +} diff --git a/www/app/lib/videoPlatforms/index.ts b/www/app/lib/videoPlatforms/index.ts new file mode 100644 index 00000000..45e2a2d6 --- /dev/null +++ b/www/app/lib/videoPlatforms/index.ts @@ -0,0 +1,5 @@ +export * from "./types"; +export * from "./factory"; +export * from "./whereby"; +export * from "./jitsi"; +export * from "./utils"; diff --git a/www/app/lib/videoPlatforms/jitsi/JitsiAdapter.tsx b/www/app/lib/videoPlatforms/jitsi/JitsiAdapter.tsx new file mode 100644 index 00000000..69d749c2 --- /dev/null +++ b/www/app/lib/videoPlatforms/jitsi/JitsiAdapter.tsx @@ -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 +}; diff --git a/www/app/lib/videoPlatforms/jitsi/JitsiProvider.tsx b/www/app/lib/videoPlatforms/jitsi/JitsiProvider.tsx new file mode 100644 index 00000000..483c8d11 --- /dev/null +++ b/www/app/lib/videoPlatforms/jitsi/JitsiProvider.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { + useCallback, + useEffect, + useRef, + useState, + forwardRef, + useImperativeHandle, +} from "react"; +import { VideoPlatformComponentProps } from "../types"; + +const JitsiProvider = forwardRef( + ({ meeting, roomRef, onReady, onConsentGiven, onConsentDeclined }, ref) => { + const [jitsiReady, setJitsiReady] = useState(false); + const internalRef = useRef(null); + const iframeRef = + (roomRef as React.RefObject) || 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 ( +