From c71fa8a27472316d1bc61da731bf3e5a29b47477 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Tue, 12 May 2026 15:54:47 +0300 Subject: [PATCH] fix: preflight standup media permissions --- .../components/fields/EmojiPicker.spec.tsx | 64 +++++-- .../src/components/fields/EmojiPicker.tsx | 164 ++++++++++-------- .../components/liveRooms/LiveRoom.spec.tsx | 1 + .../liveRooms/LiveRoomChatPanel.tsx | 1 + .../liveRooms/LiveRoomControls.spec.tsx | 66 +++++-- .../components/liveRooms/LiveRoomControls.tsx | 8 +- .../shared/src/contexts/LiveRoomContext.tsx | 63 +++++++ 7 files changed, 261 insertions(+), 106 deletions(-) diff --git a/packages/shared/src/components/fields/EmojiPicker.spec.tsx b/packages/shared/src/components/fields/EmojiPicker.spec.tsx index bc8f36829c..7c5a246b03 100644 --- a/packages/shared/src/components/fields/EmojiPicker.spec.tsx +++ b/packages/shared/src/components/fields/EmojiPicker.spec.tsx @@ -1,4 +1,11 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import React from 'react'; import { EmojiPicker } from './EmojiPicker'; @@ -21,11 +28,18 @@ describe('EmojiPicker', () => { fireEvent.click(screen.getByRole('button', { name: 'Pick emoji' })); }; - const getEmojiButton = ( - container: HTMLElement, - label: string, - ): HTMLButtonElement => { - const button = container.querySelector( + const renderEmojiPicker = (onChange = jest.fn()) => { + const queryClient = new QueryClient(); + + return render( + + + , + ); + }; + + const getEmojiButton = (label: string): HTMLButtonElement => { + const button = document.body.querySelector( `button[title="${label}"]`, ); @@ -37,7 +51,7 @@ describe('EmojiPicker', () => { }; it('shows emojis by category when search is empty', async () => { - const { container } = render(); + renderEmojiPicker(); openPicker(); @@ -49,11 +63,11 @@ describe('EmojiPicker', () => { screen.getByRole('button', { name: 'People & body' }), ).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Flags' })).toBeInTheDocument(); - expect(getEmojiButton(container, 'grinning face')).toBeInTheDocument(); + expect(getEmojiButton('grinning face')).toBeInTheDocument(); }); it('scrolls to category sections without hiding other categories', async () => { - render(); + renderEmojiPicker(); openPicker(); @@ -72,7 +86,7 @@ describe('EmojiPicker', () => { }); it('shows search results instead of category sections', async () => { - const { container } = render(); + renderEmojiPicker(); openPicker(); fireEvent.change(screen.getByPlaceholderText('Search emojis...'), { @@ -80,32 +94,48 @@ describe('EmojiPicker', () => { }); await waitFor(() => { - expect(getEmojiButton(container, 'rocket')).toBeVisible(); + expect(getEmojiButton('rocket')).toBeVisible(); }); expect(screen.queryByText('Smileys & emotion')).not.toBeInTheDocument(); }); it('stores selected emojis and shows them as recently used', async () => { const onChange = jest.fn(); - const { container } = render(); + renderEmojiPicker(onChange); openPicker(); fireEvent.change(screen.getByPlaceholderText('Search emojis...'), { target: { value: 'rocket' }, }); await waitFor(() => { - expect(getEmojiButton(container, 'rocket')).toBeInTheDocument(); + expect(getEmojiButton('rocket')).toBeInTheDocument(); }); - fireEvent.click(getEmojiButton(container, 'rocket')); + fireEvent.click(getEmojiButton('rocket')); expect(onChange).toHaveBeenCalledWith('🚀'); openPicker(); - expect(await screen.findByText('Recently used')).toBeInTheDocument(); - const recentSection = screen.getByText('Recently used').parentElement; + const recentHeading = await screen.findByText('Recently used'); + expect(recentHeading).toBeInTheDocument(); expect( - recentSection?.querySelector('button[title="rocket"]'), + within(recentHeading.parentElement as HTMLElement).getByTitle('rocket'), ).toBeInTheDocument(); }); + + it('renders the dropdown through the root portal', async () => { + const { container } = renderEmojiPicker(); + + openPicker(); + + await screen.findByText('Smileys & emotion'); + + expect( + within(container).queryByPlaceholderText('Search emojis...'), + ).not.toBeInTheDocument(); + expect(screen.getByPlaceholderText('Search emojis...')).toHaveAttribute( + 'placeholder', + 'Search emojis...', + ); + }); }); diff --git a/packages/shared/src/components/fields/EmojiPicker.tsx b/packages/shared/src/components/fields/EmojiPicker.tsx index 4ca6a4e29c..8f8f250d54 100644 --- a/packages/shared/src/components/fields/EmojiPicker.tsx +++ b/packages/shared/src/components/fields/EmojiPicker.tsx @@ -8,6 +8,7 @@ import React, { } from 'react'; import classNames from 'classnames'; import { Button, ButtonVariant } from '../buttons/Button'; +import { RootPortal } from '../tooltips/Portal'; import { Typography, TypographyType } from '../typography/Typography'; import type { EmojiCategory, @@ -63,6 +64,7 @@ export const EmojiPicker = ({ width: 0, height: FALLBACK_DROPDOWN_HEIGHT, }); + const [isDropdownPositioned, setIsDropdownPositioned] = useState(false); const inputRef = useRef(null); const containerRef = useRef(null); const triggerRef = useRef(null); @@ -112,14 +114,18 @@ export const EmojiPicker = ({ width, height, }); + setIsDropdownPositioned(true); } }, []); useEffect(() => { - if (isOpen) { - updateDropdownPosition(); - inputRef.current?.focus(); + if (!isOpen) { + setIsDropdownPositioned(false); + return; } + + updateDropdownPosition(); + inputRef.current?.focus(); }, [isOpen, updateDropdownPosition, searchQuery]); useEffect(() => { @@ -128,13 +134,18 @@ export const EmojiPicker = ({ } const handleClickOutside = (event: MouseEvent) => { - if ( - containerRef.current && - !containerRef.current.contains(event.target as Node) - ) { - setIsOpen(false); - setSearchQuery(''); + const target = event.target as Node; + + if (containerRef.current?.contains(target)) { + return; + } + + if (dropdownRef.current?.contains(target)) { + return; } + + setIsOpen(false); + setSearchQuery(''); }; const handleScroll = () => { @@ -306,75 +317,78 @@ export const EmojiPicker = ({ {isOpen && ( -
-
-
- setSearchQuery(e.target.value)} - placeholder="Search emojis..." - className="w-full rounded-10 border border-border-subtlest-tertiary bg-surface-float px-3 py-1.5 text-text-primary placeholder:text-text-quaternary focus:border-border-subtlest-secondary focus:outline-none" - /> - - {showCategories && ( -
- {categoriesToShow.map((category) => ( - - ))} -
- )} -
+ +
+
+
+ setSearchQuery(e.target.value)} + placeholder="Search emojis..." + className="w-full rounded-10 border border-border-subtlest-tertiary bg-surface-float px-3 py-1.5 text-text-primary placeholder:text-text-quaternary focus:border-border-subtlest-secondary focus:outline-none" + /> + + {showCategories && ( +
+ {categoriesToShow.map((category) => ( + + ))} +
+ )} +
-
- {showCategories && ( -
- {categoriesToShow.map(renderCategorySection)} -
- )} - - {!showCategories && emojisToShow.length > 0 && ( -
- {emojisToShow.map(renderEmojiButton)} -
- )} - - {!showCategories && emojisToShow.length === 0 && ( - - No emojis found - - )} +
+ {showCategories && ( +
+ {categoriesToShow.map(renderCategorySection)} +
+ )} + + {!showCategories && emojisToShow.length > 0 && ( +
+ {emojisToShow.map(renderEmojiButton)} +
+ )} + + {!showCategories && emojisToShow.length === 0 && ( + + No emojis found + + )} +
-
+
)}
); diff --git a/packages/shared/src/components/liveRooms/LiveRoom.spec.tsx b/packages/shared/src/components/liveRooms/LiveRoom.spec.tsx index e8e79a7168..5626ad5767 100644 --- a/packages/shared/src/components/liveRooms/LiveRoom.spec.tsx +++ b/packages/shared/src/components/liveRooms/LiveRoom.spec.tsx @@ -199,6 +199,7 @@ const createContextValue = ( }, role: 'host', participantId: 'host', + preflightMediaPermissions: jest.fn(), startRoom: jest.fn(), endRoom: jest.fn(), joinSpeakerQueue: jest.fn(), diff --git a/packages/shared/src/components/liveRooms/LiveRoomChatPanel.tsx b/packages/shared/src/components/liveRooms/LiveRoomChatPanel.tsx index abe6a3fbf9..2ff0a3c4d0 100644 --- a/packages/shared/src/components/liveRooms/LiveRoomChatPanel.tsx +++ b/packages/shared/src/components/liveRooms/LiveRoomChatPanel.tsx @@ -149,6 +149,7 @@ const LiveRoomChatComposer = ({ enabledCommand={{ [MarkdownCommand.Upload]: true, [MarkdownCommand.Mention]: true, + [MarkdownCommand.Emoji]: true, [MarkdownCommand.Gif]: true, }} textareaProps={{ diff --git a/packages/shared/src/components/liveRooms/LiveRoomControls.spec.tsx b/packages/shared/src/components/liveRooms/LiveRoomControls.spec.tsx index f1c70d43c0..93d97c5d0a 100644 --- a/packages/shared/src/components/liveRooms/LiveRoomControls.spec.tsx +++ b/packages/shared/src/components/liveRooms/LiveRoomControls.spec.tsx @@ -166,6 +166,7 @@ const createContextValue = ( }, role: 'audience', participantId: 'audience', + preflightMediaPermissions: jest.fn(), startRoom: jest.fn(), endRoom: jest.fn(), joinSpeakerQueue: jest.fn(), @@ -252,11 +253,8 @@ const click = async (element: HTMLElement): Promise => { }); }; -const getEmojiButton = ( - container: HTMLElement, - label: string, -): HTMLButtonElement => { - const button = container.querySelector( +const getEmojiButton = (label: string): HTMLButtonElement => { + const button = document.querySelector( `button[title="${label}"]`, ); @@ -279,7 +277,13 @@ describe('LiveRoomControls', () => { it('lets an audience participant join the speaker queue', async () => { const joinSpeakerQueue = jest.fn(() => new Promise(() => undefined)); - mockUseLiveRoom.mockReturnValue(createContextValue({ joinSpeakerQueue })); + const preflightMediaPermissions = jest.fn().mockResolvedValue(undefined); + mockUseLiveRoom.mockReturnValue( + createContextValue({ + preflightMediaPermissions, + joinSpeakerQueue, + }), + ); renderLiveRoomControls(); @@ -287,6 +291,7 @@ describe('LiveRoomControls', () => { await flushAsyncUpdates(); await waitFor(() => { + expect(preflightMediaPermissions).toHaveBeenCalledTimes(1); expect(joinSpeakerQueue).toHaveBeenCalledTimes(1); }); }); @@ -429,7 +434,7 @@ describe('LiveRoomControls', () => { const sendReaction = jest.fn(() => new Promise(() => undefined)); mockUseLiveRoom.mockReturnValue(createContextValue({ sendReaction })); - const { container } = renderLiveRoomControls(); + renderLiveRoomControls(); fireEvent.click(screen.getByRole('button', { name: 'Reactions' })); fireEvent.click(screen.getByRole('button', { name: 'Custom reaction' })); @@ -437,9 +442,9 @@ describe('LiveRoomControls', () => { target: { value: 'grinning face' }, }); await waitFor(() => { - expect(getEmojiButton(container, 'grinning face')).toBeInTheDocument(); + expect(getEmojiButton('grinning face')).toBeInTheDocument(); }); - await click(getEmojiButton(container, 'grinning face')); + await click(getEmojiButton('grinning face')); await flushAsyncUpdates(); await waitFor(() => { @@ -467,8 +472,10 @@ describe('LiveRoomControls', () => { it('lets an audience participant join the stage in a free-for-all room', async () => { const joinStage = jest.fn(() => new Promise(() => undefined)); + const preflightMediaPermissions = jest.fn().mockResolvedValue(undefined); mockUseLiveRoom.mockReturnValue( createContextValue({ + preflightMediaPermissions, joinStage, roomState: { ...createRoomState(), @@ -489,10 +496,45 @@ describe('LiveRoomControls', () => { await flushAsyncUpdates(); await waitFor(() => { + expect(preflightMediaPermissions).toHaveBeenCalledTimes(1); expect(joinStage).toHaveBeenCalledTimes(1); }); }); + it('preflights media permissions before going live', async () => { + const preflightMediaPermissions = jest.fn().mockResolvedValue(undefined); + const startRoom = jest.fn(() => new Promise(() => undefined)); + mockUseLiveRoom.mockReturnValue( + createContextValue({ + preflightMediaPermissions, + startRoom, + role: 'host', + participantId: 'host', + roomState: { + ...createRoomState(), + status: 'created', + participants: { + ...createRoomState().participants, + host: { + ...createRoomState().participants.host, + role: 'host', + }, + }, + }, + }), + ); + + renderLiveRoomControls(); + + await click(screen.getByRole('button', { name: 'Go live' })); + await flushAsyncUpdates(); + + await waitFor(() => { + expect(preflightMediaPermissions).toHaveBeenCalledTimes(1); + expect(startRoom).toHaveBeenCalledTimes(1); + }); + }); + it('lets a speaker leave the stage in a free-for-all room', async () => { const leaveStage = jest.fn(() => new Promise(() => undefined)); mockUseLiveRoom.mockReturnValue( @@ -524,7 +566,7 @@ describe('LiveRoomControls', () => { renderLiveRoomControls(); - await click(screen.getByRole('button', { name: 'Stop speaking' })); + await click(screen.getByRole('button', { name: 'Leave stage' })); await flushAsyncUpdates(); await waitFor(() => { @@ -561,7 +603,7 @@ describe('LiveRoomControls', () => { renderLiveRoomControls(); - await click(screen.getByRole('button', { name: 'Stop speaking' })); + await click(screen.getByRole('button', { name: 'Leave stage' })); await flushAsyncUpdates(); await waitFor(() => { @@ -610,7 +652,7 @@ describe('LiveRoomControls', () => { fireEvent.click(screen.getByRole('button', { name: 'Reactions' })); await click(screen.getByRole('button', { name: 'React 🔥' })); - expect(screen.getByRole('button', { name: 'Stop speaking' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Leave stage' })).toBeVisible(); await act(async () => { resolveReaction?.(); diff --git a/packages/shared/src/components/liveRooms/LiveRoomControls.tsx b/packages/shared/src/components/liveRooms/LiveRoomControls.tsx index 49b9465ffa..71eca00751 100644 --- a/packages/shared/src/components/liveRooms/LiveRoomControls.tsx +++ b/packages/shared/src/components/liveRooms/LiveRoomControls.tsx @@ -66,6 +66,7 @@ export const LiveRoomControls = ({ role, participantId, roomState, + preflightMediaPermissions, cameras, microphones, selectedCameraId, @@ -483,6 +484,7 @@ export const LiveRoomControls = ({ disabled={!canJoinQueue || isBusy('queue')} onClick={() => runAuthenticatedAction('queue', async () => { + await preflightMediaPermissions(); await joinSpeakerQueue(); logStandupAction(LogEvent.JoinStandupQueue, roomId, { surface: 'controls', @@ -510,6 +512,7 @@ export const LiveRoomControls = ({ disabled={!canJoinStage || isBusy('join-stage')} onClick={() => runAuthenticatedAction('join-stage', async () => { + await preflightMediaPermissions(); await joinStage(); logStandupAction(LogEvent.JoinStandupStage, roomId, { surface: 'controls', @@ -523,7 +526,7 @@ export const LiveRoomControls = ({ ) : null} {canLeaveStage ? ( ) : null} @@ -564,6 +567,7 @@ export const LiveRoomControls = ({ loading={isBusy('go-live')} onClick={() => guarded('go-live', async () => { + await preflightMediaPermissions(); await startRoom(); logStandupAction(LogEvent.StartStandup, roomId, { surface: 'controls', diff --git a/packages/shared/src/contexts/LiveRoomContext.tsx b/packages/shared/src/contexts/LiveRoomContext.tsx index 3b29f045ff..9c57bd6fbd 100644 --- a/packages/shared/src/contexts/LiveRoomContext.tsx +++ b/packages/shared/src/contexts/LiveRoomContext.tsx @@ -138,6 +138,7 @@ export interface LiveRoomContextValue { role: LiveRoomParticipantRoleValue | null; participantId: string | null; + preflightMediaPermissions: () => Promise; startRoom: () => Promise; endRoom: () => Promise; joinSpeakerQueue: () => Promise; @@ -355,6 +356,11 @@ const buildConstraints = ( }; }; +const buildVideoConstraints = ( + deviceId: string | null, +): true | MediaTrackConstraints => + deviceId ? { deviceId: { exact: deviceId } } : true; + const videoSimulcastEncodings = [ { rid: 'low', scaleResolutionDownBy: 4, maxBitrate: 150_000 }, { rid: 'medium', scaleResolutionDownBy: 2, maxBitrate: 500_000 }, @@ -1925,6 +1931,61 @@ export const LiveRoomProvider = ({ [], ); + const preflightMediaPermissions = useCallback(async () => { + if (typeof navigator === 'undefined' || !navigator.mediaDevices) { + return; + } + + const needsAudio = + !localTracksRef.current.audio && !mutedAudioTrackRef.current; + const needsVideo = !localTracksRef.current.video; + + if (!needsAudio && !needsVideo) { + return; + } + + try { + const stream = await navigator.mediaDevices.getUserMedia({ + ...(needsAudio + ? { + audio: buildAudioConstraints( + selectedMicId, + micSettings, + micSettingSupport, + ), + } + : {}), + ...(needsVideo + ? { + video: buildVideoConstraints(selectedCameraId), + } + : {}), + }); + + stream.getAudioTracks().forEach((track) => track.stop()); + stream.getVideoTracks().forEach((track) => track.stop()); + await refreshDeviceList(); + } catch (error) { + logStandupErrorRef.current( + 'media permission preflight', + getErrorMessage(error, 'Failed to preflight media permissions'), + { + source: 'permission_preflight', + needsAudio, + needsVideo, + requestedCameraId: selectedCameraId, + requestedMicId: selectedMicId, + }, + ); + } + }, [ + micSettingSupport, + micSettings, + refreshDeviceList, + selectedCameraId, + selectedMicId, + ]); + const startRoom = useCallback(async () => { await sendConnectionCommand('start room', { type: 'room.start' }); }, [sendConnectionCommand]); @@ -2102,6 +2163,7 @@ export const LiveRoomProvider = ({ roomState, role: currentRole, participantId, + preflightMediaPermissions, startRoom, endRoom, joinSpeakerQueue, @@ -2150,6 +2212,7 @@ export const LiveRoomProvider = ({ roomState, currentRole, participantId, + preflightMediaPermissions, startRoom, endRoom, joinSpeakerQueue,