feat: reorganize room edit dialog and fix Force Sync button

- Move WebHook configuration from General to dedicated WebHook tab
- Add WebHook tab after Share tab in room edit dialog
- Fix Force Sync button not appearing by adding missing isEditing prop
- Fix indentation issues in MeetingSelection component

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-08 19:41:57 -06:00
parent f15e1dc7f7
commit 7193b4dbba
21 changed files with 1053 additions and 1551 deletions

View File

@@ -1,199 +0,0 @@
import { Box, VStack, HStack, Text, Badge, Icon } from "@chakra-ui/react";
import { FaCalendarAlt, FaUsers, FaClock, FaInfoCircle } from "react-icons/fa";
import type { components } from "../reflector-api";
type Meeting = components["schemas"]["Meeting"];
interface MeetingInfoProps {
meeting: Meeting;
isOwner: boolean;
}
export default function MeetingInfo({ meeting, isOwner }: MeetingInfoProps) {
const formatDuration = (start: string | Date, end: string | Date) => {
const startDate = new Date(start);
const endDate = new Date(end);
const now = new Date();
// If meeting hasn't started yet
if (startDate > now) {
return `Scheduled for ${startDate.toLocaleTimeString()}`;
}
// Calculate duration
const durationMs = now.getTime() - startDate.getTime();
const hours = Math.floor(durationMs / (1000 * 60 * 60));
const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes} minutes`;
};
const isCalendarMeeting = !!meeting.calendar_event_id;
const metadata = meeting.calendar_metadata;
return (
<Box
position="absolute"
top="56px"
right="8px"
bg="white"
borderRadius="md"
boxShadow="lg"
p={4}
maxW="300px"
zIndex={999}
>
<VStack align="stretch" gap={3}>
{/* Meeting Title */}
<HStack>
<Icon
as={isCalendarMeeting ? FaCalendarAlt : FaInfoCircle}
color="blue.500"
/>
<Text fontWeight="semibold" fontSize="md">
{(metadata as any)?.title ||
(isCalendarMeeting ? "Calendar Meeting" : "Unscheduled Meeting")}
</Text>
</HStack>
{/* Meeting Status */}
<HStack gap={2}>
{meeting.is_active && (
<Badge colorScheme="green" fontSize="xs">
Active
</Badge>
)}
{isCalendarMeeting && (
<Badge colorScheme="blue" fontSize="xs">
Calendar
</Badge>
)}
{meeting.is_locked && (
<Badge colorScheme="orange" fontSize="xs">
Locked
</Badge>
)}
</HStack>
<Box h="1px" bg="gray.200" />
{/* Meeting Details */}
<VStack align="stretch" gap={2} fontSize="sm">
{/* Participants */}
<HStack>
<Icon as={FaUsers} color="gray.500" />
<Text>
{meeting.num_clients}{" "}
{meeting.num_clients === 1 ? "participant" : "participants"}
</Text>
</HStack>
{/* Duration */}
<HStack>
<Icon as={FaClock} color="gray.500" />
<Text>
Duration: {formatDuration(meeting.start_date, meeting.end_date)}
</Text>
</HStack>
{/* Calendar Description (Owner only) */}
{isOwner && (metadata as any)?.description && (
<>
<Box h="1px" bg="gray.200" />
<Box>
<Text
fontWeight="semibold"
fontSize="xs"
color="gray.600"
mb={1}
>
Description
</Text>
<Text fontSize="xs" color="gray.700">
{(metadata as any).description}
</Text>
</Box>
</>
)}
{/* Attendees (Owner only) */}
{isOwner &&
(metadata as any)?.attendees &&
(metadata as any).attendees.length > 0 && (
<>
<Box h="1px" bg="gray.200" />
<Box>
<Text
fontWeight="semibold"
fontSize="xs"
color="gray.600"
mb={1}
>
Invited Attendees ({(metadata as any).attendees.length})
</Text>
<VStack align="stretch" gap={1}>
{(metadata as any).attendees
.slice(0, 5)
.map((attendee: any, idx: number) => (
<HStack key={idx} fontSize="xs">
<Badge
colorScheme={
attendee.status === "ACCEPTED"
? "green"
: attendee.status === "DECLINED"
? "red"
: attendee.status === "TENTATIVE"
? "yellow"
: "gray"
}
fontSize="xs"
size="sm"
>
{attendee.status?.charAt(0) || "?"}
</Badge>
<Text color="gray.700" truncate>
{attendee.name || attendee.email}
</Text>
</HStack>
))}
{(metadata as any).attendees.length > 5 && (
<Text fontSize="xs" color="gray.500" fontStyle="italic">
+{(metadata as any).attendees.length - 5} more
</Text>
)}
</VStack>
</Box>
</>
)}
{/* Recording Info */}
{meeting.recording_type !== "none" && (
<>
<Box h="1px" bg="gray.200" />
<HStack fontSize="xs">
<Badge colorScheme="red" fontSize="xs">
Recording
</Badge>
<Text color="gray.600">
{meeting.recording_type === "cloud" ? "Cloud" : "Local"}
{meeting.recording_trigger !== "none" &&
` (${meeting.recording_trigger})`}
</Text>
</HStack>
</>
)}
</VStack>
{/* Meeting Times */}
<Box h="1px" bg="gray.200" />
<VStack align="stretch" gap={1} fontSize="xs" color="gray.600">
<Text>Start: {new Date(meeting.start_date).toLocaleString()}</Text>
<Text>End: {new Date(meeting.end_date).toLocaleString()}</Text>
</VStack>
</VStack>
</Box>
);
}

View File

@@ -9,16 +9,21 @@ import {
Spinner,
Badge,
Icon,
Flex,
} from "@chakra-ui/react";
import React from "react";
import { FaUsers, FaClock, FaCalendarAlt, FaPlus } from "react-icons/fa";
import { LuX } from "react-icons/lu";
import type { components } from "../reflector-api";
import {
useRoomActiveMeetings,
useRoomUpcomingMeetings,
useRoomJoinMeeting,
useMeetingDeactivate,
useRoomGetByName,
} from "../lib/apiHooks";
import { useRouter } from "next/navigation";
import Link from "next/link";
type Meeting = components["schemas"]["Meeting"];
type CalendarEventResponse = components["schemas"]["CalendarEventResponse"];
@@ -26,6 +31,7 @@ type CalendarEventResponse = components["schemas"]["CalendarEventResponse"];
interface MeetingSelectionProps {
roomName: string;
isOwner: boolean;
isSharedRoom: boolean;
onMeetingSelect: (meeting: Meeting) => void;
onCreateUnscheduled: () => void;
}
@@ -59,21 +65,29 @@ const formatCountdown = (startTime: string | Date) => {
export default function MeetingSelection({
roomName,
isOwner,
isSharedRoom,
onMeetingSelect,
onCreateUnscheduled,
}: MeetingSelectionProps) {
const router = useRouter();
// Use React Query hooks for data fetching
const roomQuery = useRoomGetByName(roomName);
const activeMeetingsQuery = useRoomActiveMeetings(roomName);
const upcomingMeetingsQuery = useRoomUpcomingMeetings(roomName);
const joinMeetingMutation = useRoomJoinMeeting();
const deactivateMeetingMutation = useMeetingDeactivate();
const room = roomQuery.data;
const activeMeetings = activeMeetingsQuery.data || [];
const upcomingEvents = upcomingMeetingsQuery.data || [];
const loading =
activeMeetingsQuery.isLoading || upcomingMeetingsQuery.isLoading;
const error = activeMeetingsQuery.error || upcomingMeetingsQuery.error;
roomQuery.isLoading ||
activeMeetingsQuery.isLoading ||
upcomingMeetingsQuery.isLoading;
const error =
roomQuery.error || activeMeetingsQuery.error || upcomingMeetingsQuery.error;
const handleJoinMeeting = async (meetingId: string) => {
try {
@@ -94,7 +108,21 @@ export default function MeetingSelection({
const handleJoinUpcoming = (event: CalendarEventResponse) => {
// Navigate to waiting page with event info
router.push(`/room/${roomName}/wait?eventId=${event.id}`);
router.push(`/${roomName}/wait/${event.id}`);
};
const handleEndMeeting = async (meetingId: string) => {
try {
await deactivateMeetingMutation.mutateAsync({
params: {
path: {
meeting_id: meetingId,
},
},
});
} catch (err) {
console.error("Failed to end meeting:", err);
}
};
if (loading) {
@@ -123,197 +151,241 @@ export default function MeetingSelection({
);
}
// Generate display name for room
const displayName = room?.display_name || room?.name || roomName;
const roomTitle =
displayName.endsWith("'s") || displayName.endsWith("s")
? `${displayName} Room`
: `${displayName}'s Room`;
return (
<VStack gap={6} align="stretch" p={6}>
<Box>
<Text fontSize="2xl" fontWeight="bold" mb={4}>
Select a Meeting
<Flex
flexDir="column"
w={{ base: "full", md: "container.xl" }}
mx="auto"
px={6}
py={8}
minH="100vh"
>
<Flex
flexDir="row"
justifyContent="space-between"
alignItems="center"
mb={6}
>
<Text fontSize="lg" fontWeight="semibold">
{displayName}'s room
</Text>
</Flex>
{/* Active Meetings */}
{activeMeetings.length > 0 && (
<>
<Text fontSize="lg" fontWeight="semibold" mb={3}>
Active Meetings
</Text>
<VStack gap={3} mb={6}>
{activeMeetings.map((meeting) => (
<Box
key={meeting.id}
width="100%"
border="1px solid"
borderColor="gray.200"
borderRadius="md"
p={4}
>
<HStack justify="space-between" align="start">
<VStack align="start" gap={2} flex={1}>
<HStack>
<Icon as={FaCalendarAlt} color="blue.500" />
<Text fontWeight="semibold">
{(meeting.calendar_metadata as any)?.title ||
"Meeting"}
</Text>
</HStack>
{isOwner &&
(meeting.calendar_metadata as any)?.description && (
<Text fontSize="sm" color="gray.600">
{(meeting.calendar_metadata as any).description}
</Text>
)}
<HStack gap={4} fontSize="sm" color="gray.500">
<HStack>
<Icon as={FaUsers} />
<Text>{meeting.num_clients} participants</Text>
</HStack>
<HStack>
<Icon as={FaClock} />
<Text>
Started {formatDateTime(meeting.start_date)}
</Text>
</HStack>
</HStack>
{isOwner &&
(meeting.calendar_metadata as any)?.attendees && (
<HStack gap={2} flexWrap="wrap">
{(meeting.calendar_metadata as any).attendees
.slice(0, 3)
.map((attendee: any, idx: number) => (
<Badge
key={idx}
colorScheme="green"
fontSize="xs"
>
{attendee.name || attendee.email}
</Badge>
))}
{(meeting.calendar_metadata as any).attendees
.length > 3 && (
<Badge colorScheme="gray" fontSize="xs">
+
{(meeting.calendar_metadata as any).attendees
.length - 3}{" "}
more
</Badge>
)}
</HStack>
)}
</VStack>
<Button
colorScheme="blue"
size="md"
onClick={() => handleJoinMeeting(meeting.id)}
>
Join Now
</Button>
{/* Active Meetings */}
{activeMeetings.length > 0 && (
<VStack align="stretch" gap={4} mb={6}>
<Text fontSize="md" fontWeight="semibold" color="gray.700">
Active Meetings
</Text>
{activeMeetings.map((meeting) => (
<Box
key={meeting.id}
width="100%"
bg="white"
border="1px solid"
borderColor="gray.200"
borderRadius="md"
p={4}
_hover={{ borderColor: "gray.300" }}
>
<HStack justify="space-between" align="start">
<VStack align="start" gap={2} flex={1}>
<HStack>
<Icon as={FaCalendarAlt} color="blue.500" />
<Text fontWeight="semibold">
{(meeting.calendar_metadata as any)?.title || "Meeting"}
</Text>
</HStack>
</Box>
))}
</VStack>
</>
)}
{/* Upcoming Meetings */}
{upcomingEvents.length > 0 && (
<>
<Text fontSize="lg" fontWeight="semibold" mb={3}>
Upcoming Meetings
</Text>
<VStack gap={3} mb={6}>
{upcomingEvents.map((event) => (
<Box
key={event.id}
width="100%"
border="1px solid"
borderColor="gray.200"
borderRadius="md"
p={4}
bg="gray.50"
>
<HStack justify="space-between" align="start">
<VStack align="start" gap={2} flex={1}>
<HStack>
<Icon as={FaCalendarAlt} color="orange.500" />
<Text fontWeight="semibold">
{event.title || "Scheduled Meeting"}
</Text>
<Badge colorScheme="orange" fontSize="xs">
{formatCountdown(event.start_time)}
{isOwner &&
(meeting.calendar_metadata as any)?.description && (
<Text fontSize="sm" color="gray.600">
{(meeting.calendar_metadata as any).description}
</Text>
)}
<HStack gap={4} fontSize="sm" color="gray.500">
<HStack>
<Icon as={FaUsers} />
<Text>{meeting.num_clients} participants</Text>
</HStack>
<HStack>
<Icon as={FaClock} />
<Text>Started {formatDateTime(meeting.start_date)}</Text>
</HStack>
</HStack>
{isOwner && (meeting.calendar_metadata as any)?.attendees && (
<HStack gap={2} flexWrap="wrap">
{(meeting.calendar_metadata as any).attendees
.slice(0, 3)
.map((attendee: any, idx: number) => (
<Badge key={idx} colorScheme="green" fontSize="xs">
{attendee.name || attendee.email}
</Badge>
))}
{(meeting.calendar_metadata as any).attendees.length >
3 && (
<Badge colorScheme="gray" fontSize="xs">
+
{(meeting.calendar_metadata as any).attendees.length -
3}{" "}
more
</Badge>
</HStack>
{isOwner && event.description && (
<Text fontSize="sm" color="gray.600">
{event.description}
</Text>
)}
</HStack>
)}
</VStack>
<HStack gap={4} fontSize="sm" color="gray.500">
<Text>
{formatDateTime(event.start_time)} -{" "}
{formatDateTime(event.end_time)}
</Text>
</HStack>
{isOwner && event.attendees && (
<HStack gap={2} flexWrap="wrap">
{event.attendees
.slice(0, 3)
.map((attendee: any, idx: number) => (
<Badge
key={idx}
colorScheme="purple"
fontSize="xs"
>
{attendee.name || attendee.email}
</Badge>
))}
{event.attendees.length > 3 && (
<Badge colorScheme="gray" fontSize="xs">
+{event.attendees.length - 3} more
</Badge>
)}
</HStack>
)}
</VStack>
<HStack gap={2}>
<Button
colorScheme="blue"
size="md"
onClick={() => handleJoinMeeting(meeting.id)}
>
Join Now
</Button>
{isOwner && (
<Button
variant="outline"
colorScheme="orange"
colorScheme="red"
size="md"
onClick={() => handleJoinUpcoming(event)}
onClick={() => handleEndMeeting(meeting.id)}
isLoading={deactivateMeetingMutation.isPending}
>
Join Early
<Icon as={LuX} />
End Meeting
</Button>
)}
</HStack>
</HStack>
</Box>
))}
</VStack>
)}
{/* Upcoming Meetings */}
{upcomingEvents.length > 0 && (
<VStack align="stretch" gap={4} mb={6}>
<Text fontSize="md" fontWeight="semibold" color="gray.700">
Upcoming Meetings
</Text>
{upcomingEvents.map((event) => (
<Box
key={event.id}
width="100%"
bg="white"
border="1px solid"
borderColor="gray.200"
borderRadius="md"
p={4}
_hover={{ borderColor: "gray.300" }}
>
<HStack justify="space-between" align="start">
<VStack align="start" gap={2} flex={1}>
<HStack>
<Icon as={FaCalendarAlt} color="orange.500" />
<Text fontWeight="semibold">
{event.title || "Scheduled Meeting"}
</Text>
<Badge colorScheme="orange" fontSize="xs">
{formatCountdown(event.start_time)}
</Badge>
</HStack>
</Box>
))}
</VStack>
</>
)}
<Box h="1px" bg="gray.200" my={6} />
{isOwner && event.description && (
<Text fontSize="sm" color="gray.600">
{event.description}
</Text>
)}
{/* Create Unscheduled Meeting */}
<Box width="100%" bg="gray.100" borderRadius="md" p={4}>
<HStack gap={4} fontSize="sm" color="gray.500">
<Text>
{formatDateTime(event.start_time)} -{" "}
{formatDateTime(event.end_time)}
</Text>
</HStack>
{isOwner && event.attendees && (
<HStack gap={2} flexWrap="wrap">
{event.attendees
.slice(0, 3)
.map((attendee: any, idx: number) => (
<Badge key={idx} colorScheme="purple" fontSize="xs">
{attendee.name || attendee.email}
</Badge>
))}
{event.attendees.length > 3 && (
<Badge colorScheme="gray" fontSize="xs">
+{event.attendees.length - 3} more
</Badge>
)}
</HStack>
)}
</VStack>
<Button
variant="outline"
colorScheme="orange"
size="md"
onClick={() => handleJoinUpcoming(event)}
>
Join Early
</Button>
</HStack>
</Box>
))}
</VStack>
)}
{/* Create Unscheduled Meeting - Only for room owners or shared rooms */}
{(isOwner || isSharedRoom) && (
<Box width="100%" bg="gray.50" borderRadius="md" p={4} mt={6}>
<HStack justify="space-between" align="center">
<VStack align="start" gap={1}>
<Text fontWeight="semibold">Start an Unscheduled Meeting</Text>
<Text fontWeight="semibold">Start a Quick Meeting</Text>
<Text fontSize="sm" color="gray.600">
Create a new meeting room that's not on the calendar
Jump into a meeting room right away
</Text>
</VStack>
<Button colorScheme="green" onClick={onCreateUnscheduled}>
<FaPlus />
Create Meeting
</Button>
</HStack>
</Box>
)}
{/* Message for non-owners of private rooms */}
{!isOwner && !isSharedRoom && (
<Box
width="100%"
bg="gray.50"
border="1px solid"
borderColor="gray.200"
borderRadius="md"
p={4}
mt={6}
>
<Text fontSize="sm" color="gray.600" textAlign="center">
Only the room owner can create unscheduled meetings in this private
room.
</Text>
</Box>
)}
{/* Footer with back to reflector link */}
<Box mt="auto" pt={8} borderTop="1px solid" borderColor="gray.100">
<Text textAlign="center" fontSize="sm" color="gray.500">
<Link href="/browse"> Back to Reflector</Link>
</Text>
</Box>
</VStack>
</Flex>
);
}

View File

@@ -0,0 +1,134 @@
"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";
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 || [];
const isOwner =
auth.status === "authenticated" ? auth.user?.id === room?.user_id : false;
const isLoading = auth.status === "loading" || roomQuery.isLoading;
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);
}
};
// For non-ICS rooms, automatically create and join meeting
useEffect(() => {
if (!room || isLoading || room.ics_enabled) return;
// Non-ICS room: create meeting automatically
handleCreateUnscheduled();
}, [room, isLoading]);
// Handle room not found
useEffect(() => {
if (roomQuery.isError) {
router.push("/");
}
}, [roomQuery.isError, router]);
if (isLoading) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height="100vh"
bg="gray.50"
p={4}
>
<Spinner color="blue.500" size="xl" />
</Box>
);
}
if (!room) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height="100vh"
bg="gray.50"
p={4}
>
<Text fontSize="lg">Room not found</Text>
</Box>
);
}
// For ICS-enabled rooms, ALWAYS show meeting selection (no auto-redirect)
if (room.ics_enabled) {
return (
<MeetingSelection
roomName={roomName}
isOwner={isOwner}
isSharedRoom={room?.is_shared || false}
onMeetingSelect={handleMeetingSelect}
onCreateUnscheduled={handleCreateUnscheduled}
/>
);
}
// Non-ICS rooms will auto-redirect via useEffect above
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height="100vh"
bg="gray.50"
p={4}
>
<Spinner color="blue.500" size="xl" />
</Box>
);
}

