Compare commits

...

9 Commits

Author SHA1 Message Date
Igor Loskutov
f0828bb846 active meetings type precision 2025-12-19 17:46:31 -05:00
Igor Loskutov
65916c273f no forced whereby recording indicator 2025-12-19 16:59:40 -05:00
Igor Loskutov
15afd57ed9 consent skip feature 2025-12-19 16:56:31 -05:00
Igor Loskutov
3929a80665 consent skip feature 2025-12-19 16:14:28 -05:00
Igor Loskutov
a988c3aa92 daily backend code refactor 2025-12-19 15:31:27 -05:00
Igor Loskutov
9edc38b861 consent disable refactor 2025-12-18 23:16:24 -05:00
Igor Loskutov
fbf319573e sync migration 2025-12-18 18:37:16 -05:00
Igor Monadical
537f9413a5 Merge branch 'main' into feat/consent-disable 2025-12-18 18:18:29 -05:00
Igor Loskutov
129a19bcb5 consent disable feature (no-mistakes) 2025-12-18 11:14:02 -05:00
23 changed files with 650 additions and 182 deletions

View File

@@ -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")

View File

@@ -57,6 +57,12 @@ rooms = sqlalchemy.Table(
sqlalchemy.String, sqlalchemy.String,
nullable=False, 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_is_shared", "is_shared"),
sqlalchemy.Index("idx_room_ics_enabled", "ics_enabled"), sqlalchemy.Index("idx_room_ics_enabled", "ics_enabled"),
) )
@@ -85,6 +91,7 @@ class Room(BaseModel):
ics_last_sync: datetime | None = None ics_last_sync: datetime | None = None
ics_last_etag: str | None = None ics_last_etag: str | None = None
platform: Platform = Field(default_factory=lambda: settings.DEFAULT_VIDEO_PLATFORM) platform: Platform = Field(default_factory=lambda: settings.DEFAULT_VIDEO_PLATFORM)
skip_consent: bool = False
class RoomController: class RoomController:
@@ -139,6 +146,7 @@ class RoomController:
ics_fetch_interval: int = 300, ics_fetch_interval: int = 300,
ics_enabled: bool = False, ics_enabled: bool = False,
platform: Platform = settings.DEFAULT_VIDEO_PLATFORM, platform: Platform = settings.DEFAULT_VIDEO_PLATFORM,
skip_consent: bool = False,
): ):
""" """
Add a new room Add a new room
@@ -163,6 +171,7 @@ class RoomController:
"ics_fetch_interval": ics_fetch_interval, "ics_fetch_interval": ics_fetch_interval,
"ics_enabled": ics_enabled, "ics_enabled": ics_enabled,
"platform": platform, "platform": platform,
"skip_consent": skip_consent,
} }
room = Room(**room_data) room = Room(**room_data)

View File

@@ -112,7 +112,7 @@ def get_transcript(func):
transcript_id = kwargs.pop("transcript_id") transcript_id = kwargs.pop("transcript_id")
transcript = await transcripts_controller.get_by_id(transcript_id=transcript_id) transcript = await transcripts_controller.get_by_id(transcript_id=transcript_id)
if not transcript: if not transcript:
raise Exception("Transcript {transcript_id} not found") raise Exception(f"Transcript {transcript_id} not found")
# Enhanced logger with Celery task context # Enhanced logger with Celery task context
tlogger = logger.bind(transcript_id=transcript.id) tlogger = logger.bind(transcript_id=transcript.id)

View File

