mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2026-04-23 13:45:18 +00:00
feat: allow participants to ask for email transcript (#923)
* feat: allow participants to ask for email transcript * fix: set email update in a transaction
This commit is contained in:
committed by
GitHub
parent
41e7b3e84f
commit
55222ecc47
@@ -22,6 +22,8 @@ import DailyIframe, {
|
||||
import type { components } from "../../reflector-api";
|
||||
import { useAuth } from "../../lib/AuthProvider";
|
||||
import { useConsentDialog } from "../../lib/consent";
|
||||
import { useEmailTranscriptDialog } from "../../lib/emailTranscript";
|
||||
import { featureEnabled } from "../../lib/features";
|
||||
import {
|
||||
useRoomJoinMeeting,
|
||||
useMeetingStartRecording,
|
||||
@@ -37,6 +39,7 @@ import { useUuidV5 } from "react-uuid-hook";
|
||||
|
||||
const CONSENT_BUTTON_ID = "recording-consent";
|
||||
const RECORDING_INDICATOR_ID = "recording-indicator";
|
||||
const EMAIL_TRANSCRIPT_BUTTON_ID = "email-transcript";
|
||||
|
||||
// Namespace UUID for UUIDv5 generation of raw-tracks instanceIds
|
||||
// DO NOT CHANGE: Breaks instanceId determinism across deployments
|
||||
@@ -209,6 +212,12 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) {
|
||||
const showConsentModalRef = useRef(showConsentModal);
|
||||
showConsentModalRef.current = showConsentModal;
|
||||
|
||||
const { showEmailModal } = useEmailTranscriptDialog({
|
||||
meetingId: assertMeetingId(meeting.id),
|
||||
});
|
||||
const showEmailModalRef = useRef(showEmailModal);
|
||||
showEmailModalRef.current = showEmailModal;
|
||||
|
||||
useEffect(() => {
|
||||
if (authLastUserId === undefined || !meeting?.id || !roomName) return;
|
||||
|
||||
@@ -242,6 +251,9 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) {
|
||||
if (ev.button_id === CONSENT_BUTTON_ID) {
|
||||
showConsentModalRef.current();
|
||||
}
|
||||
if (ev.button_id === EMAIL_TRANSCRIPT_BUTTON_ID) {
|
||||
showEmailModalRef.current();
|
||||
}
|
||||
},
|
||||
[
|
||||
/*keep static; iframe recreation depends on it*/
|
||||
@@ -319,6 +331,10 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) {
|
||||
() => new URL("/recording-icon.svg", window.location.origin),
|
||||
[],
|
||||
);
|
||||
const emailIconUrl = useMemo(
|
||||
() => new URL("/email-icon.svg", window.location.origin),
|
||||
[],
|
||||
);
|
||||
|
||||
const [frame, { setCustomTrayButton }] = useFrame(container, {
|
||||
onLeftMeeting: handleLeave,
|
||||
@@ -371,6 +387,20 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) {
|
||||
);
|
||||
}, [showConsentButton, recordingIconUrl, setCustomTrayButton]);
|
||||
|
||||
useEffect(() => {
|
||||
const show = featureEnabled("emailTranscript");
|
||||
setCustomTrayButton(
|
||||
EMAIL_TRANSCRIPT_BUTTON_ID,
|
||||
show
|
||||
? {
|
||||
iconPath: emailIconUrl.href,
|
||||
label: "Email Transcript",
|
||||
tooltip: "Get transcript emailed to you",
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}, [emailIconUrl, setCustomTrayButton]);
|
||||
|
||||
if (authLastUserId === undefined) {
|
||||
return (
|
||||
<Center width="100vw" height="100vh">
|
||||
|
||||
@@ -643,6 +643,16 @@ export function useMeetingAudioConsent() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useMeetingAddEmailRecipient() {
|
||||
const { setError } = useError();
|
||||
|
||||
return $api.useMutation("post", "/v1/meetings/{meeting_id}/email-recipient", {
|
||||
onError: (error) => {
|
||||
setError(error as Error, "There was an error adding the email");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMeetingDeactivate() {
|
||||
const { setError } = useError();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -13,6 +13,8 @@ export const FEATURE_PRIVACY_ENV_NAME = "FEATURE_PRIVACY" as const;
|
||||
export const FEATURE_BROWSE_ENV_NAME = "FEATURE_BROWSE" as const;
|
||||
export const FEATURE_SEND_TO_ZULIP_ENV_NAME = "FEATURE_SEND_TO_ZULIP" as const;
|
||||
export const FEATURE_ROOMS_ENV_NAME = "FEATURE_ROOMS" as const;
|
||||
export const FEATURE_EMAIL_TRANSCRIPT_ENV_NAME =
|
||||
"FEATURE_EMAIL_TRANSCRIPT" as const;
|
||||
|
||||
const FEATURE_ENV_NAMES = [
|
||||
FEATURE_REQUIRE_LOGIN_ENV_NAME,
|
||||
@@ -20,6 +22,7 @@ const FEATURE_ENV_NAMES = [
|
||||
FEATURE_BROWSE_ENV_NAME,
|
||||
FEATURE_SEND_TO_ZULIP_ENV_NAME,
|
||||
FEATURE_ROOMS_ENV_NAME,
|
||||
FEATURE_EMAIL_TRANSCRIPT_ENV_NAME,
|
||||
] as const;
|
||||
|
||||
export type FeatureEnvName = (typeof FEATURE_ENV_NAMES)[number];
|
||||
|
||||
70
www/app/lib/emailTranscript/EmailTranscriptDialog.tsx
Normal file
70
www/app/lib/emailTranscript/EmailTranscriptDialog.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Box, Button, Input, Text, VStack, HStack } from "@chakra-ui/react";
|
||||
|
||||
interface EmailTranscriptDialogProps {
|
||||
onSubmit: (email: string) => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export function EmailTranscriptDialog({
|
||||
onSubmit,
|
||||
onDismiss,
|
||||
}: EmailTranscriptDialogProps) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [inputEl, setInputEl] = useState<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputEl?.focus();
|
||||
}, [inputEl]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = email.trim();
|
||||
if (trimmed) {
|
||||
onSubmit(trimmed);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={6}
|
||||
bg="rgba(255, 255, 255, 0.7)"
|
||||
borderRadius="lg"
|
||||
boxShadow="lg"
|
||||
maxW="md"
|
||||
mx="auto"
|
||||
>
|
||||
<VStack gap={4} alignItems="center">
|
||||
<Text fontSize="md" textAlign="center" fontWeight="medium">
|
||||
Enter your email to receive the transcript when it's ready
|
||||
</Text>
|
||||
<Input
|
||||
ref={setInputEl}
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSubmit();
|
||||
}}
|
||||
size="sm"
|
||||
bg="white"
|
||||
/>
|
||||
<HStack gap={4} justifyContent="center">
|
||||
<Button variant="ghost" size="sm" onClick={onDismiss}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
colorPalette="primary"
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!email.trim()}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
1
www/app/lib/emailTranscript/index.ts
Normal file
1
www/app/lib/emailTranscript/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useEmailTranscriptDialog } from "./useEmailTranscriptDialog";
|
||||
128
www/app/lib/emailTranscript/useEmailTranscriptDialog.tsx
Normal file
128
www/app/lib/emailTranscript/useEmailTranscriptDialog.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState, useEffect, useRef } from "react";
|
||||
import { Box, Text } from "@chakra-ui/react";
|
||||
import { toaster } from "../../components/ui/toaster";
|
||||
import { useMeetingAddEmailRecipient } from "../apiHooks";
|
||||
import { EmailTranscriptDialog } from "./EmailTranscriptDialog";
|
||||
import type { MeetingId } from "../types";
|
||||
|
||||
const TOAST_CHECK_INTERVAL_MS = 100;
|
||||
|
||||
type UseEmailTranscriptDialogParams = {
|
||||
meetingId: MeetingId;
|
||||
};
|
||||
|
||||
export function useEmailTranscriptDialog({
|
||||
meetingId,
|
||||
}: UseEmailTranscriptDialogParams) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const addEmailMutation = useMeetingAddEmailRecipient();
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const keydownHandlerRef = useRef<((event: KeyboardEvent) => void) | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
if (keydownHandlerRef.current) {
|
||||
document.removeEventListener("keydown", keydownHandlerRef.current);
|
||||
keydownHandlerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSubmitEmail = useCallback(
|
||||
async (email: string) => {
|
||||
try {
|
||||
await addEmailMutation.mutateAsync({
|
||||
params: {
|
||||
path: { meeting_id: meetingId },
|
||||
},
|
||||
body: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
toaster.create({
|
||||
duration: 4000,
|
||||
render: () => (
|
||||
<Box
|
||||
p={4}
|
||||
bg="green.100"
|
||||
borderRadius="md"
|
||||
boxShadow="md"
|
||||
textAlign="center"
|
||||
>
|
||||
<Text fontWeight="medium">Email registered</Text>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
You will receive the transcript link when processing is
|
||||
complete.
|
||||
</Text>
|
||||
</Box>
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error adding email recipient:", error);
|
||||
}
|
||||
},
|
||||
[addEmailMutation, meetingId],
|
||||
);
|
||||
|
||||
const showEmailModal = useCallback(() => {
|
||||
if (modalOpen) return;
|
||||
|
||||
setModalOpen(true);
|
||||
|
||||
const toastId = toaster.create({
|
||||
placement: "top",
|
||||
duration: null,
|
||||
render: ({ dismiss }) => (
|
||||
<EmailTranscriptDialog
|
||||
onSubmit={(email) => {
|
||||
handleSubmitEmail(email);
|
||||
dismiss();
|
||||
}}
|
||||
onDismiss={() => {
|
||||
dismiss();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
toastId.then((id) => toaster.dismiss(id));
|
||||
}
|
||||
};
|
||||
|
||||
keydownHandlerRef.current = handleKeyDown;
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
toastId.then((id) => {
|
||||
intervalRef.current = setInterval(() => {
|
||||
if (!toaster.isActive(id)) {
|
||||
setModalOpen(false);
|
||||
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
if (keydownHandlerRef.current) {
|
||||
document.removeEventListener("keydown", keydownHandlerRef.current);
|
||||
keydownHandlerRef.current = null;
|
||||
}
|
||||
}
|
||||
}, TOAST_CHECK_INTERVAL_MS);
|
||||
});
|
||||
}, [handleSubmitEmail, modalOpen]);
|
||||
|
||||
return {
|
||||
showEmailModal,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
FEATURE_BROWSE_ENV_NAME,
|
||||
FEATURE_EMAIL_TRANSCRIPT_ENV_NAME,
|
||||
FEATURE_PRIVACY_ENV_NAME,
|
||||
FEATURE_REQUIRE_LOGIN_ENV_NAME,
|
||||
FEATURE_ROOMS_ENV_NAME,
|
||||
@@ -14,6 +15,7 @@ export const FEATURES = [
|
||||
"browse",
|
||||
"sendToZulip",
|
||||
"rooms",
|
||||
"emailTranscript",
|
||||
] as const;
|
||||
|
||||
export type FeatureName = (typeof FEATURES)[number];
|
||||
@@ -26,6 +28,7 @@ export const DEFAULT_FEATURES: Features = {
|
||||
browse: true,
|
||||
sendToZulip: true,
|
||||
rooms: true,
|
||||
emailTranscript: false,
|
||||
} as const;
|
||||
|
||||
export const ENV_TO_FEATURE: {
|
||||
@@ -36,6 +39,7 @@ export const ENV_TO_FEATURE: {
|
||||
FEATURE_BROWSE: "browse",
|
||||
FEATURE_SEND_TO_ZULIP: "sendToZulip",
|
||||
FEATURE_ROOMS: "rooms",
|
||||
FEATURE_EMAIL_TRANSCRIPT: "emailTranscript",
|
||||
} as const;
|
||||
|
||||
export const FEATURE_TO_ENV: {
|
||||
@@ -46,6 +50,7 @@ export const FEATURE_TO_ENV: {
|
||||
browse: "FEATURE_BROWSE",
|
||||
sendToZulip: "FEATURE_SEND_TO_ZULIP",
|
||||
rooms: "FEATURE_ROOMS",
|
||||
emailTranscript: "FEATURE_EMAIL_TRANSCRIPT",
|
||||
};
|
||||
|
||||
const features = getClientEnv();
|
||||
|
||||
63
www/app/reflector-api.d.ts
vendored
63
www/app/reflector-api.d.ts
vendored
@@ -98,6 +98,26 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/meetings/{meeting_id}/email-recipient": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Add Email Recipient
|
||||
* @description Add an email address to receive the transcript link when processing completes.
|
||||
*/
|
||||
post: operations["v1_add_email_recipient"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/rooms": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -838,6 +858,14 @@ export interface paths {
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
/** AddEmailRecipientRequest */
|
||||
AddEmailRecipientRequest: {
|
||||
/**
|
||||
* Email
|
||||
* Format: email
|
||||
*/
|
||||
email: string;
|
||||
};
|
||||
/** ApiKeyResponse */
|
||||
ApiKeyResponse: {
|
||||
/**
|
||||
@@ -2602,6 +2630,41 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
v1_add_email_recipient: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
meeting_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["AddEmailRecipientRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
v1_rooms_list: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
4
www/public/email-icon.svg
Normal file
4
www/public/email-icon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2"/>
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 274 B |
Reference in New Issue
Block a user