From 1557af1ac9faf8a6bff844f986d48c76602ad6c2 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Wed, 10 Sep 2025 17:31:34 -0600 Subject: [PATCH] feat: restore original recording consent functionality - Remove custom ConsentDialogButton and WherebyEmbed components - Merge RoomClient logic back into main room page - Restore original consent UI: blue button with toast modal - Maintain calendar integration features for ICS-enabled rooms - Add consent-handler.md documentation of original functionality - Preserve focus management and accessibility features --- consent-handler.md | 125 +++++++ www/app/[roomName]/RoomClient.tsx | 147 -------- www/app/[roomName]/[meetingId]/page.tsx | 355 ++++++++++++++++---- www/app/[roomName]/page.tsx | 423 ++++++++++++++++++++++-- 4 files changed, 811 insertions(+), 239 deletions(-) create mode 100644 consent-handler.md delete mode 100644 www/app/[roomName]/RoomClient.tsx diff --git a/consent-handler.md b/consent-handler.md new file mode 100644 index 00000000..2d9ccc38 --- /dev/null +++ b/consent-handler.md @@ -0,0 +1,125 @@ +# Recording Consent Handler Documentation + +This document describes the recording consent functionality found in the main branch of Reflector. + +## Overview + +The recording consent system manages user consent for storing meeting audio recordings on servers. It only appears when `recording_type` is set to "cloud" and shows a prominent blue button with toast-based consent dialog. + +## Components and Files + +### 1. ConsentDialogButton Component + +**Location**: `www/app/[roomName]/page.tsx:206-234` + +**Visual Appearance**: +- **Button text**: "Meeting is being recorded" +- **Button color**: Blue (`colorPalette="blue"`) +- **Position**: Absolute positioned at `top="56px"` `left="8px"` +- **Z-index**: 1000 (appears above video) +- **Size**: Small (`size="sm"`) +- **Icon**: FaBars icon from react-icons/fa6 + +**Behavior**: +- Only shows when: + - Consent context is ready + - User hasn't already given consent for this meeting + - Not currently loading consent submission + - Recording type requires consent (cloud recording) + +### 2. Consent Modal/Toast + +**Location**: `www/app/[roomName]/page.tsx:107-196` (useConsentDialog hook) + +**Visual Appearance**: +- **Background**: Semi-transparent white (`bg="rgba(255, 255, 255, 0.7)"`) +- **Border radius**: Large (`borderRadius="lg"`) +- **Box shadow**: Large (`boxShadow="lg"`) +- **Max width**: Medium (`maxW="md"`) +- **Position**: Top placement toast + +**Content**: +- **Main text**: "Can we have your permission to store this meeting's audio recording on our servers?" +- **Font**: Medium size, medium weight, center aligned + +**Buttons**: +1. **Decline Button**: + - Text: "No, delete after transcription" + - Style: Ghost variant, small size + - Action: Sets consent to false + +2. **Accept Button**: + - Text: "Yes, store the audio" + - Style: Primary color palette, small size + - Action: Sets consent to true + - Has special focus management for accessibility + +### 3. Recording Consent Context + +**Location**: `www/app/recordingConsentContext.tsx` + +**Key Features**: +- Uses localStorage to persist consent decisions +- Keeps track of up to 5 recent meeting consent decisions +- Provides three main functions: + - `state`: Current context state (ready/not ready + consent set) + - `touch(meetingId)`: Mark consent as given for a meeting + - `hasConsent(meetingId)`: Check if consent already given + +**localStorage Key**: `"recording_consent_meetings"` + +### 4. Focus Management + +**Location**: `www/app/[roomName]/page.tsx:39-74` (useConsentWherebyFocusManagement hook) + +**Purpose**: Manages focus between the consent button and Whereby video embed for accessibility +- Initially focuses the accept button +- Handles Whereby "ready" events to refocus consent +- Restores original focus when consent dialog closes + +### 5. API Integration + +**Hook**: `useMeetingAudioConsent()` from `../lib/apiHooks` + +**Endpoint**: Submits consent decision to `/meetings/{meeting_id}/consent` with body: +```json +{ + "consent_given": boolean +} +``` + +## Logic Flow + +1. **Trigger Condition**: + - Meeting has `recording_type === "cloud"` + - User hasn't already consented for this meeting + +2. **Button Display**: + - Blue "Meeting is being recorded" button appears over video + +3. **User Interaction**: + - Click button → Opens consent toast modal + - User chooses "Yes" or "No" + - Decision sent to API + - Meeting ID added to local consent cache + - Modal closes + +4. **State Management**: + - Consent decision cached locally for this meeting + - Button disappears after consent given + - No further prompts for this meeting + +## Integration Points + +- **Room Component**: Main integration in `www/app/[roomName]/page.tsx:324-329` +- **Conditional Rendering**: Only shows when `recordingTypeRequiresConsent(recordingType)` returns true +- **Authentication**: Requires user to be authenticated +- **Whereby Integration**: Coordinates with video embed focus management + +## Key Features + +- **Non-blocking**: User can interact with video while consent prompt is visible +- **Persistent**: Consent decisions remembered across sessions +- **Accessible**: Proper focus management and keyboard navigation +- **Conditional**: Only appears for cloud recordings requiring consent +- **Toast-based**: Uses toast system for non-intrusive user experience \ No newline at end of file diff --git a/www/app/[roomName]/RoomClient.tsx b/www/app/[roomName]/RoomClient.tsx deleted file mode 100644 index 3b7578a5..00000000 --- a/www/app/[roomName]/RoomClient.tsx +++ /dev/null @@ -1,147 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { Box, Spinner, Text } from "@chakra-ui/react"; -import { useRouter } from "next/navigation"; -import { - useRoomGetByName, - useRoomActiveMeetings, - useRoomUpcomingMeetings, - useRoomsCreateMeeting, -} from "../lib/apiHooks"; -import type { components } from "../reflector-api"; -import MeetingSelection from "./MeetingSelection"; -import { useAuth } from "../lib/AuthProvider"; -import useRoomMeeting from "./useRoomMeeting"; -import dynamic from "next/dynamic"; - -const WherebyEmbed = dynamic(() => import("../lib/WherebyWebinarEmbed"), { - ssr: false, -}); - -type Meeting = components["schemas"]["Meeting"]; - -interface RoomClientProps { - params: { - roomName: string; - }; -} - -export default function RoomClient({ params }: RoomClientProps) { - const roomName = params.roomName; - const router = useRouter(); - const auth = useAuth(); - - // Fetch room details using React Query - const roomQuery = useRoomGetByName(roomName); - const activeMeetingsQuery = useRoomActiveMeetings(roomName); - const upcomingMeetingsQuery = useRoomUpcomingMeetings(roomName); - const createMeetingMutation = useRoomsCreateMeeting(); - - const room = roomQuery.data; - const activeMeetings = activeMeetingsQuery.data || []; - const upcomingMeetings = upcomingMeetingsQuery.data || []; - - // For non-ICS rooms, create a meeting and get Whereby URL - const roomMeeting = useRoomMeeting( - room && !room.ics_enabled ? roomName : null, - ); - const roomUrl = - roomMeeting?.response?.host_room_url || roomMeeting?.response?.room_url; - - const isLoading = auth.status === "loading" || roomQuery.isLoading; - - const isOwner = - auth.status === "authenticated" && room - ? auth.user?.id === room.user_id - : false; - - const handleMeetingSelect = (selectedMeeting: Meeting) => { - // Navigate to specific meeting using path segment - router.push(`/${roomName}/${selectedMeeting.id}`); - }; - - const handleCreateUnscheduled = async () => { - try { - // Create a new unscheduled meeting - const newMeeting = await createMeetingMutation.mutateAsync({ - params: { - path: { room_name: roomName }, - }, - }); - handleMeetingSelect(newMeeting); - } catch (err) { - console.error("Failed to create meeting:", err); - } - }; - - // Handle room not found - useEffect(() => { - if (roomQuery.isError) { - router.push("/"); - } - }, [roomQuery.isError, router]); - - if (isLoading) { - return ( - - - - ); - } - - if (!room) { - return ( - - Room not found - - ); - } - - // For ICS-enabled rooms, show meeting selection - if (room.ics_enabled) { - return ( - - ); - } - - // For non-ICS rooms, show Whereby embed directly - if (roomUrl) { - return ; - } - - // Loading state for non-ICS rooms while creating meeting - return ( - - - - ); -} diff --git a/www/app/[roomName]/[meetingId]/page.tsx b/www/app/[roomName]/[meetingId]/page.tsx index 5bd54b74..75f80f7f 100644 --- a/www/app/[roomName]/[meetingId]/page.tsx +++ b/www/app/[roomName]/[meetingId]/page.tsx @@ -1,10 +1,234 @@ "use client"; -import { useEffect, useState } from "react"; -import { Box, Spinner, Text, VStack } from "@chakra-ui/react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Box, Button, HStack, Icon, Spinner, Text, VStack } from "@chakra-ui/react"; import { useRouter } from "next/navigation"; -import { useRoomGetByName } from "../../lib/apiHooks"; +import { useRoomGetByName, useRoomJoinMeeting, useMeetingAudioConsent } from "../../lib/apiHooks"; +import { useRecordingConsent } from "../../recordingConsentContext"; +import { toaster } from "../../components/ui/toaster"; +import { FaBars } from "react-icons/fa6"; import MinimalHeader from "../../components/MinimalHeader"; +import type { components } from "../../reflector-api"; + +type Meeting = components["schemas"]["Meeting"]; + +// 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; +}; + +// Consent functionality from main branch +const useConsentWherebyFocusManagement = ( + acceptButtonRef: React.RefObject, + wherebyRef: React.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: React.RefObject, +) => { + const { state: consentState, touch, hasConsent } = useRecordingConsent(); + const [modalOpen, setModalOpen] = useState(false); + const audioConsentMutation = useMeetingAudioConsent(); + + const handleConsent = useCallback( + async (meetingId: string, given: boolean) => { + try { + await audioConsentMutation.mutateAsync({ + params: { + path: { + meeting_id: meetingId, + }, + }, + body: { + consent_given: given, + }, + }); + + touch(meetingId); + } catch (error) { + console.error("Error submitting consent:", error); + } + }, + [audioConsentMutation, 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: audioConsentMutation.isPending, + }; +}; + +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"; +}; interface MeetingPageProps { params: { roomName: string; @@ -15,27 +239,64 @@ interface MeetingPageProps { export default function MeetingPage({ params }: MeetingPageProps) { const { roomName, meetingId } = params; const router = useRouter(); + const [attemptedJoin, setAttemptedJoin] = useState(false); + const wherebyLoaded = useWhereby(); + const wherebyRef = useRef(null); // Fetch room data const roomQuery = useRoomGetByName(roomName); + const joinMeetingMutation = useRoomJoinMeeting(); const room = roomQuery.data; - const isLoading = roomQuery.isLoading; - const error = roomQuery.error; - - // Redirect to selection if room not found + const isLoading = roomQuery.isLoading || (!attemptedJoin && room && !joinMeetingMutation.data); + + // Try to join the meeting when room is loaded useEffect(() => { - if (roomQuery.isError) { + if (room && !attemptedJoin && !joinMeetingMutation.isPending) { + setAttemptedJoin(true); + joinMeetingMutation.mutate({ + params: { + path: { + room_name: roomName, + meeting_id: meetingId, + }, + }, + }); + } + }, [room, attemptedJoin, joinMeetingMutation, roomName, meetingId]); + + // Redirect to room lobby if meeting join fails (meeting finished/not found) + useEffect(() => { + if (joinMeetingMutation.isError || roomQuery.isError) { router.push(`/${roomName}`); } - }, [roomQuery.isError, router, roomName]); + }, [joinMeetingMutation.isError, roomQuery.isError, router, roomName]); + + // Get meeting data from join response + const meeting = joinMeetingMutation.data; + const roomUrl = meeting?.host_room_url || meeting?.room_url; + const recordingType = meeting?.recording_type; + + const handleLeave = useCallback(() => { + router.push(`/${roomName}`); + }, [router, roomName]); + + useEffect(() => { + if (!isLoading && !roomUrl && !wherebyLoaded) return; + + wherebyRef.current?.addEventListener("leave", handleLeave); + + return () => { + wherebyRef.current?.removeEventListener("leave", handleLeave); + }; + }, [handleLeave, roomUrl, isLoading, wherebyLoaded]); if (isLoading) { return ( - + - - Meeting not found - - + {recordingType && recordingTypeRequiresConsent(recordingType) && ( + + )} + ); } + // This return should not be reached normally since we redirect on errors + // But keeping it as a fallback return ( - - - - - Meeting Room - - - - - - Meeting Interface Coming Soon - - - This is where the video call, transcription, and meeting - controls will be displayed. - - - Meeting ID: {meetingId} - - - - + + Meeting not available ); diff --git a/www/app/[roomName]/page.tsx b/www/app/[roomName]/page.tsx index f288fc5b..5dbd954f 100644 --- a/www/app/[roomName]/page.tsx +++ b/www/app/[roomName]/page.tsx @@ -1,5 +1,34 @@ -import { Metadata } from "next"; -import RoomClient from "./RoomClient"; +"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 { useRouter } from "next/navigation"; +import { notFound } from "next/navigation"; +import { useRecordingConsent } from "../recordingConsentContext"; +import { useMeetingAudioConsent, useRoomGetByName, useRoomActiveMeetings, useRoomUpcomingMeetings, useRoomsCreateMeeting } from "../lib/apiHooks"; +import type { components } from "../reflector-api"; +import MeetingSelection from "./MeetingSelection"; +import useRoomMeeting from "./useRoomMeeting"; + +type Meeting = components["schemas"]["Meeting"]; +import { FaBars } from "react-icons/fa6"; +import { useAuth } from "../lib/AuthProvider"; export type RoomDetails = { params: { @@ -7,42 +36,368 @@ export type RoomDetails = { }; }; -// Generate dynamic metadata for the room selection page -export async function generateMetadata({ - params, -}: RoomDetails): Promise { - const { roomName } = params; - - try { - // Fetch room data server-side for metadata - const response = await fetch( - `${process.env.NEXT_PUBLIC_REFLECTOR_API_URL}/v1/rooms/name/${roomName}`, - { - headers: { - "Content-Type": "application/json", - }, - }, - ); - - if (response.ok) { - const room = await response.json(); - const displayName = room.display_name || room.name; - return { - title: `${displayName} Room - Select a Meeting`, - description: `Join a meeting in ${displayName}'s room on Reflector.`, - }; +// 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", + ); } - } catch (error) { - console.error("Failed to fetch room for metadata:", error); + + 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(); + // toast would open duplicates, even with using "id=" prop + const [modalOpen, setModalOpen] = useState(false); + const audioConsentMutation = useMeetingAudioConsent(); + + const handleConsent = useCallback( + async (meetingId: string, given: boolean) => { + try { + await audioConsentMutation.mutateAsync({ + params: { + path: { + meeting_id: meetingId, + }, + }, + body: { + consent_given: given, + }, + }); + + touch(meetingId); + } catch (error) { + console.error("Error submitting consent:", error); + } + }, + [audioConsentMutation, 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: audioConsentMutation.isPending, + }; +}; + +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; } - // Fallback if room fetch fails - return { - title: `${roomName} Room - Select a Meeting`, - description: `Join a meeting in ${roomName}'s room on Reflector.`, - }; + 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) { - return ; + const wherebyLoaded = useWhereby(); + const wherebyRef = useRef(null); + const roomName = details.params.roomName; + const router = useRouter(); + const auth = useAuth(); + const status = auth.status; + const isAuthenticated = status === "authenticated"; + + // Fetch room details using React Query + const roomQuery = useRoomGetByName(roomName); + const activeMeetingsQuery = useRoomActiveMeetings(roomName); + const upcomingMeetingsQuery = useRoomUpcomingMeetings(roomName); + const createMeetingMutation = useRoomsCreateMeeting(); + + const room = roomQuery.data; + const activeMeetings = activeMeetingsQuery.data || []; + const upcomingMeetings = upcomingMeetingsQuery.data || []; + + // For non-ICS rooms, create a meeting and get Whereby URL + const roomMeeting = useRoomMeeting( + room && !room.ics_enabled ? roomName : null, + ); + const roomUrl = + roomMeeting?.response?.host_room_url || roomMeeting?.response?.room_url; + + const isLoading = status === "loading" || roomQuery.isLoading || roomMeeting?.loading; + + const isOwner = + isAuthenticated && room + ? auth.user?.id === room.user_id + : false; + + const meetingId = roomMeeting?.response?.id; + + const recordingType = roomMeeting?.response?.recording_type; + + const handleMeetingSelect = (selectedMeeting: Meeting) => { + // Navigate to specific meeting using path segment + router.push(`/${roomName}/${selectedMeeting.id}`); + }; + + const handleCreateUnscheduled = async () => { + try { + // Create a new unscheduled meeting + const newMeeting = await createMeetingMutation.mutateAsync({ + params: { + path: { room_name: roomName }, + }, + }); + handleMeetingSelect(newMeeting); + } catch (err) { + console.error("Failed to create meeting:", err); + } + }; + + const handleLeave = useCallback(() => { + router.push("/browse"); + }, [router]); + + useEffect(() => { + if ( + !isLoading && + (roomQuery.isError || roomMeeting?.error) && + "status" in (roomQuery.error || roomMeeting?.error || {}) && + (roomQuery.error as any)?.status === 404 + ) { + notFound(); + } + }, [isLoading, roomQuery.error, roomMeeting?.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 ( + + + + ); + } + + if (!room) { + return ( + + Room not found + + ); + } + + // For ICS-enabled rooms, show meeting selection + if (room.ics_enabled) { + return ( + + ); + } + + // For non-ICS rooms, show Whereby embed directly + return ( + <> + {roomUrl && meetingId && wherebyLoaded && ( + <> + + {recordingType && recordingTypeRequiresConsent(recordingType) && ( + + )} + + )} + + ); }