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:
2025-07-15 15:10:05 -06:00
committed by GitHub
parent 3d370336cc
commit 9deb717e5b
21 changed files with 470 additions and 126 deletions

View File

@@ -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 ###

View File

@@ -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(

View File

@@ -20,6 +20,7 @@ recordings = sa.Table(
server_default="pending",
),
sa.Column("meeting_id", sa.String),
sa.Index("idx_recording_meeting_id", "meeting_id"),
)

View File

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

View File

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

View File

@@ -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,

View File

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

View File

@@ -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,
}}

View File

@@ -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">
Loading error: {error}
</Text>
return (
<Text fontSize="sm" pt="1" pl="2">
Loading error: {error}
</Text>
);
}
if (mp3.audioDeleted) {
return <Text fontSize="sm" pt="1" pl="2">
This topic file has been deleted.
</Text>
return (
<Text fontSize="sm" pt="1" pl="2">
This topic file has been deleted.
</Text>
);
}
return (
<Skeleton

View File

@@ -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}

View File

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

View File

@@ -124,7 +124,7 @@ const useAudioDevice = () => {
permissionDenied,
audioDevices,
getAudioStream,
requestPermission
requestPermission,
};
};

View File

@@ -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]);

View File

@@ -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();

View File

@@ -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,24 +82,27 @@ const useConsentDialog = (meetingId: string, wherebyRef: RefObject<HTMLElement>/
const api = useApi();
const toast = useToast();
const handleConsent = useCallback(async (meetingId: string, given: boolean) => {
if (!api) return;
const handleConsent = useCallback(
async (meetingId: string, given: boolean) => {
if (!api) return;
setConsentLoading(true);
setConsentLoading(true);
try {
await api.v1MeetingAudioConsent({
meetingId,
requestBody: { consent_given: given }
});
try {
await api.v1MeetingAudioConsent({
meetingId,
requestBody: { consent_given: given },
});
touch(meetingId);
} catch (error) {
console.error('Error submitting consent:', error);
} finally {
setConsentLoading(false);
}
}, [api, touch]);
touch(meetingId);
} catch (error) {
console.error("Error submitting consent:", error);
} finally {
setConsentLoading(false);
}
},
[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(() => {
setWherebyLoaded(true);
}).catch(console.error.bind(console));
if (typeof window !== "undefined") {
import("@whereby.com/browser-sdk/embed")
.then(() => {
setWherebyLoaded(true);
})
.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}
/>
)}
</>
)}
</>

View File

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

View File

@@ -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_ = {

View File

@@ -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(

View File

@@ -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
*/

View File

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

View File

@@ -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.