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) && (
+
+ )}
+ >
+ )}
+ >
+ );
}