From aac89e8d035ad01a9855190312eab3e93aaf4bb7 Mon Sep 17 00:00:00 2001 From: Igor Loskutov Date: Thu, 29 Jan 2026 15:57:09 -0500 Subject: [PATCH] rejoin tags backend --- server/reflector/views/rooms.py | 41 ++++++-- www/app/[roomName]/components/DailyRoom.tsx | 95 ++++++++++++++--- www/app/lib/apiHooks.ts | 45 ++++++-- www/app/reflector-api.d.ts | 108 ++++++++++++++++++++ 4 files changed, 256 insertions(+), 33 deletions(-) diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py index 1ec31d49..51fff2d1 100644 --- a/server/reflector/views/rooms.py +++ b/server/reflector/views/rooms.py @@ -366,17 +366,12 @@ async def rooms_create_meeting( return meeting -@router.post("/rooms/{room_name}/meetings/{meeting_id}/leave") -async def rooms_leave_meeting( +@router.post("/rooms/{room_name}/meetings/{meeting_id}/joined") +async def rooms_joined_meeting( room_name: str, meeting_id: str, - user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], ): - """Trigger presence recheck when user leaves meeting (e.g., tab close/navigation). - - Immediately queues presence poll to detect dirty disconnects faster than 30s periodic poll. - Daily.co webhooks handle clean disconnects, but tab close/crash need this endpoint. - """ + """Trigger presence poll (ideally when user actually joins meeting in Daily iframe)""" room = await rooms_controller.get_by_name(room_name) if not room: raise HTTPException(status_code=404, detail="Room not found") @@ -391,6 +386,33 @@ async def rooms_leave_meeting( return {"status": "ok"} +@router.post("/rooms/{room_name}/meetings/{meeting_id}/leave") +async def rooms_leave_meeting( + room_name: str, + meeting_id: str, + delay_seconds: int = 2, +): + """Trigger presence recheck when user leaves meeting (e.g., tab close/navigation). + + Queues presence poll with optional delay to allow Daily.co to detect disconnect. + """ + 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, room=room) + if not meeting: + raise HTTPException(status_code=404, detail="Meeting not found") + + if meeting.platform == "daily": + poll_daily_room_presence_task.apply_async( + args=[meeting_id], + countdown=delay_seconds, + ) + + return {"status": "ok"} + + @router.post("/rooms/{room_id}/webhook/test", response_model=WebhookTestResult) async def rooms_test_webhook( room_id: str, @@ -622,7 +644,4 @@ async def rooms_join_meeting( meeting = meeting.model_copy() meeting.room_url = add_query_param(meeting.room_url, "t", token) - if meeting.platform == "daily": - poll_daily_room_presence_task.delay(meeting_id) - return meeting diff --git a/www/app/[roomName]/components/DailyRoom.tsx b/www/app/[roomName]/components/DailyRoom.tsx index a1ae3180..6b50a5ad 100644 --- a/www/app/[roomName]/components/DailyRoom.tsx +++ b/www/app/[roomName]/components/DailyRoom.tsx @@ -24,16 +24,24 @@ import { useAuth } from "../../lib/AuthProvider"; import { useConsentDialog } from "../../lib/consent"; import { useRoomJoinMeeting, + useRoomJoinedMeeting, useRoomLeaveMeeting, useMeetingStartRecording, + leaveRoomPostUrl, + LeaveRoomBody, } from "../../lib/apiHooks"; import { omit } from "remeda"; import { assertExists, + assertExistsAndNonEmptyString, NonEmptyString, parseNonEmptyString, } from "../../lib/utils"; -import { assertMeetingId, DailyRecordingType } from "../../lib/types"; +import { + assertMeetingId, + DailyRecordingType, + MeetingId, +} from "../../lib/types"; import { useUuidV5 } from "react-uuid-hook"; const CONSENT_BUTTON_ID = "recording-consent"; @@ -180,6 +188,58 @@ const useFrame = ( ] as const; }; +const leaveDaily = () => { + const frame = DailyIframe.getCallInstance(); + frame?.leave(); +}; + +const useDirtyDisconnects = ( + meetingId: NonEmptyString, + roomName: NonEmptyString, +) => { + useEffect(() => { + if (!meetingId || !roomName) return; + + const handleBeforeUnload = () => { + leaveDaily(); + navigator.sendBeacon( + leaveRoomPostUrl( + { + room_name: roomName, + meeting_id: meetingId, + }, + { + delay_seconds: 5, + }, + ), + undefined satisfies LeaveRoomBody, + ); + }; + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, [meetingId, roomName]); +}; + +const useDisconnects = ( + meetingId: NonEmptyString, + roomName: NonEmptyString, + leaveMutation: ReturnType, +) => { + useDirtyDisconnects(meetingId, roomName); + + useEffect(() => { + return () => { + leaveDaily(); + leaveMutation.mutate({ + params: { + path: { meeting_id: meetingId, room_name: roomName }, + query: { delay_seconds: 5 }, + }, + }); + }; + }, [meetingId, roomName]); +}; + export default function DailyRoom({ meeting, room }: DailyRoomProps) { const router = useRouter(); const params = useParams(); @@ -187,6 +247,8 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) { const authLastUserId = auth.lastUserId; const [container, setContainer] = useState(null); const joinMutation = useRoomJoinMeeting(); + const joinedMutation = useRoomJoinedMeeting(); + const leaveMutation = useRoomLeaveMeeting(); const startRecordingMutation = useMeetingStartRecording(); const [joinedMeeting, setJoinedMeeting] = useState(null); @@ -196,7 +258,9 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) { useUuidV5(meeting.id, RAW_TRACKS_NAMESPACE)[0], ); - const roomName = params?.roomName as string; + if (typeof params.roomName === "object") + throw new Error(`Invalid room name in params. array? ${params.roomName}`); + const roomName = assertExistsAndNonEmptyString(params.roomName); const { showConsentModal, @@ -238,19 +302,7 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) { router.push("/browse"); }, [router]); - // Trigger presence recheck on dirty disconnects (tab close, navigation away) - useEffect(() => { - if (!meeting?.id || !roomName) return; - - const handleBeforeUnload = () => { - // sendBeacon guarantees delivery even if tab closes mid-request - const url = `/v1/rooms/${roomName}/meetings/${meeting.id}/leave`; - navigator.sendBeacon(url, JSON.stringify({})); - }; - - window.addEventListener("beforeunload", handleBeforeUnload); - return () => window.removeEventListener("beforeunload", handleBeforeUnload); - }, [meeting?.id, roomName]); + useDisconnects(meeting.id as MeetingId, roomName, leaveMutation); const handleCustomButtonClick = useCallback( (ev: DailyEventObjectCustomButtonClick) => { @@ -264,6 +316,15 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) { ); const handleFrameJoinMeeting = useCallback(() => { + joinedMutation.mutate({ + params: { + path: { + room_name: roomName, + meeting_id: meeting.id, + }, + }, + }); + if (meeting.recording_type === "cloud") { console.log("Starting dual recording via REST API", { cloudInstanceId, @@ -323,8 +384,10 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) { startRecordingWithRetry("raw-tracks", rawTracksInstanceId); } }, [ - meeting.recording_type, + joinedMutation, + roomName, meeting.id, + meeting.recording_type, startRecordingMutation, cloudInstanceId, rawTracksInstanceId, diff --git a/www/app/lib/apiHooks.ts b/www/app/lib/apiHooks.ts index 9f5034a5..cde7c98e 100644 --- a/www/app/lib/apiHooks.ts +++ b/www/app/lib/apiHooks.ts @@ -1,11 +1,13 @@ "use client"; -import { $api } from "./apiClient"; +import { $api, API_URL } from "./apiClient"; import { useError } from "../(errors)/errorContext"; import { QueryClient, useQueryClient } from "@tanstack/react-query"; -import type { components } from "../reflector-api"; +import type { components, operations } from "../reflector-api"; import { useAuth } from "./AuthProvider"; import { MeetingId } from "./types"; +import { NonEmptyString } from "./utils"; +import { createFinalURL, createQuerySerializer } from "openapi-fetch"; /* * XXX error types returned from the hooks are not always correct; declared types are ValidationError but real type could be string or any other @@ -766,11 +768,42 @@ export function useRoomJoinMeeting() { ); } +export const LEAVE_ROOM_POST_URL_TEMPLATE = + "/v1/rooms/{room_name}/meetings/{meeting_id}/leave" as const; + +export const leaveRoomPostUrl = ( + path: operations["v1_rooms_leave_meeting"]["parameters"]["path"], + query?: operations["v1_rooms_leave_meeting"]["parameters"]["query"], +): string => + createFinalURL(LEAVE_ROOM_POST_URL_TEMPLATE, { + baseUrl: API_URL, + params: { path, query }, + querySerializer: createQuerySerializer(), + }); + +export type LeaveRoomBody = operations["v1_rooms_leave_meeting"]["requestBody"]; + export function useRoomLeaveMeeting() { - return $api.useMutation( - "post", - "/v1/rooms/{room_name}/meetings/{meeting_id}/leave", - ); + return $api.useMutation("post", LEAVE_ROOM_POST_URL_TEMPLATE); +} + +export const JOINED_ROOM_POST_URL_TEMPLATE = + "/v1/rooms/{room_name}/meetings/{meeting_id}/joined" as const; + +export const joinedRoomPostUrl = ( + params: operations["v1_rooms_joined_meeting"]["parameters"]["path"], +): string => + createFinalURL(JOINED_ROOM_POST_URL_TEMPLATE, { + baseUrl: API_URL, + params: { path: params }, + querySerializer: () => "", + }); + +export type JoinedRoomBody = + operations["v1_rooms_joined_meeting"]["requestBody"]; + +export function useRoomJoinedMeeting() { + return $api.useMutation("post", JOINED_ROOM_POST_URL_TEMPLATE); } export function useRoomIcsSync() { diff --git a/www/app/reflector-api.d.ts b/www/app/reflector-api.d.ts index 12a7085c..e2776c25 100644 --- a/www/app/reflector-api.d.ts +++ b/www/app/reflector-api.d.ts @@ -171,6 +171,48 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/rooms/{room_name}/meetings/{meeting_id}/joined": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Rooms Joined Meeting + * @description Trigger presence poll (ideally when user actually joins meeting in Daily iframe) + */ + post: operations["v1_rooms_joined_meeting"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_name}/meetings/{meeting_id}/leave": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Rooms Leave Meeting + * @description Trigger presence recheck when user leaves meeting (e.g., tab close/navigation). + * + * Queues presence poll with optional delay to allow Daily.co to detect disconnect. + */ + post: operations["v1_rooms_leave_meeting"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/rooms/{room_id}/webhook/test": { parameters: { query?: never; @@ -2435,6 +2477,72 @@ export interface operations { }; }; }; + v1_rooms_joined_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": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_leave_meeting: { + parameters: { + query?: { + delay_seconds?: number; + }; + 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": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; v1_rooms_test_webhook: { parameters: { query?: never;