feat: add setting to automatic start recording (#21853)

* feat: disable transcription setting in cal video

* feat: add enable automatic recording feature

* chore: only when organizer joins

* chore: update name of variable

* fix: improvements

* chore: improvements

* chore: add badge

* refactor: cal video premium

* tests: add tests for cal video

* fix: use isOrganizer

* Simplify type to remove 'as SessionUser' from new code

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
Udit Takkar
2025-07-15 16:54:23 +01:00
committed by GitHub
co-authored by Benny Joo Alex van Andel
parent 8ee7b12c2b
commit 10bf982028
19 changed files with 857 additions and 197 deletions
+26 -2
View File
@@ -23,6 +23,7 @@ type CalVideoSettings = {
disableRecordingForGuests: boolean;
disableRecordingForOrganizer: boolean;
enableAutomaticTranscription: boolean;
enableAutomaticRecordingForOrganizer: boolean;
disableTranscriptionForGuests: boolean;
disableTranscriptionForOrganizer: boolean;
};
@@ -59,6 +60,21 @@ const shouldEnableAutomaticTranscription = ({
return !!calVideoSettings.enableAutomaticTranscription;
};
const shouldEnableAutomaticRecording = ({
hasTeamPlan,
calVideoSettings,
isOrganizer,
}: {
hasTeamPlan: boolean;
calVideoSettings?: CalVideoSettings | null;
isOrganizer: boolean;
}) => {
if (!hasTeamPlan || !isOrganizer) return false;
if (!calVideoSettings) return false;
return !!calVideoSettings.enableAutomaticRecordingForOrganizer;
};
const shouldEnableTranscriptionButton = ({
hasTeamPlan,
calVideoSettings,
@@ -239,17 +255,24 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const showRecordingButton = shouldEnableRecordButton({
hasTeamPlan: !!hasTeamPlan,
calVideoSettings: bookingObj.eventType?.calVideoSettings,
isOrganizer: sessionUserId === bookingObj.user?.id,
isOrganizer,
});
const enableAutomaticTranscription = shouldEnableAutomaticTranscription({
hasTeamPlan: !!hasTeamPlan,
calVideoSettings: bookingObj.eventType?.calVideoSettings,
});
const enableAutomaticRecordingForOrganizer = shouldEnableAutomaticRecording({
hasTeamPlan: !!hasTeamPlan,
calVideoSettings: bookingObj.eventType?.calVideoSettings,
isOrganizer,
});
const showTranscriptionButton = shouldEnableTranscriptionButton({
hasTeamPlan: !!hasTeamPlan,
calVideoSettings: bookingObj.eventType?.calVideoSettings,
isOrganizer: sessionUserId === bookingObj.user?.id,
isOrganizer,
});
return {
@@ -274,6 +297,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
loggedInUserName: sessionUserId ? session?.user?.name : undefined,
showRecordingButton,
enableAutomaticTranscription,
enableAutomaticRecordingForOrganizer,
showTranscriptionButton,
rediectAttendeeToOnExit: isOrganizer
? undefined
@@ -0,0 +1,492 @@
import type { DailyCall } from "@daily-co/daily-js";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { BUTTONS } from "../button-states";
import { createCalVideoCallbacks } from "../cal-video-premium-features";
vi.mock("@calcom/lib/constants", () => ({
TRANSCRIPTION_STARTED_ICON: "/transcription-started-icon.svg",
RECORDING_IN_PROGRESS_ICON: "/recording-in-progress-icon.svg",
TRANSCRIPTION_STOPPED_ICON: "/transcription-stopped-icon.svg",
RECORDING_DEFAULT_ICON: "/recording-default-icon.svg",
}));
const mockDaily: DailyCall = {
startRecording: vi.fn(),
stopRecording: vi.fn(),
startTranscription: vi.fn(),
stopTranscription: vi.fn(),
updateCustomTrayButtons: vi.fn(),
} as unknown as DailyCall;
const createMockRecording = (isRecording = false) => ({
isRecording,
});
const createMockTranscription = (isTranscribing = false) => ({
isTranscribing,
});
describe("CalVideoPremiumFeatures - End-to-End Callback Tests", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("onMeetingJoined", () => {
it("should start transcription automatically when enabled and not already transcribing", () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(),
transcription: createMockTranscription(false), // not transcribing
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: true,
enableAutomaticRecordingForOrganizer: false,
});
callbacks.onMeetingJoined();
expect(mockDaily.startTranscription).toHaveBeenCalledTimes(1);
expect(mockDaily.startRecording).not.toHaveBeenCalled();
});
it("should start recording automatically when enabled and not already recording", () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(false), // not recording
transcription: createMockTranscription(),
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: true,
});
callbacks.onMeetingJoined();
expect(mockDaily.startRecording).toHaveBeenCalledTimes(1);
expect(mockDaily.startRecording).toHaveBeenCalledWith({
videoBitrate: 2000,
});
expect(mockDaily.startTranscription).not.toHaveBeenCalled();
});
it("should start both transcription and recording when both are enabled", () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(false),
transcription: createMockTranscription(false),
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: true,
enableAutomaticRecordingForOrganizer: true,
});
callbacks.onMeetingJoined();
expect(mockDaily.startTranscription).toHaveBeenCalledTimes(1);
expect(mockDaily.startRecording).toHaveBeenCalledTimes(1);
});
it("should not start transcription when already transcribing", () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(),
transcription: createMockTranscription(true), // already transcribing
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: true,
enableAutomaticRecordingForOrganizer: false,
});
callbacks.onMeetingJoined();
expect(mockDaily.startTranscription).not.toHaveBeenCalled();
});
it("should not start recording when already recording", () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(true), // already recording
transcription: createMockTranscription(),
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: true,
});
callbacks.onMeetingJoined();
expect(mockDaily.startRecording).not.toHaveBeenCalled();
});
});
describe("onCustomButtonClick - Recording Button", () => {
it("should start recording when click occurs on Recording button and recording is stopped", async () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(false), // not recording
transcription: createMockTranscription(),
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
await callbacks.onCustomButtonClick({ button_id: "recording" });
expect(mockDaily.startRecording).toHaveBeenCalledTimes(1);
expect(mockDaily.startRecording).toHaveBeenCalledWith({
videoBitrate: 2000,
});
expect(mockDaily.stopRecording).not.toHaveBeenCalled();
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.WAIT_FOR_RECORDING_TO_START,
transcription: BUTTONS.START_TRANSCRIPTION, // current state included
});
});
it("should stop recording when click occurs on Recording button and recording is started", async () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(true), // currently recording
transcription: createMockTranscription(),
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
await callbacks.onCustomButtonClick({ button_id: "recording" });
expect(mockDaily.stopRecording).toHaveBeenCalledTimes(1);
expect(mockDaily.startRecording).not.toHaveBeenCalled();
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.WAIT_FOR_RECORDING_TO_STOP,
transcription: BUTTONS.START_TRANSCRIPTION, // current state included
});
});
});
describe("onCustomButtonClick - Transcription Button", () => {
it("should start transcription when click occurs on Transcription button and transcription is stopped", async () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(),
transcription: createMockTranscription(false), // not transcribing
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
await callbacks.onCustomButtonClick({ button_id: "transcription" });
expect(mockDaily.startTranscription).toHaveBeenCalledTimes(1);
expect(mockDaily.stopTranscription).not.toHaveBeenCalled();
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.START_RECORDING, // current state included
transcription: BUTTONS.WAIT_FOR_TRANSCRIPTION_TO_START,
});
});
it("should stop transcription when click occurs on Transcription button and transcription is started", async () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(),
transcription: createMockTranscription(true), // currently transcribing
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
await callbacks.onCustomButtonClick({ button_id: "transcription" });
expect(mockDaily.stopTranscription).toHaveBeenCalledTimes(1);
expect(mockDaily.startTranscription).not.toHaveBeenCalled();
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.START_RECORDING, // current state included
transcription: BUTTONS.WAIT_FOR_TRANSCRIPTION_TO_STOP,
});
});
});
describe("onRecordingStarted", () => {
it("should update custom tray buttons to show stop recording button", () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(),
transcription: createMockTranscription(),
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
callbacks.onRecordingStarted();
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.STOP_RECORDING,
transcription: BUTTONS.START_TRANSCRIPTION, // current state included
});
});
});
describe("onRecordingStopped", () => {
it("should update custom tray buttons to show start recording button", () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(),
transcription: createMockTranscription(),
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
callbacks.onRecordingStopped();
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.START_RECORDING,
transcription: BUTTONS.START_TRANSCRIPTION, // current state included
});
});
});
describe("onTranscriptionStarted", () => {
it("should update custom tray buttons to show stop transcription button", () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(),
transcription: createMockTranscription(),
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
callbacks.onTranscriptionStarted();
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.START_RECORDING, // current state included
transcription: BUTTONS.STOP_TRANSCRIPTION,
});
});
});
describe("onTranscriptionStopped", () => {
it("should update custom tray buttons to show start transcription button", () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(),
transcription: createMockTranscription(),
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
callbacks.onTranscriptionStopped();
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.START_RECORDING, // current state included
transcription: BUTTONS.START_TRANSCRIPTION,
});
});
});
describe("toggleRecording", () => {
it("should start recording when not currently recording", async () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(false), // not recording
transcription: createMockTranscription(),
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
await callbacks.toggleRecording();
expect(mockDaily.startRecording).toHaveBeenCalledTimes(1);
expect(mockDaily.startRecording).toHaveBeenCalledWith({
videoBitrate: 2000,
});
expect(mockDaily.stopRecording).not.toHaveBeenCalled();
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.WAIT_FOR_RECORDING_TO_START,
transcription: BUTTONS.START_TRANSCRIPTION, // current state included
});
});
it("should stop recording when currently recording", async () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(true), // currently recording
transcription: createMockTranscription(),
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
await callbacks.toggleRecording();
expect(mockDaily.stopRecording).toHaveBeenCalledTimes(1);
expect(mockDaily.startRecording).not.toHaveBeenCalled();
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.WAIT_FOR_RECORDING_TO_STOP,
transcription: BUTTONS.START_TRANSCRIPTION, // current state included
});
});
});
describe("toggleTranscription", () => {
it("should start transcription when not currently transcribing", async () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(),
transcription: createMockTranscription(false), // not transcribing
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
await callbacks.toggleTranscription();
expect(mockDaily.startTranscription).toHaveBeenCalledTimes(1);
expect(mockDaily.stopTranscription).not.toHaveBeenCalled();
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.START_RECORDING, // current state included
transcription: BUTTONS.WAIT_FOR_TRANSCRIPTION_TO_START,
});
});
it("should stop transcription when currently transcribing", async () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(),
transcription: createMockTranscription(true), // currently transcribing
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
await callbacks.toggleTranscription();
expect(mockDaily.stopTranscription).toHaveBeenCalledTimes(1);
expect(mockDaily.startTranscription).not.toHaveBeenCalled();
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.START_RECORDING, // current state included
transcription: BUTTONS.WAIT_FOR_TRANSCRIPTION_TO_STOP,
});
});
});
describe("updateCustomTrayButtons", () => {
it("should update both recording and transcription buttons when both are shown", () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(false),
transcription: createMockTranscription(false),
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
callbacks.updateCustomTrayButtons({
recording: BUTTONS.STOP_RECORDING,
transcription: BUTTONS.STOP_TRANSCRIPTION,
});
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.STOP_RECORDING,
transcription: BUTTONS.STOP_TRANSCRIPTION,
});
});
it("should update only recording button when transcription button is not shown", () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(false),
transcription: createMockTranscription(false),
showRecordingButton: true,
showTranscriptionButton: false,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
callbacks.updateCustomTrayButtons({
recording: BUTTONS.STOP_RECORDING,
});
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.STOP_RECORDING,
});
});
it("should update only transcription button when recording button is not shown", () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(false),
transcription: createMockTranscription(false),
showRecordingButton: false,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
callbacks.updateCustomTrayButtons({
transcription: BUTTONS.STOP_TRANSCRIPTION,
});
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
transcription: BUTTONS.STOP_TRANSCRIPTION,
});
});
it("should use current state when no override is provided", () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(true), // currently recording
transcription: createMockTranscription(false), // not transcribing
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
callbacks.updateCustomTrayButtons({});
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.STOP_RECORDING, // because isRecording is true
transcription: BUTTONS.START_TRANSCRIPTION, // because isTranscribing is false
});
});
});
describe("startRecording", () => {
it("should call daily.startRecording with correct parameters", () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(),
transcription: createMockTranscription(),
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
callbacks.startRecording();
expect(mockDaily.startRecording).toHaveBeenCalledTimes(1);
expect(mockDaily.startRecording).toHaveBeenCalledWith({
videoBitrate: 2000,
});
});
});
});
+57
View File
@@ -0,0 +1,57 @@
import {
TRANSCRIPTION_STARTED_ICON,
RECORDING_IN_PROGRESS_ICON,
TRANSCRIPTION_STOPPED_ICON,
RECORDING_DEFAULT_ICON,
} from "@calcom/lib/constants";
export const BUTTONS = {
STOP_TRANSCRIPTION: {
label: "Stop",
tooltip: "Stop transcription",
iconPath: TRANSCRIPTION_STARTED_ICON,
iconPathDarkMode: TRANSCRIPTION_STARTED_ICON,
},
START_TRANSCRIPTION: {
label: "Cal.ai",
tooltip: "Transcription powered by AI",
iconPath: TRANSCRIPTION_STOPPED_ICON,
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
},
WAIT_FOR_TRANSCRIPTION_TO_START: {
label: "Starting..",
tooltip: "Please wait while we start transcription",
iconPath: TRANSCRIPTION_STOPPED_ICON,
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
},
WAIT_FOR_TRANSCRIPTION_TO_STOP: {
label: "Stopping..",
tooltip: "Please wait while we stop transcription",
iconPath: TRANSCRIPTION_STOPPED_ICON,
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
},
START_RECORDING: {
label: "Record",
tooltip: "Start recording",
iconPath: RECORDING_DEFAULT_ICON,
iconPathDarkMode: RECORDING_DEFAULT_ICON,
},
WAIT_FOR_RECORDING_TO_START: {
label: "Starting..",
tooltip: "Please wait while we start recording",
iconPath: RECORDING_DEFAULT_ICON,
iconPathDarkMode: RECORDING_DEFAULT_ICON,
},
WAIT_FOR_RECORDING_TO_STOP: {
label: "Stopping..",
tooltip: "Please wait while we stop recording",
iconPath: RECORDING_DEFAULT_ICON,
iconPathDarkMode: RECORDING_DEFAULT_ICON,
},
STOP_RECORDING: {
label: "Stop",
tooltip: "Stop recording",
iconPath: RECORDING_IN_PROGRESS_ICON,
iconPathDarkMode: RECORDING_IN_PROGRESS_ICON,
},
};
@@ -1,65 +1,9 @@
import type { DailyCall } from "@daily-co/daily-js";
import { useTranscription, useRecording } from "@daily-co/daily-react";
import { useDaily, useDailyEvent } from "@daily-co/daily-react";
import React, { Fragment, useCallback, useRef, useState, useLayoutEffect, useEffect } from "react";
import {
TRANSCRIPTION_STARTED_ICON,
RECORDING_IN_PROGRESS_ICON,
TRANSCRIPTION_STOPPED_ICON,
RECORDING_DEFAULT_ICON,
} from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
const BUTTONS = {
STOP_TRANSCRIPTION: {
label: "Stop",
tooltip: "Stop transcription",
iconPath: TRANSCRIPTION_STARTED_ICON,
iconPathDarkMode: TRANSCRIPTION_STARTED_ICON,
},
START_TRANSCRIPTION: {
label: "Cal.ai",
tooltip: "Transcription powered by AI",
iconPath: TRANSCRIPTION_STOPPED_ICON,
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
},
WAIT_FOR_TRANSCRIPTION_TO_START: {
label: "Starting..",
tooltip: "Please wait while we start transcription",
iconPath: TRANSCRIPTION_STOPPED_ICON,
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
},
WAIT_FOR_TRANSCRIPTION_TO_STOP: {
label: "Stopping..",
tooltip: "Please wait while we stop transcription",
iconPath: TRANSCRIPTION_STOPPED_ICON,
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
},
START_RECORDING: {
label: "Record",
tooltip: "Start recording",
iconPath: RECORDING_DEFAULT_ICON,
iconPathDarkMode: RECORDING_DEFAULT_ICON,
},
WAIT_FOR_RECORDING_TO_START: {
label: "Starting..",
tooltip: "Please wait while we start recording",
iconPath: RECORDING_DEFAULT_ICON,
iconPathDarkMode: RECORDING_DEFAULT_ICON,
},
WAIT_FOR_RECORDING_TO_STOP: {
label: "Stopping..",
tooltip: "Please wait while we stop recording",
iconPath: RECORDING_DEFAULT_ICON,
iconPathDarkMode: RECORDING_DEFAULT_ICON,
},
STOP_RECORDING: {
label: "Stop",
tooltip: "Stop recording",
iconPath: RECORDING_IN_PROGRESS_ICON,
iconPathDarkMode: RECORDING_IN_PROGRESS_ICON,
},
};
import { BUTTONS } from "./button-states";
export type DailyCustomTrayButtonVisualState = "default" | "sidebar-open" | "active";
@@ -70,46 +14,180 @@ export interface DailyCustomTrayButton {
tooltip: string;
visualState?: DailyCustomTrayButtonVisualState;
}
export const CalAiTranscribe = ({
type RecordingState = {
isRecording: boolean;
};
type TranscriptionState = {
isTranscribing: boolean;
};
type CalVideoCallbacksParams = {
daily: DailyCall | null;
recording: RecordingState | null;
transcription: TranscriptionState | null;
showRecordingButton: boolean;
showTranscriptionButton: boolean;
enableAutomaticTranscription: boolean;
enableAutomaticRecordingForOrganizer: boolean;
};
export const createCalVideoCallbacks = (params: CalVideoCallbacksParams) => {
const {
daily,
recording,
transcription,
showRecordingButton,
showTranscriptionButton,
enableAutomaticTranscription,
enableAutomaticRecordingForOrganizer,
} = params;
const startRecording = () => {
daily?.startRecording({
// 480p
videoBitrate: 2000,
});
};
const updateCustomTrayButtons = ({
recording: overrideRecording,
transcription: overrideTranscription,
}: {
recording?: DailyCustomTrayButton;
transcription?: DailyCustomTrayButton;
}) => {
const currentRecordingState = recording?.isRecording ? BUTTONS.STOP_RECORDING : BUTTONS.START_RECORDING;
const currentTranscriptionState = transcription?.isTranscribing
? BUTTONS.STOP_TRANSCRIPTION
: BUTTONS.START_TRANSCRIPTION;
daily?.updateCustomTrayButtons({
...(showRecordingButton
? {
recording: overrideRecording ?? currentRecordingState,
}
: {}),
...(showTranscriptionButton
? {
transcription: overrideTranscription ?? currentTranscriptionState,
}
: {}),
});
};
const onMeetingJoined = () => {
if (enableAutomaticTranscription && !transcription?.isTranscribing) {
daily?.startTranscription();
}
if (enableAutomaticRecordingForOrganizer && !recording?.isRecording) {
startRecording();
}
};
const onRecordingStarted = () => {
updateCustomTrayButtons({
recording: BUTTONS.STOP_RECORDING,
});
};
const onRecordingStopped = () => {
updateCustomTrayButtons({
recording: BUTTONS.START_RECORDING,
});
};
const onTranscriptionStarted = () => {
updateCustomTrayButtons({
transcription: BUTTONS.STOP_TRANSCRIPTION,
});
};
const onTranscriptionStopped = () => {
updateCustomTrayButtons({
transcription: BUTTONS.START_TRANSCRIPTION,
});
};
const toggleRecording = async () => {
if (recording?.isRecording) {
updateCustomTrayButtons({
recording: BUTTONS.WAIT_FOR_RECORDING_TO_STOP,
});
daily?.stopRecording();
} else {
updateCustomTrayButtons({
recording: BUTTONS.WAIT_FOR_RECORDING_TO_START,
});
startRecording();
}
};
const toggleTranscription = async () => {
if (transcription?.isTranscribing) {
updateCustomTrayButtons({
transcription: BUTTONS.WAIT_FOR_TRANSCRIPTION_TO_STOP,
});
daily?.stopTranscription();
} else {
updateCustomTrayButtons({
transcription: BUTTONS.WAIT_FOR_TRANSCRIPTION_TO_START,
});
daily?.startTranscription();
}
};
const onCustomButtonClick = async (ev: { button_id: string }) => {
if (ev?.button_id === "recording") {
toggleRecording();
} else if (ev?.button_id === "transcription") {
toggleTranscription();
}
};
return {
onMeetingJoined,
onRecordingStarted,
onRecordingStopped,
onTranscriptionStarted,
onTranscriptionStopped,
onCustomButtonClick,
toggleRecording,
toggleTranscription,
updateCustomTrayButtons,
startRecording,
};
};
export const CalVideoPremiumFeatures = ({
showRecordingButton,
enableAutomaticTranscription,
enableAutomaticRecordingForOrganizer,
showTranscriptionButton,
}: {
showRecordingButton: boolean;
enableAutomaticTranscription: boolean;
enableAutomaticRecordingForOrganizer: boolean;
showTranscriptionButton: boolean;
}) => {
const daily = useDaily();
const { t } = useLocale();
const [transcript, setTranscript] = useState("");
const [transcriptHeight, setTranscriptHeight] = useState(0);
const transcriptRef = useRef<HTMLDivElement | null>(null);
const transcription = useTranscription();
const recording = useRecording();
const updateCustomTrayButtons = ({
const callbacks = createCalVideoCallbacks({
daily,
recording,
transcription,
}: {
recording: DailyCustomTrayButton;
transcription: DailyCustomTrayButton;
}) => {
daily?.updateCustomTrayButtons({
...(showRecordingButton
? {
recording,
}
: {}),
...(showTranscriptionButton
? {
transcription,
}
: {}),
});
};
showRecordingButton,
showTranscriptionButton,
enableAutomaticTranscription,
enableAutomaticRecordingForOrganizer,
});
useDailyEvent(
"app-message",
@@ -119,94 +197,12 @@ export const CalAiTranscribe = ({
}, [])
);
useDailyEvent("joined-meeting", (ev) => {
if (enableAutomaticTranscription) {
daily?.startTranscription();
updateCustomTrayButtons({
recording: BUTTONS.START_RECORDING,
transcription: transcription?.isTranscribing
? BUTTONS.STOP_TRANSCRIPTION
: BUTTONS.START_TRANSCRIPTION,
});
}
});
useDailyEvent("transcription-started", (ev) => {
updateCustomTrayButtons({
recording: recording?.isRecording ? BUTTONS.STOP_RECORDING : BUTTONS.START_RECORDING,
transcription: BUTTONS.STOP_TRANSCRIPTION,
});
});
useDailyEvent("recording-started", (ev) => {
updateCustomTrayButtons({
recording: BUTTONS.STOP_RECORDING,
transcription: transcription?.isTranscribing ? BUTTONS.STOP_TRANSCRIPTION : BUTTONS.START_TRANSCRIPTION,
});
});
useDailyEvent("transcription-stopped", (ev) => {
updateCustomTrayButtons({
recording: recording?.isRecording ? BUTTONS.STOP_RECORDING : BUTTONS.START_RECORDING,
transcription: BUTTONS.START_TRANSCRIPTION,
});
});
useDailyEvent("recording-stopped", (ev) => {
updateCustomTrayButtons({
recording: BUTTONS.START_RECORDING,
transcription: transcription?.isTranscribing ? BUTTONS.STOP_TRANSCRIPTION : BUTTONS.START_TRANSCRIPTION,
});
});
const toggleRecording = async () => {
if (recording?.isRecording) {
updateCustomTrayButtons({
recording: BUTTONS.WAIT_FOR_RECORDING_TO_STOP,
transcription: transcription?.isTranscribing
? BUTTONS.STOP_TRANSCRIPTION
: BUTTONS.START_TRANSCRIPTION,
});
await daily?.stopRecording();
} else {
updateCustomTrayButtons({
recording: BUTTONS.WAIT_FOR_RECORDING_TO_START,
transcription: transcription?.isTranscribing
? BUTTONS.STOP_TRANSCRIPTION
: BUTTONS.START_TRANSCRIPTION,
});
await daily?.startRecording({
// 480p
videoBitrate: 2000,
});
}
};
const toggleTranscription = async () => {
if (transcription?.isTranscribing) {
updateCustomTrayButtons({
recording: recording?.isRecording ? BUTTONS.STOP_RECORDING : BUTTONS.START_RECORDING,
transcription: BUTTONS.WAIT_FOR_TRANSCRIPTION_TO_STOP,
});
daily?.stopTranscription();
} else {
updateCustomTrayButtons({
recording: recording?.isRecording ? BUTTONS.STOP_RECORDING : BUTTONS.START_RECORDING,
transcription: BUTTONS.WAIT_FOR_TRANSCRIPTION_TO_START,
});
daily?.startTranscription();
}
};
useDailyEvent("custom-button-click", async (ev) => {
if (ev?.button_id === "recording") {
toggleRecording();
} else if (ev?.button_id === "transcription") {
toggleTranscription();
}
});
useDailyEvent("joined-meeting", callbacks.onMeetingJoined);
useDailyEvent("transcription-started", callbacks.onTranscriptionStarted);
useDailyEvent("recording-started", callbacks.onRecordingStarted);
useDailyEvent("transcription-stopped", callbacks.onTranscriptionStopped);
useDailyEvent("recording-stopped", callbacks.onRecordingStopped);
useDailyEvent("custom-button-click", callbacks.onCustomButtonClick);
useLayoutEffect(() => {
const observer = new ResizeObserver((entries) => {
@@ -21,7 +21,7 @@ import { Icon } from "@calcom/ui/components/icon";
import type { getServerSideProps } from "@lib/video/[uid]/getServerSideProps";
import { CalAiTranscribe } from "~/videos/ai/ai-transcribe";
import { CalVideoPremiumFeatures } from "../cal-video-premium-features";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
@@ -37,6 +37,7 @@ export default function JoinCall(props: PageProps) {
overrideName,
showRecordingButton,
enableAutomaticTranscription,
enableAutomaticRecordingForOrganizer,
showTranscriptionButton,
rediectAttendeeToOnExit,
} = props;
@@ -116,8 +117,9 @@ export default function JoinCall(props: PageProps) {
<div
className="mx-auto hidden sm:block"
style={{ zIndex: 2, left: "30%", position: "absolute", bottom: 100, width: "auto" }}>
<CalAiTranscribe
<CalVideoPremiumFeatures
showRecordingButton={showRecordingButton}
enableAutomaticRecordingForOrganizer={enableAutomaticRecordingForOrganizer}
enableAutomaticTranscription={enableAutomaticTranscription}
showTranscriptionButton={showTranscriptionButton}
/>
@@ -48,6 +48,7 @@
"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",
"enable_automatic_recording": "Enable automatic recording after organizer joins the meeting",
"video_options": "Video Options",
"get_meeting_session_details": "Get Meeting Session Details",
"meeting_session_details": "Meeting Session Details",
@@ -25,6 +25,7 @@ import ServerTrans from "@calcom/lib/components/ServerTrans";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import { UpgradeTeamsBadge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { TextField } from "@calcom/ui/components/form";
import { SettingsToggle } from "@calcom/ui/components/form";
@@ -355,6 +356,7 @@ const Locations: React.FC<LocationsProps> = ({
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
@@ -370,11 +372,30 @@ const Locations: React.FC<LocationsProps> = ({
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
/>
{!isPlatform && (
<Controller
name="calVideoSettings.enableAutomaticRecordingForOrganizer"
defaultValue={!!eventType.calVideoSettings?.enableAutomaticRecordingForOrganizer}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("enable_automatic_recording")}
labelClassName="text-sm"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
/>
)}
<Controller
name="calVideoSettings.enableAutomaticTranscription"
defaultValue={!!eventType.calVideoSettings?.enableAutomaticTranscription}
@@ -385,6 +406,7 @@ const Locations: React.FC<LocationsProps> = ({
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
@@ -401,6 +423,7 @@ const Locations: React.FC<LocationsProps> = ({
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
@@ -417,6 +440,7 @@ const Locations: React.FC<LocationsProps> = ({
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
@@ -159,6 +159,7 @@ export type FormValues = {
disableRecordingForOrganizer?: boolean;
disableRecordingForGuests?: boolean;
enableAutomaticTranscription?: boolean;
enableAutomaticRecordingForOrganizer?: boolean;
disableTranscriptionForGuests?: boolean;
disableTranscriptionForOrganizer?: boolean;
redirectUrlOnExit?: string;
@@ -398,6 +398,7 @@ export class BookingRepository {
disableRecordingForGuests: true,
disableRecordingForOrganizer: true,
enableAutomaticTranscription: true,
enableAutomaticRecordingForOrganizer: true,
disableTranscriptionForGuests: true,
disableTranscriptionForOrganizer: true,
redirectUrlOnExit: true,
@@ -7,6 +7,35 @@ export class CalVideoSettingsRepository {
});
}
static async createCalVideoSettings({
eventTypeId,
calVideoSettings,
}: {
eventTypeId: number;
calVideoSettings: {
disableRecordingForGuests?: boolean | null;
disableRecordingForOrganizer?: boolean | null;
enableAutomaticTranscription?: boolean | null;
enableAutomaticRecordingForOrganizer?: boolean | null;
disableTranscriptionForGuests?: boolean | null;
disableTranscriptionForOrganizer?: boolean | null;
redirectUrlOnExit?: string | null;
};
}) {
return await prisma.calVideoSettings.create({
data: {
disableRecordingForGuests: calVideoSettings.disableRecordingForGuests ?? false,
disableRecordingForOrganizer: calVideoSettings.disableRecordingForOrganizer ?? false,
enableAutomaticTranscription: calVideoSettings.enableAutomaticTranscription ?? false,
enableAutomaticRecordingForOrganizer: calVideoSettings.enableAutomaticRecordingForOrganizer ?? false,
disableTranscriptionForGuests: calVideoSettings.disableTranscriptionForGuests ?? false,
disableTranscriptionForOrganizer: calVideoSettings.disableTranscriptionForOrganizer ?? false,
redirectUrlOnExit: calVideoSettings.redirectUrlOnExit ?? null,
eventTypeId,
},
});
}
static async createOrUpdateCalVideoSettings({
eventTypeId,
calVideoSettings,
@@ -18,6 +47,7 @@ export class CalVideoSettingsRepository {
disableTranscriptionForGuests?: boolean | null;
disableTranscriptionForOrganizer?: boolean | null;
enableAutomaticTranscription?: boolean | null;
enableAutomaticRecordingForOrganizer?: boolean | null;
redirectUrlOnExit?: string | null;
};
}) {
@@ -27,6 +57,7 @@ export class CalVideoSettingsRepository {
disableRecordingForGuests: calVideoSettings.disableRecordingForGuests ?? false,
disableRecordingForOrganizer: calVideoSettings.disableRecordingForOrganizer ?? false,
enableAutomaticTranscription: calVideoSettings.enableAutomaticTranscription ?? false,
enableAutomaticRecordingForOrganizer: calVideoSettings.enableAutomaticRecordingForOrganizer ?? false,
disableTranscriptionForGuests: calVideoSettings.disableTranscriptionForGuests ?? false,
disableTranscriptionForOrganizer: calVideoSettings.disableTranscriptionForOrganizer ?? false,
redirectUrlOnExit: calVideoSettings.redirectUrlOnExit ?? null,
@@ -36,6 +67,7 @@ export class CalVideoSettingsRepository {
disableRecordingForGuests: calVideoSettings.disableRecordingForGuests ?? false,
disableRecordingForOrganizer: calVideoSettings.disableRecordingForOrganizer ?? false,
enableAutomaticTranscription: calVideoSettings.enableAutomaticTranscription ?? false,
enableAutomaticRecordingForOrganizer: calVideoSettings.enableAutomaticRecordingForOrganizer ?? false,
disableTranscriptionForGuests: calVideoSettings.disableTranscriptionForGuests ?? false,
disableTranscriptionForOrganizer: calVideoSettings.disableTranscriptionForOrganizer ?? false,
redirectUrlOnExit: calVideoSettings.redirectUrlOnExit ?? null,
@@ -709,6 +709,7 @@ export class EventTypeRepository {
disableRecordingForGuests: true,
disableRecordingForOrganizer: true,
enableAutomaticTranscription: true,
enableAutomaticRecordingForOrganizer: true,
disableTranscriptionForGuests: true,
disableTranscriptionForOrganizer: true,
redirectUrlOnExit: true,
@@ -177,6 +177,7 @@ export const useEventTypeForm = ({
disableRecordingForOrganizer: z.boolean().nullable(),
disableRecordingForGuests: z.boolean().nullable(),
enableAutomaticTranscription: z.boolean().nullable(),
enableAutomaticRecordingForOrganizer: z.boolean().nullable(),
disableTranscriptionForGuests: z.boolean().nullable(),
disableTranscriptionForOrganizer: z.boolean().nullable(),
})
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "CalVideoSettings" ADD COLUMN "enableAutomaticRecordingForOrganizer" BOOLEAN NOT NULL DEFAULT false;
+9 -9
View File
@@ -72,15 +72,15 @@ model CalVideoSettings {
eventTypeId Int @id
eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
disableRecordingForOrganizer Boolean @default(false)
disableRecordingForGuests Boolean @default(false)
enableAutomaticTranscription Boolean @default(false)
redirectUrlOnExit String?
disableTranscriptionForGuests Boolean @default(false)
disableTranscriptionForOrganizer Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
disableRecordingForOrganizer Boolean @default(false)
disableRecordingForGuests Boolean @default(false)
enableAutomaticTranscription Boolean @default(false)
enableAutomaticRecordingForOrganizer Boolean @default(false)
redirectUrlOnExit String?
disableTranscriptionForGuests Boolean @default(false)
disableTranscriptionForOrganizer Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model EventType {
@@ -1,6 +1,7 @@
import { Prisma } from "@prisma/client";
import { generateHashedLink } from "@calcom/lib/generateHashedLink";
import { CalVideoSettingsRepository } from "@calcom/lib/server/repository/calVideoSettings";
import { EventTypeRepository } from "@calcom/lib/server/repository/eventType";
import { prisma } from "@calcom/prisma";
@@ -44,6 +45,17 @@ export const duplicateHandler = async ({ ctx, input }: DuplicateOptions) => {
webhooks: true,
hashedLink: true,
destinationCalendar: true,
calVideoSettings: {
select: {
disableRecordingForOrganizer: true,
disableRecordingForGuests: true,
enableAutomaticTranscription: true,
enableAutomaticRecordingForOrganizer: true,
redirectUrlOnExit: true,
disableTranscriptionForGuests: true,
disableTranscriptionForOrganizer: true,
},
},
},
});
@@ -95,6 +107,7 @@ export const duplicateHandler = async ({ ctx, input }: DuplicateOptions) => {
secondaryEmailId,
instantMeetingScheduleId: _instantMeetingScheduleId,
restrictionScheduleId: _restrictionScheduleId,
calVideoSettings,
...rest
} = eventType;
@@ -179,6 +192,13 @@ export const duplicateHandler = async ({ ctx, input }: DuplicateOptions) => {
});
}
if (calVideoSettings) {
await CalVideoSettingsRepository.createCalVideoSettings({
eventTypeId: newEventType.id,
calVideoSettings,
});
}
if (workflows.length > 0) {
const relationCreateData = workflows.map((workflow) => {
return { eventTypeId: newEventType.id, workflowId: workflow.workflowId };
@@ -27,12 +27,13 @@ const aiPhoneCallConfig = z
const calVideoSettingsSchema = z
.object({
disableRecordingForGuests: z.boolean().optional().nullable(),
disableRecordingForOrganizer: z.boolean().optional().nullable(),
enableAutomaticTranscription: z.boolean().optional().nullable(),
disableTranscriptionForGuests: z.boolean().optional().nullable(),
disableTranscriptionForOrganizer: z.boolean().optional().nullable(),
redirectUrlOnExit: z.string().url().optional().nullable(),
disableRecordingForGuests: z.boolean().nullish(),
disableRecordingForOrganizer: z.boolean().nullish(),
enableAutomaticTranscription: z.boolean().nullish(),
enableAutomaticRecordingForOrganizer: z.boolean().nullish(),
disableTranscriptionForGuests: z.boolean().nullish(),
disableTranscriptionForOrganizer: z.boolean().nullish(),
redirectUrlOnExit: z.string().url().nullish(),
})
.optional()
.nullable();
@@ -26,6 +26,7 @@ import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../types";
import { setDestinationCalendarHandler } from "../../viewer/calendars/setDestinationCalendar.handler";
import { hasTeamPlanHandler } from "../teams/hasTeamPlan.handler";
import type { TUpdateInputSchema } from "./update.schema";
import {
ensureUniqueBookingFields,
@@ -130,6 +131,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
disableRecordingForOrganizer: true,
disableRecordingForGuests: true,
enableAutomaticTranscription: true,
enableAutomaticRecordingForOrganizer: true,
disableTranscriptionForGuests: true,
disableTranscriptionForOrganizer: true,
redirectUrlOnExit: true,
@@ -598,10 +600,16 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
}
if (calVideoSettings) {
await CalVideoSettingsRepository.createOrUpdateCalVideoSettings({
eventTypeId: id,
calVideoSettings,
const { hasTeamPlan } = await hasTeamPlanHandler({
ctx: { user: ctx.user },
});
if (hasTeamPlan) {
await CalVideoSettingsRepository.createOrUpdateCalVideoSettings({
eventTypeId: id,
calVideoSettings,
});
}
}
const parsedEventTypeLocations = eventTypeLocations.safeParse(eventType.locations ?? []);
@@ -1,19 +1,16 @@
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
type HasTeamPlanOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
user: { id: number };
};
};
export const hasTeamPlanHandler = async ({ ctx }: HasTeamPlanOptions) => {
const userId = ctx.user.id;
export const hasTeamPlanHandler = async ({ ctx: { user } }: HasTeamPlanOptions) => {
const hasTeamPlan = await prisma.membership.findFirst({
where: {
accepted: true,
userId,
userId: user.id,
team: {
slug: {
not: null,
@@ -106,7 +106,7 @@ export function SettingsToggle({
<div>
<Label
className={classNames("text-emphasis text-sm font-semibold leading-none", labelClassName)}>
{title}
{title} {Badge ? Badge : null}
{LockedIcon}
</Label>
{description && <p className="text-default -mt-1.5 text-sm leading-normal">{description}</p>}