Merge branch 'main' into mathieu/calendar-integration-rebased2

This commit is contained in:
2025-09-10 18:52:41 -06:00
31 changed files with 170 additions and 218 deletions

View File

@@ -7,9 +7,10 @@ import {
FaMicrophone,
FaGear,
} from "react-icons/fa6";
import { TranscriptStatus } from "../../../lib/transcript";
interface TranscriptStatusIconProps {
status: string;
status: TranscriptStatus;
}
export default function TranscriptStatusIcon({

View File

@@ -5,6 +5,7 @@ import useParticipants from "../../useParticipants";
import { Box, Flex, Text, Accordion } from "@chakra-ui/react";
import { featureEnabled } from "../../../../domainContext";
import { TopicItem } from "./TopicItem";
import { TranscriptStatus } from "../../../../lib/transcript";
type TopicListProps = {
topics: Topic[];
@@ -14,7 +15,7 @@ type TopicListProps = {
];
autoscroll: boolean;
transcriptId: string;
status: string;
status: TranscriptStatus | null;
currentTranscriptText: any;
};

View File

@@ -9,8 +9,10 @@ import ParticipantList from "./participantList";
import type { components } from "../../../../reflector-api";
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
import { SelectedText, selectedTextIsTimeSlice } from "./types";
import { useTranscriptUpdate } from "../../../../lib/apiHooks";
import useTranscript from "../../useTranscript";
import {
useTranscriptGet,
useTranscriptUpdate,
} from "../../../../lib/apiHooks";
import { useError } from "../../../../(errors)/errorContext";
import { useRouter } from "next/navigation";
import { Box, Grid } from "@chakra-ui/react";
@@ -25,7 +27,7 @@ export default function TranscriptCorrect({
params: { transcriptId },
}: TranscriptCorrect) {
const updateTranscriptMutation = useTranscriptUpdate();
const transcript = useTranscript(transcriptId);
const transcript = useTranscriptGet(transcriptId);
const stateCurrentTopic = useState<GetTranscriptTopic>();
const [currentTopic, _sct] = stateCurrentTopic;
const stateSelectedText = useState<SelectedText>();
@@ -36,7 +38,7 @@ export default function TranscriptCorrect({
const router = useRouter();
const markAsDone = async () => {
if (transcript.response && !transcript.response.reviewed) {
if (transcript.data && !transcript.data.reviewed) {
try {
await updateTranscriptMutation.mutateAsync({
params: {
@@ -114,7 +116,7 @@ export default function TranscriptCorrect({
}}
/>
</Grid>
{transcript.response && !transcript.response?.reviewed && (
{transcript.data && !transcript.data?.reviewed && (
<div className="flex flex-row justify-end">
<button
className="p-2 px-4 rounded bg-green-400"

View File

@@ -1,6 +1,5 @@
"use client";
import Modal from "../modal";
import useTranscript from "../useTranscript";
import useTopics from "../useTopics";
import useWaveform from "../useWaveform";
import useMp3 from "../useMp3";
@@ -12,6 +11,8 @@ import TranscriptTitle from "../transcriptTitle";
import Player from "../player";
import { useRouter } from "next/navigation";
import { Box, Flex, Grid, GridItem, Skeleton, Text } from "@chakra-ui/react";
import { useTranscriptGet } from "../../../lib/apiHooks";
import { TranscriptStatus } from "../../../lib/transcript";
type TranscriptDetails = {
params: {
@@ -22,11 +23,15 @@ type TranscriptDetails = {
export default function TranscriptDetails(details: TranscriptDetails) {
const transcriptId = details.params.transcriptId;
const router = useRouter();
const statusToRedirect = ["idle", "recording", "processing"];
const statusToRedirect = [
"idle",
"recording",
"processing",
] satisfies TranscriptStatus[] as TranscriptStatus[];
const transcript = useTranscript(transcriptId);
const transcriptStatus = transcript.response?.status;
const waiting = statusToRedirect.includes(transcriptStatus || "");
const transcript = useTranscriptGet(transcriptId);
const waiting =
transcript.data && statusToRedirect.includes(transcript.data.status);
const mp3 = useMp3(transcriptId, waiting);
const topics = useTopics(transcriptId);
@@ -56,7 +61,7 @@ export default function TranscriptDetails(details: TranscriptDetails) {
);
}
if (transcript?.loading || topics?.loading) {
if (transcript?.isLoading || topics?.loading) {
return <Modal title="Loading" text={"Loading transcript..."} />;
}
@@ -86,7 +91,7 @@ export default function TranscriptDetails(details: TranscriptDetails) {
useActiveTopic={useActiveTopic}
waveform={waveform.waveform}
media={mp3.media}
mediaDuration={transcript.response?.duration || null}
mediaDuration={transcript.data?.duration || null}
/>
) : !mp3.loading && (waveform.error || mp3.error) ? (
<Box p={4} bg="red.100" borderRadius="md">
@@ -116,10 +121,10 @@ export default function TranscriptDetails(details: TranscriptDetails) {
<Flex direction="column" gap={0}>
<Flex alignItems="center" gap={2}>
<TranscriptTitle
title={transcript.response?.title || "Unnamed Transcript"}
title={transcript.data?.title || "Unnamed Transcript"}
transcriptId={transcriptId}
onUpdate={(newTitle) => {
transcript.reload();
transcript.refetch().then(() => {});
}}
/>
</Flex>
@@ -136,23 +141,23 @@ export default function TranscriptDetails(details: TranscriptDetails) {
useActiveTopic={useActiveTopic}
autoscroll={false}
transcriptId={transcriptId}
status={transcript.response?.status}
status={transcript.data?.status || null}
currentTranscriptText=""
/>
{transcript.response && topics.topics ? (
{transcript.data && topics.topics ? (
<>
<FinalSummary
transcriptResponse={transcript.response}
transcriptResponse={transcript.data}
topicsResponse={topics.topics}
onUpdate={(newSummary) => {
transcript.reload();
onUpdate={() => {
transcript.refetch();
}}
/>
</>
) : (
<Flex justify={"center"} alignItems={"center"} h={"100%"}>
<div className="flex flex-col h-full justify-center content-center">
{transcript.response.status == "processing" ? (
{transcript?.data?.status == "processing" ? (
<Text>Loading Transcript</Text>
) : (
<Text>

View File

@@ -2,7 +2,6 @@
import { useEffect, useState } from "react";
import Recorder from "../../recorder";
import { TopicList } from "../_components/TopicList";
import useTranscript from "../../useTranscript";
import { useWebSockets } from "../../useWebSockets";
import { Topic } from "../../webSocketTypes";
import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock";
@@ -11,6 +10,8 @@ import useMp3 from "../../useMp3";
import WaveformLoading from "../../waveformLoading";
import { Box, Text, Grid, Heading, VStack, Flex } from "@chakra-ui/react";
import LiveTrancription from "../../liveTranscription";
import { useTranscriptGet } from "../../../../lib/apiHooks";
import { TranscriptStatus } from "../../../../lib/transcript";
type TranscriptDetails = {
params: {
@@ -19,7 +20,7 @@ type TranscriptDetails = {
};
const TranscriptRecord = (details: TranscriptDetails) => {
const transcript = useTranscript(details.params.transcriptId);
const transcript = useTranscriptGet(details.params.transcriptId);
const [transcriptStarted, setTranscriptStarted] = useState(false);
const useActiveTopic = useState<Topic | null>(null);
@@ -29,8 +30,8 @@ const TranscriptRecord = (details: TranscriptDetails) => {
const router = useRouter();
const [status, setStatus] = useState(
webSockets.status.value || transcript.response?.status || "idle",
const [status, setStatus] = useState<TranscriptStatus>(
webSockets.status?.value || transcript.data?.status || "idle",
);
useEffect(() => {
@@ -41,7 +42,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
useEffect(() => {
//TODO HANDLE ERROR STATUS BETTER
const newStatus =
webSockets.status.value || transcript.response?.status || "idle";
webSockets.status?.value || transcript.data?.status || "idle";
setStatus(newStatus);
if (newStatus && (newStatus == "ended" || newStatus == "error")) {
console.log(newStatus, "redirecting");
@@ -49,7 +50,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
const newUrl = "/transcripts/" + details.params.transcriptId;
router.replace(newUrl);
}
}, [webSockets.status.value, transcript.response?.status]);
}, [webSockets.status?.value, transcript.data?.status]);
useEffect(() => {
if (webSockets.waveform && webSockets.waveform) mp3.getNow();

View File

@@ -1,12 +1,12 @@
"use client";
import { useEffect, useState } from "react";
import useTranscript from "../../useTranscript";
import { useWebSockets } from "../../useWebSockets";
import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock";
import { useRouter } from "next/navigation";
import useMp3 from "../../useMp3";
import { Center, VStack, Text, Heading, Button } from "@chakra-ui/react";
import FileUploadButton from "../../fileUploadButton";
import { useTranscriptGet } from "../../../../lib/apiHooks";
type TranscriptUpload = {
params: {
@@ -15,7 +15,7 @@ type TranscriptUpload = {
};
const TranscriptUpload = (details: TranscriptUpload) => {
const transcript = useTranscript(details.params.transcriptId);
const transcript = useTranscriptGet(details.params.transcriptId);
const [transcriptStarted, setTranscriptStarted] = useState(false);
const webSockets = useWebSockets(details.params.transcriptId);
@@ -25,13 +25,13 @@ const TranscriptUpload = (details: TranscriptUpload) => {
const router = useRouter();
const [status_, setStatus] = useState(
webSockets.status.value || transcript.response?.status || "idle",
webSockets.status?.value || transcript.data?.status || "idle",
);
// status is obviously done if we have transcript
const status =
!transcript.loading && transcript.response?.status === "ended"
? transcript.response?.status
!transcript.isLoading && transcript.data?.status === "ended"
? transcript.data?.status
: status_;
useEffect(() => {
@@ -43,9 +43,9 @@ const TranscriptUpload = (details: TranscriptUpload) => {
//TODO HANDLE ERROR STATUS BETTER
// TODO deprecate webSockets.status.value / depend on transcript.response?.status from query lib
const newStatus =
transcript.response?.status === "ended"
transcript.data?.status === "ended"
? "ended"
: webSockets.status.value || transcript.response?.status || "idle";
: webSockets.status?.value || transcript.data?.status || "idle";
setStatus(newStatus);
if (newStatus && (newStatus == "ended" || newStatus == "error")) {
console.log(newStatus, "redirecting");
@@ -53,7 +53,7 @@ const TranscriptUpload = (details: TranscriptUpload) => {
const newUrl = "/transcripts/" + details.params.transcriptId;
router.replace(newUrl);
}
}, [webSockets.status.value, transcript.response?.status]);
}, [webSockets.status?.value, transcript.data?.status]);
useEffect(() => {
if (webSockets.waveform && webSockets.waveform) mp3.getNow();

View File

@@ -11,10 +11,11 @@ import useAudioDevice from "./useAudioDevice";
import { Box, Flex, IconButton, Menu, RadioGroup } from "@chakra-ui/react";
import { LuScreenShare, LuMic, LuPlay, LuCircleStop } from "react-icons/lu";
import { RECORD_A_MEETING_URL } from "../../api/urls";
import { TranscriptStatus } from "../../lib/transcript";
type RecorderProps = {
transcriptId: string;
status: string;
status: TranscriptStatus;
};
export default function Recorder(props: RecorderProps) {

View File

@@ -1,69 +0,0 @@
import type { components } from "../../reflector-api";
import { useTranscriptGet } from "../../lib/apiHooks";
type GetTranscript = components["schemas"]["GetTranscript"];
type ErrorTranscript = {
error: Error;
loading: false;
response: null;
reload: () => void;
};
type LoadingTranscript = {
response: null;
loading: true;
error: false;
reload: () => void;
};
type SuccessTranscript = {
response: GetTranscript;
loading: false;
error: null;
reload: () => void;
};
const useTranscript = (
id: string | null,
): ErrorTranscript | LoadingTranscript | SuccessTranscript => {
const { data, isLoading, error, refetch } = useTranscriptGet(id);
// Map to the expected return format
if (isLoading) {
return {
response: null,
loading: true,
error: false,
reload: refetch,
};
}
if (error) {
return {
error: error as Error,
loading: false,
response: null,
reload: refetch,
};
}
// Check if data is undefined or null
if (!data) {
return {
response: null,
loading: true,
error: false,
reload: refetch,
};
}
return {
response: data,
loading: false,
error: null,
reload: refetch,
};
};
export default useTranscript;

View File

@@ -16,7 +16,7 @@ export type UseWebSockets = {
title: string;
topics: Topic[];
finalSummary: FinalSummary;
status: Status;
status: Status | null;
waveform: AudioWaveform | null;
duration: number | null;
};
@@ -34,7 +34,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
const [finalSummary, setFinalSummary] = useState<FinalSummary>({
summary: "",
});
const [status, setStatus] = useState<Status>({ value: "" });
const [status, setStatus] = useState<Status | null>(null);
const { setError } = useError();
const { websocket_url: websocketUrl } = useContext(DomainContext);

View File

@@ -1,4 +1,5 @@
import type { components } from "../../reflector-api";
import type { TranscriptStatus } from "../../lib/transcript";
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
@@ -13,7 +14,7 @@ export type FinalSummary = {
};
export type Status = {
value: string;
value: TranscriptStatus;
};
export type TranslatedTopic = {

View File

@@ -389,7 +389,6 @@ export default function MeetingSelection({
</HStack>
</Box>
)}
</Flex>
</Flex>
);

View File

@@ -1,9 +1,21 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Box, Button, HStack, Icon, Spinner, Text, VStack } from "@chakra-ui/react";
import {
Box,
Button,
HStack,
Icon,
Spinner,
Text,
VStack,
} from "@chakra-ui/react";
import { useRouter } from "next/navigation";
import { useRoomGetByName, useRoomJoinMeeting, useMeetingAudioConsent } from "../../lib/apiHooks";
import {
useRoomGetByName,
useRoomJoinMeeting,
useMeetingAudioConsent,
} from "../../lib/apiHooks";
import { useRecordingConsent } from "../../recordingConsentContext";
import { toaster } from "../../components/ui/toaster";
import { FaBars } from "react-icons/fa6";
@@ -248,8 +260,10 @@ export default function MeetingPage({ params }: MeetingPageProps) {
const joinMeetingMutation = useRoomJoinMeeting();
const room = roomQuery.data;
const isLoading = roomQuery.isLoading || (!attemptedJoin && room && !joinMeetingMutation.data);
const isLoading =
roomQuery.isLoading ||
(!attemptedJoin && room && !joinMeetingMutation.data);
// Try to join the meeting when room is loaded
useEffect(() => {
if (room && !attemptedJoin && !joinMeetingMutation.isPending) {
@@ -326,10 +340,7 @@ export default function MeetingPage({ params }: MeetingPageProps) {
style={{ width: "100vw", height: "100vh" }}
/>
{recordingType && recordingTypeRequiresConsent(recordingType) && (
<ConsentDialogButton
meetingId={meetingId}
wherebyRef={wherebyRef}
/>
<ConsentDialogButton meetingId={meetingId} wherebyRef={wherebyRef} />
)}
</>
);
@@ -339,10 +350,7 @@ export default function MeetingPage({ params }: MeetingPageProps) {
// But keeping it as a fallback
return (
<Box display="flex" flexDirection="column" minH="100vh">
<MinimalHeader
roomName={roomName}
displayName={room?.name}
/>
<MinimalHeader roomName={roomName} displayName={room?.name} />
<Box
display="flex"
justifyContent="center"

View File

@@ -21,7 +21,13 @@ import { toaster } from "../components/ui/toaster";
import { useRouter } from "next/navigation";
import { notFound } from "next/navigation";
import { useRecordingConsent } from "../recordingConsentContext";
import { useMeetingAudioConsent, useRoomGetByName, useRoomActiveMeetings, useRoomUpcomingMeetings, useRoomsCreateMeeting } from "../lib/apiHooks";
import {
useMeetingAudioConsent,
useRoomGetByName,
useRoomActiveMeetings,
useRoomUpcomingMeetings,
useRoomsCreateMeeting,
} from "../lib/apiHooks";
import type { components } from "../reflector-api";
import MeetingSelection from "./MeetingSelection";
import useRoomMeeting from "./useRoomMeeting";
@@ -281,12 +287,11 @@ export default function Room(details: RoomDetails) {
const roomUrl =
roomMeeting?.response?.host_room_url || roomMeeting?.response?.room_url;
const isLoading = status === "loading" || roomQuery.isLoading || roomMeeting?.loading;
const isLoading =
status === "loading" || roomQuery.isLoading || roomMeeting?.loading;
const isOwner =
isAuthenticated && room
? auth.user?.id === room.user_id
: false;
isAuthenticated && room ? auth.user?.id === room.user_id : false;
const meetingId = roomMeeting?.response?.id;

View File

@@ -88,8 +88,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
};
// not useEffect, we need it ASAP
// apparently, still no guarantee this code runs before mutations are fired
configureApiAuth(
contextValue.status === "authenticated" ? contextValue.accessToken : null,
contextValue.status === "authenticated"
? contextValue.accessToken
: contextValue.status === "loading"
? undefined
: null,
);
return (

View File

@@ -4,18 +4,16 @@ import "@whereby.com/browser-sdk/embed";
import { Box, Button, HStack, Text, Link } from "@chakra-ui/react";
import { toaster } from "../components/ui/toaster";
interface WherebyEmbedProps {
interface WherebyWebinarEmbedProps {
roomUrl: string;
onLeave?: () => void;
isWebinar?: boolean;
}
// used for both webinars and meetings
// used for webinars only
export default function WherebyWebinarEmbed({
roomUrl,
onLeave,
isWebinar = false,
}: WherebyEmbedProps) {
}: WherebyWebinarEmbedProps) {
const wherebyRef = useRef<HTMLElement>(null);
// TODO extract common toast logic / styles to be used by consent toast on normal rooms
@@ -28,8 +26,7 @@ export default function WherebyWebinarEmbed({
<Box p={4} bg="white" borderRadius="md" boxShadow="md">
<HStack justifyContent="space-between" alignItems="center">
<Text>
This {isWebinar ? "webinar" : "meeting"} is being recorded. By
continuing, you agree to our{" "}
This webinar is being recorded. By continuing, you agree to our{" "}
<Link
href="https://monadical.com/privacy"
color="blue.600"

View File

@@ -2,12 +2,6 @@
import createClient from "openapi-fetch";
import type { paths } from "../reflector-api";
import {
queryOptions,
useMutation,
useQuery,
useSuspenseQuery,
} from "@tanstack/react-query";
import createFetchClient from "openapi-react-query";
import { assertExistsAndNonEmptyString } from "./utils";
import { isBuildPhase } from "./next";
@@ -16,18 +10,31 @@ const API_URL = !isBuildPhase
? assertExistsAndNonEmptyString(process.env.NEXT_PUBLIC_API_URL)
: "http://localhost";
// Create the base openapi-fetch client with a default URL
// The actual URL will be set via middleware in AuthProvider
export const client = createClient<paths>({
baseUrl: API_URL,
});
export const $api = createFetchClient<paths>(client);
let currentAuthToken: string | null | undefined = null;
const waitForAuthTokenDefinitivePresenceOrAbscence = async () => {
let tries = 0;
let time = 0;
const STEP = 100;
while (currentAuthToken === undefined) {
await new Promise((resolve) => setTimeout(resolve, STEP));
time += STEP;
tries++;
// most likely first try is more than enough, if it's more there's already something weird happens
if (tries > 10) {
// even when there's no auth assumed at all, we probably should explicitly call configureApiAuth(null)
throw new Error(
`Could not get auth token definitive presence/absence in ${time}ms. not calling configureApiAuth?`,
);
}
}
};
client.use({
onRequest({ request }) {
async onRequest({ request }) {
await waitForAuthTokenDefinitivePresenceOrAbscence();
if (currentAuthToken) {
request.headers.set("Authorization", `Bearer ${currentAuthToken}`);
}
@@ -44,7 +51,13 @@ client.use({
},
});
export const $api = createFetchClient<paths>(client);
let currentAuthToken: string | null | undefined = undefined;
// the function contract: lightweight, idempotent
export const configureApiAuth = (token: string | null | undefined) => {
// watch only for the initial loading; "reloading" state assumes token presence/absence
if (token === undefined && currentAuthToken !== undefined) return;
currentAuthToken = token;
};

View File

@@ -96,8 +96,6 @@ export function useTranscriptProcess() {
}
export function useTranscriptGet(transcriptId: string | null) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/transcripts/{transcript_id}",
@@ -109,7 +107,7 @@ export function useTranscriptGet(transcriptId: string | null) {
},
},
{
enabled: !!transcriptId && isAuthenticated,
enabled: !!transcriptId,
},
);
}
@@ -292,18 +290,16 @@ export function useTranscriptUploadAudio() {
}
export function useTranscriptWaveform(transcriptId: string | null) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/transcripts/{transcript_id}/audio/waveform",
{
params: {
path: { transcript_id: transcriptId || "" },
path: { transcript_id: transcriptId! },
},
},
{
enabled: !!transcriptId && isAuthenticated,
enabled: !!transcriptId,
},
);
}
@@ -316,7 +312,7 @@ export function useTranscriptMP3(transcriptId: string | null) {
"/v1/transcripts/{transcript_id}/audio/mp3",
{
params: {
path: { transcript_id: transcriptId || "" },
path: { transcript_id: transcriptId! },
},
},
{
@@ -326,8 +322,6 @@ export function useTranscriptMP3(transcriptId: string | null) {
}
export function useTranscriptTopics(transcriptId: string | null) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/transcripts/{transcript_id}/topics",
@@ -337,7 +331,7 @@ export function useTranscriptTopics(transcriptId: string | null) {
},
},
{
enabled: !!transcriptId && isAuthenticated,
enabled: !!transcriptId,
},
);
}

View File

@@ -0,0 +1,5 @@
import { components } from "../reflector-api";
type ApiTranscriptStatus = components["schemas"]["GetTranscript"]["status"];
export type TranscriptStatus = ApiTranscriptStatus;

View File

@@ -1243,8 +1243,17 @@ export interface components {
source_kind: components["schemas"]["SourceKind"];
/** Created At */
created_at: string;
/** Status */
status: string;
/**
* Status
* @enum {string}
*/
status:
| "idle"
| "uploaded"
| "recording"
| "processing"
| "error"
| "ended";
/** Rank */
rank: number;
/**

View File

@@ -150,15 +150,7 @@ export default function WebinarPage(details: WebinarDetails) {
if (status === WebinarStatus.Live) {
return (
<>
{roomUrl && (
<WherebyEmbed
roomUrl={roomUrl}
onLeave={handleLeave}
isWebinar={true}
/>
)}
</>
<>{roomUrl && <WherebyEmbed roomUrl={roomUrl} onLeave={handleLeave} />}</>
);
}
if (status === WebinarStatus.Ended) {