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

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