diff --git a/server/reflector/worker/ics_sync.py b/server/reflector/worker/ics_sync.py index e46dad7c..f6ff9e86 100644 --- a/server/reflector/worker/ics_sync.py +++ b/server/reflector/worker/ics_sync.py @@ -82,6 +82,9 @@ def _should_sync(room) -> bool: return time_since_sync.total_seconds() >= room.ics_fetch_interval +MEETING_DEFAULT_DURATION = timedelta(hours=1) + + async def create_upcoming_meetings_for_event(event, create_window, room_id, room): if event.start_time <= create_window: return @@ -98,7 +101,7 @@ async def create_upcoming_meetings_for_event(event, create_window, room_id, room ) try: - end_date = event.end_time or (event.start_time + timedelta(hours=1)) + end_date = event.end_time or (event.start_time + MEETING_DEFAULT_DURATION) whereby_meeting = await create_meeting( event.title or "Scheduled Meeting", diff --git a/www/app/(app)/rooms/_components/ICSSettings.tsx b/www/app/(app)/rooms/_components/ICSSettings.tsx index 9cadb15d..ccdb2b5e 100644 --- a/www/app/(app)/rooms/_components/ICSSettings.tsx +++ b/www/app/(app)/rooms/_components/ICSSettings.tsx @@ -67,9 +67,7 @@ export default function ICSSettings({ eventsUpdated: number; } | null>(null); - // React Query hooks const syncMutation = useRoomIcsSync(); - const statusQuery = useRoomIcsStatus(roomName || null); const fetchIntervalCollection = createListCollection({ items: fetchIntervalOptions, diff --git a/www/app/(app)/rooms/_components/RoomTable.tsx b/www/app/(app)/rooms/_components/RoomTable.tsx index 2550de06..8b5e1ae1 100644 --- a/www/app/(app)/rooms/_components/RoomTable.tsx +++ b/www/app/(app)/rooms/_components/RoomTable.tsx @@ -21,6 +21,7 @@ type Room = components["schemas"]["Room"]; type Meeting = components["schemas"]["Meeting"]; type CalendarEventResponse = components["schemas"]["CalendarEventResponse"]; import { RoomActionsMenu } from "./RoomActionsMenu"; +import { MEETING_DEFAULT_TIME_MINUTES } from "../../../[roomName]/[meetingId]/constants"; interface RoomTableProps { rooms: Room[]; @@ -113,7 +114,9 @@ function MeetingStatus({ roomName }: { roomName: string }) { return ( - {diffMinutes < 60 ? `In ${diffMinutes}m` : "Upcoming"} + {diffMinutes < MEETING_DEFAULT_TIME_MINUTES + ? `In ${diffMinutes}m` + : "Upcoming"} {event.title || "Scheduled Meeting"} diff --git a/www/app/[roomName]/MeetingSelection.tsx b/www/app/[roomName]/MeetingSelection.tsx index 4ff75de6..6b64b9e8 100644 --- a/www/app/[roomName]/MeetingSelection.tsx +++ b/www/app/[roomName]/MeetingSelection.tsx @@ -1,5 +1,6 @@ "use client"; +import * as R from "remeda"; import { Box, VStack, @@ -22,16 +23,9 @@ import { useRoomGetByName, } from "../lib/apiHooks"; import { useRouter } from "next/navigation"; -import Link from "next/link"; -import { - formatDateTime, - formatCountdown, - formatStartedAgo, -} from "../lib/timeUtils"; +import { formatDateTime, formatStartedAgo } from "../lib/timeUtils"; import MeetingMinimalHeader from "../components/MeetingMinimalHeader"; - -// Meeting join settings -const EARLY_JOIN_MINUTES = 5; // Allow joining 5 minutes before meeting starts +import { MEETING_DEFAULT_TIME_MINUTES } from "./[meetingId]/constants"; type Meeting = components["schemas"]["Meeting"]; @@ -48,13 +42,11 @@ export default function MeetingSelection({ roomName, isOwner, isSharedRoom, - authLoading, onMeetingSelect, onCreateUnscheduled, }: MeetingSelectionProps) { const router = useRouter(); - // Use React Query hooks for data fetching const roomQuery = useRoomGetByName(roomName); const activeMeetingsQuery = useRoomActiveMeetings(roomName); const joinMeetingMutation = useRoomJoinMeeting(); @@ -63,48 +55,23 @@ export default function MeetingSelection({ const room = roomQuery.data; const allMeetings = activeMeetingsQuery.data || []; - // Separate current ongoing meetings from upcoming meetings (created by worker, within 5 minutes) const now = new Date(); - const currentMeetings = allMeetings.filter((meeting) => { - const startTime = new Date(meeting.start_date); - // Meeting is ongoing if it started and participants have joined or it's been running for a while - return ( - meeting.num_clients > 0 || now.getTime() - startTime.getTime() > 60000 - ); // 1 minute threshold - }); + const [currentMeetings, upcomingMeetings] = R.pipe( + allMeetings, + R.partition((meeting) => { + const startTime = new Date(meeting.start_date); + // Meeting is ongoing if it started and participants have joined or it's been running for a while + return ( + meeting.num_clients > 0 || + now.getTime() - startTime.getTime() > + MEETING_DEFAULT_TIME_MINUTES * 1000 + ); + }), + ); - 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) => { - try { - const meeting = await joinMeetingMutation.mutateAsync({ - params: { - path: { - room_name: roomName, - meeting_id: meetingId, - }, - }, - }); - onMeetingSelect(meeting); - } catch (err) { - console.error("Failed to join meeting:", err); - // Handle error appropriately since we don't have setError anymore - } - }; - const handleJoinUpcoming = async (meeting: Meeting) => { // Join the upcoming meeting and navigate to local meeting page try { @@ -167,13 +134,6 @@ export default function MeetingSelection({ ); } - // Generate display name for room - const displayName = room?.name || roomName; - const roomTitle = - displayName.endsWith("'s") || displayName.endsWith("s") - ? `${displayName} Room` - : `${displayName}'s Room`; - const handleLeaveMeeting = () => { router.push("/"); }; @@ -244,7 +204,8 @@ export default function MeetingSelection({ - Started {formatStartedAgo(meeting.start_date)} + Started{" "} + {formatStartedAgo(new Date(meeting.start_date))} @@ -356,7 +317,7 @@ export default function MeetingSelection({ - Starts: {formatDateTime(meeting.start_date)} + Starts: {formatDateTime(new Date(meeting.start_date))} - ); - }; - - return ( - - - - Can we have your permission to store this meeting's audio - recording on our servers? - - - - - - - - ); - }, - }); - - // Set modal state when toast is dismissed - toastId.then((id) => { - const checkToastStatus = setInterval(() => { - if (!toaster.isActive(id)) { - setModalOpen(false); - clearInterval(checkToastStatus); - } - }, 100); - }); - - // Handle escape key to close the toast - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - toastId.then((id) => toaster.dismiss(id)); - } - }; - - document.addEventListener("keydown", handleKeyDown); - - const cleanup = () => { - toastId.then((id) => toaster.dismiss(id)); - document.removeEventListener("keydown", handleKeyDown); - }; - - return cleanup; - }, [meetingId, handleConsent, wherebyRef, modalOpen]); - - return { - showConsentModal, - consentState, - hasConsent, - consentLoading: audioConsentMutation.isPending, - }; -}; - -function ConsentDialogButton({ - meetingId, - wherebyRef, -}: { - meetingId: string; - wherebyRef: React.RefObject; -}) { - const { showConsentModal, consentState, hasConsent, consentLoading } = - useConsentDialog(meetingId, wherebyRef); - - if (!consentState.ready || hasConsent(meetingId) || consentLoading) { - return null; - } - - return ( - - ); -} - -const recordingTypeRequiresConsent = ( - recordingType: NonNullable, -) => { - return recordingType === "cloud"; -}; -interface MeetingPageProps { - params: { - roomName: string; - meetingId: string; - }; -} - -export default function MeetingPage({ params }: MeetingPageProps) { - const { roomName, meetingId } = params; - const router = useRouter(); - const [attemptedJoin, setAttemptedJoin] = useState(false); - const wherebyLoaded = useWhereby(); - const wherebyRef = useRef(null); - - // Fetch room data - const roomQuery = useRoomGetByName(roomName); - const joinMeetingMutation = useRoomJoinMeeting(); - - const room = roomQuery.data; - const isLoading = - roomQuery.isLoading || - (!attemptedJoin && room && !joinMeetingMutation.data); - - // Try to join the meeting when room is loaded - useEffect(() => { - if (room && !attemptedJoin && !joinMeetingMutation.isPending) { - setAttemptedJoin(true); - joinMeetingMutation.mutate({ - params: { - path: { - room_name: roomName, - meeting_id: meetingId, - }, - }, - }); - } - }, [room, attemptedJoin, joinMeetingMutation, roomName, meetingId]); - - // Redirect to room lobby if meeting join fails (meeting finished/not found) - useEffect(() => { - if (joinMeetingMutation.isError || roomQuery.isError) { - router.push(`/${roomName}`); - } - }, [joinMeetingMutation.isError, roomQuery.isError, router, roomName]); - - // Get meeting data from join response - const meeting = joinMeetingMutation.data; - const roomUrl = meeting?.host_room_url || meeting?.room_url; - const recordingType = meeting?.recording_type; - - const handleLeave = useCallback(() => { - router.push(`/${roomName}`); - }, [router, roomName]); - - useEffect(() => { - if (!isLoading && !roomUrl && !wherebyLoaded) return; - - wherebyRef.current?.addEventListener("leave", handleLeave); - - return () => { - wherebyRef.current?.removeEventListener("leave", handleLeave); - }; - }, [handleLeave, roomUrl, isLoading, wherebyLoaded]); - - if (isLoading) { - return ( - - - - - - Loading meeting... - - - - ); - } - - // If we have a successful meeting join with room URL, show Whereby embed - if (meeting && roomUrl && wherebyLoaded) { - return ( - <> - - {recordingType && recordingTypeRequiresConsent(recordingType) && ( - - )} - - ); - } - - // This return should not be reached normally since we redirect on errors - // But keeping it as a fallback - return ( - - - - Meeting not available - - - ); -} +export default Room; diff --git a/www/app/[roomName]/page.tsx b/www/app/[roomName]/page.tsx index efb44448..1aaca4c7 100644 --- a/www/app/[roomName]/page.tsx +++ b/www/app/[roomName]/page.tsx @@ -1,410 +1,3 @@ -"use client"; +import Room from "./room"; -import { - useCallback, - useEffect, - useRef, - useState, - useContext, - RefObject, - use, -} from "react"; -import { - Box, - Button, - Text, - VStack, - HStack, - Spinner, - Icon, -} from "@chakra-ui/react"; -import { toaster } from "../components/ui/toaster"; -import { useRouter } from "next/navigation"; -import { notFound } from "next/navigation"; -import { useRecordingConsent } from "../recordingConsentContext"; -import { - useMeetingAudioConsent, - useRoomGetByName, - useRoomActiveMeetings, - useRoomUpcomingMeetings, - useRoomsCreateMeeting, -} from "../lib/apiHooks"; -import type { components } from "../reflector-api"; -import MeetingSelection from "./MeetingSelection"; -import useRoomMeeting from "./useRoomMeeting"; - -type Meeting = components["schemas"]["Meeting"]; -import { FaBars } from "react-icons/fa6"; -import { useAuth } from "../lib/AuthProvider"; - -export type RoomDetails = { - params: Promise<{ - roomName: string; - }>; -}; - -// stages: we focus on the consent, then whereby steals focus, then we focus on the consent again, then return focus to whoever stole it initially -const useConsentWherebyFocusManagement = ( - acceptButtonRef: RefObject, - wherebyRef: RefObject, -) => { - const currentFocusRef = useRef(null); - useEffect(() => { - if (acceptButtonRef.current) { - acceptButtonRef.current.focus(); - } else { - console.error( - "accept button ref not available yet for focus management - seems to be illegal state", - ); - } - - const handleWherebyReady = () => { - console.log("whereby ready - refocusing consent button"); - currentFocusRef.current = document.activeElement as HTMLElement; - if (acceptButtonRef.current) { - acceptButtonRef.current.focus(); - } - }; - - if (wherebyRef.current) { - wherebyRef.current.addEventListener("ready", handleWherebyReady); - } else { - console.warn( - "whereby ref not available yet for focus management - seems to be illegal state. not waiting, focus management off.", - ); - } - - return () => { - wherebyRef.current?.removeEventListener("ready", handleWherebyReady); - currentFocusRef.current?.focus(); - }; - }, []); -}; - -const useConsentDialog = ( - meetingId: string, - wherebyRef: RefObject /*accessibility*/, -) => { - const { state: consentState, touch, hasConsent } = useRecordingConsent(); - // toast would open duplicates, even with using "id=" prop - const [modalOpen, setModalOpen] = useState(false); - const audioConsentMutation = useMeetingAudioConsent(); - - const handleConsent = useCallback( - async (meetingId: string, given: boolean) => { - try { - await audioConsentMutation.mutateAsync({ - params: { - path: { - meeting_id: meetingId, - }, - }, - body: { - consent_given: given, - }, - }); - - touch(meetingId); - } catch (error) { - console.error("Error submitting consent:", error); - } - }, - [audioConsentMutation, touch], - ); - - const showConsentModal = useCallback(() => { - if (modalOpen) return; - - setModalOpen(true); - - const toastId = toaster.create({ - placement: "top", - duration: null, - render: ({ dismiss }) => { - const AcceptButton = () => { - const buttonRef = useRef(null); - useConsentWherebyFocusManagement(buttonRef, wherebyRef); - return ( - - ); - }; - - return ( - - - - Can we have your permission to store this meeting's audio - recording on our servers? - - - - - - - - ); - }, - }); - - // Set modal state when toast is dismissed - toastId.then((id) => { - const checkToastStatus = setInterval(() => { - if (!toaster.isActive(id)) { - setModalOpen(false); - clearInterval(checkToastStatus); - } - }, 100); - }); - - // Handle escape key to close the toast - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - toastId.then((id) => toaster.dismiss(id)); - } - }; - - document.addEventListener("keydown", handleKeyDown); - - const cleanup = () => { - toastId.then((id) => toaster.dismiss(id)); - document.removeEventListener("keydown", handleKeyDown); - }; - - return cleanup; - }, [meetingId, handleConsent, wherebyRef, modalOpen]); - - return { - showConsentModal, - consentState, - hasConsent, - consentLoading: audioConsentMutation.isPending, - }; -}; - -function ConsentDialogButton({ - meetingId, - wherebyRef, -}: { - meetingId: string; - wherebyRef: React.RefObject; -}) { - const { showConsentModal, consentState, hasConsent, consentLoading } = - useConsentDialog(meetingId, wherebyRef); - - if (!consentState.ready || hasConsent(meetingId) || consentLoading) { - return null; - } - - return ( - - ); -} - -const recordingTypeRequiresConsent = ( - recordingType: NonNullable, -) => { - return recordingType === "cloud"; -}; - -// next throws even with "use client" -const useWhereby = () => { - const [wherebyLoaded, setWherebyLoaded] = useState(false); - useEffect(() => { - if (typeof window !== "undefined") { - import("@whereby.com/browser-sdk/embed") - .then(() => { - setWherebyLoaded(true); - }) - .catch(console.error.bind(console)); - } - }, []); - return wherebyLoaded; -}; - -export default function Room(details: RoomDetails) { - const params = use(details.params); - const wherebyLoaded = useWhereby(); - const wherebyRef = useRef(null); - const roomName = params.roomName; - const meeting = useRoomMeeting(roomName); - const router = useRouter(); - const auth = useAuth(); - const status = auth.status; - const isAuthenticated = status === "authenticated"; - - // Fetch room details using React Query - const roomQuery = useRoomGetByName(roomName); - const activeMeetingsQuery = useRoomActiveMeetings(roomName); - const upcomingMeetingsQuery = useRoomUpcomingMeetings(roomName); - const createMeetingMutation = useRoomsCreateMeeting(); - - const room = roomQuery.data; - const activeMeetings = activeMeetingsQuery.data || []; - const upcomingMeetings = upcomingMeetingsQuery.data || []; - - // For non-ICS rooms, create a meeting and get Whereby URL - const roomMeeting = useRoomMeeting( - room && !room.ics_enabled ? roomName : null, - ); - const roomUrl = - roomMeeting?.response?.host_room_url || roomMeeting?.response?.room_url; - - const isLoading = - status === "loading" || roomQuery.isLoading || roomMeeting?.loading; - - const isOwner = - isAuthenticated && room ? auth.user?.id === room.user_id : false; - - const meetingId = roomMeeting?.response?.id; - - const recordingType = roomMeeting?.response?.recording_type; - - const handleMeetingSelect = (selectedMeeting: Meeting) => { - // Navigate to specific meeting using path segment - router.push(`/${roomName}/${selectedMeeting.id}`); - }; - - const handleCreateUnscheduled = async () => { - try { - // Create a new unscheduled meeting - const newMeeting = await createMeetingMutation.mutateAsync({ - params: { - path: { room_name: roomName }, - }, - }); - handleMeetingSelect(newMeeting); - } catch (err) { - console.error("Failed to create meeting:", err); - } - }; - - const handleLeave = useCallback(() => { - router.push("/browse"); - }, [router]); - - useEffect(() => { - if ( - !isLoading && - (roomQuery.isError || roomMeeting?.error) && - "status" in (roomQuery.error || roomMeeting?.error || {}) && - (roomQuery.error as any)?.status === 404 - ) { - notFound(); - } - }, [isLoading, roomQuery.error, roomMeeting?.error]); - - useEffect(() => { - if (isLoading || !isAuthenticated || !roomUrl || !wherebyLoaded) return; - - wherebyRef.current?.addEventListener("leave", handleLeave); - - return () => { - wherebyRef.current?.removeEventListener("leave", handleLeave); - }; - }, [handleLeave, roomUrl, isLoading, isAuthenticated, wherebyLoaded]); - - if (isLoading) { - return ( - - - - ); - } - - if (!room) { - return ( - - Room not found - - ); - } - - if (room.ics_enabled) { - return ( - - ); - } - - // For non-ICS rooms, show Whereby embed directly - return ( - <> - {roomUrl && meetingId && wherebyLoaded && ( - <> - - {recordingType && recordingTypeRequiresConsent(recordingType) && ( - - )} - - )} - - ); -} +export default Room; diff --git a/www/app/[roomName]/room.tsx b/www/app/[roomName]/room.tsx new file mode 100644 index 00000000..2386be31 --- /dev/null +++ b/www/app/[roomName]/room.tsx @@ -0,0 +1,390 @@ +"use client"; + +import { + useCallback, + useEffect, + useRef, + useState, + useContext, + RefObject, + use, +} from "react"; +import { + Box, + Button, + Text, + VStack, + HStack, + Spinner, + Icon, +} from "@chakra-ui/react"; +import { toaster } from "../components/ui/toaster"; +import { useRouter } from "next/navigation"; +import { notFound } from "next/navigation"; +import { useRecordingConsent } from "../recordingConsentContext"; +import { + useMeetingAudioConsent, + useRoomGetByName, + useRoomActiveMeetings, + useRoomUpcomingMeetings, + useRoomsCreateMeeting, +} from "../lib/apiHooks"; +import type { components } from "../reflector-api"; +import MeetingSelection from "./MeetingSelection"; +import useRoomMeeting from "./useRoomMeeting"; + +type Meeting = components["schemas"]["Meeting"]; +import { FaBars } from "react-icons/fa6"; +import { useAuth } from "../lib/AuthProvider"; +import { useWhereby } from "../lib/wherebyClient"; + +export type RoomDetails = { + params: Promise<{ + roomName: string; + }>; +}; + +// stages: we focus on the consent, then whereby steals focus, then we focus on the consent again, then return focus to whoever stole it initially +const useConsentWherebyFocusManagement = ( + acceptButtonRef: RefObject, + wherebyRef: RefObject, +) => { + const currentFocusRef = useRef(null); + useEffect(() => { + if (acceptButtonRef.current) { + acceptButtonRef.current.focus(); + } else { + console.error( + "accept button ref not available yet for focus management - seems to be illegal state", + ); + } + + const handleWherebyReady = () => { + console.log("whereby ready - refocusing consent button"); + currentFocusRef.current = document.activeElement as HTMLElement; + if (acceptButtonRef.current) { + acceptButtonRef.current.focus(); + } + }; + + if (wherebyRef.current) { + wherebyRef.current.addEventListener("ready", handleWherebyReady); + } else { + console.warn( + "whereby ref not available yet for focus management - seems to be illegal state. not waiting, focus management off.", + ); + } + + return () => { + wherebyRef.current?.removeEventListener("ready", handleWherebyReady); + currentFocusRef.current?.focus(); + }; + }, []); +}; + +const useConsentDialog = ( + meetingId: string, + wherebyRef: RefObject /*accessibility*/, +) => { + const { state: consentState, touch, hasConsent } = useRecordingConsent(); + // toast would open duplicates, even with using "id=" prop + const [modalOpen, setModalOpen] = useState(false); + const audioConsentMutation = useMeetingAudioConsent(); + + const handleConsent = useCallback( + async (meetingId: string, given: boolean) => { + try { + await audioConsentMutation.mutateAsync({ + params: { + path: { + meeting_id: meetingId, + }, + }, + body: { + consent_given: given, + }, + }); + + touch(meetingId); + } catch (error) { + console.error("Error submitting consent:", error); + } + }, + [audioConsentMutation, touch], + ); + + const showConsentModal = useCallback(() => { + if (modalOpen) return; + + setModalOpen(true); + + const toastId = toaster.create({ + placement: "top", + duration: null, + render: ({ dismiss }) => { + const AcceptButton = () => { + const buttonRef = useRef(null); + useConsentWherebyFocusManagement(buttonRef, wherebyRef); + return ( + + ); + }; + + return ( + + + + Can we have your permission to store this meeting's audio + recording on our servers? + + + + + + + + ); + }, + }); + + // Set modal state when toast is dismissed + toastId.then((id) => { + const checkToastStatus = setInterval(() => { + if (!toaster.isActive(id)) { + setModalOpen(false); + clearInterval(checkToastStatus); + } + }, 100); + }); + + // Handle escape key to close the toast + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + toastId.then((id) => toaster.dismiss(id)); + } + }; + + document.addEventListener("keydown", handleKeyDown); + + const cleanup = () => { + toastId.then((id) => toaster.dismiss(id)); + document.removeEventListener("keydown", handleKeyDown); + }; + + return cleanup; + }, [meetingId, handleConsent, wherebyRef, modalOpen]); + + return { + showConsentModal, + consentState, + hasConsent, + consentLoading: audioConsentMutation.isPending, + }; +}; + +function ConsentDialogButton({ + meetingId, + wherebyRef, +}: { + meetingId: string; + wherebyRef: React.RefObject; +}) { + const { showConsentModal, consentState, hasConsent, consentLoading } = + useConsentDialog(meetingId, wherebyRef); + + if (!consentState.ready || hasConsent(meetingId) || consentLoading) { + return null; + } + + return ( + + ); +} + +const recordingTypeRequiresConsent = ( + recordingType: NonNullable, +) => { + return recordingType === "cloud"; +}; + +export default function Room(details: RoomDetails) { + const params = use(details.params); + const wherebyLoaded = useWhereby(); + const wherebyRef = useRef(null); + const roomName = params.roomName; + useRoomMeeting(roomName); + const router = useRouter(); + const auth = useAuth(); + const status = auth.status; + const isAuthenticated = status === "authenticated"; + + const roomQuery = useRoomGetByName(roomName); + const createMeetingMutation = useRoomsCreateMeeting(); + + const room = roomQuery.data; + + // For non-ICS rooms, create a meeting and get Whereby URL + const roomMeeting = useRoomMeeting( + room && !room.ics_enabled ? roomName : null, + ); + const roomUrl = + roomMeeting?.response?.host_room_url || roomMeeting?.response?.room_url; + + const isLoading = + status === "loading" || roomQuery.isLoading || roomMeeting?.loading; + + const isOwner = + isAuthenticated && room ? auth.user?.id === room.user_id : false; + + const meetingId = roomMeeting?.response?.id; + + const recordingType = roomMeeting?.response?.recording_type; + + const handleMeetingSelect = (selectedMeeting: Meeting) => { + router.push(`/${roomName}/${selectedMeeting.id}`); + }; + + const handleCreateUnscheduled = async () => { + try { + // Create a new unscheduled meeting + const newMeeting = await createMeetingMutation.mutateAsync({ + params: { + path: { room_name: roomName }, + }, + }); + handleMeetingSelect(newMeeting); + } catch (err) { + console.error("Failed to create meeting:", err); + } + }; + + const handleLeave = useCallback(() => { + router.push("/browse"); + }, [router]); + + useEffect(() => { + if ( + !isLoading && + (roomQuery.isError || roomMeeting?.error) && + "status" in (roomQuery.error || roomMeeting?.error || {}) && + (roomQuery.error as any)?.status === 404 + ) { + notFound(); + } + }, [isLoading, roomQuery.error, roomMeeting?.error]); + + useEffect(() => { + if (isLoading || !isAuthenticated || !roomUrl || !wherebyLoaded) return; + + wherebyRef.current?.addEventListener("leave", handleLeave); + + return () => { + wherebyRef.current?.removeEventListener("leave", handleLeave); + }; + }, [handleLeave, roomUrl, isLoading, isAuthenticated, wherebyLoaded]); + + if (isLoading) { + return ( + + + + ); + } + + if (!room) { + return ( + + Room not found + + ); + } + + if (room.ics_enabled) { + return ( + + ); + } + + // For non-ICS rooms, show Whereby embed directly + return ( + <> + {roomUrl && meetingId && wherebyLoaded && ( + <> + + {recordingType && recordingTypeRequiresConsent(recordingType) && ( + + )} + + )} + + ); +} diff --git a/www/app/[roomName]/useRoomMeeting.tsx b/www/app/[roomName]/useRoomMeeting.tsx index 66e0a363..331f0b1a 100644 --- a/www/app/[roomName]/useRoomMeeting.tsx +++ b/www/app/[roomName]/useRoomMeeting.tsx @@ -30,7 +30,6 @@ type SuccessMeeting = { const useRoomMeeting = ( roomName: string | null | undefined, - meetingId?: string, ): ErrorMeeting | LoadingMeeting | SuccessMeeting => { const [response, setResponse] = useState(null); const [reload, setReload] = useState(0); @@ -42,8 +41,6 @@ const useRoomMeeting = ( if (!roomName) return; // For any case where we need a meeting (with or without meetingId), - // we create a new meeting. The meetingId parameter can be used for - // additional logic in the future if needed (e.g., fetching existing meetings) const createMeeting = async () => { try { const result = await createMeetingMutation.mutateAsync({ @@ -67,8 +64,8 @@ const useRoomMeeting = ( } }; - createMeeting(); - }, [roomName, meetingId, reload]); + createMeeting().then(() => {}); + }, [roomName, reload]); const loading = createMeetingMutation.isPending && !response; const error = createMeetingMutation.error as Error | null; diff --git a/www/app/lib/apiHooks.ts b/www/app/lib/apiHooks.ts index 39b6f508..f9784904 100644 --- a/www/app/lib/apiHooks.ts +++ b/www/app/lib/apiHooks.ts @@ -102,7 +102,7 @@ export function useTranscriptGet(transcriptId: string | null) { { params: { path: { - transcript_id: transcriptId || "", + transcript_id: transcriptId!, }, }, }, @@ -120,7 +120,7 @@ export function useRoomGet(roomId: string | null) { "/v1/rooms/{room_id}", { params: { - path: { room_id: roomId || "" }, + path: { room_id: roomId! }, }, }, { @@ -327,7 +327,7 @@ export function useTranscriptTopics(transcriptId: string | null) { "/v1/transcripts/{transcript_id}/topics", { params: { - path: { transcript_id: transcriptId || "" }, + path: { transcript_id: transcriptId! }, }, }, { @@ -344,7 +344,7 @@ export function useTranscriptTopicsWithWords(transcriptId: string | null) { "/v1/transcripts/{transcript_id}/topics/with-words", { params: { - path: { transcript_id: transcriptId || "" }, + path: { transcript_id: transcriptId! }, }, }, { @@ -365,8 +365,8 @@ export function useTranscriptTopicsWithWordsPerSpeaker( { params: { path: { - transcript_id: transcriptId || "", - topic_id: topicId || "", + transcript_id: transcriptId!, + topic_id: topicId!, }, }, }, @@ -384,7 +384,7 @@ export function useTranscriptParticipants(transcriptId: string | null) { "/v1/transcripts/{transcript_id}/participants", { params: { - path: { transcript_id: transcriptId || "" }, + path: { transcript_id: transcriptId! }, }, }, { @@ -569,23 +569,18 @@ export function useMeetingDeactivate() { const { setError } = useError(); const queryClient = useQueryClient(); - return $api.useMutation("patch", "/v1/meetings/{meeting_id}/deactivate", { + 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") || - k.includes("/meetings/upcoming")), - ) + return key.some( + (k) => + typeof k === "string" && + !!MEETING_LIST_PATH_PARTIALS.find((e) => k.includes(e)), ); }, }); @@ -646,7 +641,7 @@ export function useRoomGetByName(roomName: string | null) { "/v1/rooms/name/{room_name}", { params: { - path: { room_name: roomName || "" }, + path: { room_name: roomName! }, }, }, { @@ -660,10 +655,10 @@ export function useRoomUpcomingMeetings(roomName: string | null) { return $api.useQuery( "get", - "/v1/rooms/{room_name}/meetings/upcoming", + "/v1/rooms/{room_name}/meetings/upcoming" satisfies `/v1/rooms/{room_name}/${typeof MEETINGS_UPCOMING_PATH_PARTIAL}`, { params: { - path: { room_name: roomName || "" }, + path: { room_name: roomName! }, }, }, { @@ -672,15 +667,24 @@ export function useRoomUpcomingMeetings(roomName: string | null) { ); } +const MEETINGS_PATH_PARTIAL = "meetings" as const; +const MEETINGS_ACTIVE_PATH_PARTIAL = `${MEETINGS_PATH_PARTIAL}/active` as const; +const MEETINGS_UPCOMING_PATH_PARTIAL = + `${MEETINGS_PATH_PARTIAL}/upcoming` as const; +const MEETING_LIST_PATH_PARTIALS = [ + MEETINGS_ACTIVE_PATH_PARTIAL, + MEETINGS_UPCOMING_PATH_PARTIAL, +]; + export function useRoomActiveMeetings(roomName: string | null) { const { isAuthenticated } = useAuthReady(); return $api.useQuery( "get", - "/v1/rooms/{room_name}/meetings/active", + "/v1/rooms/{room_name}/meetings/active" satisfies `/v1/rooms/{room_name}/${typeof MEETINGS_ACTIVE_PATH_PARTIAL}`, { params: { - path: { room_name: roomName || "" }, + path: { room_name: roomName! }, }, }, { @@ -721,7 +725,7 @@ export function useRoomIcsStatus(roomName: string | null) { "/v1/rooms/{room_name}/ics/status", { params: { - path: { room_name: roomName || "" }, + path: { room_name: roomName! }, }, }, { @@ -738,7 +742,7 @@ export function useRoomCalendarEvents(roomName: string | null) { "/v1/rooms/{room_name}/meetings", { params: { - path: { room_name: roomName || "" }, + path: { room_name: roomName! }, }, }, { diff --git a/www/app/lib/timeUtils.ts b/www/app/lib/timeUtils.ts index 507d11f5..db8a8152 100644 --- a/www/app/lib/timeUtils.ts +++ b/www/app/lib/timeUtils.ts @@ -1,5 +1,4 @@ -export const formatDateTime = (date: string | Date): string => { - const d = new Date(date); +export const formatDateTime = (d: Date): string => { return d.toLocaleString("en-US", { month: "short", day: "numeric", @@ -8,26 +7,11 @@ export const formatDateTime = (date: string | Date): string => { }); }; -export const formatCountdown = (startTime: string | Date): string => { - const now = new Date(); - const start = new Date(startTime); - const diff = start.getTime() - now.getTime(); - - if (diff <= 0) return "Starting 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`; -}; - -export const formatStartedAgo = (startTime: string | Date): string => { - const now = new Date(); - const start = new Date(startTime); - const diff = now.getTime() - start.getTime(); +export const formatStartedAgo = ( + startTime: Date, + now: Date = new Date(), +): string => { + const diff = now.getTime() - startTime.getTime(); if (diff <= 0) return "Starting now"; diff --git a/www/app/lib/wherebyClient.ts b/www/app/lib/wherebyClient.ts new file mode 100644 index 00000000..03e3a927 --- /dev/null +++ b/www/app/lib/wherebyClient.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from "react"; + +export 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; +}; diff --git a/www/package.json b/www/package.json index d53c1536..c93a9554 100644 --- a/www/package.json +++ b/www/package.json @@ -45,6 +45,7 @@ "react-qr-code": "^2.0.12", "react-select-search": "^4.1.7", "redlock": "5.0.0-beta.2", + "remeda": "^2.31.1", "sass": "^1.63.6", "simple-peer": "^9.11.1", "tailwindcss": "^3.3.2", diff --git a/www/pnpm-lock.yaml b/www/pnpm-lock.yaml index a4e78972..6c0a3d83 100644 --- a/www/pnpm-lock.yaml +++ b/www/pnpm-lock.yaml @@ -106,6 +106,9 @@ importers: redlock: specifier: 5.0.0-beta.2 version: 5.0.0-beta.2 + remeda: + specifier: ^2.31.1 + version: 2.31.1 sass: specifier: ^1.63.6 version: 1.90.0 @@ -7645,6 +7648,12 @@ packages: integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==, } + remeda@2.31.1: + resolution: + { + integrity: sha512-FRZefcuXbmCoYt8hAITAzW4t8i/RERaGk/+GtRN90eV3NHxsnRKCDIOJVrwrQ6zz77TG/Xyi9mGRfiJWT7DK1g==, + } + require-directory@2.1.1: resolution: { @@ -14510,6 +14519,10 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + remeda@2.31.1: + dependencies: + type-fest: 4.41.0 + require-directory@2.1.1: {} require-from-string@2.0.2: {}