+ {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(
+ );
+};
+
+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.'}
+
+
+ Back to live rooms
+
+
+ );
+ }
+
+ if (status === 'error' || status === 'closed') {
+ return (
+
+
+ {status === 'closed'
+ ? 'Live room connection closed'
+ : "We couldn't connect to this live room"}
+
+ {errorMessage ? (
+
+ {errorMessage}
+
+ ) : null}
+
+ Back to live rooms
+
+
+ );
+ }
+
+ 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
+
+
+ Back to live rooms
+
+
+
+ ) : 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}
+
+
+
+ onJoin(room)}
+ loading={isJoining}
+ >
+ Join
+
+
+
+ );
+};
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 (
+
+
+
+
+ }
+ aria-label={caretAriaLabel}
+ className="!w-6 !rounded-l-none !border-l-0 !px-0"
+ />
+
+
+
+
+ {caretMenuLabel}
+
+ {devices.length === 0 ? (
+
+ {emptyLabel}
+
+ ) : (
+ devices.map((device) => {
+ const isSelected = device.deviceId === selectedId;
+ return (
+
onSelect(device.deviceId)}
+ aria-label={device.label}
+ >
+
+
+ {isSelected ? (
+
+ ) : null}
+
+ {device.label}
+
+
+ );
+ })
+ )}
+ {extra ? (
+
+ {extra}
+
+ ) : null}
+
+
+
+
+ );
+};
+
+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) => (
+ handleReaction(emoji)}
+ >
+ {emoji}
+
+ ))}
+ {
+ if (!emoji) {
+ return;
+ }
+
+ handleCustomReaction(emoji);
+ }}
+ renderTrigger={({ isOpen, toggleOpen }) => (
+ }
+ aria-label="Custom reaction"
+ aria-expanded={isOpen}
+ disabled={!!busy}
+ onClick={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 ? (
+ }
+ loading={busy === 'queue'}
+ disabled={!canJoinQueue}
+ onClick={handleJoinQueue}
+ >
+ {isQueued ? 'Queued' : 'Join queue'}
+
+ ) : null}
+ {isFreeForAll && isAudience ? (
+ }
+ loading={busy === 'join-stage'}
+ disabled={!canJoinStage}
+ onClick={handleJoinStage}
+ >
+ {isStageFull ? 'Stage full' : 'Join stage'}
+
+ ) : null}
+ {canLeaveStage ? (
+ }
+ loading={busy === 'leave-stage'}
+ onClick={handleLeaveStage}
+ >
+ Leave stage
+
+ ) : null}
+
+
+
+
+
+ {showGoLive ? (
+ guarded('go-live', startRoom)}
+ >
+ Go live
+
+ ) : null}
+ {isHost ? (
+
+ guarded('end', async () => {
+ await endRoom();
+ onLeave();
+ })
+ }
+ >
+ End room
+
+ ) : (
+ }
+ aria-label="Leave"
+ onClick={onLeave}
+ >
+ Leave
+
+ )}
+
+
+
+
+ );
+};
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