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

@@ -10,6 +10,7 @@ from reflector.db.meetings import (
meeting_consent_controller, meeting_consent_controller,
meetings_controller, meetings_controller,
) )
from reflector.db.rooms import rooms_controller
router = APIRouter() router = APIRouter()
@@ -41,3 +42,38 @@ async def meeting_audio_consent(
updated_consent = await meeting_consent_controller.upsert(consent) updated_consent = await meeting_consent_controller.upsert(consent)
return {"status": "success", "consent_id": updated_consent.id} return {"status": "success", "consent_id": updated_consent.id}
@router.patch("/meetings/{meeting_id}/deactivate")
async def meeting_deactivate(
meeting_id: str,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
):
"""Deactivate a meeting (owner only)"""
meeting = await meetings_controller.get_by_id(meeting_id)
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
if not meeting.is_active:
raise HTTPException(status_code=400, detail="Meeting is already inactive")
# Check if user is the meeting owner or room owner
user_id = user["sub"] if user else None
if not user_id:
raise HTTPException(status_code=401, detail="Authentication required")
# Get room to check ownership
room = await rooms_controller.get_by_id(meeting.room_id)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
# Only room owner or meeting creator can deactivate
if user_id != room.user_id and user_id != meeting.user_id:
raise HTTPException(
status_code=403, detail="Only the room owner can deactivate meetings"
)
# Deactivate the meeting
await meetings_controller.update_meeting(meeting_id, is_active=False)
return {"status": "success", "meeting_id": meeting_id}

View File

@@ -19,7 +19,7 @@ ATTENDEE;CN=Mathieu Virbel;PARTSTAT=ACCEPTED:MAILTO:mathieu@monadical.com
DTEND;TZID=America/Costa_Rica:20250819T143000 DTEND;TZID=America/Costa_Rica:20250819T143000
DTSTAMP:20250819T155951Z DTSTAMP:20250819T155951Z
DTSTART;TZID=America/Costa_Rica:20250819T140000 DTSTART;TZID=America/Costa_Rica:20250819T140000
LOCATION:http://localhost:1250/room/mathieu LOCATION:http://localhost:1250/mathieu
ORGANIZER;CN=Mathieu Virbel:MAILTO:mathieu@monadical.com ORGANIZER;CN=Mathieu Virbel:MAILTO:mathieu@monadical.com
SEQUENCE:1 SEQUENCE:1
SUMMARY:Checkin SUMMARY:Checkin

View File

@@ -9,7 +9,7 @@ ATTENDEE:MAILTO:alice@example.com,bob@example.com,charlie@example.com,diana@exam
DTEND:20250819T190000Z DTEND:20250819T190000Z
DTSTAMP:20250819T174000Z DTSTAMP:20250819T174000Z
DTSTART:20250819T180000Z DTSTART:20250819T180000Z
LOCATION:http://localhost:1250/room/test-room LOCATION:http://localhost:1250/test-room
ORGANIZER;CN=Test Organizer:MAILTO:organizer@example.com ORGANIZER;CN=Test Organizer:MAILTO:organizer@example.com
SEQUENCE:1 SEQUENCE:1
SUMMARY:Test Meeting with Many Attendees SUMMARY:Test Meeting with Many Attendees

View File

@@ -51,7 +51,7 @@ async def test_attendee_parsing_bug():
calendar = sync_service.fetch_service.parse_ics(ics_content) calendar = sync_service.fetch_service.parse_ics(ics_content)
from reflector.settings import settings from reflector.settings import settings
room_url = f"{settings.BASE_URL}/room/{room.name}" room_url = f"{settings.BASE_URL}/{room.name}"
print(f"Room URL being used for matching: {room_url}") print(f"Room URL being used for matching: {room_url}")
print(f"ICS content:\n{ics_content}") print(f"ICS content:\n{ics_content}")

View File

@@ -36,7 +36,7 @@ async def test_calendar_event_create():
description="Weekly team sync", description="Weekly team sync",
start_time=now + timedelta(hours=1), start_time=now + timedelta(hours=1),
end_time=now + timedelta(hours=2), end_time=now + timedelta(hours=2),
location=f"https://example.com/room/{room.name}", location=f"https://example.com/{room.name}",
attendees=[ attendees=[
{"email": "alice@example.com", "name": "Alice", "status": "ACCEPTED"}, {"email": "alice@example.com", "name": "Alice", "status": "ACCEPTED"},
{"email": "bob@example.com", "name": "Bob", "status": "TENTATIVE"}, {"email": "bob@example.com", "name": "Bob", "status": "TENTATIVE"},

View File

@@ -108,7 +108,7 @@ async def test_trigger_ics_sync(authenticated_client):
event.add("summary", "API Test Meeting") event.add("summary", "API Test Meeting")
from reflector.settings import settings from reflector.settings import settings
event.add("location", f"{settings.BASE_URL}/room/{room.name}") event.add("location", f"{settings.BASE_URL}/{room.name}")
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
event.add("dtstart", now + timedelta(hours=1)) event.add("dtstart", now + timedelta(hours=1))
event.add("dtend", now + timedelta(hours=2)) event.add("dtend", now + timedelta(hours=2))

View File

@@ -1,356 +0,0 @@
"use client";
import {
Box,
VStack,
Heading,
Text,
HStack,
Badge,
Spinner,
Flex,
Link,
Button,
IconButton,
Tooltip,
Wrap,
} from "@chakra-ui/react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { FaSync, FaClock, FaUsers, FaEnvelope } from "react-icons/fa";
import { LuArrowLeft } from "react-icons/lu";
import {
useRoomCalendarEvents,
useRoomIcsSync,
} from "../../../../lib/apiHooks";
import type { components } from "../../../../reflector-api";
type CalendarEventResponse = components["schemas"]["CalendarEventResponse"];
export default function RoomCalendarPage() {
const params = useParams();
const router = useRouter();
const roomName = params.roomName as string;
const [syncing, setSyncing] = useState(false);
// React Query hooks
const eventsQuery = useRoomCalendarEvents(roomName);
const syncMutation = useRoomIcsSync();
const events = eventsQuery.data || [];
const loading = eventsQuery.isLoading;
const error = eventsQuery.error ? "Failed to load calendar events" : null;
const handleSync = async () => {
try {
setSyncing(true);
await syncMutation.mutateAsync({
params: {
path: { room_name: roomName },
},
});
// Refetch events after sync
await eventsQuery.refetch();
} catch (err: any) {
console.error("Sync failed:", err);
} finally {
setSyncing(false);
}
};
const formatEventTime = (start: string, end: string) => {
const startDate = new Date(start);
const endDate = new Date(end);
const options: Intl.DateTimeFormatOptions = {
hour: "2-digit",
minute: "2-digit",
};
const dateOptions: Intl.DateTimeFormatOptions = {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
};
const isSameDay = startDate.toDateString() === endDate.toDateString();
if (isSameDay) {
return `${startDate.toLocaleDateString(undefined, dateOptions)}${startDate.toLocaleTimeString(undefined, options)} - ${endDate.toLocaleTimeString(undefined, options)}`;
} else {
return `${startDate.toLocaleDateString(undefined, dateOptions)} ${startDate.toLocaleTimeString(undefined, options)} - ${endDate.toLocaleDateString(undefined, dateOptions)} ${endDate.toLocaleTimeString(undefined, options)}`;
}
};
const isEventActive = (start: string, end: string) => {
const now = new Date();
const startDate = new Date(start);
const endDate = new Date(end);
return now >= startDate && now <= endDate;
};
const isEventUpcoming = (start: string) => {
const now = new Date();
const startDate = new Date(start);
const hourFromNow = new Date(now.getTime() + 60 * 60 * 1000);
return startDate > now && startDate <= hourFromNow;
};
const getAttendeeDisplay = (attendee: any) => {
// Use name if available, otherwise use email
const displayName = attendee.name || attendee.email || "Unknown";
// Extract just the name part if it's in "Name <email>" format
const cleanName = displayName.replace(/<.*>/, "").trim();
return cleanName;
};
const getAttendeeEmail = (attendee: any) => {
return attendee.email || "";
};
const renderAttendees = (attendees: any[]) => {
if (!attendees || attendees.length === 0) return null;
return (
<HStack fontSize="sm" color="gray.600" flexWrap="wrap">
<FaUsers />
<Text>Attendees:</Text>
<Wrap gap={2}>
{attendees.map((attendee, index) => {
const email = getAttendeeEmail(attendee);
const display = getAttendeeDisplay(attendee);
if (email && email !== display) {
return (
<Tooltip
key={index}
content={
<HStack>
<FaEnvelope size="12" />
<Text>{email}</Text>
</HStack>
}
>
<Badge variant="subtle" colorPalette="blue" cursor="help">
{display}
</Badge>
</Tooltip>
);
} else {
return (
<Badge key={index} variant="subtle" colorPalette="blue">
{display}
</Badge>
);
}
})}
</Wrap>
</HStack>
);
};
const sortedEvents = [...events].sort(
(a, b) =>
new Date(a.start_time).getTime() - new Date(b.start_time).getTime(),
);
// Separate events by status
const now = new Date();
const activeEvents = sortedEvents.filter((e) =>
isEventActive(e.start_time, e.end_time),
);
const upcomingEvents = sortedEvents.filter(
(e) => new Date(e.start_time) > now,
);
const pastEvents = sortedEvents
.filter((e) => new Date(e.end_time) < now)
.reverse();
return (
<Box w={{ base: "full", md: "container.xl" }} mx="auto" pt={2}>
<VStack align="stretch" gap={6}>
<Flex justify="space-between" align="center">
<HStack gap={3}>
<IconButton
aria-label="Back to rooms"
title="Back to rooms"
size="sm"
variant="ghost"
onClick={() => router.push("/rooms")}
>
<LuArrowLeft />
</IconButton>
<Heading size="lg">Calendar for {roomName}</Heading>
</HStack>
<Button colorPalette="blue" onClick={handleSync} disabled={syncing}>
{syncing ? <Spinner size="sm" /> : <FaSync />}
Force Sync
</Button>
</Flex>
{error && (
<Box
p={4}
borderRadius="md"
bg="red.50"
borderLeft="4px solid"
borderColor="red.400"
>
<Text fontWeight="semibold" color="red.800">
Error
</Text>
<Text color="red.700">{error}</Text>
</Box>
)}
{loading ? (
<Flex justify="center" py={8}>
<Spinner size="xl" />
</Flex>
) : events.length === 0 ? (
<Box bg="white" borderRadius="lg" boxShadow="md" p={6}>
<Text textAlign="center" color="gray.500">
No calendar events found. Make sure your calendar is configured
and synced.
</Text>
</Box>
) : (
<VStack align="stretch" gap={6}>
{/* Active Events */}
{activeEvents.length > 0 && (
<Box>
<Heading size="md" mb={3} color="green.600">
Active Now
</Heading>
<VStack align="stretch" gap={3}>
{activeEvents.map((event) => (
<Box
key={event.id}
bg="white"
borderRadius="lg"
boxShadow="md"
p={6}
borderColor="green.200"
borderWidth={2}
>
<Flex justify="space-between" align="start">
<VStack align="start" gap={2} flex={1}>
<HStack>
<Heading size="sm">
{event.title || "Untitled Event"}
</Heading>
<Badge colorPalette="green">Active</Badge>
</HStack>
<HStack fontSize="sm" color="gray.600">
<FaClock />
<Text>
{formatEventTime(
event.start_time,
event.end_time,
)}
</Text>
</HStack>
{event.description && (
<Text fontSize="sm" color="gray.700" noOfLines={2}>
{event.description}
</Text>
)}
{renderAttendees(event.attendees)}
</VStack>
<Link href={`/${roomName}`}>
<Button size="sm" colorPalette="green">
Join Room
</Button>
</Link>
</Flex>
</Box>
))}
</VStack>
</Box>
)}
{/* Upcoming Events */}
{upcomingEvents.length > 0 && (
<Box>
<Heading size="md" mb={3}>
Upcoming Events
</Heading>
<VStack align="stretch" spacing={3}>
{upcomingEvents.map((event) => (
<Card.Root key={event.id}>
<Card.Body>
<VStack align="start" spacing={2}>
<HStack>
<Heading size="sm">
{event.title || "Untitled Event"}
</Heading>
{isEventUpcoming(event.start_time) && (
<Badge colorPalette="orange">Starting Soon</Badge>
)}
</HStack>
<HStack fontSize="sm" color="gray.600">
<FaClock />
<Text>
{formatEventTime(
event.start_time,
event.end_time,
)}
</Text>
</HStack>
{event.description && (
<Text fontSize="sm" color="gray.700" noOfLines={2}>
{event.description}
</Text>
)}
{renderAttendees(event.attendees)}
</VStack>
</Card.Body>
</Card.Root>
))}
</VStack>
</Box>
)}
{/* Past Events */}
{pastEvents.length > 0 && (
<Box>
<Heading size="md" mb={3} color="gray.500">
Past Events
</Heading>
<VStack align="stretch" spacing={3}>
{pastEvents.slice(0, 5).map((event) => (
<Card.Root key={event.id} opacity={0.7}>
<Card.Body>
<VStack align="start" spacing={2}>
<Heading size="sm">
{event.title || "Untitled Event"}
</Heading>
<HStack fontSize="sm" color="gray.600">
<FaClock />
<Text>
{formatEventTime(
event.start_time,
event.end_time,
)}
</Text>
</HStack>
{renderAttendees(event.attendees)}
</VStack>
</Card.Body>
</Card.Root>
))}
{pastEvents.length > 5 && (
<Text fontSize="sm" color="gray.500" textAlign="center">
And {pastEvents.length - 5} more past events...
</Text>
)}
</VStack>
</Box>
)}
</VStack>
)}
</VStack>
</Box>
);
}

View File

@@ -7,11 +7,19 @@ import {
IconButton, IconButton,
Text, Text,
Spinner, Spinner,
Badge,
VStack,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { LuLink } from "react-icons/lu"; import { LuLink } from "react-icons/lu";
import type { components } from "../../../reflector-api"; import type { components } from "../../../reflector-api";
import {
useRoomActiveMeetings,
useRoomUpcomingMeetings,
} from "../../../lib/apiHooks";
type Room = components["schemas"]["Room"]; type Room = components["schemas"]["Room"];
type Meeting = components["schemas"]["Meeting"];
type CalendarEventResponse = components["schemas"]["CalendarEventResponse"];
import { RoomActionsMenu } from "./RoomActionsMenu"; import { RoomActionsMenu } from "./RoomActionsMenu";
interface RoomTableProps { interface RoomTableProps {
@@ -63,6 +71,70 @@ const getZulipDisplay = (
return "Enabled"; return "Enabled";
}; };
function MeetingStatus({ roomName }: { roomName: string }) {
const activeMeetingsQuery = useRoomActiveMeetings(roomName);
const upcomingMeetingsQuery = useRoomUpcomingMeetings(roomName);
const activeMeetings = activeMeetingsQuery.data || [];
const upcomingMeetings = upcomingMeetingsQuery.data || [];
if (activeMeetingsQuery.isLoading || upcomingMeetingsQuery.isLoading) {
return <Spinner size="sm" />;
}
if (activeMeetings.length > 0) {
const meeting = activeMeetings[0];
const title = (meeting.calendar_metadata as any)?.title || "Active Meeting";
return (
<VStack gap={1} alignItems="start">
<Badge colorScheme="green" size="sm">
Active
</Badge>
<Text fontSize="xs" color="gray.600" lineHeight={1}>
{title}
</Text>
<Text fontSize="xs" color="gray.500" lineHeight={1}>
{meeting.num_clients} participants
</Text>
</VStack>
);
}
if (upcomingMeetings.length > 0) {
const event = upcomingMeetings[0];
const startTime = new Date(event.start_time);
const now = new Date();
const diffMinutes = Math.floor(
(startTime.getTime() - now.getTime()) / 60000,
);
return (
<VStack gap={1} alignItems="start">
<Badge colorScheme="orange" size="sm">
{diffMinutes < 60 ? `In ${diffMinutes}m` : "Upcoming"}
</Badge>
<Text fontSize="xs" color="gray.600" lineHeight={1}>
{event.title || "Scheduled Meeting"}
</Text>
<Text fontSize="xs" color="gray.500" lineHeight={1}>
{startTime.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
month: "short",
day: "numeric",
})}
</Text>
</VStack>
);
}
return (
<Text fontSize="xs" color="gray.500">
No meetings
</Text>
);
}
export function RoomTable({ export function RoomTable({
rooms, rooms,
linkCopied, linkCopied,
@@ -97,13 +169,16 @@ export function RoomTable({
<Table.ColumnHeader width="250px" fontWeight="600"> <Table.ColumnHeader width="250px" fontWeight="600">
Room Name Room Name
</Table.ColumnHeader> </Table.ColumnHeader>
<Table.ColumnHeader width="250px" fontWeight="600"> <Table.ColumnHeader width="200px" fontWeight="600">
Zulip Current Meeting
</Table.ColumnHeader>
<Table.ColumnHeader width="150px" fontWeight="600">
Room Size
</Table.ColumnHeader> </Table.ColumnHeader>
<Table.ColumnHeader width="200px" fontWeight="600"> <Table.ColumnHeader width="200px" fontWeight="600">
Zulip
</Table.ColumnHeader>
<Table.ColumnHeader width="120px" fontWeight="600">
Room Size
</Table.ColumnHeader>
<Table.ColumnHeader width="150px" fontWeight="600">
Recording Recording
</Table.ColumnHeader> </Table.ColumnHeader>
<Table.ColumnHeader <Table.ColumnHeader
@@ -118,6 +193,9 @@ export function RoomTable({
<Table.Cell> <Table.Cell>
<Link href={`/${room.name}`}>{room.name}</Link> <Link href={`/${room.name}`}>{room.name}</Link>
</Table.Cell> </Table.Cell>
<Table.Cell>
<MeetingStatus roomName={room.name} />
</Table.Cell>
<Table.Cell> <Table.Cell>
{getZulipDisplay( {getZulipDisplay(
room.zulip_auto_post, room.zulip_auto_post,

View File

@@ -448,7 +448,7 @@ export default function RoomsList() {
<Tabs.Trigger value="general">General</Tabs.Trigger> <Tabs.Trigger value="general">General</Tabs.Trigger>
<Tabs.Trigger value="calendar">Calendar</Tabs.Trigger> <Tabs.Trigger value="calendar">Calendar</Tabs.Trigger>
<Tabs.Trigger value="share">Share</Tabs.Trigger> <Tabs.Trigger value="share">Share</Tabs.Trigger>
<Tabs.Trigger value="webhook">Webhook</Tabs.Trigger> <Tabs.Trigger value="webhook">WebHook</Tabs.Trigger>
</Tabs.List> </Tabs.List>
<Tabs.Content value="general" pt={6}> <Tabs.Content value="general" pt={6}>

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, Spinner,
Badge, Badge,
Icon, Icon,
Flex,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import React from "react"; import React from "react";
import { FaUsers, FaClock, FaCalendarAlt, FaPlus } from "react-icons/fa"; import { FaUsers, FaClock, FaCalendarAlt, FaPlus } from "react-icons/fa";
import { LuX } from "react-icons/lu";
import type { components } from "../reflector-api"; import type { components } from "../reflector-api";
import { import {
useRoomActiveMeetings, useRoomActiveMeetings,
useRoomUpcomingMeetings, useRoomUpcomingMeetings,
useRoomJoinMeeting, useRoomJoinMeeting,
useMeetingDeactivate,
useRoomGetByName,
} from "../lib/apiHooks"; } from "../lib/apiHooks";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link";
type Meeting = components["schemas"]["Meeting"]; type Meeting = components["schemas"]["Meeting"];
type CalendarEventResponse = components["schemas"]["CalendarEventResponse"]; type CalendarEventResponse = components["schemas"]["CalendarEventResponse"];
@@ -26,6 +31,7 @@ type CalendarEventResponse = components["schemas"]["CalendarEventResponse"];
interface MeetingSelectionProps { interface MeetingSelectionProps {
roomName: string; roomName: string;
isOwner: boolean; isOwner: boolean;
isSharedRoom: boolean;
onMeetingSelect: (meeting: Meeting) => void; onMeetingSelect: (meeting: Meeting) => void;
onCreateUnscheduled: () => void; onCreateUnscheduled: () => void;
} }
@@ -59,21 +65,29 @@ const formatCountdown = (startTime: string | Date) => {
export default function MeetingSelection({ export default function MeetingSelection({
roomName, roomName,
isOwner, isOwner,
isSharedRoom,
onMeetingSelect, onMeetingSelect,
onCreateUnscheduled, onCreateUnscheduled,
}: MeetingSelectionProps) { }: MeetingSelectionProps) {
const router = useRouter(); const router = useRouter();
// Use React Query hooks for data fetching // Use React Query hooks for data fetching
const roomQuery = useRoomGetByName(roomName);
const activeMeetingsQuery = useRoomActiveMeetings(roomName); const activeMeetingsQuery = useRoomActiveMeetings(roomName);
const upcomingMeetingsQuery = useRoomUpcomingMeetings(roomName); const upcomingMeetingsQuery = useRoomUpcomingMeetings(roomName);
const joinMeetingMutation = useRoomJoinMeeting(); const joinMeetingMutation = useRoomJoinMeeting();
const deactivateMeetingMutation = useMeetingDeactivate();
const room = roomQuery.data;
const activeMeetings = activeMeetingsQuery.data || []; const activeMeetings = activeMeetingsQuery.data || [];
const upcomingEvents = upcomingMeetingsQuery.data || []; const upcomingEvents = upcomingMeetingsQuery.data || [];
const loading = const loading =
activeMeetingsQuery.isLoading || upcomingMeetingsQuery.isLoading; roomQuery.isLoading ||
const error = activeMeetingsQuery.error || upcomingMeetingsQuery.error; activeMeetingsQuery.isLoading ||
upcomingMeetingsQuery.isLoading;
const error =
roomQuery.error || activeMeetingsQuery.error || upcomingMeetingsQuery.error;
const handleJoinMeeting = async (meetingId: string) => { const handleJoinMeeting = async (meetingId: string) => {
try { try {
@@ -94,7 +108,21 @@ export default function MeetingSelection({
const handleJoinUpcoming = (event: CalendarEventResponse) => { const handleJoinUpcoming = (event: CalendarEventResponse) => {
// Navigate to waiting page with event info // 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) { 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 ( return (
<VStack gap={6} align="stretch" p={6}> <Flex
<Box> flexDir="column"
<Text fontSize="2xl" fontWeight="bold" mb={4}> w={{ base: "full", md: "container.xl" }}
Select a Meeting 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> </Text>
</Flex>
{/* Active Meetings */} {/* Active Meetings */}
{activeMeetings.length > 0 && ( {activeMeetings.length > 0 && (
<> <VStack align="stretch" gap={4} mb={6}>
<Text fontSize="lg" fontWeight="semibold" mb={3}> <Text fontSize="md" fontWeight="semibold" color="gray.700">
Active Meetings Active Meetings
</Text> </Text>
<VStack gap={3} mb={6}> {activeMeetings.map((meeting) => (
{activeMeetings.map((meeting) => ( <Box
<Box key={meeting.id}
key={meeting.id} width="100%"
width="100%" bg="white"
border="1px solid" border="1px solid"
borderColor="gray.200" borderColor="gray.200"
borderRadius="md" borderRadius="md"
p={4} p={4}
> _hover={{ borderColor: "gray.300" }}
<HStack justify="space-between" align="start"> >
<VStack align="start" gap={2} flex={1}> <HStack justify="space-between" align="start">
<HStack> <VStack align="start" gap={2} flex={1}>
<Icon as={FaCalendarAlt} color="blue.500" /> <HStack>
<Text fontWeight="semibold"> <Icon as={FaCalendarAlt} color="blue.500" />
{(meeting.calendar_metadata as any)?.title || <Text fontWeight="semibold">
"Meeting"} {(meeting.calendar_metadata as any)?.title || "Meeting"}
</Text> </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>
</HStack> </HStack>
</Box>
))}
</VStack>
</>
)}
{/* Upcoming Meetings */} {isOwner &&
{upcomingEvents.length > 0 && ( (meeting.calendar_metadata as any)?.description && (
<> <Text fontSize="sm" color="gray.600">
<Text fontSize="lg" fontWeight="semibold" mb={3}> {(meeting.calendar_metadata as any).description}
Upcoming Meetings </Text>
</Text> )}
<VStack gap={3} mb={6}>
{upcomingEvents.map((event) => ( <HStack gap={4} fontSize="sm" color="gray.500">
<Box <HStack>
key={event.id} <Icon as={FaUsers} />
width="100%" <Text>{meeting.num_clients} participants</Text>
border="1px solid" </HStack>
borderColor="gray.200" <HStack>
borderRadius="md" <Icon as={FaClock} />
p={4} <Text>Started {formatDateTime(meeting.start_date)}</Text>
bg="gray.50" </HStack>
> </HStack>
<HStack justify="space-between" align="start">
<VStack align="start" gap={2} flex={1}> {isOwner && (meeting.calendar_metadata as any)?.attendees && (
<HStack> <HStack gap={2} flexWrap="wrap">
<Icon as={FaCalendarAlt} color="orange.500" /> {(meeting.calendar_metadata as any).attendees
<Text fontWeight="semibold"> .slice(0, 3)
{event.title || "Scheduled Meeting"} .map((attendee: any, idx: number) => (
</Text> <Badge key={idx} colorScheme="green" fontSize="xs">
<Badge colorScheme="orange" fontSize="xs"> {attendee.name || attendee.email}
{formatCountdown(event.start_time)} </Badge>
))}
{(meeting.calendar_metadata as any).attendees.length >
3 && (
<Badge colorScheme="gray" fontSize="xs">
+
{(meeting.calendar_metadata as any).attendees.length -
3}{" "}
more
</Badge> </Badge>
</HStack>
{isOwner && event.description && (
<Text fontSize="sm" color="gray.600">
{event.description}
</Text>
)} )}
</HStack>
)}
</VStack>
<HStack gap={4} fontSize="sm" color="gray.500"> <HStack gap={2}>
<Text> <Button
{formatDateTime(event.start_time)} -{" "} colorScheme="blue"
{formatDateTime(event.end_time)} size="md"
</Text> onClick={() => handleJoinMeeting(meeting.id)}
</HStack> >
Join Now
{isOwner && event.attendees && ( </Button>
<HStack gap={2} flexWrap="wrap"> {isOwner && (
{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 <Button
variant="outline" variant="outline"
colorScheme="orange" colorScheme="red"
size="md" size="md"
onClick={() => handleJoinUpcoming(event)} onClick={() => handleEndMeeting(meeting.id)}
isLoading={deactivateMeetingMutation.isPending}
> >
Join Early <Icon as={LuX} />
End Meeting
</Button> </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> </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 */} <HStack gap={4} fontSize="sm" color="gray.500">
<Box width="100%" bg="gray.100" borderRadius="md" p={4}> <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"> <HStack justify="space-between" align="center">
<VStack align="start" gap={1}> <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"> <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> </Text>
</VStack> </VStack>
<Button colorScheme="green" onClick={onCreateUnscheduled}> <Button colorScheme="green" onClick={onCreateUnscheduled}>
<FaPlus />
Create Meeting Create Meeting
</Button> </Button>
</HStack> </HStack>
</Box> </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> </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 { Metadata } from "next";
import RoomClient from "./RoomClient";
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"];
export type RoomDetails = { export type RoomDetails = {
params: { 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 // Generate dynamic metadata for the room selection page
const useConsentWherebyFocusManagement = ( export async function generateMetadata({
acceptButtonRef: RefObject<HTMLButtonElement>, params,
wherebyRef: RefObject<HTMLElement>, }: RoomDetails): Promise<Metadata> {
) => { const { roomName } = params;
const currentFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (acceptButtonRef.current) {
acceptButtonRef.current.focus();
} else {
console.error(
"accept button ref not available yet for focus management - seems to be illegal state",
);
}
const handleWherebyReady = () => { try {
console.log("whereby ready - refocusing consent button"); // Fetch room data server-side for metadata
currentFocusRef.current = document.activeElement as HTMLElement; const response = await fetch(
if (acceptButtonRef.current) { `${process.env.NEXT_PUBLIC_REFLECTOR_API_URL}/v1/rooms/name/${roomName}`,
acceptButtonRef.current.focus(); {
} headers: {
}; "Content-Type": "application/json",
},
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>
);
}, },
}); );
// Set modal state when toast is dismissed if (response.ok) {
toastId.then((id) => { const room = await response.json();
const checkToastStatus = setInterval(() => { const displayName = room.display_name || room.name;
if (!toaster.isActive(id)) { return {
setModalOpen(false); title: `${displayName} Room - Select a Meeting`,
clearInterval(checkToastStatus); description: `Join a meeting in ${displayName}'s room on Reflector.`,
} };
}, 100); }
}); } catch (error) {
console.error("Failed to fetch room for metadata:", error);
// 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;
} }
return ( // Fallback if room fetch fails
<Button return {
position="absolute" title: `${roomName} Room - Select a Meeting`,
top="56px" description: `Join a meeting in ${roomName}'s room on Reflector.`,
left="8px" };
zIndex={1000}
colorPalette="blue"
size="sm"
onClick={showConsentModal}
>
Meeting is being recorded
<Icon as={FaBars} ml={2} />
</Button>
);
} }
const recordingTypeRequiresConsent = (
recordingType: NonNullable<Meeting["recording_type"]>,
) => {
return recordingType === "cloud";
};
// next throws even with "use client"
const useWhereby = () => {
const [wherebyLoaded, setWherebyLoaded] = useState(false);
useEffect(() => {
if (typeof window !== "undefined") {
import("@whereby.com/browser-sdk/embed")
.then(() => {
setWherebyLoaded(true);
})
.catch(console.error.bind(console));
}
}, []);
return wherebyLoaded;
};
export default function Room(details: RoomDetails) { export default function Room(details: RoomDetails) {
const wherebyLoaded = useWhereby(); return <RoomClient params={details.params} />;
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} />
)}
</>
)}
</>
)}
</>
);
} }

View File

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

View File

@@ -0,0 +1,75 @@
"use client";
import { Flex, Link, Button, Text } from "@chakra-ui/react";
import NextLink from "next/link";
import Image from "next/image";
import { useRouter } from "next/navigation";
interface MinimalHeaderProps {
roomName: string;
displayName?: string;
showLeaveButton?: boolean;
}
export default function MinimalHeader({
roomName,
displayName,
showLeaveButton = true,
}: MinimalHeaderProps) {
const router = useRouter();
const handleLeaveMeeting = () => {
router.push(`/${roomName}`);
};
const roomTitle = displayName
? displayName.endsWith("'s") || displayName.endsWith("s")
? `${displayName} Room`
: `${displayName}'s Room`
: `${roomName} Room`;
return (
<Flex
as="header"
justify="space-between"
alignItems="center"
w="100%"
py="2"
px="4"
borderBottom="1px solid"
borderColor="gray.200"
bg="white"
position="sticky"
top="0"
zIndex="10"
>
{/* Logo and Room Context */}
<Flex alignItems="center" gap={3}>
<Link as={NextLink} href="/" className="flex items-center">
<Image
src="/reach.svg"
width={24}
height={30}
className="h-8 w-auto"
alt="Reflector"
/>
</Link>
<Text fontSize="lg" fontWeight="semibold" color="gray.700">
{roomTitle}
</Text>
</Flex>
{/* Leave Meeting Button */}
{showLeaveButton && (
<Button
variant="outline"
colorScheme="gray"
size="sm"
onClick={handleLeaveMeeting}
>
Leave Meeting
</Button>
)}
</Flex>
);
}

View File

@@ -571,6 +571,35 @@ export function useMeetingAudioConsent() {
}); });
} }
export function useMeetingDeactivate() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation("patch", "/v1/meetings/{meeting_id}/deactivate", {
onError: (error) => {
setError(error as Error, "Failed to end meeting");
},
onSuccess: () => {
// Invalidate all meeting-related queries to refresh the UI
queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return (
Array.isArray(key) &&
(key.some(
(k) => typeof k === "string" && k.includes("/meetings/active"),
) ||
key.some(
(k) =>
typeof k === "string" && k.includes("/meetings/upcoming"),
))
);
},
});
},
});
}
export function useTranscriptWebRTC() { export function useTranscriptWebRTC() {
const { setError } = useError(); const { setError } = useError();

View File

@@ -41,6 +41,26 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/v1/meetings/{meeting_id}/deactivate": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/**
* Meeting Deactivate
* @description Deactivate a meeting (owner only)
*/
patch: operations["v1_meeting_deactivate"];
trace?: never;
};
"/v1/rooms": { "/v1/rooms": {
parameters: { parameters: {
query?: never; query?: never;
@@ -932,8 +952,7 @@ export interface components {
}; };
/** ICSSyncResult */ /** ICSSyncResult */
ICSSyncResult: { ICSSyncResult: {
/** Status */ status: components["schemas"]["SyncStatus"];
status: string;
/** Hash */ /** Hash */
hash?: string | null; hash?: string | null;
/** /**
@@ -941,6 +960,11 @@ export interface components {
* @default 0 * @default 0
*/ */
events_found: number; events_found: number;
/**
* Total Events
* @default 0
*/
total_events: number;
/** /**
* Events Created * Events Created
* @default 0 * @default 0
@@ -958,6 +982,8 @@ export interface components {
events_deleted: number; events_deleted: number;
/** Error */ /** Error */
error?: string | null; error?: string | null;
/** Reason */
reason?: string | null;
}; };
/** Meeting */ /** Meeting */
Meeting: { Meeting: {
@@ -1280,6 +1306,11 @@ export interface components {
/** Name */ /** Name */
name: string; name: string;
}; };
/**
* SyncStatus
* @enum {string}
*/
SyncStatus: "success" | "unchanged" | "error" | "skipped";
/** Topic */ /** Topic */
Topic: { Topic: {
/** Name */ /** Name */
@@ -1492,6 +1523,37 @@ export interface operations {
}; };
}; };
}; };
v1_meeting_deactivate: {
parameters: {
query?: never;
header?: never;
path: {
meeting_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
v1_rooms_list: { v1_rooms_list: {
parameters: { parameters: {
query?: { query?: {

View File

@@ -1,143 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Box, Spinner, VStack, Text } from "@chakra-ui/react";
import { useRouter } from "next/navigation";
import type { components } from "../../reflector-api";
import { useAuth } from "../../lib/AuthProvider";
import {
useRoomGetByName,
useRoomUpcomingMeetings,
useRoomActiveMeetings,
useRoomsCreateMeeting,
} from "../../lib/apiHooks";
import MeetingSelection from "../../[roomName]/MeetingSelection";
type Meeting = components["schemas"]["Meeting"];
type Room = components["schemas"]["Room"];
interface RoomPageProps {
params: {
roomName: string;
};
}
export default function RoomPage({ params }: RoomPageProps) {
const { roomName } = params;
const router = useRouter();
const auth = useAuth();
// React Query hooks
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 isLoading = roomQuery.isLoading;
const isCheckingMeetings =
(room?.ics_enabled &&
(activeMeetingsQuery.isLoading || upcomingMeetingsQuery.isLoading)) ||
createMeetingMutation.isPending;
const isOwner =
auth.status === "authenticated" && auth.user?.id === room?.user_id;
const handleMeetingSelect = (meeting: Meeting) => {
// Navigate to the classic room page with the meeting
// Store meeting in session storage for the classic page to use
sessionStorage.setItem(`meeting_${roomName}`, JSON.stringify(meeting));
router.push(`/${roomName}`);
};
const handleCreateUnscheduled = async () => {
try {
// Create a new unscheduled meeting
const meeting = await createMeetingMutation.mutateAsync({
params: {
path: { room_name: roomName },
},
});
handleMeetingSelect(meeting);
} catch (err) {
console.error("Failed to create meeting:", err);
}
};
// Auto-navigate logic based on query results
useEffect(() => {
if (!room || isLoading || isCheckingMeetings) return;
if (room.ics_enabled) {
// If there's only one active meeting and no upcoming, auto-join
if (activeMeetings.length === 1 && upcomingMeetings.length === 0) {
handleMeetingSelect(activeMeetings[0]);
} else if (activeMeetings.length === 0 && upcomingMeetings.length === 0) {
// No meetings, create unscheduled
handleCreateUnscheduled();
}
// Otherwise, show selection UI (handled by render)
} else {
// ICS not enabled, use traditional flow
handleCreateUnscheduled();
}
}, [room, activeMeetings, upcomingMeetings, isLoading, isCheckingMeetings]);
// Handle room not found
useEffect(() => {
if (roomQuery.isError) {
router.push("/rooms");
}
}, [roomQuery.isError, router]);
if (isLoading || isCheckingMeetings) {
return (
<Box
minH="100vh"
display="flex"
alignItems="center"
justifyContent="center"
bg="gray.50"
>
<VStack gap={4}>
<Spinner size="xl" color="blue.500" />
<Text>{isLoading ? "Loading room..." : "Checking meetings..."}</Text>
</VStack>
</Box>
);
}
if (!room) {
return (
<Box
minH="100vh"
display="flex"
alignItems="center"
justifyContent="center"
bg="gray.50"
>
<Text fontSize="lg">Room not found</Text>
</Box>
);
}
// Show meeting selection if ICS is enabled and we have multiple options
if (room.ics_enabled) {
return (
<Box minH="100vh" bg="gray.50">
<MeetingSelection
roomName={roomName}
isOwner={isOwner}
onMeetingSelect={handleMeetingSelect}
onCreateUnscheduled={handleCreateUnscheduled}
/>
</Box>
);
}
// Should not reach here - redirected above
return null;
}

View File

@@ -1,305 +0,0 @@
"use client";
import {
Box,
VStack,
HStack,
Text,
Spinner,
Button,
Icon,
} from "@chakra-ui/react";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { FaClock, FaArrowLeft } from "react-icons/fa";
import type { components } from "../../../reflector-api";
import {
useRoomUpcomingMeetings,
useRoomActiveMeetings,
useRoomJoinMeeting,
} from "../../../lib/apiHooks";
type CalendarEventResponse = components["schemas"]["CalendarEventResponse"];
interface WaitingPageProps {
params: {
roomName: string;
};
}
export default function WaitingPage({ params }: WaitingPageProps) {
const { roomName } = params;
const router = useRouter();
const searchParams = useSearchParams();
const eventId = searchParams.get("eventId");
const [event, setEvent] = useState<CalendarEventResponse | null>(null);
const [timeRemaining, setTimeRemaining] = useState<number>(0);
const [checkingMeeting, setCheckingMeeting] = useState(false);
// Use React Query hooks
const upcomingMeetingsQuery = useRoomUpcomingMeetings(roomName);
const activeMeetingsQuery = useRoomActiveMeetings(roomName);
const joinMeetingMutation = useRoomJoinMeeting();
const loading = upcomingMeetingsQuery.isLoading;
useEffect(() => {
if (!eventId || !upcomingMeetingsQuery.data) return;
const targetEvent = upcomingMeetingsQuery.data.find(
(e) => e.id === eventId,
);
if (targetEvent) {
setEvent(targetEvent);
} else if (!upcomingMeetingsQuery.isLoading) {
// Event not found or already started
router.push(`/room/${roomName}`);
}
}, [
eventId,
upcomingMeetingsQuery.data,
upcomingMeetingsQuery.isLoading,
router,
roomName,
]);
// Handle query errors
useEffect(() => {
if (upcomingMeetingsQuery.error) {
console.error("Failed to fetch event:", upcomingMeetingsQuery.error);
router.push(`/room/${roomName}`);
}
}, [upcomingMeetingsQuery.error, router, roomName]);
useEffect(() => {
if (!event) return;
const updateCountdown = () => {
const now = new Date();
const start = new Date(event.start_time);
const diff = Math.max(0, start.getTime() - now.getTime());
setTimeRemaining(diff);
// Check if meeting has started
if (diff <= 0) {
checkForActiveMeeting();
}
};
const checkForActiveMeeting = async () => {
if (checkingMeeting) return;
setCheckingMeeting(true);
try {
// Refetch active meetings to get latest data
const result = await activeMeetingsQuery.refetch();
if (!result.data) return;
// Find meeting for this calendar event
const calendarMeeting = result.data.find(
(m) => m.calendar_event_id === eventId,
);
if (calendarMeeting) {
// Meeting is now active, join it
const meeting = await joinMeetingMutation.mutateAsync({
params: {
path: { room_name: roomName, meeting_id: calendarMeeting.id },
},
});
// Navigate to the meeting room
router.push(`/${roomName}?meetingId=${meeting.id}`);
}
} catch (err) {
console.error("Failed to check for active meeting:", err);
} finally {
setCheckingMeeting(false);
}
};
// Update countdown every second
const interval = setInterval(updateCountdown, 1000);
// Check for meeting every 10 seconds when close to start time
let checkInterval: NodeJS.Timeout | null = null;
if (timeRemaining < 60000) {
// Less than 1 minute
checkInterval = setInterval(checkForActiveMeeting, 10000);
}
updateCountdown(); // Initial update
return () => {
clearInterval(interval);
if (checkInterval) clearInterval(checkInterval);
};
}, [
event,
eventId,
roomName,
checkingMeeting,
activeMeetingsQuery,
joinMeetingMutation,
]);
const formatTime = (ms: number) => {
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds
.toString()
.padStart(2, "0")}`;
}
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
const getProgressValue = () => {
if (!event) return 0;
const now = new Date();
const created = new Date(event.created_at);
const start = new Date(event.start_time);
const totalTime = start.getTime() - created.getTime();
const elapsed = now.getTime() - created.getTime();
return Math.min(100, (elapsed / totalTime) * 100);
};
if (loading) {
return (
<Box
minH="100vh"
display="flex"
alignItems="center"
justifyContent="center"
bg="gray.50"
>
<VStack gap={4}>
<Spinner size="xl" color="blue.500" />
<Text>Loading meeting details...</Text>
</VStack>
</Box>
);
}
if (!event) {
return (
<Box
minH="100vh"
display="flex"
alignItems="center"
justifyContent="center"
bg="gray.50"
>
<VStack gap={4}>
<Text fontSize="lg">Meeting not found</Text>
<Button onClick={() => router.push(`/room/${roomName}`)}>
<FaArrowLeft />
Back to Room
</Button>
</VStack>
</Box>
);
}
return (
<Box
minH="100vh"
display="flex"
alignItems="center"
justifyContent="center"
bg="gray.50"
>
<Box
maxW="lg"
width="100%"
mx={4}
bg="white"
borderRadius="lg"
boxShadow="md"
p={6}
>
<VStack gap={6}>
<Icon as={FaClock} boxSize={16} color="blue.500" />
<VStack gap={2}>
<Text fontSize="2xl" fontWeight="bold">
{event.title || "Scheduled Meeting"}
</Text>
<Text color="gray.600" textAlign="center">
The meeting will start automatically when it's time
</Text>
</VStack>
<Box width="100%">
<Text
fontSize="4xl"
fontWeight="bold"
textAlign="center"
color="blue.600"
>
{formatTime(timeRemaining)}
</Text>
<Box
width="100%"
height="8px"
bg="gray.200"
borderRadius="full"
mt={4}
position="relative"
overflow="hidden"
>
<Box
width={`${getProgressValue()}%`}
height="100%"
bg="blue.500"
borderRadius="full"
transition="width 0.3s ease"
/>
</Box>
</Box>
{event.description && (
<Box width="100%" p={4} bg="gray.100" borderRadius="md">
<Text fontSize="sm" fontWeight="semibold" mb={1}>
Meeting Description
</Text>
<Text fontSize="sm" color="gray.700">
{event.description}
</Text>
</Box>
)}
<VStack gap={3} width="100%">
<Text fontSize="sm" color="gray.500">
Scheduled for {new Date(event.start_time).toLocaleString()}
</Text>
{checkingMeeting && (
<HStack gap={2}>
<Spinner size="sm" color="blue.500" />
<Text fontSize="sm" color="blue.600">
Checking if meeting has started...
</Text>
</HStack>
)}
</VStack>
<Button
variant="outline"
onClick={() => router.push(`/room/${roomName}`)}
width="100%"
>
<FaArrowLeft />
Back to Meeting Selection
</Button>
</VStack>
</Box>
</Box>
);
}