View File

@@ -0,0 +1,119 @@
"use client";
import { useEffect, useState } from "react";
import { Box, Spinner, Text, VStack } from "@chakra-ui/react";
import { useRouter } from "next/navigation";
import { useRoomGetByName } from "../../lib/apiHooks";
import MinimalHeader from "../../components/MinimalHeader";
interface MeetingPageProps {
params: {
roomName: string;
meetingId: string;
};
}
export default function MeetingPage({ params }: MeetingPageProps) {
const { roomName, meetingId } = params;
const router = useRouter();
// Fetch room data
const roomQuery = useRoomGetByName(roomName);
const room = roomQuery.data;
const isLoading = roomQuery.isLoading;
const error = roomQuery.error;
// Redirect to selection if room not found
useEffect(() => {
if (roomQuery.isError) {
router.push(`/${roomName}`);
}
}, [roomQuery.isError, router, roomName]);
if (isLoading) {
return (
<Box display="flex" flexDirection="column" minH="100vh">
<MinimalHeader
roomName={roomName}
displayName={room?.display_name || room?.name}
showLeaveButton={false}
/>
<Box
display="flex"
justifyContent="center"
alignItems="center"
flex="1"
bg="gray.50"
p={4}
>
<VStack gap={4}>
<Spinner color="blue.500" size="xl" />
<Text fontSize="lg">Loading meeting...</Text>
</VStack>
</Box>
</Box>
);
}
if (error || !room) {
return (
<Box display="flex" flexDirection="column" minH="100vh">
<MinimalHeader
roomName={roomName}
displayName={room?.display_name || room?.name}
/>
<Box
display="flex"
justifyContent="center"
alignItems="center"
flex="1"
bg="gray.50"
p={4}
>
<Text fontSize="lg">Meeting not found</Text>
</Box>
</Box>
);
}
return (
<Box display="flex" flexDirection="column" minH="100vh">
<MinimalHeader
roomName={roomName}
displayName={room?.display_name || room?.name}
/>
<Box flex="1" bg="gray.50" p={4}>
<VStack gap={4} align="stretch" maxW="container.lg" mx="auto">
<Text fontSize="2xl" fontWeight="bold" textAlign="center">
Meeting Room
</Text>
<Box
bg="white"
borderRadius="md"
p={6}
textAlign="center"
minH="400px"
display="flex"
alignItems="center"
justifyContent="center"
>
<VStack gap={4}>
<Text fontSize="lg" color="gray.600">
Meeting Interface Coming Soon
</Text>
<Text fontSize="sm" color="gray.500">
This is where the video call, transcription, and meeting
controls will be displayed.
</Text>
<Text fontSize="sm" color="gray.500">
Meeting ID: {meetingId}
</Text>
</VStack>
</Box>
</VStack>
</Box>
</Box>
);
}

