From 1be28368235e25aa429f0af34b86225aeae975a7 Mon Sep 17 00:00:00 2001 From: Donal Toomey Date: Tue, 19 Aug 2025 11:20:53 +0100 Subject: [PATCH 1/9] Add real-time transcription captions overlay - Enabled receiveTranscriptions in connection options - Implemented useTranscriptions hook to process partial/final events - Added TranscriptionsOverlay component with toggle in Room UI - Overlay styled as grey, semi-transparent bar above controls - Limited to 3 utterances (max 2 per participant), smaller font --- package-lock.json | 22 ++- package.json | 2 +- src/components/Room/Room.tsx | 62 ++++++++ .../TranscriptionsOverlay.tsx | 100 ++++++++++++ src/components/TranscriptionsOverlay/index.ts | 1 + src/hooks/__tests__/useTranscriptions.test.ts | 86 ++++++++++ src/hooks/useTranscriptions.ts | 149 ++++++++++++++++++ .../useConnectionOptions.ts | 4 + 8 files changed, 413 insertions(+), 13 deletions(-) create mode 100644 src/components/TranscriptionsOverlay/TranscriptionsOverlay.tsx create mode 100644 src/components/TranscriptionsOverlay/index.ts create mode 100644 src/hooks/__tests__/useTranscriptions.test.ts create mode 100644 src/hooks/useTranscriptions.ts diff --git a/package-lock.json b/package-lock.json index 745969e94..14cfcaa15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,7 @@ "swiper": "^8.1.5", "ts-node": "^9.1.1", "twilio": "^5.5.2", - "twilio-video": "^2.29.0" + "twilio-video": "^2.32.1" }, "devDependencies": { "@storybook/addon-actions": "^6.5.10", @@ -47187,18 +47187,17 @@ } }, "node_modules/twilio-video": { - "version": "2.29.0", - "resolved": "https://registry.npmjs.org/twilio-video/-/twilio-video-2.29.0.tgz", - "integrity": "sha512-4gzwAHyLPlftE2sfeaDAZ6r0WDxEbzqksnVxGMN2AGuQOVKi+Xrsj2nJj/m1I3QS3jho3Gwmxc9SmfGBR+kIkQ==", + "version": "2.32.1", + "resolved": "https://registry.npmjs.org/twilio-video/-/twilio-video-2.32.1.tgz", + "integrity": "sha512-GDRF/I68cH8CISuWShXkzn7sa1TwT290BWL9gEseAEhNNFN7H+2yQIzmR/Q3uVHfGgkrp2QvQCEcfh85fADivA==", "license": "BSD-3-Clause", "dependencies": { "events": "^3.3.0", "util": "^0.12.4", - "ws": "^7.4.6", - "xmlhttprequest": "^1.8.0" + "ws": "^7.4.6" }, "engines": { - "node": ">=0.12" + "node": ">=22" } }, "node_modules/twilio-video/node_modules/ws": { @@ -83840,14 +83839,13 @@ } }, "twilio-video": { - "version": "2.29.0", - "resolved": "https://registry.npmjs.org/twilio-video/-/twilio-video-2.29.0.tgz", - "integrity": "sha512-4gzwAHyLPlftE2sfeaDAZ6r0WDxEbzqksnVxGMN2AGuQOVKi+Xrsj2nJj/m1I3QS3jho3Gwmxc9SmfGBR+kIkQ==", + "version": "2.32.1", + "resolved": "https://registry.npmjs.org/twilio-video/-/twilio-video-2.32.1.tgz", + "integrity": "sha512-GDRF/I68cH8CISuWShXkzn7sa1TwT290BWL9gEseAEhNNFN7H+2yQIzmR/Q3uVHfGgkrp2QvQCEcfh85fADivA==", "requires": { "events": "^3.3.0", "util": "^0.12.4", - "ws": "^7.4.6", - "xmlhttprequest": "^1.8.0" + "ws": "^7.4.6" }, "dependencies": { "ws": { diff --git a/package.json b/package.json index 18db7fb44..70d9f8c0e 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "swiper": "^8.1.5", "ts-node": "^9.1.1", "twilio": "^5.5.2", - "twilio-video": "^2.29.0" + "twilio-video": "^2.32.1" }, "devDependencies": { "@storybook/addon-actions": "^6.5.10", diff --git a/src/components/Room/Room.tsx b/src/components/Room/Room.tsx index 999957f2c..b3f2dd694 100644 --- a/src/components/Room/Room.tsx +++ b/src/components/Room/Room.tsx @@ -13,6 +13,10 @@ import { useAppState } from '../../state'; import useChatContext from '../../hooks/useChatContext/useChatContext'; import useScreenShareParticipant from '../../hooks/useScreenShareParticipant/useScreenShareParticipant'; import useVideoContext from '../../hooks/useVideoContext/useVideoContext'; +import { useTranscriptions } from '../../hooks/useTranscriptions'; +import TranscriptionsOverlay from '../TranscriptionsOverlay'; +import { Tooltip, IconButton } from '@material-ui/core'; +import ClosedCaptionIcon from '@material-ui/icons/ClosedCaption'; const useStyles = makeStyles((theme: Theme) => { const totalMobileSidebarHeight = `${theme.sidebarMobileHeight + @@ -78,6 +82,32 @@ export default function Room() { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const screenShareParticipant = useScreenShareParticipant(); + const { lines, live, clear } = useTranscriptions(room); + + const [showCaptions, setShowCaptions] = React.useState(true); + + // Hide overlay when not connected + const isConnected = !!room && room.state === 'connected'; + const captionsVisible = showCaptions && isConnected; + + React.useEffect(() => { + if (!room) { + clear(); + return; + } + const handleDisconnect = () => { + clear(); + setShowCaptions(false); // Hide overlay when disconnected + }; + room.on('disconnected', handleDisconnect); + + // Remove transcription listener and cleanup on unmount/disconnect + return () => { + room.off('disconnected', handleDisconnect); + clear(); + setShowCaptions(false); + }; + }, [room, clear]); // Here we switch to speaker view when a participant starts sharing their screen, but // the user is still free to switch back to gallery view. @@ -89,6 +119,37 @@ export default function Room() { [classes.rightDrawerOpen]: isChatWindowOpen || isBackgroundSelectionOpen, })} > + {/* Captions toggle control in header */} +
+ + setShowCaptions(v => !v)} + color={showCaptions ? 'primary' : 'default'} + style={{ + background: showCaptions ? '#222' : '#eee', + color: showCaptions ? '#fff' : '#444', + borderRadius: 8, + transition: 'background 0.2s', + }} + > + + + + Captions +
+ {/* This ParticipantAudioTracks component will render the audio track for all participants in the room. It is in a separate component so that the audio tracks will always be rendered, and that they will never be @@ -111,6 +172,7 @@ export default function Room() { + ); } diff --git a/src/components/TranscriptionsOverlay/TranscriptionsOverlay.tsx b/src/components/TranscriptionsOverlay/TranscriptionsOverlay.tsx new file mode 100644 index 000000000..b68748b34 --- /dev/null +++ b/src/components/TranscriptionsOverlay/TranscriptionsOverlay.tsx @@ -0,0 +1,100 @@ +import React from 'react'; + +/** + * Overlay component for displaying Twilio Real-Time Transcriptions. + * + * See official docs: + * - Overview: https://www.twilio.com/docs/video/api/real-time-transcriptions + * - JS SDK usage: https://www.twilio.com/docs/video/api/real-time-transcriptions#receiving-transcriptions-in-the-js-sdk + * + * Props: + * lines: Array of committed transcription lines. + * live: Optional live partial line. + * visible: Show/hide overlay. + */ + +export type TranscriptionLine = { + text: string; + participant: string; + time: number; +}; + +type Props = { + lines: TranscriptionLine[]; + live?: { text: string; participant: string } | null; + visible: boolean; +}; + +export const TranscriptionsOverlay: React.FC = ({ lines, live, visible }) => { + if (!visible) return null; + + // Show last 3-5 lines + const recentLines = lines.slice(-5); + + return ( +
+
+ {recentLines.map((line, idx) => ( +
+ {line.participant}: + {line.text} +
+ ))} + {live && live.text && ( +
+ {live.participant}: + {live.text} +
+ )} +
+
+ ); +}; + +export default TranscriptionsOverlay; diff --git a/src/components/TranscriptionsOverlay/index.ts b/src/components/TranscriptionsOverlay/index.ts new file mode 100644 index 000000000..4aa4bea79 --- /dev/null +++ b/src/components/TranscriptionsOverlay/index.ts @@ -0,0 +1 @@ +export { default, TranscriptionsOverlay } from './TranscriptionsOverlay'; diff --git a/src/hooks/__tests__/useTranscriptions.test.ts b/src/hooks/__tests__/useTranscriptions.test.ts new file mode 100644 index 000000000..18a53b29c --- /dev/null +++ b/src/hooks/__tests__/useTranscriptions.test.ts @@ -0,0 +1,86 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useTranscriptions, TranscriptionEvent } from '../useTranscriptions'; + +function createMockRoom() { + let listeners: { [event: string]: Function[] } = {}; + return { + on: (event: string, cb: Function) => { + listeners[event] = listeners[event] || []; + listeners[event].push(cb); + }, + off: (event: string, cb: Function) => { + listeners[event] = (listeners[event] || []).filter(fn => fn !== cb); + }, + emit: (event: string, payload: any) => { + (listeners[event] || []).forEach(fn => fn(payload)); + }, + state: 'connected', + }; +} + +describe('useTranscriptions', () => { + it('appends a final line when a final event arrives', () => { + const room = createMockRoom(); + const { result } = renderHook(() => useTranscriptions(room as any)); + + act(() => { + room.emit('transcription', { + participant: 'p1', + transcription: 'Hello world', + partial_results: false, + absolute_time: new Date().toISOString(), + } as TranscriptionEvent); + }); + + expect(result.current.lines).toHaveLength(1); + expect(result.current.lines[0].text).toBe('Hello world'); + expect(result.current.live).toBeNull(); + }); + + it('updates live line for partials, commits on final', () => { + const room = createMockRoom(); + const { result } = renderHook(() => useTranscriptions(room as any)); + + act(() => { + room.emit('transcription', { + participant: 'p2', + transcription: 'This is', + partial_results: true, + } as TranscriptionEvent); + }); + + expect(result.current.live?.text).toBe('This is'); + expect(result.current.lines).toHaveLength(0); + + act(() => { + room.emit('transcription', { + participant: 'p2', + transcription: 'This is final', + partial_results: false, + } as TranscriptionEvent); + }); + + expect(result.current.live).toBeNull(); + expect(result.current.lines).toHaveLength(1); + expect(result.current.lines[0].text).toBe('This is final'); + }); + + it('keeps only last N committed lines', () => { + const room = createMockRoom(); + const { result } = renderHook(() => useTranscriptions(room as any)); + + act(() => { + for (let i = 0; i < 7; i++) { + room.emit('transcription', { + participant: 'p3', + transcription: `Line ${i}`, + partial_results: false, + } as TranscriptionEvent); + } + }); + + expect(result.current.lines).toHaveLength(5); + expect(result.current.lines[0].text).toBe('Line 2'); + expect(result.current.lines[4].text).toBe('Line 6'); + }); +}); diff --git a/src/hooks/useTranscriptions.ts b/src/hooks/useTranscriptions.ts new file mode 100644 index 000000000..5d279d3a1 --- /dev/null +++ b/src/hooks/useTranscriptions.ts @@ -0,0 +1,149 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import { Room } from 'twilio-video'; + +/** + * React hook for Twilio Real-Time Transcriptions. + * + * See official docs: + * - Overview: https://www.twilio.com/docs/video/api/real-time-transcriptions + * - Console defaults & REST configuration: https://www.twilio.com/docs/video/api/real-time-transcriptions#enabling-real-time-transcriptions + * - JS SDK usage: https://www.twilio.com/docs/video/api/real-time-transcriptions#receiving-transcriptions-in-the-js-sdk + * + * Usage: + * const { lines, live, clear } = useTranscriptions(room); + * room.on('transcription', event => ...); + * Pass receiveTranscriptions: true in ConnectOptions. + */ + +export type TranscriptionEvent = { + participant: string; // display name or SID + transcription: string; // text content + partial_results?: boolean; // true if partial + absolute_time?: string; // ISO 8601 + language_code?: string; +}; + +export type TranscriptionLine = { + text: string; + participant: string; + time: number; +}; + +type UseTranscriptionsResult = { + lines: TranscriptionLine[]; + live?: TranscriptionLine | null; + push: (event: TranscriptionEvent) => void; + clear: () => void; +}; + +const MAX_TOTAL_LINES = 3; +const MAX_LINES_PER_PARTICIPANT = 2; + +/** + * Format participant display string as PA..1234 (last 4 chars of SID). + */ +function formatParticipant(participant: string): string { + if (!participant) return ''; + if (/^PA[a-zA-Z0-9]+$/.test(participant) && participant.length > 4) { + return `PA..${participant.slice(-4)}`; + } + return participant; +} + +export function useTranscriptions(room: Room | null): UseTranscriptionsResult { + const [lines, setLines] = useState([]); + const [live, setLive] = useState(null); + const liveRef = useRef<{ [sid: string]: TranscriptionLine }>({}); + + const clear = useCallback(() => { + setLines([]); + setLive(null); + liveRef.current = {}; + }, []); + + const push = useCallback((event: TranscriptionEvent) => { + const participant = formatParticipant(event.participant); + const text = event.transcription; + const time = event.absolute_time ? Date.parse(event.absolute_time) : Date.now(); + + if (event.partial_results) { + // Store/update live partial for participant + const liveLine: TranscriptionLine = { text, participant, time }; + liveRef.current[participant] = liveLine; + setLive(liveLine); + } else { + // Final result: move from live to committed + const finalLine: TranscriptionLine = { text, participant, time }; + setLines(prev => { + // Add new line + const updated = [...prev, finalLine]; + + // Enforce max per participant + const perParticipant: { [sid: string]: TranscriptionLine[] } = {}; + updated.forEach(line => { + if (!perParticipant[line.participant]) perParticipant[line.participant] = []; + perParticipant[line.participant].push(line); + }); + + // Remove oldest lines if any participant exceeds MAX_LINES_PER_PARTICIPANT + let filtered = updated; + Object.keys(perParticipant).forEach(sid => { + const arr = perParticipant[sid]; + if (arr.length > MAX_LINES_PER_PARTICIPANT) { + // Remove oldest for this participant + const toRemove = arr.length - MAX_LINES_PER_PARTICIPANT; + let count = 0; + filtered = filtered.filter(line => { + if (line.participant === sid && count < toRemove) { + count++; + return false; + } + return true; + }); + } + }); + + // Enforce max total lines + if (filtered.length > MAX_TOTAL_LINES) { + filtered = filtered.slice(filtered.length - MAX_TOTAL_LINES); + } + + return filtered; + }); + // Remove live partial for participant + delete liveRef.current[participant]; + setLive(null); + } + }, []); + + useEffect(() => { + if (!room) { + clear(); + return; + } + + const handler = (event: TranscriptionEvent) => { + push(event); + }; + + room.on('transcription', handler); + + // Clear on disconnect + const disconnectHandler = () => { + clear(); + }; + room.on('disconnected', disconnectHandler); + + return () => { + room.off('transcription', handler); + room.off('disconnected', disconnectHandler); + clear(); + }; + }, [room, push, clear]); + + if (!room) { + return { lines: [], push: () => {}, clear: () => {} }; + } + + return { lines, live, push, clear }; +} diff --git a/src/utils/useConnectionOptions/useConnectionOptions.ts b/src/utils/useConnectionOptions/useConnectionOptions.ts index d7c6d8ec1..3a7cf4df1 100644 --- a/src/utils/useConnectionOptions/useConnectionOptions.ts +++ b/src/utils/useConnectionOptions/useConnectionOptions.ts @@ -31,6 +31,10 @@ export default function useConnectionOptions() { //@ts-ignore - Internal use only. This property is not exposed in type definitions. environment: process.env.REACT_APP_TWILIO_ENVIRONMENT, + + // Enable Twilio Real-Time Transcriptions + //@ts-ignore - Internal use only. This property is not exposed in type definitions. + receiveTranscriptions: true, }; // For mobile browsers, limit the maximum incoming video bitrate to 2.5 Mbps. From cd8ed971d34ccf5cf1ae32250cc9aab3ad45930e Mon Sep 17 00:00:00 2001 From: Donal Toomey Date: Thu, 11 Sep 2025 11:26:13 +0100 Subject: [PATCH 2/9] Updated to use the new stability field. Ignoring partial results with stability less than 0.9 --- src/hooks/useTranscriptions.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/hooks/useTranscriptions.ts b/src/hooks/useTranscriptions.ts index 5d279d3a1..c1d6002c1 100644 --- a/src/hooks/useTranscriptions.ts +++ b/src/hooks/useTranscriptions.ts @@ -15,10 +15,16 @@ import { Room } from 'twilio-video'; * Pass receiveTranscriptions: true in ConnectOptions. */ +const STABILITY_THRESHOLD = 0.9; + export type TranscriptionEvent = { participant: string; // display name or SID transcription: string; // text content partial_results?: boolean; // true if partial + stability?: number; // present on partials; not present on finals + sequence_number?: number; + track?: string; + timestamp?: string; absolute_time?: string; // ISO 8601 language_code?: string; }; @@ -67,6 +73,13 @@ export function useTranscriptions(room: Room | null): UseTranscriptionsResult { const time = event.absolute_time ? Date.parse(event.absolute_time) : Date.now(); if (event.partial_results) { + // Drop low-stability partials: only show partials with stability >= threshold. + // Finals are always committed regardless of stability. + const stab = typeof event.stability === 'number' ? event.stability : 0; + if (stab < STABILITY_THRESHOLD) { + // Ignore low-stability partials; keep showing the last acceptable partial + return; + } // Store/update live partial for participant const liveLine: TranscriptionLine = { text, participant, time }; liveRef.current[participant] = liveLine; From e3fbaca38f6f9b45253c5a04765ca52b24349a41 Mon Sep 17 00:00:00 2001 From: Donal Toomey Date: Fri, 19 Sep 2025 11:06:29 +0100 Subject: [PATCH 3/9] UI: added ToggleCaptionsButton and integrated into MenuBar. Not wired up yet --- .../ToggleCaptionsButton.tsx | 31 +++++++++++++++++++ .../Buttons/ToggleCaptionsButton/index.ts | 1 + src/components/MenuBar/MenuBar.tsx | 2 ++ 3 files changed, 34 insertions(+) create mode 100644 src/components/Buttons/ToggleCaptionsButton/ToggleCaptionsButton.tsx create mode 100644 src/components/Buttons/ToggleCaptionsButton/index.ts diff --git a/src/components/Buttons/ToggleCaptionsButton/ToggleCaptionsButton.tsx b/src/components/Buttons/ToggleCaptionsButton/ToggleCaptionsButton.tsx new file mode 100644 index 000000000..ae88a4656 --- /dev/null +++ b/src/components/Buttons/ToggleCaptionsButton/ToggleCaptionsButton.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import Button from '@material-ui/core/Button'; +import Tooltip from '@material-ui/core/Tooltip'; +import ClosedCaptionIcon from '@material-ui/icons/ClosedCaption'; +import ClosedCaptionOutlinedIcon from '@material-ui/icons/ClosedCaptionOutlined'; + +type Props = { disabled?: boolean; className?: string }; + +export default function ToggleCaptionsButton({ disabled, className }: Props) { + const [enabled, setEnabled] = React.useState(false); + const title = enabled ? 'Hide captions' : 'Show captions'; + const Icon = enabled ? ClosedCaptionIcon : ClosedCaptionOutlinedIcon; + + return ( + + {/* span wrapper ensures Tooltip works when Button is disabled */} + + + + + ); +} diff --git a/src/components/Buttons/ToggleCaptionsButton/index.ts b/src/components/Buttons/ToggleCaptionsButton/index.ts new file mode 100644 index 000000000..2f3e52874 --- /dev/null +++ b/src/components/Buttons/ToggleCaptionsButton/index.ts @@ -0,0 +1 @@ +export { default } from './ToggleCaptionsButton'; diff --git a/src/components/MenuBar/MenuBar.tsx b/src/components/MenuBar/MenuBar.tsx index fa03fd176..93f58fcd3 100644 --- a/src/components/MenuBar/MenuBar.tsx +++ b/src/components/MenuBar/MenuBar.tsx @@ -13,6 +13,7 @@ import ToggleAudioButton from '../Buttons/ToggleAudioButton/ToggleAudioButton'; import ToggleChatButton from '../Buttons/ToggleChatButton/ToggleChatButton'; import ToggleVideoButton from '../Buttons/ToggleVideoButton/ToggleVideoButton'; import ToggleScreenShareButton from '../Buttons/ToogleScreenShareButton/ToggleScreenShareButton'; +import ToggleCaptionsButton from '../Buttons/ToggleCaptionsButton/ToggleCaptionsButton'; const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -92,6 +93,7 @@ export default function MenuBar() { + {!isSharingScreen && !isMobile && } {process.env.REACT_APP_DISABLE_TWILIO_CONVERSATIONS !== 'true' && } From b1e2fd9bb0a3170c6b28eb0c93befcf588b08017 Mon Sep 17 00:00:00 2001 From: Donal Toomey Date: Fri, 19 Sep 2025 11:49:24 +0100 Subject: [PATCH 4/9] UI: wired up the new captions button and removed the old one --- src/App.tsx | 15 +- .../ToggleCaptionsButton.tsx | 16 ++- src/components/MenuBar/MenuBar.test.tsx | 25 ++-- src/components/MenuBar/MenuBar.tsx | 13 +- src/components/Room/Room.tsx | 58 ++------ src/hooks/useTranscriptions.ts | 130 +++++++++--------- 6 files changed, 122 insertions(+), 135 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 9c6e65edf..b899024cb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState, useCallback } from 'react'; import { styled, Theme } from '@material-ui/core/styles'; import MenuBar from './components/MenuBar/MenuBar'; @@ -27,14 +27,11 @@ const Main = styled('main')(({ theme }: { theme: Theme }) => ({ export default function App() { const roomState = useRoomState(); - - // Here we would like the height of the main container to be the height of the viewport. - // On some mobile browsers, 'height: 100vh' sets the height equal to that of the screen, - // not the viewport. This looks bad when the mobile browsers location bar is open. - // We will dynamically set the height with 'window.innerHeight', which means that this - // will look good on mobile browsers even after the location bar opens or closes. const height = useHeight(); + const [showCaptions, setShowCaptions] = useState(false); + const onToggleCaptions = useCallback(() => setShowCaptions(v => !v), []); + return ( {roomState === 'disconnected' ? ( @@ -44,8 +41,8 @@ export default function App() { - - + + )} diff --git a/src/components/Buttons/ToggleCaptionsButton/ToggleCaptionsButton.tsx b/src/components/Buttons/ToggleCaptionsButton/ToggleCaptionsButton.tsx index ae88a4656..382ba3f2d 100644 --- a/src/components/Buttons/ToggleCaptionsButton/ToggleCaptionsButton.tsx +++ b/src/components/Buttons/ToggleCaptionsButton/ToggleCaptionsButton.tsx @@ -4,11 +4,17 @@ import Tooltip from '@material-ui/core/Tooltip'; import ClosedCaptionIcon from '@material-ui/icons/ClosedCaption'; import ClosedCaptionOutlinedIcon from '@material-ui/icons/ClosedCaptionOutlined'; -type Props = { disabled?: boolean; className?: string }; +type Props = { + disabled?: boolean; + className?: string; + showCaptions: boolean; + onToggleCaptions: () => void; + tooltip?: string; +}; -export default function ToggleCaptionsButton({ disabled, className }: Props) { - const [enabled, setEnabled] = React.useState(false); - const title = enabled ? 'Hide captions' : 'Show captions'; +export default function ToggleCaptionsButton({ disabled, className, showCaptions, onToggleCaptions, tooltip }: Props) { + const enabled = showCaptions; + const title = tooltip ?? (enabled ? '' : 'Requires Real-Time Transcriptions to be enabled in the Twilio Console'); const Icon = enabled ? ClosedCaptionIcon : ClosedCaptionOutlinedIcon; return ( @@ -17,7 +23,7 @@ export default function ToggleCaptionsButton({ disabled, className }: Props) { diff --git a/src/components/MenuBar/MenuBar.test.tsx b/src/components/MenuBar/MenuBar.test.tsx index 9d1ce8e64..b01010b11 100644 --- a/src/components/MenuBar/MenuBar.test.tsx +++ b/src/components/MenuBar/MenuBar.test.tsx @@ -28,11 +28,6 @@ mockUseVideoContext.mockImplementation(() => ({ mockUseRoomState.mockImplementation(() => 'connected'); mockUseParticipants.mockImplementation(() => ['mockRemoteParticpant', 'mockRemoteParticpant2']); -const defaultProps = { - showCaptions: false, - onToggleCaptions: jest.fn(), -}; - describe('the MenuBar component', () => { beforeEach(() => { //@ts-ignore @@ -42,14 +37,14 @@ describe('the MenuBar component', () => { it('should disable toggle buttons while reconnecting to the room', () => { mockUseRoomState.mockImplementationOnce(() => 'reconnecting'); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(ToggleAudioButton).prop('disabled')).toBe(true); expect(wrapper.find(ToggleVideoButton).prop('disabled')).toBe(true); expect(wrapper.find(ToggleScreenShareButton).prop('disabled')).toBe(true); }); it('should enable toggle buttons while connected to the room', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(ToggleAudioButton).prop('disabled')).toBe(false); expect(wrapper.find(ToggleVideoButton).prop('disabled')).toBe(false); expect(wrapper.find(ToggleScreenShareButton).prop('disabled')).toBe(false); @@ -61,7 +56,7 @@ describe('the MenuBar component', () => { toggleScreenShare: () => {}, room: { name: 'Test Room' }, })); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(ToggleScreenShareButton).exists()).toBe(false); expect( wrapper @@ -78,7 +73,7 @@ describe('the MenuBar component', () => { toggleScreenShare: () => {}, room: { name: 'Test Room' }, })); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(ToggleScreenShareButton).exists()).toBe(true); }); @@ -90,18 +85,18 @@ describe('the MenuBar component', () => { })); // @ts-ignore utils.isMobile = true; - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(ToggleScreenShareButton).exists()).toBe(false); }); it('should render the ToggleChatButton when REACT_APP_DISABLE_TWILIO_CONVERSATIONS is not true', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(ToggleChatButton).exists()).toBe(true); }); it('should hide the ToggleChatButton when REACT_APP_DISABLE_TWILIO_CONVERSATIONS is true', () => { process.env.REACT_APP_DISABLE_TWILIO_CONVERSATIONS = 'true'; - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(ToggleChatButton).exists()).toBe(false); }); @@ -112,7 +107,7 @@ describe('the MenuBar component', () => { toggleScreenShare: mockToggleScreenShare, room: { name: 'Test Room' }, })); - const wrapper = shallow(); + const wrapper = shallow(); wrapper .find(Grid) @@ -124,7 +119,7 @@ describe('the MenuBar component', () => { }); it('should correctly display the number of participants in a room when there is more than 1 participant', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect( wrapper .find('WithStyles(ForwardRef(Typography))') @@ -135,7 +130,7 @@ describe('the MenuBar component', () => { it('should correctly display the number of participants in a room when there is exactly 1 participant', () => { mockUseParticipants.mockImplementationOnce(() => []); - const wrapper = shallow(); + const wrapper = shallow(); expect( wrapper .find('WithStyles(ForwardRef(Typography))') diff --git a/src/components/MenuBar/MenuBar.tsx b/src/components/MenuBar/MenuBar.tsx index 053bde9dc..93f58fcd3 100644 --- a/src/components/MenuBar/MenuBar.tsx +++ b/src/components/MenuBar/MenuBar.tsx @@ -64,12 +64,7 @@ const useStyles = makeStyles((theme: Theme) => }) ); -type MenuBarProps = { - showCaptions: boolean; - onToggleCaptions: () => void; -}; - -export default function MenuBar({ showCaptions, onToggleCaptions }: MenuBarProps) { +export default function MenuBar() { const classes = useStyles(); const { isSharingScreen, toggleScreenShare } = useVideoContext(); const roomState = useRoomState(); @@ -98,11 +93,7 @@ export default function MenuBar({ showCaptions, onToggleCaptions }: MenuBarProps - + {!isSharingScreen && !isMobile && } {process.env.REACT_APP_DISABLE_TWILIO_CONVERSATIONS !== 'true' && } diff --git a/src/components/Room/Room.tsx b/src/components/Room/Room.tsx index 743e6a4fe..e8160a963 100644 --- a/src/components/Room/Room.tsx +++ b/src/components/Room/Room.tsx @@ -13,7 +13,6 @@ import { useAppState } from '../../state'; import useChatContext from '../../hooks/useChatContext/useChatContext'; import useScreenShareParticipant from '../../hooks/useScreenShareParticipant/useScreenShareParticipant'; import useVideoContext from '../../hooks/useVideoContext/useVideoContext'; -import { useTranscriptions } from '../../hooks/useTranscriptions'; import TranscriptionsOverlay from '../TranscriptionsOverlay'; const useStyles = makeStyles((theme: Theme) => { @@ -72,11 +71,7 @@ export function useSetSpeakerViewOnScreenShare( }, [screenShareParticipant, setIsGalleryViewActive, room]); } -type RoomProps = { - showCaptions?: boolean; -}; - -export default function Room({ showCaptions = false }: RoomProps) { +export default function Room() { const classes = useStyles(); const { isChatWindowOpen } = useChatContext(); const { isBackgroundSelectionOpen, room } = useVideoContext(); @@ -85,27 +80,6 @@ export default function Room({ showCaptions = false }: RoomProps) { const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const screenShareParticipant = useScreenShareParticipant(); - // Connection/captions visibility - const isConnected = !!room && room.state === 'connected'; - const captionsVisible = !!showCaptions && isConnected; - - const { lines, live, clear } = useTranscriptions(room, { enabled: captionsVisible }); - - useEffect(() => { - if (!room) { - clear(); - return; - } - const handleDisconnect = () => clear(); - room.on('disconnected', handleDisconnect); - return () => { - room.off('disconnected', handleDisconnect); - clear(); - }; - }, [room, clear]); - - // Here we switch to speaker view when a participant starts sharing their screen, but - // the user is still free to switch back to gallery view. useSetSpeakerViewOnScreenShare(screenShareParticipant, room, setIsGalleryViewActive, isGalleryViewActive); return ( @@ -136,7 +110,7 @@ export default function Room({ showCaptions = false }: RoomProps) { - + ); } diff --git a/src/components/TranscriptionsOverlay/TranscriptionsOverlay.test.tsx b/src/components/TranscriptionsOverlay/TranscriptionsOverlay.test.tsx new file mode 100644 index 000000000..a83f25a33 --- /dev/null +++ b/src/components/TranscriptionsOverlay/TranscriptionsOverlay.test.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import TranscriptionsOverlay, { TranscriptionLine } from './TranscriptionsOverlay'; +import { useAppState } from '../../state'; +import useVideoContext from '../../hooks/useVideoContext/useVideoContext'; +import { useTranscriptions } from '../../hooks/useTranscriptions/useTranscriptions'; + +jest.mock('../../state'); +jest.mock('../../hooks/useVideoContext/useVideoContext'); +jest.mock('../../hooks/useTranscriptions/useTranscriptions'); + +const mockUseAppState = useAppState as jest.Mock; +const mockUseVideoContext = useVideoContext as jest.Mock; +const mockUseTranscriptions = useTranscriptions as jest.Mock; + +describe('the TranscriptionsOverlay component', () => { + const mockLines: TranscriptionLine[] = [ + { text: 'Hello world', participant: 'PA..1234', time: 1000 }, + { text: 'How are you?', participant: 'PA..5678', time: 2000 }, + ]; + + const mockRoom = { state: 'connected' }; + + beforeEach(() => { + mockUseVideoContext.mockImplementation(() => ({ room: mockRoom })); + }); + + it('should not render when captions are disabled', () => { + mockUseAppState.mockImplementation(() => ({ isCaptionsEnabled: false })); + mockUseTranscriptions.mockImplementation(() => ({ lines: mockLines, live: null })); + const wrapper = shallow(); + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('should not render when not connected to room', () => { + mockUseAppState.mockImplementation(() => ({ isCaptionsEnabled: true })); + mockUseVideoContext.mockImplementationOnce(() => ({ room: null })); + mockUseTranscriptions.mockImplementation(() => ({ lines: mockLines, live: null })); + const wrapper = shallow(); + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('should not render when no content available', () => { + mockUseAppState.mockImplementation(() => ({ isCaptionsEnabled: true })); + mockUseTranscriptions.mockImplementation(() => ({ lines: [], live: null })); + const wrapper = shallow(); + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('should render when captions enabled and content available', () => { + mockUseAppState.mockImplementation(() => ({ isCaptionsEnabled: true })); + mockUseTranscriptions.mockImplementation(() => ({ lines: mockLines, live: null })); + const wrapper = shallow(); + expect(wrapper.exists()).toBe(true); + expect(wrapper.find('[role="region"]')).toHaveLength(1); + }); + + it('should render all provided lines', () => { + mockUseAppState.mockImplementation(() => ({ isCaptionsEnabled: true })); + mockUseTranscriptions.mockImplementation(() => ({ lines: mockLines, live: null })); + const wrapper = shallow(); + expect(wrapper.text()).toContain('PA..1234:'); + expect(wrapper.text()).toContain('Hello world'); + expect(wrapper.text()).toContain('PA..5678:'); + expect(wrapper.text()).toContain('How are you?'); + }); + + it('should render live partial line when provided', () => { + const mockLive = { text: 'In progress...', participant: 'PA..9999' }; + mockUseAppState.mockImplementation(() => ({ isCaptionsEnabled: true })); + mockUseTranscriptions.mockImplementation(() => ({ lines: mockLines, live: mockLive })); + const wrapper = shallow(); + expect(wrapper.text()).toContain('PA..9999:'); + expect(wrapper.text()).toContain('In progress...'); + }); + + it('should not render live line when not provided', () => { + mockUseAppState.mockImplementation(() => ({ isCaptionsEnabled: true })); + mockUseTranscriptions.mockImplementation(() => ({ lines: mockLines, live: null })); + const wrapper = shallow(); + expect(wrapper.text()).not.toContain('PA..9999:'); + }); + + it('should limit display to last 5 lines', () => { + const manyLines: TranscriptionLine[] = [ + { text: 'Line 1', participant: 'PA..1111', time: 1000 }, + { text: 'Line 2', participant: 'PA..2222', time: 2000 }, + { text: 'Line 3', participant: 'PA..3333', time: 3000 }, + { text: 'Line 4', participant: 'PA..4444', time: 4000 }, + { text: 'Line 5', participant: 'PA..5555', time: 5000 }, + { text: 'Line 6', participant: 'PA..6666', time: 6000 }, + { text: 'Line 7', participant: 'PA..7777', time: 7000 }, + ]; + mockUseAppState.mockImplementation(() => ({ isCaptionsEnabled: true })); + mockUseTranscriptions.mockImplementation(() => ({ lines: manyLines, live: null })); + const wrapper = shallow(); + expect(wrapper.text()).not.toContain('Line 1'); + expect(wrapper.text()).not.toContain('Line 2'); + expect(wrapper.text()).toContain('Line 3'); + expect(wrapper.text()).toContain('Line 7'); + }); +}); diff --git a/src/components/TranscriptionsOverlay/TranscriptionsOverlay.tsx b/src/components/TranscriptionsOverlay/TranscriptionsOverlay.tsx index b68748b34..284ef66fb 100644 --- a/src/components/TranscriptionsOverlay/TranscriptionsOverlay.tsx +++ b/src/components/TranscriptionsOverlay/TranscriptionsOverlay.tsx @@ -1,17 +1,8 @@ import React from 'react'; - -/** - * Overlay component for displaying Twilio Real-Time Transcriptions. - * - * See official docs: - * - Overview: https://www.twilio.com/docs/video/api/real-time-transcriptions - * - JS SDK usage: https://www.twilio.com/docs/video/api/real-time-transcriptions#receiving-transcriptions-in-the-js-sdk - * - * Props: - * lines: Array of committed transcription lines. - * live: Optional live partial line. - * visible: Show/hide overlay. - */ +import { makeStyles, Theme } from '@material-ui/core/styles'; +import { useAppState } from '../../state'; +import useVideoContext from '../../hooks/useVideoContext/useVideoContext'; +import { useTranscriptions } from '../../hooks/useTranscriptions/useTranscriptions'; export type TranscriptionLine = { text: string; @@ -19,76 +10,89 @@ export type TranscriptionLine = { time: number; }; -type Props = { - lines: TranscriptionLine[]; - live?: { text: string; participant: string } | null; - visible: boolean; -}; +const useStyles = makeStyles((theme: Theme) => ({ + overlay: { + position: 'fixed', + left: '50%', + bottom: 64, + transform: 'translateX(-50%)', + maxWidth: 960, + width: '80vw', + margin: '0 auto', + background: 'rgba(51, 51, 51, 0.8)', + color: '#fff', + borderRadius: '1.25rem', + boxShadow: '0 4px 24px rgba(0,0,0,0.18)', + padding: '1.25rem 2rem', + zIndex: 1000, + fontSize: 'clamp(0.875rem, 1.5vw, 1rem)', + transition: 'opacity 0.3s ease', + opacity: 1, + pointerEvents: 'auto', + }, + container: { + display: 'flex', + flexDirection: 'column', + gap: '0.5rem', + }, + line: { + opacity: 0.8, + fontSize: '0.875rem', + fontWeight: 400, + letterSpacing: '0.01em', + transition: 'opacity 0.3s ease', + color: '#fff', + textShadow: '0 1px 2px #222', + }, + liveLine: { + opacity: 0.6, + fontStyle: 'italic', + fontSize: '0.875rem', + fontWeight: 400, + letterSpacing: '0.01em', + color: '#fff', + textShadow: '0 1px 2px #222', + transition: 'opacity 0.3s ease', + }, + participant: { + fontWeight: 600, + marginRight: 8, + }, +})); + +export const TranscriptionsOverlay: React.FC = () => { + const classes = useStyles(); + const { isCaptionsEnabled } = useAppState(); + const { room } = useVideoContext(); + + const isConnected = !!room && room.state === 'connected'; + const captionsVisible = isCaptionsEnabled && isConnected; -export const TranscriptionsOverlay: React.FC = ({ lines, live, visible }) => { - if (!visible) return null; + const { lines, live } = useTranscriptions(room, { enabled: captionsVisible }); + + if (!captionsVisible) return null; - // Show last 3-5 lines const recentLines = lines.slice(-5); + const hasContent = recentLines.length > 0 || (live && live.text); + + if (!hasContent) return null; return ( -
-
+
+
{recentLines.map((line, idx) => (
- {line.participant}: + {line.participant}: {line.text}
))} {live && live.text && ( -
- {live.participant}: +
+ {live.participant}: {live.text}
)} diff --git a/src/hooks/__tests__/useTranscriptions.test.ts b/src/hooks/useTranscriptions/useTranscriptions.test.ts similarity index 88% rename from src/hooks/__tests__/useTranscriptions.test.ts rename to src/hooks/useTranscriptions/useTranscriptions.test.ts index 18a53b29c..104450f6f 100644 --- a/src/hooks/__tests__/useTranscriptions.test.ts +++ b/src/hooks/useTranscriptions/useTranscriptions.test.ts @@ -1,5 +1,5 @@ import { renderHook, act } from '@testing-library/react-hooks'; -import { useTranscriptions, TranscriptionEvent } from '../useTranscriptions'; +import { useTranscriptions, TranscriptionEvent } from './useTranscriptions'; function createMockRoom() { let listeners: { [event: string]: Function[] } = {}; @@ -46,6 +46,7 @@ describe('useTranscriptions', () => { participant: 'p2', transcription: 'This is', partial_results: true, + stability: 0.95, } as TranscriptionEvent); }); @@ -72,15 +73,15 @@ describe('useTranscriptions', () => { act(() => { for (let i = 0; i < 7; i++) { room.emit('transcription', { - participant: 'p3', + participant: `p${i % 3}`, transcription: `Line ${i}`, partial_results: false, } as TranscriptionEvent); } }); - expect(result.current.lines).toHaveLength(5); - expect(result.current.lines[0].text).toBe('Line 2'); - expect(result.current.lines[4].text).toBe('Line 6'); + expect(result.current.lines).toHaveLength(3); + expect(result.current.lines[0].text).toBe('Line 4'); + expect(result.current.lines[2].text).toBe('Line 6'); }); }); diff --git a/src/hooks/useTranscriptions.ts b/src/hooks/useTranscriptions/useTranscriptions.ts similarity index 100% rename from src/hooks/useTranscriptions.ts rename to src/hooks/useTranscriptions/useTranscriptions.ts diff --git a/src/icons/CaptionsIcon.tsx b/src/icons/CaptionsIcon.tsx new file mode 100644 index 000000000..427746fa4 --- /dev/null +++ b/src/icons/CaptionsIcon.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +export default function CaptionsIcon() { + return ( + + + + ); +} diff --git a/src/icons/CaptionsOffIcon.tsx b/src/icons/CaptionsOffIcon.tsx new file mode 100644 index 000000000..dbccec4e3 --- /dev/null +++ b/src/icons/CaptionsOffIcon.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +export default function CaptionsOffIcon() { + return ( + + + + ); +} diff --git a/src/state/index.tsx b/src/state/index.tsx index c5bbbacc9..2c1e546c4 100644 --- a/src/state/index.tsx +++ b/src/state/index.tsx @@ -31,6 +31,8 @@ export interface StateContextType { setIsKrispEnabled: React.Dispatch>; isKrispInstalled: boolean; setIsKrispInstalled: React.Dispatch>; + isCaptionsEnabled: boolean; + setIsCaptionsEnabled: React.Dispatch>; } export const StateContext = createContext(null!); @@ -58,6 +60,7 @@ export default function AppStateProvider(props: React.PropsWithChildren<{}>) { const [isKrispEnabled, setIsKrispEnabled] = useState(false); const [isKrispInstalled, setIsKrispInstalled] = useState(false); + const [isCaptionsEnabled, setIsCaptionsEnabled] = useLocalStorageState('captions-enabled-key', false); let contextValue = { error, @@ -76,6 +79,8 @@ export default function AppStateProvider(props: React.PropsWithChildren<{}>) { setIsKrispEnabled, isKrispInstalled, setIsKrispInstalled, + isCaptionsEnabled, + setIsCaptionsEnabled, } as StateContextType; if (process.env.REACT_APP_SET_AUTH === 'firebase') { diff --git a/src/utils/useConnectionOptions/useConnectionOptions.test.tsx b/src/utils/useConnectionOptions/useConnectionOptions.test.tsx index 3527c2ac1..084699928 100644 --- a/src/utils/useConnectionOptions/useConnectionOptions.test.tsx +++ b/src/utils/useConnectionOptions/useConnectionOptions.test.tsx @@ -27,6 +27,7 @@ describe('the useConnectionOptions function', () => { maxAudioBitrate: 0, networkQuality: { local: 1, remote: 1 }, preferredVideoCodecs: 'auto', + receiveTranscriptions: true, }; mockUseAppState.mockImplementationOnce(() => ({ settings })); @@ -57,6 +58,7 @@ describe('the useConnectionOptions function', () => { maxAudioBitrate: 0, networkQuality: { local: 1, remote: 1 }, preferredVideoCodecs: 'auto', + receiveTranscriptions: true, }; mockUseAppState.mockImplementationOnce(() => ({ settings })); diff --git a/src/utils/useConnectionOptions/useConnectionOptions.ts b/src/utils/useConnectionOptions/useConnectionOptions.ts index 3a7cf4df1..d9f60708c 100644 --- a/src/utils/useConnectionOptions/useConnectionOptions.ts +++ b/src/utils/useConnectionOptions/useConnectionOptions.ts @@ -5,7 +5,7 @@ import { useAppState } from '../../state'; export default function useConnectionOptions() { const { settings } = useAppState(); - // See: https://sdk.twilio.com/js/video/releases/2.0.0/docs/global.html#ConnectOptions + // See: https://sdk.twilio.com/js/video/releases/2.32.1/docs/global.html#ConnectOptions // for available connection options. const connectionOptions: ConnectOptions = { // Bandwidth Profile, Dominant Speaker, and Network Quality @@ -29,12 +29,10 @@ export default function useConnectionOptions() { preferredVideoCodecs: 'auto', - //@ts-ignore - Internal use only. This property is not exposed in type definitions. - environment: process.env.REACT_APP_TWILIO_ENVIRONMENT, + receiveTranscriptions: true, - // Enable Twilio Real-Time Transcriptions //@ts-ignore - Internal use only. This property is not exposed in type definitions. - receiveTranscriptions: true, + environment: process.env.REACT_APP_TWILIO_ENVIRONMENT, }; // For mobile browsers, limit the maximum incoming video bitrate to 2.5 Mbps. From 9a49e552c9a424b249d3f6a309d6fd02b75229fa Mon Sep 17 00:00:00 2001 From: Luis Rivas Date: Tue, 14 Oct 2025 11:00:55 -0500 Subject: [PATCH 7/9] test: add unit tests for ToggleCaptionsButton component and its integration in MenuBar --- .../ToggleCaptionsButton.test.tsx | 91 +++++++++++++++++++ .../ToggleCaptionsButton.tsx | 4 +- src/components/MenuBar/MenuBar.test.tsx | 6 ++ 3 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 src/components/Buttons/ToggleCaptionsButton/ToggleCaptionsButton.test.tsx diff --git a/src/components/Buttons/ToggleCaptionsButton/ToggleCaptionsButton.test.tsx b/src/components/Buttons/ToggleCaptionsButton/ToggleCaptionsButton.test.tsx new file mode 100644 index 000000000..312c01d24 --- /dev/null +++ b/src/components/Buttons/ToggleCaptionsButton/ToggleCaptionsButton.test.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import ToggleCaptionsButton from './ToggleCaptionsButton'; +import { useAppState } from '../../../state'; +import CaptionsIcon from '../../../icons/CaptionsIcon'; +import CaptionsOffIcon from '../../../icons/CaptionsOffIcon'; + +jest.mock('../../../state'); +const mockUseAppState = useAppState as jest.Mock; + +describe('the ToggleCaptionsButton component', () => { + it('should render correctly when captions are enabled', () => { + const mockSetIsCaptionsEnabled = jest.fn(); + mockUseAppState.mockImplementation(() => ({ + isCaptionsEnabled: true, + setIsCaptionsEnabled: mockSetIsCaptionsEnabled, + })); + const wrapper = shallow(); + const button = wrapper.find('[aria-label="Toggle Captions"]'); + expect(button.prop('startIcon')).toEqual(); + expect(button.text()).toBe('Hide Captions'); + }); + + it('should render correctly when captions are disabled', () => { + const mockSetIsCaptionsEnabled = jest.fn(); + mockUseAppState.mockImplementation(() => ({ + isCaptionsEnabled: false, + setIsCaptionsEnabled: mockSetIsCaptionsEnabled, + })); + const wrapper = shallow(); + const button = wrapper.find('[aria-label="Toggle Captions"]'); + expect(button.prop('startIcon')).toEqual(); + expect(button.text()).toBe('Show Captions'); + }); + + it('should call the correct toggle function when clicked', () => { + const mockSetIsCaptionsEnabled = jest.fn(); + mockUseAppState.mockImplementation(() => ({ + isCaptionsEnabled: false, + setIsCaptionsEnabled: mockSetIsCaptionsEnabled, + })); + const wrapper = shallow(); + const button = wrapper.find('[aria-label="Toggle Captions"]'); + button.simulate('click'); + expect(mockSetIsCaptionsEnabled).toHaveBeenCalled(); + }); + + it('should be disabled when disabled prop is true', () => { + const mockSetIsCaptionsEnabled = jest.fn(); + mockUseAppState.mockImplementation(() => ({ + isCaptionsEnabled: false, + setIsCaptionsEnabled: mockSetIsCaptionsEnabled, + })); + const wrapper = shallow(); + const button = wrapper.find('[aria-label="Toggle Captions"]'); + expect(button.prop('disabled')).toBe(true); + }); + + it('should apply className prop', () => { + const mockSetIsCaptionsEnabled = jest.fn(); + mockUseAppState.mockImplementation(() => ({ + isCaptionsEnabled: false, + setIsCaptionsEnabled: mockSetIsCaptionsEnabled, + })); + const wrapper = shallow(); + const button = wrapper.find('[aria-label="Toggle Captions"]'); + expect(button.prop('className')).toBe('test-class'); + }); + + it('should show tooltip when captions are disabled', () => { + const mockSetIsCaptionsEnabled = jest.fn(); + mockUseAppState.mockImplementation(() => ({ + isCaptionsEnabled: false, + setIsCaptionsEnabled: mockSetIsCaptionsEnabled, + })); + const wrapper = shallow(); + const tooltip = wrapper.find('[data-testid="captions-tooltip"]'); + expect(tooltip.prop('title')).toBe('Requires Real-Time Transcriptions to be enabled in the Twilio Console'); + }); + + it('should not show tooltip when captions are enabled', () => { + const mockSetIsCaptionsEnabled = jest.fn(); + mockUseAppState.mockImplementation(() => ({ + isCaptionsEnabled: true, + setIsCaptionsEnabled: mockSetIsCaptionsEnabled, + })); + const wrapper = shallow(); + const tooltip = wrapper.find('[data-testid="captions-tooltip"]'); + expect(tooltip.prop('title')).toBe(''); + }); +}); diff --git a/src/components/Buttons/ToggleCaptionsButton/ToggleCaptionsButton.tsx b/src/components/Buttons/ToggleCaptionsButton/ToggleCaptionsButton.tsx index 242205d28..bad5f65da 100644 --- a/src/components/Buttons/ToggleCaptionsButton/ToggleCaptionsButton.tsx +++ b/src/components/Buttons/ToggleCaptionsButton/ToggleCaptionsButton.tsx @@ -15,7 +15,7 @@ export default function ToggleCaptionsButton(props: { disabled?: boolean; classN const tooltipTitle = isCaptionsEnabled ? '' : 'Requires Real-Time Transcriptions to be enabled in the Twilio Console'; return ( - + diff --git a/src/components/MenuBar/MenuBar.test.tsx b/src/components/MenuBar/MenuBar.test.tsx index b01010b11..f048123ed 100644 --- a/src/components/MenuBar/MenuBar.test.tsx +++ b/src/components/MenuBar/MenuBar.test.tsx @@ -3,6 +3,7 @@ import { Button, Grid, Typography } from '@material-ui/core'; import MenuBar from './MenuBar'; import { shallow } from 'enzyme'; import ToggleAudioButton from '../Buttons/ToggleAudioButton/ToggleAudioButton'; +import ToggleCaptionsButton from '../Buttons/ToggleCaptionsButton/ToggleCaptionsButton'; import ToggleChatButton from '../Buttons/ToggleChatButton/ToggleChatButton'; import ToggleScreenShareButton from '../Buttons/ToogleScreenShareButton/ToggleScreenShareButton'; import ToggleVideoButton from '../Buttons/ToggleVideoButton/ToggleVideoButton'; @@ -138,4 +139,9 @@ describe('the MenuBar component', () => { .text() ).toBe('Test Room | 1 participant'); }); + + it('should render the ToggleCaptionsButton', () => { + const wrapper = shallow(); + expect(wrapper.find(ToggleCaptionsButton).exists()).toBe(true); + }); }); From 31addaf686d2ef2d30941ae821b5f7184c6d0aaf Mon Sep 17 00:00:00 2001 From: Luis Rivas Date: Tue, 14 Oct 2025 11:02:18 -0500 Subject: [PATCH 8/9] nit: comment on the why we set the Container height --- src/App.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/App.tsx b/src/App.tsx index 6aeebb7d0..9c6e65edf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,6 +27,12 @@ const Main = styled('main')(({ theme }: { theme: Theme }) => ({ export default function App() { const roomState = useRoomState(); + + // Here we would like the height of the main container to be the height of the viewport. + // On some mobile browsers, 'height: 100vh' sets the height equal to that of the screen, + // not the viewport. This looks bad when the mobile browsers location bar is open. + // We will dynamically set the height with 'window.innerHeight', which means that this + // will look good on mobile browsers even after the location bar opens or closes. const height = useHeight(); return ( From 636b8c1ffadd838f559f35d71112656e489fe413 Mon Sep 17 00:00:00 2001 From: Luis Rivas Date: Tue, 14 Oct 2025 11:07:24 -0500 Subject: [PATCH 9/9] chore: remove unnecessary changes --- src/components/Buttons/ToggleCaptionsButton/index.ts | 1 - src/components/Room/Room.tsx | 4 +++- src/components/TranscriptionsOverlay/index.ts | 1 - 3 files changed, 3 insertions(+), 3 deletions(-) delete mode 100644 src/components/Buttons/ToggleCaptionsButton/index.ts delete mode 100644 src/components/TranscriptionsOverlay/index.ts diff --git a/src/components/Buttons/ToggleCaptionsButton/index.ts b/src/components/Buttons/ToggleCaptionsButton/index.ts deleted file mode 100644 index 2f3e52874..000000000 --- a/src/components/Buttons/ToggleCaptionsButton/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ToggleCaptionsButton'; diff --git a/src/components/Room/Room.tsx b/src/components/Room/Room.tsx index e8160a963..05be4863a 100644 --- a/src/components/Room/Room.tsx +++ b/src/components/Room/Room.tsx @@ -13,7 +13,7 @@ import { useAppState } from '../../state'; import useChatContext from '../../hooks/useChatContext/useChatContext'; import useScreenShareParticipant from '../../hooks/useScreenShareParticipant/useScreenShareParticipant'; import useVideoContext from '../../hooks/useVideoContext/useVideoContext'; -import TranscriptionsOverlay from '../TranscriptionsOverlay'; +import TranscriptionsOverlay from '../TranscriptionsOverlay/TranscriptionsOverlay'; const useStyles = makeStyles((theme: Theme) => { const totalMobileSidebarHeight = `${theme.sidebarMobileHeight + @@ -80,6 +80,8 @@ export default function Room() { const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const screenShareParticipant = useScreenShareParticipant(); + // Here we switch to speaker view when a participant starts sharing their screen, but + // the user is still free to switch back to gallery view. useSetSpeakerViewOnScreenShare(screenShareParticipant, room, setIsGalleryViewActive, isGalleryViewActive); return ( diff --git a/src/components/TranscriptionsOverlay/index.ts b/src/components/TranscriptionsOverlay/index.ts deleted file mode 100644 index 4aa4bea79..000000000 --- a/src/components/TranscriptionsOverlay/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default, TranscriptionsOverlay } from './TranscriptionsOverlay';