mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2026-02-04 09:56:47 +00:00
feat: brady bunch (#816)
* brady bunch PRD/tasks * clean dead daily.co code * brady bunch prototype (no-mistakes) * brady bunch prototype (no-mistakes) review * self-review * daily poll time match (no-mistakes) * daily poll self-review (no-mistakes) * daily poll self-review (no-mistakes) * daily co doc * cleanup * cleanup * self-review (no-mistakes) * self-review (no-mistakes) * self-review * self-review * ui typefix * dupe calls error handling proper * daily reflector data model doc * logging style fix * migration merge --------- Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
This commit is contained in:
@@ -3,7 +3,8 @@ import React from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import "../../../styles/markdown.css";
|
||||
import type { components } from "../../../reflector-api";
|
||||
type GetTranscript = components["schemas"]["GetTranscript"];
|
||||
type GetTranscriptWithParticipants =
|
||||
components["schemas"]["GetTranscriptWithParticipants"];
|
||||
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
|
||||
import { useTranscriptUpdate } from "../../../lib/apiHooks";
|
||||
import {
|
||||
@@ -18,7 +19,7 @@ import { LuPen } from "react-icons/lu";
|
||||
import { useError } from "../../../(errors)/errorContext";
|
||||
|
||||
type FinalSummaryProps = {
|
||||
transcript: GetTranscript;
|
||||
transcript: GetTranscriptWithParticipants;
|
||||
topics: GetTranscriptTopic[];
|
||||
onUpdate: (newSummary: string) => void;
|
||||
finalSummaryRef: React.Dispatch<React.SetStateAction<HTMLDivElement | null>>;
|
||||
|
||||
@@ -2,10 +2,11 @@ import type { components } from "../../reflector-api";
|
||||
import { useTranscriptCreate } from "../../lib/apiHooks";
|
||||
|
||||
type CreateTranscript = components["schemas"]["CreateTranscript"];
|
||||
type GetTranscript = components["schemas"]["GetTranscript"];
|
||||
type GetTranscriptWithParticipants =
|
||||
components["schemas"]["GetTranscriptWithParticipants"];
|
||||
|
||||
type UseCreateTranscript = {
|
||||
transcript: GetTranscript | null;
|
||||
transcript: GetTranscriptWithParticipants | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
create: (transcriptCreationDetails: CreateTranscript) => Promise<void>;
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useEffect, useState } from "react";
|
||||
|
||||
import { ShareMode, toShareMode } from "../../lib/shareMode";
|
||||
import type { components } from "../../reflector-api";
|
||||
type GetTranscript = components["schemas"]["GetTranscript"];
|
||||
type GetTranscriptWithParticipants =
|
||||
components["schemas"]["GetTranscriptWithParticipants"];
|
||||
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
|
||||
type UpdateTranscript = components["schemas"]["UpdateTranscript"];
|
||||
import {
|
||||
@@ -27,7 +28,7 @@ import { featureEnabled } from "../../lib/features";
|
||||
|
||||
type ShareAndPrivacyProps = {
|
||||
finalSummaryElement: HTMLDivElement | null;
|
||||
transcript: GetTranscript;
|
||||
transcript: GetTranscriptWithParticipants;
|
||||
topics: GetTranscriptTopic[];
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import type { components } from "../../reflector-api";
|
||||
|
||||
type GetTranscript = components["schemas"]["GetTranscript"];
|
||||
type GetTranscriptWithParticipants =
|
||||
components["schemas"]["GetTranscriptWithParticipants"];
|
||||
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
|
||||
import {
|
||||
BoxProps,
|
||||
@@ -26,7 +27,7 @@ import {
|
||||
import { featureEnabled } from "../../lib/features";
|
||||
|
||||
type ShareZulipProps = {
|
||||
transcript: GetTranscript;
|
||||
transcript: GetTranscriptWithParticipants;
|
||||
topics: GetTranscriptTopic[];
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useState } from "react";
|
||||
import type { components } from "../../reflector-api";
|
||||
|
||||
type UpdateTranscript = components["schemas"]["UpdateTranscript"];
|
||||
type GetTranscript = components["schemas"]["GetTranscript"];
|
||||
type GetTranscriptWithParticipants =
|
||||
components["schemas"]["GetTranscriptWithParticipants"];
|
||||
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
|
||||
import {
|
||||
useTranscriptUpdate,
|
||||
@@ -20,7 +21,7 @@ type TranscriptTitle = {
|
||||
onUpdate: (newTitle: string) => void;
|
||||
|
||||
// share props
|
||||
transcript: GetTranscript | null;
|
||||
transcript: GetTranscriptWithParticipants | null;
|
||||
topics: GetTranscriptTopic[] | null;
|
||||
finalSummaryElement: HTMLDivElement | null;
|
||||
};
|
||||
|
||||
@@ -22,14 +22,29 @@ import DailyIframe, {
|
||||
import type { components } from "../../reflector-api";
|
||||
import { useAuth } from "../../lib/AuthProvider";
|
||||
import { useConsentDialog } from "../../lib/consent";
|
||||
import { useRoomJoinMeeting } from "../../lib/apiHooks";
|
||||
import {
|
||||
useRoomJoinMeeting,
|
||||
useMeetingStartRecording,
|
||||
} from "../../lib/apiHooks";
|
||||
import { omit } from "remeda";
|
||||
import { assertExists } from "../../lib/utils";
|
||||
import { assertMeetingId } from "../../lib/types";
|
||||
import {
|
||||
assertExists,
|
||||
NonEmptyString,
|
||||
parseNonEmptyString,
|
||||
} from "../../lib/utils";
|
||||
import { assertMeetingId, DailyRecordingType } from "../../lib/types";
|
||||
import { useUuidV5 } from "react-uuid-hook";
|
||||
|
||||
const CONSENT_BUTTON_ID = "recording-consent";
|
||||
const RECORDING_INDICATOR_ID = "recording-indicator";
|
||||
|
||||
// Namespace UUID for UUIDv5 generation of raw-tracks instanceIds
|
||||
// DO NOT CHANGE: Breaks instanceId determinism across deployments
|
||||
const RAW_TRACKS_NAMESPACE = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
|
||||
const RECORDING_START_DELAY_MS = 2000;
|
||||
const RECORDING_START_MAX_RETRIES = 5;
|
||||
|
||||
type Meeting = components["schemas"]["Meeting"];
|
||||
type Room = components["schemas"]["RoomDetails"];
|
||||
|
||||
@@ -73,9 +88,7 @@ const useFrame = (
|
||||
cbs: {
|
||||
onLeftMeeting: () => void;
|
||||
onCustomButtonClick: (ev: DailyEventObjectCustomButtonClick) => void;
|
||||
onJoinMeeting: (
|
||||
startRecording: (args: { type: "raw-tracks" }) => void,
|
||||
) => void;
|
||||
onJoinMeeting: () => void;
|
||||
},
|
||||
) => {
|
||||
const [{ frame, joined }, setState] = useState(USE_FRAME_INIT_STATE);
|
||||
@@ -126,7 +139,7 @@ const useFrame = (
|
||||
console.error("frame is null in joined-meeting callback");
|
||||
return;
|
||||
}
|
||||
cbs.onJoinMeeting(frame.startRecording.bind(frame));
|
||||
cbs.onJoinMeeting();
|
||||
};
|
||||
frame.on("joined-meeting", joinCb);
|
||||
return () => {
|
||||
@@ -173,8 +186,15 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) {
|
||||
const authLastUserId = auth.lastUserId;
|
||||
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
||||
const joinMutation = useRoomJoinMeeting();
|
||||
const startRecordingMutation = useMeetingStartRecording();
|
||||
const [joinedMeeting, setJoinedMeeting] = useState<Meeting | null>(null);
|
||||
|
||||
// Generate deterministic instanceIds so all participants use SAME IDs
|
||||
const cloudInstanceId = parseNonEmptyString(meeting.id);
|
||||
const rawTracksInstanceId = parseNonEmptyString(
|
||||
useUuidV5(meeting.id, RAW_TRACKS_NAMESPACE)[0],
|
||||
);
|
||||
|
||||
const roomName = params?.roomName as string;
|
||||
|
||||
const {
|
||||
@@ -228,19 +248,72 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) {
|
||||
],
|
||||
);
|
||||
|
||||
const handleFrameJoinMeeting = useCallback(
|
||||
(startRecording: (args: { type: "raw-tracks" }) => void) => {
|
||||
try {
|
||||
if (meeting.recording_type === "cloud") {
|
||||
console.log("Starting cloud recording");
|
||||
startRecording({ type: "raw-tracks" });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to start recording:", error);
|
||||
}
|
||||
},
|
||||
[meeting.recording_type],
|
||||
);
|
||||
const handleFrameJoinMeeting = useCallback(() => {
|
||||
if (meeting.recording_type === "cloud") {
|
||||
console.log("Starting dual recording via REST API", {
|
||||
cloudInstanceId,
|
||||
rawTracksInstanceId,
|
||||
});
|
||||
|
||||
// Start both cloud and raw-tracks via backend REST API (with retry on 404)
|
||||
// Daily.co needs time to register call as "hosting" for REST API
|
||||
const startRecordingWithRetry = (
|
||||
type: DailyRecordingType,
|
||||
instanceId: NonEmptyString,
|
||||
attempt: number = 1,
|
||||
) => {
|
||||
setTimeout(() => {
|
||||
startRecordingMutation.mutate(
|
||||
{
|
||||
params: {
|
||||
path: {
|
||||
meeting_id: meeting.id,
|
||||
},
|
||||
},
|
||||
body: {
|
||||
type,
|
||||
instanceId,
|
||||
},
|
||||
},
|
||||
{
|
||||
onError: (error: any) => {
|
||||
const errorText = error?.detail || error?.message || "";
|
||||
const is404NotHosting = errorText.includes(
|
||||
"does not seem to be hosting a call",
|
||||
);
|
||||
const isActiveStream = errorText.includes(
|
||||
"has an active stream",
|
||||
);
|
||||
|
||||
if (is404NotHosting && attempt < RECORDING_START_MAX_RETRIES) {
|
||||
console.log(
|
||||
`${type}: Call not hosting yet, retry ${attempt + 1}/${RECORDING_START_MAX_RETRIES} in ${RECORDING_START_DELAY_MS}ms...`,
|
||||
);
|
||||
startRecordingWithRetry(type, instanceId, attempt + 1);
|
||||
} else if (isActiveStream) {
|
||||
console.log(
|
||||
`${type}: Recording already active (started by another participant)`,
|
||||
);
|
||||
} else {
|
||||
console.error(`Failed to start ${type} recording:`, error);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
}, RECORDING_START_DELAY_MS);
|
||||
};
|
||||
|
||||
// Start both recordings
|
||||
startRecordingWithRetry("cloud", cloudInstanceId);
|
||||
startRecordingWithRetry("raw-tracks", rawTracksInstanceId);
|
||||
}
|
||||
}, [
|
||||
meeting.recording_type,
|
||||
meeting.id,
|
||||
startRecordingMutation,
|
||||
cloudInstanceId,
|
||||
rawTracksInstanceId,
|
||||
]);
|
||||
|
||||
const recordingIconUrl = useMemo(
|
||||
() => new URL("/recording-icon.svg", window.location.origin),
|
||||
|
||||
@@ -567,6 +567,20 @@ export function useTranscriptSpeakerMerge() {
|
||||
);
|
||||
}
|
||||
|
||||
export function useMeetingStartRecording() {
|
||||
const { setError } = useError();
|
||||
|
||||
return $api.useMutation(
|
||||
"post",
|
||||
"/v1/meetings/{meeting_id}/recordings/start",
|
||||
{
|
||||
onError: (error) => {
|
||||
setError(error as Error, "Failed to start recording");
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useMeetingAudioConsent() {
|
||||
const { setError } = useError();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { components } from "../reflector-api";
|
||||
|
||||
type ApiTranscriptStatus = components["schemas"]["GetTranscript"]["status"];
|
||||
type ApiTranscriptStatus =
|
||||
components["schemas"]["GetTranscriptWithParticipants"]["status"];
|
||||
|
||||
export type TranscriptStatus = ApiTranscriptStatus;
|
||||
|
||||
@@ -89,3 +89,5 @@ export const assertMeetingId = (s: string): MeetingId => {
|
||||
// just cast for now
|
||||
return nes as MeetingId;
|
||||
};
|
||||
|
||||
export type DailyRecordingType = "cloud" | "raw-tracks";
|
||||
|
||||
79
www/app/reflector-api.d.ts
vendored
79
www/app/reflector-api.d.ts
vendored
@@ -75,6 +75,31 @@ export interface paths {
|
||||
patch: operations["v1_meeting_deactivate"];
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/meetings/{meeting_id}/recordings/start": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Start Recording
|
||||
* @description Start cloud or raw-tracks recording via Daily.co REST API.
|
||||
*
|
||||
* Both cloud and raw-tracks are started via REST API to bypass enable_recording limitation of allowing only 1 recording at a time.
|
||||
* Uses different instanceIds for cloud vs raw-tracks (same won't work)
|
||||
*
|
||||
* Note: No authentication required - anonymous users supported. TODO this is a DOS vector
|
||||
*/
|
||||
post: operations["v1_start_recording"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/rooms": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1544,6 +1569,10 @@ export interface components {
|
||||
* @enum {string}
|
||||
*/
|
||||
platform: "whereby" | "daily";
|
||||
/** Daily Composed Video S3 Key */
|
||||
daily_composed_video_s3_key?: string | null;
|
||||
/** Daily Composed Video Duration */
|
||||
daily_composed_video_duration?: number | null;
|
||||
};
|
||||
/** MeetingConsentRequest */
|
||||
MeetingConsentRequest: {
|
||||
@@ -1818,6 +1847,19 @@ export interface components {
|
||||
/** Words */
|
||||
words: components["schemas"]["Word"][];
|
||||
};
|
||||
/** StartRecordingRequest */
|
||||
StartRecordingRequest: {
|
||||
/**
|
||||
* Type
|
||||
* @enum {string}
|
||||
*/
|
||||
type: "cloud" | "raw-tracks";
|
||||
/**
|
||||
* Instanceid
|
||||
* Format: uuid
|
||||
*/
|
||||
instanceId: string;
|
||||
};
|
||||
/** Stream */
|
||||
Stream: {
|
||||
/** Stream Id */
|
||||
@@ -2126,6 +2168,43 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
v1_start_recording: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
meeting_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["StartRecordingRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
v1_rooms_list: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"react-markdown": "^9.0.0",
|
||||
"react-qr-code": "^2.0.12",
|
||||
"react-select-search": "^4.1.7",
|
||||
"react-uuid-hook": "^0.0.6",
|
||||
"redlock": "5.0.0-beta.2",
|
||||
"remeda": "^2.31.1",
|
||||
"sass": "^1.63.6",
|
||||
|
||||
25
www/pnpm-lock.yaml
generated
25
www/pnpm-lock.yaml
generated
@@ -106,6 +106,9 @@ importers:
|
||||
react-select-search:
|
||||
specifier: ^4.1.7
|
||||
version: 4.1.8(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-uuid-hook:
|
||||
specifier: ^0.0.6
|
||||
version: 0.0.6(react@18.3.1)
|
||||
redlock:
|
||||
specifier: 5.0.0-beta.2
|
||||
version: 5.0.0-beta.2
|
||||
@@ -7628,6 +7631,14 @@ packages:
|
||||
"@types/react":
|
||||
optional: true
|
||||
|
||||
react-uuid-hook@0.0.6:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-u9+EvFbqpWfLE/ReYFry0vYu1BAg1fY9ekr0XLSDNnfWyrnVFytpurwz5qYsIB0psevuvrpZHIcvu7AjUwqinA==,
|
||||
}
|
||||
peerDependencies:
|
||||
react: ">=16.8.0"
|
||||
|
||||
react@18.3.1:
|
||||
resolution:
|
||||
{
|
||||
@@ -8771,6 +8782,13 @@ packages:
|
||||
integrity: sha512-Fykw5U4eZESbq739BeLvEBFRuJODfrlmjx5eJux7W817LjRaq4b7/i4t2zxQmhcX+fAj4nMfRdTzO4tmwLKn0w==,
|
||||
}
|
||||
|
||||
uuid@13.0.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==,
|
||||
}
|
||||
hasBin: true
|
||||
|
||||
uuid@8.3.2:
|
||||
resolution:
|
||||
{
|
||||
@@ -14570,6 +14588,11 @@ snapshots:
|
||||
optionalDependencies:
|
||||
"@types/react": 18.2.20
|
||||
|
||||
react-uuid-hook@0.0.6(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
uuid: 13.0.0
|
||||
|
||||
react@18.3.1:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
@@ -15401,6 +15424,8 @@ snapshots:
|
||||
|
||||
uuid-validate@0.0.3: {}
|
||||
|
||||
uuid@13.0.0: {}
|
||||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
uuid@9.0.1: {}
|
||||
|
||||
Reference in New Issue
Block a user