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,
|
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(
|
||||||
|
|||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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 (
|
||||||
Loading error: {error}
|
<Text fontSize="sm" pt="1" pl="2">
|
||||||
</Text>
|
Loading error: {error}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (mp3.audioDeleted) {
|
if (mp3.audioDeleted) {
|
||||||
return <Text fontSize="sm" pt="1" pl="2">
|
return (
|
||||||
This topic file has been deleted.
|
<Text fontSize="sm" pt="1" pl="2">
|
||||||
</Text>
|
This topic file has been deleted.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Skeleton
|
<Skeleton
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ const useAudioDevice = () => {
|
|||||||
permissionDenied,
|
permissionDenied,
|
||||||
audioDevices,
|
audioDevices,
|
||||||
getAudioStream,
|
getAudioStream,
|
||||||
requestPermission
|
requestPermission,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,28 +51,29 @@ 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;
|
||||||
|
|
||||||
let deleted: boolean | null = null;
|
let deleted: boolean | null = null;
|
||||||
|
|
||||||
setTranscriptMetadataLoading(true);
|
setTranscriptMetadataLoading(true);
|
||||||
|
|
||||||
const audioElement = document.createElement("audio");
|
const audioElement = document.createElement("audio");
|
||||||
audioElement.src = `${api_url}/v1/transcripts/${transcriptId}/audio/mp3`;
|
audioElement.src = `${api_url}/v1/transcripts/${transcriptId}/audio/mp3`;
|
||||||
audioElement.crossOrigin = "anonymous";
|
audioElement.crossOrigin = "anonymous";
|
||||||
audioElement.preload = "auto";
|
audioElement.preload = "auto";
|
||||||
|
|
||||||
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);
|
||||||
setAudioLoadingError(null);
|
setAudioLoadingError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleError = () => {
|
const handleError = () => {
|
||||||
setAudioLoading(false);
|
setAudioLoading(false);
|
||||||
if (deleted) {
|
if (deleted) {
|
||||||
@@ -77,18 +82,18 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
|
|||||||
}
|
}
|
||||||
setAudioLoadingError("Failed to load audio");
|
setAudioLoadingError("Failed to load audio");
|
||||||
};
|
};
|
||||||
|
|
||||||
audioElement.addEventListener('canplay', handleCanPlay);
|
|
||||||
audioElement.addEventListener('error', handleError);
|
|
||||||
|
|
||||||
setMedia(audioElement);
|
|
||||||
|
|
||||||
|
audioElement.addEventListener("canplay", handleCanPlay);
|
||||||
|
audioElement.addEventListener("error", handleError);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,34 +69,40 @@ 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
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const handleConsent = useCallback(async (meetingId: string, given: boolean) => {
|
|
||||||
if (!api) return;
|
|
||||||
|
|
||||||
setConsentLoading(true);
|
const handleConsent = useCallback(
|
||||||
|
async (meetingId: string, given: boolean) => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
try {
|
setConsentLoading(true);
|
||||||
await api.v1MeetingAudioConsent({
|
|
||||||
meetingId,
|
|
||||||
requestBody: { consent_given: given }
|
|
||||||
});
|
|
||||||
|
|
||||||
touch(meetingId);
|
try {
|
||||||
} catch (error) {
|
await api.v1MeetingAudioConsent({
|
||||||
console.error('Error submitting consent:', error);
|
meetingId,
|
||||||
} finally {
|
requestBody: { consent_given: given },
|
||||||
setConsentLoading(false);
|
});
|
||||||
}
|
|
||||||
}, [api, touch]);
|
touch(meetingId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error submitting consent:", error);
|
||||||
|
} finally {
|
||||||
|
setConsentLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[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")
|
||||||
setWherebyLoaded(true);
|
.then(() => {
|
||||||
}).catch(console.error.bind(console));
|
setWherebyLoaded(true);
|
||||||
|
})
|
||||||
|
.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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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_ = {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
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;
|
||||||
}
|
}
|
||||||
@@ -81,7 +86,7 @@ export const RecordingConsentProvider: React.FC<RecordingConsentProviderProps> =
|
|||||||
setState({ ready: true, consentAnsweredForMeetings: new Set() });
|
setState({ ready: true, consentAnsweredForMeetings: new Set() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = JSON.parse(stored);
|
const parsed = JSON.parse(stored);
|
||||||
if (!Array.isArray(parsed)) {
|
if (!Array.isArray(parsed)) {
|
||||||
console.warn("Invalid consent data format in localStorage, resetting");
|
console.warn("Invalid consent data format in localStorage, resetting");
|
||||||
@@ -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.
|
||||||
@@ -110,4 +117,4 @@ export const RecordingConsentProvider: React.FC<RecordingConsentProviderProps> =
|
|||||||
{children}
|
{children}
|
||||||
</RecordingConsentContext.Provider>
|
</RecordingConsentContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user