feat: add Video Options dropdown with meeting session details (#22147)
* feat: add Video Options dropdown with meeting session details - Add Video Options dropdown to BookingListItem for Daily video bookings - Move recording actions from TableActions to new dropdown - Add meeting session details feature with participant info, join times, duration - Create new tRPC endpoint for fetching Daily.co meeting information - Add getMeetingInformation method to VideoApiAdapter - Create MeetingSessionDetailsDialog component following ViewRecordingsDialog pattern - Add translation strings for new UI elements - Update VideoApiAdapter TypeScript interface Co-Authored-By: udit@cal.com <udit222001@gmail.com> * fix: bugs and types * Update packages/app-store/dailyvideo/lib/VideoApiAdapter.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * Update packages/app-store/dailyvideo/lib/VideoApiAdapter.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
8 files changed
+328
-4
No files matched your search
@@ -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<boolean>(false);
|
||||
const [meetingSessionDetailsDialogIsOpen, setMeetingSessionDetailsDialogIsOpen] = useState<boolean>(false);
|
||||
const [isNoShowDialogOpen, setIsNoShowDialogOpen] = useState<boolean>(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 && (
|
||||
<ViewRecordingsDialog
|
||||
booking={booking}
|
||||
isOpenDialog={viewRecordingsDialogIsOpen}
|
||||
@@ -524,6 +537,14 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
timeFormat={userTimeFormat ?? null}
|
||||
/>
|
||||
)}
|
||||
{isCalVideoLocation && meetingSessionDetailsDialogIsOpen && (
|
||||
<MeetingSessionDetailsDialog
|
||||
booking={booking}
|
||||
isOpenDialog={meetingSessionDetailsDialogIsOpen}
|
||||
setIsOpenDialog={setMeetingSessionDetailsDialogIsOpen}
|
||||
timeFormat={userTimeFormat ?? null}
|
||||
/>
|
||||
)}
|
||||
{isNoShowDialogOpen && (
|
||||
<NoShowAttendeesDialog
|
||||
bookingUid={booking.uid}
|
||||
@@ -714,8 +735,10 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
) : null}
|
||||
{isBookingInPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
|
||||
{isBookingInPast && isConfirmed ? <TableActions actions={bookedActions} /> : null}
|
||||
{(showViewRecordingsButton || showCheckRecordingButton) && (
|
||||
<TableActions actions={showRecordingActions} />
|
||||
{isCalVideoLocation && (
|
||||
<TableActions
|
||||
actions={[{ id: "video_options", label: t("video_options"), actions: videoOptionsActions }]}
|
||||
/>
|
||||
)}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="hidden h-full items-center md:flex">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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<React.SetStateAction<boolean>>;
|
||||
timeFormat: number | null;
|
||||
}
|
||||
|
||||
const MeetingSessionDetailsSkeleton = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{[...Array(2)].map((_, index) => (
|
||||
<div key={index} className="border-subtle flex w-full flex-col rounded-md border p-4">
|
||||
<div className="mb-3">
|
||||
<SkeletonText className="h-5 w-24" />
|
||||
<SkeletonText className="mt-1 h-4 w-32" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i}>
|
||||
<SkeletonText className="h-4 w-16" />
|
||||
<SkeletonText className="mt-1 h-4 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<SkeletonText className="mb-2 h-5 w-20" />
|
||||
<div className="space-y-2">
|
||||
{[...Array(2)].map((_, i) => (
|
||||
<div key={i} className="border-subtle rounded border p-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<SkeletonText className="h-4 w-24" />
|
||||
<SkeletonText className="h-4 w-16" />
|
||||
</div>
|
||||
<SkeletonText className="mt-1 h-3 w-32" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MeetingSessionDetailsList = ({ roomName }: { roomName: string }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const { data: meetingInfo, isLoading } = trpc.viewer.calVideo.getMeetingInformation.useQuery(
|
||||
{ roomName },
|
||||
{
|
||||
suspense: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <MeetingSessionDetailsSkeleton />;
|
||||
}
|
||||
|
||||
if (!meetingInfo || !meetingInfo.data || meetingInfo.data.length === 0) {
|
||||
return <p className="font-semibold">{t("no_meeting_sessions_found")}</p>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
{meetingInfo.data.map((session: Session, index: number) => (
|
||||
<div key={session.id} className="border-subtle flex w-full flex-col rounded-md border p-4">
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t("meeting_session")} {index + 1}
|
||||
</h3>
|
||||
<p className="text-subtle text-sm">
|
||||
{t("session_id")}: {session.id}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">{t("start_time")}:</span>
|
||||
<p className="text-subtle">
|
||||
{session.start_time
|
||||
? dayjs(session.start_time * 1000).format("MMM D, YYYY h:mm A")
|
||||
: t("not_available")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-medium">{t("duration")}:</span>
|
||||
<p className="text-subtle">
|
||||
{session.duration ? formatDuration(session.duration) : t("ongoing")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-medium">{t("max_participants")}:</span>
|
||||
<p className="text-subtle">{session.max_participants || t("unlimited")}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-medium">{t("status")}:</span>
|
||||
<p className="text-subtle">{session.ongoing ? t("ongoing") : t("ended")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{session.participants && session.participants.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="mb-2 text-sm font-medium">{t("participants")}:</h4>
|
||||
<div className="space-y-2">
|
||||
{session.participants.map((participant: Participant) => (
|
||||
<div key={participant.participant_id} className="border-subtle rounded border p-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="font-medium">{participant.user_name || t("anonymous_user")}</span>
|
||||
<span className="text-subtle">
|
||||
{participant.join_time ? dayjs(participant.join_time * 1000).format("h:mm A") : ""}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-subtle text-xs">
|
||||
{t("user_id")}: {participant.user_id || t("not_available")}
|
||||
</p>
|
||||
<p className="text-subtle text-xs">
|
||||
{t("participant_id")}: {participant.participant_id}
|
||||
</p>
|
||||
{participant.duration && (
|
||||
<p className="text-subtle text-xs">
|
||||
{t("session_duration")}: {formatDuration(participant.duration)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent enableOverflow>
|
||||
<DialogHeader title={t("meeting_session_details")} subtitle={subtitle} />
|
||||
{roomName ? (
|
||||
<Suspense fallback={<MeetingSessionDetailsSkeleton />}>
|
||||
<MeetingSessionDetailsList roomName={roomName} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<p className="font-semibold">{t("no_meeting_sessions_found")}</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<DialogClose className="border" />
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -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 });
|
||||
}),
|
||||
});
|
||||
@@ -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<TrpcSessionUser>;
|
||||
};
|
||||
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",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZGetMeetingInformationInputSchema = z.object({
|
||||
roomName: z.string(),
|
||||
});
|
||||
|
||||
export type TGetMeetingInformationInputSchema = z.infer<typeof ZGetMeetingInformationInputSchema>;
|
||||
Vendored
+2
@@ -43,6 +43,8 @@ export type VideoApiAdapter =
|
||||
): Promise<TGetTranscriptAccessLink["transcription"] | { message: string }>;
|
||||
|
||||
checkIfRoomNameMatchesInRecording?(roomName: string, recordingId: string): Promise<boolean>;
|
||||
|
||||
getMeetingInformation?(roomName: string): Promise<any>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
|
||||
Reference in new issue
Block a user