View File

@@ -1,36 +1,5 @@
"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 { useRecordingConsent } from "../recordingConsentContext";
import { useMeetingAudioConsent, useRoomGetByName } from "../lib/apiHooks";
import type { components } from "../reflector-api";
import { FaBars } from "react-icons/fa6";
import { FaInfoCircle } from "react-icons/fa";
import MeetingInfo from "./MeetingInfo";
import { useAuth } from "../lib/AuthProvider";
type Meeting = components["schemas"]["Meeting"];
type Room = components["schemas"]["Room"];
import { Metadata } from "next";
import RoomClient from "./RoomClient";
export type RoomDetails = {
params: {
@@ -38,328 +7,42 @@ 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",
);
}
// Generate dynamic metadata for the room selection page
export async function generateMetadata({
params,
}: RoomDetails): Promise<Metadata> {
const { roomName } = params;
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();
// 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<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>
);
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",
},
},
});
);
// 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<HTMLElement>;
}) {
const { showConsentModal, consentState, hasConsent, consentLoading } =
useConsentDialog(meetingId, wherebyRef);
if (!consentState.ready || hasConsent(meetingId) || consentLoading) {
return null;
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.`,
};
}
} catch (error) {
console.error("Failed to fetch room for metadata:", error);
}
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>
);
// Fallback if room fetch fails
return {
title: `${roomName} Room - Select a Meeting`,
description: `Join a meeting in ${roomName}'s room on Reflector.`,
};
}
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 auth = useAuth();
const status = auth.status;
const isAuthenticated = status === "authenticated";
const isLoading = status === "loading" || meeting.loading;
const [showMeetingInfo, setShowMeetingInfo] = useState(false);
// Fetch room details using React Query
const roomQuery = useRoomGetByName(roomName);
const room = roomQuery.data;
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 isOwner =
auth.status === "authenticated" ? auth.user?.id === room?.user_id : false;
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}
/>
)}
{meeting?.response && (
<>
<Button
position="absolute"
top="56px"
right={showMeetingInfo ? "320px" : "8px"}
zIndex={1000}
colorPalette="blue"
size="sm"
onClick={() => setShowMeetingInfo(!showMeetingInfo)}
>
<Icon as={FaInfoCircle} />
Meeting Info
</Button>
{showMeetingInfo && (
<MeetingInfo meeting={meeting.response} isOwner={isOwner} />
)}
</>
)}
</>
)}
</>
);
return <RoomClient params={details.params} />;
}

