mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
Compare commits
1 Commits
feat/conse
...
v0.24.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 37a454f283 |
15
CHANGELOG.md
15
CHANGELOG.md
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## [0.24.0](https://github.com/Monadical-SAS/reflector/compare/v0.23.2...v0.24.0) (2025-12-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* identify action items ([#790](https://github.com/Monadical-SAS/reflector/issues/790)) ([964cd78](https://github.com/Monadical-SAS/reflector/commit/964cd78bb699d83d012ae4b8c96565df25b90a5d))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* automatically reprocess daily recordings ([#797](https://github.com/Monadical-SAS/reflector/issues/797)) ([5f458aa](https://github.com/Monadical-SAS/reflector/commit/5f458aa4a7ec3d00ca5ec49d62fcc8ad232b138e))
|
||||
* daily video optimisation ([#789](https://github.com/Monadical-SAS/reflector/issues/789)) ([16284e1](https://github.com/Monadical-SAS/reflector/commit/16284e1ac3faede2b74f0d91b50c0b5612af2c35))
|
||||
* main menu login ([#800](https://github.com/Monadical-SAS/reflector/issues/800)) ([0bc971b](https://github.com/Monadical-SAS/reflector/commit/0bc971ba966a52d719c8c240b47dc7b3bdea4391))
|
||||
* retry on workflow timeout ([#798](https://github.com/Monadical-SAS/reflector/issues/798)) ([5f7dfad](https://github.com/Monadical-SAS/reflector/commit/5f7dfadabd3e8017406ad3720ba495a59963ee34))
|
||||
|
||||
## [0.23.2](https://github.com/Monadical-SAS/reflector/compare/v0.23.1...v0.23.2) (2025-12-11)
|
||||
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
"""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,12 +57,6 @@ rooms = sqlalchemy.Table(
|
||||
sqlalchemy.String,
|
||||
nullable=False,
|
||||
),
|
||||
sqlalchemy.Column(
|
||||
"skip_consent",
|
||||
sqlalchemy.Boolean,
|
||||
nullable=False,
|
||||
server_default=sqlalchemy.sql.false(),
|
||||
),
|
||||
sqlalchemy.Index("idx_room_is_shared", "is_shared"),
|
||||
sqlalchemy.Index("idx_room_ics_enabled", "ics_enabled"),
|
||||
)
|
||||
@@ -91,7 +85,6 @@ class Room(BaseModel):
|
||||
ics_last_sync: datetime | None = None
|
||||
ics_last_etag: str | None = None
|
||||
platform: Platform = Field(default_factory=lambda: settings.DEFAULT_VIDEO_PLATFORM)
|
||||
skip_consent: bool = False
|
||||
|
||||
|
||||
class RoomController:
|
||||
@@ -146,7 +139,6 @@ class RoomController:
|
||||
ics_fetch_interval: int = 300,
|
||||
ics_enabled: bool = False,
|
||||
platform: Platform = settings.DEFAULT_VIDEO_PLATFORM,
|
||||
skip_consent: bool = False,
|
||||
):
|
||||
"""
|
||||
Add a new room
|
||||
@@ -171,7 +163,6 @@ class RoomController:
|
||||
"ics_fetch_interval": ics_fetch_interval,
|
||||
"ics_enabled": ics_enabled,
|
||||
"platform": platform,
|
||||
"skip_consent": skip_consent,
|
||||
}
|
||||
|
||||
room = Room(**room_data)
|
||||
|
||||
@@ -112,7 +112,7 @@ def get_transcript(func):
|
||||
transcript_id = kwargs.pop("transcript_id")
|
||||
transcript = await transcripts_controller.get_by_id(transcript_id=transcript_id)
|
||||
if not transcript:
|
||||
raise Exception(f"Transcript {transcript_id} not found")
|
||||
raise Exception("Transcript {transcript_id} not found")
|
||||
|
||||
# Enhanced logger with Celery task context
|
||||
tlogger = logger.bind(transcript_id=transcript.id)
|
||||
|
||||
@@ -44,7 +44,6 @@ class Room(BaseModel):
|
||||
ics_last_sync: Optional[datetime] = None
|
||||
ics_last_etag: Optional[str] = None
|
||||
platform: Platform
|
||||
skip_consent: bool = False
|
||||
|
||||
|
||||
class RoomDetails(Room):
|
||||
@@ -91,7 +90,6 @@ class CreateRoom(BaseModel):
|
||||
ics_fetch_interval: int = 300
|
||||
ics_enabled: bool = False
|
||||
platform: Platform
|
||||
skip_consent: bool = False
|
||||
|
||||
|
||||
class UpdateRoom(BaseModel):
|
||||
@@ -110,7 +108,6 @@ class UpdateRoom(BaseModel):
|
||||
ics_fetch_interval: Optional[int] = None
|
||||
ics_enabled: Optional[bool] = None
|
||||
platform: Optional[Platform] = None
|
||||
skip_consent: Optional[bool] = None
|
||||
|
||||
|
||||
class CreateRoomMeeting(BaseModel):
|
||||
@@ -252,7 +249,6 @@ async def rooms_create(
|
||||
ics_fetch_interval=room.ics_fetch_interval,
|
||||
ics_enabled=room.ics_enabled,
|
||||
platform=room.platform,
|
||||
skip_consent=room.skip_consent,
|
||||
)
|
||||
|
||||
|
||||
@@ -571,17 +567,10 @@ async def rooms_join_meeting(
|
||||
|
||||
if meeting.platform == "daily" and user_id is not None:
|
||||
client = create_platform_client(meeting.platform)
|
||||
# Show Daily's built-in recording UI when:
|
||||
# - local recording (user controls when to record), OR
|
||||
# - cloud recording with consent disabled (skip_consent=True)
|
||||
# Hide it when cloud recording with consent enabled (we show custom consent UI)
|
||||
enable_recording_ui = meeting.recording_type == "local" or (
|
||||
meeting.recording_type == "cloud" and room.skip_consent
|
||||
)
|
||||
token = await client.create_meeting_token(
|
||||
meeting.room_name,
|
||||
start_cloud_recording=meeting.recording_type == "cloud",
|
||||
enable_recording_ui=enable_recording_ui,
|
||||
enable_recording_ui=meeting.recording_type == "local",
|
||||
user_id=user_id,
|
||||
is_owner=user_id == room.user_id,
|
||||
)
|
||||
|
||||
@@ -7,12 +7,6 @@ from reflector.settings import settings
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@shared_task(name="celery.ping")
|
||||
def celery_ping():
|
||||
"""Compatibility task for Celery 5.x - celery.ping was removed but monitoring tools still call it."""
|
||||
return "pong"
|
||||
|
||||
|
||||
@shared_task
|
||||
def healthcheck_ping():
|
||||
url = settings.HEALTHCHECK_URL
|
||||
|
||||
@@ -570,12 +570,12 @@ async def process_meetings():
|
||||
client = create_platform_client(meeting.platform)
|
||||
room_sessions = await client.get_room_sessions(meeting.room_name)
|
||||
|
||||
has_active_sessions = bool(
|
||||
room_sessions and any(s.ended_at is None for s in room_sessions)
|
||||
has_active_sessions = room_sessions and any(
|
||||
s.ended_at is None for s in room_sessions
|
||||
)
|
||||
has_had_sessions = bool(room_sessions)
|
||||
logger_.info(
|
||||
f"has_active_sessions={has_active_sessions}, has_had_sessions={has_had_sessions}"
|
||||
f"found {has_active_sessions} active sessions, had {has_had_sessions}"
|
||||
)
|
||||
|
||||
if has_active_sessions:
|
||||
|
||||
@@ -91,7 +91,6 @@ const roomInitialState = {
|
||||
icsEnabled: false,
|
||||
icsFetchInterval: 5,
|
||||
platform: "whereby",
|
||||
skipConsent: false,
|
||||
};
|
||||
|
||||
export default function RoomsList() {
|
||||
@@ -176,7 +175,6 @@ export default function RoomsList() {
|
||||
icsEnabled: detailedEditedRoom.ics_enabled || false,
|
||||
icsFetchInterval: detailedEditedRoom.ics_fetch_interval || 5,
|
||||
platform: detailedEditedRoom.platform,
|
||||
skipConsent: detailedEditedRoom.skip_consent || false,
|
||||
}
|
||||
: null,
|
||||
[detailedEditedRoom],
|
||||
@@ -328,7 +326,6 @@ export default function RoomsList() {
|
||||
ics_enabled: room.icsEnabled,
|
||||
ics_fetch_interval: room.icsFetchInterval,
|
||||
platform,
|
||||
skip_consent: room.skipConsent,
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
@@ -391,7 +388,6 @@ export default function RoomsList() {
|
||||
icsEnabled: roomData.ics_enabled || false,
|
||||
icsFetchInterval: roomData.ics_fetch_interval || 5,
|
||||
platform: roomData.platform,
|
||||
skipConsent: roomData.skip_consent || false,
|
||||
});
|
||||
setEditRoomId(roomId);
|
||||
setIsEditing(true);
|
||||
@@ -800,34 +796,6 @@ export default function RoomsList() {
|
||||
<Checkbox.Label>Shared room</Checkbox.Label>
|
||||
</Checkbox.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 value="share" pt={6}>
|
||||
|
||||
@@ -26,7 +26,6 @@ import { useRouter } from "next/navigation";
|
||||
import { formatDateTime, formatStartedAgo } from "../lib/timeUtils";
|
||||
import MeetingMinimalHeader from "../components/MeetingMinimalHeader";
|
||||
import { NonEmptyString } from "../lib/utils";
|
||||
import { MeetingId } from "../lib/types";
|
||||
|
||||
type Meeting = components["schemas"]["Meeting"];
|
||||
|
||||
@@ -99,7 +98,7 @@ export default function MeetingSelection({
|
||||
onMeetingSelect(meeting);
|
||||
};
|
||||
|
||||
const handleEndMeeting = async (meetingId: MeetingId) => {
|
||||
const handleEndMeeting = async (meetingId: string) => {
|
||||
try {
|
||||
await deactivateMeetingMutation.mutateAsync({
|
||||
params: {
|
||||
|
||||
@@ -1,194 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Box, Spinner, Center, Text } from "@chakra-ui/react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import DailyIframe, {
|
||||
DailyCall,
|
||||
DailyCallOptions,
|
||||
DailyCustomTrayButton,
|
||||
DailyCustomTrayButtons,
|
||||
DailyEventObjectCustomButtonClick,
|
||||
DailyFactoryOptions,
|
||||
DailyParticipantsObject,
|
||||
} from "@daily-co/daily-js";
|
||||
import DailyIframe, { DailyCall } from "@daily-co/daily-js";
|
||||
import type { components } from "../../reflector-api";
|
||||
import { useAuth } from "../../lib/AuthProvider";
|
||||
import { useConsentDialog } from "../../lib/consent";
|
||||
import {
|
||||
ConsentDialogButton,
|
||||
recordingTypeRequiresConsent,
|
||||
} from "../../lib/consent";
|
||||
import { useRoomJoinMeeting } from "../../lib/apiHooks";
|
||||
import { omit } from "remeda";
|
||||
import { assertExists } from "../../lib/utils";
|
||||
import { assertMeetingId } from "../../lib/types";
|
||||
|
||||
const CONSENT_BUTTON_ID = "recording-consent";
|
||||
const RECORDING_INDICATOR_ID = "recording-indicator";
|
||||
|
||||
type Meeting = components["schemas"]["Meeting"];
|
||||
type Room = components["schemas"]["RoomDetails"];
|
||||
|
||||
type DailyRoomProps = {
|
||||
interface DailyRoomProps {
|
||||
meeting: Meeting;
|
||||
room: Room;
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
export default function DailyRoom({ meeting }: DailyRoomProps) {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const auth = useAuth();
|
||||
const authLastUserId = auth.lastUserId;
|
||||
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const joinMutation = useRoomJoinMeeting();
|
||||
const [joinedMeeting, setJoinedMeeting] = useState<Meeting | null>(null);
|
||||
|
||||
const roomName = params?.roomName as string;
|
||||
|
||||
const {
|
||||
showConsentModal,
|
||||
showRecordingIndicator: showRecordingInTray,
|
||||
showConsentButton,
|
||||
} = useConsentDialog({
|
||||
meetingId: assertMeetingId(meeting.id),
|
||||
recordingType: meeting.recording_type,
|
||||
skipConsent: room.skip_consent,
|
||||
});
|
||||
const showConsentModalRef = useRef(showConsentModal);
|
||||
showConsentModalRef.current = showConsentModal;
|
||||
|
||||
useEffect(() => {
|
||||
if (authLastUserId === undefined || !meeting?.id || !roomName) return;
|
||||
|
||||
@@ -208,7 +49,7 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) {
|
||||
}
|
||||
};
|
||||
|
||||
join().catch(console.error.bind(console, "Failed to join meeting:"));
|
||||
join();
|
||||
}, [meeting?.id, roomName, authLastUserId]);
|
||||
|
||||
const roomUrl = joinedMeeting?.room_url;
|
||||
@@ -217,86 +58,84 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) {
|
||||
router.push("/browse");
|
||||
}, [router]);
|
||||
|
||||
const handleCustomButtonClick = useCallback(
|
||||
(ev: DailyEventObjectCustomButtonClick) => {
|
||||
if (ev.button_id === CONSENT_BUTTON_ID) {
|
||||
showConsentModalRef.current();
|
||||
}
|
||||
},
|
||||
[
|
||||
/*keep static; iframe recreation depends on it*/
|
||||
],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (authLastUserId === undefined || !roomUrl || !containerRef.current)
|
||||
return;
|
||||
|
||||
const handleFrameJoinMeeting = useCallback(
|
||||
(startRecording: (args: { type: "raw-tracks" }) => void) => {
|
||||
let frame: DailyCall | null = null;
|
||||
let destroyed = false;
|
||||
|
||||
const createAndJoin = async () => {
|
||||
try {
|
||||
if (meeting.recording_type === "cloud") {
|
||||
console.log("Starting cloud recording");
|
||||
startRecording({ type: "raw-tracks" });
|
||||
const existingFrame = DailyIframe.getCallInstance();
|
||||
if (existingFrame) {
|
||||
await existingFrame.destroy();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to start recording:", error);
|
||||
}
|
||||
},
|
||||
[meeting.recording_type],
|
||||
);
|
||||
|
||||
const recordingIconUrl = useMemo(
|
||||
() => new URL("/recording-icon.svg", window.location.origin),
|
||||
[],
|
||||
);
|
||||
|
||||
const [frame, { setCustomTrayButton }] = useFrame(container, {
|
||||
onLeftMeeting: handleLeave,
|
||||
onCustomButtonClick: handleCustomButtonClick,
|
||||
onJoinMeeting: handleFrameJoinMeeting,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!frame || !roomUrl) return;
|
||||
frame
|
||||
.join({
|
||||
url: roomUrl,
|
||||
sendSettings: {
|
||||
video: {
|
||||
// Optimize bandwidth for camera video
|
||||
// allowAdaptiveLayers automatically adjusts quality based on network conditions
|
||||
allowAdaptiveLayers: true,
|
||||
// Use bandwidth-optimized preset as fallback for browsers without adaptive support
|
||||
maxQuality: "medium",
|
||||
frame = DailyIframe.createFrame(containerRef.current!, {
|
||||
iframeStyle: {
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
border: "none",
|
||||
},
|
||||
// Note: screenVideo intentionally not configured to preserve full quality for screen shares
|
||||
},
|
||||
})
|
||||
.catch(console.error.bind(console, "Failed to join daily room:"));
|
||||
}, [frame, roomUrl]);
|
||||
showLeaveButton: true,
|
||||
showFullscreenButton: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setCustomTrayButton(
|
||||
RECORDING_INDICATOR_ID,
|
||||
showRecordingInTray
|
||||
? {
|
||||
iconPath: recordingIconUrl.href,
|
||||
label: "Recording",
|
||||
tooltip: "Recording in progress",
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}, [showRecordingInTray, recordingIconUrl, setCustomTrayButton]);
|
||||
if (destroyed) {
|
||||
await frame.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setCustomTrayButton(
|
||||
CONSENT_BUTTON_ID,
|
||||
showConsentButton
|
||||
? {
|
||||
iconPath: recordingIconUrl.href,
|
||||
label: "Recording (click to consent)",
|
||||
tooltip: "Recording (click to consent)",
|
||||
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);
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}, [showConsentButton, recordingIconUrl, setCustomTrayButton]);
|
||||
});
|
||||
|
||||
await frame.join({
|
||||
url: roomUrl,
|
||||
sendSettings: {
|
||||
video: {
|
||||
// Optimize bandwidth for camera video
|
||||
// allowAdaptiveLayers automatically adjusts quality based on network conditions
|
||||
allowAdaptiveLayers: true,
|
||||
// Use bandwidth-optimized preset as fallback for browsers without adaptive support
|
||||
maxQuality: "medium",
|
||||
},
|
||||
// Note: screenVideo intentionally not configured to preserve full quality for screen shares
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error creating Daily frame:", error);
|
||||
}
|
||||
};
|
||||
|
||||
createAndJoin().catch((error) => {
|
||||
console.error("Failed to create and join meeting:", error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
destroyed = true;
|
||||
if (frame) {
|
||||
frame.destroy().catch((e) => {
|
||||
console.error("Error destroying frame:", e);
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [roomUrl, authLastUserId, handleLeave]);
|
||||
|
||||
if (authLastUserId === undefined) {
|
||||
return (
|
||||
@@ -320,7 +159,10 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) {
|
||||
|
||||
return (
|
||||
<Box position="relative" width="100vw" height="100vh">
|
||||
<div ref={setContainer} style={{ width: "100%", height: "100%" }} />
|
||||
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
|
||||
{meeting.recording_type &&
|
||||
recordingTypeRequiresConsent(meeting.recording_type) &&
|
||||
meeting.id && <ConsentDialogButton meetingId={meeting.id} />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import { useAuth } from "../../lib/AuthProvider";
|
||||
import { useError } from "../../(errors)/errorContext";
|
||||
import { parseNonEmptyString } from "../../lib/utils";
|
||||
import { printApiError } from "../../api/_error";
|
||||
import { assertMeetingId } from "../../lib/types";
|
||||
|
||||
type Meeting = components["schemas"]["Meeting"];
|
||||
|
||||
@@ -68,10 +67,7 @@ export default function RoomContainer(details: RoomDetails) {
|
||||
room && !room.ics_enabled && !pageMeetingId ? roomName : null,
|
||||
);
|
||||
|
||||
const explicitMeeting = useRoomGetMeeting(
|
||||
roomName,
|
||||
pageMeetingId ? assertMeetingId(pageMeetingId) : null,
|
||||
);
|
||||
const explicitMeeting = useRoomGetMeeting(roomName, pageMeetingId || null);
|
||||
|
||||
const meeting = explicitMeeting.data || defaultMeeting.response;
|
||||
|
||||
@@ -196,9 +192,9 @@ export default function RoomContainer(details: RoomDetails) {
|
||||
|
||||
switch (platform) {
|
||||
case "daily":
|
||||
return <DailyRoom meeting={meeting} room={room} />;
|
||||
return <DailyRoom meeting={meeting} />;
|
||||
case "whereby":
|
||||
return <WherebyRoom meeting={meeting} room={room} />;
|
||||
return <WherebyRoom meeting={meeting} />;
|
||||
default: {
|
||||
const _exhaustive: never = platform;
|
||||
return (
|
||||
|
||||
@@ -5,29 +5,24 @@ import { useRouter } from "next/navigation";
|
||||
import type { components } from "../../reflector-api";
|
||||
import { useAuth } from "../../lib/AuthProvider";
|
||||
import { getWherebyUrl, useWhereby } from "../../lib/wherebyClient";
|
||||
import { assertExistsAndNonEmptyString, NonEmptyString } from "../../lib/utils";
|
||||
import {
|
||||
ConsentDialogButton as BaseConsentDialogButton,
|
||||
useConsentDialog,
|
||||
recordingTypeRequiresConsent,
|
||||
} from "../../lib/consent";
|
||||
import { assertMeetingId, MeetingId } from "../../lib/types";
|
||||
|
||||
type Meeting = components["schemas"]["Meeting"];
|
||||
type Room = components["schemas"]["RoomDetails"];
|
||||
|
||||
interface WherebyRoomProps {
|
||||
meeting: Meeting;
|
||||
room: Room;
|
||||
}
|
||||
|
||||
function WherebyConsentDialogButton({
|
||||
meetingId,
|
||||
recordingType,
|
||||
skipConsent,
|
||||
wherebyRef,
|
||||
}: {
|
||||
meetingId: MeetingId;
|
||||
recordingType: Meeting["recording_type"];
|
||||
skipConsent: boolean;
|
||||
meetingId: NonEmptyString;
|
||||
wherebyRef: React.RefObject<HTMLElement>;
|
||||
}) {
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
@@ -50,16 +45,10 @@ function WherebyConsentDialogButton({
|
||||
};
|
||||
}, [wherebyRef]);
|
||||
|
||||
return (
|
||||
<BaseConsentDialogButton
|
||||
meetingId={meetingId}
|
||||
recordingType={recordingType}
|
||||
skipConsent={skipConsent}
|
||||
/>
|
||||
);
|
||||
return <BaseConsentDialogButton meetingId={meetingId} />;
|
||||
}
|
||||
|
||||
export default function WherebyRoom({ meeting, room }: WherebyRoomProps) {
|
||||
export default function WherebyRoom({ meeting }: WherebyRoomProps) {
|
||||
const wherebyLoaded = useWhereby();
|
||||
const wherebyRef = useRef<HTMLElement>(null);
|
||||
const router = useRouter();
|
||||
@@ -68,14 +57,9 @@ export default function WherebyRoom({ meeting, room }: WherebyRoomProps) {
|
||||
const isAuthenticated = status === "authenticated";
|
||||
|
||||
const wherebyRoomUrl = getWherebyUrl(meeting);
|
||||
const recordingType = meeting.recording_type;
|
||||
const meetingId = meeting.id;
|
||||
|
||||
const { showConsentButton } = useConsentDialog({
|
||||
meetingId: assertMeetingId(meetingId),
|
||||
recordingType: meeting.recording_type,
|
||||
skipConsent: room.skip_consent,
|
||||
});
|
||||
|
||||
const isLoading = status === "loading";
|
||||
|
||||
const handleLeave = useCallback(() => {
|
||||
@@ -104,14 +88,14 @@ export default function WherebyRoom({ meeting, room }: WherebyRoomProps) {
|
||||
room={wherebyRoomUrl}
|
||||
style={{ width: "100vw", height: "100vh" }}
|
||||
/>
|
||||
{showConsentButton && (
|
||||
<WherebyConsentDialogButton
|
||||
meetingId={assertMeetingId(meetingId)}
|
||||
recordingType={meeting.recording_type}
|
||||
skipConsent={room.skip_consent}
|
||||
wherebyRef={wherebyRef}
|
||||
/>
|
||||
)}
|
||||
{recordingType &&
|
||||
recordingTypeRequiresConsent(recordingType) &&
|
||||
meetingId && (
|
||||
<WherebyConsentDialogButton
|
||||
meetingId={assertExistsAndNonEmptyString(meetingId)}
|
||||
wherebyRef={wherebyRef}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useContext,
|
||||
RefObject,
|
||||
use,
|
||||
} from "react";
|
||||
@@ -24,6 +25,8 @@ import { useRecordingConsent } from "../recordingConsentContext";
|
||||
import {
|
||||
useMeetingAudioConsent,
|
||||
useRoomGetByName,
|
||||
useRoomActiveMeetings,
|
||||
useRoomUpcomingMeetings,
|
||||
useRoomsCreateMeeting,
|
||||
useRoomGetMeeting,
|
||||
} from "../lib/apiHooks";
|
||||
@@ -36,9 +39,12 @@ import { FaBars } from "react-icons/fa6";
|
||||
import { useAuth } from "../lib/AuthProvider";
|
||||
import { getWherebyUrl, useWhereby } from "../lib/wherebyClient";
|
||||
import { useError } from "../(errors)/errorContext";
|
||||
import { parseNonEmptyString } from "../lib/utils";
|
||||
import {
|
||||
assertExistsAndNonEmptyString,
|
||||
NonEmptyString,
|
||||
parseNonEmptyString,
|
||||
} from "../lib/utils";
|
||||
import { printApiError } from "../api/_error";
|
||||
import { assertMeetingId, MeetingId } from "../lib/types";
|
||||
|
||||
export type RoomDetails = {
|
||||
params: Promise<{
|
||||
@@ -86,16 +92,16 @@ const useConsentWherebyFocusManagement = (
|
||||
};
|
||||
|
||||
const useConsentDialog = (
|
||||
meetingId: MeetingId,
|
||||
meetingId: string,
|
||||
wherebyRef: RefObject<HTMLElement> /*accessibility*/,
|
||||
) => {
|
||||
const { state: consentState, touch, hasAnswered } = useRecordingConsent();
|
||||
const { state: consentState, touch, hasConsent } = useRecordingConsent();
|
||||
// toast would open duplicates, even with using "id=" prop
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const audioConsentMutation = useMeetingAudioConsent();
|
||||
|
||||
const handleConsent = useCallback(
|
||||
async (meetingId: MeetingId, given: boolean) => {
|
||||
async (meetingId: string, given: boolean) => {
|
||||
try {
|
||||
await audioConsentMutation.mutateAsync({
|
||||
params: {
|
||||
@@ -108,7 +114,7 @@ const useConsentDialog = (
|
||||
},
|
||||
});
|
||||
|
||||
touch(meetingId, given);
|
||||
touch(meetingId);
|
||||
} catch (error) {
|
||||
console.error("Error submitting consent:", error);
|
||||
}
|
||||
@@ -210,7 +216,7 @@ const useConsentDialog = (
|
||||
return {
|
||||
showConsentModal,
|
||||
consentState,
|
||||
hasAnswered,
|
||||
hasConsent,
|
||||
consentLoading: audioConsentMutation.isPending,
|
||||
};
|
||||
};
|
||||
@@ -219,13 +225,13 @@ function ConsentDialogButton({
|
||||
meetingId,
|
||||
wherebyRef,
|
||||
}: {
|
||||
meetingId: MeetingId;
|
||||
meetingId: NonEmptyString;
|
||||
wherebyRef: React.RefObject<HTMLElement>;
|
||||
}) {
|
||||
const { showConsentModal, consentState, hasAnswered, consentLoading } =
|
||||
const { showConsentModal, consentState, hasConsent, consentLoading } =
|
||||
useConsentDialog(meetingId, wherebyRef);
|
||||
|
||||
if (!consentState.ready || hasAnswered(meetingId) || consentLoading) {
|
||||
if (!consentState.ready || hasConsent(meetingId) || consentLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -278,10 +284,7 @@ export default function Room(details: RoomDetails) {
|
||||
room && !room.ics_enabled && !pageMeetingId ? roomName : null,
|
||||
);
|
||||
|
||||
const explicitMeeting = useRoomGetMeeting(
|
||||
roomName,
|
||||
pageMeetingId ? assertMeetingId(pageMeetingId) : null,
|
||||
);
|
||||
const explicitMeeting = useRoomGetMeeting(roomName, pageMeetingId || null);
|
||||
const wherebyRoomUrl = explicitMeeting.data
|
||||
? getWherebyUrl(explicitMeeting.data)
|
||||
: defaultMeeting.response
|
||||
@@ -434,7 +437,7 @@ export default function Room(details: RoomDetails) {
|
||||
recordingTypeRequiresConsent(recordingType) &&
|
||||
meetingId && (
|
||||
<ConsentDialogButton
|
||||
meetingId={assertMeetingId(meetingId)}
|
||||
meetingId={assertExistsAndNonEmptyString(meetingId)}
|
||||
wherebyRef={wherebyRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useError } from "../(errors)/errorContext";
|
||||
import { QueryClient, useQueryClient } from "@tanstack/react-query";
|
||||
import type { components } from "../reflector-api";
|
||||
import { useAuth } from "./AuthProvider";
|
||||
import { MeetingId } from "./types";
|
||||
|
||||
/*
|
||||
* XXX error types returned from the hooks are not always correct; declared types are ValidationError but real type could be string or any other
|
||||
@@ -719,7 +718,7 @@ export function useRoomActiveMeetings(roomName: string | null) {
|
||||
|
||||
export function useRoomGetMeeting(
|
||||
roomName: string | null,
|
||||
meetingId: MeetingId | null,
|
||||
meetingId: string | null,
|
||||
) {
|
||||
return $api.useQuery(
|
||||
"get",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Box, Button, Text, VStack, HStack } from "@chakra-ui/react";
|
||||
import { CONSENT_DIALOG_TEXT } from "./constants";
|
||||
|
||||
@@ -10,15 +9,6 @@ interface 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 (
|
||||
<Box
|
||||
p={6}
|
||||
@@ -36,12 +26,7 @@ export function ConsentDialog({ onAccept, onReject }: ConsentDialogProps) {
|
||||
<Button variant="ghost" size="sm" onClick={onReject}>
|
||||
{CONSENT_DIALOG_TEXT.rejectButton}
|
||||
</Button>
|
||||
<Button
|
||||
ref={setAcceptButton}
|
||||
colorPalette="primary"
|
||||
size="sm"
|
||||
onClick={onAccept}
|
||||
>
|
||||
<Button colorPalette="primary" size="sm" onClick={onAccept}>
|
||||
{CONSENT_DIALOG_TEXT.acceptButton}
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
@@ -9,26 +9,16 @@ import {
|
||||
CONSENT_BUTTON_Z_INDEX,
|
||||
CONSENT_DIALOG_TEXT,
|
||||
} from "./constants";
|
||||
import { MeetingId } from "../types";
|
||||
import type { components } from "../../reflector-api";
|
||||
|
||||
type Meeting = components["schemas"]["Meeting"];
|
||||
interface ConsentDialogButtonProps {
|
||||
meetingId: string;
|
||||
}
|
||||
|
||||
type ConsentDialogButtonProps = {
|
||||
meetingId: MeetingId;
|
||||
recordingType: Meeting["recording_type"];
|
||||
skipConsent: boolean;
|
||||
};
|
||||
export function ConsentDialogButton({ meetingId }: ConsentDialogButtonProps) {
|
||||
const { showConsentModal, consentState, hasConsent, consentLoading } =
|
||||
useConsentDialog(meetingId);
|
||||
|
||||
export function ConsentDialogButton({
|
||||
meetingId,
|
||||
recordingType,
|
||||
skipConsent,
|
||||
}: ConsentDialogButtonProps) {
|
||||
const { showConsentModal, consentState, showConsentButton, consentLoading } =
|
||||
useConsentDialog({ meetingId, recordingType, skipConsent });
|
||||
|
||||
if (!consentState.ready || !showConsentButton || consentLoading) {
|
||||
if (!consentState.ready || hasConsent(meetingId) || consentLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"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,7 +2,6 @@
|
||||
|
||||
export { ConsentDialogButton } from "./ConsentDialogButton";
|
||||
export { ConsentDialog } from "./ConsentDialog";
|
||||
export { RecordingIndicator } from "./RecordingIndicator";
|
||||
export { useConsentDialog } from "./useConsentDialog";
|
||||
export { recordingTypeRequiresConsent } from "./utils";
|
||||
export * from "./constants";
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { MeetingId } from "../types";
|
||||
|
||||
export type ConsentDialogResult = {
|
||||
export interface ConsentDialogResult {
|
||||
showConsentModal: () => void;
|
||||
consentState: {
|
||||
ready: boolean;
|
||||
consentForMeetings?: Map<MeetingId, boolean>;
|
||||
consentAnsweredForMeetings?: Set<string>;
|
||||
};
|
||||
hasAnswered: (meetingId: MeetingId) => boolean;
|
||||
hasAccepted: (meetingId: MeetingId) => boolean;
|
||||
hasConsent: (meetingId: string) => boolean;
|
||||
consentLoading: boolean;
|
||||
showRecordingIndicator: boolean;
|
||||
showConsentButton: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,29 +7,9 @@ import { useMeetingAudioConsent } from "../apiHooks";
|
||||
import { ConsentDialog } from "./ConsentDialog";
|
||||
import { TOAST_CHECK_INTERVAL_MS } from "./constants";
|
||||
import type { ConsentDialogResult } from "./types";
|
||||
import { MeetingId } from "../types";
|
||||
import { recordingTypeRequiresConsent } from "./utils";
|
||||
import type { components } from "../../reflector-api";
|
||||
|
||||
type Meeting = components["schemas"]["Meeting"];
|
||||
|
||||
type UseConsentDialogParams = {
|
||||
meetingId: MeetingId;
|
||||
recordingType: Meeting["recording_type"];
|
||||
skipConsent: boolean;
|
||||
};
|
||||
|
||||
export function useConsentDialog({
|
||||
meetingId,
|
||||
recordingType,
|
||||
skipConsent,
|
||||
}: UseConsentDialogParams): ConsentDialogResult {
|
||||
const {
|
||||
state: consentState,
|
||||
touch,
|
||||
hasAnswered,
|
||||
hasAccepted,
|
||||
} = useRecordingConsent();
|
||||
export function useConsentDialog(meetingId: string): ConsentDialogResult {
|
||||
const { state: consentState, touch, hasConsent } = useRecordingConsent();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const audioConsentMutation = useMeetingAudioConsent();
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -62,7 +42,7 @@ export function useConsentDialog({
|
||||
},
|
||||
});
|
||||
|
||||
touch(meetingId, given);
|
||||
touch(meetingId);
|
||||
} catch (error) {
|
||||
console.error("Error submitting consent:", error);
|
||||
}
|
||||
@@ -120,23 +100,10 @@ export function useConsentDialog({
|
||||
});
|
||||
}, [handleConsent, modalOpen]);
|
||||
|
||||
const requiresConsent = Boolean(
|
||||
recordingType && recordingTypeRequiresConsent(recordingType),
|
||||
);
|
||||
|
||||
const showRecordingIndicator =
|
||||
requiresConsent && (skipConsent || hasAccepted(meetingId));
|
||||
|
||||
const showConsentButton =
|
||||
requiresConsent && !skipConsent && !hasAnswered(meetingId);
|
||||
|
||||
return {
|
||||
showConsentModal,
|
||||
consentState,
|
||||
hasAnswered,
|
||||
hasAccepted,
|
||||
hasConsent,
|
||||
consentLoading: audioConsentMutation.isPending,
|
||||
showRecordingIndicator,
|
||||
showConsentButton,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { Session } from "next-auth";
|
||||
import type { JWT } from "next-auth/jwt";
|
||||
import {
|
||||
assertExistsAndNonEmptyString,
|
||||
NonEmptyString,
|
||||
parseMaybeNonEmptyString,
|
||||
} from "./utils";
|
||||
import { parseMaybeNonEmptyString } from "./utils";
|
||||
|
||||
export interface JWTWithAccessToken extends JWT {
|
||||
accessToken: string;
|
||||
@@ -82,10 +78,3 @@ export const assertCustomSession = <T extends Session>(
|
||||
export type Mutable<T> = {
|
||||
-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,22 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||
import { MeetingId } from "./lib/types";
|
||||
|
||||
type ConsentMap = Map<MeetingId, boolean>;
|
||||
|
||||
type ConsentContextState =
|
||||
| { ready: false }
|
||||
| {
|
||||
ready: true;
|
||||
consentForMeetings: ConsentMap;
|
||||
consentAnsweredForMeetings: Set<string>;
|
||||
};
|
||||
|
||||
interface RecordingConsentContextValue {
|
||||
state: ConsentContextState;
|
||||
touch: (meetingId: MeetingId, accepted: boolean) => void;
|
||||
hasAnswered: (meetingId: MeetingId) => boolean;
|
||||
hasAccepted: (meetingId: MeetingId) => boolean;
|
||||
touch: (meetingId: string) => void;
|
||||
hasConsent: (meetingId: string) => boolean;
|
||||
}
|
||||
|
||||
const RecordingConsentContext = createContext<
|
||||
@@ -39,116 +35,81 @@ interface RecordingConsentProviderProps {
|
||||
|
||||
const LOCAL_STORAGE_KEY = "recording_consent_meetings";
|
||||
|
||||
const ACCEPTED = "T" as const;
|
||||
type Accepted = typeof ACCEPTED;
|
||||
const REJECTED = "F" as const;
|
||||
type Rejected = typeof REJECTED;
|
||||
type Consent = Accepted | Rejected;
|
||||
const SEPARATOR = "|" as const;
|
||||
type Separator = typeof SEPARATOR;
|
||||
const DEFAULT_CONSENT = ACCEPTED;
|
||||
type Entry = `${MeetingId}${Separator}${Consent}`;
|
||||
type EntryAndDefault = Entry | MeetingId;
|
||||
|
||||
// Format: "meetingId|T" or "meetingId|F", legacy format "meetingId" is treated as accepted
|
||||
const encodeEntry = (meetingId: MeetingId, accepted: boolean): Entry =>
|
||||
`${meetingId}|${accepted ? ACCEPTED : REJECTED}`;
|
||||
|
||||
const decodeEntry = (
|
||||
entry: EntryAndDefault,
|
||||
): { meetingId: MeetingId; accepted: boolean } | null => {
|
||||
const pipeIndex = entry.lastIndexOf(SEPARATOR);
|
||||
if (pipeIndex === -1) {
|
||||
// Legacy format: no pipe means accepted (backward compat)
|
||||
return { meetingId: entry as MeetingId, accepted: true };
|
||||
}
|
||||
const suffix = entry.slice(pipeIndex + 1);
|
||||
const meetingId = entry.slice(0, pipeIndex) as MeetingId;
|
||||
// T = accepted, F = rejected, anything else = accepted (safe default)
|
||||
const accepted = suffix !== REJECTED;
|
||||
return { meetingId, accepted };
|
||||
};
|
||||
|
||||
export const RecordingConsentProvider: React.FC<
|
||||
RecordingConsentProviderProps
|
||||
> = ({ children }) => {
|
||||
const [state, setState] = useState<ConsentContextState>({ ready: false });
|
||||
|
||||
const safeWriteToStorage = (consentMap: ConsentMap): void => {
|
||||
const safeWriteToStorage = (meetingIds: string[]): void => {
|
||||
try {
|
||||
if (typeof window !== "undefined" && window.localStorage) {
|
||||
const entries = Array.from(consentMap.entries())
|
||||
.slice(-5)
|
||||
.map(([id, accepted]) => encodeEntry(id, accepted));
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(entries));
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(meetingIds));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save consent data to localStorage:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const touch = (meetingId: MeetingId, accepted: boolean): void => {
|
||||
// writes to local storage and to the state of context both
|
||||
const touch = (meetingId: string): void => {
|
||||
if (!state.ready) {
|
||||
console.warn("Attempted to touch consent before context is ready");
|
||||
return;
|
||||
}
|
||||
|
||||
const newMap = new Map(state.consentForMeetings);
|
||||
newMap.set(meetingId, accepted);
|
||||
safeWriteToStorage(newMap);
|
||||
setState({ ready: true, consentForMeetings: newMap });
|
||||
// has success regardless local storage write success: we don't handle that
|
||||
// and don't want to crash anything with just consent functionality
|
||||
const newSet = state.consentAnsweredForMeetings.has(meetingId)
|
||||
? state.consentAnsweredForMeetings
|
||||
: new Set([...state.consentAnsweredForMeetings, meetingId]);
|
||||
// note: preserves the set insertion order
|
||||
const array = Array.from(newSet).slice(-5); // Keep latest 5
|
||||
safeWriteToStorage(array);
|
||||
setState({ ready: true, consentAnsweredForMeetings: newSet });
|
||||
};
|
||||
|
||||
const hasAnswered = (meetingId: MeetingId): boolean => {
|
||||
const hasConsent = (meetingId: string): boolean => {
|
||||
if (!state.ready) return false;
|
||||
return state.consentForMeetings.has(meetingId);
|
||||
};
|
||||
|
||||
const hasAccepted = (meetingId: MeetingId): boolean => {
|
||||
if (!state.ready) return false;
|
||||
return state.consentForMeetings.get(meetingId) === true;
|
||||
return state.consentAnsweredForMeetings.has(meetingId);
|
||||
};
|
||||
|
||||
// initialize on mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (typeof window === "undefined" || !window.localStorage) {
|
||||
setState({ ready: true, consentForMeetings: new Map() });
|
||||
setState({ ready: true, consentAnsweredForMeetings: new Set() });
|
||||
return;
|
||||
}
|
||||
|
||||
const stored = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||
if (!stored) {
|
||||
setState({ ready: true, consentForMeetings: new Map() });
|
||||
setState({ ready: true, consentAnsweredForMeetings: new Set() });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(stored);
|
||||
if (!Array.isArray(parsed)) {
|
||||
console.warn("Invalid consent data format in localStorage, resetting");
|
||||
setState({ ready: true, consentForMeetings: new Map() });
|
||||
setState({ ready: true, consentAnsweredForMeetings: new Set() });
|
||||
return;
|
||||
}
|
||||
|
||||
const consentForMeetings = new Map<MeetingId, boolean>();
|
||||
for (const entry of parsed) {
|
||||
const decoded = decodeEntry(entry);
|
||||
if (decoded) {
|
||||
consentForMeetings.set(decoded.meetingId, decoded.accepted);
|
||||
}
|
||||
}
|
||||
setState({ ready: true, consentForMeetings });
|
||||
// pre-historic way of parsing!
|
||||
const consentAnsweredForMeetings = new Set(
|
||||
parsed.filter((id) => !!id && typeof id === "string"),
|
||||
);
|
||||
setState({ ready: true, consentAnsweredForMeetings });
|
||||
} catch (error) {
|
||||
// we don't want to fail the page here; the component is not essential.
|
||||
console.error("Failed to parse consent data from localStorage:", error);
|
||||
setState({ ready: true, consentForMeetings: new Map() });
|
||||
setState({ ready: true, consentAnsweredForMeetings: new Set() });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value: RecordingConsentContextValue = {
|
||||
state,
|
||||
touch,
|
||||
hasAnswered,
|
||||
hasAccepted,
|
||||
hasConsent,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
64
www/app/reflector-api.d.ts
vendored
64
www/app/reflector-api.d.ts
vendored
@@ -893,16 +893,8 @@ export interface components {
|
||||
* @default false
|
||||
*/
|
||||
ics_enabled: boolean;
|
||||
/**
|
||||
* Platform
|
||||
* @enum {string}
|
||||
*/
|
||||
platform: "whereby" | "daily";
|
||||
/**
|
||||
* Skip Consent
|
||||
* @default false
|
||||
*/
|
||||
skip_consent: boolean;
|
||||
/** Platform */
|
||||
platform?: ("whereby" | "daily") | null;
|
||||
};
|
||||
/** CreateRoomMeeting */
|
||||
CreateRoomMeeting: {
|
||||
@@ -1131,9 +1123,7 @@ export interface components {
|
||||
/** Audio Deleted */
|
||||
audio_deleted?: boolean | null;
|
||||
/** Participants */
|
||||
participants:
|
||||
| components["schemas"]["TranscriptParticipantWithEmail"][]
|
||||
| null;
|
||||
participants: components["schemas"]["TranscriptParticipant"][] | null;
|
||||
/**
|
||||
* @description discriminator enum property added by openapi-typescript
|
||||
* @enum {string}
|
||||
@@ -1194,9 +1184,7 @@ export interface components {
|
||||
/** Audio Deleted */
|
||||
audio_deleted?: boolean | null;
|
||||
/** Participants */
|
||||
participants:
|
||||
| components["schemas"]["TranscriptParticipantWithEmail"][]
|
||||
| null;
|
||||
participants: components["schemas"]["TranscriptParticipant"][] | null;
|
||||
};
|
||||
/**
|
||||
* GetTranscriptWithText
|
||||
@@ -1258,9 +1246,7 @@ export interface components {
|
||||
/** Audio Deleted */
|
||||
audio_deleted?: boolean | null;
|
||||
/** Participants */
|
||||
participants:
|
||||
| components["schemas"]["TranscriptParticipantWithEmail"][]
|
||||
| null;
|
||||
participants: components["schemas"]["TranscriptParticipant"][] | null;
|
||||
/**
|
||||
* @description discriminator enum property added by openapi-typescript
|
||||
* @enum {string}
|
||||
@@ -1329,9 +1315,7 @@ export interface components {
|
||||
/** Audio Deleted */
|
||||
audio_deleted?: boolean | null;
|
||||
/** Participants */
|
||||
participants:
|
||||
| components["schemas"]["TranscriptParticipantWithEmail"][]
|
||||
| null;
|
||||
participants: components["schemas"]["TranscriptParticipant"][] | null;
|
||||
/**
|
||||
* @description discriminator enum property added by openapi-typescript
|
||||
* @enum {string}
|
||||
@@ -1402,9 +1386,7 @@ export interface components {
|
||||
/** Audio Deleted */
|
||||
audio_deleted?: boolean | null;
|
||||
/** Participants */
|
||||
participants:
|
||||
| components["schemas"]["TranscriptParticipantWithEmail"][]
|
||||
| null;
|
||||
participants: components["schemas"]["TranscriptParticipant"][] | null;
|
||||
/**
|
||||
* @description discriminator enum property added by openapi-typescript
|
||||
* @enum {string}
|
||||
@@ -1585,11 +1567,6 @@ export interface components {
|
||||
/** Name */
|
||||
name: string;
|
||||
};
|
||||
/** ProcessStatus */
|
||||
ProcessStatus: {
|
||||
/** Status */
|
||||
status: string;
|
||||
};
|
||||
/** Room */
|
||||
Room: {
|
||||
/** Id */
|
||||
@@ -1640,11 +1617,6 @@ export interface components {
|
||||
* @enum {string}
|
||||
*/
|
||||
platform: "whereby" | "daily";
|
||||
/**
|
||||
* Skip Consent
|
||||
* @default false
|
||||
*/
|
||||
skip_consent: boolean;
|
||||
};
|
||||
/** RoomDetails */
|
||||
RoomDetails: {
|
||||
@@ -1696,11 +1668,6 @@ export interface components {
|
||||
* @enum {string}
|
||||
*/
|
||||
platform: "whereby" | "daily";
|
||||
/**
|
||||
* Skip Consent
|
||||
* @default false
|
||||
*/
|
||||
skip_consent: boolean;
|
||||
/** Webhook Url */
|
||||
webhook_url: string | null;
|
||||
/** Webhook Secret */
|
||||
@@ -1846,19 +1813,6 @@ export interface components {
|
||||
/** User Id */
|
||||
user_id?: string | null;
|
||||
};
|
||||
/** TranscriptParticipantWithEmail */
|
||||
TranscriptParticipantWithEmail: {
|
||||
/** Id */
|
||||
id?: string;
|
||||
/** Speaker */
|
||||
speaker: number | null;
|
||||
/** Name */
|
||||
name: string;
|
||||
/** User Id */
|
||||
user_id?: string | null;
|
||||
/** Email */
|
||||
email?: string | null;
|
||||
};
|
||||
/**
|
||||
* TranscriptSegment
|
||||
* @description A single transcript segment with speaker and timing information.
|
||||
@@ -1914,8 +1868,6 @@ export interface components {
|
||||
ics_enabled?: boolean | null;
|
||||
/** Platform */
|
||||
platform?: ("whereby" | "daily") | null;
|
||||
/** Skip Consent */
|
||||
skip_consent?: boolean | null;
|
||||
};
|
||||
/** UpdateTranscript */
|
||||
UpdateTranscript: {
|
||||
@@ -3410,7 +3362,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProcessStatus"];
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 131 B |
Reference in New Issue
Block a user