mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
feat: daily.co support as alternative to whereby (#691)
* llm instructions * vibe dailyco * vibe dailyco * doc update (vibe) * dont show recording ui on call * stub processor (vibe) * stub processor (vibe) self-review * stub processor (vibe) self-review * chore(main): release 0.14.0 (#670) * Add multitrack pipeline * Mixdown audio tracks * Mixdown with pyav filter graph * Trigger multitrack processing for daily recordings * apply platform from envs in priority: non-dry * Use explicit track keys for processing * Align tracks of a multitrack recording * Generate waveforms for the mixed audio * Emit multriack pipeline events * Fix multitrack pipeline track alignment * dailico docs * Enable multitrack reprocessing * modal temp files uniform names, cleanup. remove llm temporary docs * docs cleanup * dont proceed with raw recordings if any of the downloads fail * dry transcription pipelines * remove is_miltitrack * comments * explicit dailyco room name * docs * remove stub data/method * frontend daily/whereby code self-review (no-mistake) * frontend daily/whereby code self-review (no-mistakes) * frontend daily/whereby code self-review (no-mistakes) * consent cleanup for multitrack (no-mistakes) * llm fun * remove extra comments * fix tests * merge migrations * Store participant names * Get participants by meeting session id * pop back main branch migration * s3 paddington (no-mistakes) * comment * pr comments * pr comments * pr comments * platform / meeting cleanup * Use participant names in summary generation * platform assignment to meeting at controller level * pr comment * room playform properly default none * room playform properly default none * restore migration lost * streaming WIP * extract storage / use common storage / proper env vars for storage * fix mocks tests * remove fall back * streaming for multifile * cenrtal storage abstraction (no-mistakes) * remove dead code / vars * Set participant user id for authenticated users * whereby recording name parsing fix * whereby recording name parsing fix * more file stream * storage dry + tests * remove homemade boto3 streaming and use proper boto * update migration guide * webhook creation script - print uuid --------- Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com> Co-authored-by: Mathieu Virbel <mat@meltingrocks.com> Co-authored-by: Sergey Mankovsky <sergey@monadical.com>
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
import Room from "../room";
|
||||
import RoomContainer from "../components/RoomContainer";
|
||||
|
||||
export default Room;
|
||||
export default RoomContainer;
|
||||
|
||||
93
www/app/[roomName]/components/DailyRoom.tsx
Normal file
93
www/app/[roomName]/components/DailyRoom.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Box } from "@chakra-ui/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import DailyIframe, { DailyCall } from "@daily-co/daily-js";
|
||||
import type { components } from "../../reflector-api";
|
||||
import { useAuth } from "../../lib/AuthProvider";
|
||||
import {
|
||||
ConsentDialogButton,
|
||||
recordingTypeRequiresConsent,
|
||||
} from "../../lib/consent";
|
||||
|
||||
type Meeting = components["schemas"]["Meeting"];
|
||||
|
||||
interface DailyRoomProps {
|
||||
meeting: Meeting;
|
||||
}
|
||||
|
||||
export default function DailyRoom({ meeting }: DailyRoomProps) {
|
||||
const router = useRouter();
|
||||
const auth = useAuth();
|
||||
const status = auth.status;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const roomUrl = meeting?.host_room_url || meeting?.room_url;
|
||||
|
||||
const isLoading = status === "loading";
|
||||
|
||||
const handleLeave = useCallback(() => {
|
||||
router.push("/browse");
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !roomUrl || !containerRef.current) return;
|
||||
|
||||
let frame: DailyCall | null = null;
|
||||
let destroyed = false;
|
||||
|
||||
const createAndJoin = async () => {
|
||||
try {
|
||||
const existingFrame = DailyIframe.getCallInstance();
|
||||
if (existingFrame) {
|
||||
await existingFrame.destroy();
|
||||
}
|
||||
|
||||
frame = DailyIframe.createFrame(containerRef.current!, {
|
||||
iframeStyle: {
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
border: "none",
|
||||
},
|
||||
showLeaveButton: true,
|
||||
showFullscreenButton: true,
|
||||
});
|
||||
|
||||
if (destroyed) {
|
||||
await frame.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
frame.on("left-meeting", handleLeave);
|
||||
await frame.join({ url: roomUrl });
|
||||
} catch (error) {
|
||||
console.error("Error creating Daily frame:", error);
|
||||
}
|
||||
};
|
||||
|
||||
createAndJoin();
|
||||
|
||||
return () => {
|
||||
destroyed = true;
|
||||
if (frame) {
|
||||
frame.destroy().catch((e) => {
|
||||
console.error("Error destroying frame:", e);
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [roomUrl, isLoading, handleLeave]);
|
||||
|
||||
if (!roomUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box position="relative" width="100vw" height="100vh">
|
||||
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
|
||||
{meeting.recording_type &&
|
||||
recordingTypeRequiresConsent(meeting.recording_type) &&
|
||||
meeting.id && <ConsentDialogButton meetingId={meeting.id} />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
214
www/app/[roomName]/components/RoomContainer.tsx
Normal file
214
www/app/[roomName]/components/RoomContainer.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { roomMeetingUrl } from "../../lib/routes";
|
||||
import { useCallback, useEffect, useState, use } from "react";
|
||||
import { Box, Text, Spinner } from "@chakra-ui/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
useRoomGetByName,
|
||||
useRoomsCreateMeeting,
|
||||
useRoomGetMeeting,
|
||||
} from "../../lib/apiHooks";
|
||||
import type { components } from "../../reflector-api";
|
||||
import MeetingSelection from "../MeetingSelection";
|
||||
import useRoomDefaultMeeting from "../useRoomDefaultMeeting";
|
||||
import WherebyRoom from "./WherebyRoom";
|
||||
import DailyRoom from "./DailyRoom";
|
||||
import { useAuth } from "../../lib/AuthProvider";
|
||||
import { useError } from "../../(errors)/errorContext";
|
||||
import { parseNonEmptyString } from "../../lib/utils";
|
||||
import { printApiError } from "../../api/_error";
|
||||
|
||||
type Meeting = components["schemas"]["Meeting"];
|
||||
|
||||
export type RoomDetails = {
|
||||
params: Promise<{
|
||||
roomName: string;
|
||||
meetingId?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function LoadingSpinner() {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
height="100vh"
|
||||
bg="gray.50"
|
||||
p={4}
|
||||
>
|
||||
<Spinner color="blue.500" size="xl" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RoomContainer(details: RoomDetails) {
|
||||
const params = use(details.params);
|
||||
const roomName = parseNonEmptyString(
|
||||
params.roomName,
|
||||
true,
|
||||
"panic! params.roomName is required",
|
||||
);
|
||||
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;
|
||||
|
||||
const pageMeetingId = params.meetingId;
|
||||
|
||||
const defaultMeeting = useRoomDefaultMeeting(
|
||||
room && !room.ics_enabled && !pageMeetingId ? roomName : null,
|
||||
);
|
||||
|
||||
const explicitMeeting = useRoomGetMeeting(roomName, pageMeetingId || null);
|
||||
|
||||
const meeting = explicitMeeting.data || defaultMeeting.response;
|
||||
|
||||
const isLoading =
|
||||
status === "loading" ||
|
||||
roomQuery.isLoading ||
|
||||
defaultMeeting?.loading ||
|
||||
explicitMeeting.isLoading ||
|
||||
createMeetingMutation.isPending;
|
||||
|
||||
const errors = [
|
||||
explicitMeeting.error,
|
||||
defaultMeeting.error,
|
||||
roomQuery.error,
|
||||
createMeetingMutation.error,
|
||||
].filter(Boolean);
|
||||
|
||||
const isOwner =
|
||||
isAuthenticated && room ? auth.user?.id === room.user_id : false;
|
||||
|
||||
const handleMeetingSelect = (selectedMeeting: Meeting) => {
|
||||
router.push(
|
||||
roomMeetingUrl(
|
||||
roomName,
|
||||
parseNonEmptyString(
|
||||
selectedMeeting.id,
|
||||
true,
|
||||
"panic! selectedMeeting.id is required",
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreateUnscheduled = async () => {
|
||||
try {
|
||||
const newMeeting = await createMeetingMutation.mutateAsync({
|
||||
params: {
|
||||
path: { room_name: roomName },
|
||||
},
|
||||
body: {
|
||||
allow_duplicated: room ? room.ics_enabled : false,
|
||||
},
|
||||
});
|
||||
handleMeetingSelect(newMeeting);
|
||||
} catch (err) {
|
||||
console.error("Failed to create meeting:", err);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!room) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
height="100vh"
|
||||
bg="gray.50"
|
||||
p={4}
|
||||
>
|
||||
<Text fontSize="lg">Room not found</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (room.ics_enabled && !params.meetingId) {
|
||||
return (
|
||||
<MeetingSelection
|
||||
roomName={roomName}
|
||||
isOwner={isOwner}
|
||||
isSharedRoom={room?.is_shared || false}
|
||||
authLoading={["loading", "refreshing"].includes(auth.status)}
|
||||
onMeetingSelect={handleMeetingSelect}
|
||||
onCreateUnscheduled={handleCreateUnscheduled}
|
||||
isCreatingMeeting={createMeetingMutation.isPending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
height="100vh"
|
||||
bg="gray.50"
|
||||
p={4}
|
||||
>
|
||||
{errors.map((error, i) => (
|
||||
<Text key={i} fontSize="lg">
|
||||
{printApiError(error)}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!meeting) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
const platform = meeting.platform;
|
||||
|
||||
if (!platform) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
height="100vh"
|
||||
bg="gray.50"
|
||||
p={4}
|
||||
>
|
||||
<Text fontSize="lg">Meeting platform not configured</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
switch (platform) {
|
||||
case "daily":
|
||||
return <DailyRoom meeting={meeting} />;
|
||||
case "whereby":
|
||||
return <WherebyRoom meeting={meeting} />;
|
||||
default: {
|
||||
const _exhaustive: never = platform;
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
height="100vh"
|
||||
bg="gray.50"
|
||||
p={4}
|
||||
>
|
||||
<Text fontSize="lg">Unknown platform: {platform}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
101
www/app/[roomName]/components/WherebyRoom.tsx
Normal file
101
www/app/[roomName]/components/WherebyRoom.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, RefObject } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { components } from "../../reflector-api";
|
||||
import { useAuth } from "../../lib/AuthProvider";
|
||||
import { getWherebyUrl, useWhereby } from "../../lib/wherebyClient";
|
||||
import { assertExistsAndNonEmptyString, NonEmptyString } from "../../lib/utils";
|
||||
import {
|
||||
ConsentDialogButton as BaseConsentDialogButton,
|
||||
useConsentDialog,
|
||||
recordingTypeRequiresConsent,
|
||||
} from "../../lib/consent";
|
||||
|
||||
type Meeting = components["schemas"]["Meeting"];
|
||||
|
||||
interface WherebyRoomProps {
|
||||
meeting: Meeting;
|
||||
}
|
||||
|
||||
function WherebyConsentDialogButton({
|
||||
meetingId,
|
||||
wherebyRef,
|
||||
}: {
|
||||
meetingId: NonEmptyString;
|
||||
wherebyRef: React.RefObject<HTMLElement>;
|
||||
}) {
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const element = wherebyRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const handleWherebyReady = () => {
|
||||
previousFocusRef.current = document.activeElement as HTMLElement;
|
||||
};
|
||||
|
||||
element.addEventListener("ready", handleWherebyReady);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("ready", handleWherebyReady);
|
||||
if (previousFocusRef.current && document.activeElement === element) {
|
||||
previousFocusRef.current.focus();
|
||||
}
|
||||
};
|
||||
}, [wherebyRef]);
|
||||
|
||||
return <BaseConsentDialogButton meetingId={meetingId} />;
|
||||
}
|
||||
|
||||
export default function WherebyRoom({ meeting }: WherebyRoomProps) {
|
||||
const wherebyLoaded = useWhereby();
|
||||
const wherebyRef = useRef<HTMLElement>(null);
|
||||
const router = useRouter();
|
||||
const auth = useAuth();
|
||||
const status = auth.status;
|
||||
const isAuthenticated = status === "authenticated";
|
||||
|
||||
const wherebyRoomUrl = getWherebyUrl(meeting);
|
||||
const recordingType = meeting.recording_type;
|
||||
const meetingId = meeting.id;
|
||||
|
||||
const isLoading = status === "loading";
|
||||
|
||||
const handleLeave = useCallback(() => {
|
||||
router.push("/browse");
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !isAuthenticated || !wherebyRoomUrl || !wherebyLoaded)
|
||||
return;
|
||||
|
||||
wherebyRef.current?.addEventListener("leave", handleLeave);
|
||||
|
||||
return () => {
|
||||
wherebyRef.current?.removeEventListener("leave", handleLeave);
|
||||
};
|
||||
}, [handleLeave, wherebyRoomUrl, isLoading, isAuthenticated, wherebyLoaded]);
|
||||
|
||||
if (!wherebyRoomUrl || !wherebyLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<whereby-embed
|
||||
ref={wherebyRef}
|
||||
room={wherebyRoomUrl}
|
||||
style={{ width: "100vw", height: "100vh" }}
|
||||
/>
|
||||
{recordingType &&
|
||||
recordingTypeRequiresConsent(recordingType) &&
|
||||
meetingId && (
|
||||
<WherebyConsentDialogButton
|
||||
meetingId={assertExistsAndNonEmptyString(meetingId)}
|
||||
wherebyRef={wherebyRef}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
import Room from "./room";
|
||||
import RoomContainer from "./components/RoomContainer";
|
||||
|
||||
export default Room;
|
||||
export default RoomContainer;
|
||||
|
||||
36
www/app/lib/consent/ConsentDialog.tsx
Normal file
36
www/app/lib/consent/ConsentDialog.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Button, Text, VStack, HStack } from "@chakra-ui/react";
|
||||
import { CONSENT_DIALOG_TEXT } from "./constants";
|
||||
|
||||
interface ConsentDialogProps {
|
||||
onAccept: () => void;
|
||||
onReject: () => void;
|
||||
}
|
||||
|
||||
export function ConsentDialog({ onAccept, onReject }: ConsentDialogProps) {
|
||||
return (
|
||||
<Box
|
||||
p={6}
|
||||
bg="rgba(255, 255, 255, 0.7)"
|
||||
borderRadius="lg"
|
||||
boxShadow="lg"
|
||||
maxW="md"
|
||||
mx="auto"
|
||||
>
|
||||
<VStack gap={4} alignItems="center">
|
||||
<Text fontSize="md" textAlign="center" fontWeight="medium">
|
||||
{CONSENT_DIALOG_TEXT.question}
|
||||
</Text>
|
||||
<HStack gap={4} justifyContent="center">
|
||||
<Button variant="ghost" size="sm" onClick={onReject}>
|
||||
{CONSENT_DIALOG_TEXT.rejectButton}
|
||||
</Button>
|
||||
<Button colorPalette="primary" size="sm" onClick={onAccept}>
|
||||
{CONSENT_DIALOG_TEXT.acceptButton}
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
39
www/app/lib/consent/ConsentDialogButton.tsx
Normal file
39
www/app/lib/consent/ConsentDialogButton.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Icon } from "@chakra-ui/react";
|
||||
import { FaBars } from "react-icons/fa6";
|
||||
import { useConsentDialog } from "./useConsentDialog";
|
||||
import {
|
||||
CONSENT_BUTTON_TOP_OFFSET,
|
||||
CONSENT_BUTTON_LEFT_OFFSET,
|
||||
CONSENT_BUTTON_Z_INDEX,
|
||||
CONSENT_DIALOG_TEXT,
|
||||
} from "./constants";
|
||||
|
||||
interface ConsentDialogButtonProps {
|
||||
meetingId: string;
|
||||
}
|
||||
|
||||
export function ConsentDialogButton({ meetingId }: ConsentDialogButtonProps) {
|
||||
const { showConsentModal, consentState, hasConsent, consentLoading } =
|
||||
useConsentDialog(meetingId);
|
||||
|
||||
if (!consentState.ready || hasConsent(meetingId) || consentLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
position="absolute"
|
||||
top={CONSENT_BUTTON_TOP_OFFSET}
|
||||
left={CONSENT_BUTTON_LEFT_OFFSET}
|
||||
zIndex={CONSENT_BUTTON_Z_INDEX}
|
||||
colorPalette="blue"
|
||||
size="sm"
|
||||
onClick={showConsentModal}
|
||||
>
|
||||
{CONSENT_DIALOG_TEXT.triggerButton}
|
||||
<Icon as={FaBars} ml={2} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
12
www/app/lib/consent/constants.ts
Normal file
12
www/app/lib/consent/constants.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const CONSENT_BUTTON_TOP_OFFSET = "56px";
|
||||
export const CONSENT_BUTTON_LEFT_OFFSET = "8px";
|
||||
export const CONSENT_BUTTON_Z_INDEX = 1000;
|
||||
export const TOAST_CHECK_INTERVAL_MS = 100;
|
||||
|
||||
export const CONSENT_DIALOG_TEXT = {
|
||||
question:
|
||||
"Can we have your permission to store this meeting's audio recording on our servers?",
|
||||
acceptButton: "Yes, store the audio",
|
||||
rejectButton: "No, delete after transcription",
|
||||
triggerButton: "Meeting is being recorded",
|
||||
} as const;
|
||||
8
www/app/lib/consent/index.ts
Normal file
8
www/app/lib/consent/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
export { ConsentDialogButton } from "./ConsentDialogButton";
|
||||
export { ConsentDialog } from "./ConsentDialog";
|
||||
export { useConsentDialog } from "./useConsentDialog";
|
||||
export { recordingTypeRequiresConsent } from "./utils";
|
||||
export * from "./constants";
|
||||
export * from "./types";
|
||||
9
www/app/lib/consent/types.ts
Normal file
9
www/app/lib/consent/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface ConsentDialogResult {
|
||||
showConsentModal: () => void;
|
||||
consentState: {
|
||||
ready: boolean;
|
||||
consentAnsweredForMeetings?: Set<string>;
|
||||
};
|
||||
hasConsent: (meetingId: string) => boolean;
|
||||
consentLoading: boolean;
|
||||
}
|
||||
109
www/app/lib/consent/useConsentDialog.tsx
Normal file
109
www/app/lib/consent/useConsentDialog.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState, useEffect, useRef } from "react";
|
||||
import { toaster } from "../../components/ui/toaster";
|
||||
import { useRecordingConsent } from "../../recordingConsentContext";
|
||||
import { useMeetingAudioConsent } from "../apiHooks";
|
||||
import { ConsentDialog } from "./ConsentDialog";
|
||||
import { TOAST_CHECK_INTERVAL_MS } from "./constants";
|
||||
import type { ConsentDialogResult } from "./types";
|
||||
|
||||
export function useConsentDialog(meetingId: string): ConsentDialogResult {
|
||||
const { state: consentState, touch, hasConsent } = useRecordingConsent();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const audioConsentMutation = useMeetingAudioConsent();
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const keydownHandlerRef = useRef<((event: KeyboardEvent) => void) | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
if (keydownHandlerRef.current) {
|
||||
document.removeEventListener("keydown", keydownHandlerRef.current);
|
||||
keydownHandlerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleConsent = useCallback(
|
||||
async (given: boolean) => {
|
||||
try {
|
||||
await audioConsentMutation.mutateAsync({
|
||||
params: {
|
||||
path: { meeting_id: meetingId },
|
||||
},
|
||||
body: {
|
||||
consent_given: given,
|
||||
},
|
||||
});
|
||||
|
||||
touch(meetingId);
|
||||
} catch (error) {
|
||||
console.error("Error submitting consent:", error);
|
||||
}
|
||||
},
|
||||
[audioConsentMutation, touch, meetingId],
|
||||
);
|
||||
|
||||
const showConsentModal = useCallback(() => {
|
||||
if (modalOpen) return;
|
||||
|
||||
setModalOpen(true);
|
||||
|
||||
const toastId = toaster.create({
|
||||
placement: "top",
|
||||
duration: null,
|
||||
render: ({ dismiss }) => (
|
||||
<ConsentDialog
|
||||
onAccept={() => {
|
||||
handleConsent(true);
|
||||
dismiss();
|
||||
}}
|
||||
onReject={() => {
|
||||
handleConsent(false);
|
||||
dismiss();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
toastId.then((id) => toaster.dismiss(id));
|
||||
}
|
||||
};
|
||||
|
||||
keydownHandlerRef.current = handleKeyDown;
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
toastId.then((id) => {
|
||||
intervalRef.current = setInterval(() => {
|
||||
if (!toaster.isActive(id)) {
|
||||
setModalOpen(false);
|
||||
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
if (keydownHandlerRef.current) {
|
||||
document.removeEventListener("keydown", keydownHandlerRef.current);
|
||||
keydownHandlerRef.current = null;
|
||||
}
|
||||
}
|
||||
}, TOAST_CHECK_INTERVAL_MS);
|
||||
});
|
||||
}, [handleConsent, modalOpen]);
|
||||
|
||||
return {
|
||||
showConsentModal,
|
||||
consentState,
|
||||
hasConsent,
|
||||
consentLoading: audioConsentMutation.isPending,
|
||||
};
|
||||
}
|
||||
13
www/app/lib/consent/utils.ts
Normal file
13
www/app/lib/consent/utils.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { components } from "../../reflector-api";
|
||||
|
||||
type Meeting = components["schemas"]["Meeting"];
|
||||
|
||||
/**
|
||||
* Determines if a meeting's recording type requires user consent.
|
||||
* Currently only "cloud" recordings require consent.
|
||||
*/
|
||||
export function recordingTypeRequiresConsent(
|
||||
recordingType: Meeting["recording_type"],
|
||||
): boolean {
|
||||
return recordingType === "cloud";
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { PROTECTED_PAGES } from "./auth";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAuth } from "./AuthProvider";
|
||||
import { useEffect } from "react";
|
||||
import { featureEnabled } from "./features";
|
||||
|
||||
const HOME = "/" as const;
|
||||
|
||||
@@ -13,7 +14,9 @@ export const useLoginRequiredPages = () => {
|
||||
const isNotLoggedIn = auth.status === "unauthenticated";
|
||||
// safety
|
||||
const isLastDestination = pathname === HOME;
|
||||
const shouldRedirect = isNotLoggedIn && isProtected && !isLastDestination;
|
||||
const requireLogin = featureEnabled("requireLogin");
|
||||
const shouldRedirect =
|
||||
requireLogin && isNotLoggedIn && isProtected && !isLastDestination;
|
||||
useEffect(() => {
|
||||
if (!shouldRedirect) return;
|
||||
// on the backend, the redirect goes straight to the auth provider, but we don't have it because it's hidden inside next-auth middleware
|
||||
|
||||
91
www/app/reflector-api.d.ts
vendored
91
www/app/reflector-api.d.ts
vendored
@@ -696,6 +696,26 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/webhook": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Webhook
|
||||
* @description Handle Daily webhook events.
|
||||
*/
|
||||
post: operations["v1_webhook"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
@@ -852,6 +872,8 @@ export interface components {
|
||||
* @default false
|
||||
*/
|
||||
ics_enabled: boolean;
|
||||
/** Platform */
|
||||
platform?: ("whereby" | "daily") | null;
|
||||
};
|
||||
/** CreateRoomMeeting */
|
||||
CreateRoomMeeting: {
|
||||
@@ -877,6 +899,22 @@ export interface components {
|
||||
target_language: string;
|
||||
source_kind?: components["schemas"]["SourceKind"] | null;
|
||||
};
|
||||
/**
|
||||
* DailyWebhookEvent
|
||||
* @description Daily webhook event structure.
|
||||
*/
|
||||
DailyWebhookEvent: {
|
||||
/** Type */
|
||||
type: string;
|
||||
/** Id */
|
||||
id: string;
|
||||
/** Ts */
|
||||
ts: number;
|
||||
/** Data */
|
||||
data: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
/** DeletionStatus */
|
||||
DeletionStatus: {
|
||||
/** Status */
|
||||
@@ -1193,6 +1231,12 @@ export interface components {
|
||||
calendar_metadata?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/**
|
||||
* Platform
|
||||
* @default whereby
|
||||
* @enum {string}
|
||||
*/
|
||||
platform: "whereby" | "daily";
|
||||
};
|
||||
/** MeetingConsentRequest */
|
||||
MeetingConsentRequest: {
|
||||
@@ -1279,6 +1323,12 @@ export interface components {
|
||||
ics_last_sync?: string | null;
|
||||
/** Ics Last Etag */
|
||||
ics_last_etag?: string | null;
|
||||
/**
|
||||
* Platform
|
||||
* @default whereby
|
||||
* @enum {string}
|
||||
*/
|
||||
platform: "whereby" | "daily";
|
||||
};
|
||||
/** RoomDetails */
|
||||
RoomDetails: {
|
||||
@@ -1325,6 +1375,12 @@ export interface components {
|
||||
ics_last_sync?: string | null;
|
||||
/** Ics Last Etag */
|
||||
ics_last_etag?: string | null;
|
||||
/**
|
||||
* Platform
|
||||
* @default whereby
|
||||
* @enum {string}
|
||||
*/
|
||||
platform: "whereby" | "daily";
|
||||
/** Webhook Url */
|
||||
webhook_url: string | null;
|
||||
/** Webhook Secret */
|
||||
@@ -1505,6 +1561,8 @@ export interface components {
|
||||
ics_fetch_interval?: number | null;
|
||||
/** Ics Enabled */
|
||||
ics_enabled?: boolean | null;
|
||||
/** Platform */
|
||||
platform?: ("whereby" | "daily") | null;
|
||||
};
|
||||
/** UpdateTranscript */
|
||||
UpdateTranscript: {
|
||||
@@ -3191,4 +3249,37 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
v1_webhook: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["DailyWebhookEvent"];
|
||||
};
|
||||
};
|
||||
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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user