diff --git a/Tiltfile b/Tiltfile index 2cc4e551420..69f08a5d7b5 100644 --- a/Tiltfile +++ b/Tiltfile @@ -8,6 +8,7 @@ def include_if_exists(path): print("skipping %s because it does not exist", path) include_if_exists('../daily-api/Tiltfile') +include_if_exists('../flyting/Tiltfile') include_if_exists('../adhoc-infra/Tiltfile') include_if_exists('../post-scraper-one-ai/Tiltfile') include_if_exists('../njord/Tiltfile') diff --git a/packages/shared/package.json b/packages/shared/package.json index e816fe2d0b1..94f9e4c9c4d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -131,6 +131,7 @@ "graphql-ws": "^5.5.5", "jotai": "^2.12.2", "lottie-react": "^2.4.1", + "mediasoup-client": "^3.7.13", "node-emoji": "^2.2.0", "node-fetch": "^2.6.6", "react-hook-form": "7.54.2", diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index 55592d5f7da..86b6eee64c1 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -61,6 +61,7 @@ export interface MainLayoutProps onNavTabClick?: (tab: string) => void; canGoBack?: string; hideBackButton?: boolean; + hideFeedbackWidget?: boolean; } export const feeds = Object.values(SharedFeedPage); @@ -77,6 +78,7 @@ function MainLayoutComponent({ onLogoClick, onNavTabClick, canGoBack, + hideFeedbackWidget = false, }: MainLayoutProps): ReactElement | null { const router = useRouter(); const { logEvent } = useLogContext(); @@ -220,7 +222,7 @@ function MainLayoutComponent({ )} {children} - + {!hideFeedbackWidget && } ); } diff --git a/packages/shared/src/components/fields/EmojiPicker.tsx b/packages/shared/src/components/fields/EmojiPicker.tsx index 400c03024fa..1f6658b3c27 100644 --- a/packages/shared/src/components/fields/EmojiPicker.tsx +++ b/packages/shared/src/components/fields/EmojiPicker.tsx @@ -1,4 +1,4 @@ -import type { ReactElement } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import React, { useState, useCallback, @@ -44,11 +44,23 @@ const COMMON_EMOJIS = [ '❤️', ]; +const DROPDOWN_GAP = 4; +const VIEWPORT_MARGIN = 12; +const MIN_DROPDOWN_WIDTH = 300; +const MIN_DROPDOWN_HEIGHT = 160; +const FALLBACK_DROPDOWN_HEIGHT = 320; + type EmojiPickerProps = { value: string; onChange: (emoji: string) => void; - label?: string; + label?: string | null; className?: string; + renderTrigger?: (props: { + isOpen: boolean; + value: string; + toggleOpen: () => void; + clearValue: () => void; + }) => ReactNode; }; export const EmojiPicker = ({ @@ -56,6 +68,7 @@ export const EmojiPicker = ({ onChange, label = 'Icon (optional)', className, + renderTrigger, }: EmojiPickerProps): ReactElement => { const [isOpen, setIsOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); @@ -63,18 +76,52 @@ export const EmojiPicker = ({ top: 0, left: 0, width: 0, + maxHeight: FALLBACK_DROPDOWN_HEIGHT, }); const inputRef = useRef(null); const containerRef = useRef(null); const triggerRef = useRef(null); + const dropdownRef = useRef(null); const updateDropdownPosition = useCallback(() => { if (triggerRef.current) { const rect = triggerRef.current.getBoundingClientRect(); + const dropdownHeight = + dropdownRef.current?.offsetHeight ?? FALLBACK_DROPDOWN_HEIGHT; + const width = Math.min( + Math.max(rect.width, MIN_DROPDOWN_WIDTH), + window.innerWidth - VIEWPORT_MARGIN * 2, + ); + const left = Math.min( + Math.max(rect.left, VIEWPORT_MARGIN), + window.innerWidth - width - VIEWPORT_MARGIN, + ); + const availableBelow = + window.innerHeight - rect.bottom - VIEWPORT_MARGIN - DROPDOWN_GAP; + const availableAbove = rect.top - VIEWPORT_MARGIN - DROPDOWN_GAP; + const shouldOpenAbove = + availableBelow < dropdownHeight && availableAbove > availableBelow; + const maxHeight = Math.max( + shouldOpenAbove ? availableAbove : availableBelow, + MIN_DROPDOWN_HEIGHT, + ); + const top = shouldOpenAbove + ? Math.max( + VIEWPORT_MARGIN, + rect.top - Math.min(dropdownHeight, maxHeight) - DROPDOWN_GAP, + ) + : Math.min( + rect.bottom + DROPDOWN_GAP, + window.innerHeight - + Math.min(dropdownHeight, maxHeight) - + VIEWPORT_MARGIN, + ); + setDropdownPosition({ - top: rect.bottom + 4, - left: rect.left, - width: rect.width, + top, + left, + width, + maxHeight, }); } }, []); @@ -84,7 +131,7 @@ export const EmojiPicker = ({ updateDropdownPosition(); inputRef.current?.focus(); } - }, [isOpen, updateDropdownPosition]); + }, [isOpen, updateDropdownPosition, searchQuery]); useEffect(() => { if (!isOpen) { @@ -125,6 +172,16 @@ export const EmojiPicker = ({ [onChange], ); + const clearValue = useCallback(() => { + onChange(''); + setIsOpen(false); + setSearchQuery(''); + }, [onChange]); + + const toggleOpen = useCallback(() => { + setIsOpen((open) => !open); + }, []); + const searchResults = useMemo(() => { if (!searchQuery.trim()) { return []; @@ -138,53 +195,63 @@ export const EmojiPicker = ({ return (
- - {label} - + {label ? ( + + {label} + + ) : null} -
- {value && ( - - )} +
+ {renderTrigger ? ( + renderTrigger({ isOpen, value, toggleOpen, clearValue }) + ) : ( +
+ {value && ( + + )} + + {value && ( +
+ {value} +
+ )} - {value && ( -
- {value} +
)} - -
{isOpen && (
void; inlineActions?: ReactNode; rightActions?: ReactNode; + allowBlockFormatting?: boolean; } export interface RichTextToolbarRef { @@ -71,7 +72,13 @@ const ToolbarButton = ({ }; function RichTextToolbarComponent( - { editor, onLinkAdd, inlineActions, rightActions }: RichTextToolbarProps, + { + editor, + onLinkAdd, + inlineActions, + rightActions, + allowBlockFormatting = true, + }: RichTextToolbarProps, ref: Ref, ): ReactElement { const [isLinkModalOpen, setIsLinkModalOpen] = useState(false); @@ -154,19 +161,23 @@ function RichTextToolbarComponent( isActive={editorState.isItalic} onClick={() => editor.chain().focus().toggleItalic().run()} /> -
- } - isActive={editorState.isBulletList} - onClick={() => editor.chain().focus().toggleBulletList().run()} - /> - } - isActive={editorState.isOrderedList} - onClick={() => editor.chain().focus().toggleOrderedList().run()} - /> + {allowBlockFormatting ? ( + <> +
+ } + isActive={editorState.isBulletList} + onClick={() => editor.chain().focus().toggleBulletList().run()} + /> + } + isActive={editorState.isOrderedList} + onClick={() => editor.chain().focus().toggleOrderedList().run()} + /> + + ) : null}
void; + suggestions?: UserShortProfile[]; } export function useMentionAutocomplete({ @@ -24,12 +25,13 @@ export function useMentionAutocomplete({ sourceId, userId, onOffsetUpdate, + suggestions, }: UseMentionAutocompleteProps) { const { requestMethod } = useRequestProtocol(); - const [query, setQuery] = useState(undefined); + const [query, setQuery] = useState(undefined); const [selected, setSelected] = useState(0); const mentionRangeRef = useRef(null); - const queryRef = useRef(undefined); + const queryRef = useRef(undefined); const selectedRef = useRef(0); const mentionsRef = useRef([]); @@ -43,12 +45,40 @@ export function useMentionAutocomplete({ { postId, query, sourceId }, { requestKey: JSON.stringify(key) }, ), - enabled: !!userId && typeof query !== 'undefined', + enabled: !suggestions && !!userId && typeof query !== 'undefined', refetchOnWindowFocus: false, refetchOnMount: false, }); - const mentions = data?.recommendedMentions; + const mentions = useMemo(() => { + if (!suggestions) { + return data?.recommendedMentions?.filter( + (mention) => mention.id !== userId, + ); + } + + if (typeof query === 'undefined') { + return []; + } + + const normalizedQuery = query.trim().toLowerCase(); + const filtered = suggestions.filter((suggestion) => { + if (suggestion.id === userId) { + return false; + } + + if (!normalizedQuery) { + return true; + } + + return ( + suggestion.username.toLowerCase().startsWith(normalizedQuery) || + suggestion.name?.toLowerCase().startsWith(normalizedQuery) === true + ); + }); + + return filtered.slice(0, 8); + }, [data?.recommendedMentions, query, suggestions, userId]); useEffect(() => { queryRef.current = query; diff --git a/packages/shared/src/components/fields/RichTextInput.tsx b/packages/shared/src/components/fields/RichTextInput.tsx index c13e0a79f5c..9d6cc674f70 100644 --- a/packages/shared/src/components/fields/RichTextInput.tsx +++ b/packages/shared/src/components/fields/RichTextInput.tsx @@ -1,4 +1,5 @@ import type { + ForwardedRef, FormEventHandler, MutableRefObject, ReactElement, @@ -27,12 +28,7 @@ import Image from '@tiptap/extension-image'; import { ImageIcon, AtIcon, MarkdownIcon } from '../icons'; import { EditIcon } from '../icons/Edit'; import { GifIcon } from '../icons/Gif'; -import { - Button, - ButtonColor, - ButtonSize, - ButtonVariant, -} from '../buttons/Button'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import { RecommendedMentionTooltip } from '../tooltips/RecommendedMentionTooltip'; import { SimpleTooltip } from '../tooltips/SimpleTooltip'; import { SavingLabel } from './MarkdownInput/SavingLabel'; @@ -59,6 +55,7 @@ import { useImageUpload } from './RichTextEditor/useImageUpload'; import { useDraftStorage } from './RichTextEditor/useDraftStorage'; import { useToastNotification } from '../../hooks/useToastNotification'; import styles from './RichTextEditor/richtext.module.css'; +import type { UserShortProfile } from '../../lib/user'; const RecommendedEmojiTooltip = dynamic( () => @@ -144,6 +141,10 @@ interface RichTextInputProps { enabledCommand?: Partial>; editCommentId?: string; parentCommentId?: string; + mentionSuggestions?: UserShortProfile[]; + allowBlockFormatting?: boolean; + minHeightClassName?: string; + markdownToHtml?: (markdown: string) => string; } export interface RichTextInputRef { @@ -175,8 +176,12 @@ function RichTextInput( enabledCommand = {}, editCommentId, parentCommentId, + mentionSuggestions, + allowBlockFormatting = true, + minHeightClassName = 'min-h-[8rem]', + markdownToHtml = markdownToHtmlBasic, }: RichTextInputProps, - ref: MutableRefObject, + ref: ForwardedRef, ): ReactElement { const shouldShowSubmit = !!submitCopy; const { user } = useAuthContext(); @@ -251,6 +256,7 @@ function RichTextInput( sourceId, userId: user?.id, onOffsetUpdate: updateOffset, + suggestions: mentionSuggestions, }); const emoji = useEmojiAutocomplete({ @@ -295,6 +301,11 @@ function RichTextInput( const editor = useEditor({ extensions: [ StarterKit.configure({ + heading: allowBlockFormatting ? undefined : false, + bulletList: allowBlockFormatting ? undefined : false, + orderedList: allowBlockFormatting ? undefined : false, + listItem: allowBlockFormatting ? undefined : false, + codeBlock: allowBlockFormatting ? undefined : false, blockquote: false, horizontalRule: false, }), @@ -313,7 +324,7 @@ function RichTextInput( ...(maxLength ? [CharacterCount.configure({ limit: maxLength })] : []), LinkShortcut, ], - content: markdownToHtmlBasic(input), + content: markdownToHtml(input), onUpdate: ({ editor: updatedEditor }) => { if (isSyncingRef.current) { isSyncingRef.current = false; @@ -361,7 +372,7 @@ function RichTextInput( const trimmedText = textToInsert.trim(); if (trimmedText && looksLikeMarkdown(trimmedText)) { - const convertedHtml = markdownToHtmlBasic(trimmedText); + const convertedHtml = markdownToHtml(trimmedText); if (convertedHtml) { event.preventDefault(); editorRef.current @@ -484,12 +495,10 @@ function RichTextInput( const switchToRichMode = useCallback(() => { if (editorRef.current) { isSyncingRef.current = true; - editorRef.current.commands.setContent( - markdownToHtmlBasic(inputRef.current), - ); + editorRef.current.commands.setContent(markdownToHtml(inputRef.current)); } setIsMarkdownMode(false); - }, []); + }, [markdownToHtml]); const onMarkdownInput = useCallback( (event: React.FormEvent) => { @@ -576,7 +585,7 @@ function RichTextInput( return; } isSyncingRef.current = true; - editor.commands.setContent(markdownToHtmlBasic(value)); + editor.commands.setContent(markdownToHtml(value)); }, focus: () => { if (isMarkdownMode) { @@ -606,10 +615,10 @@ function RichTextInput( if (editor) { isSyncingRef.current = true; - editor.commands.setContent(markdownToHtmlBasic(initialContent)); + editor.commands.setContent(markdownToHtml(initialContent)); } } - }, [editor, initialContent, input, updateInput]); + }, [editor, initialContent, input, markdownToHtml, updateInput]); const actionIcon = upload.queueCount === 0 ? ( @@ -637,9 +646,10 @@ function RichTextInput( + + + + ); +}; + +export default CreateLiveRoomModal; diff --git a/packages/shared/src/components/liveRooms/LiveRoom.spec.tsx b/packages/shared/src/components/liveRooms/LiveRoom.spec.tsx new file mode 100644 index 00000000000..2c8f44121fd --- /dev/null +++ b/packages/shared/src/components/liveRooms/LiveRoom.spec.tsx @@ -0,0 +1,313 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; +import type { LiveRoomContextValue } from '../../contexts/LiveRoomContext'; +import { LiveRoom } from './LiveRoom'; + +const mockPush = jest.fn(); +const mockDisplayToast = jest.fn(); +const mockUseLiveRoomConnection = jest.fn(); +const mockUseLiveRoomQuery = jest.fn(); +const mockUseAuthContext = jest.fn(); + +jest.mock('next/router', () => ({ + useRouter: () => ({ push: mockPush }), +})); + +jest.mock('../../contexts/LiveRoomContext', () => ({ + LiveRoomProvider: ({ children }: { children: React.ReactNode }) => children, + useLiveRoom: () => mockUseLiveRoomConnection(), +})); + +jest.mock('../../hooks/liveRooms/useLiveRoom', () => ({ + useLiveRoom: (...args: unknown[]) => mockUseLiveRoomQuery(...args), +})); + +jest.mock('../../contexts/AuthContext', () => ({ + useAuthContext: () => mockUseAuthContext(), +})); + +jest.mock('../../hooks/useToastNotification', () => ({ + useToastNotification: () => ({ displayToast: mockDisplayToast }), +})); + +jest.mock('../../graphql/users', () => ({ + getUserShortInfo: jest.fn(), +})); + +jest.mock('./LiveRoomVideoTile', () => ({ + LiveRoomVideoTile: ({ user }: { user: { username: string } }) => ( +
{`tile-${user.username}`}
+ ), +})); + +jest.mock('./LiveRoomControls', () => ({ + LiveRoomControls: () =>
controls
, +})); + +jest.mock('../dropdown/DropdownMenu', () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => + children, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuItem: ({ + children, + disabled, + onClick, + }: { + children: React.ReactNode; + disabled?: boolean; + onClick?: () => void; + }) => ( + + ), +})); + +const createContextValue = ( + overrides: Partial = {}, +): LiveRoomContextValue => ({ + status: 'connected', + errorMessage: null, + roomState: { + roomId: 'room-1', + mode: 'moderated', + status: 'live', + version: 1, + participants: { + host: { + participantId: 'host', + role: 'host', + sessionIds: ['session-host'], + joinedAt: '2026-04-27T09:00:00.000Z', + updatedAt: '2026-04-27T09:00:00.000Z', + }, + speaker1: { + participantId: 'speaker1', + role: 'speaker', + sessionIds: ['session-speaker1'], + joinedAt: '2026-04-27T09:01:00.000Z', + updatedAt: '2026-04-27T09:01:00.000Z', + }, + speaker2: { + participantId: 'speaker2', + role: 'speaker', + sessionIds: ['session-speaker2'], + joinedAt: '2026-04-27T09:02:00.000Z', + updatedAt: '2026-04-27T09:02:00.000Z', + }, + queued1: { + participantId: 'queued1', + role: 'audience', + sessionIds: ['session-queued1'], + joinedAt: '2026-04-27T09:03:00.000Z', + updatedAt: '2026-04-27T09:03:00.000Z', + }, + }, + chatPermissions: {}, + sessions: {}, + stage: { + speakerQueueParticipantIds: ['queued1'], + activeSpeakerParticipantIds: ['speaker1', 'speaker2'], + }, + mediaPublications: {}, + mediaRuntimeOwner: null, + createdAt: '2026-04-27T09:00:00.000Z', + updatedAt: '2026-04-27T09:00:00.000Z', + }, + role: 'host', + participantId: 'host', + startRoom: jest.fn(), + endRoom: jest.fn(), + joinSpeakerQueue: jest.fn(), + joinStage: jest.fn(), + leaveStage: jest.fn(), + sendReaction: jest.fn(), + sendChatMessage: jest.fn(), + deleteChatMessage: jest.fn(), + setParticipantChatEnabled: jest.fn(), + promoteSpeaker: jest.fn(), + removeSpeaker: jest.fn(), + kickParticipant: jest.fn(), + canChat: true, + canPublish: true, + isCameraOn: false, + isMicOn: false, + isCameraPublishing: false, + isMicPublishing: false, + toggleCamera: jest.fn(), + toggleMic: jest.fn(), + cameras: [], + microphones: [], + selectedCameraId: null, + selectedMicId: null, + selectCamera: jest.fn(), + selectMic: jest.fn(), + localStream: null, + remoteStreams: [], + reactions: [], + chatMessages: [], + ...overrides, +}); + +const renderLiveRoom = () => { + const queryClient = new QueryClient(); + + return render( + + + , + ); +}; + +describe('LiveRoom', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseAuthContext.mockReturnValue({ + isAuthReady: true, + isLoggedIn: true, + showLogin: jest.fn(), + user: { id: 'user-1' }, + }); + mockUseLiveRoomQuery.mockReturnValue({ + data: { + id: 'room-1', + topic: 'Queue changes', + status: 'live', + host: { + id: 'host', + username: 'host', + name: 'Host', + image: '', + permalink: '#', + }, + }, + error: null, + isLoading: false, + }); + }); + + it('lets the host promote a specific queued participant and remove a specific active speaker', async () => { + const promoteSpeaker = jest.fn().mockResolvedValue(undefined); + const removeSpeaker = jest.fn().mockResolvedValue(undefined); + mockUseLiveRoomConnection.mockReturnValue( + createContextValue({ promoteSpeaker, removeSpeaker }), + ); + + renderLiveRoom(); + + fireEvent.click(screen.getByRole('tab', { name: /Queue/ })); + fireEvent.click(screen.getByRole('button', { name: 'Promote @queued1' })); + + await waitFor(() => { + expect(promoteSpeaker).toHaveBeenCalledWith('queued1'); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Remove @speaker1' })); + + await waitFor(() => { + expect(removeSpeaker).toHaveBeenCalledWith('speaker1'); + }); + }); + + it('renders every active speaker on stage instead of a single current speaker', () => { + mockUseLiveRoomConnection.mockReturnValue(createContextValue()); + + renderLiveRoom(); + + expect(screen.getByText('tile-host')).toBeInTheDocument(); + expect(screen.getByText('tile-speaker1')).toBeInTheDocument(); + expect(screen.getByText('tile-speaker2')).toBeInTheDocument(); + }); + + it('switches the side panel to audience mode for free-for-all rooms', () => { + mockUseLiveRoomConnection.mockReturnValue( + createContextValue({ + roomState: { + ...createContextValue().roomState!, + mode: 'free_for_all', + stage: { + speakerQueueParticipantIds: [], + activeSpeakerParticipantIds: ['speaker1'], + speakerLimit: 4, + }, + }, + }), + ); + + renderLiveRoom(); + + expect(screen.getByRole('tab', { name: /Audience/ })).toBeInTheDocument(); + }); + + it('allows anonymous users to load the live room', () => { + mockUseAuthContext.mockReturnValue({ + isAuthReady: true, + isLoggedIn: false, + showLogin: jest.fn(), + user: undefined, + }); + mockUseLiveRoomConnection.mockReturnValue(createContextValue()); + + renderLiveRoom(); + + expect( + screen.queryByText('Sign in to join this live room'), + ).not.toBeInTheDocument(); + expect(screen.getByText('tile-host')).toBeInTheDocument(); + }); + + it('renders chat messages with sanitized markdown and send controls', () => { + mockUseLiveRoomConnection.mockReturnValue( + createContextValue({ + chatMessages: [ + { + messageId: 'message-1', + participantId: 'speaker1', + body: '# heading **hello** [daily](https://daily.dev)', + createdAt: '2026-04-27T09:04:00.000Z', + }, + ], + }), + ); + + renderLiveRoom(); + + expect(screen.getByText('@speaker1')).toBeInTheDocument(); + expect(screen.getByText('# heading')).toBeInTheDocument(); + expect(screen.getByText('hello')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Send' })).toBeInTheDocument(); + }); + + it('shows host moderation controls for chat rows', async () => { + mockUseLiveRoomConnection.mockReturnValue( + createContextValue({ + chatMessages: [ + { + messageId: 'message-1', + participantId: 'speaker1', + body: 'hello', + createdAt: '2026-04-27T09:04:00.000Z', + }, + ], + }), + ); + + renderLiveRoom(); + + expect( + screen.getByRole('button', { name: /Delete message/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /Kick user/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /Revoke chat access/i }), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/liveRooms/LiveRoom.tsx b/packages/shared/src/components/liveRooms/LiveRoom.tsx new file mode 100644 index 00000000000..04f102358a5 --- /dev/null +++ b/packages/shared/src/components/liveRooms/LiveRoom.tsx @@ -0,0 +1,1424 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useRouter } from 'next/router'; +import classNames from 'classnames'; +import { useQueries } from '@tanstack/react-query'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../typography/Typography'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { Loader } from '../Loader'; +import Markdown from '../Markdown'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '../dropdown/DropdownMenu'; +import { LiveRoomVideoTile } from './LiveRoomVideoTile'; +import { LiveRoomControls } from './LiveRoomControls'; +import { + LiveRoomProvider, + type LiveRoomChatEntry, + useLiveRoom as useLiveRoomConnection, + type LiveRoomReaction, + type RemoteMediaStream, +} from '../../contexts/LiveRoomContext'; +import type { LiveRoomParticipantRecord } from '../../lib/liveRoom/protocol'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { AuthTriggers } from '../../lib/auth'; +import { useLiveRoom as useLiveRoomQuery } from '../../hooks/liveRooms/useLiveRoom'; +import { useToastNotification } from '../../hooks/useToastNotification'; +import { webappUrl } from '../../lib/constants'; +import type { UserShortProfile } from '../../lib/user'; +import { ProfilePicture, ProfileImageSize } from '../ProfilePicture'; +import { Tooltip } from '../tooltip/Tooltip'; +import { + BlockIcon, + DiscussIcon, + LockIcon, + MenuIcon, + PlusUserIcon, + RemoveUserIcon, + TimerIcon, + TrashIcon, + UserIcon, +} from '../icons'; +import { IconSize } from '../Icon'; +import { getUserShortInfo } from '../../graphql/users'; +import { generateQueryKey, RequestKey } from '../../lib/query'; +import { ONE_MINUTE } from '../../lib/time'; +import RichTextInput, { type RichTextInputRef } from '../fields/RichTextInput'; +import { MarkdownCommand } from '../../hooks/input/useMarkdownInput'; +import { chatMarkdownToHtml } from '../../lib/liveRoom/chatMarkdown'; +import { + anonymousDisplayName, + anonymousHandle, +} from '../../lib/liveRoom/anonymousName'; + +interface LiveRoomProps { + roomId: string; +} + +type SidePanelTab = 'chat' | 'queue' | 'audience'; + +const formatStreamDuration = (seconds: number): string => { + const safe = Math.max(0, Math.floor(seconds)); + const hours = Math.floor(safe / 3600); + const minutes = Math.floor((safe % 3600) / 60); + const secs = safe % 60; + const pad = (n: number) => n.toString().padStart(2, '0'); + if (hours > 0) { + return `${hours}:${pad(minutes)}:${pad(secs)}`; + } + return `${pad(minutes)}:${pad(secs)}`; +}; + +const useStreamDuration = (isLive: boolean): number => { + const startedAtRef = useRef(null); + const [elapsed, setElapsed] = useState(0); + + useEffect(() => { + if (!isLive) { + startedAtRef.current = null; + setElapsed(0); + return undefined; + } + if (startedAtRef.current === null) { + startedAtRef.current = Date.now(); + } + const tick = () => { + const start = startedAtRef.current; + if (start === null) { + return; + } + setElapsed(Math.floor((Date.now() - start) / 1000)); + }; + tick(); + const interval = window.setInterval(tick, 1000); + return () => window.clearInterval(interval); + }, [isLive]); + + return elapsed; +}; + +const AnimatedCount = ({ value }: { value: number }): ReactElement => ( + + {value} + +); + +const buildParticipantStream = ( + targetParticipantId: string | null | undefined, + remoteStreams: RemoteMediaStream[], + localStream: MediaStream | null, + participantId: string | null, +): MediaStream | null => { + if (!targetParticipantId) { + return null; + } + if (targetParticipantId === participantId) { + return localStream; + } + const tracks: MediaStreamTrack[] = []; + remoteStreams + .filter((entry) => entry.participantId === targetParticipantId) + .forEach((entry) => { + entry.stream.getTracks().forEach((track) => tracks.push(track)); + }); + if (tracks.length === 0) { + return null; + } + return new MediaStream(tracks); +}; + +const userDisplayName = (user: Pick): string => + `@${user.username}`; + +const buildParticipantProfile = (participantId: string): UserShortProfile => ({ + id: participantId, + name: anonymousDisplayName(participantId), + username: anonymousHandle(participantId), + image: '', + createdAt: '', + reputation: 0, + permalink: '#', +}); + +const buildDisplayProfile = (user: UserShortProfile): UserShortProfile => ({ + ...user, + name: userDisplayName(user), +}); + +const ReactionOverlay = ({ + reactions, +}: { + reactions: LiveRoomReaction[]; +}): ReactElement | null => { + if (reactions.length === 0) { + return null; + } + + return ( +
+ {reactions.map((reaction) => ( + + {reaction.emoji} + + ))} +
+ ); +}; + +interface StageParticipantItemProps { + participantId: string; + profile?: UserShortProfile; + subtitle?: string; + leading?: ReactNode; + actions?: ReactNode; +} + +const StageParticipantItem = ({ + participantId, + profile, + subtitle, + leading, + actions, +}: StageParticipantItemProps): ReactElement => { + const user = profile ?? buildParticipantProfile(participantId); + + return ( +
  • + {leading} + +
    + + {userDisplayName(user)} + + {subtitle ? ( + + {subtitle} + + ) : null} +
    + {actions} +
  • + ); +}; + +interface SidePanelTabsProps { + active: SidePanelTab; + tabs: { id: SidePanelTab; label: string; count?: number }[]; + onChange: (tab: SidePanelTab) => void; +} + +const SidePanelTabs = ({ + active, + tabs, + onChange, +}: SidePanelTabsProps): ReactElement => { + return ( +
    + {tabs.map((tab) => { + const isActive = tab.id === active; + return ( + + ); + })} +
    + ); +}; + +interface LiveRoomChatComposerProps { + canChat: boolean; + isLive: boolean; + isEnded: boolean; + isLoggedIn: boolean; + mentionSuggestions: UserShortProfile[]; + onSendMessage: (body: string) => Promise; + onRequestLogin: () => void; +} + +const LiveRoomChatComposer = ({ + canChat, + isLive, + isEnded, + isLoggedIn, + mentionSuggestions, + onSendMessage, + onRequestLogin, +}: LiveRoomChatComposerProps): ReactElement => { + const inputRef = useRef(null); + const [isSending, setIsSending] = useState(false); + const [draft, setDraft] = useState(''); + + let disabledReason = 'The host disabled your chat access.'; + if (!isLoggedIn) { + disabledReason = 'Sign in to join the chat.'; + } else if (isEnded) { + disabledReason = 'Chat has ended for this room.'; + } else if (!isLive) { + disabledReason = 'Chat opens when the room goes live.'; + } + + const handleSubmit = async (body: string): Promise => { + const trimmed = body.trim(); + if (!trimmed || isSending || !canChat) { + return; + } + + setIsSending(true); + try { + await onSendMessage(trimmed); + setDraft(''); + inputRef.current?.setInput(''); + inputRef.current?.clearDraft(); + } finally { + setIsSending(false); + } + }; + + if (!canChat) { + return ( +
    +
    + + {disabledReason} + + {!isLoggedIn ? ( + + ) : null} +
    +
    + ); + } + + return ( +
    +
    { + event.preventDefault(); + handleSubmit(draft).catch(() => undefined); + }} + > + + handleSubmit(event.currentTarget.value).catch(() => undefined) + } + /> + +
    + ); +}; + +interface LiveRoomChatPanelProps { + chatMessages: LiveRoomChatEntry[]; + participantProfilesById: Map; + mentionSuggestions: UserShortProfile[]; + participantChatPermissions: Record; + hostParticipantId: string; + canChat: boolean; + isLive: boolean; + isEnded: boolean; + isLoggedIn: boolean; + isHost: boolean; + onSendMessage: (body: string) => Promise; + onDeleteMessage: (messageId: string) => Promise; + onKickParticipant: (participantId: string) => Promise; + onSetParticipantChatEnabled: ( + targetParticipantId: string, + canChat: boolean, + ) => Promise; + onRequestLogin: () => void; +} + +const LiveRoomChatPanel = ({ + chatMessages, + participantProfilesById, + mentionSuggestions, + participantChatPermissions, + hostParticipantId, + canChat, + isLive, + isEnded, + isLoggedIn, + isHost, + onSendMessage, + onDeleteMessage, + onKickParticipant, + onSetParticipantChatEnabled, + onRequestLogin, +}: LiveRoomChatPanelProps): ReactElement => { + const scrollRef = useRef(null); + const shouldAutoScrollRef = useRef(true); + const [moderationBusy, setModerationBusy] = useState(null); + const [openMenuId, setOpenMenuId] = useState(null); + + useEffect(() => { + const node = scrollRef.current; + if (!node || !shouldAutoScrollRef.current) { + return; + } + + node.scrollTop = node.scrollHeight; + }, [chatMessages]); + + const onScroll = (): void => { + const node = scrollRef.current; + if (!node) { + return; + } + + const distanceFromBottom = + node.scrollHeight - node.scrollTop - node.clientHeight; + shouldAutoScrollRef.current = distanceFromBottom < 32; + }; + + return ( +
    +
    + {chatMessages.length === 0 ? ( +
    + + + + + No messages yet + + + Chat is live and messages disappear when the room ends. + +
    + ) : ( + chatMessages.map((message) => { + const sender = + participantProfilesById.get(message.participantId) ?? + buildParticipantProfile(message.participantId); + const isSenderBlocked = + (participantChatPermissions[message.participantId] ?? true) === + false; + const canModerateParticipant = + isHost && + message.participantId !== hostParticipantId && + message.participantId !== ''; + + return ( +
    + +
    +
    + + {userDisplayName(sender)} + + +
    +
    + {isHost ? ( +
    + + setOpenMenuId(open ? message.messageId : null) + } + > + +
    + ) : null} +
    + ); + }) + )} +
    + +
    + ); +}; + +interface LiveRoomQueuePanelProps { + tab: 'queue' | 'audience'; + mode: 'moderated' | 'free_for_all'; + activeSpeakerParticipantIds: string[]; + queuedParticipantIds: string[]; + audienceParticipantIds: string[]; + participantsById: Record; + participantProfilesById: Map; + isHost: boolean; + stageLimit?: number | null; + moderationBusy: string | null; + guardedModerationAction: ( + key: string, + fn: () => Promise, + ) => Promise; + promoteSpeaker: (targetParticipantId: string) => Promise; + removeSpeaker: (targetParticipantId: string) => Promise; + kickParticipant: (targetParticipantId: string) => Promise; +} + +const LiveRoomQueuePanel = ({ + tab, + mode, + activeSpeakerParticipantIds, + queuedParticipantIds, + audienceParticipantIds, + participantsById, + participantProfilesById, + isHost, + stageLimit, + moderationBusy, + guardedModerationAction, + promoteSpeaker, + removeSpeaker, + kickParticipant, +}: LiveRoomQueuePanelProps): ReactElement => { + const activeSpeakers = activeSpeakerParticipantIds + .map((id) => participantsById[id]) + .filter( + (participant): participant is LiveRoomParticipantRecord => !!participant, + ); + const audienceMembers = audienceParticipantIds + .map((id) => participantsById[id]) + .filter( + (participant): participant is LiveRoomParticipantRecord => !!participant, + ); + const isAudienceTab = tab === 'audience'; + // Show On stage in the Queue tab (moderated) and in the Audience tab when + // there is no separate Queue tab (free-for-all has no queue). + const showStageSection = !isAudienceTab || mode === 'free_for_all'; + const sectionTitle = isAudienceTab ? 'Audience' : 'Queue'; + const sectionCount = isAudienceTab + ? audienceMembers.length + : queuedParticipantIds.length; + const listIds = isAudienceTab ? audienceParticipantIds : queuedParticipantIds; + + return ( +
    +
    + {showStageSection ? ( +
    +
    + + On stage + + + {stageLimit && mode === 'free_for_all' + ? `${activeSpeakers.length}/${stageLimit}` + : activeSpeakers.length} + +
    + {activeSpeakers.length === 0 ? ( +
    + + + +
    + + No active speakers + + + {mode === 'free_for_all' + ? 'Audience members can hop on stage while seats are open.' + : 'Promote someone from the queue to bring them on stage.'} + +
    +
    + ) : ( +
      + {activeSpeakers.map((participant) => { + const id = participant.participantId; + return ( + + +
    + ) : null + } + /> + ); + })} + + )} + + ) : null} + +
    +
    + + {sectionTitle} + + + {sectionCount} + +
    + {sectionCount === 0 ? ( +
    + + + + + {isAudienceTab ? 'Audience is quiet' : 'Queue is empty'} + + + {isAudienceTab + ? 'New listeners will show up here until they join the stage.' + : 'Audience members can request to speak.'} + +
    + ) : ( +
      + {listIds.map((id) => { + const participant = participantsById[id]; + if (!participant) { + return null; + } + + const profile = + participantProfilesById.get(id) ?? + buildParticipantProfile(id); + return ( + + {!isAudienceTab && mode === 'moderated' ? ( + +
    + ) : null + } + /> + ); + })} + + )} + +
    +
    + ); +}; + +interface LiveBadgeProps { + isLive: boolean; +} + +const LiveBadge = ({ isLive }: LiveBadgeProps): ReactElement => ( + + + + {isLive ? 'Live' : 'Setup'} + + +); + +const LiveRoomInner = ({ roomId }: LiveRoomProps): ReactElement => { + const router = useRouter(); + const { displayToast } = useToastNotification(); + const { isAuthReady, showLogin, user } = useAuthContext(); + const { + status, + errorMessage, + roomState, + role, + participantId, + sendChatMessage, + deleteChatMessage, + setParticipantChatEnabled, + promoteSpeaker, + removeSpeaker, + kickParticipant, + canChat, + localStream, + remoteStreams, + reactions, + chatMessages, + } = useLiveRoomConnection(); + const { + data: room, + error: roomError, + isLoading: isRoomLoading, + } = useLiveRoomQuery(roomId); + + const handleLeave = (): void => { + router.push(`${webappUrl}live`); + }; + + const [moderationBusy, setModerationBusy] = useState(null); + const [activeTab, setActiveTab] = useState('chat'); + const streamDuration = useStreamDuration(roomState?.status === 'live'); + + const guardedModerationAction = async ( + key: string, + fn: () => Promise, + ): Promise => { + if (moderationBusy) { + return; + } + setModerationBusy(key); + try { + await fn(); + } catch (err) { + displayToast(err instanceof Error ? err.message : 'Action failed'); + } finally { + setModerationBusy(null); + } + }; + + const handleSendChatMessage = async (body: string): Promise => { + try { + await sendChatMessage(body); + } catch (err) { + displayToast(err instanceof Error ? err.message : 'Message failed'); + } + }; + + const handleChatLogin = (): void => { + showLogin({ trigger: AuthTriggers.MainButton }); + }; + + const handleDeleteChatMessage = async (messageId: string): Promise => { + try { + await deleteChatMessage(messageId); + } catch (err) { + displayToast( + err instanceof Error ? err.message : 'Could not delete message', + ); + } + }; + + const handleSetParticipantChatEnabled = async ( + targetParticipantId: string, + nextCanChat: boolean, + ): Promise => { + try { + await setParticipantChatEnabled(targetParticipantId, nextCanChat); + } catch (err) { + displayToast( + err instanceof Error ? err.message : 'Could not update chat access', + ); + } + }; + + const handleKickChatParticipant = async ( + targetParticipantId: string, + ): Promise => { + try { + await kickParticipant(targetParticipantId); + } catch (err) { + displayToast(err instanceof Error ? err.message : 'Could not kick user'); + } + }; + + const participantIds = useMemo(() => { + const ids = new Set(); + + Object.keys(roomState?.participants ?? {}).forEach((id) => { + if (id && id !== room?.host.id) { + ids.add(id); + } + }); + chatMessages.forEach((message) => { + if (message.participantId && message.participantId !== room?.host.id) { + ids.add(message.participantId); + } + }); + + return [...ids]; + }, [chatMessages, room?.host.id, roomState?.participants]); + const participantProfileQueries = useQueries({ + queries: participantIds.map((id) => ({ + queryKey: generateQueryKey( + RequestKey.Profile, + undefined, + 'live-room-participant', + id, + ), + queryFn: () => getUserShortInfo(id), + staleTime: ONE_MINUTE, + })), + }); + const participantProfilesById = new Map(); + participantIds.forEach((id, index) => { + const profile = participantProfileQueries[index]?.data; + if (profile) { + participantProfilesById.set(id, profile); + } + }); + + if (!isAuthReady || isRoomLoading) { + return ( +
    + +
    + ); + } + + if (roomError || !room) { + return ( +
    + + We couldn't load this live room + + + {roomError?.message ?? 'The room may no longer be available.'} + + +
    + ); + } + + if (status === 'error' || status === 'closed') { + return ( +
    + + {status === 'closed' + ? 'Live room connection closed' + : "We couldn't connect to this live room"} + + {errorMessage ? ( + + {errorMessage} + + ) : null} + +
    + ); + } + + const isHost = role === 'host'; + const isCreated = roomState?.status === 'created'; + const isLive = roomState?.status === 'live'; + const isEnded = roomState?.status === 'ended' || room.status === 'ended'; + const roomMode = roomState?.mode ?? room.mode; + const isFreeForAll = roomMode === 'free_for_all'; + const participantCount = roomState + ? Object.keys(roomState.participants).length + : 0; + const activeSpeakerIds = + roomState?.stage.activeSpeakerParticipantIds.filter( + (id) => !!roomState.participants[id] && id !== room.host.id, + ) ?? []; + const queuedParticipantIds = + roomState?.stage.speakerQueueParticipantIds.filter( + (id) => !!roomState.participants[id], + ) ?? []; + const audienceParticipantIds = roomState + ? Object.values(roomState.participants) + .map((participant) => participant.participantId) + .filter( + (id) => + id !== room.host.id && + !activeSpeakerIds.includes(id) && + !queuedParticipantIds.includes(id), + ) + : []; + const stageLimit = roomState?.stage.speakerLimit ?? null; + const remainingSeats = + stageLimit === null + ? null + : Math.max(stageLimit - activeSpeakerIds.length, 0); + let waitingPrompt = 'Audience can join the queue'; + if (isFreeForAll) { + waitingPrompt = + remainingSeats === 0 + ? 'The stage is full right now' + : `${remainingSeats ?? 0} open stage spots`; + } else if (queuedParticipantIds.length > 0) { + waitingPrompt = `${queuedParticipantIds.length} in queue`; + } + const hostProfile = room.host; + const hostDisplayProfile = buildDisplayProfile(room.host); + participantProfilesById.set(room.host.id, hostProfile); + const mentionSuggestions = [ + hostProfile, + ...participantIds + .map((id) => participantProfilesById.get(id)) + .filter((profile): profile is UserShortProfile => !!profile), + ]; + + const stageSpeakers: { + id: string; + profile: UserShortProfile; + stream: MediaStream | null; + selfView: boolean; + isHost: boolean; + }[] = []; + const hostStream = buildParticipantStream( + room.host.id, + remoteStreams, + localStream, + participantId, + ); + stageSpeakers.push({ + id: room.host.id, + profile: hostDisplayProfile, + stream: hostStream, + selfView: room.host.id === participantId, + isHost: true, + }); + activeSpeakerIds.forEach((activeSpeakerId) => { + stageSpeakers.push({ + id: activeSpeakerId, + profile: buildDisplayProfile( + participantProfilesById.get(activeSpeakerId) ?? + buildParticipantProfile(activeSpeakerId), + ), + stream: buildParticipantStream( + activeSpeakerId, + remoteStreams, + localStream, + participantId, + ), + selfView: activeSpeakerId === participantId, + isHost: false, + }); + }); + + const showAudienceWaiting = isCreated && !isHost; + + return ( +
    + + +
    +
    + + + {room.topic} + +
    + {roomState ? ( +
    + {isLive ? ( + + + + {formatStreamDuration(streamDuration)} + + + ) : null} + + + + + + watching + +
    + ) : null} +
    + +
    +
    +
    + {stageSpeakers.map((speaker) => { + const canModerate = isHost && !speaker.isHost; + return ( +
    + + guardedModerationAction( + `tile-remove-${speaker.id}`, + () => removeSpeaker(speaker.id), + ) + : undefined + } + onKick={ + canModerate + ? () => + guardedModerationAction( + `tile-kick-${speaker.id}`, + () => kickParticipant(speaker.id), + ) + : undefined + } + isRemoving={moderationBusy === `tile-remove-${speaker.id}`} + isKicking={moderationBusy === `tile-kick-${speaker.id}`} + moderationDisabled={!!moderationBusy} + /> +
    + ); + })} + {stageSpeakers.length === 1 ? ( +
    +
    + + + + + Waiting for the next speaker + + + {waitingPrompt} + +
    +
    + ) : null} +
    + + {showAudienceWaiting ? ( +
    + + + Waiting for the host to start the room… + + +
    + ) : null} + + {isEnded ? ( +
    +
    + + This live room has ended + + +
    +
    + ) : null} + + {roomState && !isEnded ? ( + + ) : null} +
    + + +
    +
    + ); +}; + +export const LiveRoom = ({ roomId }: LiveRoomProps): ReactElement => ( + + + +); + +export default LiveRoom; diff --git a/packages/shared/src/components/liveRooms/LiveRoomCard.tsx b/packages/shared/src/components/liveRooms/LiveRoomCard.tsx new file mode 100644 index 00000000000..03563627f39 --- /dev/null +++ b/packages/shared/src/components/liveRooms/LiveRoomCard.tsx @@ -0,0 +1,101 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + type LiveRoom, + LiveRoomMode, + LiveRoomStatus, +} from '../../graphql/liveRooms'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { ProfilePicture, ProfileImageSize } from '../ProfilePicture'; +import { FlexCol, FlexRow } from '../utilities'; + +interface LiveRoomCardProps { + room: LiveRoom; + onJoin: (room: LiveRoom) => void; + isJoining?: boolean; +} + +const statusLabel: Record = { + [LiveRoomStatus.Live]: 'Live', + [LiveRoomStatus.Created]: 'Starting', + [LiveRoomStatus.Ended]: 'Ended', +}; + +const statusBadgeClass: Record = { + [LiveRoomStatus.Live]: 'bg-status-error text-white', + [LiveRoomStatus.Created]: 'bg-surface-float text-text-secondary', + [LiveRoomStatus.Ended]: 'bg-surface-float text-text-tertiary', +}; + +const modeLabel: Record = { + [LiveRoomMode.Moderated]: 'Moderated', + [LiveRoomMode.FreeForAll]: 'Free for all', +}; + +export const LiveRoomCard = ({ + room, + onJoin, + isJoining, +}: LiveRoomCardProps): ReactElement => { + return ( + + + + {room.status === LiveRoomStatus.Live ? ( + + ) : null} + {statusLabel[room.status]} + + + {modeLabel[room.mode]} + + + + + {room.topic} + + + + + + + + {room.host.name} + + + @{room.host.username} + + + + + + + ); +}; diff --git a/packages/shared/src/components/liveRooms/LiveRoomControls.spec.tsx b/packages/shared/src/components/liveRooms/LiveRoomControls.spec.tsx new file mode 100644 index 00000000000..08b354ce0a4 --- /dev/null +++ b/packages/shared/src/components/liveRooms/LiveRoomControls.spec.tsx @@ -0,0 +1,358 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; +import type { LiveRoomContextValue } from '../../contexts/LiveRoomContext'; +import { LiveRoomControls } from './LiveRoomControls'; +import { AuthTriggers } from '../../lib/auth'; + +const mockUseLiveRoom = jest.fn(); +const mockDisplayToast = jest.fn(); +const mockShowLogin = jest.fn(); +const mockUseAuthContext = jest.fn(); + +jest.mock('../../contexts/LiveRoomContext', () => ({ + useLiveRoom: () => mockUseLiveRoom(), +})); + +jest.mock('../../contexts/AuthContext', () => ({ + useAuthContext: () => mockUseAuthContext(), +})); + +jest.mock('../../hooks/useToastNotification', () => ({ + useToastNotification: () => ({ displayToast: mockDisplayToast }), +})); + +const createContextValue = ( + overrides: Partial = {}, +): LiveRoomContextValue => ({ + status: 'connected', + errorMessage: null, + roomState: { + roomId: 'room-1', + mode: 'moderated', + status: 'live', + version: 1, + participants: { + host: { + participantId: 'host', + role: 'host', + sessionIds: ['session-host'], + joinedAt: '2026-04-27T09:00:00.000Z', + updatedAt: '2026-04-27T09:00:00.000Z', + }, + audience: { + participantId: 'audience', + role: 'audience', + sessionIds: ['session-audience'], + joinedAt: '2026-04-27T09:01:00.000Z', + updatedAt: '2026-04-27T09:01:00.000Z', + }, + }, + chatPermissions: {}, + sessions: {}, + stage: { + speakerQueueParticipantIds: [], + activeSpeakerParticipantIds: [], + }, + mediaPublications: {}, + mediaRuntimeOwner: null, + createdAt: '2026-04-27T09:00:00.000Z', + updatedAt: '2026-04-27T09:00:00.000Z', + }, + role: 'audience', + participantId: 'audience', + startRoom: jest.fn(), + endRoom: jest.fn(), + joinSpeakerQueue: jest.fn(), + joinStage: jest.fn(), + leaveStage: jest.fn(), + sendReaction: jest.fn(), + sendChatMessage: jest.fn(), + deleteChatMessage: jest.fn(), + setParticipantChatEnabled: jest.fn(), + promoteSpeaker: jest.fn(), + removeSpeaker: jest.fn(), + kickParticipant: jest.fn(), + canChat: true, + canPublish: false, + isCameraOn: false, + isMicOn: false, + isCameraPublishing: false, + isMicPublishing: false, + toggleCamera: jest.fn(), + toggleMic: jest.fn(), + cameras: [], + microphones: [], + selectedCameraId: null, + selectedMicId: null, + selectCamera: jest.fn(), + selectMic: jest.fn(), + localStream: null, + remoteStreams: [], + reactions: [], + chatMessages: [], + ...overrides, +}); + +const createRoomState = (): NonNullable => { + const { roomState } = createContextValue(); + if (!roomState) { + throw new Error('Expected default room state'); + } + return roomState; +}; + +const renderLiveRoomControls = () => { + const queryClient = new QueryClient(); + + return render( + + + , + ); +}; + +describe('LiveRoomControls', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseAuthContext.mockReturnValue({ + user: { id: 'viewer-1' }, + showLogin: mockShowLogin, + }); + }); + + it('lets an audience participant join the speaker queue', () => { + const joinSpeakerQueue = jest.fn().mockResolvedValue(undefined); + mockUseLiveRoom.mockReturnValue(createContextValue({ joinSpeakerQueue })); + + renderLiveRoomControls(); + + fireEvent.click(screen.getByRole('button', { name: 'Join queue' })); + + expect(joinSpeakerQueue).toHaveBeenCalledTimes(1); + }); + + it('shows queued audience state without requeueing', () => { + const joinSpeakerQueue = jest.fn().mockResolvedValue(undefined); + mockUseLiveRoom.mockReturnValue( + createContextValue({ + joinSpeakerQueue, + roomState: { + ...createRoomState(), + stage: { + speakerQueueParticipantIds: ['audience'], + activeSpeakerParticipantIds: [], + }, + }, + }), + ); + + renderLiveRoomControls(); + + const queuedButton = screen.getByRole('button', { name: 'Queued' }); + expect(queuedButton).toBeDisabled(); + fireEvent.click(queuedButton); + expect(joinSpeakerQueue).not.toHaveBeenCalled(); + }); + + it('sends emoji reactions through the live room command path', () => { + const sendReaction = jest.fn().mockResolvedValue(undefined); + mockUseLiveRoom.mockReturnValue(createContextValue({ sendReaction })); + + renderLiveRoomControls(); + + fireEvent.click(screen.getByRole('button', { name: 'Reactions' })); + fireEvent.click(screen.getByRole('button', { name: 'React 🔥' })); + + expect(sendReaction).toHaveBeenCalledWith('🔥'); + }); + + it('sends custom emoji reactions through the emoji picker', () => { + const sendReaction = jest.fn().mockResolvedValue(undefined); + mockUseLiveRoom.mockReturnValue(createContextValue({ sendReaction })); + + renderLiveRoomControls(); + + fireEvent.click(screen.getByRole('button', { name: 'Reactions' })); + fireEvent.click(screen.getByRole('button', { name: 'Custom reaction' })); + fireEvent.click(screen.getByRole('button', { name: '⭐' })); + + expect(sendReaction).toHaveBeenCalledWith('⭐'); + }); + + it('prompts anonymous viewers to sign up instead of joining the queue', () => { + const joinSpeakerQueue = jest.fn().mockResolvedValue(undefined); + mockUseAuthContext.mockReturnValue({ + user: undefined, + showLogin: mockShowLogin, + }); + mockUseLiveRoom.mockReturnValue(createContextValue({ joinSpeakerQueue })); + + renderLiveRoomControls(); + + fireEvent.click(screen.getByRole('button', { name: 'Join queue' })); + + expect(joinSpeakerQueue).not.toHaveBeenCalled(); + expect(mockShowLogin).toHaveBeenCalledWith({ + trigger: AuthTriggers.MainButton, + }); + }); + + it('lets an audience participant join the stage in a free-for-all room', () => { + const joinStage = jest.fn().mockResolvedValue(undefined); + mockUseLiveRoom.mockReturnValue( + createContextValue({ + joinStage, + roomState: { + ...createRoomState(), + mode: 'free_for_all', + stage: { + speakerQueueParticipantIds: [], + activeSpeakerParticipantIds: [], + speakerLimit: 4, + }, + }, + }), + ); + + renderLiveRoomControls(); + + fireEvent.click(screen.getByRole('button', { name: 'Join stage' })); + + expect(joinStage).toHaveBeenCalledTimes(1); + }); + + it('lets a speaker leave the stage in a free-for-all room', () => { + const leaveStage = jest.fn().mockResolvedValue(undefined); + mockUseLiveRoom.mockReturnValue( + createContextValue({ + leaveStage, + role: 'speaker', + roomState: { + ...createRoomState(), + mode: 'free_for_all', + participants: { + ...createRoomState().participants, + audience: { + participantId: 'audience', + role: 'speaker', + sessionIds: ['session-audience'], + joinedAt: '2026-04-27T09:01:00.000Z', + updatedAt: '2026-04-27T09:01:00.000Z', + }, + }, + stage: { + speakerQueueParticipantIds: [], + activeSpeakerParticipantIds: ['audience'], + speakerLimit: 4, + }, + }, + }), + ); + + renderLiveRoomControls(); + + fireEvent.click(screen.getByRole('button', { name: 'Leave stage' })); + + expect(leaveStage).toHaveBeenCalledTimes(1); + }); + + it('shows a full-stage state instead of allowing another join in free-for-all mode', () => { + const joinStage = jest.fn().mockResolvedValue(undefined); + mockUseLiveRoom.mockReturnValue( + createContextValue({ + joinStage, + roomState: { + ...createRoomState(), + mode: 'free_for_all', + stage: { + speakerQueueParticipantIds: [], + activeSpeakerParticipantIds: ['speaker-1', 'speaker-2'], + speakerLimit: 2, + }, + }, + }), + ); + + renderLiveRoomControls(); + + const fullButton = screen.getByRole('button', { name: 'Stage full' }); + expect(fullButton).toBeDisabled(); + fireEvent.click(fullButton); + expect(joinStage).not.toHaveBeenCalled(); + }); + + it('prompts anonymous viewers to sign up instead of opening reactions', () => { + const sendReaction = jest.fn().mockResolvedValue(undefined); + mockUseAuthContext.mockReturnValue({ + user: undefined, + showLogin: mockShowLogin, + }); + mockUseLiveRoom.mockReturnValue(createContextValue({ sendReaction })); + + renderLiveRoomControls(); + + fireEvent.click(screen.getByRole('button', { name: 'Reactions' })); + + expect(sendReaction).not.toHaveBeenCalled(); + expect( + screen.queryByRole('button', { name: 'React 🔥' }), + ).not.toBeInTheDocument(); + expect(mockShowLogin).toHaveBeenCalledWith({ + trigger: AuthTriggers.MainButton, + }); + }); + + it('keeps host controls separate from the audience queue button', () => { + mockUseLiveRoom.mockReturnValue( + createContextValue({ + role: 'host', + participantId: 'host', + canPublish: true, + }), + ); + + renderLiveRoomControls(); + + expect( + screen.getByRole('button', { name: 'End room' }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Join queue' }), + ).not.toBeInTheDocument(); + }); + + it('shows media controls when the current participant becomes a speaker', () => { + mockUseLiveRoom.mockReturnValue( + createContextValue({ + role: 'speaker', + participantId: 'audience', + canPublish: true, + roomState: { + ...createRoomState(), + participants: { + ...createRoomState().participants, + audience: { + ...createRoomState().participants.audience, + role: 'speaker', + }, + }, + stage: { + speakerQueueParticipantIds: [], + activeSpeakerParticipantIds: ['audience'], + }, + }, + }), + ); + + renderLiveRoomControls(); + + expect(screen.getByRole('button', { name: 'Mic off' })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Camera off' }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Join queue' }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/liveRooms/LiveRoomControls.tsx b/packages/shared/src/components/liveRooms/LiveRoomControls.tsx new file mode 100644 index 00000000000..e166c23720a --- /dev/null +++ b/packages/shared/src/components/liveRooms/LiveRoomControls.tsx @@ -0,0 +1,537 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useMemo, useState } from 'react'; +import classNames from 'classnames'; +import { IconSize } from '../Icon'; +import { + Button, + ButtonSize, + ButtonVariant, + type IconType, +} from '../buttons/Button'; +import { EmojiPicker } from '../fields/EmojiPicker'; +import { + ArrowIcon, + CameraIcon, + MegaphoneIcon, + PhoneIcon, + PlusIcon, + VIcon, + VolumeOffIcon, +} from '../icons'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '../dropdown/DropdownMenu'; +import type { LiveRoomDeviceInfo } from '../../contexts/LiveRoomContext'; +import { useLiveRoom } from '../../contexts/LiveRoomContext'; +import { useToastNotification } from '../../hooks/useToastNotification'; +import { LiveRoomMicLevel } from './LiveRoomMicLevel'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { AuthTriggers } from '../../lib/auth'; + +const REACTION_EMOJIS = ['👏', '🔥', '💡', '😂', '🤯']; + +interface LiveRoomControlsProps { + onLeave: () => void; +} + +const Divider = (): ReactElement => ( + +); + +const ControlGroup = ({ + children, + className, +}: { + children: ReactNode; + className?: string; +}): ReactElement => ( +
    + {children} +
    +); + +interface DeviceSplitButtonProps { + isOn: boolean; + isLoading: boolean; + toggleAriaLabel: string; + caretAriaLabel: string; + caretMenuLabel: string; + toggleIcon: IconType; + onToggle: () => void; + devices: LiveRoomDeviceInfo[]; + selectedId: string | null; + onSelect: (deviceId: string) => void; + emptyLabel: string; + extra?: ReactNode; +} + +const DeviceSplitButton = ({ + isOn, + isLoading, + toggleAriaLabel, + caretAriaLabel, + caretMenuLabel, + toggleIcon, + onToggle, + devices, + selectedId, + onSelect, + emptyLabel, + extra, +}: DeviceSplitButtonProps): ReactElement => { + const variant = isOn ? ButtonVariant.Primary : ButtonVariant.Secondary; + return ( +
    +
    + ); +}; + +export const LiveRoomControls = ({ + onLeave, +}: LiveRoomControlsProps): ReactElement => { + const { user, showLogin } = useAuthContext(); + const { + canPublish, + isCameraOn, + isMicOn, + isCameraPublishing, + isMicPublishing, + toggleCamera, + toggleMic, + startRoom, + endRoom, + joinSpeakerQueue, + joinStage, + leaveStage, + sendReaction, + role, + participantId, + roomState, + cameras, + microphones, + selectedCameraId, + selectedMicId, + selectCamera, + selectMic, + localStream, + } = useLiveRoom(); + const localAudioTrack = localStream?.getAudioTracks()[0] ?? null; + // Wrap the local audio track in its own MediaStream once per track so the + // meter's AudioContext doesn't churn on every render. + const localAudioStream = useMemo( + () => (localAudioTrack ? new MediaStream([localAudioTrack]) : null), + [localAudioTrack], + ); + const { displayToast } = useToastNotification(); + const [busy, setBusy] = useState(null); + const [reactionsOpen, setReactionsOpen] = useState(false); + + const guarded = async ( + key: string, + fn: () => Promise, + ): Promise => { + if (busy) { + return; + } + setBusy(key); + try { + await fn(); + } catch (err) { + displayToast(err instanceof Error ? err.message : 'Action failed'); + } finally { + setBusy(null); + } + }; + + const isHost = role === 'host'; + const isAudience = role === 'audience'; + const isSpeaker = role === 'speaker'; + const isModerated = roomState?.mode === 'moderated'; + const isFreeForAll = roomState?.mode === 'free_for_all'; + const isQueued = + !!participantId && + !!roomState?.stage.speakerQueueParticipantIds.includes(participantId); + const speakerLimit = roomState?.stage.speakerLimit ?? null; + const activeSpeakerCount = + roomState?.stage.activeSpeakerParticipantIds.length ?? 0; + const isStageFull = + isFreeForAll && + roomState?.status === 'live' && + speakerLimit !== null && + activeSpeakerCount >= speakerLimit; + const canJoinQueue = + isModerated && + isAudience && + roomState?.status === 'live' && + !isQueued && + !busy; + const canJoinStage = + isFreeForAll && + isAudience && + roomState?.status === 'live' && + !isStageFull && + !busy; + const canLeaveStage = + isFreeForAll && isSpeaker && roomState?.status === 'live' && !busy; + const showGoLive = isHost && roomState?.status === 'created'; + + const previewSuffix = (active: boolean, publishing: boolean) => + active && !publishing ? ' (preview)' : ''; + + const promptSignup = (): void => { + showLogin({ trigger: AuthTriggers.MainButton }); + }; + + const handleReactionsToggle = (): void => { + if (!user) { + promptSignup(); + return; + } + + setReactionsOpen((open) => !open); + }; + + const handleReaction = (emoji: string): void => { + if (!user) { + promptSignup(); + return; + } + + guarded(`reaction-${emoji}`, () => sendReaction(emoji)); + }; + + const handleCustomReaction = (emoji: string): void => { + if (!user) { + promptSignup(); + return; + } + + guarded('reaction-custom', () => sendReaction(emoji)); + }; + + const handleJoinQueue = (): void => { + if (!user) { + promptSignup(); + return; + } + + guarded('queue', joinSpeakerQueue); + }; + + const handleJoinStage = (): void => { + if (!user) { + promptSignup(); + return; + } + + guarded('join-stage', joinStage); + }; + + const handleLeaveStage = (): void => { + guarded('leave-stage', leaveStage); + }; + + return ( +
    +
    + {reactionsOpen ? ( +
    + {REACTION_EMOJIS.map((emoji) => ( + + ))} + { + if (!emoji) { + return; + } + + handleCustomReaction(emoji); + }} + renderTrigger={({ isOpen, toggleOpen }) => ( +
    + ) : null} + +
    + {canPublish ? ( + + : } + onToggle={() => guarded('mic', toggleMic)} + devices={microphones} + selectedId={selectedMicId} + onSelect={(id) => { + selectMic(id).catch((err: unknown) => + displayToast( + err instanceof Error + ? err.message + : 'Failed to switch mic', + ), + ); + }} + emptyLabel="No microphones" + extra={ + localAudioStream ? ( +
    + + Input + + +
    + ) : null + } + /> + } + onToggle={() => guarded('camera', toggleCamera)} + devices={cameras} + selectedId={selectedCameraId} + onSelect={(id) => { + selectCamera(id).catch((err: unknown) => + displayToast( + err instanceof Error + ? err.message + : 'Failed to switch camera', + ), + ); + }} + emptyLabel="No cameras" + /> +
    + ) : null} + + {canPublish ? : null} + + + + {isModerated && isAudience ? ( + + ) : null} + {isFreeForAll && isAudience ? ( + + ) : null} + {canLeaveStage ? ( + + ) : null} + + + + + + {showGoLive ? ( + + ) : null} + {isHost ? ( + + ) : ( + + )} + +
    +
    +
    + ); +}; diff --git a/packages/shared/src/components/liveRooms/LiveRoomDevicePicker.tsx b/packages/shared/src/components/liveRooms/LiveRoomDevicePicker.tsx new file mode 100644 index 00000000000..2687b98733b --- /dev/null +++ b/packages/shared/src/components/liveRooms/LiveRoomDevicePicker.tsx @@ -0,0 +1,49 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import { Dropdown } from '../fields/Dropdown'; +import { ButtonSize, ButtonVariant } from '../buttons/common'; +import type { LiveRoomDeviceInfo } from '../../contexts/LiveRoomContext'; + +interface LiveRoomDevicePickerProps { + icon: ReactNode; + devices: LiveRoomDeviceInfo[]; + selectedId: string | null; + onChange: (deviceId: string) => void; + emptyLabel: string; + className?: string; +} + +export const LiveRoomDevicePicker = ({ + icon, + devices, + selectedId, + onChange, + emptyLabel, + className, +}: LiveRoomDevicePickerProps): ReactElement => { + const options = devices.length + ? devices.map((device) => device.label) + : [emptyLabel]; + const selectedIndex = devices.findIndex( + (device) => device.deviceId === selectedId, + ); + + return ( + = 0 ? selectedIndex : 0} + disabled={!devices.length} + shouldIndicateSelected + onChange={(_value, index) => { + const device = devices[index]; + if (device) { + onChange(device.deviceId); + } + }} + /> + ); +}; diff --git a/packages/shared/src/components/liveRooms/LiveRoomMicLevel.spec.ts b/packages/shared/src/components/liveRooms/LiveRoomMicLevel.spec.ts new file mode 100644 index 00000000000..c60a6a9ef32 --- /dev/null +++ b/packages/shared/src/components/liveRooms/LiveRoomMicLevel.spec.ts @@ -0,0 +1,23 @@ +import { computeRms, rmsToLevel } from './LiveRoomMicLevel'; +import { SPEAKING_LEVEL_THRESHOLD } from './useLiveRoomAudioLevel'; + +describe('LiveRoomMicLevel', () => { + it('maps normal remote speech RMS to a visible level', () => { + expect(rmsToLevel(0.02)).toBeGreaterThan(0.5); + }); + + it('keeps silence at zero', () => { + expect(rmsToLevel(0)).toBe(0); + }); + + it('computes RMS from float audio samples', () => { + const rms = computeRms(new Float32Array([0.5, -0.5, 0.5, -0.5])); + + expect(rms).toBeCloseTo(0.5); + }); + + it('keeps the speaking indicator above background-noise levels', () => { + expect(rmsToLevel(0.003)).toBeLessThan(SPEAKING_LEVEL_THRESHOLD); + expect(rmsToLevel(0.005)).toBeGreaterThan(SPEAKING_LEVEL_THRESHOLD); + }); +}); diff --git a/packages/shared/src/components/liveRooms/LiveRoomMicLevel.tsx b/packages/shared/src/components/liveRooms/LiveRoomMicLevel.tsx new file mode 100644 index 00000000000..cffce28dcdc --- /dev/null +++ b/packages/shared/src/components/liveRooms/LiveRoomMicLevel.tsx @@ -0,0 +1,53 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { useLiveRoomAudioLevel } from './useLiveRoomAudioLevel'; + +export { computeRms, rmsToLevel } from './audioLevel'; + +interface LiveRoomMicLevelProps { + // Pass the SAME MediaStream that's attached to the playing