mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-21 12:49:06 +00:00
feat: improve calendar integration and meeting UI
- Refactor ICS sync tasks to use @asynctask decorator for cleaner async handling - Extract meeting creation logic into reusable function - Improve meeting selection UI with distinct current/upcoming sections - Add early join functionality for upcoming meetings within 5-minute window - Simplify non-ICS room workflow with direct Whereby embed - Fix import paths and component organization 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,9 @@ import structlog
|
|||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from celery.utils.log import get_task_logger
|
from celery.utils.log import get_task_logger
|
||||||
|
|
||||||
|
from reflector.asynctask import asynctask
|
||||||
from reflector.db import get_database
|
from reflector.db import get_database
|
||||||
|
from reflector.db.calendar_events import calendar_events_controller
|
||||||
from reflector.db.meetings import meetings_controller
|
from reflector.db.meetings import meetings_controller
|
||||||
from reflector.db.rooms import rooms, rooms_controller
|
from reflector.db.rooms import rooms, rooms_controller
|
||||||
from reflector.services.ics_sync import ics_sync_service
|
from reflector.services.ics_sync import ics_sync_service
|
||||||
@@ -14,11 +16,8 @@ logger = structlog.wrap_logger(get_task_logger(__name__))
|
|||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def sync_room_ics(room_id: str):
|
@asynctask
|
||||||
asynctask(_sync_room_ics_async(room_id))
|
async def sync_room_ics(room_id: str):
|
||||||
|
|
||||||
|
|
||||||
async def _sync_room_ics_async(room_id: str):
|
|
||||||
try:
|
try:
|
||||||
room = await rooms_controller.get_by_id(room_id)
|
room = await rooms_controller.get_by_id(room_id)
|
||||||
if not room:
|
if not room:
|
||||||
@@ -55,11 +54,8 @@ async def _sync_room_ics_async(room_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def sync_all_ics_calendars():
|
@asynctask
|
||||||
asynctask(_sync_all_ics_calendars_async())
|
async def sync_all_ics_calendars():
|
||||||
|
|
||||||
|
|
||||||
async def _sync_all_ics_calendars_async():
|
|
||||||
try:
|
try:
|
||||||
logger.info("Starting sync for all ICS-enabled rooms")
|
logger.info("Starting sync for all ICS-enabled rooms")
|
||||||
|
|
||||||
@@ -99,16 +95,68 @@ def _should_sync(room) -> bool:
|
|||||||
return time_since_sync.total_seconds() >= room.ics_fetch_interval
|
return time_since_sync.total_seconds() >= room.ics_fetch_interval
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
async def create_upcoming_meetings_for_event(event, create_window, room_id, room):
|
||||||
def pre_create_upcoming_meetings():
|
if event.start_time <= create_window:
|
||||||
asynctask(_pre_create_upcoming_meetings_async())
|
return
|
||||||
|
existing_meeting = await meetings_controller.get_by_calendar_event(event.id)
|
||||||
|
|
||||||
|
if existing_meeting:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Pre-creating meeting for calendar event",
|
||||||
|
room_id=room_id,
|
||||||
|
event_id=event.id,
|
||||||
|
event_title=event.title,
|
||||||
|
)
|
||||||
|
|
||||||
async def _pre_create_upcoming_meetings_async():
|
|
||||||
try:
|
try:
|
||||||
logger.info("Starting pre-creation of upcoming meetings")
|
end_date = event.end_time or (event.start_time + timedelta(hours=1))
|
||||||
|
|
||||||
from reflector.db.calendar_events import calendar_events_controller
|
whereby_meeting = await create_meeting(
|
||||||
|
event.title or "Scheduled Meeting",
|
||||||
|
end_date=end_date,
|
||||||
|
room=room,
|
||||||
|
)
|
||||||
|
await upload_logo(whereby_meeting["roomName"], "./images/logo.png")
|
||||||
|
|
||||||
|
meeting = await meetings_controller.create(
|
||||||
|
id=whereby_meeting["meetingId"],
|
||||||
|
room_name=whereby_meeting["roomName"],
|
||||||
|
room_url=whereby_meeting["roomUrl"],
|
||||||
|
host_room_url=whereby_meeting["hostRoomUrl"],
|
||||||
|
start_date=datetime.fromisoformat(whereby_meeting["startDate"]),
|
||||||
|
end_date=datetime.fromisoformat(whereby_meeting["endDate"]),
|
||||||
|
user_id=room.user_id,
|
||||||
|
room=room,
|
||||||
|
calendar_event_id=event.id,
|
||||||
|
calendar_metadata={
|
||||||
|
"title": event.title,
|
||||||
|
"description": event.description,
|
||||||
|
"attendees": event.attendees,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Meeting pre-created successfully",
|
||||||
|
meeting_id=meeting.id,
|
||||||
|
event_id=event.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to pre-create meeting",
|
||||||
|
room_id=room_id,
|
||||||
|
event_id=event.id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
@asynctask
|
||||||
|
async def create_upcoming_meetings():
|
||||||
|
try:
|
||||||
|
logger.info("Starting creation of upcoming meetings")
|
||||||
|
|
||||||
# Get ALL rooms with ICS enabled
|
# Get ALL rooms with ICS enabled
|
||||||
query = rooms.select().where(
|
query = rooms.select().where(
|
||||||
@@ -116,7 +164,7 @@ async def _pre_create_upcoming_meetings_async():
|
|||||||
)
|
)
|
||||||
all_rooms = await get_database().fetch_all(query)
|
all_rooms = await get_database().fetch_all(query)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
pre_create_window = now + timedelta(minutes=1)
|
create_window = now - timedelta(minutes=6)
|
||||||
|
|
||||||
for room_data in all_rooms:
|
for room_data in all_rooms:
|
||||||
room_id = room_data["id"]
|
room_id = room_data["id"]
|
||||||
@@ -126,84 +174,13 @@ async def _pre_create_upcoming_meetings_async():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
events = await calendar_events_controller.get_upcoming(
|
events = await calendar_events_controller.get_upcoming(
|
||||||
room_id, minutes_ahead=2
|
room_id,
|
||||||
|
minutes_ahead=7,
|
||||||
)
|
)
|
||||||
|
|
||||||
for event in events:
|
for event in events:
|
||||||
if event.start_time <= pre_create_window:
|
await create_upcoming_meetings_for_event(event)
|
||||||
existing_meeting = await meetings_controller.get_by_calendar_event(
|
|
||||||
event.id
|
|
||||||
)
|
|
||||||
|
|
||||||
if not existing_meeting:
|
|
||||||
logger.info(
|
|
||||||
"Pre-creating meeting for calendar event",
|
|
||||||
room_id=room_id,
|
|
||||||
event_id=event.id,
|
|
||||||
event_title=event.title,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
end_date = event.end_time or (
|
|
||||||
event.start_time + timedelta(hours=1)
|
|
||||||
)
|
|
||||||
|
|
||||||
whereby_meeting = await create_meeting(
|
|
||||||
event.title or "Scheduled Meeting",
|
|
||||||
end_date=end_date,
|
|
||||||
room=room,
|
|
||||||
)
|
|
||||||
await upload_logo(
|
|
||||||
whereby_meeting["roomName"], "./images/logo.png"
|
|
||||||
)
|
|
||||||
|
|
||||||
meeting = await meetings_controller.create(
|
|
||||||
id=whereby_meeting["meetingId"],
|
|
||||||
room_name=whereby_meeting["roomName"],
|
|
||||||
room_url=whereby_meeting["roomUrl"],
|
|
||||||
host_room_url=whereby_meeting["hostRoomUrl"],
|
|
||||||
start_date=datetime.fromisoformat(
|
|
||||||
whereby_meeting["startDate"]
|
|
||||||
),
|
|
||||||
end_date=datetime.fromisoformat(
|
|
||||||
whereby_meeting["endDate"]
|
|
||||||
),
|
|
||||||
user_id=room.user_id,
|
|
||||||
room=room,
|
|
||||||
calendar_event_id=event.id,
|
|
||||||
calendar_metadata={
|
|
||||||
"title": event.title,
|
|
||||||
"description": event.description,
|
|
||||||
"attendees": event.attendees,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Meeting pre-created successfully",
|
|
||||||
meeting_id=meeting.id,
|
|
||||||
event_id=event.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"Failed to pre-create meeting",
|
|
||||||
room_id=room_id,
|
|
||||||
event_id=event.id,
|
|
||||||
error=str(e),
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info("Completed pre-creation check for upcoming meetings")
|
logger.info("Completed pre-creation check for upcoming meetings")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error in pre_create_upcoming_meetings", error=str(e))
|
logger.error("Error in create_upcoming_meetings", error=str(e))
|
||||||
|
|
||||||
|
|
||||||
def asynctask(coro):
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
loop = asyncio.new_event_loop()
|
|
||||||
asyncio.set_event_loop(loop)
|
|
||||||
try:
|
|
||||||
return loop.run_until_complete(coro)
|
|
||||||
finally:
|
|
||||||
loop.close()
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { LuX } from "react-icons/lu";
|
|||||||
import type { components } from "../reflector-api";
|
import type { components } from "../reflector-api";
|
||||||
import {
|
import {
|
||||||
useRoomActiveMeetings,
|
useRoomActiveMeetings,
|
||||||
useRoomUpcomingMeetings,
|
|
||||||
useRoomJoinMeeting,
|
useRoomJoinMeeting,
|
||||||
useMeetingDeactivate,
|
useMeetingDeactivate,
|
||||||
useRoomGetByName,
|
useRoomGetByName,
|
||||||
@@ -31,13 +30,16 @@ import {
|
|||||||
} from "../lib/timeUtils";
|
} from "../lib/timeUtils";
|
||||||
import MinimalHeader from "../components/MinimalHeader";
|
import MinimalHeader from "../components/MinimalHeader";
|
||||||
|
|
||||||
|
// Meeting join settings
|
||||||
|
const EARLY_JOIN_MINUTES = 5; // Allow joining 5 minutes before meeting starts
|
||||||
|
|
||||||
type Meeting = components["schemas"]["Meeting"];
|
type Meeting = components["schemas"]["Meeting"];
|
||||||
type CalendarEventResponse = components["schemas"]["CalendarEventResponse"];
|
|
||||||
|
|
||||||
interface MeetingSelectionProps {
|
interface MeetingSelectionProps {
|
||||||
roomName: string;
|
roomName: string;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
isSharedRoom: boolean;
|
isSharedRoom: boolean;
|
||||||
|
authLoading: boolean;
|
||||||
onMeetingSelect: (meeting: Meeting) => void;
|
onMeetingSelect: (meeting: Meeting) => void;
|
||||||
onCreateUnscheduled: () => void;
|
onCreateUnscheduled: () => void;
|
||||||
}
|
}
|
||||||
@@ -46,6 +48,7 @@ export default function MeetingSelection({
|
|||||||
roomName,
|
roomName,
|
||||||
isOwner,
|
isOwner,
|
||||||
isSharedRoom,
|
isSharedRoom,
|
||||||
|
authLoading,
|
||||||
onMeetingSelect,
|
onMeetingSelect,
|
||||||
onCreateUnscheduled,
|
onCreateUnscheduled,
|
||||||
}: MeetingSelectionProps) {
|
}: MeetingSelectionProps) {
|
||||||
@@ -54,20 +57,36 @@ export default function MeetingSelection({
|
|||||||
// Use React Query hooks for data fetching
|
// Use React Query hooks for data fetching
|
||||||
const roomQuery = useRoomGetByName(roomName);
|
const roomQuery = useRoomGetByName(roomName);
|
||||||
const activeMeetingsQuery = useRoomActiveMeetings(roomName);
|
const activeMeetingsQuery = useRoomActiveMeetings(roomName);
|
||||||
const upcomingMeetingsQuery = useRoomUpcomingMeetings(roomName);
|
|
||||||
const joinMeetingMutation = useRoomJoinMeeting();
|
const joinMeetingMutation = useRoomJoinMeeting();
|
||||||
const deactivateMeetingMutation = useMeetingDeactivate();
|
const deactivateMeetingMutation = useMeetingDeactivate();
|
||||||
|
|
||||||
const room = roomQuery.data;
|
const room = roomQuery.data;
|
||||||
|
const allMeetings = activeMeetingsQuery.data || [];
|
||||||
|
|
||||||
const activeMeetings = activeMeetingsQuery.data || [];
|
// Separate current ongoing meetings from upcoming meetings (created by worker, within 5 minutes)
|
||||||
const upcomingEvents = upcomingMeetingsQuery.data || [];
|
const now = new Date();
|
||||||
const loading =
|
const currentMeetings = allMeetings.filter((meeting) => {
|
||||||
roomQuery.isLoading ||
|
const startTime = new Date(meeting.start_date);
|
||||||
activeMeetingsQuery.isLoading ||
|
// Meeting is ongoing if it started and participants have joined or it's been running for a while
|
||||||
upcomingMeetingsQuery.isLoading;
|
return (
|
||||||
const error =
|
meeting.num_clients > 0 || now.getTime() - startTime.getTime() > 60000
|
||||||
roomQuery.error || activeMeetingsQuery.error || upcomingMeetingsQuery.error;
|
); // 1 minute threshold
|
||||||
|
});
|
||||||
|
|
||||||
|
const upcomingMeetings = allMeetings.filter((meeting) => {
|
||||||
|
const startTime = new Date(meeting.start_date);
|
||||||
|
const minutesUntilStart = Math.floor(
|
||||||
|
(startTime.getTime() - now.getTime()) / (1000 * 60),
|
||||||
|
);
|
||||||
|
// Show meetings that start within 5 minutes and haven't started yet
|
||||||
|
return (
|
||||||
|
minutesUntilStart <= EARLY_JOIN_MINUTES &&
|
||||||
|
minutesUntilStart > 0 &&
|
||||||
|
meeting.num_clients === 0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const loading = roomQuery.isLoading || activeMeetingsQuery.isLoading;
|
||||||
|
const error = roomQuery.error || activeMeetingsQuery.error;
|
||||||
|
|
||||||
const handleJoinMeeting = async (meetingId: string) => {
|
const handleJoinMeeting = async (meetingId: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -86,9 +105,26 @@ export default function MeetingSelection({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleJoinUpcoming = async (event: CalendarEventResponse) => {
|
const handleJoinUpcoming = async (meeting: Meeting) => {
|
||||||
// Create an unscheduled meeting for this calendar event
|
// Join the upcoming meeting directly
|
||||||
onCreateUnscheduled();
|
try {
|
||||||
|
await joinMeetingMutation.mutateAsync({
|
||||||
|
params: {
|
||||||
|
path: {
|
||||||
|
room_name: roomName,
|
||||||
|
meeting_id: meeting.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
handleJoinDirect(meeting.room_url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to join upcoming meeting:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoinDirect = (roomUrl: string) => {
|
||||||
|
// Go directly to the meeting URL (Whereby/etc)
|
||||||
|
window.open(roomUrl, "_blank");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEndMeeting = async (meetingId: string) => {
|
const handleEndMeeting = async (meetingId: string) => {
|
||||||
@@ -158,73 +194,93 @@ export default function MeetingSelection({
|
|||||||
px={6}
|
px={6}
|
||||||
py={8}
|
py={8}
|
||||||
flex="1"
|
flex="1"
|
||||||
|
gap={6}
|
||||||
>
|
>
|
||||||
{/* Active Meetings */}
|
{/* Current Ongoing Meetings - BIG DISPLAY */}
|
||||||
{activeMeetings.length > 0 && (
|
{currentMeetings.length > 0 && (
|
||||||
<VStack align="stretch" gap={4} mb={6}>
|
<VStack align="stretch" gap={6} mb={8}>
|
||||||
<Text fontSize="md" fontWeight="semibold" color="gray.700">
|
<Text fontSize="xl" fontWeight="bold" color="gray.800">
|
||||||
Active Meetings
|
Live Meeting{currentMeetings.length > 1 ? "s" : ""}
|
||||||
</Text>
|
</Text>
|
||||||
{activeMeetings.map((meeting) => (
|
{currentMeetings.map((meeting) => (
|
||||||
<Box
|
<Box
|
||||||
key={meeting.id}
|
key={meeting.id}
|
||||||
width="100%"
|
width="100%"
|
||||||
bg="white"
|
bg="blue.50"
|
||||||
border="1px solid"
|
border="3px solid"
|
||||||
borderColor="gray.200"
|
borderColor="blue.300"
|
||||||
borderRadius="md"
|
borderRadius="xl"
|
||||||
p={4}
|
p={8}
|
||||||
_hover={{ borderColor: "gray.300" }}
|
_hover={{ borderColor: "blue.400", bg: "blue.100" }}
|
||||||
|
transition="all 0.2s"
|
||||||
|
shadow="lg"
|
||||||
>
|
>
|
||||||
<HStack justify="space-between" align="start">
|
<HStack justify="space-between" align="start">
|
||||||
<VStack align="start" gap={2} flex={1}>
|
<VStack align="start" gap={4} flex={1}>
|
||||||
<HStack>
|
<HStack>
|
||||||
<Icon as={FaCalendarAlt} color="blue.500" />
|
<Icon
|
||||||
<Text fontWeight="semibold">
|
as={FaCalendarAlt}
|
||||||
{(meeting.calendar_metadata as any)?.title || "Meeting"}
|
color="blue.600"
|
||||||
|
boxSize="24px"
|
||||||
|
/>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" color="blue.800">
|
||||||
|
{(meeting.calendar_metadata as any)?.title ||
|
||||||
|
"Live Meeting"}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Badge colorScheme="blue" fontSize="lg" px={4} py={2}>
|
||||||
|
LIVE
|
||||||
|
</Badge>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
{isOwner &&
|
{isOwner &&
|
||||||
(meeting.calendar_metadata as any)?.description && (
|
(meeting.calendar_metadata as any)?.description && (
|
||||||
<Text fontSize="sm" color="gray.600">
|
<Text fontSize="lg" color="gray.700">
|
||||||
{(meeting.calendar_metadata as any).description}
|
{(meeting.calendar_metadata as any).description}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<HStack gap={4} fontSize="sm" color="gray.500">
|
<HStack gap={8} fontSize="md" color="gray.600">
|
||||||
<HStack>
|
<HStack>
|
||||||
<Icon as={FaUsers} />
|
<Icon as={FaUsers} boxSize="20px" />
|
||||||
<Text>{meeting.num_clients} participants</Text>
|
<Text fontWeight="medium">
|
||||||
|
{meeting.num_clients} participants
|
||||||
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack>
|
<HStack>
|
||||||
<Icon as={FaClock} />
|
<Icon as={FaClock} boxSize="20px" />
|
||||||
<Text>
|
<Text>
|
||||||
Started {formatDateTime(meeting.start_date)}
|
Started {formatStartedAgo(meeting.start_date)}
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
{isOwner &&
|
{isOwner &&
|
||||||
(meeting.calendar_metadata as any)?.attendees && (
|
(meeting.calendar_metadata as any)?.attendees && (
|
||||||
<HStack gap={2} flexWrap="wrap">
|
<HStack gap={3} flexWrap="wrap">
|
||||||
{(meeting.calendar_metadata as any).attendees
|
{(meeting.calendar_metadata as any).attendees
|
||||||
.slice(0, 3)
|
.slice(0, 4)
|
||||||
.map((attendee: any, idx: number) => (
|
.map((attendee: any, idx: number) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={idx}
|
key={idx}
|
||||||
colorScheme="green"
|
colorScheme="blue"
|
||||||
fontSize="xs"
|
fontSize="sm"
|
||||||
|
px={3}
|
||||||
|
py={1}
|
||||||
>
|
>
|
||||||
{attendee.name || attendee.email}
|
{attendee.name || attendee.email}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
{(meeting.calendar_metadata as any).attendees.length >
|
{(meeting.calendar_metadata as any).attendees.length >
|
||||||
3 && (
|
4 && (
|
||||||
<Badge colorScheme="gray" fontSize="xs">
|
<Badge
|
||||||
|
colorScheme="gray"
|
||||||
|
fontSize="sm"
|
||||||
|
px={3}
|
||||||
|
py={1}
|
||||||
|
>
|
||||||
+
|
+
|
||||||
{(meeting.calendar_metadata as any).attendees
|
{(meeting.calendar_metadata as any).attendees
|
||||||
.length - 3}{" "}
|
.length - 4}{" "}
|
||||||
more
|
more
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -232,11 +288,15 @@ export default function MeetingSelection({
|
|||||||
)}
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|
||||||
<HStack gap={2}>
|
<VStack gap={3}>
|
||||||
<Button
|
<Button
|
||||||
colorScheme="blue"
|
colorScheme="blue"
|
||||||
size="md"
|
size="xl"
|
||||||
onClick={() => handleJoinMeeting(meeting.id)}
|
fontSize="lg"
|
||||||
|
px={8}
|
||||||
|
py={6}
|
||||||
|
onClick={() => handleJoinDirect(meeting.room_url)}
|
||||||
|
leftIcon={<Icon as={FaUsers} boxSize="20px" />}
|
||||||
>
|
>
|
||||||
Join Now
|
Join Now
|
||||||
</Button>
|
</Button>
|
||||||
@@ -247,113 +307,76 @@ export default function MeetingSelection({
|
|||||||
size="md"
|
size="md"
|
||||||
onClick={() => handleEndMeeting(meeting.id)}
|
onClick={() => handleEndMeeting(meeting.id)}
|
||||||
isLoading={deactivateMeetingMutation.isPending}
|
isLoading={deactivateMeetingMutation.isPending}
|
||||||
|
leftIcon={<Icon as={LuX} />}
|
||||||
>
|
>
|
||||||
<Icon as={LuX} />
|
|
||||||
End Meeting
|
End Meeting
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</HStack>
|
</VStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</VStack>
|
</VStack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Upcoming Meetings */}
|
{/* Upcoming Meetings - SMALLER ASIDE DISPLAY */}
|
||||||
{upcomingEvents.length > 0 && (
|
{upcomingMeetings.length > 0 && (
|
||||||
<VStack align="stretch" gap={4} mb={6}>
|
<VStack align="stretch" gap={4} mb={6}>
|
||||||
<Text fontSize="md" fontWeight="semibold" color="gray.700">
|
<Text fontSize="lg" fontWeight="semibold" color="gray.700">
|
||||||
Upcoming Meetings
|
Starting Soon
|
||||||
</Text>
|
</Text>
|
||||||
{upcomingEvents.map((event) => {
|
<HStack gap={4} flexWrap="wrap">
|
||||||
const now = new Date();
|
{upcomingMeetings.map((meeting) => {
|
||||||
const startTime = new Date(event.start_time);
|
const now = new Date();
|
||||||
const endTime = new Date(event.end_time);
|
const startTime = new Date(meeting.start_date);
|
||||||
const isOngoing = startTime <= now && now <= endTime;
|
const minutesUntilStart = Math.floor(
|
||||||
const minutesUntilStart = Math.floor(
|
(startTime.getTime() - now.getTime()) / (1000 * 60),
|
||||||
(startTime.getTime() - now.getTime()) / (1000 * 60),
|
);
|
||||||
);
|
|
||||||
const canJoinEarly = minutesUntilStart <= 5; // Allow joining 5 minutes before
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={event.id}
|
key={meeting.id}
|
||||||
width="100%"
|
bg="white"
|
||||||
bg="white"
|
border="2px solid"
|
||||||
border="1px solid"
|
borderColor="orange.200"
|
||||||
borderColor="gray.200"
|
borderRadius="lg"
|
||||||
borderRadius="md"
|
p={4}
|
||||||
p={4}
|
minW="300px"
|
||||||
_hover={{ borderColor: "gray.300" }}
|
maxW="400px"
|
||||||
>
|
_hover={{ borderColor: "orange.300", bg: "orange.50" }}
|
||||||
<HStack justify="space-between" align="start">
|
transition="all 0.2s"
|
||||||
<VStack align="start" gap={2} flex={1}>
|
>
|
||||||
|
<VStack align="start" gap={3}>
|
||||||
<HStack>
|
<HStack>
|
||||||
<Icon
|
<Icon as={FaCalendarAlt} color="orange.500" />
|
||||||
as={FaCalendarAlt}
|
<Text fontWeight="semibold" fontSize="md">
|
||||||
color={isOngoing ? "blue.500" : "orange.500"}
|
{(meeting.calendar_metadata as any)?.title ||
|
||||||
/>
|
"Upcoming Meeting"}
|
||||||
<Text fontWeight="semibold">
|
|
||||||
{event.title || "Scheduled Meeting"}
|
|
||||||
</Text>
|
|
||||||
<Badge
|
|
||||||
colorScheme={isOngoing ? "blue" : "orange"}
|
|
||||||
fontSize="xs"
|
|
||||||
>
|
|
||||||
{isOngoing
|
|
||||||
? formatStartedAgo(event.start_time)
|
|
||||||
: formatCountdown(event.start_time)}
|
|
||||||
</Badge>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{isOwner && event.description && (
|
|
||||||
<Text fontSize="sm" color="gray.600">
|
|
||||||
{event.description}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<HStack gap={4} fontSize="sm" color="gray.500">
|
|
||||||
<Text>
|
|
||||||
{formatDateTime(event.start_time)} -{" "}
|
|
||||||
{formatDateTime(event.end_time)}
|
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
{isOwner && event.attendees && (
|
<Badge colorScheme="orange" fontSize="sm" px={2} py={1}>
|
||||||
<HStack gap={2} flexWrap="wrap">
|
in {minutesUntilStart} minute
|
||||||
{event.attendees
|
{minutesUntilStart !== 1 ? "s" : ""}
|
||||||
.slice(0, 3)
|
</Badge>
|
||||||
.map((attendee: any, idx: number) => (
|
|
||||||
<Badge
|
<Text fontSize="sm" color="gray.600">
|
||||||
key={idx}
|
Starts: {formatDateTime(meeting.start_date)}
|
||||||
colorScheme="purple"
|
</Text>
|
||||||
fontSize="xs"
|
|
||||||
>
|
<Button
|
||||||
{attendee.name || attendee.email}
|
colorScheme="orange"
|
||||||
</Badge>
|
size="sm"
|
||||||
))}
|
width="full"
|
||||||
{event.attendees.length > 3 && (
|
onClick={() => handleJoinUpcoming(meeting)}
|
||||||
<Badge colorScheme="gray" fontSize="xs">
|
>
|
||||||
+{event.attendees.length - 3} more
|
Join Early
|
||||||
</Badge>
|
</Button>
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
</VStack>
|
</VStack>
|
||||||
|
</Box>
|
||||||
<Button
|
);
|
||||||
variant="outline"
|
})}
|
||||||
colorScheme={isOngoing || canJoinEarly ? "blue" : "gray"}
|
</HStack>
|
||||||
size="md"
|
|
||||||
onClick={() => handleJoinUpcoming(event)}
|
|
||||||
isDisabled={!isOngoing && !canJoinEarly}
|
|
||||||
>
|
|
||||||
Join
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</VStack>
|
</VStack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -374,8 +397,8 @@ export default function MeetingSelection({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Message for non-owners of private rooms */}
|
{/* Message for non-owners of private rooms - only show when auth is not loading */}
|
||||||
{!isOwner && !isSharedRoom && (
|
{!authLoading && !isOwner && !isSharedRoom && (
|
||||||
<Box
|
<Box
|
||||||
width="100%"
|
width="100%"
|
||||||
bg="gray.50"
|
bg="gray.50"
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ import {
|
|||||||
import type { components } from "../reflector-api";
|
import type { components } from "../reflector-api";
|
||||||
import MeetingSelection from "./MeetingSelection";
|
import MeetingSelection from "./MeetingSelection";
|
||||||
import { useAuth } from "../lib/AuthProvider";
|
import { useAuth } from "../lib/AuthProvider";
|
||||||
|
import useRoomMeeting from "./useRoomMeeting";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
const WherebyEmbed = dynamic(() => import("../lib/WherebyWebinarEmbed"), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
type Meeting = components["schemas"]["Meeting"];
|
type Meeting = components["schemas"]["Meeting"];
|
||||||
|
|
||||||
@@ -36,11 +42,20 @@ export default function RoomClient({ params }: RoomClientProps) {
|
|||||||
const activeMeetings = activeMeetingsQuery.data || [];
|
const activeMeetings = activeMeetingsQuery.data || [];
|
||||||
const upcomingMeetings = upcomingMeetingsQuery.data || [];
|
const upcomingMeetings = upcomingMeetingsQuery.data || [];
|
||||||
|
|
||||||
const isOwner =
|
// For non-ICS rooms, create a meeting and get Whereby URL
|
||||||
auth.status === "authenticated" ? auth.user?.id === room?.user_id : false;
|
const roomMeeting = useRoomMeeting(
|
||||||
|
room && !room.ics_enabled ? roomName : null,
|
||||||
|
);
|
||||||
|
const roomUrl =
|
||||||
|
roomMeeting?.response?.host_room_url || roomMeeting?.response?.room_url;
|
||||||
|
|
||||||
const isLoading = auth.status === "loading" || roomQuery.isLoading;
|
const isLoading = auth.status === "loading" || roomQuery.isLoading;
|
||||||
|
|
||||||
|
const isOwner =
|
||||||
|
auth.status === "authenticated" && room
|
||||||
|
? auth.user?.id === room.user_id
|
||||||
|
: false;
|
||||||
|
|
||||||
const handleMeetingSelect = (selectedMeeting: Meeting) => {
|
const handleMeetingSelect = (selectedMeeting: Meeting) => {
|
||||||
// Navigate to specific meeting using path segment
|
// Navigate to specific meeting using path segment
|
||||||
router.push(`/${roomName}/${selectedMeeting.id}`);
|
router.push(`/${roomName}/${selectedMeeting.id}`);
|
||||||
@@ -60,14 +75,6 @@ export default function RoomClient({ params }: RoomClientProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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
|
// Handle room not found
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (roomQuery.isError) {
|
if (roomQuery.isError) {
|
||||||
@@ -105,20 +112,26 @@ export default function RoomClient({ params }: RoomClientProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For ICS-enabled rooms, ALWAYS show meeting selection (no auto-redirect)
|
// For ICS-enabled rooms, show meeting selection
|
||||||
if (room.ics_enabled) {
|
if (room.ics_enabled) {
|
||||||
return (
|
return (
|
||||||
<MeetingSelection
|
<MeetingSelection
|
||||||
roomName={roomName}
|
roomName={roomName}
|
||||||
isOwner={isOwner}
|
isOwner={isOwner}
|
||||||
isSharedRoom={room?.is_shared || false}
|
isSharedRoom={room?.is_shared || false}
|
||||||
|
authLoading={["loading", "refreshing"].includes(auth.status)}
|
||||||
onMeetingSelect={handleMeetingSelect}
|
onMeetingSelect={handleMeetingSelect}
|
||||||
onCreateUnscheduled={handleCreateUnscheduled}
|
onCreateUnscheduled={handleCreateUnscheduled}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-ICS rooms will auto-redirect via useEffect above
|
// For non-ICS rooms, show Whereby embed directly
|
||||||
|
if (roomUrl) {
|
||||||
|
return <WherebyEmbed roomUrl={roomUrl} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state for non-ICS rooms while creating meeting
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
display="flex"
|
display="flex"
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import { toaster } from "../components/ui/toaster";
|
|||||||
interface WherebyEmbedProps {
|
interface WherebyEmbedProps {
|
||||||
roomUrl: string;
|
roomUrl: string;
|
||||||
onLeave?: () => void;
|
onLeave?: () => void;
|
||||||
|
isWebinar?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// currently used for webinars only
|
// used for both webinars and meetings
|
||||||
export default function WherebyWebinarEmbed({
|
export default function WherebyWebinarEmbed({
|
||||||
roomUrl,
|
roomUrl,
|
||||||
onLeave,
|
onLeave,
|
||||||
|
isWebinar = false,
|
||||||
}: WherebyEmbedProps) {
|
}: WherebyEmbedProps) {
|
||||||
const wherebyRef = useRef<HTMLElement>(null);
|
const wherebyRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
@@ -26,7 +28,8 @@ export default function WherebyWebinarEmbed({
|
|||||||
<Box p={4} bg="white" borderRadius="md" boxShadow="md">
|
<Box p={4} bg="white" borderRadius="md" boxShadow="md">
|
||||||
<HStack justifyContent="space-between" alignItems="center">
|
<HStack justifyContent="space-between" alignItems="center">
|
||||||
<Text>
|
<Text>
|
||||||
This webinar is being recorded. By continuing, you agree to our{" "}
|
This {isWebinar ? "webinar" : "meeting"} is being recorded. By
|
||||||
|
continuing, you agree to our{" "}
|
||||||
<Link
|
<Link
|
||||||
href="https://monadical.com/privacy"
|
href="https://monadical.com/privacy"
|
||||||
color="blue.600"
|
color="blue.600"
|
||||||
|
|||||||
@@ -150,7 +150,15 @@ export default function WebinarPage(details: WebinarDetails) {
|
|||||||
|
|
||||||
if (status === WebinarStatus.Live) {
|
if (status === WebinarStatus.Live) {
|
||||||
return (
|
return (
|
||||||
<>{roomUrl && <WherebyEmbed roomUrl={roomUrl} onLeave={handleLeave} />}</>
|
<>
|
||||||
|
{roomUrl && (
|
||||||
|
<WherebyEmbed
|
||||||
|
roomUrl={roomUrl}
|
||||||
|
onLeave={handleLeave}
|
||||||
|
isWebinar={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (status === WebinarStatus.Ended) {
|
if (status === WebinarStatus.Ended) {
|
||||||
|
|||||||
Reference in New Issue
Block a user