diff --git a/src/frontend/src/api/queryKeys.ts b/src/frontend/src/api/queryKeys.ts index 1af695d8..96c01d77 100644 --- a/src/frontend/src/api/queryKeys.ts +++ b/src/frontend/src/api/queryKeys.ts @@ -3,4 +3,5 @@ export const keys = { room: 'room', config: 'config', requestEntry: 'requestEntry', + waitingParticipants: 'waitingParticipants', } diff --git a/src/frontend/src/features/notifications/NotificationType.ts b/src/frontend/src/features/notifications/NotificationType.ts index 2f0ecf3d..74974ecf 100644 --- a/src/frontend/src/features/notifications/NotificationType.ts +++ b/src/frontend/src/features/notifications/NotificationType.ts @@ -5,4 +5,5 @@ export enum NotificationType { MessageReceived = 'messageReceived', LowerHand = 'lowerHand', ReactionReceived = 'reactionReceived', + ParticipantWaiting = 'participantWaiting', } diff --git a/src/frontend/src/features/rooms/api/enterRoom.ts b/src/frontend/src/features/rooms/api/enterRoom.ts new file mode 100644 index 00000000..a0564818 --- /dev/null +++ b/src/frontend/src/features/rooms/api/enterRoom.ts @@ -0,0 +1,37 @@ +import { ApiError } from '@/api/ApiError' +import { fetchApi } from '@/api/fetchApi' +import { useMutation, UseMutationOptions } from '@tanstack/react-query' + +export interface EnterRoomParams { + roomId: string + allowEntry: boolean + participantId: string +} + +export interface EnterRoomResponse { + message?: string +} + +export const enterRoom = async ({ + roomId, + allowEntry, + participantId, +}: EnterRoomParams): Promise => { + return await fetchApi(`/rooms/${roomId}/enter/`, { + method: 'POST', + body: JSON.stringify({ + participant_id: participantId, + allow_entry: allowEntry, + }), + }) +} + +export function useEnterRoom( + options?: UseMutationOptions +) { + return useMutation({ + mutationFn: enterRoom, + onSuccess: options?.onSuccess, + ...options, + }) +} diff --git a/src/frontend/src/features/rooms/api/listWaitingParticipants.ts b/src/frontend/src/features/rooms/api/listWaitingParticipants.ts new file mode 100644 index 00000000..43c07a44 --- /dev/null +++ b/src/frontend/src/features/rooms/api/listWaitingParticipants.ts @@ -0,0 +1,52 @@ +import { fetchApi } from '@/api/fetchApi' +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { ApiError } from '@/api/ApiError' +import { keys } from '@/api/queryKeys' + +export type WaitingParticipant = { + id: string + status: string + username: string + color: string +} + +export type WaitingParticipantsResponse = { + participants: WaitingParticipant[] +} + +export type WaitingParticipantsParams = { + roomId: string +} + +export const listWaitingParticipants = async ({ + roomId, +}: WaitingParticipantsParams): Promise => { + return fetchApi( + `/rooms/${roomId}/waiting-participants/`, + { + method: 'GET', + } + ) +} + +export const useListWaitingParticipants = ( + roomId: string, + queryOptions?: Omit< + UseQueryOptions< + WaitingParticipantsResponse, + ApiError, + WaitingParticipantsResponse + >, + 'queryKey' + > +) => { + return useQuery< + WaitingParticipantsResponse, + ApiError, + WaitingParticipantsResponse + >({ + queryKey: [keys.waitingParticipants, roomId], + queryFn: () => listWaitingParticipants({ roomId }), + ...queryOptions, + }) +} diff --git a/src/frontend/src/features/rooms/hooks/useWaitingParticipants.ts b/src/frontend/src/features/rooms/hooks/useWaitingParticipants.ts new file mode 100644 index 00000000..7b83f55a --- /dev/null +++ b/src/frontend/src/features/rooms/hooks/useWaitingParticipants.ts @@ -0,0 +1,96 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useRoomContext } from '@livekit/components-react' +import { RoomEvent } from 'livekit-client' +import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' +import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner' +import { useEnterRoom } from '../api/enterRoom' +import { + useListWaitingParticipants, + WaitingParticipant, +} from '../api/listWaitingParticipants' +import { decodeNotificationDataReceived } from '@/features/notifications/utils' +import { NotificationType } from '@/features/notifications/NotificationType' + +export const POLL_INTERVAL_MS = 1000 + +export const useWaitingParticipants = () => { + const [listEnabled, setListEnabled] = useState(true) + + const roomData = useRoomData() + const roomId = roomData?.id || '' // FIXME - bad practice + + const room = useRoomContext() + const isAdminOrOwner = useIsAdminOrOwner() + + const handleDataReceived = useCallback((payload: Uint8Array) => { + const { type } = decodeNotificationDataReceived(payload) + if (type === NotificationType.ParticipantWaiting) { + setListEnabled(true) + } + }, []) + + useEffect(() => { + if (isAdminOrOwner) { + room.on(RoomEvent.DataReceived, handleDataReceived) + } + return () => { + room.off(RoomEvent.DataReceived, handleDataReceived) + } + }, [isAdminOrOwner, room, handleDataReceived]) + + const { data: waitingData, refetch: refetchWaiting } = + useListWaitingParticipants(roomId, { + retry: false, + enabled: listEnabled && isAdminOrOwner, + refetchInterval: POLL_INTERVAL_MS, + refetchIntervalInBackground: true, + }) + + const waitingParticipants = useMemo( + () => waitingData?.participants || [], + [waitingData] + ) + + useEffect(() => { + if (!waitingParticipants.length) setListEnabled(false) + }, [waitingParticipants]) + + const { mutateAsync: enterRoom } = useEnterRoom() + + const handleParticipantEntry = async ( + participant: WaitingParticipant, + allowEntry: boolean + ) => { + await enterRoom({ + roomId: roomId, + allowEntry, + participantId: participant.id, + }) + await refetchWaiting() + } + + const handleParticipantsEntry = async ( + allowEntry: boolean + ): Promise => { + try { + setListEnabled(false) + for (const participant of waitingParticipants) { + await enterRoom({ + roomId: roomId, + allowEntry, + participantId: participant.id, + }) + } + await refetchWaiting() + } catch (e) { + console.error(e) + setListEnabled(true) + } + } + + return { + waitingParticipants, + handleParticipantEntry, + handleParticipantsEntry, + } +} diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsCollapsableList.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsCollapsableList.tsx index 46699875..093559bf 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsCollapsableList.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsCollapsableList.tsx @@ -3,7 +3,6 @@ import { css } from '@/styled-system/css' import { ToggleButton } from 'react-aria-components' import { HStack, styled, VStack } from '@/styled-system/jsx' import { RiArrowUpSLine } from '@remixicon/react' -import { Participant } from 'livekit-client' import { useTranslation } from 'react-i18next' const ToggleHeader = styled(ToggleButton, { @@ -46,19 +45,19 @@ const ListContainer = styled(VStack, { }, }) -type ParticipantsCollapsableListProps = { +export type ParticipantsCollapsableListProps = { heading: string - participants: Array - renderParticipant: (participant: Participant) => JSX.Element + participants: Array + renderParticipant: (participant: T) => JSX.Element action?: () => JSX.Element } -export const ParticipantsCollapsableList = ({ +export function ParticipantsCollapsableList({ heading, participants, renderParticipant, action, -}: ParticipantsCollapsableListProps) => { +}: ParticipantsCollapsableListProps) { const { t } = useTranslation('rooms') const [isOpen, setIsOpen] = useState(true) const label = t(`participants.collapsable.${isOpen ? 'close' : 'open'}`, { diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsList.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsList.tsx index 9453e6a1..19b85a3a 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsList.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsList.tsx @@ -1,12 +1,17 @@ import { css } from '@/styled-system/css' import { useParticipants } from '@livekit/components-react' -import { Div, H } from '@/primitives' +import { Button, Div, H } from '@/primitives' import { useTranslation } from 'react-i18next' import { ParticipantListItem } from '../../controls/Participants/ParticipantListItem' import { ParticipantsCollapsableList } from '../../controls/Participants/ParticipantsCollapsableList' import { HandRaisedListItem } from '../../controls/Participants/HandRaisedListItem' import { LowerAllHandsButton } from '../../controls/Participants/LowerAllHandsButton' +import { HStack } from '@/styled-system/jsx' +import { WaitingParticipantListItem } from './WaitingParticipantListItem' +import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants' +import { Participant } from 'livekit-client' +import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants' // TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short. export const ParticipantsList = () => { @@ -35,6 +40,12 @@ export const ParticipantsList = () => { return data.raised }) + const { + waitingParticipants, + handleParticipantEntry, + handleParticipantsEntry, + } = useWaitingParticipants() + // TODO - extract inline styling in a centralized styling file, and avoid magic numbers return (
@@ -50,9 +61,42 @@ export const ParticipantsList = () => { > {t('subheading').toUpperCase()} + {waitingParticipants?.length > 0 && ( +
+ + heading={t('waiting.title')} + participants={waitingParticipants} + renderParticipant={(participant) => ( + + )} + action={() => ( + + + + + )} + /> +
+ )} {raisedHandParticipants.length > 0 && (
- heading={t('raisedHands')} participants={raisedHandParticipants} renderParticipant={(participant) => ( @@ -67,7 +111,7 @@ export const ParticipantsList = () => { />
)} - heading={t('contributors')} participants={sortedParticipants} renderParticipant={(participant) => ( diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Participants/WaitingParticipantListItem.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Participants/WaitingParticipantListItem.tsx new file mode 100644 index 00000000..0acc38cb --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/controls/Participants/WaitingParticipantListItem.tsx @@ -0,0 +1,78 @@ +import { Button, Text } from '@/primitives' +import { HStack } from '@/styled-system/jsx' +import { css } from '@/styled-system/css' +import { Avatar } from '@/components/Avatar' +import { useTranslation } from 'react-i18next' +import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants' + +export const WaitingParticipantListItem = ({ + participant, + onAction, +}: { + participant: WaitingParticipant + onAction: (participant: WaitingParticipant, allowEntry: boolean) => void +}) => { + const { t } = useTranslation('rooms') + + return ( + + + + + + {participant.username} + + + + {/* FIXME - flex layout is broken when the participant name is long */} + + + + + + ) +} diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json index fc853d0f..4ea6442b 100644 --- a/src/frontend/src/locales/de/rooms.json +++ b/src/frontend/src/locales/de/rooms.json @@ -209,7 +209,20 @@ }, "raisedHands": "", "lowerParticipantHand": "", - "lowerParticipantsHand": "" + "lowerParticipantsHand": "", + "waiting": { + "title": "", + "accept": { + "button": "", + "label": "", + "all": "" + }, + "deny": { + "button": "", + "label": "", + "all": "" + } + } }, "recording": { "label": "" diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index 06445ce8..6c596702 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -208,7 +208,20 @@ }, "raisedHands": "Raised hands", "lowerParticipantHand": "Lower {{name}}'s hand", - "lowerParticipantsHand": "Lower all hands" + "lowerParticipantsHand": "Lower all hands", + "waiting": { + "title": "Lobby", + "accept": { + "button": "Accept", + "label": "Accept {{name}} into the meeting", + "all": "Accept all" + }, + "deny": { + "button": "Deny", + "label": "Deny {{name}} from the meeting", + "all": "Deny all" + } + } }, "recording": { "label": "Recording" diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index efb619db..d3795976 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -208,7 +208,20 @@ }, "raisedHands": "Mains levées", "lowerParticipantHand": "Baisser la main de {{name}}", - "lowerParticipantsHand": "Baisser la main de tous les participants" + "lowerParticipantsHand": "Baisser la main de tous les participants", + "waiting": { + "title": "Salle d'attente", + "accept": { + "button": "Accepter", + "label": "Accepter {{name}} dans la réunion", + "all": "Tout accepter" + }, + "deny": { + "button": "Refuser", + "label": "Refuser {{name}} dans la réunion", + "all": "Tout rejeter" + } + } }, "recording": { "label": "Enregistrement"