mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
refactor: improve transcript list performance (#480)
* refactor: improve transcript list performance * fix: sync openapi * fix: frontend types * fix: remove drop table _alembic_tmp_meeting * fix: remove create table too * fix: remove uq_recording_object_key
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
"""add_performance_indexes
|
||||
|
||||
Revision ID: ccd68dc784ff
|
||||
Revises: 20250618140000
|
||||
Create Date: 2025-07-15 11:48:42.854741
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "ccd68dc784ff"
|
||||
down_revision: Union[str, None] = "20250618140000"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("meeting", schema=None) as batch_op:
|
||||
batch_op.create_index("idx_meeting_room_id", ["room_id"], unique=False)
|
||||
|
||||
with op.batch_alter_table("recording", schema=None) as batch_op:
|
||||
batch_op.create_index("idx_recording_meeting_id", ["meeting_id"], unique=False)
|
||||
|
||||
with op.batch_alter_table("room", schema=None) as batch_op:
|
||||
batch_op.create_index("idx_room_is_shared", ["is_shared"], unique=False)
|
||||
|
||||
with op.batch_alter_table("transcript", schema=None) as batch_op:
|
||||
batch_op.create_index("idx_transcript_created_at", ["created_at"], unique=False)
|
||||
batch_op.create_index(
|
||||
"idx_transcript_recording_id", ["recording_id"], unique=False
|
||||
)
|
||||
batch_op.create_index("idx_transcript_user_id", ["user_id"], unique=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("transcript", schema=None) as batch_op:
|
||||
batch_op.drop_index("idx_transcript_user_id")
|
||||
batch_op.drop_index("idx_transcript_recording_id")
|
||||
batch_op.drop_index("idx_transcript_created_at")
|
||||
|
||||
with op.batch_alter_table("room", schema=None) as batch_op:
|
||||
batch_op.drop_index("idx_room_is_shared")
|
||||
|
||||
with op.batch_alter_table("recording", schema=None) as batch_op:
|
||||
batch_op.drop_index("idx_recording_meeting_id")
|
||||
|
||||
with op.batch_alter_table("meeting", schema=None) as batch_op:
|
||||
batch_op.drop_index("idx_meeting_room_id")
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -40,6 +40,7 @@ meetings = sa.Table(
|
||||
nullable=False,
|
||||
server_default=sa.true(),
|
||||
),
|
||||
sa.Index("idx_meeting_room_id", "room_id"),
|
||||
)
|
||||
|
||||
meeting_consent = sa.Table(
|
||||
|
||||
@@ -20,6 +20,7 @@ recordings = sa.Table(
|
||||
server_default="pending",
|
||||
),
|
||||
sa.Column("meeting_id", sa.String),
|
||||
sa.Index("idx_recording_meeting_id", "meeting_id"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ rooms = sqlalchemy.Table(
|
||||
sqlalchemy.Column(
|
||||
"is_shared", sqlalchemy.Boolean, nullable=False, server_default=false()
|
||||
),
|
||||
sqlalchemy.Index("idx_room_is_shared", "is_shared"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from reflector.utils import generate_uuid4
|
||||
|
||||
import sqlalchemy
|
||||
from fastapi import HTTPException
|
||||
@@ -15,6 +14,7 @@ from reflector.db import database, metadata
|
||||
from reflector.processors.types import Word as ProcessorWord
|
||||
from reflector.settings import settings
|
||||
from reflector.storage import get_transcripts_storage
|
||||
from reflector.utils import generate_uuid4
|
||||
from sqlalchemy import Enum
|
||||
from sqlalchemy.sql import false, or_
|
||||
|
||||
@@ -74,6 +74,9 @@ transcripts = sqlalchemy.Table(
|
||||
# the main "audio deleted" is the presence of the audio itself / consents not-given
|
||||
# same field could've been in recording/meeting, and it's maybe even ok to dupe it at need
|
||||
sqlalchemy.Column("audio_deleted", sqlalchemy.Boolean, nullable=True),
|
||||
sqlalchemy.Index("idx_transcript_recording_id", "recording_id"),
|
||||
sqlalchemy.Index("idx_transcript_user_id", "user_id"),
|
||||
sqlalchemy.Index("idx_transcript_created_at", "created_at"),
|
||||
)
|
||||
|
||||
|
||||
@@ -306,6 +309,7 @@ class TranscriptController:
|
||||
room_id: str | None = None,
|
||||
search_term: str | None = None,
|
||||
return_query: bool = False,
|
||||
exclude_columns: list[str] = ["topics", "events", "participants"],
|
||||
) -> list[Transcript]:
|
||||
"""
|
||||
Get all transcripts
|
||||
@@ -348,9 +352,14 @@ class TranscriptController:
|
||||
if search_term:
|
||||
query = query.where(transcripts.c.title.ilike(f"%{search_term}%"))
|
||||
|
||||
# Exclude heavy JSON columns from list queries
|
||||
transcript_columns = [
|
||||
col for col in transcripts.c if col.name not in exclude_columns
|
||||
]
|
||||
|
||||
query = query.with_only_columns(
|
||||
[
|
||||
transcripts,
|
||||
transcript_columns
|
||||
+ [
|
||||
rooms.c.id.label("room_id"),
|
||||
rooms.c.name.label("room_name"),
|
||||
]
|
||||
|
||||
@@ -45,7 +45,7 @@ def create_access_token(data: dict, expires_delta: timedelta):
|
||||
# ==============================================================
|
||||
|
||||
|
||||
class GetTranscript(BaseModel):
|
||||
class GetTranscriptMinimal(BaseModel):
|
||||
id: str
|
||||
user_id: str | None
|
||||
name: str
|
||||
@@ -59,7 +59,6 @@ class GetTranscript(BaseModel):
|
||||
share_mode: str = Field("private")
|
||||
source_language: str | None
|
||||
target_language: str | None
|
||||
participants: list[TranscriptParticipant] | None
|
||||
reviewed: bool
|
||||
meeting_id: str | None
|
||||
source_kind: SourceKind
|
||||
@@ -68,6 +67,10 @@ class GetTranscript(BaseModel):
|
||||
audio_deleted: bool | None = None
|
||||
|
||||
|
||||
class GetTranscript(GetTranscriptMinimal):
|
||||
participants: list[TranscriptParticipant] | None
|
||||
|
||||
|
||||
class CreateTranscript(BaseModel):
|
||||
name: str
|
||||
source_language: str = Field("en")
|
||||
@@ -90,7 +93,7 @@ class DeletionStatus(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
@router.get("/transcripts", response_model=Page[GetTranscript])
|
||||
@router.get("/transcripts", response_model=Page[GetTranscriptMinimal])
|
||||
async def transcripts_list(
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
source_kind: SourceKind | None = None,
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
import useTranscriptList from "../transcripts/useTranscriptList";
|
||||
import useSessionUser from "../../lib/useSessionUser";
|
||||
import NextLink from "next/link";
|
||||
import { Room, GetTranscript } from "../../api";
|
||||
import { Room, GetTranscriptMinimal } from "../../api";
|
||||
import Pagination from "./pagination";
|
||||
import { formatTimeMs } from "../../lib/time";
|
||||
import useApi from "../../lib/useApi";
|
||||
@@ -328,7 +328,7 @@ export default function TranscriptBrowser() {
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{response?.items?.map((item: GetTranscript) => (
|
||||
{response?.items?.map((item: GetTranscriptMinimal) => (
|
||||
<Tr key={item.id}>
|
||||
<Td>
|
||||
<Flex alignItems="start">
|
||||
@@ -416,7 +416,7 @@ export default function TranscriptBrowser() {
|
||||
</Box>
|
||||
<Box display={{ base: "block", md: "none" }}>
|
||||
<Stack spacing={2}>
|
||||
{response?.items?.map((item: GetTranscript) => (
|
||||
{response?.items?.map((item: GetTranscriptMinimal) => (
|
||||
<Box key={item.id} borderWidth={1} p={4} borderRadius="md">
|
||||
<Flex justify="space-between" alignItems="flex-start" gap="2">
|
||||
<Box>
|
||||
|
||||
@@ -193,7 +193,7 @@ export default function RoomsList() {
|
||||
(err.body as any).detail == "Room name is not unique"
|
||||
) {
|
||||
setNameError(
|
||||
"This room name is already taken. Please choose a different name."
|
||||
"This room name is already taken. Please choose a different name.",
|
||||
);
|
||||
} else {
|
||||
setNameError("An error occurred. Please try again.");
|
||||
@@ -316,7 +316,7 @@ export default function RoomsList() {
|
||||
options={roomModeOptions}
|
||||
value={{
|
||||
label: roomModeOptions.find(
|
||||
(rm) => rm.value === room.roomMode
|
||||
(rm) => rm.value === room.roomMode,
|
||||
)?.label,
|
||||
value: room.roomMode,
|
||||
}}
|
||||
@@ -335,7 +335,7 @@ export default function RoomsList() {
|
||||
options={recordingTypeOptions}
|
||||
value={{
|
||||
label: recordingTypeOptions.find(
|
||||
(rt) => rt.value === room.recordingType
|
||||
(rt) => rt.value === room.recordingType,
|
||||
)?.label,
|
||||
value: room.recordingType,
|
||||
}}
|
||||
@@ -358,7 +358,7 @@ export default function RoomsList() {
|
||||
options={recordingTriggerOptions}
|
||||
value={{
|
||||
label: recordingTriggerOptions.find(
|
||||
(rt) => rt.value === room.recordingTrigger
|
||||
(rt) => rt.value === room.recordingTrigger,
|
||||
)?.label,
|
||||
value: room.recordingTrigger,
|
||||
}}
|
||||
|
||||
@@ -183,17 +183,21 @@ const TopicPlayer = ({
|
||||
setIsPlaying(false);
|
||||
};
|
||||
|
||||
const isLoaded = !mp3.loading && !!topicTime
|
||||
const isLoaded = !mp3.loading && !!topicTime;
|
||||
const error = mp3.error;
|
||||
if (error !== null) {
|
||||
return <Text fontSize="sm" pt="1" pl="2">
|
||||
return (
|
||||
<Text fontSize="sm" pt="1" pl="2">
|
||||
Loading error: {error}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (mp3.audioDeleted) {
|
||||
return <Text fontSize="sm" pt="1" pl="2">
|
||||
return (
|
||||
<Text fontSize="sm" pt="1" pl="2">
|
||||
This topic file has been deleted.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Skeleton
|
||||
|
||||
@@ -67,8 +67,6 @@ export default function TranscriptDetails(details: TranscriptDetails) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid
|
||||
@@ -78,7 +76,10 @@ export default function TranscriptDetails(details: TranscriptDetails) {
|
||||
mt={4}
|
||||
mb={4}
|
||||
>
|
||||
{waveform.waveform && mp3.media && !mp3.audioDeleted && topics.topics ? (
|
||||
{waveform.waveform &&
|
||||
mp3.media &&
|
||||
!mp3.audioDeleted &&
|
||||
topics.topics ? (
|
||||
<Player
|
||||
topics={topics?.topics}
|
||||
useActiveTopic={useActiveTopic}
|
||||
|
||||
@@ -43,8 +43,7 @@ import {
|
||||
Input,
|
||||
} from "@chakra-ui/react";
|
||||
const TranscriptCreate = () => {
|
||||
|
||||
const isClient = typeof window !== 'undefined';
|
||||
const isClient = typeof window !== "undefined";
|
||||
const router = useRouter();
|
||||
const { isLoading, isAuthenticated } = useSessionStatus();
|
||||
const requireLogin = featureEnabled("requireLogin");
|
||||
@@ -186,9 +185,9 @@ const TranscriptCreate = () => {
|
||||
<Spacer />
|
||||
) : permissionDenied ? (
|
||||
<Text className="">
|
||||
Permission to use your microphone was denied, please change
|
||||
the permission setting in your browser and refresh this
|
||||
page.
|
||||
Permission to use your microphone was denied, please
|
||||
change the permission setting in your browser and refresh
|
||||
this page.
|
||||
</Text>
|
||||
) : (
|
||||
<Button
|
||||
|
||||
@@ -124,7 +124,7 @@ const useAudioDevice = () => {
|
||||
permissionDenied,
|
||||
audioDevices,
|
||||
getAudioStream,
|
||||
requestPermission
|
||||
requestPermission,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -14,9 +14,13 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
|
||||
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
|
||||
const [later, setLater] = useState(waiting);
|
||||
const [audioLoading, setAudioLoading] = useState<boolean>(true);
|
||||
const [audioLoadingError, setAudioLoadingError] = useState<null | string>(null);
|
||||
const [transcriptMetadataLoading, setTranscriptMetadataLoading] = useState<boolean>(true);
|
||||
const [transcriptMetadataLoadingError, setTranscriptMetadataLoadingError] = useState<string | null>(null);
|
||||
const [audioLoadingError, setAudioLoadingError] = useState<null | string>(
|
||||
null,
|
||||
);
|
||||
const [transcriptMetadataLoading, setTranscriptMetadataLoading] =
|
||||
useState<boolean>(true);
|
||||
const [transcriptMetadataLoadingError, setTranscriptMetadataLoadingError] =
|
||||
useState<string | null>(null);
|
||||
const [audioDeleted, setAudioDeleted] = useState<boolean | null>(null);
|
||||
const api = getApi();
|
||||
const { api_url } = useContext(DomainContext);
|
||||
@@ -47,7 +51,6 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
|
||||
});
|
||||
}, [navigator.serviceWorker, !serviceWorker, accessTokenInfo]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!transcriptId || !api || later) return;
|
||||
|
||||
@@ -62,7 +65,9 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
|
||||
|
||||
const handleCanPlay = () => {
|
||||
if (deleted) {
|
||||
console.error('Illegal state: audio supposed to be deleted, but was loaded');
|
||||
console.error(
|
||||
"Illegal state: audio supposed to be deleted, but was loaded",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setAudioLoading(false);
|
||||
@@ -78,17 +83,17 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
|
||||
setAudioLoadingError("Failed to load audio");
|
||||
};
|
||||
|
||||
audioElement.addEventListener('canplay', handleCanPlay);
|
||||
audioElement.addEventListener('error', handleError);
|
||||
audioElement.addEventListener("canplay", handleCanPlay);
|
||||
audioElement.addEventListener("error", handleError);
|
||||
|
||||
setMedia(audioElement);
|
||||
|
||||
|
||||
setAudioLoading(true);
|
||||
|
||||
let stopped = false;
|
||||
// Fetch transcript info in parallel
|
||||
api.v1TranscriptGet({ transcriptId })
|
||||
api
|
||||
.v1TranscriptGet({ transcriptId })
|
||||
.then((transcript) => {
|
||||
if (stopped) return;
|
||||
deleted = transcript.audio_deleted || false;
|
||||
@@ -109,12 +114,12 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
|
||||
.finally(() => {
|
||||
if (stopped) return;
|
||||
setTranscriptMetadataLoading(false);
|
||||
})
|
||||
});
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
audioElement.removeEventListener('canplay', handleCanPlay);
|
||||
audioElement.removeEventListener('error', handleError);
|
||||
audioElement.removeEventListener("canplay", handleCanPlay);
|
||||
audioElement.removeEventListener("error", handleError);
|
||||
};
|
||||
}, [transcriptId, !api, later, api_url]);
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useError } from "../../(errors)/errorContext";
|
||||
import useApi from "../../lib/useApi";
|
||||
import { Page_GetTranscript_, SourceKind } from "../../api";
|
||||
import { Page_GetTranscriptMinimal_, SourceKind } from "../../api";
|
||||
|
||||
type TranscriptList = {
|
||||
response: Page_GetTranscript_ | null;
|
||||
response: Page_GetTranscriptMinimal_ | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
@@ -16,7 +16,9 @@ const useTranscriptList = (
|
||||
roomId: string | null,
|
||||
searchTerm: string | null,
|
||||
): TranscriptList => {
|
||||
const [response, setResponse] = useState<Page_GetTranscript_ | null>(null);
|
||||
const [response, setResponse] = useState<Page_GetTranscriptMinimal_ | null>(
|
||||
null,
|
||||
);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setErrorState] = useState<Error | null>(null);
|
||||
const { setError } = useError();
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, useContext, RefObject } from "react";
|
||||
import { Box, Button, Text, VStack, HStack, Spinner, useToast, Icon } from "@chakra-ui/react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useContext,
|
||||
RefObject,
|
||||
} from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Text,
|
||||
VStack,
|
||||
HStack,
|
||||
Spinner,
|
||||
useToast,
|
||||
Icon,
|
||||
} from "@chakra-ui/react";
|
||||
import useRoomMeeting from "./useRoomMeeting";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { notFound } from "next/navigation";
|
||||
import useSessionStatus from "../lib/useSessionStatus";
|
||||
import { useRecordingConsent } from "../recordingConsentContext";
|
||||
import useApi from "../lib/useApi";
|
||||
import { Meeting } from '../api';
|
||||
import { Meeting } from "../api";
|
||||
import { FaBars } from "react-icons/fa6";
|
||||
|
||||
export type RoomDetails = {
|
||||
@@ -18,13 +34,18 @@ export type RoomDetails = {
|
||||
};
|
||||
|
||||
// stages: we focus on the consent, then whereby steals focus, then we focus on the consent again, then return focus to whoever stole it initially
|
||||
const useConsentWherebyFocusManagement = (acceptButtonRef: RefObject<HTMLButtonElement>, wherebyRef: RefObject<HTMLElement>) => {
|
||||
const useConsentWherebyFocusManagement = (
|
||||
acceptButtonRef: RefObject<HTMLButtonElement>,
|
||||
wherebyRef: RefObject<HTMLElement>,
|
||||
) => {
|
||||
const currentFocusRef = useRef<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (acceptButtonRef.current) {
|
||||
acceptButtonRef.current.focus();
|
||||
} else {
|
||||
console.error("accept button ref not available yet for focus management - seems to be illegal state");
|
||||
console.error(
|
||||
"accept button ref not available yet for focus management - seems to be illegal state",
|
||||
);
|
||||
}
|
||||
|
||||
const handleWherebyReady = () => {
|
||||
@@ -38,7 +59,9 @@ const useConsentWherebyFocusManagement = (acceptButtonRef: RefObject<HTMLButtonE
|
||||
if (wherebyRef.current) {
|
||||
wherebyRef.current.addEventListener("ready", handleWherebyReady);
|
||||
} else {
|
||||
console.warn("whereby ref not available yet for focus management - seems to be illegal state. not waiting, focus management off.");
|
||||
console.warn(
|
||||
"whereby ref not available yet for focus management - seems to be illegal state. not waiting, focus management off.",
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
@@ -46,9 +69,12 @@ const useConsentWherebyFocusManagement = (acceptButtonRef: RefObject<HTMLButtonE
|
||||
currentFocusRef.current?.focus();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
};
|
||||
|
||||
const useConsentDialog = (meetingId: string, wherebyRef: RefObject<HTMLElement>/*accessibility*/) => {
|
||||
const useConsentDialog = (
|
||||
meetingId: string,
|
||||
wherebyRef: RefObject<HTMLElement> /*accessibility*/,
|
||||
) => {
|
||||
const { state: consentState, touch, hasConsent } = useRecordingConsent();
|
||||
const [consentLoading, setConsentLoading] = useState(false);
|
||||
// toast would open duplicates, even with using "id=" prop
|
||||
@@ -56,7 +82,8 @@ const useConsentDialog = (meetingId: string, wherebyRef: RefObject<HTMLElement>/
|
||||
const api = useApi();
|
||||
const toast = useToast();
|
||||
|
||||
const handleConsent = useCallback(async (meetingId: string, given: boolean) => {
|
||||
const handleConsent = useCallback(
|
||||
async (meetingId: string, given: boolean) => {
|
||||
if (!api) return;
|
||||
|
||||
setConsentLoading(true);
|
||||
@@ -64,16 +91,18 @@ const useConsentDialog = (meetingId: string, wherebyRef: RefObject<HTMLElement>/
|
||||
try {
|
||||
await api.v1MeetingAudioConsent({
|
||||
meetingId,
|
||||
requestBody: { consent_given: given }
|
||||
requestBody: { consent_given: given },
|
||||
});
|
||||
|
||||
touch(meetingId);
|
||||
} catch (error) {
|
||||
console.error('Error submitting consent:', error);
|
||||
console.error("Error submitting consent:", error);
|
||||
} finally {
|
||||
setConsentLoading(false);
|
||||
}
|
||||
}, [api, touch]);
|
||||
},
|
||||
[api, touch],
|
||||
);
|
||||
|
||||
const showConsentModal = useCallback(() => {
|
||||
if (modalOpen) return;
|
||||
@@ -94,8 +123,10 @@ const useConsentDialog = (meetingId: string, wherebyRef: RefObject<HTMLElement>/
|
||||
colorScheme="blue"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleConsent(meetingId, true).then(() => {/*signifies it's ok to now wait here.*/})
|
||||
onClose()
|
||||
handleConsent(meetingId, true).then(() => {
|
||||
/*signifies it's ok to now wait here.*/
|
||||
});
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Yes, store the audio
|
||||
@@ -104,10 +135,18 @@ const useConsentDialog = (meetingId: string, wherebyRef: RefObject<HTMLElement>/
|
||||
};
|
||||
|
||||
return (
|
||||
<Box p={6} bg="rgba(255, 255, 255, 0.7)" borderRadius="lg" boxShadow="lg" maxW="md" mx="auto">
|
||||
<Box
|
||||
p={6}
|
||||
bg="rgba(255, 255, 255, 0.7)"
|
||||
borderRadius="lg"
|
||||
boxShadow="lg"
|
||||
maxW="md"
|
||||
mx="auto"
|
||||
>
|
||||
<VStack spacing={4} align="center">
|
||||
<Text fontSize="md" textAlign="center" fontWeight="medium">
|
||||
Can we have your permission to store this meeting's audio recording on our servers?
|
||||
Can we have your permission to store this meeting's audio
|
||||
recording on our servers?
|
||||
</Text>
|
||||
<HStack spacing={4} justify="center">
|
||||
<AcceptButton />
|
||||
@@ -115,8 +154,10 @@ const useConsentDialog = (meetingId: string, wherebyRef: RefObject<HTMLElement>/
|
||||
colorScheme="gray"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleConsent(meetingId, false).then(() => {/*signifies it's ok to now wait here.*/})
|
||||
onClose()
|
||||
handleConsent(meetingId, false).then(() => {
|
||||
/*signifies it's ok to now wait here.*/
|
||||
});
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
No, delete after transcription
|
||||
@@ -128,31 +169,38 @@ const useConsentDialog = (meetingId: string, wherebyRef: RefObject<HTMLElement>/
|
||||
},
|
||||
onCloseComplete: () => {
|
||||
setModalOpen(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Handle escape key to close the toast
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
if (event.key === "Escape") {
|
||||
toast.close(toastId);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
const cleanup = () => {
|
||||
toast.close(toastId);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
|
||||
return cleanup;
|
||||
}, [meetingId, toast, handleConsent, wherebyRef, modalOpen]);
|
||||
|
||||
return { showConsentModal, consentState, hasConsent, consentLoading };
|
||||
}
|
||||
};
|
||||
|
||||
function ConsentDialogButton({ meetingId, wherebyRef }: { meetingId: string; wherebyRef: React.RefObject<HTMLElement> }) {
|
||||
const { showConsentModal, consentState, hasConsent, consentLoading } = useConsentDialog(meetingId, wherebyRef);
|
||||
function ConsentDialogButton({
|
||||
meetingId,
|
||||
wherebyRef,
|
||||
}: {
|
||||
meetingId: string;
|
||||
wherebyRef: React.RefObject<HTMLElement>;
|
||||
}) {
|
||||
const { showConsentModal, consentState, hasConsent, consentLoading } =
|
||||
useConsentDialog(meetingId, wherebyRef);
|
||||
|
||||
if (!consentState.ready || hasConsent(meetingId) || consentLoading) {
|
||||
return null;
|
||||
@@ -174,22 +222,26 @@ function ConsentDialogButton({ meetingId, wherebyRef }: { meetingId: string; whe
|
||||
);
|
||||
}
|
||||
|
||||
const recordingTypeRequiresConsent = (recordingType: NonNullable<Meeting['recording_type']>) => {
|
||||
return recordingType === 'cloud';
|
||||
}
|
||||
const recordingTypeRequiresConsent = (
|
||||
recordingType: NonNullable<Meeting["recording_type"]>,
|
||||
) => {
|
||||
return recordingType === "cloud";
|
||||
};
|
||||
|
||||
// next throws even with "use client"
|
||||
const useWhereby = () => {
|
||||
const [wherebyLoaded, setWherebyLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
import("@whereby.com/browser-sdk/embed").then(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
import("@whereby.com/browser-sdk/embed")
|
||||
.then(() => {
|
||||
setWherebyLoaded(true);
|
||||
}).catch(console.error.bind(console));
|
||||
})
|
||||
.catch(console.error.bind(console));
|
||||
}
|
||||
}, []);
|
||||
return wherebyLoaded;
|
||||
}
|
||||
};
|
||||
|
||||
export default function Room(details: RoomDetails) {
|
||||
const wherebyLoaded = useWhereby();
|
||||
@@ -253,7 +305,6 @@ export default function Room(details: RoomDetails) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
{roomUrl && meetingId && wherebyLoaded && (
|
||||
@@ -263,7 +314,12 @@ export default function Room(details: RoomDetails) {
|
||||
room={roomUrl}
|
||||
style={{ width: "100vw", height: "100vh" }}
|
||||
/>
|
||||
{recordingType && recordingTypeRequiresConsent(recordingType) && <ConsentDialogButton meetingId={meetingId} wherebyRef={wherebyRef} />}
|
||||
{recordingType && recordingTypeRequiresConsent(recordingType) && (
|
||||
<ConsentDialogButton
|
||||
meetingId={meetingId}
|
||||
wherebyRef={wherebyRef}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -27,7 +27,7 @@ type SuccessMeeting = {
|
||||
};
|
||||
|
||||
const useRoomMeeting = (
|
||||
roomName: string | null | undefined
|
||||
roomName: string | null | undefined,
|
||||
): ErrorMeeting | LoadingMeeting | SuccessMeeting => {
|
||||
const [response, setResponse] = useState<Meeting | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
@@ -55,7 +55,7 @@ const useRoomMeeting = (
|
||||
if (shouldShowHuman && error.status !== 404) {
|
||||
setError(
|
||||
error,
|
||||
"There was an error loading the meeting. Please try again by refreshing the page."
|
||||
"There was an error loading the meeting. Please try again by refreshing the page.",
|
||||
);
|
||||
} else {
|
||||
setError(error);
|
||||
|
||||
@@ -239,6 +239,57 @@ export const $GetTranscript = {
|
||||
],
|
||||
title: "Target Language",
|
||||
},
|
||||
reviewed: {
|
||||
type: "boolean",
|
||||
title: "Reviewed",
|
||||
},
|
||||
meeting_id: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Meeting Id",
|
||||
},
|
||||
source_kind: {
|
||||
$ref: "#/components/schemas/SourceKind",
|
||||
},
|
||||
room_id: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Room Id",
|
||||
},
|
||||
room_name: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Room Name",
|
||||
},
|
||||
audio_deleted: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Audio Deleted",
|
||||
},
|
||||
participants: {
|
||||
anyOf: [
|
||||
{
|
||||
@@ -253,6 +304,127 @@ export const $GetTranscript = {
|
||||
],
|
||||
title: "Participants",
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
required: [
|
||||
"id",
|
||||
"user_id",
|
||||
"name",
|
||||
"status",
|
||||
"locked",
|
||||
"duration",
|
||||
"title",
|
||||
"short_summary",
|
||||
"long_summary",
|
||||
"created_at",
|
||||
"source_language",
|
||||
"target_language",
|
||||
"reviewed",
|
||||
"meeting_id",
|
||||
"source_kind",
|
||||
"participants",
|
||||
],
|
||||
title: "GetTranscript",
|
||||
} as const;
|
||||
|
||||
export const $GetTranscriptMinimal = {
|
||||
properties: {
|
||||
id: {
|
||||
type: "string",
|
||||
title: "Id",
|
||||
},
|
||||
user_id: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "User Id",
|
||||
},
|
||||
name: {
|
||||
type: "string",
|
||||
title: "Name",
|
||||
},
|
||||
status: {
|
||||
type: "string",
|
||||
title: "Status",
|
||||
},
|
||||
locked: {
|
||||
type: "boolean",
|
||||
title: "Locked",
|
||||
},
|
||||
duration: {
|
||||
type: "number",
|
||||
title: "Duration",
|
||||
},
|
||||
title: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Title",
|
||||
},
|
||||
short_summary: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Short Summary",
|
||||
},
|
||||
long_summary: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Long Summary",
|
||||
},
|
||||
created_at: {
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
title: "Created At",
|
||||
},
|
||||
share_mode: {
|
||||
type: "string",
|
||||
title: "Share Mode",
|
||||
default: "private",
|
||||
},
|
||||
source_language: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Source Language",
|
||||
},
|
||||
target_language: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Target Language",
|
||||
},
|
||||
reviewed: {
|
||||
type: "boolean",
|
||||
title: "Reviewed",
|
||||
@@ -319,12 +491,11 @@ export const $GetTranscript = {
|
||||
"created_at",
|
||||
"source_language",
|
||||
"target_language",
|
||||
"participants",
|
||||
"reviewed",
|
||||
"meeting_id",
|
||||
"source_kind",
|
||||
],
|
||||
title: "GetTranscript",
|
||||
title: "GetTranscriptMinimal",
|
||||
} as const;
|
||||
|
||||
export const $GetTranscriptSegmentTopic = {
|
||||
@@ -577,11 +748,11 @@ export const $MeetingConsentRequest = {
|
||||
title: "MeetingConsentRequest",
|
||||
} as const;
|
||||
|
||||
export const $Page_GetTranscript_ = {
|
||||
export const $Page_GetTranscriptMinimal_ = {
|
||||
properties: {
|
||||
items: {
|
||||
items: {
|
||||
$ref: "#/components/schemas/GetTranscript",
|
||||
$ref: "#/components/schemas/GetTranscriptMinimal",
|
||||
},
|
||||
type: "array",
|
||||
title: "Items",
|
||||
@@ -630,7 +801,7 @@ export const $Page_GetTranscript_ = {
|
||||
},
|
||||
type: "object",
|
||||
required: ["items", "total", "page", "size"],
|
||||
title: "Page[GetTranscript]",
|
||||
title: "Page[GetTranscriptMinimal]",
|
||||
} as const;
|
||||
|
||||
export const $Page_Room_ = {
|
||||
|
||||
@@ -233,7 +233,7 @@ export class DefaultService {
|
||||
* @param data.searchTerm
|
||||
* @param data.page Page number
|
||||
* @param data.size Page size
|
||||
* @returns Page_GetTranscript_ Successful Response
|
||||
* @returns Page_GetTranscriptMinimal_ Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public v1TranscriptsList(
|
||||
|
||||
@@ -50,7 +50,29 @@ export type GetTranscript = {
|
||||
share_mode?: string;
|
||||
source_language: string | null;
|
||||
target_language: string | null;
|
||||
reviewed: boolean;
|
||||
meeting_id: string | null;
|
||||
source_kind: SourceKind;
|
||||
room_id?: string | null;
|
||||
room_name?: string | null;
|
||||
audio_deleted?: boolean | null;
|
||||
participants: Array<TranscriptParticipant> | null;
|
||||
};
|
||||
|
||||
export type GetTranscriptMinimal = {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
name: string;
|
||||
status: string;
|
||||
locked: boolean;
|
||||
duration: number;
|
||||
title: string | null;
|
||||
short_summary: string | null;
|
||||
long_summary: string | null;
|
||||
created_at: string;
|
||||
share_mode?: string;
|
||||
source_language: string | null;
|
||||
target_language: string | null;
|
||||
reviewed: boolean;
|
||||
meeting_id: string | null;
|
||||
source_kind: SourceKind;
|
||||
@@ -117,8 +139,8 @@ export type MeetingConsentRequest = {
|
||||
consent_given: boolean;
|
||||
};
|
||||
|
||||
export type Page_GetTranscript_ = {
|
||||
items: Array<GetTranscript>;
|
||||
export type Page_GetTranscriptMinimal_ = {
|
||||
items: Array<GetTranscriptMinimal>;
|
||||
total: number;
|
||||
page: number | null;
|
||||
size: number | null;
|
||||
@@ -316,7 +338,7 @@ export type V1TranscriptsListData = {
|
||||
sourceKind?: SourceKind | null;
|
||||
};
|
||||
|
||||
export type V1TranscriptsListResponse = Page_GetTranscript_;
|
||||
export type V1TranscriptsListResponse = Page_GetTranscriptMinimal_;
|
||||
|
||||
export type V1TranscriptsCreateData = {
|
||||
requestBody: CreateTranscript;
|
||||
@@ -590,7 +612,7 @@ export type $OpenApiTs = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: Page_GetTranscript_;
|
||||
200: Page_GetTranscriptMinimal_;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
|
||||
@@ -9,7 +9,10 @@ interface WherebyEmbedProps {
|
||||
}
|
||||
|
||||
// currently used for webinars only
|
||||
export default function WherebyWebinarEmbed({ roomUrl, onLeave }: WherebyEmbedProps) {
|
||||
export default function WherebyWebinarEmbed({
|
||||
roomUrl,
|
||||
onLeave,
|
||||
}: WherebyEmbedProps) {
|
||||
const wherebyRef = useRef<HTMLElement>(null);
|
||||
|
||||
// TODO extract common toast logic / styles to be used by consent toast on normal rooms
|
||||
|
||||
@@ -5,8 +5,8 @@ import React, { createContext, useContext, useEffect, useState } from "react";
|
||||
type ConsentContextState =
|
||||
| { ready: false }
|
||||
| {
|
||||
ready: true,
|
||||
consentAnsweredForMeetings: Set<string>
|
||||
ready: true;
|
||||
consentAnsweredForMeetings: Set<string>;
|
||||
};
|
||||
|
||||
interface RecordingConsentContextValue {
|
||||
@@ -15,12 +15,16 @@ interface RecordingConsentContextValue {
|
||||
hasConsent: (meetingId: string) => boolean;
|
||||
}
|
||||
|
||||
const RecordingConsentContext = createContext<RecordingConsentContextValue | undefined>(undefined);
|
||||
const RecordingConsentContext = createContext<
|
||||
RecordingConsentContextValue | undefined
|
||||
>(undefined);
|
||||
|
||||
export const useRecordingConsent = () => {
|
||||
const context = useContext(RecordingConsentContext);
|
||||
if (!context) {
|
||||
throw new Error("useRecordingConsent must be used within RecordingConsentProvider");
|
||||
throw new Error(
|
||||
"useRecordingConsent must be used within RecordingConsentProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -31,12 +35,14 @@ interface RecordingConsentProviderProps {
|
||||
|
||||
const LOCAL_STORAGE_KEY = "recording_consent_meetings";
|
||||
|
||||
export const RecordingConsentProvider: React.FC<RecordingConsentProviderProps> = ({ children }) => {
|
||||
export const RecordingConsentProvider: React.FC<
|
||||
RecordingConsentProviderProps
|
||||
> = ({ children }) => {
|
||||
const [state, setState] = useState<ConsentContextState>({ ready: false });
|
||||
|
||||
const safeWriteToStorage = (meetingIds: string[]): void => {
|
||||
try {
|
||||
if (typeof window !== 'undefined' && window.localStorage) {
|
||||
if (typeof window !== "undefined" && window.localStorage) {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(meetingIds));
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -46,7 +52,6 @@ export const RecordingConsentProvider: React.FC<RecordingConsentProviderProps> =
|
||||
|
||||
// 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;
|
||||
@@ -54,9 +59,9 @@ export const RecordingConsentProvider: React.FC<RecordingConsentProviderProps> =
|
||||
|
||||
// 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]);
|
||||
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);
|
||||
@@ -71,7 +76,7 @@ export const RecordingConsentProvider: React.FC<RecordingConsentProviderProps> =
|
||||
// initialize on mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (typeof window === 'undefined' || !window.localStorage) {
|
||||
if (typeof window === "undefined" || !window.localStorage) {
|
||||
setState({ ready: true, consentAnsweredForMeetings: new Set() });
|
||||
return;
|
||||
}
|
||||
@@ -90,7 +95,9 @@ export const RecordingConsentProvider: React.FC<RecordingConsentProviderProps> =
|
||||
}
|
||||
|
||||
// pre-historic way of parsing!
|
||||
const consentAnsweredForMeetings = new Set(parsed.filter(id => !!id && typeof id === 'string'));
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user