@@ -44,6 +44,7 @@ class Room(BaseModel):
ics_last_sync: Optional[datetime] = None ics_last_sync: Optional[datetime] = None
ics_last_etag: Optional[str] = None ics_last_etag: Optional[str] = None
platform: Platform platform: Platform
skip_consent: bool = False
class RoomDetails(Room): class RoomDetails(Room):
@@ -90,6 +91,7 @@ class CreateRoom(BaseModel):
ics_fetch_interval: int = 300 ics_fetch_interval: int = 300
ics_enabled: bool = False ics_enabled: bool = False
platform: Platform platform: Platform
skip_consent: bool = False
class UpdateRoom(BaseModel): class UpdateRoom(BaseModel):
@@ -108,6 +110,7 @@ class UpdateRoom(BaseModel):
ics_fetch_interval: Optional[int] = None ics_fetch_interval: Optional[int] = None
ics_enabled: Optional[bool] = None ics_enabled: Optional[bool] = None
platform: Optional[Platform] = None platform: Optional[Platform] = None
skip_consent: Optional[bool] = None
class CreateRoomMeeting(BaseModel): class CreateRoomMeeting(BaseModel):
@@ -249,6 +252,7 @@ async def rooms_create(
ics_fetch_interval=room.ics_fetch_interval, ics_fetch_interval=room.ics_fetch_interval,
ics_enabled=room.ics_enabled, ics_enabled=room.ics_enabled,
platform=room.platform, 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: if meeting.platform == "daily" and user_id is not None:
client = create_platform_client(meeting.platform) 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( token = await client.create_meeting_token(
meeting.room_name, meeting.room_name,
start_cloud_recording=meeting.recording_type == "cloud", start_cloud_recording=meeting.recording_type == "cloud",
enable_recording_ui=meeting.recording_type == "local", enable_recording_ui=enable_recording_ui,
user_id=user_id, user_id=user_id,
is_owner=user_id == room.user_id, is_owner=user_id == room.user_id,
) )

View File

@@ -7,6 +7,12 @@ from reflector.settings import settings
logger = structlog.get_logger(__name__) 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 @shared_task
def healthcheck_ping(): def healthcheck_ping():
url = settings.HEALTHCHECK_URL url = settings.HEALTHCHECK_URL

View File

@@ -570,12 +570,12 @@ async def process_meetings():
client = create_platform_client(meeting.platform) client = create_platform_client(meeting.platform)
room_sessions = await client.get_room_sessions(meeting.room_name) room_sessions = await client.get_room_sessions(meeting.room_name)
has_active_sessions = room_sessions and any( has_active_sessions = bool(
s.ended_at is None for s in room_sessions room_sessions and any(s.ended_at is None for s in room_sessions)
) )
has_had_sessions = bool(room_sessions) has_had_sessions = bool(room_sessions)
logger_.info( 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: if has_active_sessions:

View File

@@ -91,6 +91,7 @@ const roomInitialState = {
icsEnabled: false, icsEnabled: false,
icsFetchInterval: 5, icsFetchInterval: 5,
platform: "whereby", platform: "whereby",
skipConsent: false,
}; };
export default function RoomsList() { export default function RoomsList() {
@@ -175,6 +176,7 @@ export default function RoomsList() {
icsEnabled: detailedEditedRoom.ics_enabled || false, icsEnabled: detailedEditedRoom.ics_enabled || false,
icsFetchInterval: detailedEditedRoom.ics_fetch_interval || 5, icsFetchInterval: detailedEditedRoom.ics_fetch_interval || 5,
platform: detailedEditedRoom.platform, platform: detailedEditedRoom.platform,
skipConsent: detailedEditedRoom.skip_consent || false,
} }
: null, : null,
[detailedEditedRoom], [detailedEditedRoom],
@@ -326,6 +328,7 @@ export default function RoomsList() {
ics_enabled: room.icsEnabled, ics_enabled: room.icsEnabled,
ics_fetch_interval: room.icsFetchInterval, ics_fetch_interval: room.icsFetchInterval,
platform, platform,
skip_consent: room.skipConsent,
}; };
if (isEditing) { if (isEditing) {
@@ -388,6 +391,7 @@ export default function RoomsList() {
icsEnabled: roomData.ics_enabled || false, icsEnabled: roomData.ics_enabled || false,
icsFetchInterval: roomData.ics_fetch_interval || 5, icsFetchInterval: roomData.ics_fetch_interval || 5,
platform: roomData.platform, platform: roomData.platform,
skipConsent: roomData.skip_consent || false,
}); });
setEditRoomId(roomId); setEditRoomId(roomId);
setIsEditing(true); setIsEditing(true);
@@ -796,6 +800,34 @@ export default function RoomsList() {
<Checkbox.Label>Shared room</Checkbox.Label> <Checkbox.Label>Shared room</Checkbox.Label>
</Checkbox.Root> </Checkbox.Root>
</Field.Root> </Field.Root>
{room.recordingType === "cloud" && (
<Field.Root mt={4}>
<Checkbox.Root
name="skipConsent"
checked={room.skipConsent}
onCheckedChange={(e) => {
const syntheticEvent = {
target: {
name: "skipConsent",
type: "checkbox",
checked: e.checked,
},
};
handleRoomChange(syntheticEvent);
}}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>Skip consent dialog</Checkbox.Label>
</Checkbox.Root>
<Field.HelperText>
When enabled, participants won't be asked for
recording consent. Audio will be stored automatically.
</Field.HelperText>
</Field.Root>
)}
</Tabs.Content> </Tabs.Content>
<Tabs.Content value="share" pt={6}> <Tabs.Content value="share" pt={6}>

View File

@@ -26,6 +26,7 @@ import { useRouter } from "next/navigation";
import { formatDateTime, formatStartedAgo } from "../lib/timeUtils"; import { formatDateTime, formatStartedAgo } from "../lib/timeUtils";
import MeetingMinimalHeader from "../components/MeetingMinimalHeader"; import MeetingMinimalHeader from "../components/MeetingMinimalHeader";
import { NonEmptyString } from "../lib/utils"; import { NonEmptyString } from "../lib/utils";
import { MeetingId } from "../lib/types";
type Meeting = components["schemas"]["Meeting"]; type Meeting = components["schemas"]["Meeting"];
@@ -98,7 +99,7 @@ export default function MeetingSelection({
onMeetingSelect(meeting); onMeetingSelect(meeting);
}; };
const handleEndMeeting = async (meetingId: string) => { const handleEndMeeting = async (meetingId: MeetingId) => {
try { try {
await deactivateMeetingMutation.mutateAsync({ await deactivateMeetingMutation.mutateAsync({
params: { params: {

View File

@@ -1,35 +1,194 @@
"use client"; "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 { Box, Spinner, Center, Text } from "@chakra-ui/react";
import { useRouter, useParams } from "next/navigation"; 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 type { components } from "../../reflector-api";
import { useAuth } from "../../lib/AuthProvider"; import { useAuth } from "../../lib/AuthProvider";
import { import { useConsentDialog } from "../../lib/consent";
ConsentDialogButton,
recordingTypeRequiresConsent,
} from "../../lib/consent";
import { useRoomJoinMeeting } from "../../lib/apiHooks"; import { useRoomJoinMeeting } from "../../lib/apiHooks";
import { omit } from "remeda";
import { assertExists } from "../../lib/utils"; 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 Meeting = components["schemas"]["Meeting"];
type Room = components["schemas"]["RoomDetails"];
interface DailyRoomProps { type DailyRoomProps = {
meeting: Meeting; meeting: Meeting;
} room: Room;
};
export default function DailyRoom({ meeting }: DailyRoomProps) { const useCustomTrayButtons = (
frame: {
updateCustomTrayButtons: (
customTrayButtons: DailyCustomTrayButtons,
) => void;
joined: boolean;
} | null,
) => {
const [, setCustomTrayButtons] = useState<DailyCustomTrayButtons>({});
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<DailyParticipantsObject | void> => {
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 router = useRouter();
const params = useParams(); const params = useParams();
const auth = useAuth(); const auth = useAuth();
const authLastUserId = auth.lastUserId; const authLastUserId = auth.lastUserId;
const containerRef = useRef<HTMLDivElement>(null); const [container, setContainer] = useState<HTMLDivElement | null>(null);
const joinMutation = useRoomJoinMeeting(); const joinMutation = useRoomJoinMeeting();
const [joinedMeeting, setJoinedMeeting] = useState<Meeting | null>(null); const [joinedMeeting, setJoinedMeeting] = useState<Meeting | null>(null);
const roomName = params?.roomName as string; 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(() => { useEffect(() => {
if (authLastUserId === undefined || !meeting?.id || !roomName) return; 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]); }, [meeting?.id, roomName, authLastUserId]);
const roomUrl = joinedMeeting?.room_url; const roomUrl = joinedMeeting?.room_url;
@@ -58,84 +217,86 @@ export default function DailyRoom({ meeting }: DailyRoomProps) {
router.push("/browse"); router.push("/browse");
}, [router]); }, [router]);
useEffect(() => { const handleCustomButtonClick = useCallback(
if (authLastUserId === undefined || !roomUrl || !containerRef.current) (ev: DailyEventObjectCustomButtonClick) => {
return; if (ev.button_id === CONSENT_BUTTON_ID) {
showConsentModalRef.current();
}
},
[
/*keep static; iframe recreation depends on it*/
],
);
let frame: DailyCall | null = null; const handleFrameJoinMeeting = useCallback(
let destroyed = false; (startRecording: (args: { type: "raw-tracks" }) => void) => {
const createAndJoin = async () => {
try { try {
const existingFrame = DailyIframe.getCallInstance(); if (meeting.recording_type === "cloud") {
if (existingFrame) { console.log("Starting cloud recording");
await existingFrame.destroy(); 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) { } catch (error) {
console.error("Error creating Daily frame:", error); console.error("Failed to start recording:", error);
} }
}; },
[meeting.recording_type],
);
createAndJoin().catch((error) => { const recordingIconUrl = useMemo(
console.error("Failed to create and join meeting:", error); () => new URL("/recording-icon.svg", window.location.origin),
}); [],
);
return () => { const [frame, { setCustomTrayButton }] = useFrame(container, {
destroyed = true; onLeftMeeting: handleLeave,
if (frame) { onCustomButtonClick: handleCustomButtonClick,
frame.destroy().catch((e) => { onJoinMeeting: handleFrameJoinMeeting,
console.error("Error destroying frame:", e); });
});
} useEffect(() => {
}; if (!frame || !roomUrl) return;
}, [roomUrl, authLastUserId, handleLeave]); 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) { if (authLastUserId === undefined) {
return ( return (
@@ -159,10 +320,7 @@ export default function DailyRoom({ meeting }: DailyRoomProps) {
return ( return (
<Box position="relative" width="100vw" height="100vh"> <Box position="relative" width="100vw" height="100vh">
<div ref={containerRef} style={{ width: "100%", height: "100%" }} /> <div ref={setContainer} style={{ width: "100%", height: "100%" }} />
{meeting.recording_type &&
recordingTypeRequiresConsent(meeting.recording_type) &&
meeting.id && <ConsentDialogButton meetingId={meeting.id} />}
</Box> </Box>
); );
} }

View File

@@ -18,6 +18,7 @@ import { useAuth } from "../../lib/AuthProvider";
import { useError } from "../../(errors)/errorContext"; import { useError } from "../../(errors)/errorContext";
import { parseNonEmptyString } from "../../lib/utils"; import { parseNonEmptyString } from "../../lib/utils";
import { printApiError } from "../../api/_error"; import { printApiError } from "../../api/_error";
import { assertMeetingId } from "../../lib/types";
type Meeting = components["schemas"]["Meeting"]; type Meeting = components["schemas"]["Meeting"];
@@ -67,7 +68,10 @@ export default function RoomContainer(details: RoomDetails) {
room && !room.ics_enabled && !pageMeetingId ? roomName : null, 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; const meeting = explicitMeeting.data || defaultMeeting.response;
@@ -192,9 +196,9 @@ export default function RoomContainer(details: RoomDetails) {
switch (platform) { switch (platform) {
case "daily": case "daily":
return <DailyRoom meeting={meeting} />; return <DailyRoom meeting={meeting} room={room} />;
case "whereby": case "whereby":
return <WherebyRoom meeting={meeting} />; return <WherebyRoom meeting={meeting} room={room} />;
default: { default: {
const _exhaustive: never = platform; const _exhaustive: never = platform;
return ( return (

View File

@@ -5,24 +5,29 @@ import { useRouter } from "next/navigation";
import type { components } from "../../reflector-api"; import type { components } from "../../reflector-api";
import { useAuth } from "../../lib/AuthProvider"; import { useAuth } from "../../lib/AuthProvider";
import { getWherebyUrl, useWhereby } from "../../lib/wherebyClient"; import { getWherebyUrl, useWhereby } from "../../lib/wherebyClient";
import { assertExistsAndNonEmptyString, NonEmptyString } from "../../lib/utils";
import { import {
ConsentDialogButton as BaseConsentDialogButton, ConsentDialogButton as BaseConsentDialogButton,
useConsentDialog, useConsentDialog,
recordingTypeRequiresConsent,
} from "../../lib/consent"; } from "../../lib/consent";
import { assertMeetingId, MeetingId } from "../../lib/types";
type Meeting = components["schemas"]["Meeting"]; type Meeting = components["schemas"]["Meeting"];
type Room = components["schemas"]["RoomDetails"];
interface WherebyRoomProps { interface WherebyRoomProps {
meeting: Meeting; meeting: Meeting;
room: Room;
} }
function WherebyConsentDialogButton({ function WherebyConsentDialogButton({
meetingId, meetingId,
recordingType,
skipConsent,
wherebyRef, wherebyRef,
}: { }: {
meetingId: NonEmptyString; meetingId: MeetingId;
recordingType: Meeting["recording_type"];
skipConsent: boolean;
wherebyRef: React.RefObject<HTMLElement>; wherebyRef: React.RefObject<HTMLElement>;
}) { }) {
const previousFocusRef = useRef<HTMLElement | null>(null); const previousFocusRef = useRef<HTMLElement | null>(null);
@@ -45,10 +50,16 @@ function WherebyConsentDialogButton({
}; };
}, [wherebyRef]); }, [wherebyRef]);
return <BaseConsentDialogButton meetingId={meetingId} />; return (
<BaseConsentDialogButton
meetingId={meetingId}
recordingType={recordingType}
skipConsent={skipConsent}
/>
);
} }
export default function WherebyRoom({ meeting }: WherebyRoomProps) { export default function WherebyRoom({ meeting, room }: WherebyRoomProps) {
const wherebyLoaded = useWhereby(); const wherebyLoaded = useWhereby();
const wherebyRef = useRef<HTMLElement>(null); const wherebyRef = useRef<HTMLElement>(null);
const router = useRouter(); const router = useRouter();
@@ -57,9 +68,14 @@ export default function WherebyRoom({ meeting }: WherebyRoomProps) {
const isAuthenticated = status === "authenticated"; const isAuthenticated = status === "authenticated";
const wherebyRoomUrl = getWherebyUrl(meeting); const wherebyRoomUrl = getWherebyUrl(meeting);
const recordingType = meeting.recording_type;
const meetingId = meeting.id; const meetingId = meeting.id;
const { showConsentButton } = useConsentDialog({
meetingId: assertMeetingId(meetingId),
recordingType: meeting.recording_type,
skipConsent: room.skip_consent,
});
const isLoading = status === "loading"; const isLoading = status === "loading";
const handleLeave = useCallback(() => { const handleLeave = useCallback(() => {
@@ -88,14 +104,14 @@ export default function WherebyRoom({ meeting }: WherebyRoomProps) {
room={wherebyRoomUrl} room={wherebyRoomUrl}
style={{ width: "100vw", height: "100vh" }} style={{ width: "100vw", height: "100vh" }}
/> />
{recordingType && {showConsentButton && (
recordingTypeRequiresConsent(recordingType) && <WherebyConsentDialogButton
meetingId && ( meetingId={assertMeetingId(meetingId)}
<WherebyConsentDialogButton recordingType={meeting.recording_type}
meetingId={assertExistsAndNonEmptyString(meetingId)} skipConsent={room.skip_consent}
wherebyRef={wherebyRef} wherebyRef={wherebyRef}
/> />
)} )}
</> </>
); );
} }

View File

@@ -6,7 +6,6 @@ import {
useEffect, useEffect,
useRef, useRef,
useState, useState,
useContext,
RefObject, RefObject,
use, use,
} from "react"; } from "react";
@@ -25,8 +24,6 @@ import { useRecordingConsent } from "../recordingConsentContext";
import { import {
useMeetingAudioConsent, useMeetingAudioConsent,
useRoomGetByName, useRoomGetByName,
useRoomActiveMeetings,
useRoomUpcomingMeetings,
useRoomsCreateMeeting, useRoomsCreateMeeting,
useRoomGetMeeting, useRoomGetMeeting,
} from "../lib/apiHooks"; } from "../lib/apiHooks";
@@ -39,12 +36,9 @@ import { FaBars } from "react-icons/fa6";
import { useAuth } from "../lib/AuthProvider"; import { useAuth } from "../lib/AuthProvider";
import { getWherebyUrl, useWhereby } from "../lib/wherebyClient"; import { getWherebyUrl, useWhereby } from "../lib/wherebyClient";
import { useError } from "../(errors)/errorContext"; import { useError } from "../(errors)/errorContext";
import { import { parseNonEmptyString } from "../lib/utils";
assertExistsAndNonEmptyString,
NonEmptyString,
parseNonEmptyString,
} from "../lib/utils";
import { printApiError } from "../api/_error"; import { printApiError } from "../api/_error";
import { assertMeetingId, MeetingId } from "../lib/types";
export type RoomDetails = { export type RoomDetails = {
params: Promise<{ params: Promise<{
@@ -92,16 +86,16 @@ const useConsentWherebyFocusManagement = (
}; };
const useConsentDialog = ( const useConsentDialog = (
meetingId: string, meetingId: MeetingId,
wherebyRef: RefObject<HTMLElement> /*accessibility*/, wherebyRef: RefObject<HTMLElement> /*accessibility*/,
) => { ) => {
const { state: consentState, touch, hasConsent } = useRecordingConsent(); const { state: consentState, touch, hasAnswered } = useRecordingConsent();
// toast would open duplicates, even with using "id=" prop // toast would open duplicates, even with using "id=" prop
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const audioConsentMutation = useMeetingAudioConsent(); const audioConsentMutation = useMeetingAudioConsent();
const handleConsent = useCallback( const handleConsent = useCallback(
async (meetingId: string, given: boolean) => { async (meetingId: MeetingId, given: boolean) => {
try { try {
await audioConsentMutation.mutateAsync({ await audioConsentMutation.mutateAsync({
params: { params: {
@@ -114,7 +108,7 @@ const useConsentDialog = (
}, },
}); });
touch(meetingId); touch(meetingId, given);
} catch (error) { } catch (error) {
console.error("Error submitting consent:", error); console.error("Error submitting consent:", error);
} }
@@ -216,7 +210,7 @@ const useConsentDialog = (
return { return {
showConsentModal, showConsentModal,
consentState, consentState,
hasConsent, hasAnswered,
consentLoading: audioConsentMutation.isPending, consentLoading: audioConsentMutation.isPending,
}; };
}; };
@@ -225,13 +219,13 @@ function ConsentDialogButton({
meetingId, meetingId,
wherebyRef, wherebyRef,
}: { }: {
meetingId: NonEmptyString; meetingId: MeetingId;
wherebyRef: React.RefObject<HTMLElement>; wherebyRef: React.RefObject<HTMLElement>;
}) { }) {
const { showConsentModal, consentState, hasConsent, consentLoading } = const { showConsentModal, consentState, hasAnswered, consentLoading } =
useConsentDialog(meetingId, wherebyRef); useConsentDialog(meetingId, wherebyRef);
if (!consentState.ready || hasConsent(meetingId) || consentLoading) { if (!consentState.ready || hasAnswered(meetingId) || consentLoading) {
return null; return null;
} }
@@ -284,7 +278,10 @@ export default function Room(details: RoomDetails) {
room && !room.ics_enabled && !pageMeetingId ? roomName : null, 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 const wherebyRoomUrl = explicitMeeting.data
? getWherebyUrl(explicitMeeting.data) ? getWherebyUrl(explicitMeeting.data)
: defaultMeeting.response : defaultMeeting.response
@@ -437,7 +434,7 @@ export default function Room(details: RoomDetails) {
recordingTypeRequiresConsent(recordingType) && recordingTypeRequiresConsent(recordingType) &&
meetingId && ( meetingId && (
<ConsentDialogButton <ConsentDialogButton
meetingId={assertExistsAndNonEmptyString(meetingId)} meetingId={assertMeetingId(meetingId)}
wherebyRef={wherebyRef} wherebyRef={wherebyRef}
/> />
)} )}

View File

@@ -5,6 +5,7 @@ import { useError } from "../(errors)/errorContext";
import { QueryClient, useQueryClient } from "@tanstack/react-query"; import { QueryClient, useQueryClient } from "@tanstack/react-query";
import type { components } from "../reflector-api"; import type { components } from "../reflector-api";
import { useAuth } from "./AuthProvider"; 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 * 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( export function useRoomGetMeeting(
roomName: string | null, roomName: string | null,
meetingId: string | null, meetingId: MeetingId | null,
) { ) {
return $api.useQuery( return $api.useQuery(
"get", "get",

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { useState, useEffect } from "react";
import { Box, Button, Text, VStack, HStack } from "@chakra-ui/react"; import { Box, Button, Text, VStack, HStack } from "@chakra-ui/react";
import { CONSENT_DIALOG_TEXT } from "./constants"; import { CONSENT_DIALOG_TEXT } from "./constants";
@@ -9,6 +10,15 @@ interface ConsentDialogProps {
} }
export function ConsentDialog({ onAccept, onReject }: ConsentDialogProps) { export function ConsentDialog({ onAccept, onReject }: ConsentDialogProps) {
const [acceptButton, setAcceptButton] = useState<HTMLButtonElement | null>(
null,
);
useEffect(() => {
// Auto-focus accept button so Escape key works (Daily iframe captures keyboard otherwise)
acceptButton?.focus();
}, [acceptButton]);
return ( return (
<Box <Box
p={6} p={6}
@@ -26,7 +36,12 @@ export function ConsentDialog({ onAccept, onReject }: ConsentDialogProps) {
<Button variant="ghost" size="sm" onClick={onReject}> <Button variant="ghost" size="sm" onClick={onReject}>
{CONSENT_DIALOG_TEXT.rejectButton} {CONSENT_DIALOG_TEXT.rejectButton}
</Button> </Button>
<Button colorPalette="primary" size="sm" onClick={onAccept}> <Button
ref={setAcceptButton}
colorPalette="primary"
size="sm"
onClick={onAccept}
>
{CONSENT_DIALOG_TEXT.acceptButton} {CONSENT_DIALOG_TEXT.acceptButton}
</Button> </Button>
</HStack> </HStack>

View File

@@ -9,16 +9,26 @@ import {
CONSENT_BUTTON_Z_INDEX, CONSENT_BUTTON_Z_INDEX,
CONSENT_DIALOG_TEXT, CONSENT_DIALOG_TEXT,
} from "./constants"; } from "./constants";
import { MeetingId } from "../types";
import type { components } from "../../reflector-api";
interface ConsentDialogButtonProps { type Meeting = components["schemas"]["Meeting"];
meetingId: string;
}
export function ConsentDialogButton({ meetingId }: ConsentDialogButtonProps) { type ConsentDialogButtonProps = {
const { showConsentModal, consentState, hasConsent, consentLoading } = meetingId: MeetingId;
useConsentDialog(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; return null;
} }

View File

@@ -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 (
<Box
position="absolute"
top={CONSENT_BUTTON_TOP_OFFSET}
left={CONSENT_BUTTON_LEFT_OFFSET}
zIndex={CONSENT_BUTTON_Z_INDEX}
display="flex"
alignItems="center"
gap={2}
bg="red.500"
color="white"
px={3}
py={1.5}
borderRadius="md"
fontSize="sm"
fontWeight="medium"
>
<FaCircle size={8} />
<Text>Recording</Text>
</Box>
);
}

View File

@@ -2,6 +2,7 @@
export { ConsentDialogButton } from "./ConsentDialogButton"; export { ConsentDialogButton } from "./ConsentDialogButton";
export { ConsentDialog } from "./ConsentDialog"; export { ConsentDialog } from "./ConsentDialog";
export { RecordingIndicator } from "./RecordingIndicator";
export { useConsentDialog } from "./useConsentDialog"; export { useConsentDialog } from "./useConsentDialog";
export { recordingTypeRequiresConsent } from "./utils"; export { recordingTypeRequiresConsent } from "./utils";
export * from "./constants"; export * from "./constants";

View File

@@ -1,9 +1,14 @@
export interface ConsentDialogResult { import { MeetingId } from "../types";
export type ConsentDialogResult = {
showConsentModal: () => void; showConsentModal: () => void;
consentState: { consentState: {
ready: boolean; ready: boolean;
consentAnsweredForMeetings?: Set<string>; consentForMeetings?: Map<MeetingId, boolean>;
}; };
hasConsent: (meetingId: string) => boolean; hasAnswered: (meetingId: MeetingId) => boolean;
hasAccepted: (meetingId: MeetingId) => boolean;
consentLoading: boolean; consentLoading: boolean;
} showRecordingIndicator: boolean;
showConsentButton: boolean;
};

View File

@@ -7,9 +7,29 @@ import { useMeetingAudioConsent } from "../apiHooks";
import { ConsentDialog } from "./ConsentDialog"; import { ConsentDialog } from "./ConsentDialog";
import { TOAST_CHECK_INTERVAL_MS } from "./constants"; import { TOAST_CHECK_INTERVAL_MS } from "./constants";
import type { ConsentDialogResult } from "./types"; 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 { type Meeting = components["schemas"]["Meeting"];
const { state: consentState, touch, hasConsent } = useRecordingConsent();
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 [modalOpen, setModalOpen] = useState(false);
const audioConsentMutation = useMeetingAudioConsent(); const audioConsentMutation = useMeetingAudioConsent();
const intervalRef = useRef<NodeJS.Timeout | null>(null); const intervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -42,7 +62,7 @@ export function useConsentDialog(meetingId: string): ConsentDialogResult {
}, },
}); });
touch(meetingId); touch(meetingId, given);
} catch (error) { } catch (error) {
console.error("Error submitting consent:", error); console.error("Error submitting consent:", error);
} }
@@ -100,10 +120,23 @@ export function useConsentDialog(meetingId: string): ConsentDialogResult {
}); });
}, [handleConsent, modalOpen]); }, [handleConsent, modalOpen]);
const requiresConsent = Boolean(
recordingType && recordingTypeRequiresConsent(recordingType),
);
const showRecordingIndicator =
requiresConsent && (skipConsent || hasAccepted(meetingId));
const showConsentButton =
requiresConsent && !skipConsent && !hasAnswered(meetingId);
return { return {
showConsentModal, showConsentModal,
consentState, consentState,
hasConsent, hasAnswered,
hasAccepted,
consentLoading: audioConsentMutation.isPending, consentLoading: audioConsentMutation.isPending,
showRecordingIndicator,
showConsentButton,
}; };
} }

View File

@@ -1,6 +1,10 @@
import type { Session } from "next-auth"; import type { Session } from "next-auth";
import type { JWT } from "next-auth/jwt"; import type { JWT } from "next-auth/jwt";
import { parseMaybeNonEmptyString } from "./utils"; import {
assertExistsAndNonEmptyString,
NonEmptyString,
parseMaybeNonEmptyString,
} from "./utils";
export interface JWTWithAccessToken extends JWT { export interface JWTWithAccessToken extends JWT {
accessToken: string; accessToken: string;
@@ -78,3 +82,10 @@ export const assertCustomSession = <T extends Session>(
export type Mutable<T> = { export type Mutable<T> = {
-readonly [P in keyof T]: T[P]; -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;
};

View File

@@ -1,18 +1,22 @@
"use client"; "use client";
import React, { createContext, useContext, useEffect, useState } from "react"; import React, { createContext, useContext, useEffect, useState } from "react";
import { MeetingId } from "./lib/types";
type ConsentMap = Map<MeetingId, boolean>;
type ConsentContextState = type ConsentContextState =
| { ready: false } | { ready: false }
| { | {
ready: true; ready: true;
consentAnsweredForMeetings: Set<string>; consentForMeetings: ConsentMap;
}; };
interface RecordingConsentContextValue { interface RecordingConsentContextValue {
state: ConsentContextState; state: ConsentContextState;
touch: (meetingId: string) => void; touch: (meetingId: MeetingId, accepted: boolean) => void;
hasConsent: (meetingId: string) => boolean; hasAnswered: (meetingId: MeetingId) => boolean;
hasAccepted: (meetingId: MeetingId) => boolean;
} }
const RecordingConsentContext = createContext< const RecordingConsentContext = createContext<
@@ -35,81 +39,116 @@ interface RecordingConsentProviderProps {
const LOCAL_STORAGE_KEY = "recording_consent_meetings"; 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< export const RecordingConsentProvider: React.FC<
RecordingConsentProviderProps RecordingConsentProviderProps
> = ({ children }) => { > = ({ children }) => {
const [state, setState] = useState<ConsentContextState>({ ready: false }); const [state, setState] = useState<ConsentContextState>({ ready: false });
const safeWriteToStorage = (meetingIds: string[]): void => { const safeWriteToStorage = (consentMap: ConsentMap): void => {
try { try {
if (typeof window !== "undefined" && window.localStorage) { 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) { } catch (error) {
console.error("Failed to save consent data to localStorage:", 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: MeetingId, accepted: boolean): void => {
const touch = (meetingId: string): void => {
if (!state.ready) { if (!state.ready) {
console.warn("Attempted to touch consent before context is ready"); console.warn("Attempted to touch consent before context is ready");
return; return;
} }
// has success regardless local storage write success: we don't handle that const newMap = new Map(state.consentForMeetings);
// and don't want to crash anything with just consent functionality newMap.set(meetingId, accepted);
const newSet = state.consentAnsweredForMeetings.has(meetingId) safeWriteToStorage(newMap);
? state.consentAnsweredForMeetings setState({ ready: true, consentForMeetings: newMap });
: 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 hasConsent = (meetingId: string): boolean => { const hasAnswered = (meetingId: MeetingId): boolean => {
if (!state.ready) return false; 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 // initialize on mount
useEffect(() => { useEffect(() => {
try { try {
if (typeof window === "undefined" || !window.localStorage) { if (typeof window === "undefined" || !window.localStorage) {
setState({ ready: true, consentAnsweredForMeetings: new Set() }); setState({ ready: true, consentForMeetings: new Map() });
return; return;
} }
const stored = localStorage.getItem(LOCAL_STORAGE_KEY); const stored = localStorage.getItem(LOCAL_STORAGE_KEY);
if (!stored) { if (!stored) {
setState({ ready: true, consentAnsweredForMeetings: new Set() }); setState({ ready: true, consentForMeetings: new Map() });
return; return;
} }
const parsed = JSON.parse(stored); const parsed = JSON.parse(stored);
if (!Array.isArray(parsed)) { if (!Array.isArray(parsed)) {
console.warn("Invalid consent data format in localStorage, resetting"); console.warn("Invalid consent data format in localStorage, resetting");
setState({ ready: true, consentAnsweredForMeetings: new Set() }); setState({ ready: true, consentForMeetings: new Map() });
return; return;
} }
// pre-historic way of parsing! const consentForMeetings = new Map<MeetingId, boolean>();
const consentAnsweredForMeetings = new Set( for (const entry of parsed) {
parsed.filter((id) => !!id && typeof id === "string"), const decoded = decodeEntry(entry);
); if (decoded) {
setState({ ready: true, consentAnsweredForMeetings }); consentForMeetings.set(decoded.meetingId, decoded.accepted);
}
}
setState({ ready: true, consentForMeetings });
} catch (error) { } 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); 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 = { const value: RecordingConsentContextValue = {
state, state,
touch, touch,
hasConsent, hasAnswered,
hasAccepted,
}; };
return ( return (

View File

@@ -893,8 +893,16 @@ export interface components {
* @default false * @default false
*/ */
ics_enabled: boolean; ics_enabled: boolean;
/** Platform */ /**
platform?: ("whereby" | "daily") | null; * Platform
* @enum {string}
*/
platform: "whereby" | "daily";
/**
* Skip Consent
* @default false
*/
skip_consent: boolean;
}; };
/** CreateRoomMeeting */ /** CreateRoomMeeting */
CreateRoomMeeting: { CreateRoomMeeting: {
@@ -1123,7 +1131,9 @@ export interface components {
/** Audio Deleted */ /** Audio Deleted */
audio_deleted?: boolean | null; audio_deleted?: boolean | null;
/** Participants */ /** Participants */
participants: components["schemas"]["TranscriptParticipant"][] | null; participants:
| components["schemas"]["TranscriptParticipantWithEmail"][]
| null;
/** /**
* @description discriminator enum property added by openapi-typescript * @description discriminator enum property added by openapi-typescript
* @enum {string} * @enum {string}
@@ -1184,7 +1194,9 @@ export interface components {
/** Audio Deleted */ /** Audio Deleted */
audio_deleted?: boolean | null; audio_deleted?: boolean | null;
/** Participants */ /** Participants */
participants: components["schemas"]["TranscriptParticipant"][] | null; participants:
| components["schemas"]["TranscriptParticipantWithEmail"][]
| null;
}; };
/** /**
* GetTranscriptWithText * GetTranscriptWithText
@@ -1246,7 +1258,9 @@ export interface components {
/** Audio Deleted */ /** Audio Deleted */
audio_deleted?: boolean | null; audio_deleted?: boolean | null;
/** Participants */ /** Participants */
participants: components["schemas"]["TranscriptParticipant"][] | null; participants:
| components["schemas"]["TranscriptParticipantWithEmail"][]
| null;
/** /**
* @description discriminator enum property added by openapi-typescript * @description discriminator enum property added by openapi-typescript
* @enum {string} * @enum {string}
@@ -1315,7 +1329,9 @@ export interface components {
/** Audio Deleted */ /** Audio Deleted */
audio_deleted?: boolean | null; audio_deleted?: boolean | null;
/** Participants */ /** Participants */
participants: components["schemas"]["TranscriptParticipant"][] | null; participants:
| components["schemas"]["TranscriptParticipantWithEmail"][]
| null;
/** /**
* @description discriminator enum property added by openapi-typescript * @description discriminator enum property added by openapi-typescript
* @enum {string} * @enum {string}
@@ -1386,7 +1402,9 @@ export interface components {
/** Audio Deleted */ /** Audio Deleted */
audio_deleted?: boolean | null; audio_deleted?: boolean | null;
/** Participants */ /** Participants */
participants: components["schemas"]["TranscriptParticipant"][] | null; participants:
| components["schemas"]["TranscriptParticipantWithEmail"][]
| null;
/** /**
* @description discriminator enum property added by openapi-typescript * @description discriminator enum property added by openapi-typescript
* @enum {string} * @enum {string}
@@ -1567,6 +1585,11 @@ export interface components {
/** Name */ /** Name */
name: string; name: string;
}; };
/** ProcessStatus */
ProcessStatus: {
/** Status */
status: string;
};
/** Room */ /** Room */
Room: { Room: {
/** Id */ /** Id */
@@ -1617,6 +1640,11 @@ export interface components {
* @enum {string} * @enum {string}
*/ */
platform: "whereby" | "daily"; platform: "whereby" | "daily";
/**
* Skip Consent
* @default false
*/
skip_consent: boolean;
}; };
/** RoomDetails */ /** RoomDetails */
RoomDetails: { RoomDetails: {
@@ -1668,6 +1696,11 @@ export interface components {
* @enum {string} * @enum {string}
*/ */
platform: "whereby" | "daily"; platform: "whereby" | "daily";
/**
* Skip Consent
* @default false
*/
skip_consent: boolean;
/** Webhook Url */ /** Webhook Url */
webhook_url: string | null; webhook_url: string | null;
/** Webhook Secret */ /** Webhook Secret */
@@ -1813,6 +1846,19 @@ export interface components {
/** User Id */ /** User Id */
user_id?: string | null; 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 * TranscriptSegment
* @description A single transcript segment with speaker and timing information. * @description A single transcript segment with speaker and timing information.
@@ -1868,6 +1914,8 @@ export interface components {
ics_enabled?: boolean | null; ics_enabled?: boolean | null;
/** Platform */ /** Platform */
platform?: ("whereby" | "daily") | null; platform?: ("whereby" | "daily") | null;
/** Skip Consent */
skip_consent?: boolean | null;
}; };
/** UpdateTranscript */ /** UpdateTranscript */
UpdateTranscript: { UpdateTranscript: {
@@ -3362,7 +3410,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": unknown; "application/json": components["schemas"]["ProcessStatus"];
}; };
}; };
/** @description Validation Error */ /** @description Validation Error */

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ef4444" stroke="none">
<circle cx="12" cy="12" r="8"/>
</svg>

After

Width:  |  Height:  |  Size: 131 B