From 88032cc8008c6a02a10091304e520cd49b5c2fe6 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Mon, 25 May 2026 18:16:09 +0300 Subject: [PATCH] feat: wire community-moderated room activity status --- .../cards/liveRoom/LiveRoomPostGrid.spec.tsx | 3 +- .../cards/liveRoom/LiveRoomPostList.spec.tsx | 27 ++- .../cards/liveRoom/LiveRoomPostList.tsx | 4 +- .../components/cards/liveRoom/common.spec.ts | 3 +- .../src/components/cards/liveRoom/common.tsx | 9 +- .../liveRooms/CreateLiveRoomForm.spec.tsx | 89 ++++++++ .../liveRooms/CreateLiveRoomForm.tsx | 208 +++++++++++++++++- .../components/liveRooms/LiveRoom.spec.tsx | 169 ++++++++++++++ .../src/components/liveRooms/LiveRoom.tsx | 52 ++++- .../liveRooms/LiveRoomChatPanel.spec.tsx | 49 +++++ .../liveRooms/LiveRoomChatPanel.tsx | 4 +- .../liveRooms/LiveRoomControls.spec.tsx | 108 +++++++++ .../components/liveRooms/LiveRoomControls.tsx | 14 +- .../liveRooms/LiveRoomQueuePanel.tsx | 7 +- .../liveRooms/LiveStandupsStrip.spec.tsx | 21 ++ .../liveRooms/LiveStandupsStrip.tsx | 7 +- .../src/contexts/LiveRoomContext.spec.tsx | 106 +++++++++ .../shared/src/contexts/LiveRoomContext.tsx | 43 ++-- packages/shared/src/graphql/fragments.ts | 2 + packages/shared/src/graphql/liveRooms.ts | 20 +- .../src/hooks/liveRooms/useCreateLiveRoom.ts | 2 + .../hooks/liveRooms/useLiveRoomStageModel.ts | 14 +- .../useLiveRoomSubscriptionAction.ts | 9 +- .../src/hooks/liveRooms/useSubmitStandup.ts | 22 +- packages/shared/src/lib/liveRoom/protocol.ts | 36 ++- packages/shared/src/lib/liveRoom/status.ts | 23 ++ 26 files changed, 989 insertions(+), 62 deletions(-) create mode 100644 packages/shared/src/lib/liveRoom/status.ts diff --git a/packages/shared/src/components/cards/liveRoom/LiveRoomPostGrid.spec.tsx b/packages/shared/src/components/cards/liveRoom/LiveRoomPostGrid.spec.tsx index 4b984b7788d..368c6f7cf80 100644 --- a/packages/shared/src/components/cards/liveRoom/LiveRoomPostGrid.spec.tsx +++ b/packages/shared/src/components/cards/liveRoom/LiveRoomPostGrid.spec.tsx @@ -7,7 +7,7 @@ import { useRouter } from 'next/router'; import post from '../../../../__tests__/fixture/post'; import { TestBootProvider } from '../../../../__tests__/helpers/boot'; import type { LiveRoomPost } from '../../../graphql/liveRooms'; -import { LiveRoomStatus } from '../../../graphql/liveRooms'; +import { LiveRoomMode, LiveRoomStatus } from '../../../graphql/liveRooms'; import { PostType } from '../../../graphql/posts'; import type { Post } from '../../../graphql/posts'; import type { PostCardProps } from '../common/common'; @@ -20,6 +20,7 @@ jest.mock('next/router', () => ({ const room: LiveRoomPost = { id: 'room-1', topic: 'Weekly product standup', + mode: LiveRoomMode.Moderated, status: LiveRoomStatus.Created, scheduledStart: '2026-05-20T10:00:00.000Z', subscribed: false, diff --git a/packages/shared/src/components/cards/liveRoom/LiveRoomPostList.spec.tsx b/packages/shared/src/components/cards/liveRoom/LiveRoomPostList.spec.tsx index 8c026043b5c..e93046916f8 100644 --- a/packages/shared/src/components/cards/liveRoom/LiveRoomPostList.spec.tsx +++ b/packages/shared/src/components/cards/liveRoom/LiveRoomPostList.spec.tsx @@ -7,7 +7,11 @@ import { useRouter } from 'next/router'; import post from '../../../../__tests__/fixture/post'; import { TestBootProvider } from '../../../../__tests__/helpers/boot'; import type { LiveRoomPost } from '../../../graphql/liveRooms'; -import { LiveRoomStatus } from '../../../graphql/liveRooms'; +import { + LiveRoomActivityStatus, + LiveRoomMode, + LiveRoomStatus, +} from '../../../graphql/liveRooms'; import { PostType } from '../../../graphql/posts'; import type { Post } from '../../../graphql/posts'; import type { PostCardProps } from '../common/common'; @@ -20,6 +24,7 @@ jest.mock('next/router', () => ({ const room: LiveRoomPost = { id: 'room-1', topic: 'Weekly product standup', + mode: LiveRoomMode.Moderated, status: LiveRoomStatus.Created, scheduledStart: '2026-05-20T10:00:00.000Z', subscribed: false, @@ -77,3 +82,23 @@ it('renders live room post list content and standup route link', async () => { '/standups/room-1', ); }); + +it('shows community-moderated durable-created rooms as live when activity is live', async () => { + renderComponent({ + post: { + ...liveRoomPost, + liveRoom: { + ...room, + mode: LiveRoomMode.CommunityModerated, + status: LiveRoomStatus.Created, + activityStatus: LiveRoomActivityStatus.Live, + }, + }, + }); + + expect(await screen.findByText('Live')).toBeInTheDocument(); + expect(screen.queryByText(/May 20 at/)).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /RSVP/ }), + ).not.toBeInTheDocument(); +}); diff --git a/packages/shared/src/components/cards/liveRoom/LiveRoomPostList.tsx b/packages/shared/src/components/cards/liveRoom/LiveRoomPostList.tsx index 33059a0ffdd..eaa773428a1 100644 --- a/packages/shared/src/components/cards/liveRoom/LiveRoomPostList.tsx +++ b/packages/shared/src/components/cards/liveRoom/LiveRoomPostList.tsx @@ -10,6 +10,7 @@ import { ViewSize, } from '../../../hooks'; import { LiveRoomStatus } from '../../../graphql/liveRooms'; +import { isLiveRoomEffectivelyLive } from '../../../lib/liveRoom/status'; import FeedItemContainer from '../common/list/FeedItemContainer'; import { combinedClicks } from '../../../lib/click'; import { CardContainer, CardContent, CardTitle } from '../common/list/ListCard'; @@ -79,8 +80,7 @@ export const LiveRoomPostList = forwardRef(function LiveRoomPostList( const metadata = useMemo(() => { const authorName = post.author?.name ?? post.source?.name; const isActiveOrEnded = - room.status === LiveRoomStatus.Live || - room.status === LiveRoomStatus.Ended; + isLiveRoomEffectivelyLive(room) || room.status === LiveRoomStatus.Ended; const bottomLabel = isActiveOrEnded ? ( ) : ( diff --git a/packages/shared/src/components/cards/liveRoom/common.spec.ts b/packages/shared/src/components/cards/liveRoom/common.spec.ts index 021fb66af3c..23512048ad1 100644 --- a/packages/shared/src/components/cards/liveRoom/common.spec.ts +++ b/packages/shared/src/components/cards/liveRoom/common.spec.ts @@ -1,6 +1,6 @@ import post from '../../../../__tests__/fixture/post'; import type { LiveRoomPost } from '../../../graphql/liveRooms'; -import { LiveRoomStatus } from '../../../graphql/liveRooms'; +import { LiveRoomMode, LiveRoomStatus } from '../../../graphql/liveRooms'; import { PostType } from '../../../graphql/posts'; import type { Post } from '../../../graphql/posts'; import { getLiveRoomPostRoom, getLiveRoomPostTitle } from './common'; @@ -8,6 +8,7 @@ import { getLiveRoomPostRoom, getLiveRoomPostTitle } from './common'; const createRoom = (room: Partial = {}): LiveRoomPost => ({ id: 'room-1', topic: 'Weekly product standup', + mode: LiveRoomMode.Moderated, status: LiveRoomStatus.Created, scheduledStart: '2026-05-20T10:00:00.000Z', subscribed: false, diff --git a/packages/shared/src/components/cards/liveRoom/common.tsx b/packages/shared/src/components/cards/liveRoom/common.tsx index b64617f6580..be50901b52e 100644 --- a/packages/shared/src/components/cards/liveRoom/common.tsx +++ b/packages/shared/src/components/cards/liveRoom/common.tsx @@ -7,6 +7,7 @@ import { LiveRoomStatus } from '../../../graphql/liveRooms'; import { webappUrl } from '../../../lib/constants'; import { anchorDefaultRel } from '../../../lib/strings'; import { formatLiveRoomScheduledStart } from '../../../lib/liveRoom/date'; +import { isLiveRoomEffectivelyLive } from '../../../lib/liveRoom/status'; import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; import { BellIcon, CalendarIcon, MicrophoneIcon, VIcon } from '../../icons'; import { IconSize } from '../../Icon'; @@ -45,7 +46,7 @@ export const LiveRoomPostStatusBadge = ({ className?: string; room: LiveRoomPost; }): ReactElement | null => { - if (room.status === LiveRoomStatus.Live) { + if (isLiveRoomEffectivelyLive(room)) { return ( { - if (!room.scheduledStart) { + if ( + !room.scheduledStart || + isLiveRoomEffectivelyLive(room) || + room.status === LiveRoomStatus.Ended + ) { return null; } diff --git a/packages/shared/src/components/liveRooms/CreateLiveRoomForm.spec.tsx b/packages/shared/src/components/liveRooms/CreateLiveRoomForm.spec.tsx index 3fa07f4174f..eaec5cbf913 100644 --- a/packages/shared/src/components/liveRooms/CreateLiveRoomForm.spec.tsx +++ b/packages/shared/src/components/liveRooms/CreateLiveRoomForm.spec.tsx @@ -113,6 +113,95 @@ describe('CreateLiveRoomForm', () => { expect(onCreated).toHaveBeenCalledTimes(1); }); + it('creates a community moderated standup with community parameters', async () => { + const onCreated = jest.fn(); + + render(); + + fireEvent.change(screen.getByPlaceholderText('Topic'), { + target: { value: 'Community room' }, + }); + fireEvent.click(screen.getByLabelText('Community moderated')); + fireEvent.change(screen.getByLabelText('Minimum participants to go live'), { + target: { value: '4' }, + }); + fireEvent.change(screen.getByLabelText('Speaker limit'), { + target: { value: '8' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Create standup' })); + + await waitFor(() => { + expect(mockCreateLiveRoom).toHaveBeenCalledWith({ + topic: 'Community room', + mode: LiveRoomMode.CommunityModerated, + scheduledStart: undefined, + minParticipantsToGoLive: 4, + speakerLimit: 8, + description: undefined, + }); + }); + expect(onCreated).toHaveBeenCalledTimes(1); + expect(mockLogEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event_name: 'create standup', + extra: expect.stringContaining('"mode":"community_moderated"'), + }), + ); + expect(mockLogEvent).toHaveBeenCalledWith( + expect.objectContaining({ + extra: expect.stringContaining('"min_participants_to_go_live":4'), + }), + ); + expect(mockLogEvent).toHaveBeenCalledWith( + expect.objectContaining({ + extra: expect.stringContaining('"speaker_limit":8'), + }), + ); + }); + + it('requires a participant minimum for community moderated standups', async () => { + render(); + + fireEvent.change(screen.getByPlaceholderText('Topic'), { + target: { value: 'Community room' }, + }); + fireEvent.click(screen.getByLabelText('Community moderated')); + fireEvent.change(screen.getByLabelText('Minimum participants to go live'), { + target: { value: '' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Create standup' })); + + expect( + await screen.findByText( + 'Minimum participants is required for community-moderated rooms', + ), + ).toBeInTheDocument(); + expect(mockCreateLiveRoom).not.toHaveBeenCalled(); + }); + + it('requires the speaker limit to fit the community participant minimum', async () => { + render(); + + fireEvent.change(screen.getByPlaceholderText('Topic'), { + target: { value: 'Community room' }, + }); + fireEvent.click(screen.getByLabelText('Community moderated')); + fireEvent.change(screen.getByLabelText('Minimum participants to go live'), { + target: { value: '6' }, + }); + fireEvent.change(screen.getByLabelText('Speaker limit'), { + target: { value: '4' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Create standup' })); + + expect( + await screen.findByText( + 'Speaker limit must be greater than or equal to the participant minimum', + ), + ).toBeInTheDocument(); + expect(mockCreateLiveRoom).not.toHaveBeenCalled(); + }); + it('submits scheduled lobby fields as UTC and explains the local delta', async () => { jest.useFakeTimers().setSystemTime(new Date('2026-05-04T07:00:00.000Z')); diff --git a/packages/shared/src/components/liveRooms/CreateLiveRoomForm.tsx b/packages/shared/src/components/liveRooms/CreateLiveRoomForm.tsx index 32a7f4a1fc7..9c52e329b5f 100644 --- a/packages/shared/src/components/liveRooms/CreateLiveRoomForm.tsx +++ b/packages/shared/src/components/liveRooms/CreateLiveRoomForm.tsx @@ -15,7 +15,7 @@ import { Button, ButtonVariant } from '../buttons/Button'; import ControlledTextField from '../fields/ControlledTextField'; import { TextField } from '../fields/TextField'; import RichTextInput from '../fields/RichTextInput'; -import type { LiveRoomJoinToken } from '../../graphql/liveRooms'; +import { LiveRoomMode, type LiveRoomJoinToken } from '../../graphql/liveRooms'; import { useSubmitStandup } from '../../hooks/liveRooms/useSubmitStandup'; import { useAuthContext } from '../../contexts/AuthContext'; import { @@ -29,11 +29,24 @@ import { CREATE_LIVE_ROOM_FORM_ID } from '../fields/form/common'; import styles from './CreateLiveRoomForm.module.css'; const DEFAULT_SCHEDULE_DELAY_MS = 30 * 60 * 1000; +const DEFAULT_COMMUNITY_MIN_PARTICIPANTS = 3; const TOPIC_MAX_LENGTH = 280; const DESCRIPTION_MAX_LENGTH = 4000; +type RoomModeChoice = LiveRoomMode.Moderated | LiveRoomMode.CommunityModerated; type ScheduleChoice = 'now' | 'later'; +const optionalPositiveInteger = (message: string) => + z.preprocess((value) => { + if (value === '' || value === null || value === undefined) { + return undefined; + } + if (typeof value === 'string') { + return Number(value); + } + return value; + }, z.number({ message }).int(message).positive(message).optional()); + const createLiveRoomFormSchema = z .object({ topic: z @@ -44,8 +57,13 @@ const createLiveRoomFormSchema = z TOPIC_MAX_LENGTH, `Topic must be ${TOPIC_MAX_LENGTH} characters or less`, ), + mode: z.enum([LiveRoomMode.Moderated, LiveRoomMode.CommunityModerated]), scheduleChoice: z.enum(['now', 'later']), scheduledStart: z.string().optional(), + minParticipantsToGoLive: optionalPositiveInteger( + 'Enter at least 2 participants', + ), + speakerLimit: optionalPositiveInteger('Enter a speaker limit'), description: z .string() .trim() @@ -63,6 +81,40 @@ const createLiveRoomFormSchema = z message: 'Scheduled time is required', }); } + + if (values.mode !== LiveRoomMode.CommunityModerated) { + return; + } + + if (!values.minParticipantsToGoLive) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['minParticipantsToGoLive'], + message: + 'Minimum participants is required for community-moderated rooms', + }); + return; + } + + if (values.minParticipantsToGoLive < 2) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['minParticipantsToGoLive'], + message: 'Community rooms need at least 2 participants', + }); + } + + if ( + values.speakerLimit && + values.minParticipantsToGoLive > values.speakerLimit + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['speakerLimit'], + message: + 'Speaker limit must be greater than or equal to the participant minimum', + }); + } }); type CreateLiveRoomFormValues = z.infer; @@ -127,19 +179,37 @@ export const CreateLiveRoomForm = ({ resolver: zodResolver(createLiveRoomFormSchema), defaultValues: { topic: '', + mode: LiveRoomMode.Moderated, scheduleChoice: 'now', scheduledStart: defaultScheduledStart, + minParticipantsToGoLive: undefined, + speakerLimit: undefined, description: '', }, }); + const roomMode = form.watch('mode'); const scheduleChoice = form.watch('scheduleChoice'); const scheduledStart = form.watch('scheduledStart'); const scheduledStartDate = parseScheduledStart(scheduledStart, timezone); + const isCommunityModerated = roomMode === LiveRoomMode.CommunityModerated; const isScheduled = scheduleChoice === 'later'; const scheduleDelta = isScheduled ? formatScheduleDelta(scheduledStartDate) : null; const submitCopy = isScheduled ? 'Schedule standup' : 'Create standup'; + let submissionHint = + "You'll be asked for camera and microphone access before going live."; + + if (isScheduled) { + submissionHint = + "We'll open the lobby right away and share a post on your feed so people can RSVP. You can go live at the scheduled time."; + } + + if (isCommunityModerated) { + submissionHint = isScheduled + ? "We'll open the lobby right away and share a post on your feed so people can RSVP." + : 'Community rooms become live automatically once enough participants join.'; + } const onSubmit = form.handleSubmit(async (values) => { const result = await submitStandup(values); @@ -163,17 +233,131 @@ export const CreateLiveRoomForm = ({ type={TypographyType.Callout} color={TypographyColor.Tertiary} > - Standups are short, host-led audio and video rooms on daily.dev. Pick - a topic, optionally schedule it, and go live when you're ready. - Standups don't start automatically. + {isCommunityModerated + ? 'Community-moderated standups let authenticated speakers join directly and go live once enough participants are in the room.' + : "Standups are short, host-led audio and video rooms on daily.dev. Pick a topic, optionally schedule it, and go live when you're ready. Standups don't start automatically."} + ( +
+ + Room type + + + name={field.name} + value={field.value} + onChange={(value) => { + field.onChange(value); + if (value === LiveRoomMode.CommunityModerated) { + form.setValue( + 'minParticipantsToGoLive', + form.getValues('minParticipantsToGoLive') ?? + DEFAULT_COMMUNITY_MIN_PARTICIPANTS, + { shouldDirty: true, shouldValidate: true }, + ); + return; + } + form.setValue('minParticipantsToGoLive', undefined, { + shouldDirty: true, + shouldValidate: true, + }); + form.setValue('speakerLimit', undefined, { + shouldDirty: true, + shouldValidate: true, + }); + }} + options={[ + { + value: LiveRoomMode.Moderated, + label: 'Host moderated', + className: { wrapper: 'gap-0' }, + afterElement: ( + + The host controls when the room goes live and who can + speak. + + ), + }, + { + value: LiveRoomMode.CommunityModerated, + label: 'Community moderated', + className: { wrapper: 'gap-0' }, + afterElement: ( + + Speakers can join directly. The room becomes live after + the participant minimum is reached. + + ), + }, + ]} + /> +
+ )} /> + {isCommunityModerated ? ( +
+ ( + + )} + /> + ( + + )} + /> +
+ ) : null} - Open the room immediately. You'll still need to - click "Go live" before listeners can join. + {isCommunityModerated + ? 'Open the room immediately. It becomes live once the participant minimum is reached.' + : 'Open the room immediately. You will still need to click "Go live" before listeners can join.'} ), }, @@ -220,8 +405,9 @@ export const CreateLiveRoomForm = ({ color={TypographyColor.Tertiary} className="-mt-1 pl-10" > - Open a lobby where people can RSVP, chat, and get - notified when you go live. + {isCommunityModerated + ? 'Open a lobby where people can RSVP, chat, and get notified when the room is live.' + : 'Open a lobby where people can RSVP, chat, and get notified when you go live.'} ), }, @@ -325,9 +511,7 @@ export const CreateLiveRoomForm = ({ color={TypographyColor.Tertiary} className="min-w-0 flex-1" > - {isScheduled - ? "We'll open the lobby right away and share a post on your feed so people can RSVP. You can go live at the scheduled time." - : "You'll be asked for camera and microphone access before going live."} + {submissionHint} + + ); + } + if (status === 'error' || status === 'closed') { return (
@@ -670,6 +705,7 @@ const LiveRoomInner = ({ roomId }: LiveRoomProps): ReactElement => { isEnded={!!isEnded} isLoggedIn={!!user} hasHostPrivileges={hasHostPrivileges} + canKickParticipants={!isCommunityRoom} onSendMessage={handleSendChatMessage} onDeleteMessage={handleDeleteChatMessage} onSendMessageReaction={handleSendChatMessageReaction} @@ -767,7 +803,7 @@ const LiveRoomInner = ({ roomId }: LiveRoomProps): ReactElement => { stagePageStart={stagePageStart} focusedSpeakerIndex={focusedSpeakerIndex} waitingPrompt={waitingPrompt} - hasHostPrivileges={hasHostPrivileges} + hasHostPrivileges={hasDirectModeration} isHost={isHost} moderationBusy={moderationBusy} onFocusSpeaker={focusSpeaker} @@ -813,7 +849,7 @@ const LiveRoomInner = ({ roomId }: LiveRoomProps): ReactElement => { participantsById={roomState?.participants ?? {}} participantProfilesById={participantProfilesById} coHostParticipantIds={coHostParticipantIds} - canModerate={hasHostPrivileges} + canModerate={hasDirectModeration} canManageCoHosts={isHost} stageLimit={stageLimit} moderationBusy={moderationBusy} diff --git a/packages/shared/src/components/liveRooms/LiveRoomChatPanel.spec.tsx b/packages/shared/src/components/liveRooms/LiveRoomChatPanel.spec.tsx index fa62a2d2c58..81966b9d55e 100644 --- a/packages/shared/src/components/liveRooms/LiveRoomChatPanel.spec.tsx +++ b/packages/shared/src/components/liveRooms/LiveRoomChatPanel.spec.tsx @@ -10,6 +10,30 @@ jest.mock('../Markdown', () => ({ default: ({ content }: { content: string }) => {content}, })); +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; + }) => ( + + ), +})); + jest.mock('../ProfilePicture', () => ({ ProfilePicture: () =>
avatar
, ProfileImageSize: { Small: 'small' }, @@ -37,6 +61,10 @@ jest.mock('../tooltips/Portal', () => ({ RootPortal: ({ children }: { children: React.ReactNode }) => children, })); +jest.mock('../tooltip/Tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => children, +})); + jest.mock('../drawers', () => ({ Drawer: ({ children }: { children: React.ReactNode }) => (
{children}
@@ -100,6 +128,7 @@ const defaultProps = { isEnded: false, isLoggedIn: true, hasHostPrivileges: false, + canKickParticipants: true, onSendMessage: jest.fn(), onDeleteMessage: jest.fn(), onSendMessageReaction: jest.fn(), @@ -222,6 +251,26 @@ describe('LiveRoomChatPanel', () => { ); }); + it('hides direct kick while keeping other moderation actions available', () => { + render( + , + ); + + expect( + screen.getByRole('button', { name: /Delete message/i }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /Kick user/i }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /Revoke chat access/i }), + ).toBeInTheDocument(); + }); + it('scrolls to the full height of a newly added long message', () => { const { rerender } = render(); diff --git a/packages/shared/src/components/liveRooms/LiveRoomChatPanel.tsx b/packages/shared/src/components/liveRooms/LiveRoomChatPanel.tsx index 527dbc0ca63..ee157e842ee 100644 --- a/packages/shared/src/components/liveRooms/LiveRoomChatPanel.tsx +++ b/packages/shared/src/components/liveRooms/LiveRoomChatPanel.tsx @@ -196,6 +196,7 @@ interface LiveRoomChatPanelProps { isEnded: boolean; isLoggedIn: boolean; hasHostPrivileges: boolean; + canKickParticipants: boolean; onSendMessage: (body: string) => Promise; onDeleteMessage: (messageId: string) => Promise; onSendMessageReaction: ( @@ -237,6 +238,7 @@ export const LiveRoomChatPanel = ({ isEnded, isLoggedIn, hasHostPrivileges, + canKickParticipants, onSendMessage, onDeleteMessage, onSendMessageReaction, @@ -642,7 +644,7 @@ export const LiveRoomChatPanel = ({ Delete message
- {canModerateParticipant ? ( + {canModerateParticipant && canKickParticipants ? ( diff --git a/packages/shared/src/components/liveRooms/LiveRoomControls.spec.tsx b/packages/shared/src/components/liveRooms/LiveRoomControls.spec.tsx index 3d876bdf951..b6fb1260833 100644 --- a/packages/shared/src/components/liveRooms/LiveRoomControls.spec.tsx +++ b/packages/shared/src/components/liveRooms/LiveRoomControls.spec.tsx @@ -612,6 +612,114 @@ describe('LiveRoomControls', () => { }); }); + it('does not show leave-stage or speaker-entry controls in a live community-moderated room', () => { + mockUseLiveRoom.mockReturnValue( + createContextValue({ + role: 'speaker', + canPublish: true, + roomState: { + ...createRoomState(), + mode: 'community_moderated', + activityStatus: 'live', + 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'], + raisedHandParticipantIds: [], + }, + }, + }), + ); + + renderLiveRoomControls(); + + expect( + screen.queryByRole('button', { name: 'Leave stage' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Ask to speak' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Join as speaker' }), + ).not.toBeInTheDocument(); + }); + + it('keeps community-moderated room media controls hidden until quorum is live', () => { + mockUseLiveRoom.mockReturnValue( + createContextValue({ + role: 'speaker', + canPublish: true, + roomState: { + ...createRoomState(), + mode: 'community_moderated', + activityStatus: 'pending', + status: 'created', + 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'], + raisedHandParticipantIds: [], + }, + }, + }), + ); + + renderLiveRoomControls(); + + expect( + screen.queryByRole('button', { name: /Mic off/ }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Reactions' }), + ).not.toBeInTheDocument(); + }); + + it('does not show go-live for community-moderated rooms', () => { + mockUseLiveRoom.mockReturnValue( + createContextValue({ + role: 'host', + participantId: 'host', + roomState: { + ...createRoomState(), + mode: 'community_moderated', + activityStatus: 'pending', + status: 'created', + participants: { + ...createRoomState().participants, + host: { + ...createRoomState().participants.host, + role: 'host', + }, + }, + }, + }), + ); + + renderLiveRoomControls(); + + expect( + screen.queryByRole('button', { name: 'Go live' }), + ).not.toBeInTheDocument(); + }); + it('keeps the leave-stage control visible while a reaction is pending', async () => { let resolveReaction: (() => void) | undefined; const sendReaction = jest.fn( diff --git a/packages/shared/src/components/liveRooms/LiveRoomControls.tsx b/packages/shared/src/components/liveRooms/LiveRoomControls.tsx index 71eca007517..1ffa5039ac5 100644 --- a/packages/shared/src/components/liveRooms/LiveRoomControls.tsx +++ b/packages/shared/src/components/liveRooms/LiveRoomControls.tsx @@ -20,6 +20,10 @@ import { useAuthContext } from '../../contexts/AuthContext'; import { useViewSize, ViewSize } from '../../hooks'; import { AuthTriggers } from '../../lib/auth'; import { getLiveRoomPrivilegeState } from '../../lib/liveRoom/privileges'; +import { + isCommunityModeratedRoom, + isLiveRoomEffectivelyLive, +} from '../../lib/liveRoom/status'; import { LogEvent } from '../../lib/log'; import { useLiveRoomStandupAnalytics } from '../../hooks/liveRooms/useLiveRoomStandupAnalytics'; import { @@ -176,7 +180,8 @@ export const LiveRoomControls = ({ const isAudience = role === 'audience'; const isSpeaker = role === 'speaker'; - const isLive = roomState?.status === 'live'; + const isLive = isLiveRoomEffectivelyLive(roomState); + const isCommunityModerated = isCommunityModeratedRoom(roomState); const isModerated = roomState?.mode === 'moderated'; const isFreeForAll = roomState?.mode === 'free_for_all'; const isQueued = @@ -200,10 +205,13 @@ export const LiveRoomControls = ({ activeSpeakerCount >= speakerLimit; const canJoinQueue = isModerated && isAudience && isLive && !isQueued; const canJoinStage = isFreeForAll && isAudience && isLive && !isStageFull; - const canLeaveStage = isSpeaker && isLive; + const canLeaveStage = !isCommunityModerated && isSpeaker && isLive; const canRaiseHand = isLive && (isSpeaker || privilegeState.hasHostPrivileges); - const showGoLive = privilegeState.isHost && roomState?.status === 'created'; + const showGoLive = + !isCommunityModerated && + privilegeState.isHost && + roomState?.status === 'created'; const showMediaControls = canPublish && isLive; const showLiveInteractionControls = isLive; diff --git a/packages/shared/src/components/liveRooms/LiveRoomQueuePanel.tsx b/packages/shared/src/components/liveRooms/LiveRoomQueuePanel.tsx index 50c07dce34a..515a21799ee 100644 --- a/packages/shared/src/components/liveRooms/LiveRoomQueuePanel.tsx +++ b/packages/shared/src/components/liveRooms/LiveRoomQueuePanel.tsx @@ -19,7 +19,10 @@ import { import { IconSize } from '../Icon'; import { ProfilePicture, ProfileImageSize } from '../ProfilePicture'; import { ProfileTooltip } from '../profile/ProfileTooltip'; -import type { LiveRoomParticipantRecord } from '../../lib/liveRoom/protocol'; +import type { + LiveRoomModeValue, + LiveRoomParticipantRecord, +} from '../../lib/liveRoom/protocol'; import type { UserShortProfile } from '../../lib/user'; import { anchorDefaultRel } from '../../lib/strings'; import { @@ -125,7 +128,7 @@ const StageParticipantItem = ({ interface LiveRoomQueuePanelProps { tab: Extract; - mode: 'moderated' | 'free_for_all'; + mode: LiveRoomModeValue; activeSpeakerParticipantIds: string[]; queuedParticipantIds: string[]; audienceParticipantIds: string[]; diff --git a/packages/shared/src/components/liveRooms/LiveStandupsStrip.spec.tsx b/packages/shared/src/components/liveRooms/LiveStandupsStrip.spec.tsx index 1e980eac16f..1b4bb3ce4f5 100644 --- a/packages/shared/src/components/liveRooms/LiveStandupsStrip.spec.tsx +++ b/packages/shared/src/components/liveRooms/LiveStandupsStrip.spec.tsx @@ -7,6 +7,8 @@ import { gqlClient } from '../../graphql/common'; import type { ActiveLiveRoom } from '../../graphql/liveRooms'; import { ACTIVE_LIVE_ROOMS_QUERY, + LiveRoomActivityStatus, + LiveRoomMode, LiveRoomStatus, } from '../../graphql/liveRooms'; import { LogEvent } from '../../lib/log'; @@ -31,6 +33,7 @@ const createClient = (): QueryClient => const room: ActiveLiveRoom = { id: 'room-1', topic: 'Weekly product standup', + mode: LiveRoomMode.Moderated, status: LiveRoomStatus.Live, participantCount: 12, host: { @@ -91,6 +94,24 @@ it('fetches and renders active standups when boot has a live-room hint', async ( ); }); +it('renders community-moderated standups that are activity-live while durable-created', () => { + const communityRoom: ActiveLiveRoom = { + ...room, + id: 'room-community', + mode: LiveRoomMode.CommunityModerated, + status: LiveRoomStatus.Created, + activityStatus: LiveRoomActivityStatus.Live, + }; + + render( + + + , + ); + + expect(screen.getByText('Weekly product standup')).toBeInTheDocument(); +}); + it('logs impression once when the strip renders with live standups', async () => { const client = createClient(); client.setQueryData(BOOT_QUERY_KEY, { liveRooms: { hasLive: true } }); diff --git a/packages/shared/src/components/liveRooms/LiveStandupsStrip.tsx b/packages/shared/src/components/liveRooms/LiveStandupsStrip.tsx index 1ab3d12cb7e..370224dd46c 100644 --- a/packages/shared/src/components/liveRooms/LiveStandupsStrip.tsx +++ b/packages/shared/src/components/liveRooms/LiveStandupsStrip.tsx @@ -12,13 +12,13 @@ import { import { ArrowIcon, MicrophoneIcon } from '../icons'; import { IconSize } from '../Icon'; import type { ActiveLiveRoom } from '../../graphql/liveRooms'; -import { LiveRoomStatus } from '../../graphql/liveRooms'; import { BOOT_QUERY_KEY } from '../../contexts/common'; import type { Boot } from '../../lib/boot'; import { useActiveLiveRooms } from '../../hooks/liveRooms/useActiveLiveRooms'; import { useLogContext } from '../../contexts/LogContext'; import useLogEventOnce from '../../hooks/log/useLogEventOnce'; import { LogEvent } from '../../lib/log'; +import { isLiveRoomEffectivelyLive } from '../../lib/liveRoom/status'; import styles from './LiveStandupsStrip.module.css'; const STRIP_SURFACE = 'home_strip'; @@ -94,10 +94,7 @@ export const LiveStandupsStrip = ({ }, ); const liveItems = useMemo( - () => - (items ?? activeRooms ?? []).filter( - (item) => item.status === LiveRoomStatus.Live, - ), + () => (items ?? activeRooms ?? []).filter(isLiveRoomEffectivelyLive), [activeRooms, items], ); diff --git a/packages/shared/src/contexts/LiveRoomContext.spec.tsx b/packages/shared/src/contexts/LiveRoomContext.spec.tsx index 68b38a308c8..82163b4fa1d 100644 --- a/packages/shared/src/contexts/LiveRoomContext.spec.tsx +++ b/packages/shared/src/contexts/LiveRoomContext.spec.tsx @@ -225,6 +225,112 @@ describe('LiveRoomContext', () => { expect(connectionInstances).toHaveLength(0); }); + it('does not fetch a join token or open a websocket for anonymous community-moderated viewers', async () => { + mockUseAuthContext.mockReturnValue({ + user: undefined, + isAuthReady: true, + }); + mockUseLiveRoomQuery.mockReturnValue({ + data: { + id: 'room-1', + mode: 'community_moderated', + status: 'created', + }, + isLoading: false, + }); + writeStoredLiveRoomResumeSession({ + roomId: 'room-1', + participantId: 'tracking-anon-1', + resumeToken: 'resume-token-1', + ttlMs: 30_000, + updatedAt: Date.now(), + }); + + render( + +
standup
+
, + { wrapper }, + ); + + await waitFor(() => { + expect(readStoredLiveRoomResumeSession('room-1')).toBeNull(); + }); + + expect(gqlClient.request).not.toHaveBeenCalled(); + expect(connectionInstances).toHaveLength(0); + }); + + it('only allows publishing in community-moderated rooms after quorum is live', async () => { + render( + + + , + { wrapper }, + ); + + await waitFor(() => expect(connectionInstances).toHaveLength(1)); + + const connection = connectionInstances[0]; + const handleSessionReady = connection.onSessionReady.mock.calls[0][0]; + const handleSnapshot = connection.onSnapshot.mock.calls[0][0]; + const handleRoomUpdated = connection.onRoomUpdated.mock.calls[0][0]; + const pendingRoom = { + roomId: 'room-1', + mode: 'community_moderated', + status: 'created', + activityStatus: 'pending', + minParticipantsToGoLive: 3, + version: 1, + participants: { + 'speaker-1': { + participantId: 'speaker-1', + role: 'speaker', + sessionIds: ['session-speaker-1'], + joinedAt: '2026-05-12T10:00:00.000Z', + updatedAt: '2026-05-12T10:00:00.000Z', + }, + }, + coHostParticipantIds: [], + chatPermissions: {}, + sessions: {}, + stage: { + speakerQueueParticipantIds: [], + activeSpeakerParticipantIds: ['speaker-1'], + raisedHandParticipantIds: [], + }, + mediaPublications: {}, + mediaRuntimeOwner: null, + createdAt: '2026-05-12T10:00:00.000Z', + updatedAt: '2026-05-12T10:00:00.000Z', + }; + + act(() => { + handleSessionReady({ + roomId: 'room-1', + participantId: 'speaker-1', + role: 'speaker', + resumeToken: 'resume-token-1', + resumeSessionTtlMs: 30_000, + }); + handleSnapshot({ room: pendingRoom }); + }); + + await waitFor(() => expect(latestContext?.canPublish).toBe(false)); + + act(() => { + handleRoomUpdated({ + room: { + ...pendingRoom, + activityStatus: 'live', + version: 2, + }, + }); + }); + + await waitFor(() => expect(latestContext?.canPublish).toBe(true)); + }); + it('lowers a raised hand after successfully unmuting', async () => { render( diff --git a/packages/shared/src/contexts/LiveRoomContext.tsx b/packages/shared/src/contexts/LiveRoomContext.tsx index 58e4a5bdff0..68dbdf803fd 100644 --- a/packages/shared/src/contexts/LiveRoomContext.tsx +++ b/packages/shared/src/contexts/LiveRoomContext.tsx @@ -33,6 +33,10 @@ import { import { buildStandupAnalyticsExtra } from '../lib/liveRoom/analytics'; import { getSnapshotChatMessages } from '../lib/liveRoom/protocol'; import { getLiveRoomPrivilegeState } from '../lib/liveRoom/privileges'; +import { + isCommunityModeratedRoom, + isLiveRoomEffectivelyLive, +} from '../lib/liveRoom/status'; import { LogEvent } from '../lib/log'; import { clearStoredLiveRoomResumeSession, @@ -454,12 +458,14 @@ export const LiveRoomProvider = ({ const intentionalDisconnectRef = useRef(false); const currentRole = (participantId && roomState?.participants[participantId]?.role) || role; + const isRoomActive = isLiveRoomEffectivelyLive(roomState); const privilegeState = getLiveRoomPrivilegeState( roomState, participantId, currentRole, ); - const canPublish = !!currentRole && PUBLISH_ROLES.includes(currentRole); + const canPublish = + isRoomActive && !!currentRole && PUBLISH_ROLES.includes(currentRole); const canChat = !!user && !!roomState && @@ -880,6 +886,7 @@ export const LiveRoomProvider = ({ isAuthReady && !!roomId && !storedResumeSession && + (!isCommunityModeratedRoom(room) || !!user?.id) && !!room && !isRoomLoading && room?.status !== LiveRoomStatus.Ended, @@ -917,6 +924,16 @@ export const LiveRoomProvider = ({ setStatus('idle'); }, [room?.status, roomId]); + useEffect(() => { + if (!isAuthReady || !isCommunityModeratedRoom(room) || user?.id) { + return; + } + + clearStoredLiveRoomResumeSession(roomId); + setStoredResumeSession(null); + setResumeSessionTtlMs(null); + }, [isAuthReady, room, roomId, user?.id]); + const requestFreshJoinToken = useCallback(() => { clearStoredLiveRoomResumeSession(roomId); setStoredResumeSession(null); @@ -1048,6 +1065,10 @@ export const LiveRoomProvider = ({ return undefined; } + if (isCommunityModeratedRoom(room) && !user?.id) { + return undefined; + } + if ( storedResumeSession && user?.id && @@ -1667,7 +1688,7 @@ export const LiveRoomProvider = ({ if (!sendTransportReady) { return; } - if (roomState?.status !== 'live') { + if (!isRoomActive) { return; } if (localTracksRef.current.audio && !producersRef.current.has('audio')) { @@ -1678,7 +1699,7 @@ export const LiveRoomProvider = ({ } }, [ sendTransportReady, - roomState?.status, + isRoomActive, publishLocalTrack, isCameraOn, isMicOn, @@ -1750,11 +1771,7 @@ export const LiveRoomProvider = ({ return; } // Otherwise, publish if room is live and transport is ready. - if ( - sendTransportRef.current && - roomState?.status === 'live' && - canPublish - ) { + if (sendTransportRef.current && isRoomActive && canPublish) { await publishLocalTrack(kind); } } catch (error) { @@ -1773,7 +1790,7 @@ export const LiveRoomProvider = ({ publishLocalTrack, unpublishKind, canPublish, - roomState?.status, + isRoomActive, micSettings, micSettingSupport, ], @@ -1853,11 +1870,7 @@ export const LiveRoomProvider = ({ cachedTrack.track.enabled = true; localTracksRef.current.audio = cachedTrack.track; refreshLocalStream(); - if ( - sendTransportRef.current && - roomState?.status === 'live' && - canPublish - ) { + if (sendTransportRef.current && isRoomActive && canPublish) { await publishLocalTrack('audio'); } setIsMicOn(true); @@ -1877,7 +1890,7 @@ export const LiveRoomProvider = ({ removeRaisedHandOnUnmute, publishLocalTrack, refreshLocalStream, - roomState?.status, + isRoomActive, selectedMicId, startCapture, unpublishKind, diff --git a/packages/shared/src/graphql/fragments.ts b/packages/shared/src/graphql/fragments.ts index 4d35695915f..5fa7dc83880 100644 --- a/packages/shared/src/graphql/fragments.ts +++ b/packages/shared/src/graphql/fragments.ts @@ -356,7 +356,9 @@ export const FEED_POST_INFO_FRAGMENT = gql` liveRoom { id topic + mode status + activityStatus scheduledStart subscribed } diff --git a/packages/shared/src/graphql/liveRooms.ts b/packages/shared/src/graphql/liveRooms.ts index c577c2bb943..26a8a2e099a 100644 --- a/packages/shared/src/graphql/liveRooms.ts +++ b/packages/shared/src/graphql/liveRooms.ts @@ -7,6 +7,7 @@ import type { EmptyResponse } from './emptyResponse'; export enum LiveRoomMode { Moderated = 'moderated', FreeForAll = 'free_for_all', + CommunityModerated = 'community_moderated', } export enum LiveRoomStatus { @@ -15,6 +16,11 @@ export enum LiveRoomStatus { Ended = 'ended', } +export enum LiveRoomActivityStatus { + Pending = 'pending', + Live = 'live', +} + export enum LiveRoomParticipantRole { Host = 'host', Audience = 'audience', @@ -27,6 +33,7 @@ export interface LiveRoom { topic: string; mode: LiveRoomMode; status: LiveRoomStatus; + activityStatus?: LiveRoomActivityStatus | null; startedAt: string | null; endedAt: string | null; scheduledStart: string | null; @@ -39,12 +46,18 @@ export interface LiveRoom { export type LiveRoomPost = Pick< LiveRoom, - 'id' | 'topic' | 'status' | 'scheduledStart' | 'subscribed' + | 'id' + | 'topic' + | 'mode' + | 'status' + | 'activityStatus' + | 'scheduledStart' + | 'subscribed' >; export type ActiveLiveRoom = Pick< LiveRoom, - 'id' | 'topic' | 'status' | 'participantCount' + 'id' | 'topic' | 'mode' | 'status' | 'activityStatus' | 'participantCount' > & { host: Pick< UserShortProfile, @@ -94,6 +107,7 @@ export const LIVE_ROOM_FRAGMENT = gql` topic mode status + activityStatus startedAt endedAt scheduledStart @@ -142,7 +156,9 @@ export const ACTIVE_LIVE_ROOMS_QUERY = gql` activeLiveRooms(limit: $limit) { id topic + mode status + activityStatus participantCount host { id diff --git a/packages/shared/src/hooks/liveRooms/useCreateLiveRoom.ts b/packages/shared/src/hooks/liveRooms/useCreateLiveRoom.ts index 349343491c1..d9963fde30e 100644 --- a/packages/shared/src/hooks/liveRooms/useCreateLiveRoom.ts +++ b/packages/shared/src/hooks/liveRooms/useCreateLiveRoom.ts @@ -10,6 +10,7 @@ import { gqlClient } from '../../graphql/common'; export interface CreateLiveRoomInput { topic: string; mode?: LiveRoomMode; + minParticipantsToGoLive?: number; speakerLimit?: number; scheduledStart?: string; description?: string; @@ -24,6 +25,7 @@ export const useCreateLiveRoom = () => { input: { topic: input.topic, mode: input.mode ?? LiveRoomMode.Moderated, + minParticipantsToGoLive: input.minParticipantsToGoLive, speakerLimit: input.speakerLimit, scheduledStart: input.scheduledStart, description: input.description, diff --git a/packages/shared/src/hooks/liveRooms/useLiveRoomStageModel.ts b/packages/shared/src/hooks/liveRooms/useLiveRoomStageModel.ts index 4dcef5ee858..abcb13b47c3 100644 --- a/packages/shared/src/hooks/liveRooms/useLiveRoomStageModel.ts +++ b/packages/shared/src/hooks/liveRooms/useLiveRoomStageModel.ts @@ -1,9 +1,11 @@ import { useMemo } from 'react'; import type { LiveRoomContextValue } from '../../contexts/LiveRoomContext'; import type { LiveRoom as LiveRoomModel } from '../../graphql/liveRooms'; +import type { LiveRoomModeValue } from '../../lib/liveRoom/protocol'; import type { UserShortProfile } from '../../lib/user'; import { buildParticipantProfile } from '../../components/liveRooms/liveRoomParticipants'; import type { LiveRoomStageSpeaker } from '../../components/liveRooms/LiveRoomStage'; +import { isCommunityModeratedRoom } from '../../lib/liveRoom/status'; import { useLiveRoomParticipantProfiles } from './useLiveRoomParticipantProfiles'; import { useLiveRoomParticipantStreams } from './useLiveRoomParticipantStreams'; @@ -206,14 +208,19 @@ export const useLiveRoomStageModel = ({ ), [roomState?.mediaPublications], ); - const roomMode = roomState?.mode ?? room?.mode ?? 'moderated'; + const roomMode = (roomState?.mode ?? + room?.mode ?? + 'moderated') as LiveRoomModeValue; const isFreeForAll = roomMode === 'free_for_all'; + const isCommunityModerated = isCommunityModeratedRoom({ mode: roomMode }); const remainingSeats = stageLimit === null ? null : Math.max(stageLimit - activeSpeakerIds.length, 0); - let waitingPrompt = 'Audience can join the queue'; - if (isFreeForAll && remainingSeats !== null) { + let waitingPrompt = isCommunityModerated + ? 'Waiting for more participants' + : 'Audience can join the queue'; + if ((isFreeForAll || isCommunityModerated) && remainingSeats !== null) { waitingPrompt = remainingSeats === 0 ? 'The stage is full right now' @@ -280,6 +287,7 @@ export const useLiveRoomStageModel = ({ mentionSuggestions, roomMode, isFreeForAll, + isCommunityModerated, activeSpeakerIds, queuedParticipantIds, audienceParticipantIds, diff --git a/packages/shared/src/hooks/liveRooms/useLiveRoomSubscriptionAction.ts b/packages/shared/src/hooks/liveRooms/useLiveRoomSubscriptionAction.ts index 8f8e603cddc..6a7712830c1 100644 --- a/packages/shared/src/hooks/liveRooms/useLiveRoomSubscriptionAction.ts +++ b/packages/shared/src/hooks/liveRooms/useLiveRoomSubscriptionAction.ts @@ -11,6 +11,7 @@ import { usePushNotificationContext } from '../../contexts/PushNotificationConte import { usePushNotificationMutation } from '../notifications/usePushNotificationMutation'; import { useLogContext } from '../../contexts/LogContext'; import { buildStandupAnalyticsExtra } from '../../lib/liveRoom/analytics'; +import { isLiveRoomEffectivelyLive } from '../../lib/liveRoom/status'; type BuildLiveRoomSubscriptionExtra = ( extra: Record, @@ -18,7 +19,7 @@ type BuildLiveRoomSubscriptionExtra = ( type LiveRoomSubscriptionActionRoom = Pick< LiveRoom, - 'id' | 'status' | 'scheduledStart' | 'subscribed' + 'id' | 'status' | 'activityStatus' | 'scheduledStart' | 'subscribed' > & Partial>; @@ -71,6 +72,7 @@ export const useLiveRoomSubscriptionAction = ({ const subscriptionBusy = subscribe.isPending || unsubscribe.isPending; const canToggleSubscription = room?.status === LiveRoomStatus.Created && + !isLiveRoomEffectivelyLive(room) && !!room.scheduledStart && (!user || !hostUserId || user.id !== hostUserId); @@ -91,7 +93,10 @@ export const useLiveRoomSubscriptionAction = ({ ); } - if (room.status !== LiveRoomStatus.Created) { + if ( + room.status !== LiveRoomStatus.Created || + isLiveRoomEffectivelyLive(room) + ) { throw new Error( 'Live room subscription is only available before start', ); diff --git a/packages/shared/src/hooks/liveRooms/useSubmitStandup.ts b/packages/shared/src/hooks/liveRooms/useSubmitStandup.ts index 3d57688e993..e30a45d7863 100644 --- a/packages/shared/src/hooks/liveRooms/useSubmitStandup.ts +++ b/packages/shared/src/hooks/liveRooms/useSubmitStandup.ts @@ -13,8 +13,11 @@ export type StandupScheduleChoice = 'now' | 'later'; export interface SubmitStandupInput { topic: string; + mode?: LiveRoomMode; scheduleChoice: StandupScheduleChoice; scheduledStart?: string; + minParticipantsToGoLive?: number; + speakerLimit?: number; description?: string; } @@ -43,6 +46,8 @@ export const useSubmitStandup = (): UseSubmitStandup => { async (input: SubmitStandupInput): Promise => { const timezone = user?.timezone || DEFAULT_TIMEZONE; const description = input.description?.trim(); + const mode = input.mode ?? LiveRoomMode.Moderated; + const isCommunityModerated = mode === LiveRoomMode.CommunityModerated; let scheduledStartUtc: string | undefined; if (input.scheduleChoice === 'later') { @@ -73,8 +78,14 @@ export const useSubmitStandup = (): UseSubmitStandup => { try { const joinToken = await createLiveRoom({ topic: input.topic.trim(), - mode: LiveRoomMode.Moderated, + mode, scheduledStart: scheduledStartUtc, + ...(isCommunityModerated && input.minParticipantsToGoLive + ? { minParticipantsToGoLive: input.minParticipantsToGoLive } + : {}), + ...(isCommunityModerated && input.speakerLimit + ? { speakerLimit: input.speakerLimit } + : {}), description: description || undefined, }); logEvent({ @@ -82,7 +93,16 @@ export const useSubmitStandup = (): UseSubmitStandup => { target_id: joinToken.room.id, extra: JSON.stringify({ scheduled: input.scheduleChoice === 'later', + mode, has_description: !!description, + min_participants_to_go_live: + isCommunityModerated && input.minParticipantsToGoLive + ? input.minParticipantsToGoLive + : null, + speaker_limit: + isCommunityModerated && input.speakerLimit + ? input.speakerLimit + : null, scheduled_start_delta_minutes: scheduledStartUtc ? Math.max( 0, diff --git a/packages/shared/src/lib/liveRoom/protocol.ts b/packages/shared/src/lib/liveRoom/protocol.ts index e8aae596cc0..10added5e1c 100644 --- a/packages/shared/src/lib/liveRoom/protocol.ts +++ b/packages/shared/src/lib/liveRoom/protocol.ts @@ -7,7 +7,11 @@ import type { } from 'mediasoup-client/types'; export type LiveRoomStatusValue = 'created' | 'live' | 'ended'; -export type LiveRoomModeValue = 'moderated' | 'free_for_all'; +export type LiveRoomModeValue = + | 'moderated' + | 'free_for_all' + | 'community_moderated'; +export type LiveRoomActivityStatusValue = 'pending' | 'live'; export type LiveRoomParticipantRoleValue = 'host' | 'speaker' | 'audience'; export type MediaKindValue = 'audio' | 'video'; @@ -38,6 +42,23 @@ export interface LiveRoomMediaPublicationRecord { updatedAt: string; } +export interface LiveRoomCommunityKickVote { + targetParticipantId: string; + proposedByParticipantId: string; + eligibleParticipantIds: string[]; + yesParticipantIds: string[]; + noParticipantIds: string[]; + createdAt: string; + expiresAt: string; +} + +export interface LiveRoomKickedParticipantRecord { + participantId: string; + kickedAt: string; + expiresAt: string | null; + reason: 'direct' | 'community_vote'; +} + export interface LiveRoomStageState { speakerQueueParticipantIds: string[]; activeSpeakerParticipantIds: string[]; @@ -49,9 +70,16 @@ export interface LiveRoomState { roomId: string; mode: LiveRoomModeValue; status: LiveRoomStatusValue; + activityStatus?: LiveRoomActivityStatusValue; + minParticipantsToGoLive?: number | null; version: number; participants: Record; coHostParticipantIds: string[]; + kickedParticipantIds?: string[]; + kickedParticipants?: Record; + communityKickVotes?: Record; + communityKickVoteActorCooldowns?: Record; + communityKickVoteTargetCooldowns?: Record; chatPermissions: Record; sessions: Record; stage: LiveRoomStageState; @@ -232,6 +260,12 @@ export type LiveRoomCommand = | { type: 'stage.speaker.promote'; targetParticipantId: string } | { type: 'stage.speaker.remove'; targetParticipantId: string } | { type: 'stage.kick'; targetParticipantId: string } + | { type: 'stage.kick.vote.start'; targetParticipantId: string } + | { + type: 'stage.kick.vote.cast'; + targetParticipantId: string; + vote: 'yes' | 'no'; + } | { type: 'media.capabilities.get' } | { type: 'media.transport.create'; direction: 'send' | 'recv' } | { diff --git a/packages/shared/src/lib/liveRoom/status.ts b/packages/shared/src/lib/liveRoom/status.ts new file mode 100644 index 00000000000..ea12470c8b1 --- /dev/null +++ b/packages/shared/src/lib/liveRoom/status.ts @@ -0,0 +1,23 @@ +interface LiveRoomStatusLike { + activityStatus?: string | null; + mode?: string | null; + status?: string | null; +} + +export const isCommunityModeratedRoom = ( + room: Pick | null | undefined, +): boolean => room?.mode === 'community_moderated'; + +export const isLiveRoomEffectivelyLive = ( + room: LiveRoomStatusLike | null | undefined, +): boolean => { + if (!room) { + return false; + } + + if (isCommunityModeratedRoom(room)) { + return room.activityStatus === 'live'; + } + + return room.status === 'live'; +};