diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index bfea1cd38b..7f712dcfbd 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -9,6 +9,7 @@ import dayjs from "@calcom/dayjs"; // TODO: Use browser locale, implement Intl in Dayjs maybe? import "@calcom/dayjs/locales"; import { Dialog } from "@calcom/features/components/controlled-dialog"; +import { MeetingSessionDetailsDialog } from "@calcom/features/ee/video/MeetingSessionDetailsDialog"; import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog"; import { formatTime } from "@calcom/lib/dayjs"; import { getPaymentAppData } from "@calcom/lib/getPaymentAppData"; @@ -118,6 +119,7 @@ function BookingListItem(booking: BookingItemProps) { const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false); const [chargeCardDialogIsOpen, setChargeCardDialogIsOpen] = useState(false); const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState(false); + const [meetingSessionDetailsDialogIsOpen, setMeetingSessionDetailsDialogIsOpen] = useState(false); const [isNoShowDialogOpen, setIsNoShowDialogOpen] = useState(false); const cardCharged = booking?.payment[0]?.success; @@ -465,7 +467,10 @@ function BookingListItem(booking: BookingItemProps) { !booking.isRecorded && (!booking.location || booking.location === "integrations:daily" || booking?.location?.trim() === ""); - const showRecordingActions: ActionType[] = [ + const isCalVideoLocation = + !booking.location || booking.location === "integrations:daily" || booking?.location?.trim() === ""; + + const videoOptionsActions: ActionType[] = [ { id: "view_recordings", label: showCheckRecordingButton ? t("check_for_recordings") : t("view_recordings"), @@ -475,6 +480,14 @@ function BookingListItem(booking: BookingItemProps) { color: showCheckRecordingButton ? "secondary" : "primary", disabled: mutation.isPending, }, + { + id: "meeting_session_details", + label: t("get_meeting_session_details"), + onClick: () => { + setMeetingSessionDetailsDialogIsOpen(true); + }, + disabled: mutation.isPending, + }, ]; const showPendingPayment = paymentAppData.enabled && booking.payment.length && !booking.paid; @@ -516,7 +529,7 @@ function BookingListItem(booking: BookingItemProps) { paymentCurrency={booking.payment[0].currency} /> )} - {(showViewRecordingsButton || showCheckRecordingButton) && ( + {isCalVideoLocation && ( )} + {isCalVideoLocation && meetingSessionDetailsDialogIsOpen && ( + + )} {isNoShowDialogOpen && ( : null} {isBookingInPast && isConfirmed ? : null} - {(showViewRecordingsButton || showCheckRecordingButton) && ( - + {isCalVideoLocation && ( + )} {isCancelled && booking.rescheduled && (
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 0724e3baf7..82f69d4ddf 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -48,6 +48,21 @@ "calvideo_settings_description": "Configure video meeting preferences including recording permissions for hosts and attendees.", "disable_recording_for_guests": "Disable recording for guests", "enable_automatic_transcription": "Enable automatic transcription after joining the meeting", + "video_options": "Video Options", + "get_meeting_session_details": "Get Meeting Session Details", + "meeting_session_details": "Meeting Session Details", + "meeting_session": "Meeting Session", + "session_id": "Session ID", + "max_participants": "Max Participants", + "unlimited": "Unlimited", + "ongoing": "Ongoing", + "ended": "Ended", + "participants": "Participants", + "anonymous_user": "Anonymous User", + "session_duration": "Session Duration", + "minutes": "minutes", + "no_meeting_sessions_found": "No meeting sessions found", + "not_available": "Not available", "disable_recording_for_organizer": "Disable recording for organizer", "disable_transcription_for_guests": "Disable transcription for guests", "disable_transcription_for_organizer": "Disable transcription for organizer", diff --git a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts index ee640cce9b..94ad618234 100644 --- a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts +++ b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts @@ -25,6 +25,28 @@ import { ZGetMeetingTokenResponseSchema, } from "./types"; +const meetingParticipantSchema = z.object({ + user_id: z.string().nullable(), + participant_id: z.string(), + user_name: z.string().nullable(), + join_time: z.number(), + duration: z.number(), +}); + +const meetingSessionSchema = z.object({ + id: z.string(), + room: z.string(), + start_time: z.number(), + duration: z.number(), + ongoing: z.boolean(), + max_participants: z.number(), + participants: z.array(meetingParticipantSchema), +}); + +const getMeetingInformationResponseSchema = z.object({ + data: z.array(meetingSessionSchema), +}); + export interface DailyEventResult { id: string; name: string; @@ -501,6 +523,17 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => { throw new Error(`Something went wrong! Unable to checkIfRoomNameMatchesInRecording. ${err}`); } }, + getMeetingInformation: async (roomName: string) => { + try { + const res = await fetcher(`/meetings?room=${encodeURIComponent(roomName)}`).then( + getMeetingInformationResponseSchema.parse + ); + return res; + } catch (err) { + console.error("err", err); + throw new Error("Something went wrong! Unable to get meeting information"); + } + }, }; }; diff --git a/packages/features/ee/video/MeetingSessionDetailsDialog.tsx b/packages/features/ee/video/MeetingSessionDetailsDialog.tsx new file mode 100644 index 0000000000..681eacb56c --- /dev/null +++ b/packages/features/ee/video/MeetingSessionDetailsDialog.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { Suspense } from "react"; + +import dayjs from "@calcom/dayjs"; +import { Dialog } from "@calcom/features/components/controlled-dialog"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { RouterOutputs } from "@calcom/trpc/react"; +import { trpc } from "@calcom/trpc/react"; +import type { PartialReference } from "@calcom/types/EventManager"; +import { DialogContent, DialogFooter, DialogHeader, DialogClose } from "@calcom/ui/components/dialog"; +import { SkeletonText } from "@calcom/ui/components/skeleton"; + +type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number]; +type Session = RouterOutputs["viewer"]["calVideo"]["getMeetingInformation"]["data"][number]; +type Participant = Session["participants"][number]; + +interface IMeetingSessionDetailsDialog { + booking?: BookingItem; + isOpenDialog: boolean; + setIsOpenDialog: React.Dispatch>; + timeFormat: number | null; +} + +const MeetingSessionDetailsSkeleton = () => { + return ( +
+ {[...Array(2)].map((_, index) => ( +
+
+ + +
+
+ {[...Array(4)].map((_, i) => ( +
+ + +
+ ))} +
+
+ +
+ {[...Array(2)].map((_, i) => ( +
+
+ + +
+ +
+ ))} +
+
+
+ ))} +
+ ); +}; + +const MeetingSessionDetailsList = ({ roomName }: { roomName: string }) => { + const { t } = useLocale(); + + const { data: meetingInfo, isLoading } = trpc.viewer.calVideo.getMeetingInformation.useQuery( + { roomName }, + { + suspense: true, + } + ); + + if (isLoading) { + return ; + } + + if (!meetingInfo || !meetingInfo.data || meetingInfo.data.length === 0) { + return

{t("no_meeting_sessions_found")}

; + } + + const formatDuration = (totalSeconds: number) => { + if (!totalSeconds) { + return t("ongoing"); + } + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + const parts = []; + if (minutes > 0) { + parts.push(`${minutes} ${t("minutes")}`); + } + if (seconds > 0) { + parts.push(`${seconds} ${t("seconds")}`); + } + return parts.join(" "); + }; + + return ( +
+ {meetingInfo.data.map((session: Session, index: number) => ( +
+
+

+ {t("meeting_session")} {index + 1} +

+

+ {t("session_id")}: {session.id} +

+
+ +
+
+ {t("start_time")}: +

+ {session.start_time + ? dayjs(session.start_time * 1000).format("MMM D, YYYY h:mm A") + : t("not_available")} +

+
+ +
+ {t("duration")}: +

+ {session.duration ? formatDuration(session.duration) : t("ongoing")} +

+
+ +
+ {t("max_participants")}: +

{session.max_participants || t("unlimited")}

+
+ +
+ {t("status")}: +

{session.ongoing ? t("ongoing") : t("ended")}

+
+
+ + {session.participants && session.participants.length > 0 && ( +
+

{t("participants")}:

+
+ {session.participants.map((participant: Participant) => ( +
+
+ {participant.user_name || t("anonymous_user")} + + {participant.join_time ? dayjs(participant.join_time * 1000).format("h:mm A") : ""} + +
+

+ {t("user_id")}: {participant.user_id || t("not_available")} +

+

+ {t("participant_id")}: {participant.participant_id} +

+ {participant.duration && ( +

+ {t("session_duration")}: {formatDuration(participant.duration)} +

+ )} +
+ ))} +
+
+ )} +
+ ))} +
+ ); +}; + +export const MeetingSessionDetailsDialog = (props: IMeetingSessionDetailsDialog) => { + const { t, i18n } = useLocale(); + const { isOpenDialog, setIsOpenDialog, booking, timeFormat } = props; + + const roomName = + booking?.references?.find((reference: PartialReference) => reference.type === "daily_video")?.meetingId ?? + undefined; + + const subtitle = `${booking?.title} - ${dayjs(booking?.startTime).format("ddd")} ${dayjs( + booking?.startTime + ).format("D")}, ${dayjs(booking?.startTime).format("MMM")}`; + + return ( + + + + {roomName ? ( + }> + + + ) : ( +

{t("no_meeting_sessions_found")}

+ )} + + + +
+
+ ); +}; diff --git a/packages/trpc/server/routers/viewer/calVideo/_router.tsx b/packages/trpc/server/routers/viewer/calVideo/_router.tsx index 3bc6086b1b..5a6e050dbc 100644 --- a/packages/trpc/server/routers/viewer/calVideo/_router.tsx +++ b/packages/trpc/server/routers/viewer/calVideo/_router.tsx @@ -2,10 +2,12 @@ import authedProcedure from "../../../procedures/authedProcedure"; import { router } from "../../../trpc"; import { ZGetCalVideoRecordingsInputSchema } from "./getCalVideoRecordings.schema"; import { ZGetDownloadLinkOfCalVideoRecordingsInputSchema } from "./getDownloadLinkOfCalVideoRecordings.schema"; +import { ZGetMeetingInformationInputSchema } from "./getMeetingInformation.schema"; type CalVideoRouterHandlerCache = { getCalVideoRecordings?: typeof import("./getCalVideoRecordings.handler").getCalVideoRecordingsHandler; getDownloadLinkOfCalVideoRecordings?: typeof import("./getDownloadLinkOfCalVideoRecordings.handler").getDownloadLinkOfCalVideoRecordingsHandler; + getMeetingInformation?: typeof import("./getMeetingInformation.handler").getMeetingInformationHandler; }; export const calVideoRouter = router({ @@ -26,4 +28,12 @@ export const calVideoRouter = router({ return getDownloadLinkOfCalVideoRecordingsHandler({ ctx, input }); }), + + getMeetingInformation: authedProcedure + .input(ZGetMeetingInformationInputSchema) + .query(async ({ ctx, input }) => { + const { getMeetingInformationHandler } = await import("./getMeetingInformation.handler"); + + return getMeetingInformationHandler({ ctx, input }); + }), }); diff --git a/packages/trpc/server/routers/viewer/calVideo/getMeetingInformation.handler.ts b/packages/trpc/server/routers/viewer/calVideo/getMeetingInformation.handler.ts new file mode 100644 index 0000000000..de12ba635b --- /dev/null +++ b/packages/trpc/server/routers/viewer/calVideo/getMeetingInformation.handler.ts @@ -0,0 +1,33 @@ +import DailyVideoApiAdapter from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter"; +import type { TrpcSessionUser } from "@calcom/trpc/server/types"; + +import { TRPCError } from "@trpc/server"; + +import type { TGetMeetingInformationInputSchema } from "./getMeetingInformation.schema"; + +type GetMeetingInformationOptions = { + ctx: { + user: NonNullable; + }; + input: TGetMeetingInformationInputSchema; +}; + +export const getMeetingInformationHandler = async ({ ctx: _ctx, input }: GetMeetingInformationOptions) => { + const { roomName } = input; + + try { + const videoApiAdapter = DailyVideoApiAdapter(); + if (!videoApiAdapter || !videoApiAdapter.getMeetingInformation) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Meeting information feature not available", + }); + } + const res = await videoApiAdapter.getMeetingInformation(roomName); + return res; + } catch (err) { + throw new TRPCError({ + code: "BAD_REQUEST", + }); + } +}; diff --git a/packages/trpc/server/routers/viewer/calVideo/getMeetingInformation.schema.ts b/packages/trpc/server/routers/viewer/calVideo/getMeetingInformation.schema.ts new file mode 100644 index 0000000000..e8b300c334 --- /dev/null +++ b/packages/trpc/server/routers/viewer/calVideo/getMeetingInformation.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZGetMeetingInformationInputSchema = z.object({ + roomName: z.string(), +}); + +export type TGetMeetingInformationInputSchema = z.infer; diff --git a/packages/types/VideoApiAdapter.d.ts b/packages/types/VideoApiAdapter.d.ts index 96331d2ccf..099224464c 100644 --- a/packages/types/VideoApiAdapter.d.ts +++ b/packages/types/VideoApiAdapter.d.ts @@ -43,6 +43,8 @@ export type VideoApiAdapter = ): Promise; checkIfRoomNameMatchesInRecording?(roomName: string, recordingId: string): Promise; + + getMeetingInformation?(roomName: string): Promise; } | undefined;