feat: send email in share transcript and add email sending in room (#924)

* fix: add source language for file pipeline

* feat: send email in share transcript and add email sending in room

* fix: hide audio and video streaming for unauthenticated users

* fix: security order
This commit is contained in:
Juan Diego García
2026-03-24 17:17:52 -05:00
committed by GitHub
parent 74b9b97453
commit e2ba502697
28 changed files with 861 additions and 174 deletions

View File

@@ -31,6 +31,7 @@ import {
useZulipTopics,
useRoomGet,
useRoomTestWebhook,
useConfig,
} from "../../lib/apiHooks";
import { RoomList } from "./_components/RoomList";
import { PaginationPage } from "../browse/_components/Pagination";
@@ -92,6 +93,7 @@ const roomInitialState = {
icsFetchInterval: 5,
platform: "whereby",
skipConsent: false,
emailTranscriptTo: "",
};
export default function RoomsList() {
@@ -133,11 +135,15 @@ export default function RoomsList() {
null,
);
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
const [emailTranscriptEnabled, setEmailTranscriptEnabled] = useState(false);
const createRoomMutation = useRoomCreate();
const updateRoomMutation = useRoomUpdate();
const deleteRoomMutation = useRoomDelete();
const { data: streams = [] } = useZulipStreams();
const { data: config } = useConfig();
const zulipEnabled = config?.zulip_enabled ?? false;
const emailEnabled = config?.email_enabled ?? false;
const { data: streams = [] } = useZulipStreams(zulipEnabled);
const { data: topics = [] } = useZulipTopics(selectedStreamId);
const {
@@ -177,6 +183,7 @@ export default function RoomsList() {
icsFetchInterval: detailedEditedRoom.ics_fetch_interval || 5,
platform: detailedEditedRoom.platform,
skipConsent: detailedEditedRoom.skip_consent || false,
emailTranscriptTo: detailedEditedRoom.email_transcript_to || "",
}
: null,
[detailedEditedRoom],
@@ -329,6 +336,7 @@ export default function RoomsList() {
ics_fetch_interval: room.icsFetchInterval,
platform,
skip_consent: room.skipConsent,
email_transcript_to: room.emailTranscriptTo || null,
};
if (isEditing) {
@@ -369,6 +377,7 @@ export default function RoomsList() {
// Reset states
setShowWebhookSecret(false);
setWebhookTestResult(null);
setEmailTranscriptEnabled(!!roomData.email_transcript_to);
setRoomInput({
name: roomData.name,
@@ -392,6 +401,7 @@ export default function RoomsList() {
icsFetchInterval: roomData.ics_fetch_interval || 5,
platform: roomData.platform,
skipConsent: roomData.skip_consent || false,
emailTranscriptTo: roomData.email_transcript_to || "",
});
setEditRoomId(roomId);
setIsEditing(true);
@@ -469,6 +479,7 @@ export default function RoomsList() {
setNameError("");
setShowWebhookSecret(false);
setWebhookTestResult(null);
setEmailTranscriptEnabled(false);
onOpen();
}}
>
@@ -504,7 +515,9 @@ export default function RoomsList() {
<Tabs.List>
<Tabs.Trigger value="general">General</Tabs.Trigger>
<Tabs.Trigger value="calendar">Calendar</Tabs.Trigger>
<Tabs.Trigger value="share">Share</Tabs.Trigger>
{(zulipEnabled || emailEnabled) && (
<Tabs.Trigger value="share">Share</Tabs.Trigger>
)}
<Tabs.Trigger value="webhook">WebHook</Tabs.Trigger>
</Tabs.List>
@@ -831,96 +844,144 @@ export default function RoomsList() {
</Tabs.Content>
<Tabs.Content value="share" pt={6}>
<Field.Root>
<Checkbox.Root
name="zulipAutoPost"
checked={room.zulipAutoPost}
onCheckedChange={(e) => {
const syntheticEvent = {
target: {
name: "zulipAutoPost",
type: "checkbox",
checked: e.checked,
},
};
handleRoomChange(syntheticEvent);
}}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>
Automatically post transcription to Zulip
</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Zulip stream</Field.Label>
<Select.Root
value={room.zulipStream ? [room.zulipStream] : []}
onValueChange={(e) =>
setRoomInput({
...room,
zulipStream: e.value[0],
zulipTopic: "",
})
}
collection={streamCollection}
disabled={!room.zulipAutoPost}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select stream" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{streamOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Zulip topic</Field.Label>
<Select.Root
value={room.zulipTopic ? [room.zulipTopic] : []}
onValueChange={(e) =>
setRoomInput({ ...room, zulipTopic: e.value[0] })
}
collection={topicCollection}
disabled={!room.zulipAutoPost}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select topic" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{topicOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
{emailEnabled && (
<>
<Field.Root>
<Checkbox.Root
checked={emailTranscriptEnabled}
onCheckedChange={(e) => {
setEmailTranscriptEnabled(!!e.checked);
if (!e.checked) {
setRoomInput({
...room,
emailTranscriptTo: "",
});
}
}}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>
Email me transcript when processed
</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
{emailTranscriptEnabled && (
<Field.Root mt={2}>
<Input
name="emailTranscriptTo"
type="email"
placeholder="your@email.com"
value={room.emailTranscriptTo}
onChange={handleRoomChange}
/>
<Field.HelperText>
Transcript will be emailed to this address after
processing
</Field.HelperText>
</Field.Root>
)}
</>
)}
{zulipEnabled && (
<>
<Field.Root mt={emailEnabled ? 4 : 0}>
<Checkbox.Root
name="zulipAutoPost"
checked={room.zulipAutoPost}
onCheckedChange={(e) => {
const syntheticEvent = {
target: {
name: "zulipAutoPost",
type: "checkbox",
checked: e.checked,
},
};
handleRoomChange(syntheticEvent);
}}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>
Automatically post transcription to Zulip
</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Zulip stream</Field.Label>
<Select.Root
value={room.zulipStream ? [room.zulipStream] : []}
onValueChange={(e) =>
setRoomInput({
...room,
zulipStream: e.value[0],
zulipTopic: "",
})
}
collection={streamCollection}
disabled={!room.zulipAutoPost}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select stream" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{streamOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Zulip topic</Field.Label>
<Select.Root
value={room.zulipTopic ? [room.zulipTopic] : []}
onValueChange={(e) =>
setRoomInput({
...room,
zulipTopic: e.value[0],
})
}
collection={topicCollection}
disabled={!room.zulipAutoPost}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select topic" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{topicOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
</>
)}
</Tabs.Content>
<Tabs.Content value="webhook" pt={6}>

View File

@@ -24,6 +24,8 @@ import {
} from "@chakra-ui/react";
import { useTranscriptGet } from "../../../lib/apiHooks";
import { TranscriptStatus } from "../../../lib/transcript";
import { useAuth } from "../../../lib/AuthProvider";
import { featureEnabled } from "../../../lib/features";
type TranscriptDetails = {
params: Promise<{
@@ -57,7 +59,10 @@ export default function TranscriptDetails(details: TranscriptDetails) {
const [finalSummaryElement, setFinalSummaryElement] =
useState<HTMLDivElement | null>(null);
const hasCloudVideo = !!transcript.data?.has_cloud_video;
const auth = useAuth();
const isAuthenticated =
auth.status === "authenticated" || !featureEnabled("requireLogin");
const hasCloudVideo = !!transcript.data?.has_cloud_video && isAuthenticated;
const [videoExpanded, setVideoExpanded] = useState(false);
const [videoNewBadge, setVideoNewBadge] = useState(() => {
if (typeof window === "undefined") return true;
@@ -145,7 +150,7 @@ export default function TranscriptDetails(details: TranscriptDetails) {
mt={4}
mb={4}
>
{!mp3.audioDeleted && (
{isAuthenticated && !mp3.audioDeleted && (
<>
{waveform.waveform && mp3.media && topics.topics ? (
<Player

View File

@@ -21,6 +21,10 @@ import { useAuth } from "../../../lib/AuthProvider";
import { featureEnabled } from "../../../lib/features";
import { SearchableLanguageSelect } from "../../../components/SearchableLanguageSelect";
const sourceLanguages = supportedLanguages.filter(
(l) => l.value && l.value !== "NOTRANSLATION",
);
const TranscriptCreate = () => {
const router = useRouter();
const auth = useAuth();
@@ -33,8 +37,13 @@ const TranscriptCreate = () => {
const nameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setName(event.target.value);
};
const [sourceLanguage, setSourceLanguage] = useState<string>("");
const [targetLanguage, setTargetLanguage] = useState<string>("NOTRANSLATION");
const onSourceLanguageChange = (newval) => {
(!newval || typeof newval === "string") &&
setSourceLanguage(newval || "en");
};
const onLanguageChange = (newval) => {
(!newval || typeof newval === "string") && setTargetLanguage(newval);
};
@@ -55,7 +64,7 @@ const TranscriptCreate = () => {
const targetLang = getTargetLanguage();
createTranscript.create({
name,
source_language: "en",
source_language: sourceLanguage || "en",
target_language: targetLang || "en",
source_kind: "live",
});
@@ -67,7 +76,7 @@ const TranscriptCreate = () => {
const targetLang = getTargetLanguage();
createTranscript.create({
name,
source_language: "en",
source_language: sourceLanguage || "en",
target_language: targetLang || "en",
source_kind: "file",
});
@@ -160,6 +169,15 @@ const TranscriptCreate = () => {
placeholder="Optional"
/>
</Box>
<Box mb={4}>
<Text mb={1}>Audio language</Text>
<SearchableLanguageSelect
options={sourceLanguages}
value={sourceLanguage}
onChange={onSourceLanguageChange}
placeholder="Select language"
/>
</Box>
<Box mb={4}>
<Text mb={1}>Do you want to enable live translation?</Text>
<SearchableLanguageSelect

View File

@@ -18,10 +18,11 @@ import {
createListCollection,
} from "@chakra-ui/react";
import { LuShare2 } from "react-icons/lu";
import { useTranscriptUpdate } from "../../lib/apiHooks";
import { useTranscriptUpdate, useConfig } from "../../lib/apiHooks";
import ShareLink from "./shareLink";
import ShareCopy from "./shareCopy";
import ShareZulip from "./shareZulip";
import ShareEmail from "./shareEmail";
import { useAuth } from "../../lib/AuthProvider";
import { featureEnabled } from "../../lib/features";
@@ -55,6 +56,9 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
const [shareLoading, setShareLoading] = useState(false);
const requireLogin = featureEnabled("requireLogin");
const updateTranscriptMutation = useTranscriptUpdate();
const { data: config } = useConfig();
const zulipEnabled = config?.zulip_enabled ?? false;
const emailEnabled = config?.email_enabled ?? false;
const updateShareMode = async (selectedValue: string) => {
const selectedOption = shareOptionsData.find(
@@ -169,14 +173,20 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
<Text fontSize="sm" mb="2" fontWeight={"bold"}>
Share options
</Text>
<Flex gap={2} mb={2}>
{requireLogin && (
<Flex gap={2} mb={2} flexWrap="wrap">
{requireLogin && zulipEnabled && (
<ShareZulip
transcript={props.transcript}
topics={props.topics}
disabled={toShareMode(shareMode.value) === "private"}
/>
)}
{emailEnabled && (
<ShareEmail
transcript={props.transcript}
disabled={toShareMode(shareMode.value) === "private"}
/>
)}
<ShareCopy
finalSummaryElement={props.finalSummaryElement}
transcript={props.transcript}

View File

@@ -0,0 +1,110 @@
import { useState } from "react";
import type { components } from "../../reflector-api";
type GetTranscriptWithParticipants =
components["schemas"]["GetTranscriptWithParticipants"];
import {
Button,
Dialog,
CloseButton,
Input,
Box,
Text,
} from "@chakra-ui/react";
import { LuMail } from "react-icons/lu";
import { useTranscriptSendEmail } from "../../lib/apiHooks";
type ShareEmailProps = {
transcript: GetTranscriptWithParticipants;
disabled: boolean;
};
export default function ShareEmail(props: ShareEmailProps) {
const [showModal, setShowModal] = useState(false);
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const sendEmailMutation = useTranscriptSendEmail();
const handleSend = async () => {
if (!email) return;
try {
await sendEmailMutation.mutateAsync({
params: {
path: { transcript_id: props.transcript.id },
},
body: { email },
});
setSent(true);
setTimeout(() => {
setSent(false);
setShowModal(false);
setEmail("");
}, 2000);
} catch (error) {
console.error("Error sending email:", error);
}
};
return (
<>
<Button disabled={props.disabled} onClick={() => setShowModal(true)}>
<LuMail /> Send Email
</Button>
<Dialog.Root
open={showModal}
onOpenChange={(e) => {
setShowModal(e.open);
if (!e.open) {
setSent(false);
setEmail("");
}
}}
size="md"
>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Send Transcript via Email</Dialog.Title>
<Dialog.CloseTrigger asChild>
<CloseButton />
</Dialog.CloseTrigger>
</Dialog.Header>
<Dialog.Body>
{sent ? (
<Text color="green.500">Email sent successfully!</Text>
) : (
<Box>
<Text mb={2}>
Enter the email address to send this transcript to:
</Text>
<Input
type="email"
placeholder="recipient@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
/>
</Box>
)}
</Dialog.Body>
<Dialog.Footer>
<Button variant="ghost" onClick={() => setShowModal(false)}>
Close
</Button>
{!sent && (
<Button
disabled={!email || sendEmailMutation.isPending}
onClick={handleSend}
>
{sendEmailMutation.isPending ? "Sending..." : "Send"}
</Button>
)}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Positioner>
</Dialog.Root>
</>
);
}

View File

@@ -56,7 +56,7 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
}, [navigator.serviceWorker, !serviceWorker, accessTokenInfo]);
useEffect(() => {
if (!transcriptId || later || !transcript) return;
if (!transcriptId || later || !transcript || !accessTokenInfo) return;
let stopped = false;
let audioElement: HTMLAudioElement | null = null;
@@ -113,7 +113,7 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
if (handleError) audioElement.removeEventListener("error", handleError);
}
};
}, [transcriptId, transcript, later]);
}, [transcriptId, transcript, later, accessTokenInfo]);
const getNow = () => {
setLater(false);

View File

@@ -39,17 +39,16 @@ export default function VideoPlayer({
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (accessToken) {
params.set("token", accessToken);
}
const url = `${API_URL}/v1/transcripts/${transcriptId}/video/url?${params}`;
const url = `${API_URL}/v1/transcripts/${transcriptId}/video/url`;
const headers: Record<string, string> = {};
if (accessToken) {
headers["Authorization"] = `Bearer ${accessToken}`;
}
const resp = await fetch(url, { headers });
if (!resp.ok) {
if (resp.status === 401) {
throw new Error("Sign in to view the video recording");
}
throw new Error("Failed to load video");
}
const data = await resp.json();
@@ -90,7 +89,7 @@ export default function VideoPlayer({
w="fit-content"
maxW="100%"
>
<Text fontSize="sm">Failed to load video recording</Text>
<Text fontSize="sm">{error || "Failed to load video recording"}</Text>
</Box>
);
}
@@ -132,10 +131,14 @@ export default function VideoPlayer({
</Flex>
</Flex>
{/* Video element with visible controls */}
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
src={videoUrl}
controls
autoPlay
controlsList="nodownload"
disablePictureInPicture
onContextMenu={(e) => e.preventDefault()}
style={{
display: "block",
width: "100%",

View File

@@ -67,7 +67,7 @@ export function SearchableLanguageSelect({
const collection = useMemo(() => createListCollection({ items }), [items]);
const selectedValues = value && value !== "NOTRANSLATION" ? [value] : [];
const selectedValues = value ? [value] : [];
return (
<Combobox.Root

View File

@@ -228,7 +228,11 @@ export function useRoomDelete() {
});
}
export function useZulipStreams() {
export function useConfig() {
return $api.useQuery("get", "/v1/config", {});
}
export function useZulipStreams(enabled: boolean = true) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
@@ -236,7 +240,7 @@ export function useZulipStreams() {
"/v1/zulip/streams",
{},
{
enabled: isAuthenticated,
enabled: enabled && isAuthenticated,
},
);
}
@@ -291,6 +295,16 @@ export function useTranscriptPostToZulip() {
});
}
export function useTranscriptSendEmail() {
const { setError } = useError();
return $api.useMutation("post", "/v1/transcripts/{transcript_id}/email", {
onError: (error) => {
setError(error as Error, "There was an error sending the email");
},
});
}
export function useTranscriptUploadAudio() {
const { setError } = useError();
const queryClient = useQueryClient();

View File

@@ -456,6 +456,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/transcripts/{transcript_id}/email": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Transcript Send Email */
post: operations["v1_transcript_send_email"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/transcripts/{transcript_id}/audio/mp3": {
parameters: {
query?: never;
@@ -739,6 +756,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/config": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Config */
get: operations["v1_get_config"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/zulip/streams": {
parameters: {
query?: never;
@@ -942,6 +976,13 @@ export interface components {
*/
updated_at: string;
};
/** ConfigResponse */
ConfigResponse: {
/** Zulip Enabled */
zulip_enabled: boolean;
/** Email Enabled */
email_enabled: boolean;
};
/** CreateApiKeyRequest */
CreateApiKeyRequest: {
/** Name */
@@ -1025,6 +1066,8 @@ export interface components {
* @default false
*/
skip_consent: boolean;
/** Email Transcript To */
email_transcript_to?: string | null;
};
/** CreateRoomMeeting */
CreateRoomMeeting: {
@@ -1844,6 +1887,8 @@ export interface components {
* @default false
*/
skip_consent: boolean;
/** Email Transcript To */
email_transcript_to?: string | null;
};
/** RoomDetails */
RoomDetails: {
@@ -1900,6 +1945,8 @@ export interface components {
* @default false
*/
skip_consent: boolean;
/** Email Transcript To */
email_transcript_to?: string | null;
/** Webhook Url */
webhook_url: string | null;
/** Webhook Secret */
@@ -1984,6 +2031,16 @@ export interface components {
/** Change Seq */
change_seq?: number | null;
};
/** SendEmailRequest */
SendEmailRequest: {
/** Email */
email: string;
};
/** SendEmailResponse */
SendEmailResponse: {
/** Sent */
sent: number;
};
/**
* SourceKind
* @enum {string}
@@ -2264,6 +2321,8 @@ export interface components {
platform?: ("whereby" | "daily") | null;
/** Skip Consent */
skip_consent?: boolean | null;
/** Email Transcript To */
email_transcript_to?: string | null;
};
/** UpdateTranscript */
UpdateTranscript: {
@@ -3497,6 +3556,41 @@ export interface operations {
};
};
};
v1_transcript_send_email: {
parameters: {
query?: never;
header?: never;
path: {
transcript_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SendEmailRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SendEmailResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
v1_transcript_get_audio_mp3: {
parameters: {
query?: {
@@ -4167,6 +4261,26 @@ export interface operations {
};
};
};
v1_get_config: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ConfigResponse"];
};
};
};
};
v1_zulip_get_streams: {
parameters: {
query?: never;