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:
Juan Diego García
2026-03-20 15:43:58 -05:00
committed by GitHub
parent 41e7b3e84f
commit 55222ecc47
27 changed files with 803 additions and 10 deletions

View File

@@ -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">

View File

@@ -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();

View File

@@ -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];

View 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&apos;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>
);
}

View File

@@ -0,0 +1 @@
export { useEmailTranscriptDialog } from "./useEmailTranscriptDialog";

View 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,
};
}

View File

@@ -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();

View File

@@ -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?: {

View 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