From 1764c591c407d8af93c5bba1cc31c476fa191dfb Mon Sep 17 00:00:00 2001 From: Igor Loskutov Date: Wed, 17 Sep 2025 12:18:25 -0400 Subject: [PATCH] meeting page frontend fixes --- server/reflector/views/rooms.py | 29 +++++ .../(app)/rooms/_components/ICSSettings.tsx | 10 +- www/app/(app)/rooms/_components/RoomList.tsx | 3 +- www/app/(app)/rooms/_components/RoomTable.tsx | 7 +- www/app/(app)/rooms/page.tsx | 4 +- www/app/[roomName]/MeetingSelection.tsx | 4 +- www/app/[roomName]/room.tsx | 123 ++++++++++++------ ...mMeeting.tsx => useRoomDefaultMeeting.tsx} | 16 +-- www/app/api/_error.ts | 26 ++++ www/app/components/MeetingMinimalHeader.tsx | 3 +- www/app/lib/apiHooks.ts | 27 +++- www/app/lib/routes.ts | 8 +- www/app/lib/routesClient.ts | 3 +- www/app/lib/wherebyClient.ts | 7 + www/app/reflector-api.d.ts | 52 ++++++++ www/app/webinars/[title]/page.tsx | 4 +- 16 files changed, 259 insertions(+), 67 deletions(-) rename www/app/[roomName]/{useRoomMeeting.tsx => useRoomDefaultMeeting.tsx} (89%) create mode 100644 www/app/api/_error.ts diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py index 85336ea0..3d90a755 100644 --- a/server/reflector/views/rooms.py +++ b/server/reflector/views/rooms.py @@ -62,6 +62,7 @@ class Meeting(BaseModel): id: str room_name: str room_url: str + # TODO it's not always present, | None host_room_url: str start_date: datetime end_date: datetime @@ -502,6 +503,34 @@ async def rooms_list_active_meetings( return meetings +@router.get("/rooms/{room_name}/meetings/{meeting_id}", response_model=Meeting) +async def rooms_get_meeting( + room_name: str, + meeting_id: str, + user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], +): + """Get a single meeting by ID within a specific room.""" + user_id = user["sub"] if user else None + + room = await rooms_controller.get_by_name(room_name) + if not room: + raise HTTPException(status_code=404, detail="Room not found") + + meeting = await meetings_controller.get_by_id(meeting_id) + if not meeting: + raise HTTPException(status_code=404, detail="Meeting not found") + + if meeting.room_id != room.id: + raise HTTPException( + status_code=403, detail="Meeting does not belong to this room" + ) + + if user_id != room.user_id and not room.is_shared: + meeting.host_room_url = "" + + return meeting + + @router.post("/rooms/{room_name}/meetings/{meeting_id}/join", response_model=Meeting) async def rooms_join_meeting( room_name: str, diff --git a/www/app/(app)/rooms/_components/ICSSettings.tsx b/www/app/(app)/rooms/_components/ICSSettings.tsx index beb4d9fa..556aff34 100644 --- a/www/app/(app)/rooms/_components/ICSSettings.tsx +++ b/www/app/(app)/rooms/_components/ICSSettings.tsx @@ -19,7 +19,11 @@ import { FaCheckCircle, FaExclamationCircle } from "react-icons/fa"; import { useRoomIcsSync, useRoomIcsStatus } from "../../../lib/apiHooks"; import { toaster } from "../../../components/ui/toaster"; import { roomAbsoluteUrl } from "../../../lib/routesClient"; -import { assertExists } from "../../../lib/utils"; +import { + assertExists, + assertExistsAndNonEmptyString, + parseNonEmptyString, +} from "../../../lib/utils"; interface ICSSettingsProps { roomName: string; @@ -80,7 +84,7 @@ export default function ICSSettings({ const handleCopyRoomUrl = async () => { try { await navigator.clipboard.writeText( - roomAbsoluteUrl(assertExists(roomName)), + roomAbsoluteUrl(assertExistsAndNonEmptyString(roomName)), ); setJustCopied(true); @@ -194,7 +198,7 @@ export default function ICSSettings({ void; + onCopyUrl: (roomName: NonEmptyString) => void; onEdit: (roomId: string, roomData: any) => void; onDelete: (roomId: string) => void; emptyMessage?: string; diff --git a/www/app/(app)/rooms/_components/RoomTable.tsx b/www/app/(app)/rooms/_components/RoomTable.tsx index 9ad64880..04d5af38 100644 --- a/www/app/(app)/rooms/_components/RoomTable.tsx +++ b/www/app/(app)/rooms/_components/RoomTable.tsx @@ -25,6 +25,7 @@ type Meeting = components["schemas"]["Meeting"]; type CalendarEventResponse = components["schemas"]["CalendarEventResponse"]; import { RoomActionsMenu } from "./RoomActionsMenu"; import { MEETING_DEFAULT_TIME_MINUTES } from "../../../[roomName]/[meetingId]/constants"; +import { NonEmptyString, parseNonEmptyString } from "../../../lib/utils"; // Custom icon component that combines calendar and refresh icons const CalendarSyncIcon = () => ( @@ -57,7 +58,7 @@ const CalendarSyncIcon = () => ( interface RoomTableProps { rooms: Room[]; linkCopied: string; - onCopyUrl: (roomName: string) => void; + onCopyUrl: (roomName: NonEmptyString) => void; onEdit: (roomId: string, roomData: any) => void; onDelete: (roomId: string) => void; loading?: boolean; @@ -292,7 +293,9 @@ export function RoomTable({ ) : ( onCopyUrl(room.name)} + onClick={() => + onCopyUrl(parseNonEmptyString(room.name)) + } size="sm" variant="ghost" > diff --git a/www/app/(app)/rooms/page.tsx b/www/app/(app)/rooms/page.tsx index 7d9b1240..999c3e25 100644 --- a/www/app/(app)/rooms/page.tsx +++ b/www/app/(app)/rooms/page.tsx @@ -31,7 +31,7 @@ import { } from "../../lib/apiHooks"; import { RoomList } from "./_components/RoomList"; import { PaginationPage } from "../browse/_components/Pagination"; -import { assertExists } from "../../lib/utils"; +import { assertExists, NonEmptyString } from "../../lib/utils"; import ICSSettings from "./_components/ICSSettings"; import { roomAbsoluteUrl } from "../../lib/routesClient"; @@ -187,7 +187,7 @@ export default function RoomsList() { items: topicOptions, }); - const handleCopyUrl = (roomName: string) => { + const handleCopyUrl = (roomName: NonEmptyString) => { navigator.clipboard.writeText(roomAbsoluteUrl(roomName)).then(() => { setLinkCopied(roomName); setTimeout(() => { diff --git a/www/app/[roomName]/MeetingSelection.tsx b/www/app/[roomName]/MeetingSelection.tsx index 8056fd6c..2780acbd 100644 --- a/www/app/[roomName]/MeetingSelection.tsx +++ b/www/app/[roomName]/MeetingSelection.tsx @@ -25,11 +25,12 @@ import { import { useRouter } from "next/navigation"; import { formatDateTime, formatStartedAgo } from "../lib/timeUtils"; import MeetingMinimalHeader from "../components/MeetingMinimalHeader"; +import { NonEmptyString } from "../lib/utils"; type Meeting = components["schemas"]["Meeting"]; interface MeetingSelectionProps { - roomName: string; + roomName: NonEmptyString; isOwner: boolean; isSharedRoom: boolean; authLoading: boolean; @@ -47,7 +48,6 @@ export default function MeetingSelection({ isCreatingMeeting = false, }: MeetingSelectionProps) { const router = useRouter(); - const roomQuery = useRoomGetByName(roomName); const activeMeetingsQuery = useRoomActiveMeetings(roomName); const joinMeetingMutation = useRoomJoinMeeting(); diff --git a/www/app/[roomName]/room.tsx b/www/app/[roomName]/room.tsx index a728c058..780851e2 100644 --- a/www/app/[roomName]/room.tsx +++ b/www/app/[roomName]/room.tsx @@ -1,5 +1,6 @@ "use client"; +import { roomMeetingUrl, roomUrl as getRoomUrl } from "../lib/routes"; import { useCallback, useEffect, @@ -20,7 +21,6 @@ import { } 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, @@ -28,19 +28,28 @@ import { useRoomActiveMeetings, useRoomUpcomingMeetings, useRoomsCreateMeeting, + useRoomGetMeeting, } from "../lib/apiHooks"; import type { components } from "../reflector-api"; import MeetingSelection from "./MeetingSelection"; -import useRoomMeeting from "./useRoomMeeting"; +import useRoomDefaultMeeting from "./useRoomDefaultMeeting"; type Meeting = components["schemas"]["Meeting"]; import { FaBars } from "react-icons/fa6"; import { useAuth } from "../lib/AuthProvider"; -import { useWhereby } from "../lib/wherebyClient"; +import { getWherebyUrl, useWhereby } from "../lib/wherebyClient"; +import { useError } from "../(errors)/errorContext"; +import { + assertExistsAndNonEmptyString, + NonEmptyString, + parseNonEmptyString, +} from "../lib/utils"; +import { printApiError } from "../api/_error"; export type RoomDetails = { params: Promise<{ roomName: string; + meetingId?: string; }>; }; @@ -216,7 +225,7 @@ function ConsentDialogButton({ meetingId, wherebyRef, }: { - meetingId: string; + meetingId: NonEmptyString; wherebyRef: React.RefObject; }) { const { showConsentModal, consentState, hasConsent, consentLoading } = @@ -252,37 +261,55 @@ 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 roomName = parseNonEmptyString(params.roomName); const router = useRouter(); const auth = useAuth(); const status = auth.status; const isAuthenticated = status === "authenticated"; + const { setError } = useError(); 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 pageMeetingId = params.meetingId; + + // this one is called on room page + const defaultMeeting = useRoomDefaultMeeting( + room && !room.ics_enabled && !pageMeetingId ? roomName : null, ); - const roomUrl = - roomMeeting?.response?.host_room_url || roomMeeting?.response?.room_url; + + const explicitMeeting = useRoomGetMeeting(roomName, pageMeetingId || null); + const wherebyRoomUrl = explicitMeeting.data + ? getWherebyUrl(explicitMeeting.data) + : defaultMeeting.response + ? getWherebyUrl(defaultMeeting.response) + : null; + const recordingType = (explicitMeeting.data || defaultMeeting.response) + ?.recording_type; + const meetingId = (explicitMeeting.data || defaultMeeting.response)?.id; const isLoading = - status === "loading" || roomQuery.isLoading || roomMeeting?.loading; + status === "loading" || + roomQuery.isLoading || + defaultMeeting?.loading || + explicitMeeting.isLoading; + + const errors = [ + explicitMeeting.error, + defaultMeeting.error, + roomQuery.error, + createMeetingMutation.error, + ].filter(Boolean); 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}`); + router.push( + roomMeetingUrl(roomName, parseNonEmptyString(selectedMeeting.id)), + ); }; const handleCreateUnscheduled = async () => { @@ -307,25 +334,21 @@ export default function Room(details: RoomDetails) { }, [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; + if (isLoading || !isAuthenticated || !wherebyRoomUrl || !wherebyLoaded) + return; wherebyRef.current?.addEventListener("leave", handleLeave); return () => { wherebyRef.current?.removeEventListener("leave", handleLeave); }; - }, [handleLeave, roomUrl, isLoading, isAuthenticated, wherebyLoaded]); + }, [handleLeave, wherebyRoomUrl, isLoading, isAuthenticated, wherebyLoaded]); + + useEffect(() => { + if (!isLoading && !wherebyRoomUrl) { + setError(new Error("Whereby room URL not found")); + } + }, [isLoading, wherebyRoomUrl]); if (isLoading) { return ( @@ -357,7 +380,7 @@ export default function Room(details: RoomDetails) { ); } - if (room.ics_enabled) { + if (room.ics_enabled && !params.meetingId) { return ( 0) { + return ( + + {errors.map((error, i) => ( + + {printApiError(error)} + + ))} + + ); + } + return ( <> - {roomUrl && meetingId && wherebyLoaded && ( + {wherebyRoomUrl && wherebyLoaded && ( <> - {recordingType && recordingTypeRequiresConsent(recordingType) && ( - - )} + {recordingType && + recordingTypeRequiresConsent(recordingType) && + meetingId && ( + + )} )} diff --git a/www/app/[roomName]/useRoomMeeting.tsx b/www/app/[roomName]/useRoomDefaultMeeting.tsx similarity index 89% rename from www/app/[roomName]/useRoomMeeting.tsx rename to www/app/[roomName]/useRoomDefaultMeeting.tsx index 8a9f7b4a..724e692f 100644 --- a/www/app/[roomName]/useRoomMeeting.tsx +++ b/www/app/[roomName]/useRoomDefaultMeeting.tsx @@ -6,30 +6,31 @@ import { shouldShowError } from "../lib/errorUtils"; type Meeting = components["schemas"]["Meeting"]; import { useRoomsCreateMeeting } from "../lib/apiHooks"; import { notFound } from "next/navigation"; +import { ApiError } from "../api/_error"; type ErrorMeeting = { - error: Error; + error: ApiError; loading: false; response: null; reload: () => void; }; type LoadingMeeting = { + error: null; response: null; loading: true; - error: false; reload: () => void; }; type SuccessMeeting = { + error: null; response: Meeting; loading: false; - error: null; reload: () => void; }; -const useRoomMeeting = ( - roomName: string | null | undefined, +const useRoomDefaultMeeting = ( + roomName: string | null, ): ErrorMeeting | LoadingMeeting | SuccessMeeting => { const [response, setResponse] = useState(null); const [reload, setReload] = useState(0); @@ -44,7 +45,6 @@ const useRoomMeeting = ( if (!roomName) return; if (creatingRef.current) return; - // For any case where we need a meeting (with or without meetingId), const createMeeting = async () => { creatingRef.current = true; try { @@ -78,7 +78,7 @@ const useRoomMeeting = ( }, [roomName, reload]); const loading = createMeetingMutation.isPending && !response; - const error = createMeetingMutation.error as Error | null; + const error = createMeetingMutation.error; return { response, loading, error, reload: reloadHandler } as | ErrorMeeting @@ -86,4 +86,4 @@ const useRoomMeeting = ( | SuccessMeeting; }; -export default useRoomMeeting; +export default useRoomDefaultMeeting; diff --git a/www/app/api/_error.ts b/www/app/api/_error.ts new file mode 100644 index 00000000..9603b8e8 --- /dev/null +++ b/www/app/api/_error.ts @@ -0,0 +1,26 @@ +import { components } from "../reflector-api"; +import { isArray } from "remeda"; + +export type ApiError = { + detail?: components["schemas"]["ValidationError"][]; +} | null; + +// errors as declared on api types is not != as they in reality e.g. detail may be a string +export const printApiError = (error: ApiError) => { + if (!error || !error.detail) { + return null; + } + const detail = error.detail as unknown; + if (isArray(error.detail)) { + return error.detail.map((e) => e.msg).join(", "); + } + if (typeof detail === "string") { + if (detail.length > 0) { + return detail; + } + console.error("Error detail is empty"); + return null; + } + console.error("Error detail is not a string or array"); + return null; +}; diff --git a/www/app/components/MeetingMinimalHeader.tsx b/www/app/components/MeetingMinimalHeader.tsx index 2527a033..fe08c9d6 100644 --- a/www/app/components/MeetingMinimalHeader.tsx +++ b/www/app/components/MeetingMinimalHeader.tsx @@ -5,9 +5,10 @@ import NextLink from "next/link"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { roomUrl } from "../lib/routes"; +import { NonEmptyString } from "../lib/utils"; interface MeetingMinimalHeaderProps { - roomName: string; + roomName: NonEmptyString; displayName?: string; showLeaveButton?: boolean; onLeave?: () => void; diff --git a/www/app/lib/apiHooks.ts b/www/app/lib/apiHooks.ts index 7cae9611..c5b4f9b9 100644 --- a/www/app/lib/apiHooks.ts +++ b/www/app/lib/apiHooks.ts @@ -12,7 +12,7 @@ import { useAuth } from "./AuthProvider"; * or, limitation or incorrect usage of .d type generator from json schema * */ -const useAuthReady = () => { +export const useAuthReady = () => { const auth = useAuth(); return { @@ -695,8 +695,6 @@ const MEETING_LIST_PATH_PARTIALS = [ ]; export function useRoomActiveMeetings(roomName: string | null) { - const { isAuthenticated } = useAuthReady(); - return $api.useQuery( "get", "/v1/rooms/{room_name}/meetings/active" satisfies `/v1/rooms/{room_name}/${typeof MEETINGS_ACTIVE_PATH_PARTIAL}`, @@ -706,7 +704,28 @@ export function useRoomActiveMeetings(roomName: string | null) { }, }, { - enabled: !!roomName && isAuthenticated, + enabled: !!roomName, + }, + ); +} + +export function useRoomGetMeeting( + roomName: string | null, + meetingId: string | null, +) { + return $api.useQuery( + "get", + "/v1/rooms/{room_name}/meetings/{meeting_id}", + { + params: { + path: { + room_name: roomName!, + meeting_id: meetingId!, + }, + }, + }, + { + enabled: !!roomName && !!meetingId, }, ); } diff --git a/www/app/lib/routes.ts b/www/app/lib/routes.ts index 3bf5aa94..480082d0 100644 --- a/www/app/lib/routes.ts +++ b/www/app/lib/routes.ts @@ -1 +1,7 @@ -export const roomUrl = (roomName: string) => `/${roomName}`; +import { NonEmptyString } from "./utils"; + +export const roomUrl = (roomName: NonEmptyString) => `/${roomName}`; +export const roomMeetingUrl = ( + roomName: NonEmptyString, + meetingId: NonEmptyString, +) => `${roomUrl(roomName)}/${meetingId}`; diff --git a/www/app/lib/routesClient.ts b/www/app/lib/routesClient.ts index 6b0e5fb8..9522bc74 100644 --- a/www/app/lib/routesClient.ts +++ b/www/app/lib/routesClient.ts @@ -1,4 +1,5 @@ import { roomUrl } from "./routes"; +import { NonEmptyString } from "./utils"; -export const roomAbsoluteUrl = (roomName: string) => +export const roomAbsoluteUrl = (roomName: NonEmptyString) => `${window.location.origin}${roomUrl(roomName)}`; diff --git a/www/app/lib/wherebyClient.ts b/www/app/lib/wherebyClient.ts index 03e3a927..2345bd7b 100644 --- a/www/app/lib/wherebyClient.ts +++ b/www/app/lib/wherebyClient.ts @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { components } from "../reflector-api"; export const useWhereby = () => { const [wherebyLoaded, setWherebyLoaded] = useState(false); @@ -13,3 +14,9 @@ export const useWhereby = () => { }, []); return wherebyLoaded; }; + +export const getWherebyUrl = ( + meeting: Pick, +) => + // host_room_url possible '' atm + meeting.host_room_url || meeting.room_url; diff --git a/www/app/reflector-api.d.ts b/www/app/reflector-api.d.ts index 106efae1..e1709d69 100644 --- a/www/app/reflector-api.d.ts +++ b/www/app/reflector-api.d.ts @@ -234,6 +234,26 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/rooms/{room_name}/meetings/{meeting_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Rooms Get Meeting + * @description Get a single meeting by ID within a specific room. + */ + get: operations["v1_rooms_get_meeting"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/rooms/{room_name}/meetings/{meeting_id}/join": { parameters: { query?: never; @@ -1993,6 +2013,38 @@ export interface operations { }; }; }; + v1_rooms_get_meeting: { + parameters: { + query?: never; + header?: never; + path: { + room_name: string; + meeting_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Meeting"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; v1_rooms_join_meeting: { parameters: { query?: never; diff --git a/www/app/webinars/[title]/page.tsx b/www/app/webinars/[title]/page.tsx index 51583a2a..ff21af1e 100644 --- a/www/app/webinars/[title]/page.tsx +++ b/www/app/webinars/[title]/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState, use } from "react"; import Link from "next/link"; import Image from "next/image"; import { notFound } from "next/navigation"; -import useRoomMeeting from "../../[roomName]/useRoomMeeting"; +import useRoomDefaultMeeting from "../../[roomName]/useRoomDefaultMeeting"; import dynamic from "next/dynamic"; const WherebyEmbed = dynamic(() => import("../../lib/WherebyWebinarEmbed"), { ssr: false, @@ -72,7 +72,7 @@ export default function WebinarPage(details: WebinarDetails) { const startDate = new Date(Date.parse(webinar.startsAt)); const endDate = new Date(Date.parse(webinar.endsAt)); - const meeting = useRoomMeeting(ROOM_NAME); + const meeting = useRoomDefaultMeeting(ROOM_NAME); const roomUrl = meeting?.response?.host_room_url ? meeting?.response?.host_room_url : meeting?.response?.room_url;