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, nullable=False,
server_default=sa.true(), server_default=sa.true(),
), ),
sa.Index("idx_meeting_room_id", "room_id"),
) )
meeting_consent = sa.Table( meeting_consent = sa.Table(

View File

@@ -20,6 +20,7 @@ recordings = sa.Table(
server_default="pending", server_default="pending",
), ),
sa.Column("meeting_id", sa.String), 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( sqlalchemy.Column(
"is_shared", sqlalchemy.Boolean, nullable=False, server_default=false() "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 datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
from reflector.utils import generate_uuid4
import sqlalchemy import sqlalchemy
from fastapi import HTTPException from fastapi import HTTPException
@@ -15,6 +14,7 @@ from reflector.db import database, metadata
from reflector.processors.types import Word as ProcessorWord from reflector.processors.types import Word as ProcessorWord
from reflector.settings import settings from reflector.settings import settings
from reflector.storage import get_transcripts_storage from reflector.storage import get_transcripts_storage
from reflector.utils import generate_uuid4
from sqlalchemy import Enum from sqlalchemy import Enum
from sqlalchemy.sql import false, or_ 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 # 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 # 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.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, room_id: str | None = None,
search_term: str | None = None, search_term: str | None = None,
return_query: bool = False, return_query: bool = False,
exclude_columns: list[str] = ["topics", "events", "participants"],
) -> list[Transcript]: ) -> list[Transcript]:
""" """
Get all transcripts Get all transcripts
@@ -348,9 +352,14 @@ class TranscriptController:
if search_term: if search_term:
query = query.where(transcripts.c.title.ilike(f"%{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( query = query.with_only_columns(
[ transcript_columns
transcripts, + [
rooms.c.id.label("room_id"), rooms.c.id.label("room_id"),
rooms.c.name.label("room_name"), 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 id: str
user_id: str | None user_id: str | None
name: str name: str
@@ -59,7 +59,6 @@ class GetTranscript(BaseModel):
share_mode: str = Field("private") share_mode: str = Field("private")
source_language: str | None source_language: str | None
target_language: str | None target_language: str | None
participants: list[TranscriptParticipant] | None
reviewed: bool reviewed: bool
meeting_id: str | None meeting_id: str | None
source_kind: SourceKind source_kind: SourceKind
@@ -68,6 +67,10 @@ class GetTranscript(BaseModel):
audio_deleted: bool | None = None audio_deleted: bool | None = None
class GetTranscript(GetTranscriptMinimal):
participants: list[TranscriptParticipant] | None
class CreateTranscript(BaseModel): class CreateTranscript(BaseModel):
name: str name: str
source_language: str = Field("en") source_language: str = Field("en")
@@ -90,7 +93,7 @@ class DeletionStatus(BaseModel):
status: str status: str
@router.get("/transcripts", response_model=Page[GetTranscript]) @router.get("/transcripts", response_model=Page[GetTranscriptMinimal])
async def transcripts_list( async def transcripts_list(
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
source_kind: SourceKind | None = None, source_kind: SourceKind | None = None,

View File

@@ -44,7 +44,7 @@ import {
import useTranscriptList from "../transcripts/useTranscriptList"; import useTranscriptList from "../transcripts/useTranscriptList";
import useSessionUser from "../../lib/useSessionUser"; import useSessionUser from "../../lib/useSessionUser";
import NextLink from "next/link"; import NextLink from "next/link";
import { Room, GetTranscript } from "../../api"; import { Room, GetTranscriptMinimal } from "../../api";
import Pagination from "./pagination"; import Pagination from "./pagination";
import { formatTimeMs } from "../../lib/time"; import { formatTimeMs } from "../../lib/time";
import useApi from "../../lib/useApi"; import useApi from "../../lib/useApi";
@@ -328,7 +328,7 @@ export default function TranscriptBrowser() {
</Tr> </Tr>
</Thead> </Thead>
<Tbody> <Tbody>
{response?.items?.map((item: GetTranscript) => ( {response?.items?.map((item: GetTranscriptMinimal) => (
<Tr key={item.id}> <Tr key={item.id}>
<Td> <Td>
<Flex alignItems="start"> <Flex alignItems="start">
@@ -416,7 +416,7 @@ export default function TranscriptBrowser() {
</Box> </Box>
<Box display={{ base: "block", md: "none" }}> <Box display={{ base: "block", md: "none" }}>
<Stack spacing={2}> <Stack spacing={2}>
{response?.items?.map((item: GetTranscript) => ( {response?.items?.map((item: GetTranscriptMinimal) => (
<Box key={item.id} borderWidth={1} p={4} borderRadius="md"> <Box key={item.id} borderWidth={1} p={4} borderRadius="md">
<Flex justify="space-between" alignItems="flex-start" gap="2"> <Flex justify="space-between" alignItems="flex-start" gap="2">
<Box> <Box>

View File

@@ -193,7 +193,7 @@ export default function RoomsList() {
(err.body as any).detail == "Room name is not unique" (err.body as any).detail == "Room name is not unique"
) { ) {
setNameError( setNameError(
"This room name is already taken. Please choose a different name." "This room name is already taken. Please choose a different name.",
); );
} else { } else {
setNameError("An error occurred. Please try again."); setNameError("An error occurred. Please try again.");
@@ -316,7 +316,7 @@ export default function RoomsList() {
options={roomModeOptions} options={roomModeOptions}
value={{ value={{
label: roomModeOptions.find( label: roomModeOptions.find(
(rm) => rm.value === room.roomMode (rm) => rm.value === room.roomMode,
)?.label, )?.label,
value: room.roomMode, value: room.roomMode,
}} }}
@@ -335,7 +335,7 @@ export default function RoomsList() {
options={recordingTypeOptions} options={recordingTypeOptions}
value={{ value={{
label: recordingTypeOptions.find( label: recordingTypeOptions.find(
(rt) => rt.value === room.recordingType (rt) => rt.value === room.recordingType,
)?.label, )?.label,
value: room.recordingType, value: room.recordingType,
}} }}
@@ -358,7 +358,7 @@ export default function RoomsList() {
options={recordingTriggerOptions} options={recordingTriggerOptions}
value={{ value={{
label: recordingTriggerOptions.find( label: recordingTriggerOptions.find(
(rt) => rt.value === room.recordingTrigger (rt) => rt.value === room.recordingTrigger,
)?.label, )?.label,
value: room.recordingTrigger, value: room.recordingTrigger,
}} }}

View File

@@ -183,17 +183,21 @@ const TopicPlayer = ({
setIsPlaying(false); setIsPlaying(false);
}; };
const isLoaded = !mp3.loading && !!topicTime const isLoaded = !mp3.loading && !!topicTime;
const error = mp3.error; const error = mp3.error;
if (error !== null) { if (error !== null) {
return <Text fontSize="sm" pt="1" pl="2"> return (
<Text fontSize="sm" pt="1" pl="2">
Loading error: {error} Loading error: {error}
</Text> </Text>
);
} }
if (mp3.audioDeleted) { 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. This topic file has been deleted.
</Text> </Text>
);
} }
return ( return (
<Skeleton <Skeleton

View File

@@ -67,8 +67,6 @@ export default function TranscriptDetails(details: TranscriptDetails) {
); );
} }
return ( return (
<> <>
<Grid <Grid
@@ -78,7 +76,10 @@ export default function TranscriptDetails(details: TranscriptDetails) {
mt={4} mt={4}
mb={4} mb={4}
> >
{waveform.waveform && mp3.media && !mp3.audioDeleted && topics.topics ? ( {waveform.waveform &&
mp3.media &&
!mp3.audioDeleted &&
topics.topics ? (
<Player <Player
topics={topics?.topics} topics={topics?.topics}
useActiveTopic={useActiveTopic} useActiveTopic={useActiveTopic}

View File

@@ -43,8 +43,7 @@ import {
Input, Input,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
const TranscriptCreate = () => { const TranscriptCreate = () => {
const isClient = typeof window !== "undefined";
const isClient = typeof window !== 'undefined';
const router = useRouter(); const router = useRouter();
const { isLoading, isAuthenticated } = useSessionStatus(); const { isLoading, isAuthenticated } = useSessionStatus();
const requireLogin = featureEnabled("requireLogin"); const requireLogin = featureEnabled("requireLogin");
@@ -186,9 +185,9 @@ const TranscriptCreate = () => {
<Spacer /> <Spacer />
) : permissionDenied ? ( ) : permissionDenied ? (
<Text className=""> <Text className="">
Permission to use your microphone was denied, please change Permission to use your microphone was denied, please
the permission setting in your browser and refresh this change the permission setting in your browser and refresh
page. this page.
</Text> </Text>
) : ( ) : (
<Button <Button

View File

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

View File

@@ -14,9 +14,13 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
const [media, setMedia] = useState<HTMLMediaElement | null>(null); const [media, setMedia] = useState<HTMLMediaElement | null>(null);
const [later, setLater] = useState(waiting); const [later, setLater] = useState(waiting);
const [audioLoading, setAudioLoading] = useState<boolean>(true); const [audioLoading, setAudioLoading] = useState<boolean>(true);
const [audioLoadingError, setAudioLoadingError] = useState<null | string>(null); const [audioLoadingError, setAudioLoadingError] = useState<null | string>(
const [transcriptMetadataLoading, setTranscriptMetadataLoading] = useState<boolean>(true); null,
const [transcriptMetadataLoadingError, setTranscriptMetadataLoadingError] = useState<string | null>(null); );
const [transcriptMetadataLoading, setTranscriptMetadataLoading] =
useState<boolean>(true);
const [transcriptMetadataLoadingError, setTranscriptMetadataLoadingError] =
useState<string | null>(null);
const [audioDeleted, setAudioDeleted] = useState<boolean | null>(null); const [audioDeleted, setAudioDeleted] = useState<boolean | null>(null);
const api = getApi(); const api = getApi();
const { api_url } = useContext(DomainContext); const { api_url } = useContext(DomainContext);
@@ -47,7 +51,6 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
}); });
}, [navigator.serviceWorker, !serviceWorker, accessTokenInfo]); }, [navigator.serviceWorker, !serviceWorker, accessTokenInfo]);
useEffect(() => { useEffect(() => {
if (!transcriptId || !api || later) return; if (!transcriptId || !api || later) return;
@@ -62,7 +65,9 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
const handleCanPlay = () => { const handleCanPlay = () => {
if (deleted) { 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; return;
} }
setAudioLoading(false); setAudioLoading(false);
@@ -78,17 +83,17 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
setAudioLoadingError("Failed to load audio"); setAudioLoadingError("Failed to load audio");
}; };
audioElement.addEventListener('canplay', handleCanPlay); audioElement.addEventListener("canplay", handleCanPlay);
audioElement.addEventListener('error', handleError); audioElement.addEventListener("error", handleError);
setMedia(audioElement); setMedia(audioElement);
setAudioLoading(true); setAudioLoading(true);
let stopped = false; let stopped = false;
// Fetch transcript info in parallel // Fetch transcript info in parallel
api.v1TranscriptGet({ transcriptId }) api
.v1TranscriptGet({ transcriptId })
.then((transcript) => { .then((transcript) => {
if (stopped) return; if (stopped) return;
deleted = transcript.audio_deleted || false; deleted = transcript.audio_deleted || false;
@@ -109,12 +114,12 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
.finally(() => { .finally(() => {
if (stopped) return; if (stopped) return;
setTranscriptMetadataLoading(false); setTranscriptMetadataLoading(false);
}) });
return () => { return () => {
stopped = true; stopped = true;
audioElement.removeEventListener('canplay', handleCanPlay); audioElement.removeEventListener("canplay", handleCanPlay);
audioElement.removeEventListener('error', handleError); audioElement.removeEventListener("error", handleError);
}; };
}, [transcriptId, !api, later, api_url]); }, [transcriptId, !api, later, api_url]);

View File

@@ -1,10 +1,10 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useError } from "../../(errors)/errorContext"; import { useError } from "../../(errors)/errorContext";
import useApi from "../../lib/useApi"; import useApi from "../../lib/useApi";
import { Page_GetTranscript_, SourceKind } from "../../api"; import { Page_GetTranscriptMinimal_, SourceKind } from "../../api";
type TranscriptList = { type TranscriptList = {
response: Page_GetTranscript_ | null; response: Page_GetTranscriptMinimal_ | null;
loading: boolean; loading: boolean;
error: Error | null; error: Error | null;
refetch: () => void; refetch: () => void;
@@ -16,7 +16,9 @@ const useTranscriptList = (
roomId: string | null, roomId: string | null,
searchTerm: string | null, searchTerm: string | null,
): TranscriptList => { ): TranscriptList => {
const [response, setResponse] = useState<Page_GetTranscript_ | null>(null); const [response, setResponse] = useState<Page_GetTranscriptMinimal_ | null>(
null,
);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setErrorState] = useState<Error | null>(null); const [error, setErrorState] = useState<Error | null>(null);
const { setError } = useError(); const { setError } = useError();

View File

@@ -1,14 +1,30 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState, useContext, RefObject } from "react"; import {
import { Box, Button, Text, VStack, HStack, Spinner, useToast, Icon } from "@chakra-ui/react"; 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 useRoomMeeting from "./useRoomMeeting";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import useSessionStatus from "../lib/useSessionStatus"; import useSessionStatus from "../lib/useSessionStatus";
import { useRecordingConsent } from "../recordingConsentContext"; import { useRecordingConsent } from "../recordingConsentContext";
import useApi from "../lib/useApi"; import useApi from "../lib/useApi";
import { Meeting } from '../api'; import { Meeting } from "../api";
import { FaBars } from "react-icons/fa6"; import { FaBars } from "react-icons/fa6";
export type RoomDetails = { 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 // 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); const currentFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => { useEffect(() => {
if (acceptButtonRef.current) { if (acceptButtonRef.current) {
acceptButtonRef.current.focus(); acceptButtonRef.current.focus();
} else { } 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 = () => { const handleWherebyReady = () => {
@@ -38,7 +59,9 @@ const useConsentWherebyFocusManagement = (acceptButtonRef: RefObject<HTMLButtonE
if (wherebyRef.current) { if (wherebyRef.current) {
wherebyRef.current.addEventListener("ready", handleWherebyReady); wherebyRef.current.addEventListener("ready", handleWherebyReady);
} else { } 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 () => { return () => {
@@ -46,9 +69,12 @@ const useConsentWherebyFocusManagement = (acceptButtonRef: RefObject<HTMLButtonE
currentFocusRef.current?.focus(); 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 { state: consentState, touch, hasConsent } = useRecordingConsent();
const [consentLoading, setConsentLoading] = useState(false); const [consentLoading, setConsentLoading] = useState(false);
// toast would open duplicates, even with using "id=" prop // toast would open duplicates, even with using "id=" prop
@@ -56,7 +82,8 @@ const useConsentDialog = (meetingId: string, wherebyRef: RefObject<HTMLElement>/
const api = useApi(); const api = useApi();
const toast = useToast(); const toast = useToast();
const handleConsent = useCallback(async (meetingId: string, given: boolean) => { const handleConsent = useCallback(
async (meetingId: string, given: boolean) => {
if (!api) return; if (!api) return;
setConsentLoading(true); setConsentLoading(true);
@@ -64,16 +91,18 @@ const useConsentDialog = (meetingId: string, wherebyRef: RefObject<HTMLElement>/
try { try {
await api.v1MeetingAudioConsent({ await api.v1MeetingAudioConsent({
meetingId, meetingId,
requestBody: { consent_given: given } requestBody: { consent_given: given },
}); });
touch(meetingId); touch(meetingId);
} catch (error) { } catch (error) {
console.error('Error submitting consent:', error); console.error("Error submitting consent:", error);
} finally { } finally {
setConsentLoading(false); setConsentLoading(false);
} }
}, [api, touch]); },
[api, touch],
);
const showConsentModal = useCallback(() => { const showConsentModal = useCallback(() => {
if (modalOpen) return; if (modalOpen) return;
@@ -94,8 +123,10 @@ const useConsentDialog = (meetingId: string, wherebyRef: RefObject<HTMLElement>/
colorScheme="blue" colorScheme="blue"
size="sm" size="sm"
onClick={() => { onClick={() => {
handleConsent(meetingId, true).then(() => {/*signifies it's ok to now wait here.*/}) handleConsent(meetingId, true).then(() => {
onClose() /*signifies it's ok to now wait here.*/
});
onClose();
}} }}
> >
Yes, store the audio Yes, store the audio
@@ -104,10 +135,18 @@ const useConsentDialog = (meetingId: string, wherebyRef: RefObject<HTMLElement>/
}; };
return ( 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"> <VStack spacing={4} align="center">
<Text fontSize="md" textAlign="center" fontWeight="medium"> <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> </Text>
<HStack spacing={4} justify="center"> <HStack spacing={4} justify="center">
<AcceptButton /> <AcceptButton />
@@ -115,8 +154,10 @@ const useConsentDialog = (meetingId: string, wherebyRef: RefObject<HTMLElement>/
colorScheme="gray" colorScheme="gray"
size="sm" size="sm"
onClick={() => { onClick={() => {
handleConsent(meetingId, false).then(() => {/*signifies it's ok to now wait here.*/}) handleConsent(meetingId, false).then(() => {
onClose() /*signifies it's ok to now wait here.*/
});
onClose();
}} }}
> >
No, delete after transcription No, delete after transcription
@@ -128,31 +169,38 @@ const useConsentDialog = (meetingId: string, wherebyRef: RefObject<HTMLElement>/
}, },
onCloseComplete: () => { onCloseComplete: () => {
setModalOpen(false); setModalOpen(false);
} },
}); });
// Handle escape key to close the toast // Handle escape key to close the toast
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === "Escape") {
toast.close(toastId); toast.close(toastId);
} }
}; };
document.addEventListener('keydown', handleKeyDown); document.addEventListener("keydown", handleKeyDown);
const cleanup = () => { const cleanup = () => {
toast.close(toastId); toast.close(toastId);
document.removeEventListener('keydown', handleKeyDown); document.removeEventListener("keydown", handleKeyDown);
}; };
return cleanup; return cleanup;
}, [meetingId, toast, handleConsent, wherebyRef, modalOpen]); }, [meetingId, toast, handleConsent, wherebyRef, modalOpen]);
return { showConsentModal, consentState, hasConsent, consentLoading }; return { showConsentModal, consentState, hasConsent, consentLoading };
} };
function ConsentDialogButton({ meetingId, wherebyRef }: { meetingId: string; wherebyRef: React.RefObject<HTMLElement> }) { function ConsentDialogButton({
const { showConsentModal, consentState, hasConsent, consentLoading } = useConsentDialog(meetingId, wherebyRef); meetingId,
wherebyRef,
}: {
meetingId: string;
wherebyRef: React.RefObject<HTMLElement>;
}) {
const { showConsentModal, consentState, hasConsent, consentLoading } =
useConsentDialog(meetingId, wherebyRef);
if (!consentState.ready || hasConsent(meetingId) || consentLoading) { if (!consentState.ready || hasConsent(meetingId) || consentLoading) {
return null; return null;
@@ -174,22 +222,26 @@ function ConsentDialogButton({ meetingId, wherebyRef }: { meetingId: string; whe
); );
} }
const recordingTypeRequiresConsent = (recordingType: NonNullable<Meeting['recording_type']>) => { const recordingTypeRequiresConsent = (
return recordingType === 'cloud'; recordingType: NonNullable<Meeting["recording_type"]>,
} ) => {
return recordingType === "cloud";
};
// next throws even with "use client" // next throws even with "use client"
const useWhereby = () => { const useWhereby = () => {
const [wherebyLoaded, setWherebyLoaded] = useState(false); const [wherebyLoaded, setWherebyLoaded] = useState(false);
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
import("@whereby.com/browser-sdk/embed").then(() => { import("@whereby.com/browser-sdk/embed")
.then(() => {
setWherebyLoaded(true); setWherebyLoaded(true);
}).catch(console.error.bind(console)); })
.catch(console.error.bind(console));
} }
}, []); }, []);
return wherebyLoaded; return wherebyLoaded;
} };
export default function Room(details: RoomDetails) { export default function Room(details: RoomDetails) {
const wherebyLoaded = useWhereby(); const wherebyLoaded = useWhereby();
@@ -253,7 +305,6 @@ export default function Room(details: RoomDetails) {
); );
} }
return ( return (
<> <>
{roomUrl && meetingId && wherebyLoaded && ( {roomUrl && meetingId && wherebyLoaded && (
@@ -263,7 +314,12 @@ export default function Room(details: RoomDetails) {
room={roomUrl} room={roomUrl}
style={{ width: "100vw", height: "100vh" }} 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 = ( const useRoomMeeting = (
roomName: string | null | undefined roomName: string | null | undefined,
): ErrorMeeting | LoadingMeeting | SuccessMeeting => { ): ErrorMeeting | LoadingMeeting | SuccessMeeting => {
const [response, setResponse] = useState<Meeting | null>(null); const [response, setResponse] = useState<Meeting | null>(null);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
@@ -55,7 +55,7 @@ const useRoomMeeting = (
if (shouldShowHuman && error.status !== 404) { if (shouldShowHuman && error.status !== 404) {
setError( setError(
error, 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 { } else {
setError(error); setError(error);

View File

@@ -239,6 +239,57 @@ export const $GetTranscript = {
], ],
title: "Target Language", 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: { participants: {
anyOf: [ anyOf: [
{ {
@@ -253,6 +304,127 @@ export const $GetTranscript = {
], ],
title: "Participants", 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: { reviewed: {
type: "boolean", type: "boolean",
title: "Reviewed", title: "Reviewed",
@@ -319,12 +491,11 @@ export const $GetTranscript = {
"created_at", "created_at",
"source_language", "source_language",
"target_language", "target_language",
"participants",
"reviewed", "reviewed",
"meeting_id", "meeting_id",
"source_kind", "source_kind",
], ],
title: "GetTranscript", title: "GetTranscriptMinimal",
} as const; } as const;
export const $GetTranscriptSegmentTopic = { export const $GetTranscriptSegmentTopic = {
@@ -577,11 +748,11 @@ export const $MeetingConsentRequest = {
title: "MeetingConsentRequest", title: "MeetingConsentRequest",
} as const; } as const;
export const $Page_GetTranscript_ = { export const $Page_GetTranscriptMinimal_ = {
properties: { properties: {
items: { items: {
items: { items: {
$ref: "#/components/schemas/GetTranscript", $ref: "#/components/schemas/GetTranscriptMinimal",
}, },
type: "array", type: "array",
title: "Items", title: "Items",
@@ -630,7 +801,7 @@ export const $Page_GetTranscript_ = {
}, },
type: "object", type: "object",
required: ["items", "total", "page", "size"], required: ["items", "total", "page", "size"],
title: "Page[GetTranscript]", title: "Page[GetTranscriptMinimal]",
} as const; } as const;
export const $Page_Room_ = { export const $Page_Room_ = {

View File

@@ -233,7 +233,7 @@ export class DefaultService {
* @param data.searchTerm * @param data.searchTerm
* @param data.page Page number * @param data.page Page number
* @param data.size Page size * @param data.size Page size
* @returns Page_GetTranscript_ Successful Response * @returns Page_GetTranscriptMinimal_ Successful Response
* @throws ApiError * @throws ApiError
*/ */
public v1TranscriptsList( public v1TranscriptsList(

View File

@@ -50,7 +50,29 @@ export type GetTranscript = {
share_mode?: string; share_mode?: string;
source_language: string | null; source_language: string | null;
target_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; 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; reviewed: boolean;
meeting_id: string | null; meeting_id: string | null;
source_kind: SourceKind; source_kind: SourceKind;
@@ -117,8 +139,8 @@ export type MeetingConsentRequest = {
consent_given: boolean; consent_given: boolean;
}; };
export type Page_GetTranscript_ = { export type Page_GetTranscriptMinimal_ = {
items: Array<GetTranscript>; items: Array<GetTranscriptMinimal>;
total: number; total: number;
page: number | null; page: number | null;
size: number | null; size: number | null;
@@ -316,7 +338,7 @@ export type V1TranscriptsListData = {
sourceKind?: SourceKind | null; sourceKind?: SourceKind | null;
}; };
export type V1TranscriptsListResponse = Page_GetTranscript_; export type V1TranscriptsListResponse = Page_GetTranscriptMinimal_;
export type V1TranscriptsCreateData = { export type V1TranscriptsCreateData = {
requestBody: CreateTranscript; requestBody: CreateTranscript;
@@ -590,7 +612,7 @@ export type $OpenApiTs = {
/** /**
* Successful Response * Successful Response
*/ */
200: Page_GetTranscript_; 200: Page_GetTranscriptMinimal_;
/** /**
* Validation Error * Validation Error
*/ */

View File

@@ -9,7 +9,10 @@ interface WherebyEmbedProps {
} }
// currently used for webinars only // currently used for webinars only
export default function WherebyWebinarEmbed({ roomUrl, onLeave }: WherebyEmbedProps) { export default function WherebyWebinarEmbed({
roomUrl,
onLeave,
}: WherebyEmbedProps) {
const wherebyRef = useRef<HTMLElement>(null); const wherebyRef = useRef<HTMLElement>(null);
// TODO extract common toast logic / styles to be used by consent toast on normal rooms // 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 = type ConsentContextState =
| { ready: false } | { ready: false }
| { | {
ready: true, ready: true;
consentAnsweredForMeetings: Set<string> consentAnsweredForMeetings: Set<string>;
}; };
interface RecordingConsentContextValue { interface RecordingConsentContextValue {
@@ -15,12 +15,16 @@ interface RecordingConsentContextValue {
hasConsent: (meetingId: string) => boolean; hasConsent: (meetingId: string) => boolean;
} }
const RecordingConsentContext = createContext<RecordingConsentContextValue | undefined>(undefined); const RecordingConsentContext = createContext<
RecordingConsentContextValue | undefined
>(undefined);
export const useRecordingConsent = () => { export const useRecordingConsent = () => {
const context = useContext(RecordingConsentContext); const context = useContext(RecordingConsentContext);
if (!context) { if (!context) {
throw new Error("useRecordingConsent must be used within RecordingConsentProvider"); throw new Error(
"useRecordingConsent must be used within RecordingConsentProvider",
);
} }
return context; return context;
}; };
@@ -31,12 +35,14 @@ interface RecordingConsentProviderProps {
const LOCAL_STORAGE_KEY = "recording_consent_meetings"; 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 [state, setState] = useState<ConsentContextState>({ ready: false });
const safeWriteToStorage = (meetingIds: string[]): void => { const safeWriteToStorage = (meetingIds: string[]): void => {
try { try {
if (typeof window !== 'undefined' && window.localStorage) { if (typeof window !== "undefined" && window.localStorage) {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(meetingIds)); localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(meetingIds));
} }
} catch (error) { } catch (error) {
@@ -46,7 +52,6 @@ export const RecordingConsentProvider: React.FC<RecordingConsentProviderProps> =
// writes to local storage and to the state of context both // writes to local storage and to the state of context both
const touch = (meetingId: string): void => { const touch = (meetingId: string): void => {
if (!state.ready) { if (!state.ready) {
console.warn("Attempted to touch consent before context is ready"); console.warn("Attempted to touch consent before context is ready");
return; return;
@@ -54,9 +59,9 @@ export const RecordingConsentProvider: React.FC<RecordingConsentProviderProps> =
// has success regardless local storage write success: we don't handle that // has success regardless local storage write success: we don't handle that
// and don't want to crash anything with just consent functionality // and don't want to crash anything with just consent functionality
const newSet = state.consentAnsweredForMeetings.has(meetingId) ? const newSet = state.consentAnsweredForMeetings.has(meetingId)
state.consentAnsweredForMeetings : ? state.consentAnsweredForMeetings
new Set([...state.consentAnsweredForMeetings, meetingId]); : new Set([...state.consentAnsweredForMeetings, meetingId]);
// note: preserves the set insertion order // note: preserves the set insertion order
const array = Array.from(newSet).slice(-5); // Keep latest 5 const array = Array.from(newSet).slice(-5); // Keep latest 5
safeWriteToStorage(array); safeWriteToStorage(array);
@@ -71,7 +76,7 @@ export const RecordingConsentProvider: React.FC<RecordingConsentProviderProps> =
// initialize on mount // initialize on mount
useEffect(() => { useEffect(() => {
try { try {
if (typeof window === 'undefined' || !window.localStorage) { if (typeof window === "undefined" || !window.localStorage) {
setState({ ready: true, consentAnsweredForMeetings: new Set() }); setState({ ready: true, consentAnsweredForMeetings: new Set() });
return; return;
} }
@@ -90,7 +95,9 @@ export const RecordingConsentProvider: React.FC<RecordingConsentProviderProps> =
} }
// pre-historic way of parsing! // 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 }); setState({ ready: true, consentAnsweredForMeetings });
} catch (error) { } catch (error) {
// we don't want to fail the page here; the component is not essential. // we don't want to fail the page here; the component is not essential.