mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
Compare commits
9 Commits
daily-reco
...
feat/conse
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0828bb846 | ||
|
|
65916c273f | ||
|
|
15afd57ed9 | ||
|
|
3929a80665 | ||
|
|
a988c3aa92 | ||
|
|
9edc38b861 | ||
|
|
fbf319573e | ||
|
|
537f9413a5 | ||
|
|
129a19bcb5 |
@@ -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")
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
33
www/app/lib/consent/RecordingIndicator.tsx
Normal file
33
www/app/lib/consent/RecordingIndicator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
64
www/app/reflector-api.d.ts
vendored
64
www/app/reflector-api.d.ts
vendored
@@ -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 */
|
||||||
|
|||||||
3
www/public/recording-icon.svg
Normal file
3
www/public/recording-icon.svg
Normal 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 |
Reference in New Issue
Block a user