diff --git a/server/migrations/versions/20251217000000_add_skip_consent_to_room.py b/server/migrations/versions/20251217000000_add_skip_consent_to_room.py
new file mode 100644
index 00000000..924c7350
--- /dev/null
+++ b/server/migrations/versions/20251217000000_add_skip_consent_to_room.py
@@ -0,0 +1,35 @@
+"""add skip_consent to room
+
+Revision ID: 20251217000000
+Revises: 05f8688d6895
+Create Date: 2025-12-17 00:00:00.000000
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "20251217000000"
+down_revision: Union[str, None] = "05f8688d6895"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ with op.batch_alter_table("room", schema=None) as batch_op:
+ batch_op.add_column(
+ sa.Column(
+ "skip_consent",
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.text("false"),
+ )
+ )
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("room", schema=None) as batch_op:
+ batch_op.drop_column("skip_consent")
diff --git a/server/reflector/db/rooms.py b/server/reflector/db/rooms.py
index fc6194e3..92ac5eac 100644
--- a/server/reflector/db/rooms.py
+++ b/server/reflector/db/rooms.py
@@ -57,6 +57,12 @@ rooms = sqlalchemy.Table(
sqlalchemy.String,
nullable=False,
),
+ sqlalchemy.Column(
+ "skip_consent",
+ sqlalchemy.Boolean,
+ nullable=False,
+ server_default=sqlalchemy.sql.false(),
+ ),
sqlalchemy.Index("idx_room_is_shared", "is_shared"),
sqlalchemy.Index("idx_room_ics_enabled", "ics_enabled"),
)
@@ -85,6 +91,7 @@ class Room(BaseModel):
ics_last_sync: datetime | None = None
ics_last_etag: str | None = None
platform: Platform = Field(default_factory=lambda: settings.DEFAULT_VIDEO_PLATFORM)
+ skip_consent: bool = False
class RoomController:
@@ -139,6 +146,7 @@ class RoomController:
ics_fetch_interval: int = 300,
ics_enabled: bool = False,
platform: Platform = settings.DEFAULT_VIDEO_PLATFORM,
+ skip_consent: bool = False,
):
"""
Add a new room
@@ -163,6 +171,7 @@ class RoomController:
"ics_fetch_interval": ics_fetch_interval,
"ics_enabled": ics_enabled,
"platform": platform,
+ "skip_consent": skip_consent,
}
room = Room(**room_data)
diff --git a/server/reflector/pipelines/main_live_pipeline.py b/server/reflector/pipelines/main_live_pipeline.py
index 2b6c6d07..fbe83737 100644
--- a/server/reflector/pipelines/main_live_pipeline.py
+++ b/server/reflector/pipelines/main_live_pipeline.py
@@ -112,7 +112,7 @@ def get_transcript(func):
transcript_id = kwargs.pop("transcript_id")
transcript = await transcripts_controller.get_by_id(transcript_id=transcript_id)
if not transcript:
- raise Exception("Transcript {transcript_id} not found")
+ raise Exception(f"Transcript {transcript_id} not found")
# Enhanced logger with Celery task context
tlogger = logger.bind(transcript_id=transcript.id)
diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py
index 5b218cb4..606a6fd6 100644
--- a/server/reflector/views/rooms.py
+++ b/server/reflector/views/rooms.py
@@ -44,6 +44,7 @@ class Room(BaseModel):
ics_last_sync: Optional[datetime] = None
ics_last_etag: Optional[str] = None
platform: Platform
+ skip_consent: bool = False
class RoomDetails(Room):
@@ -90,6 +91,7 @@ class CreateRoom(BaseModel):
ics_fetch_interval: int = 300
ics_enabled: bool = False
platform: Platform
+ skip_consent: bool = False
class UpdateRoom(BaseModel):
@@ -108,6 +110,7 @@ class UpdateRoom(BaseModel):
ics_fetch_interval: Optional[int] = None
ics_enabled: Optional[bool] = None
platform: Optional[Platform] = None
+ skip_consent: Optional[bool] = None
class CreateRoomMeeting(BaseModel):
@@ -249,6 +252,7 @@ async def rooms_create(
ics_fetch_interval=room.ics_fetch_interval,
ics_enabled=room.ics_enabled,
platform=room.platform,
+ skip_consent=room.skip_consent,
)
@@ -567,10 +571,17 @@ async def rooms_join_meeting(
if meeting.platform == "daily" and user_id is not None:
client = create_platform_client(meeting.platform)
+ # Show Daily's built-in recording UI when:
+ # - local recording (user controls when to record), OR
+ # - cloud recording with consent disabled (skip_consent=True)
+ # Hide it when cloud recording with consent enabled (we show custom consent UI)
+ enable_recording_ui = meeting.recording_type == "local" or (
+ meeting.recording_type == "cloud" and room.skip_consent
+ )
token = await client.create_meeting_token(
meeting.room_name,
start_cloud_recording=meeting.recording_type == "cloud",
- enable_recording_ui=meeting.recording_type == "local",
+ enable_recording_ui=enable_recording_ui,
user_id=user_id,
is_owner=user_id == room.user_id,
)
diff --git a/server/reflector/worker/healthcheck.py b/server/reflector/worker/healthcheck.py
index 6cb16f38..cab6ab5b 100644
--- a/server/reflector/worker/healthcheck.py
+++ b/server/reflector/worker/healthcheck.py
@@ -7,6 +7,12 @@ from reflector.settings import settings
logger = structlog.get_logger(__name__)
+@shared_task(name="celery.ping")
+def celery_ping():
+ """Compatibility task for Celery 5.x - celery.ping was removed but monitoring tools still call it."""
+ return "pong"
+
+
@shared_task
def healthcheck_ping():
url = settings.HEALTHCHECK_URL
diff --git a/server/reflector/worker/process.py b/server/reflector/worker/process.py
index ab163fad..df49bdbc 100644
--- a/server/reflector/worker/process.py
+++ b/server/reflector/worker/process.py
@@ -570,12 +570,12 @@ async def process_meetings():
client = create_platform_client(meeting.platform)
room_sessions = await client.get_room_sessions(meeting.room_name)
- has_active_sessions = room_sessions and any(
- s.ended_at is None for s in room_sessions
+ has_active_sessions = bool(
+ room_sessions and any(s.ended_at is None for s in room_sessions)
)
has_had_sessions = bool(room_sessions)
logger_.info(
- f"found {has_active_sessions} active sessions, had {has_had_sessions}"
+ f"has_active_sessions={has_active_sessions}, has_had_sessions={has_had_sessions}"
)
if has_active_sessions:
diff --git a/www/app/(app)/rooms/page.tsx b/www/app/(app)/rooms/page.tsx
index 147f8351..f542e8e8 100644
--- a/www/app/(app)/rooms/page.tsx
+++ b/www/app/(app)/rooms/page.tsx
@@ -91,6 +91,7 @@ const roomInitialState = {
icsEnabled: false,
icsFetchInterval: 5,
platform: "whereby",
+ skipConsent: false,
};
export default function RoomsList() {
@@ -175,6 +176,7 @@ export default function RoomsList() {
icsEnabled: detailedEditedRoom.ics_enabled || false,
icsFetchInterval: detailedEditedRoom.ics_fetch_interval || 5,
platform: detailedEditedRoom.platform,
+ skipConsent: detailedEditedRoom.skip_consent || false,
}
: null,
[detailedEditedRoom],
@@ -326,6 +328,7 @@ export default function RoomsList() {
ics_enabled: room.icsEnabled,
ics_fetch_interval: room.icsFetchInterval,
platform,
+ skip_consent: room.skipConsent,
};
if (isEditing) {
@@ -388,6 +391,7 @@ export default function RoomsList() {
icsEnabled: roomData.ics_enabled || false,
icsFetchInterval: roomData.ics_fetch_interval || 5,
platform: roomData.platform,
+ skipConsent: roomData.skip_consent || false,
});
setEditRoomId(roomId);
setIsEditing(true);
@@ -796,6 +800,34 @@ export default function RoomsList() {
Shared room
+ {room.recordingType === "cloud" && (
+
+ {
+ const syntheticEvent = {
+ target: {
+ name: "skipConsent",
+ type: "checkbox",
+ checked: e.checked,
+ },
+ };
+ handleRoomChange(syntheticEvent);
+ }}
+ >
+
+
+
+
+ Skip consent dialog
+
+
+ When enabled, participants won't be asked for
+ recording consent. Audio will be stored automatically.
+
+
+ )}
diff --git a/www/app/[roomName]/MeetingSelection.tsx b/www/app/[roomName]/MeetingSelection.tsx
index 2780acbd..63743668 100644
--- a/www/app/[roomName]/MeetingSelection.tsx
+++ b/www/app/[roomName]/MeetingSelection.tsx
@@ -26,6 +26,7 @@ import { useRouter } from "next/navigation";
import { formatDateTime, formatStartedAgo } from "../lib/timeUtils";
import MeetingMinimalHeader from "../components/MeetingMinimalHeader";
import { NonEmptyString } from "../lib/utils";
+import { MeetingId } from "../lib/types";
type Meeting = components["schemas"]["Meeting"];
@@ -98,7 +99,7 @@ export default function MeetingSelection({
onMeetingSelect(meeting);
};
- const handleEndMeeting = async (meetingId: string) => {
+ const handleEndMeeting = async (meetingId: MeetingId) => {
try {
await deactivateMeetingMutation.mutateAsync({
params: {
diff --git a/www/app/[roomName]/components/DailyRoom.tsx b/www/app/[roomName]/components/DailyRoom.tsx
index db051cc6..44fa6315 100644
--- a/www/app/[roomName]/components/DailyRoom.tsx
+++ b/www/app/[roomName]/components/DailyRoom.tsx
@@ -1,35 +1,194 @@
"use client";
-import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ RefObject,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
import { Box, Spinner, Center, Text } from "@chakra-ui/react";
import { useRouter, useParams } from "next/navigation";
-import DailyIframe, { DailyCall } from "@daily-co/daily-js";
+import DailyIframe, {
+ DailyCall,
+ DailyCallOptions,
+ DailyCustomTrayButton,
+ DailyCustomTrayButtons,
+ DailyEventObjectCustomButtonClick,
+ DailyFactoryOptions,
+ DailyParticipantsObject,
+} from "@daily-co/daily-js";
import type { components } from "../../reflector-api";
import { useAuth } from "../../lib/AuthProvider";
-import {
- ConsentDialogButton,
- recordingTypeRequiresConsent,
-} from "../../lib/consent";
+import { useConsentDialog } from "../../lib/consent";
import { useRoomJoinMeeting } from "../../lib/apiHooks";
+import { omit } from "remeda";
import { assertExists } from "../../lib/utils";
+import { assertMeetingId } from "../../lib/types";
+
+const CONSENT_BUTTON_ID = "recording-consent";
+const RECORDING_INDICATOR_ID = "recording-indicator";
type Meeting = components["schemas"]["Meeting"];
+type Room = components["schemas"]["RoomDetails"];
-interface DailyRoomProps {
+type DailyRoomProps = {
meeting: Meeting;
-}
+ room: Room;
+};
-export default function DailyRoom({ meeting }: DailyRoomProps) {
+const useCustomTrayButtons = (
+ frame: {
+ updateCustomTrayButtons: (
+ customTrayButtons: DailyCustomTrayButtons,
+ ) => void;
+ joined: boolean;
+ } | null,
+) => {
+ const [, setCustomTrayButtons] = useState({});
+ return useCallback(
+ (id: string, button: DailyCustomTrayButton | null) => {
+ setCustomTrayButtons((prev) => {
+ // would blink state when frame blinks but it's ok here
+ const state =
+ button === null ? omit(prev, [id]) : { ...prev, [id]: button };
+ if (frame !== null && frame.joined)
+ frame.updateCustomTrayButtons(state);
+ return state;
+ });
+ },
+ [setCustomTrayButtons, frame],
+ );
+};
+
+const USE_FRAME_INIT_STATE = {
+ frame: null as DailyCall | null,
+ joined: false as boolean,
+} as const;
+
+// Daily js and not Daily react used right now because daily-js allows for prebuild interface vs. -react is customizable but has no nice defaults
+const useFrame = (
+ container: HTMLDivElement | null,
+ cbs: {
+ onLeftMeeting: () => void;
+ onCustomButtonClick: (ev: DailyEventObjectCustomButtonClick) => void;
+ onJoinMeeting: (
+ startRecording: (args: { type: "raw-tracks" }) => void,
+ ) => void;
+ },
+) => {
+ const [{ frame, joined }, setState] = useState(USE_FRAME_INIT_STATE);
+ const setJoined = useCallback(
+ (joined: boolean) => setState((prev) => ({ ...prev, joined })),
+ [setState],
+ );
+ const setFrame = useCallback(
+ (frame: DailyCall | null) => setState((prev) => ({ ...prev, frame })),
+ [setState],
+ );
+ useEffect(() => {
+ if (!container) return;
+ const init = async () => {
+ const existingFrame = DailyIframe.getCallInstance();
+ if (existingFrame) {
+ console.error("existing daily frame present");
+ await existingFrame.destroy();
+ }
+ const frameOptions: DailyFactoryOptions = {
+ iframeStyle: {
+ width: "100vw",
+ height: "100vh",
+ border: "none",
+ },
+ showLeaveButton: true,
+ showFullscreenButton: true,
+ };
+ const frame = DailyIframe.createFrame(container, frameOptions);
+ setFrame(frame);
+ };
+ init().catch(
+ console.error.bind(console, "Failed to initialize daily frame:"),
+ );
+ return () => {
+ frame
+ ?.destroy()
+ .catch(console.error.bind(console, "Failed to destroy daily frame:"));
+ setState(USE_FRAME_INIT_STATE);
+ };
+ }, [container]);
+ useEffect(() => {
+ if (!frame) return;
+ frame.on("left-meeting", cbs.onLeftMeeting);
+ frame.on("custom-button-click", cbs.onCustomButtonClick);
+ const joinCb = () => {
+ if (!frame) {
+ console.error("frame is null in joined-meeting callback");
+ return;
+ }
+ cbs.onJoinMeeting(frame.startRecording.bind(frame));
+ };
+ frame.on("joined-meeting", joinCb);
+ return () => {
+ frame.off("left-meeting", cbs.onLeftMeeting);
+ frame.off("custom-button-click", cbs.onCustomButtonClick);
+ frame.off("joined-meeting", joinCb);
+ };
+ }, [frame, cbs]);
+ const frame_ = useMemo(() => {
+ if (frame === null) return frame;
+ return {
+ join: async (
+ properties?: DailyCallOptions,
+ ): Promise => {
+ await frame.join(properties);
+ setJoined(!frame.isDestroyed());
+ },
+ updateCustomTrayButtons: (
+ customTrayButtons: DailyCustomTrayButtons,
+ ): DailyCall => frame.updateCustomTrayButtons(customTrayButtons),
+ };
+ }, [frame]);
+ const setCustomTrayButton = useCustomTrayButtons(
+ useMemo(() => {
+ if (frame_ === null) return null;
+ return {
+ updateCustomTrayButtons: frame_.updateCustomTrayButtons,
+ joined,
+ };
+ }, [frame_, joined]),
+ );
+ return [
+ frame_,
+ {
+ setCustomTrayButton,
+ },
+ ] as const;
+};
+
+export default function DailyRoom({ meeting, room }: DailyRoomProps) {
const router = useRouter();
const params = useParams();
const auth = useAuth();
const authLastUserId = auth.lastUserId;
- const containerRef = useRef(null);
+ const [container, setContainer] = useState(null);
const joinMutation = useRoomJoinMeeting();
const [joinedMeeting, setJoinedMeeting] = useState(null);
const roomName = params?.roomName as string;
+ const {
+ showConsentModal,
+ showRecordingIndicator: showRecordingInTray,
+ showConsentButton,
+ } = useConsentDialog({
+ meetingId: assertMeetingId(meeting.id),
+ recordingType: meeting.recording_type,
+ skipConsent: room.skip_consent,
+ });
+ const showConsentModalRef = useRef(showConsentModal);
+ showConsentModalRef.current = showConsentModal;
+
useEffect(() => {
if (authLastUserId === undefined || !meeting?.id || !roomName) return;
@@ -49,7 +208,7 @@ export default function DailyRoom({ meeting }: DailyRoomProps) {
}
};
- join();
+ join().catch(console.error.bind(console, "Failed to join meeting:"));
}, [meeting?.id, roomName, authLastUserId]);
const roomUrl = joinedMeeting?.room_url;
@@ -58,84 +217,86 @@ export default function DailyRoom({ meeting }: DailyRoomProps) {
router.push("/browse");
}, [router]);
- useEffect(() => {
- if (authLastUserId === undefined || !roomUrl || !containerRef.current)
- return;
+ const handleCustomButtonClick = useCallback(
+ (ev: DailyEventObjectCustomButtonClick) => {
+ if (ev.button_id === CONSENT_BUTTON_ID) {
+ showConsentModalRef.current();
+ }
+ },
+ [
+ /*keep static; iframe recreation depends on it*/
+ ],
+ );
- let frame: DailyCall | null = null;
- let destroyed = false;
-
- const createAndJoin = async () => {
+ const handleFrameJoinMeeting = useCallback(
+ (startRecording: (args: { type: "raw-tracks" }) => void) => {
try {
- const existingFrame = DailyIframe.getCallInstance();
- if (existingFrame) {
- await existingFrame.destroy();
+ if (meeting.recording_type === "cloud") {
+ console.log("Starting cloud recording");
+ startRecording({ type: "raw-tracks" });
}
-
- 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);
-
- frame.on("joined-meeting", async () => {
- try {
- const frameInstance = assertExists(
- frame,
- "frame object got lost somewhere after frame.on was called",
- );
-
- if (meeting.recording_type === "cloud") {
- console.log("Starting cloud recording");
- await frameInstance.startRecording({ type: "raw-tracks" });
- }
- } catch (error) {
- console.error("Failed to start recording:", error);
- }
- });
-
- await frame.join({
- url: roomUrl,
- sendSettings: {
- video: {
- // Optimize bandwidth for camera video
- // allowAdaptiveLayers automatically adjusts quality based on network conditions
- allowAdaptiveLayers: true,
- // Use bandwidth-optimized preset as fallback for browsers without adaptive support
- maxQuality: "medium",
- },
- // Note: screenVideo intentionally not configured to preserve full quality for screen shares
- },
- });
} catch (error) {
- console.error("Error creating Daily frame:", error);
+ console.error("Failed to start recording:", error);
}
- };
+ },
+ [meeting.recording_type],
+ );
- createAndJoin().catch((error) => {
- console.error("Failed to create and join meeting:", error);
- });
+ const recordingIconUrl = useMemo(
+ () => new URL("/recording-icon.svg", window.location.origin),
+ [],
+ );
- return () => {
- destroyed = true;
- if (frame) {
- frame.destroy().catch((e) => {
- console.error("Error destroying frame:", e);
- });
- }
- };
- }, [roomUrl, authLastUserId, handleLeave]);
+ const [frame, { setCustomTrayButton }] = useFrame(container, {
+ onLeftMeeting: handleLeave,
+ onCustomButtonClick: handleCustomButtonClick,
+ onJoinMeeting: handleFrameJoinMeeting,
+ });
+
+ useEffect(() => {
+ if (!frame || !roomUrl) return;
+ frame
+ .join({
+ url: roomUrl,
+ sendSettings: {
+ video: {
+ // Optimize bandwidth for camera video
+ // allowAdaptiveLayers automatically adjusts quality based on network conditions
+ allowAdaptiveLayers: true,
+ // Use bandwidth-optimized preset as fallback for browsers without adaptive support
+ maxQuality: "medium",
+ },
+ // Note: screenVideo intentionally not configured to preserve full quality for screen shares
+ },
+ })
+ .catch(console.error.bind(console, "Failed to join daily room:"));
+ }, [frame, roomUrl]);
+
+ useEffect(() => {
+ setCustomTrayButton(
+ RECORDING_INDICATOR_ID,
+ showRecordingInTray
+ ? {
+ iconPath: recordingIconUrl.href,
+ label: "Recording",
+ tooltip: "Recording in progress",
+ }
+ : null,
+ );
+ }, [showRecordingInTray, recordingIconUrl, setCustomTrayButton]);
+
+ useEffect(() => {
+ setCustomTrayButton(
+ CONSENT_BUTTON_ID,
+ showConsentButton
+ ? {
+ iconPath: recordingIconUrl.href,
+ label: "Recording (click to consent)",
+ tooltip: "Recording (click to consent)",
+ }
+ : null,
+ );
+ }, [showConsentButton, recordingIconUrl, setCustomTrayButton]);
if (authLastUserId === undefined) {
return (
@@ -159,10 +320,7 @@ export default function DailyRoom({ meeting }: DailyRoomProps) {
return (
-
- {meeting.recording_type &&
- recordingTypeRequiresConsent(meeting.recording_type) &&
- meeting.id && }
+
);
}
diff --git a/www/app/[roomName]/components/RoomContainer.tsx b/www/app/[roomName]/components/RoomContainer.tsx
index bfcd82f7..88a5210f 100644
--- a/www/app/[roomName]/components/RoomContainer.tsx
+++ b/www/app/[roomName]/components/RoomContainer.tsx
@@ -18,6 +18,7 @@ import { useAuth } from "../../lib/AuthProvider";
import { useError } from "../../(errors)/errorContext";
import { parseNonEmptyString } from "../../lib/utils";
import { printApiError } from "../../api/_error";
+import { assertMeetingId } from "../../lib/types";
type Meeting = components["schemas"]["Meeting"];
@@ -67,7 +68,10 @@ export default function RoomContainer(details: RoomDetails) {
room && !room.ics_enabled && !pageMeetingId ? roomName : null,
);
- const explicitMeeting = useRoomGetMeeting(roomName, pageMeetingId || null);
+ const explicitMeeting = useRoomGetMeeting(
+ roomName,
+ pageMeetingId ? assertMeetingId(pageMeetingId) : null,
+ );
const meeting = explicitMeeting.data || defaultMeeting.response;
@@ -192,9 +196,9 @@ export default function RoomContainer(details: RoomDetails) {
switch (platform) {
case "daily":
- return ;
+ return ;
case "whereby":
- return ;
+ return ;
default: {
const _exhaustive: never = platform;
return (
diff --git a/www/app/[roomName]/components/WherebyRoom.tsx b/www/app/[roomName]/components/WherebyRoom.tsx
index d670b4e2..971788b6 100644
--- a/www/app/[roomName]/components/WherebyRoom.tsx
+++ b/www/app/[roomName]/components/WherebyRoom.tsx
@@ -5,24 +5,29 @@ 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";
+import { assertMeetingId, MeetingId } from "../../lib/types";
type Meeting = components["schemas"]["Meeting"];
+type Room = components["schemas"]["RoomDetails"];
interface WherebyRoomProps {
meeting: Meeting;
+ room: Room;
}
function WherebyConsentDialogButton({
meetingId,
+ recordingType,
+ skipConsent,
wherebyRef,
}: {
- meetingId: NonEmptyString;
+ meetingId: MeetingId;
+ recordingType: Meeting["recording_type"];
+ skipConsent: boolean;
wherebyRef: React.RefObject;
}) {
const previousFocusRef = useRef(null);
@@ -45,10 +50,16 @@ function WherebyConsentDialogButton({
};
}, [wherebyRef]);
- return ;
+ return (
+
+ );
}
-export default function WherebyRoom({ meeting }: WherebyRoomProps) {
+export default function WherebyRoom({ meeting, room }: WherebyRoomProps) {
const wherebyLoaded = useWhereby();
const wherebyRef = useRef(null);
const router = useRouter();
@@ -57,9 +68,14 @@ export default function WherebyRoom({ meeting }: WherebyRoomProps) {
const isAuthenticated = status === "authenticated";
const wherebyRoomUrl = getWherebyUrl(meeting);
- const recordingType = meeting.recording_type;
const meetingId = meeting.id;
+ const { showConsentButton } = useConsentDialog({
+ meetingId: assertMeetingId(meetingId),
+ recordingType: meeting.recording_type,
+ skipConsent: room.skip_consent,
+ });
+
const isLoading = status === "loading";
const handleLeave = useCallback(() => {
@@ -88,14 +104,14 @@ export default function WherebyRoom({ meeting }: WherebyRoomProps) {
room={wherebyRoomUrl}
style={{ width: "100vw", height: "100vh" }}
/>
- {recordingType &&
- recordingTypeRequiresConsent(recordingType) &&
- meetingId && (
-
- )}
+ {showConsentButton && (
+
+ )}
>
);
}
diff --git a/www/app/[roomName]/room.tsx b/www/app/[roomName]/room.tsx
index e7b68b42..2c9280e7 100644
--- a/www/app/[roomName]/room.tsx
+++ b/www/app/[roomName]/room.tsx
@@ -6,7 +6,6 @@ import {
useEffect,
useRef,
useState,
- useContext,
RefObject,
use,
} from "react";
@@ -25,8 +24,6 @@ import { useRecordingConsent } from "../recordingConsentContext";
import {
useMeetingAudioConsent,
useRoomGetByName,
- useRoomActiveMeetings,
- useRoomUpcomingMeetings,
useRoomsCreateMeeting,
useRoomGetMeeting,
} from "../lib/apiHooks";
@@ -39,12 +36,9 @@ import { FaBars } from "react-icons/fa6";
import { useAuth } from "../lib/AuthProvider";
import { getWherebyUrl, useWhereby } from "../lib/wherebyClient";
import { useError } from "../(errors)/errorContext";
-import {
- assertExistsAndNonEmptyString,
- NonEmptyString,
- parseNonEmptyString,
-} from "../lib/utils";
+import { parseNonEmptyString } from "../lib/utils";
import { printApiError } from "../api/_error";
+import { assertMeetingId, MeetingId } from "../lib/types";
export type RoomDetails = {
params: Promise<{
@@ -92,16 +86,16 @@ const useConsentWherebyFocusManagement = (
};
const useConsentDialog = (
- meetingId: string,
+ meetingId: MeetingId,
wherebyRef: RefObject /*accessibility*/,
) => {
- const { state: consentState, touch, hasConsent } = useRecordingConsent();
+ const { state: consentState, touch, hasAnswered } = useRecordingConsent();
// toast would open duplicates, even with using "id=" prop
const [modalOpen, setModalOpen] = useState(false);
const audioConsentMutation = useMeetingAudioConsent();
const handleConsent = useCallback(
- async (meetingId: string, given: boolean) => {
+ async (meetingId: MeetingId, given: boolean) => {
try {
await audioConsentMutation.mutateAsync({
params: {
@@ -114,7 +108,7 @@ const useConsentDialog = (
},
});
- touch(meetingId);
+ touch(meetingId, given);
} catch (error) {
console.error("Error submitting consent:", error);
}
@@ -216,7 +210,7 @@ const useConsentDialog = (
return {
showConsentModal,
consentState,
- hasConsent,
+ hasAnswered,
consentLoading: audioConsentMutation.isPending,
};
};
@@ -225,13 +219,13 @@ function ConsentDialogButton({
meetingId,
wherebyRef,
}: {
- meetingId: NonEmptyString;
+ meetingId: MeetingId;
wherebyRef: React.RefObject;
}) {
- const { showConsentModal, consentState, hasConsent, consentLoading } =
+ const { showConsentModal, consentState, hasAnswered, consentLoading } =
useConsentDialog(meetingId, wherebyRef);
- if (!consentState.ready || hasConsent(meetingId) || consentLoading) {
+ if (!consentState.ready || hasAnswered(meetingId) || consentLoading) {
return null;
}
@@ -284,7 +278,10 @@ export default function Room(details: RoomDetails) {
room && !room.ics_enabled && !pageMeetingId ? roomName : null,
);
- const explicitMeeting = useRoomGetMeeting(roomName, pageMeetingId || null);
+ const explicitMeeting = useRoomGetMeeting(
+ roomName,
+ pageMeetingId ? assertMeetingId(pageMeetingId) : null,
+ );
const wherebyRoomUrl = explicitMeeting.data
? getWherebyUrl(explicitMeeting.data)
: defaultMeeting.response
@@ -437,7 +434,7 @@ export default function Room(details: RoomDetails) {
recordingTypeRequiresConsent(recordingType) &&
meetingId && (
)}
diff --git a/www/app/lib/apiHooks.ts b/www/app/lib/apiHooks.ts
index 726e5441..a59c31eb 100644
--- a/www/app/lib/apiHooks.ts
+++ b/www/app/lib/apiHooks.ts
@@ -5,6 +5,7 @@ import { useError } from "../(errors)/errorContext";
import { QueryClient, useQueryClient } from "@tanstack/react-query";
import type { components } from "../reflector-api";
import { useAuth } from "./AuthProvider";
+import { MeetingId } from "./types";
/*
* XXX error types returned from the hooks are not always correct; declared types are ValidationError but real type could be string or any other
@@ -718,7 +719,7 @@ export function useRoomActiveMeetings(roomName: string | null) {
export function useRoomGetMeeting(
roomName: string | null,
- meetingId: string | null,
+ meetingId: MeetingId | null,
) {
return $api.useQuery(
"get",
diff --git a/www/app/lib/consent/ConsentDialog.tsx b/www/app/lib/consent/ConsentDialog.tsx
index 488599d0..6dac9102 100644
--- a/www/app/lib/consent/ConsentDialog.tsx
+++ b/www/app/lib/consent/ConsentDialog.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useState, useEffect } from "react";
import { Box, Button, Text, VStack, HStack } from "@chakra-ui/react";
import { CONSENT_DIALOG_TEXT } from "./constants";
@@ -9,6 +10,15 @@ interface ConsentDialogProps {
}
export function ConsentDialog({ onAccept, onReject }: ConsentDialogProps) {
+ const [acceptButton, setAcceptButton] = useState(
+ null,
+ );
+
+ useEffect(() => {
+ // Auto-focus accept button so Escape key works (Daily iframe captures keyboard otherwise)
+ acceptButton?.focus();
+ }, [acceptButton]);
+
return (
{CONSENT_DIALOG_TEXT.rejectButton}
-