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} - diff --git a/www/app/lib/consent/ConsentDialogButton.tsx b/www/app/lib/consent/ConsentDialogButton.tsx index 2c1d084b..2f192331 100644 --- a/www/app/lib/consent/ConsentDialogButton.tsx +++ b/www/app/lib/consent/ConsentDialogButton.tsx @@ -9,16 +9,26 @@ import { CONSENT_BUTTON_Z_INDEX, CONSENT_DIALOG_TEXT, } from "./constants"; +import { MeetingId } from "../types"; +import type { components } from "../../reflector-api"; -interface ConsentDialogButtonProps { - meetingId: string; -} +type Meeting = components["schemas"]["Meeting"]; -export function ConsentDialogButton({ meetingId }: ConsentDialogButtonProps) { - const { showConsentModal, consentState, hasConsent, consentLoading } = - useConsentDialog(meetingId); +type ConsentDialogButtonProps = { + meetingId: MeetingId; + recordingType: Meeting["recording_type"]; + skipConsent: boolean; +}; - if (!consentState.ready || hasConsent(meetingId) || consentLoading) { +export function ConsentDialogButton({ + meetingId, + recordingType, + skipConsent, +}: ConsentDialogButtonProps) { + const { showConsentModal, consentState, showConsentButton, consentLoading } = + useConsentDialog({ meetingId, recordingType, skipConsent }); + + if (!consentState.ready || !showConsentButton || consentLoading) { return null; } diff --git a/www/app/lib/consent/RecordingIndicator.tsx b/www/app/lib/consent/RecordingIndicator.tsx new file mode 100644 index 00000000..882ef96d --- /dev/null +++ b/www/app/lib/consent/RecordingIndicator.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { Box, Text } from "@chakra-ui/react"; +import { FaCircle } from "react-icons/fa6"; +import { + CONSENT_BUTTON_TOP_OFFSET, + CONSENT_BUTTON_LEFT_OFFSET, + CONSENT_BUTTON_Z_INDEX, +} from "./constants"; + +export function RecordingIndicator() { + return ( + + + Recording + + ); +} diff --git a/www/app/lib/consent/index.ts b/www/app/lib/consent/index.ts index eabca8ac..385db26d 100644 --- a/www/app/lib/consent/index.ts +++ b/www/app/lib/consent/index.ts @@ -2,6 +2,7 @@ export { ConsentDialogButton } from "./ConsentDialogButton"; export { ConsentDialog } from "./ConsentDialog"; +export { RecordingIndicator } from "./RecordingIndicator"; export { useConsentDialog } from "./useConsentDialog"; export { recordingTypeRequiresConsent } from "./utils"; export * from "./constants"; diff --git a/www/app/lib/consent/types.ts b/www/app/lib/consent/types.ts index 0bd15202..62446805 100644 --- a/www/app/lib/consent/types.ts +++ b/www/app/lib/consent/types.ts @@ -1,9 +1,14 @@ -export interface ConsentDialogResult { +import { MeetingId } from "../types"; + +export type ConsentDialogResult = { showConsentModal: () => void; consentState: { ready: boolean; - consentAnsweredForMeetings?: Set; + consentForMeetings?: Map; }; - hasConsent: (meetingId: string) => boolean; + hasAnswered: (meetingId: MeetingId) => boolean; + hasAccepted: (meetingId: MeetingId) => boolean; consentLoading: boolean; -} + showRecordingIndicator: boolean; + showConsentButton: boolean; +}; diff --git a/www/app/lib/consent/useConsentDialog.tsx b/www/app/lib/consent/useConsentDialog.tsx index 2a5c0ab3..ea13ba5d 100644 --- a/www/app/lib/consent/useConsentDialog.tsx +++ b/www/app/lib/consent/useConsentDialog.tsx @@ -7,9 +7,29 @@ import { useMeetingAudioConsent } from "../apiHooks"; import { ConsentDialog } from "./ConsentDialog"; import { TOAST_CHECK_INTERVAL_MS } from "./constants"; import type { ConsentDialogResult } from "./types"; +import { MeetingId } from "../types"; +import { recordingTypeRequiresConsent } from "./utils"; +import type { components } from "../../reflector-api"; -export function useConsentDialog(meetingId: string): ConsentDialogResult { - const { state: consentState, touch, hasConsent } = useRecordingConsent(); +type Meeting = components["schemas"]["Meeting"]; + +type UseConsentDialogParams = { + meetingId: MeetingId; + recordingType: Meeting["recording_type"]; + skipConsent: boolean; +}; + +export function useConsentDialog({ + meetingId, + recordingType, + skipConsent, +}: UseConsentDialogParams): ConsentDialogResult { + const { + state: consentState, + touch, + hasAnswered, + hasAccepted, + } = useRecordingConsent(); const [modalOpen, setModalOpen] = useState(false); const audioConsentMutation = useMeetingAudioConsent(); const intervalRef = useRef(null); @@ -42,7 +62,7 @@ export function useConsentDialog(meetingId: string): ConsentDialogResult { }, }); - touch(meetingId); + touch(meetingId, given); } catch (error) { console.error("Error submitting consent:", error); } @@ -100,10 +120,23 @@ export function useConsentDialog(meetingId: string): ConsentDialogResult { }); }, [handleConsent, modalOpen]); + const requiresConsent = Boolean( + recordingType && recordingTypeRequiresConsent(recordingType), + ); + + const showRecordingIndicator = + requiresConsent && (skipConsent || hasAccepted(meetingId)); + + const showConsentButton = + requiresConsent && !skipConsent && !hasAnswered(meetingId); + return { showConsentModal, consentState, - hasConsent, + hasAnswered, + hasAccepted, consentLoading: audioConsentMutation.isPending, + showRecordingIndicator, + showConsentButton, }; } diff --git a/www/app/lib/types.ts b/www/app/lib/types.ts index 7bcb522b..c5ab8ce7 100644 --- a/www/app/lib/types.ts +++ b/www/app/lib/types.ts @@ -1,6 +1,10 @@ import type { Session } from "next-auth"; import type { JWT } from "next-auth/jwt"; -import { parseMaybeNonEmptyString } from "./utils"; +import { + assertExistsAndNonEmptyString, + NonEmptyString, + parseMaybeNonEmptyString, +} from "./utils"; export interface JWTWithAccessToken extends JWT { accessToken: string; @@ -78,3 +82,10 @@ export const assertCustomSession = ( export type Mutable = { -readonly [P in keyof T]: T[P]; }; + +export type MeetingId = NonEmptyString & { __type: "MeetingId" }; +export const assertMeetingId = (s: string): MeetingId => { + const nes = assertExistsAndNonEmptyString(s); + // just cast for now + return nes as MeetingId; +}; diff --git a/www/app/recordingConsentContext.tsx b/www/app/recordingConsentContext.tsx index b0aef9de..0b39b882 100644 --- a/www/app/recordingConsentContext.tsx +++ b/www/app/recordingConsentContext.tsx @@ -1,18 +1,22 @@ "use client"; import React, { createContext, useContext, useEffect, useState } from "react"; +import { MeetingId } from "./lib/types"; + +type ConsentMap = Map; type ConsentContextState = | { ready: false } | { ready: true; - consentAnsweredForMeetings: Set; + consentForMeetings: ConsentMap; }; interface RecordingConsentContextValue { state: ConsentContextState; - touch: (meetingId: string) => void; - hasConsent: (meetingId: string) => boolean; + touch: (meetingId: MeetingId, accepted: boolean) => void; + hasAnswered: (meetingId: MeetingId) => boolean; + hasAccepted: (meetingId: MeetingId) => boolean; } const RecordingConsentContext = createContext< @@ -35,81 +39,116 @@ interface RecordingConsentProviderProps { const LOCAL_STORAGE_KEY = "recording_consent_meetings"; +const ACCEPTED = "T" as const; +type Accepted = typeof ACCEPTED; +const REJECTED = "F" as const; +type Rejected = typeof REJECTED; +type Consent = Accepted | Rejected; +const SEPARATOR = "|" as const; +type Separator = typeof SEPARATOR; +const DEFAULT_CONSENT = ACCEPTED; +type Entry = `${MeetingId}${Separator}${Consent}`; +type EntryAndDefault = Entry | MeetingId; + +// Format: "meetingId|T" or "meetingId|F", legacy format "meetingId" is treated as accepted +const encodeEntry = (meetingId: MeetingId, accepted: boolean): Entry => + `${meetingId}|${accepted ? ACCEPTED : REJECTED}`; + +const decodeEntry = ( + entry: EntryAndDefault, +): { meetingId: MeetingId; accepted: boolean } | null => { + const pipeIndex = entry.lastIndexOf(SEPARATOR); + if (pipeIndex === -1) { + // Legacy format: no pipe means accepted (backward compat) + return { meetingId: entry as MeetingId, accepted: true }; + } + const suffix = entry.slice(pipeIndex + 1); + const meetingId = entry.slice(0, pipeIndex) as MeetingId; + // T = accepted, F = rejected, anything else = accepted (safe default) + const accepted = suffix !== REJECTED; + return { meetingId, accepted }; +}; + export const RecordingConsentProvider: React.FC< RecordingConsentProviderProps > = ({ children }) => { const [state, setState] = useState({ ready: false }); - const safeWriteToStorage = (meetingIds: string[]): void => { + const safeWriteToStorage = (consentMap: ConsentMap): void => { try { if (typeof window !== "undefined" && window.localStorage) { - localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(meetingIds)); + const entries = Array.from(consentMap.entries()) + .slice(-5) + .map(([id, accepted]) => encodeEntry(id, accepted)); + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(entries)); } } catch (error) { console.error("Failed to save consent data to localStorage:", error); } }; - // writes to local storage and to the state of context both - const touch = (meetingId: string): void => { + const touch = (meetingId: MeetingId, accepted: boolean): void => { if (!state.ready) { console.warn("Attempted to touch consent before context is ready"); return; } - // has success regardless local storage write success: we don't handle that - // and don't want to crash anything with just consent functionality - const newSet = state.consentAnsweredForMeetings.has(meetingId) - ? state.consentAnsweredForMeetings - : new Set([...state.consentAnsweredForMeetings, meetingId]); - // note: preserves the set insertion order - const array = Array.from(newSet).slice(-5); // Keep latest 5 - safeWriteToStorage(array); - setState({ ready: true, consentAnsweredForMeetings: newSet }); + const newMap = new Map(state.consentForMeetings); + newMap.set(meetingId, accepted); + safeWriteToStorage(newMap); + setState({ ready: true, consentForMeetings: newMap }); }; - const hasConsent = (meetingId: string): boolean => { + const hasAnswered = (meetingId: MeetingId): boolean => { if (!state.ready) return false; - return state.consentAnsweredForMeetings.has(meetingId); + return state.consentForMeetings.has(meetingId); + }; + + const hasAccepted = (meetingId: MeetingId): boolean => { + if (!state.ready) return false; + return state.consentForMeetings.get(meetingId) === true; }; // initialize on mount useEffect(() => { try { if (typeof window === "undefined" || !window.localStorage) { - setState({ ready: true, consentAnsweredForMeetings: new Set() }); + setState({ ready: true, consentForMeetings: new Map() }); return; } const stored = localStorage.getItem(LOCAL_STORAGE_KEY); if (!stored) { - setState({ ready: true, consentAnsweredForMeetings: new Set() }); + setState({ ready: true, consentForMeetings: new Map() }); return; } const parsed = JSON.parse(stored); if (!Array.isArray(parsed)) { console.warn("Invalid consent data format in localStorage, resetting"); - setState({ ready: true, consentAnsweredForMeetings: new Set() }); + setState({ ready: true, consentForMeetings: new Map() }); return; } - // pre-historic way of parsing! - const consentAnsweredForMeetings = new Set( - parsed.filter((id) => !!id && typeof id === "string"), - ); - setState({ ready: true, consentAnsweredForMeetings }); + const consentForMeetings = new Map(); + for (const entry of parsed) { + const decoded = decodeEntry(entry); + if (decoded) { + consentForMeetings.set(decoded.meetingId, decoded.accepted); + } + } + setState({ ready: true, consentForMeetings }); } catch (error) { - // we don't want to fail the page here; the component is not essential. console.error("Failed to parse consent data from localStorage:", error); - setState({ ready: true, consentAnsweredForMeetings: new Set() }); + setState({ ready: true, consentForMeetings: new Map() }); } }, []); const value: RecordingConsentContextValue = { state, touch, - hasConsent, + hasAnswered, + hasAccepted, }; return ( diff --git a/www/app/reflector-api.d.ts b/www/app/reflector-api.d.ts index 4aa6ee36..3704a9a0 100644 --- a/www/app/reflector-api.d.ts +++ b/www/app/reflector-api.d.ts @@ -893,8 +893,16 @@ export interface components { * @default false */ ics_enabled: boolean; - /** Platform */ - platform?: ("whereby" | "daily") | null; + /** + * Platform + * @enum {string} + */ + platform: "whereby" | "daily"; + /** + * Skip Consent + * @default false + */ + skip_consent: boolean; }; /** CreateRoomMeeting */ CreateRoomMeeting: { @@ -1123,7 +1131,9 @@ export interface components { /** Audio Deleted */ audio_deleted?: boolean | null; /** Participants */ - participants: components["schemas"]["TranscriptParticipant"][] | null; + participants: + | components["schemas"]["TranscriptParticipantWithEmail"][] + | null; /** * @description discriminator enum property added by openapi-typescript * @enum {string} @@ -1184,7 +1194,9 @@ export interface components { /** Audio Deleted */ audio_deleted?: boolean | null; /** Participants */ - participants: components["schemas"]["TranscriptParticipant"][] | null; + participants: + | components["schemas"]["TranscriptParticipantWithEmail"][] + | null; }; /** * GetTranscriptWithText @@ -1246,7 +1258,9 @@ export interface components { /** Audio Deleted */ audio_deleted?: boolean | null; /** Participants */ - participants: components["schemas"]["TranscriptParticipant"][] | null; + participants: + | components["schemas"]["TranscriptParticipantWithEmail"][] + | null; /** * @description discriminator enum property added by openapi-typescript * @enum {string} @@ -1315,7 +1329,9 @@ export interface components { /** Audio Deleted */ audio_deleted?: boolean | null; /** Participants */ - participants: components["schemas"]["TranscriptParticipant"][] | null; + participants: + | components["schemas"]["TranscriptParticipantWithEmail"][] + | null; /** * @description discriminator enum property added by openapi-typescript * @enum {string} @@ -1386,7 +1402,9 @@ export interface components { /** Audio Deleted */ audio_deleted?: boolean | null; /** Participants */ - participants: components["schemas"]["TranscriptParticipant"][] | null; + participants: + | components["schemas"]["TranscriptParticipantWithEmail"][] + | null; /** * @description discriminator enum property added by openapi-typescript * @enum {string} @@ -1567,6 +1585,11 @@ export interface components { /** Name */ name: string; }; + /** ProcessStatus */ + ProcessStatus: { + /** Status */ + status: string; + }; /** Room */ Room: { /** Id */ @@ -1617,6 +1640,11 @@ export interface components { * @enum {string} */ platform: "whereby" | "daily"; + /** + * Skip Consent + * @default false + */ + skip_consent: boolean; }; /** RoomDetails */ RoomDetails: { @@ -1668,6 +1696,11 @@ export interface components { * @enum {string} */ platform: "whereby" | "daily"; + /** + * Skip Consent + * @default false + */ + skip_consent: boolean; /** Webhook Url */ webhook_url: string | null; /** Webhook Secret */ @@ -1813,6 +1846,19 @@ export interface components { /** User Id */ user_id?: string | null; }; + /** TranscriptParticipantWithEmail */ + TranscriptParticipantWithEmail: { + /** Id */ + id?: string; + /** Speaker */ + speaker: number | null; + /** Name */ + name: string; + /** User Id */ + user_id?: string | null; + /** Email */ + email?: string | null; + }; /** * TranscriptSegment * @description A single transcript segment with speaker and timing information. @@ -1868,6 +1914,8 @@ export interface components { ics_enabled?: boolean | null; /** Platform */ platform?: ("whereby" | "daily") | null; + /** Skip Consent */ + skip_consent?: boolean | null; }; /** UpdateTranscript */ UpdateTranscript: { @@ -3362,7 +3410,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["ProcessStatus"]; }; }; /** @description Validation Error */ diff --git a/www/public/recording-icon.svg b/www/public/recording-icon.svg new file mode 100644 index 00000000..b7d544c1 --- /dev/null +++ b/www/public/recording-icon.svg @@ -0,0 +1,3 @@ + + +