mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-21 04:39:06 +00:00
Compare commits
2 Commits
feat/conse
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f0ee7b531a | |||
| 37a454f283 |
15
CHANGELOG.md
15
CHANGELOG.md
@@ -1,5 +1,20 @@
|
|||||||
# Changelog
|
# 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)
|
## [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,
|
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"),
|
||||||
)
|
)
|
||||||
@@ -91,7 +85,6 @@ 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:
|
||||||
@@ -146,7 +139,6 @@ 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
|
||||||
@@ -171,7 +163,6 @@ 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(f"Transcript {transcript_id} not found")
|
raise Exception("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,7 +44,6 @@ 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):
|
||||||
@@ -91,7 +90,6 @@ 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):
|
||||||
@@ -110,7 +108,6 @@ 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):
|
||||||
@@ -252,7 +249,6 @@ 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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -571,17 +567,10 @@ 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=enable_recording_ui,
|
enable_recording_ui=meeting.recording_type == "local",
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
is_owner=user_id == room.user_id,
|
is_owner=user_id == room.user_id,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,12 +7,6 @@ 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 = bool(
|
has_active_sessions = room_sessions and any(
|
||||||
room_sessions and any(s.ended_at is None for s in room_sessions)
|
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"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:
|
if has_active_sessions:
|
||||||
|
|||||||
@@ -91,7 +91,6 @@ const roomInitialState = {
|
|||||||
icsEnabled: false,
|
icsEnabled: false,
|
||||||
icsFetchInterval: 5,
|
icsFetchInterval: 5,
|
||||||
platform: "whereby",
|
platform: "whereby",
|
||||||
skipConsent: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RoomsList() {
|
export default function RoomsList() {
|
||||||
@@ -176,7 +175,6 @@ 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],
|
||||||
@@ -328,7 +326,6 @@ 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) {
|
||||||
@@ -391,7 +388,6 @@ 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);
|
||||||
@@ -800,34 +796,6 @@ 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}>
|
||||||
|
|||||||
@@ -2,13 +2,19 @@
|
|||||||
|
|
||||||
import { Spinner, Link } from "@chakra-ui/react";
|
import { Spinner, Link } from "@chakra-ui/react";
|
||||||
import { useAuth } from "../lib/AuthProvider";
|
import { useAuth } from "../lib/AuthProvider";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { getLogoutRedirectUrl } from "../lib/auth";
|
||||||
|
|
||||||
export default function UserInfo() {
|
export default function UserInfo() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
const pathname = usePathname();
|
||||||
const status = auth.status;
|
const status = auth.status;
|
||||||
const isLoading = status === "loading";
|
const isLoading = status === "loading";
|
||||||
const isAuthenticated = status === "authenticated";
|
const isAuthenticated = status === "authenticated";
|
||||||
const isRefreshing = status === "refreshing";
|
const isRefreshing = status === "refreshing";
|
||||||
|
|
||||||
|
const callbackUrl = getLogoutRedirectUrl(pathname);
|
||||||
|
|
||||||
return isLoading ? (
|
return isLoading ? (
|
||||||
<Spinner size="xs" className="mx-3" />
|
<Spinner size="xs" className="mx-3" />
|
||||||
) : !isAuthenticated && !isRefreshing ? (
|
) : !isAuthenticated && !isRefreshing ? (
|
||||||
@@ -26,7 +32,7 @@ export default function UserInfo() {
|
|||||||
<Link
|
<Link
|
||||||
href="#"
|
href="#"
|
||||||
className="font-light px-2"
|
className="font-light px-2"
|
||||||
onClick={() => auth.signOut({ callbackUrl: "/" })}
|
onClick={() => auth.signOut({ callbackUrl })}
|
||||||
>
|
>
|
||||||
Log out
|
Log out
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ 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"];
|
||||||
|
|
||||||
@@ -99,7 +98,7 @@ export default function MeetingSelection({
|
|||||||
onMeetingSelect(meeting);
|
onMeetingSelect(meeting);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEndMeeting = async (meetingId: MeetingId) => {
|
const handleEndMeeting = async (meetingId: string) => {
|
||||||
try {
|
try {
|
||||||
await deactivateMeetingMutation.mutateAsync({
|
await deactivateMeetingMutation.mutateAsync({
|
||||||
params: {
|
params: {
|
||||||
|
|||||||
@@ -1,194 +1,35 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
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, {
|
import DailyIframe, { DailyCall } from "@daily-co/daily-js";
|
||||||
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 { useConsentDialog } from "../../lib/consent";
|
import {
|
||||||
|
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"];
|
|
||||||
|
|
||||||
type DailyRoomProps = {
|
interface DailyRoomProps {
|
||||||
meeting: Meeting;
|
meeting: Meeting;
|
||||||
room: Room;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const useCustomTrayButtons = (
|
export default function DailyRoom({ meeting }: DailyRoomProps) {
|
||||||
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 [container, setContainer] = useState<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement>(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;
|
||||||
|
|
||||||
@@ -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]);
|
}, [meeting?.id, roomName, authLastUserId]);
|
||||||
|
|
||||||
const roomUrl = joinedMeeting?.room_url;
|
const roomUrl = joinedMeeting?.room_url;
|
||||||
@@ -217,86 +58,84 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) {
|
|||||||
router.push("/browse");
|
router.push("/browse");
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const handleCustomButtonClick = useCallback(
|
useEffect(() => {
|
||||||
(ev: DailyEventObjectCustomButtonClick) => {
|
if (authLastUserId === undefined || !roomUrl || !containerRef.current)
|
||||||
if (ev.button_id === CONSENT_BUTTON_ID) {
|
return;
|
||||||
showConsentModalRef.current();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
/*keep static; iframe recreation depends on it*/
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleFrameJoinMeeting = useCallback(
|
let frame: DailyCall | null = null;
|
||||||
(startRecording: (args: { type: "raw-tracks" }) => void) => {
|
let destroyed = false;
|
||||||
|
|
||||||
|
const createAndJoin = async () => {
|
||||||
try {
|
try {
|
||||||
if (meeting.recording_type === "cloud") {
|
const existingFrame = DailyIframe.getCallInstance();
|
||||||
console.log("Starting cloud recording");
|
if (existingFrame) {
|
||||||
startRecording({ type: "raw-tracks" });
|
await existingFrame.destroy();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to start recording:", error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[meeting.recording_type],
|
|
||||||
);
|
|
||||||
|
|
||||||
const recordingIconUrl = useMemo(
|
frame = DailyIframe.createFrame(containerRef.current!, {
|
||||||
() => new URL("/recording-icon.svg", window.location.origin),
|
iframeStyle: {
|
||||||
[],
|
width: "100vw",
|
||||||
);
|
height: "100vh",
|
||||||
|
border: "none",
|
||||||
const [frame, { setCustomTrayButton }] = useFrame(container, {
|
|
||||||
onLeftMeeting: handleLeave,
|
|
||||||
onCustomButtonClick: handleCustomButtonClick,
|
|
||||||
onJoinMeeting: handleFrameJoinMeeting,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!frame || !roomUrl) return;
|
|
||||||
frame
|
|
||||||
.join({
|
|
||||||
url: roomUrl,
|
|
||||||
sendSettings: {
|
|
||||||
video: {
|
|
||||||
// Optimize bandwidth for camera video
|
|
||||||
// allowAdaptiveLayers automatically adjusts quality based on network conditions
|
|
||||||
allowAdaptiveLayers: true,
|
|
||||||
// Use bandwidth-optimized preset as fallback for browsers without adaptive support
|
|
||||||
maxQuality: "medium",
|
|
||||||
},
|
},
|
||||||
// Note: screenVideo intentionally not configured to preserve full quality for screen shares
|
showLeaveButton: true,
|
||||||
},
|
showFullscreenButton: true,
|
||||||
})
|
});
|
||||||
.catch(console.error.bind(console, "Failed to join daily room:"));
|
|
||||||
}, [frame, roomUrl]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (destroyed) {
|
||||||
setCustomTrayButton(
|
await frame.destroy();
|
||||||
RECORDING_INDICATOR_ID,
|
return;
|
||||||
showRecordingInTray
|
}
|
||||||
? {
|
|
||||||
iconPath: recordingIconUrl.href,
|
|
||||||
label: "Recording",
|
|
||||||
tooltip: "Recording in progress",
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
}, [showRecordingInTray, recordingIconUrl, setCustomTrayButton]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
frame.on("left-meeting", handleLeave);
|
||||||
setCustomTrayButton(
|
|
||||||
CONSENT_BUTTON_ID,
|
frame.on("joined-meeting", async () => {
|
||||||
showConsentButton
|
try {
|
||||||
? {
|
const frameInstance = assertExists(
|
||||||
iconPath: recordingIconUrl.href,
|
frame,
|
||||||
label: "Recording (click to consent)",
|
"frame object got lost somewhere after frame.on was called",
|
||||||
tooltip: "Recording (click to consent)",
|
);
|
||||||
|
|
||||||
|
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) {
|
if (authLastUserId === undefined) {
|
||||||
return (
|
return (
|
||||||
@@ -320,7 +159,10 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box position="relative" width="100vw" height="100vh">
|
<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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ 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"];
|
||||||
|
|
||||||
@@ -68,10 +67,7 @@ export default function RoomContainer(details: RoomDetails) {
|
|||||||
room && !room.ics_enabled && !pageMeetingId ? roomName : null,
|
room && !room.ics_enabled && !pageMeetingId ? roomName : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const explicitMeeting = useRoomGetMeeting(
|
const explicitMeeting = useRoomGetMeeting(roomName, pageMeetingId || null);
|
||||||
roomName,
|
|
||||||
pageMeetingId ? assertMeetingId(pageMeetingId) : null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const meeting = explicitMeeting.data || defaultMeeting.response;
|
const meeting = explicitMeeting.data || defaultMeeting.response;
|
||||||
|
|
||||||
@@ -196,9 +192,9 @@ export default function RoomContainer(details: RoomDetails) {
|
|||||||
|
|
||||||
switch (platform) {
|
switch (platform) {
|
||||||
case "daily":
|
case "daily":
|
||||||
return <DailyRoom meeting={meeting} room={room} />;
|
return <DailyRoom meeting={meeting} />;
|
||||||
case "whereby":
|
case "whereby":
|
||||||
return <WherebyRoom meeting={meeting} room={room} />;
|
return <WherebyRoom meeting={meeting} />;
|
||||||
default: {
|
default: {
|
||||||
const _exhaustive: never = platform;
|
const _exhaustive: never = platform;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,29 +5,24 @@ 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: MeetingId;
|
meetingId: NonEmptyString;
|
||||||
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);
|
||||||
@@ -50,16 +45,10 @@ function WherebyConsentDialogButton({
|
|||||||
};
|
};
|
||||||
}, [wherebyRef]);
|
}, [wherebyRef]);
|
||||||
|
|
||||||
return (
|
return <BaseConsentDialogButton meetingId={meetingId} />;
|
||||||
<BaseConsentDialogButton
|
|
||||||
meetingId={meetingId}
|
|
||||||
recordingType={recordingType}
|
|
||||||
skipConsent={skipConsent}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WherebyRoom({ meeting, room }: WherebyRoomProps) {
|
export default function WherebyRoom({ meeting }: WherebyRoomProps) {
|
||||||
const wherebyLoaded = useWhereby();
|
const wherebyLoaded = useWhereby();
|
||||||
const wherebyRef = useRef<HTMLElement>(null);
|
const wherebyRef = useRef<HTMLElement>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -68,14 +57,9 @@ export default function WherebyRoom({ meeting, room }: 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(() => {
|
||||||
@@ -104,14 +88,14 @@ export default function WherebyRoom({ meeting, room }: WherebyRoomProps) {
|
|||||||
room={wherebyRoomUrl}
|
room={wherebyRoomUrl}
|
||||||
style={{ width: "100vw", height: "100vh" }}
|
style={{ width: "100vw", height: "100vh" }}
|
||||||
/>
|
/>
|
||||||
{showConsentButton && (
|
{recordingType &&
|
||||||
<WherebyConsentDialogButton
|
recordingTypeRequiresConsent(recordingType) &&
|
||||||
meetingId={assertMeetingId(meetingId)}
|
meetingId && (
|
||||||
recordingType={meeting.recording_type}
|
<WherebyConsentDialogButton
|
||||||
skipConsent={room.skip_consent}
|
meetingId={assertExistsAndNonEmptyString(meetingId)}
|
||||||
wherebyRef={wherebyRef}
|
wherebyRef={wherebyRef}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
useContext,
|
||||||
RefObject,
|
RefObject,
|
||||||
use,
|
use,
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -24,6 +25,8 @@ import { useRecordingConsent } from "../recordingConsentContext";
|
|||||||
import {
|
import {
|
||||||
useMeetingAudioConsent,
|
useMeetingAudioConsent,
|
||||||
useRoomGetByName,
|
useRoomGetByName,
|
||||||
|
useRoomActiveMeetings,
|
||||||
|
useRoomUpcomingMeetings,
|
||||||
useRoomsCreateMeeting,
|
useRoomsCreateMeeting,
|
||||||
useRoomGetMeeting,
|
useRoomGetMeeting,
|
||||||
} from "../lib/apiHooks";
|
} from "../lib/apiHooks";
|
||||||
@@ -36,9 +39,12 @@ 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 { parseNonEmptyString } from "../lib/utils";
|
import {
|
||||||
|
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<{
|
||||||
@@ -86,16 +92,16 @@ const useConsentWherebyFocusManagement = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const useConsentDialog = (
|
const useConsentDialog = (
|
||||||
meetingId: MeetingId,
|
meetingId: string,
|
||||||
wherebyRef: RefObject<HTMLElement> /*accessibility*/,
|
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
|
// 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: MeetingId, given: boolean) => {
|
async (meetingId: string, given: boolean) => {
|
||||||
try {
|
try {
|
||||||
await audioConsentMutation.mutateAsync({
|
await audioConsentMutation.mutateAsync({
|
||||||
params: {
|
params: {
|
||||||
@@ -108,7 +114,7 @@ const useConsentDialog = (
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
touch(meetingId, given);
|
touch(meetingId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error submitting consent:", error);
|
console.error("Error submitting consent:", error);
|
||||||
}
|
}
|
||||||
@@ -210,7 +216,7 @@ const useConsentDialog = (
|
|||||||
return {
|
return {
|
||||||
showConsentModal,
|
showConsentModal,
|
||||||
consentState,
|
consentState,
|
||||||
hasAnswered,
|
hasConsent,
|
||||||
consentLoading: audioConsentMutation.isPending,
|
consentLoading: audioConsentMutation.isPending,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -219,13 +225,13 @@ function ConsentDialogButton({
|
|||||||
meetingId,
|
meetingId,
|
||||||
wherebyRef,
|
wherebyRef,
|
||||||
}: {
|
}: {
|
||||||
meetingId: MeetingId;
|
meetingId: NonEmptyString;
|
||||||
wherebyRef: React.RefObject<HTMLElement>;
|
wherebyRef: React.RefObject<HTMLElement>;
|
||||||
}) {
|
}) {
|
||||||
const { showConsentModal, consentState, hasAnswered, consentLoading } =
|
const { showConsentModal, consentState, hasConsent, consentLoading } =
|
||||||
useConsentDialog(meetingId, wherebyRef);
|
useConsentDialog(meetingId, wherebyRef);
|
||||||
|
|
||||||
if (!consentState.ready || hasAnswered(meetingId) || consentLoading) {
|
if (!consentState.ready || hasConsent(meetingId) || consentLoading) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,10 +284,7 @@ export default function Room(details: RoomDetails) {
|
|||||||
room && !room.ics_enabled && !pageMeetingId ? roomName : null,
|
room && !room.ics_enabled && !pageMeetingId ? roomName : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const explicitMeeting = useRoomGetMeeting(
|
const explicitMeeting = useRoomGetMeeting(roomName, pageMeetingId || null);
|
||||||
roomName,
|
|
||||||
pageMeetingId ? assertMeetingId(pageMeetingId) : null,
|
|
||||||
);
|
|
||||||
const wherebyRoomUrl = explicitMeeting.data
|
const wherebyRoomUrl = explicitMeeting.data
|
||||||
? getWherebyUrl(explicitMeeting.data)
|
? getWherebyUrl(explicitMeeting.data)
|
||||||
: defaultMeeting.response
|
: defaultMeeting.response
|
||||||
@@ -434,7 +437,7 @@ export default function Room(details: RoomDetails) {
|
|||||||
recordingTypeRequiresConsent(recordingType) &&
|
recordingTypeRequiresConsent(recordingType) &&
|
||||||
meetingId && (
|
meetingId && (
|
||||||
<ConsentDialogButton
|
<ConsentDialogButton
|
||||||
meetingId={assertMeetingId(meetingId)}
|
meetingId={assertExistsAndNonEmptyString(meetingId)}
|
||||||
wherebyRef={wherebyRef}
|
wherebyRef={wherebyRef}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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
|
||||||
@@ -719,7 +718,7 @@ export function useRoomActiveMeetings(roomName: string | null) {
|
|||||||
|
|
||||||
export function useRoomGetMeeting(
|
export function useRoomGetMeeting(
|
||||||
roomName: string | null,
|
roomName: string | null,
|
||||||
meetingId: MeetingId | null,
|
meetingId: string | null,
|
||||||
) {
|
) {
|
||||||
return $api.useQuery(
|
return $api.useQuery(
|
||||||
"get",
|
"get",
|
||||||
|
|||||||
@@ -18,3 +18,8 @@ export const LOGIN_REQUIRED_PAGES = [
|
|||||||
export const PROTECTED_PAGES = new RegExp(
|
export const PROTECTED_PAGES = new RegExp(
|
||||||
LOGIN_REQUIRED_PAGES.map((page) => `^${page}$`).join("|"),
|
LOGIN_REQUIRED_PAGES.map((page) => `^${page}$`).join("|"),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export function getLogoutRedirectUrl(pathname: string): string {
|
||||||
|
const transcriptPagePattern = /^\/transcripts\/[^/]+$/;
|
||||||
|
return transcriptPagePattern.test(pathname) ? pathname : "/";
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"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";
|
||||||
|
|
||||||
@@ -10,15 +9,6 @@ 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}
|
||||||
@@ -36,12 +26,7 @@ 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
|
<Button colorPalette="primary" size="sm" onClick={onAccept}>
|
||||||
ref={setAcceptButton}
|
|
||||||
colorPalette="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={onAccept}
|
|
||||||
>
|
|
||||||
{CONSENT_DIALOG_TEXT.acceptButton}
|
{CONSENT_DIALOG_TEXT.acceptButton}
|
||||||
</Button>
|
</Button>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|||||||
@@ -9,26 +9,16 @@ 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";
|
|
||||||
|
|
||||||
type Meeting = components["schemas"]["Meeting"];
|
interface ConsentDialogButtonProps {
|
||||||
|
meetingId: string;
|
||||||
|
}
|
||||||
|
|
||||||
type ConsentDialogButtonProps = {
|
export function ConsentDialogButton({ meetingId }: ConsentDialogButtonProps) {
|
||||||
meetingId: MeetingId;
|
const { showConsentModal, consentState, hasConsent, consentLoading } =
|
||||||
recordingType: Meeting["recording_type"];
|
useConsentDialog(meetingId);
|
||||||
skipConsent: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ConsentDialogButton({
|
if (!consentState.ready || hasConsent(meetingId) || consentLoading) {
|
||||||
meetingId,
|
|
||||||
recordingType,
|
|
||||||
skipConsent,
|
|
||||||
}: ConsentDialogButtonProps) {
|
|
||||||
const { showConsentModal, consentState, showConsentButton, consentLoading } =
|
|
||||||
useConsentDialog({ meetingId, recordingType, skipConsent });
|
|
||||||
|
|
||||||
if (!consentState.ready || !showConsentButton || consentLoading) {
|
|
||||||
return null;
|
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 { 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,14 +1,9 @@
|
|||||||
import { MeetingId } from "../types";
|
export interface ConsentDialogResult {
|
||||||
|
|
||||||
export type ConsentDialogResult = {
|
|
||||||
showConsentModal: () => void;
|
showConsentModal: () => void;
|
||||||
consentState: {
|
consentState: {
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
consentForMeetings?: Map<MeetingId, boolean>;
|
consentAnsweredForMeetings?: Set<string>;
|
||||||
};
|
};
|
||||||
hasAnswered: (meetingId: MeetingId) => boolean;
|
hasConsent: (meetingId: string) => boolean;
|
||||||
hasAccepted: (meetingId: MeetingId) => boolean;
|
|
||||||
consentLoading: boolean;
|
consentLoading: boolean;
|
||||||
showRecordingIndicator: boolean;
|
}
|
||||||
showConsentButton: boolean;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -7,29 +7,9 @@ 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";
|
|
||||||
|
|
||||||
type Meeting = components["schemas"]["Meeting"];
|
export function useConsentDialog(meetingId: string): ConsentDialogResult {
|
||||||
|
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);
|
||||||
@@ -62,7 +42,7 @@ export function useConsentDialog({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
touch(meetingId, given);
|
touch(meetingId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error submitting consent:", error);
|
console.error("Error submitting consent:", error);
|
||||||
}
|
}
|
||||||
@@ -120,23 +100,10 @@ export function useConsentDialog({
|
|||||||
});
|
});
|
||||||
}, [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,
|
||||||
hasAnswered,
|
hasConsent,
|
||||||
hasAccepted,
|
|
||||||
consentLoading: audioConsentMutation.isPending,
|
consentLoading: audioConsentMutation.isPending,
|
||||||
showRecordingIndicator,
|
|
||||||
showConsentButton,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
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 {
|
import { parseMaybeNonEmptyString } from "./utils";
|
||||||
assertExistsAndNonEmptyString,
|
|
||||||
NonEmptyString,
|
|
||||||
parseMaybeNonEmptyString,
|
|
||||||
} from "./utils";
|
|
||||||
|
|
||||||
export interface JWTWithAccessToken extends JWT {
|
export interface JWTWithAccessToken extends JWT {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
@@ -82,10 +78,3 @@ 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,22 +1,18 @@
|
|||||||
"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;
|
||||||
consentForMeetings: ConsentMap;
|
consentAnsweredForMeetings: Set<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface RecordingConsentContextValue {
|
interface RecordingConsentContextValue {
|
||||||
state: ConsentContextState;
|
state: ConsentContextState;
|
||||||
touch: (meetingId: MeetingId, accepted: boolean) => void;
|
touch: (meetingId: string) => void;
|
||||||
hasAnswered: (meetingId: MeetingId) => boolean;
|
hasConsent: (meetingId: string) => boolean;
|
||||||
hasAccepted: (meetingId: MeetingId) => boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const RecordingConsentContext = createContext<
|
const RecordingConsentContext = createContext<
|
||||||
@@ -39,116 +35,81 @@ 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 = (consentMap: ConsentMap): void => {
|
const safeWriteToStorage = (meetingIds: string[]): void => {
|
||||||
try {
|
try {
|
||||||
if (typeof window !== "undefined" && window.localStorage) {
|
if (typeof window !== "undefined" && window.localStorage) {
|
||||||
const entries = Array.from(consentMap.entries())
|
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(meetingIds));
|
||||||
.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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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) {
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newMap = new Map(state.consentForMeetings);
|
// has success regardless local storage write success: we don't handle that
|
||||||
newMap.set(meetingId, accepted);
|
// and don't want to crash anything with just consent functionality
|
||||||
safeWriteToStorage(newMap);
|
const newSet = state.consentAnsweredForMeetings.has(meetingId)
|
||||||
setState({ ready: true, consentForMeetings: newMap });
|
? 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;
|
if (!state.ready) return false;
|
||||||
return state.consentForMeetings.has(meetingId);
|
return state.consentAnsweredForMeetings.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, consentForMeetings: new Map() });
|
setState({ ready: true, consentAnsweredForMeetings: new Set() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stored = localStorage.getItem(LOCAL_STORAGE_KEY);
|
const stored = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||||
if (!stored) {
|
if (!stored) {
|
||||||
setState({ ready: true, consentForMeetings: new Map() });
|
setState({ ready: true, consentAnsweredForMeetings: new Set() });
|
||||||
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, consentForMeetings: new Map() });
|
setState({ ready: true, consentAnsweredForMeetings: new Set() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const consentForMeetings = new Map<MeetingId, boolean>();
|
// pre-historic way of parsing!
|
||||||
for (const entry of parsed) {
|
const consentAnsweredForMeetings = new Set(
|
||||||
const decoded = decodeEntry(entry);
|
parsed.filter((id) => !!id && typeof id === "string"),
|
||||||
if (decoded) {
|
);
|
||||||
consentForMeetings.set(decoded.meetingId, decoded.accepted);
|
setState({ ready: true, consentAnsweredForMeetings });
|
||||||
}
|
|
||||||
}
|
|
||||||
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, consentForMeetings: new Map() });
|
setState({ ready: true, consentAnsweredForMeetings: new Set() });
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const value: RecordingConsentContextValue = {
|
const value: RecordingConsentContextValue = {
|
||||||
state,
|
state,
|
||||||
touch,
|
touch,
|
||||||
hasAnswered,
|
hasConsent,
|
||||||
hasAccepted,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
* @default false
|
||||||
*/
|
*/
|
||||||
ics_enabled: boolean;
|
ics_enabled: boolean;
|
||||||
/**
|
/** Platform */
|
||||||
* Platform
|
platform?: ("whereby" | "daily") | null;
|
||||||
* @enum {string}
|
|
||||||
*/
|
|
||||||
platform: "whereby" | "daily";
|
|
||||||
/**
|
|
||||||
* Skip Consent
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
skip_consent: boolean;
|
|
||||||
};
|
};
|
||||||
/** CreateRoomMeeting */
|
/** CreateRoomMeeting */
|
||||||
CreateRoomMeeting: {
|
CreateRoomMeeting: {
|
||||||
@@ -1131,9 +1123,7 @@ export interface components {
|
|||||||
/** Audio Deleted */
|
/** Audio Deleted */
|
||||||
audio_deleted?: boolean | null;
|
audio_deleted?: boolean | null;
|
||||||
/** Participants */
|
/** Participants */
|
||||||
participants:
|
participants: components["schemas"]["TranscriptParticipant"][] | null;
|
||||||
| components["schemas"]["TranscriptParticipantWithEmail"][]
|
|
||||||
| null;
|
|
||||||
/**
|
/**
|
||||||
* @description discriminator enum property added by openapi-typescript
|
* @description discriminator enum property added by openapi-typescript
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
@@ -1194,9 +1184,7 @@ export interface components {
|
|||||||
/** Audio Deleted */
|
/** Audio Deleted */
|
||||||
audio_deleted?: boolean | null;
|
audio_deleted?: boolean | null;
|
||||||
/** Participants */
|
/** Participants */
|
||||||
participants:
|
participants: components["schemas"]["TranscriptParticipant"][] | null;
|
||||||
| components["schemas"]["TranscriptParticipantWithEmail"][]
|
|
||||||
| null;
|
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* GetTranscriptWithText
|
* GetTranscriptWithText
|
||||||
@@ -1258,9 +1246,7 @@ export interface components {
|
|||||||
/** Audio Deleted */
|
/** Audio Deleted */
|
||||||
audio_deleted?: boolean | null;
|
audio_deleted?: boolean | null;
|
||||||
/** Participants */
|
/** Participants */
|
||||||
participants:
|
participants: components["schemas"]["TranscriptParticipant"][] | null;
|
||||||
| components["schemas"]["TranscriptParticipantWithEmail"][]
|
|
||||||
| null;
|
|
||||||
/**
|
/**
|
||||||
* @description discriminator enum property added by openapi-typescript
|
* @description discriminator enum property added by openapi-typescript
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
@@ -1329,9 +1315,7 @@ export interface components {
|
|||||||
/** Audio Deleted */
|
/** Audio Deleted */
|
||||||
audio_deleted?: boolean | null;
|
audio_deleted?: boolean | null;
|
||||||
/** Participants */
|
/** Participants */
|
||||||
participants:
|
participants: components["schemas"]["TranscriptParticipant"][] | null;
|
||||||
| components["schemas"]["TranscriptParticipantWithEmail"][]
|
|
||||||
| null;
|
|
||||||
/**
|
/**
|
||||||
* @description discriminator enum property added by openapi-typescript
|
* @description discriminator enum property added by openapi-typescript
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
@@ -1402,9 +1386,7 @@ export interface components {
|
|||||||
/** Audio Deleted */
|
/** Audio Deleted */
|
||||||
audio_deleted?: boolean | null;
|
audio_deleted?: boolean | null;
|
||||||
/** Participants */
|
/** Participants */
|
||||||
participants:
|
participants: components["schemas"]["TranscriptParticipant"][] | null;
|
||||||
| components["schemas"]["TranscriptParticipantWithEmail"][]
|
|
||||||
| null;
|
|
||||||
/**
|
/**
|
||||||
* @description discriminator enum property added by openapi-typescript
|
* @description discriminator enum property added by openapi-typescript
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
@@ -1585,11 +1567,6 @@ export interface components {
|
|||||||
/** Name */
|
/** Name */
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
/** ProcessStatus */
|
|
||||||
ProcessStatus: {
|
|
||||||
/** Status */
|
|
||||||
status: string;
|
|
||||||
};
|
|
||||||
/** Room */
|
/** Room */
|
||||||
Room: {
|
Room: {
|
||||||
/** Id */
|
/** Id */
|
||||||
@@ -1640,11 +1617,6 @@ export interface components {
|
|||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
platform: "whereby" | "daily";
|
platform: "whereby" | "daily";
|
||||||
/**
|
|
||||||
* Skip Consent
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
skip_consent: boolean;
|
|
||||||
};
|
};
|
||||||
/** RoomDetails */
|
/** RoomDetails */
|
||||||
RoomDetails: {
|
RoomDetails: {
|
||||||
@@ -1696,11 +1668,6 @@ 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 */
|
||||||
@@ -1846,19 +1813,6 @@ 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.
|
||||||
@@ -1914,8 +1868,6 @@ 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: {
|
||||||
@@ -3410,7 +3362,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["ProcessStatus"];
|
"application/json": unknown;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Validation Error */
|
/** @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