View File

@@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
import { useError } from "../(errors)/errorContext";
import type { components } from "../reflector-api";
import { shouldShowError } from "../lib/errorUtils";
import { useError } from "../../../(errors)/errorContext";
import type { components } from "../../../reflector-api";
import { shouldShowError } from "../../../lib/errorUtils";
type Meeting = components["schemas"]["Meeting"];
import { useRoomsCreateMeeting } from "../lib/apiHooks";
import { useRoomsCreateMeeting } from "../../../lib/apiHooks";
import { notFound } from "next/navigation";
type ErrorMeeting = {
@@ -30,6 +30,7 @@ type SuccessMeeting = {
const useRoomMeeting = (
roomName: string | null | undefined,
meetingId?: string,
): ErrorMeeting | LoadingMeeting | SuccessMeeting => {
const [response, setResponse] = useState<Meeting | null>(null);
const [reload, setReload] = useState(0);
@@ -40,19 +41,9 @@ const useRoomMeeting = (
useEffect(() => {
if (!roomName) return;
// Check if meeting was pre-selected from meeting selection page
const storedMeeting = sessionStorage.getItem(`meeting_${roomName}`);
if (storedMeeting) {
try {
const meeting = JSON.parse(storedMeeting);
sessionStorage.removeItem(`meeting_${roomName}`); // Clean up
setResponse(meeting);
return;
} catch (e) {
console.error("Failed to parse stored meeting:", e);
}
}
// For any case where we need a meeting (with or without meetingId),
// we create a new meeting. The meetingId parameter can be used for
// additional logic in the future if needed (e.g., fetching existing meetings)
const createMeeting = async () => {
try {
const result = await createMeetingMutation.mutateAsync({
@@ -77,7 +68,7 @@ const useRoomMeeting = (
};
createMeeting();
}, [roomName, reload]);
}, [roomName, meetingId, reload]);
const loading = createMeetingMutation.isPending && !response;
const error = createMeetingMutation.error as Error | null;

View File

@@ -0,0 +1,226 @@
"use client";
import { useEffect, useState } from "react";
import {
Box,
Spinner,
Text,
VStack,
Button,
HStack,
Badge,
} from "@chakra-ui/react";
import { useRouter } from "next/navigation";
import { useRoomGetByName } from "../../../lib/apiHooks";
import MinimalHeader from "../../../components/MinimalHeader";
import { Metadata } from "next";
interface WaitPageProps {
params: {
roomName: string;
eventId: string;
};
}
const formatDateTime = (date: string | Date) => {
const d = new Date(date);
return d.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const formatCountdown = (startTime: string | Date) => {
const now = new Date();
const start = new Date(startTime);
const diff = start.getTime() - now.getTime();
if (diff <= 0) return "Meeting should start now";
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `Starts in ${days}d ${hours % 24}h ${minutes % 60}m`;
if (hours > 0) return `Starts in ${hours}h ${minutes % 60}m`;
return `Starts in ${minutes} minutes`;
};
// Generate dynamic metadata for the waiting page
export async function generateMetadata({
params,
}: WaitPageProps): Promise<Metadata> {
const { roomName } = params;
try {
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: `Waiting for Meeting - ${displayName}'s Room`,
description: `Waiting for upcoming meeting in ${displayName}'s room on Reflector.`,
};
}
} catch (error) {
console.error("Failed to fetch room for metadata:", error);
}
return {
title: `Waiting for Meeting - ${roomName}'s Room`,
description: `Waiting for upcoming meeting in ${roomName}'s room on Reflector.`,
};
}
export default function WaitPage({ params }: WaitPageProps) {
const { roomName, eventId } = params;
const router = useRouter();
const [countdown, setCountdown] = useState<string>("");
// Fetch room data
const roomQuery = useRoomGetByName(roomName);
const room = roomQuery.data;
// Mock event data - in a real implementation, you'd fetch the actual event
const mockEvent = {
id: eventId,
title: "Upcoming Meeting",
start_time: new Date(Date.now() + 15 * 60 * 1000), // 15 minutes from now
end_time: new Date(Date.now() + 75 * 60 * 1000), // 1 hour 15 minutes from now
description: "Meeting will start soon",
};
// Update countdown every second
useEffect(() => {
const timer = setInterval(() => {
setCountdown(formatCountdown(mockEvent.start_time));
}, 1000);
return () => clearInterval(timer);
}, [mockEvent.start_time]);
// Redirect to selection if room not found
useEffect(() => {
if (roomQuery.isError) {
router.push(`/${roomName}`);
}
}, [roomQuery.isError, router, roomName]);
const handleJoinEarly = () => {
// In a real implementation, this would create a meeting and join
alert("Join early functionality not yet implemented");
};
const handleBackToSelection = () => {
router.push(`/${roomName}`);
};
if (roomQuery.isLoading) {
return (
<Box display="flex" flexDirection="column" minH="100vh">
<MinimalHeader
roomName={roomName}
displayName={room?.display_name || room?.name}
showLeaveButton={false}
/>
<Box
display="flex"
justifyContent="center"
alignItems="center"
flex="1"
bg="gray.50"
p={4}
>
<VStack gap={4}>
<Spinner color="blue.500" size="xl" />
<Text fontSize="lg">Loading...</Text>
</VStack>
</Box>
</Box>
);
}
return (
<Box display="flex" flexDirection="column" minH="100vh">
<MinimalHeader
roomName={roomName}
displayName={room?.display_name || room?.name}
/>
<Box flex="1" bg="gray.50" p={4}>
<VStack gap={6} align="stretch" maxW="container.md" mx="auto" pt={8}>
<Box textAlign="center">
<Text fontSize="3xl" fontWeight="bold" mb={2}>
{mockEvent.title}
</Text>
<Badge colorScheme="orange" fontSize="lg" px={4} py={2}>
{countdown}
</Badge>
</Box>
<Box
bg="white"
borderRadius="md"
p={8}
textAlign="center"
boxShadow="sm"
>
<VStack gap={6}>
<VStack gap={2}>
<Text fontSize="lg" fontWeight="semibold">
Meeting Details
</Text>
<Text color="gray.600">
{formatDateTime(mockEvent.start_time)} -{" "}
{formatDateTime(mockEvent.end_time)}
</Text>
{mockEvent.description && (
<Text fontSize="sm" color="gray.500">
{mockEvent.description}
</Text>
)}
</VStack>
<Box h="1px" bg="gray.200" w="100%" />
<VStack gap={4}>
<Text fontSize="md" color="gray.600">
The meeting hasn't started yet. You can wait here or come back
later.
</Text>
<HStack gap={4}>
<Button
colorScheme="blue"
onClick={handleJoinEarly}
size="lg"
>
Join Early
</Button>
<Button
variant="outline"
onClick={handleBackToSelection}
size="lg"
>
Back to Meetings
</Button>
</HStack>
</VStack>
</VStack>
</Box>
</VStack>
</Box>
</Box>
);
}