bigbluebutton-Github/bigbluebutton-html5/imports/ui/components/breakout-room/create-breakout-room/component.tsx
2024-06-14 10:30:48 -03:00

583 lines
19 KiB
TypeScript

import React, { useMemo } from 'react';
import ModalFullscreen from '/imports/ui/components/common/modal/fullscreen/component';
import { defineMessages, useIntl } from 'react-intl';
import { range } from 'ramda';
import { uniqueId } from '/imports/utils/string-utils';
import { useIsImportPresentationWithAnnotationsFromBreakoutRoomsEnabled, useIsImportSharedNotesFromBreakoutRoomsEnabled } from '/imports/ui/services/features';
import useMeeting from '/imports/ui/core/hooks/useMeeting';
import { useLazyQuery, useQuery, useMutation } from '@apollo/client';
import Styled from './styles';
import {
getBreakouts,
getBreakoutsResponse,
getUser,
getUserResponse,
} from './queries';
import logger from '/imports/startup/client/logger';
import BreakoutRoomUserAssignment from './breakout-room-user-assignment/component';
import deviceInfo from '/imports/utils/deviceInfo';
import BreakoutRoomUserAssignmentMobile from './breakout-room-user-assignment-mobile/component';
import RoomManagmentState from './room-managment-state/component';
import {
Rooms,
RoomToWithSettings,
BreakoutUser,
moveUserRegistery,
} from './room-managment-state/types';
import { BREAKOUT_ROOM_CREATE, BREAKOUT_ROOM_MOVE_USER } from '../mutations';
const MIN_BREAKOUT_ROOMS = 2;
const MIN_BREAKOUT_TIME = 5;
const DEFAULT_BREAKOUT_TIME = 15;
interface CreateBreakoutRoomContainerProps {
isOpen: boolean
setIsOpen: (isOpen: boolean) => void
priority: string,
isUpdate?: boolean,
}
interface CreateBreakoutRoomProps extends CreateBreakoutRoomContainerProps {
isBreakoutRecordable: boolean,
users: Array<BreakoutUser>,
runningRooms: getBreakoutsResponse['breakoutRoom'],
}
const intlMessages = defineMessages({
breakoutRoomTitle: {
id: 'app.createBreakoutRoom.title',
description: 'modal title',
},
breakoutRoomDesc: {
id: 'app.createBreakoutRoom.modalDesc',
description: 'modal description',
},
breakoutRoomUpdateDesc: {
id: 'app.updateBreakoutRoom.modalDesc',
description: 'update modal description',
},
cancelLabel: {
id: 'app.updateBreakoutRoom.cancelLabel',
description: 'used in the button that close update modal',
},
updateTitle: {
id: 'app.updateBreakoutRoom.title',
description: 'update breakout title',
},
updateConfirm: {
id: 'app.updateBreakoutRoom.confirm',
description: 'Update to breakout confirm button label',
},
resetUserRoom: {
id: 'app.update.resetRoom',
description: 'Reset user room button label',
},
confirmButton: {
id: 'app.createBreakoutRoom.confirm',
description: 'confirm button label',
},
dismissLabel: {
id: 'app.presentationUploder.dismissLabel',
description: 'used in the button that close modal',
},
numberOfRooms: {
id: 'app.createBreakoutRoom.numberOfRooms',
description: 'number of rooms label',
},
duration: {
id: 'app.createBreakoutRoom.durationInMinutes',
description: 'duration time label',
},
resetAssignments: {
id: 'app.createBreakoutRoom.resetAssignments',
description: 'reset assignments label',
},
resetAssignmentsDesc: {
id: 'app.createBreakoutRoom.resetAssignmentsDesc',
description: 'reset assignments label description',
},
randomlyAssign: {
id: 'app.createBreakoutRoom.randomlyAssign',
description: 'randomly assign label',
},
randomlyAssignDesc: {
id: 'app.createBreakoutRoom.randomlyAssignDesc',
description: 'randomly assign label description',
},
breakoutRoom: {
id: 'app.createBreakoutRoom.room',
description: 'breakout room',
},
freeJoinLabel: {
id: 'app.createBreakoutRoom.freeJoin',
description: 'free join label',
},
captureNotesLabel: {
id: 'app.createBreakoutRoom.captureNotes',
description: 'capture shared notes label',
},
captureSlidesLabel: {
id: 'app.createBreakoutRoom.captureSlides',
description: 'capture slides label',
},
captureNotesType: {
id: 'app.notes.label',
description: 'indicates notes have been captured',
},
captureSlidesType: {
id: 'app.shortcut-help.whiteboard',
description: 'indicates the whiteboard has been captured',
},
roomLabel: {
id: 'app.createBreakoutRoom.room',
description: 'Room label',
},
leastOneWarnBreakout: {
id: 'app.createBreakoutRoom.leastOneWarnBreakout',
description: 'warn message label',
},
notAssigned: {
id: 'app.createBreakoutRoom.notAssigned',
description: 'Not assigned label',
},
breakoutRoomLabel: {
id: 'app.createBreakoutRoom.breakoutRoomLabel',
description: 'breakout room label',
},
addParticipantLabel: {
id: 'app.createBreakoutRoom.addParticipantLabel',
description: 'add Participant label',
},
nextLabel: {
id: 'app.createBreakoutRoom.nextLabel',
description: 'Next label',
},
backLabel: {
id: 'app.audio.backLabel',
description: 'Back label',
},
minusRoomTime: {
id: 'app.createBreakoutRoom.minusRoomTime',
description: 'aria label for btn to decrease room time',
},
addRoomTime: {
id: 'app.createBreakoutRoom.addRoomTime',
description: 'aria label for btn to increase room time',
},
record: {
id: 'app.createBreakoutRoom.record',
description: 'label for checkbox to allow record',
},
roomTime: {
id: 'app.createBreakoutRoom.roomTime',
description: 'used to provide current room time for aria label',
},
numberOfRoomsIsValid: {
id: 'app.createBreakoutRoom.numberOfRoomsError',
description: 'Label an error message',
},
roomNameEmptyIsValid: {
id: 'app.createBreakoutRoom.emptyRoomNameError',
description: 'Label an error message',
},
roomNameDuplicatedIsValid: {
id: 'app.createBreakoutRoom.duplicatedRoomNameError',
description: 'Label an error message',
},
you: {
id: 'app.userList.you',
description: 'Text for identifying your user',
},
minimumDurationWarnBreakout: {
id: 'app.createBreakoutRoom.minimumDurationWarnBreakout',
description: 'minimum duration warning message label',
},
roomNameInputDesc: {
id: 'app.createBreakoutRoom.roomNameInputDesc',
description: 'aria description for room name change',
},
movedUserLabel: {
id: 'app.createBreakoutRoom.movedUserLabel',
description: 'screen reader alert when users are moved to rooms',
},
manageRooms: {
id: 'app.createBreakoutRoom.manageRoomsLabel',
description: 'Label for manage rooms',
},
sendInvitationToMods: {
id: 'app.createBreakoutRoom.sendInvitationToMods',
description: 'label for checkbox send invitation to moderators',
},
});
const CreateBreakoutRoom: React.FC<CreateBreakoutRoomProps> = ({
isOpen,
setIsOpen,
priority,
isUpdate = false,
isBreakoutRecordable,
users,
runningRooms,
}) => {
const { isMobile } = deviceInfo;
const intl = useIntl();
const [numberOfRoomsIsValid, setNumberOfRoomsIsValid] = React.useState(true);
const [durationIsValid, setDurationIsValid] = React.useState(true);
const [freeJoin, setFreeJoin] = React.useState(false);
const [record, setRecord] = React.useState(false);
const [captureSlides, setCaptureSlides] = React.useState(false);
const [leastOneUserIsValid, setLeastOneUserIsValid] = React.useState(false);
const [captureNotes, setCaptureNotes] = React.useState(false);
const [inviteMods, setInviteMods] = React.useState(false);
const [numberOfRooms, setNumberOfRooms] = React.useState(MIN_BREAKOUT_ROOMS);
const [durationTime, setDurationTime] = React.useState(DEFAULT_BREAKOUT_TIME);
const isImportPresentationWithAnnotationsEnabled = useIsImportPresentationWithAnnotationsFromBreakoutRoomsEnabled();
const isImportSharedNotesEnabled = useIsImportSharedNotesFromBreakoutRoomsEnabled();
const [createBreakoutRoom] = useMutation(BREAKOUT_ROOM_CREATE);
const [moveUser] = useMutation(BREAKOUT_ROOM_MOVE_USER);
const roomsRef = React.useRef<Rooms>({});
const moveRegisterRef = React.useRef<moveUserRegistery>({});
const setRoomsRef = (rooms: Rooms) => {
roomsRef.current = rooms;
};
const setMoveRegisterRef = (moveRegister: moveUserRegistery) => {
moveRegisterRef.current = moveRegister;
};
const checkboxCallbackFactory = (call: (value: boolean) => void) => (e: React.ChangeEvent<HTMLInputElement>) => {
const { checked } = e.target;
call(checked);
};
const createRoom = () => {
const rooms = roomsRef.current;
const roomsArray: RoomToWithSettings[] = [];
/* eslint no-restricted-syntax: "off" */
for (let i = 0; i < numberOfRooms; i += 1) {
const roomNumber = i + 1;
if (rooms[roomNumber]) {
const r = rooms[roomNumber];
roomsArray.push({
name: r.name,
sequence: r.id,
captureNotesFilename: `${r.name.replace(/\s/g, '_')}_${intl.formatMessage(intlMessages.captureNotesType)}`,
captureSlidesFilename: `${r.name.replace(/\s/g, '_')}_${intl.formatMessage(intlMessages.captureSlidesType)}`,
isDefaultName: r.name === intl.formatMessage(intlMessages.breakoutRoom, {
0: r.id,
}),
users: r.users.map((u) => u.userId),
freeJoin,
shortName: r.name,
});
} else {
const defaultName = intl.formatMessage(intlMessages.breakoutRoom, {
0: roomNumber,
});
roomsArray.push({
name: defaultName,
sequence: roomNumber,
captureNotesFilename: `${defaultName.replace(/\s/g, '_')}_${intl.formatMessage(intlMessages.captureNotesType)}`,
captureSlidesFilename: `${defaultName.replace(/\s/g, '_')}_${intl.formatMessage(intlMessages.captureSlidesType)}`,
isDefaultName: true,
freeJoin,
shortName: defaultName,
users: [],
});
}
}
createBreakoutRoom(
{
variables: {
record,
captureNotes,
captureSlides,
durationInMinutes: durationTime,
sendInviteToModerators: inviteMods,
rooms: roomsArray,
},
},
);
setIsOpen(false);
};
const userUpdate = () => {
const userIds = Object.keys(moveRegisterRef.current);
userIds.forEach((userId) => {
const { fromRoomId, toRoomId } = moveRegisterRef.current[userId];
if (fromRoomId !== toRoomId) {
moveUser({
variables: {
userId,
fromBreakoutRoomId: fromRoomId || '',
toBreakoutRoomId: toRoomId,
},
});
}
});
setIsOpen(false);
};
const title = useMemo(() => (
<Styled.SubTitle>
{isUpdate
? intl.formatMessage(intlMessages.breakoutRoomUpdateDesc)
: intl.formatMessage(intlMessages.breakoutRoomDesc)}
</Styled.SubTitle>
), [isUpdate]);
const checkboxesInfo = useMemo(() => {
return [
{
allowed: true,
htmlFor: 'freeJoinCheckbox',
key: 'free-join-breakouts',
id: 'freeJoinCheckbox',
onChange: checkboxCallbackFactory((e: boolean) => {
setFreeJoin(e);
setLeastOneUserIsValid(true);
}),
label: intl.formatMessage(intlMessages.freeJoinLabel),
},
{
allowed: isBreakoutRecordable,
htmlFor: 'recordBreakoutCheckbox',
key: 'record-breakouts',
id: 'recordBreakoutCheckbox',
onChange: checkboxCallbackFactory(setRecord),
label: intl.formatMessage(intlMessages.record),
},
{
allowed: isImportPresentationWithAnnotationsEnabled,
htmlFor: 'captureSlidesBreakoutCheckbox',
key: 'capture-slides-breakouts',
id: 'captureSlidesBreakoutCheckbox',
onChange: checkboxCallbackFactory(setCaptureSlides),
label: intl.formatMessage(intlMessages.captureSlidesLabel),
},
{
allowed: isImportSharedNotesEnabled,
htmlFor: 'captureNotesBreakoutCheckbox',
key: 'capture-notes-breakouts',
id: 'captureNotesBreakoutCheckbox',
onChange: checkboxCallbackFactory(setCaptureNotes),
label: intl.formatMessage(intlMessages.captureNotesLabel),
},
{
allowed: true,
htmlFor: 'sendInvitationToAssignedModeratorsCheckbox',
key: 'send-invitation-to-assigned-moderators-breakouts',
id: 'sendInvitationToAssignedModeratorsCheckbox',
onChange: checkboxCallbackFactory(setInviteMods),
label: intl.formatMessage(intlMessages.sendInvitationToMods),
},
];
}, [isBreakoutRecordable]);
const form = useMemo(() => {
if (isUpdate) return null;
const BREAKOUT_LIM = window.meetingClientSettings.public.app.breakouts.breakoutRoomLimit;
const MAX_BREAKOUT_ROOMS = BREAKOUT_LIM > MIN_BREAKOUT_ROOMS ? BREAKOUT_LIM : MIN_BREAKOUT_ROOMS;
return (
<React.Fragment key="breakout-form">
<Styled.BreakoutSettings>
<div>
<Styled.FormLabel valid={numberOfRoomsIsValid} aria-hidden>
{intl.formatMessage(intlMessages.numberOfRooms)}
</Styled.FormLabel>
<Styled.InputRooms
id="numberOfRooms"
name="numberOfRooms"
valid={numberOfRoomsIsValid}
value={numberOfRooms}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
const { value } = e.target;
setNumberOfRooms(Number.parseInt(value, 10));
setNumberOfRoomsIsValid(true);
}}
aria-label={intl.formatMessage(intlMessages.numberOfRooms)}
>
{
range(MIN_BREAKOUT_ROOMS, MAX_BREAKOUT_ROOMS + 1).map((item) => (<option key={uniqueId('value-')}>{item}</option>))
}
</Styled.InputRooms>
</div>
<Styled.DurationLabel valid={durationIsValid} htmlFor="breakoutRoomTime">
<Styled.LabelText bold={false} aria-hidden>
{intl.formatMessage(intlMessages.duration)}
</Styled.LabelText>
<Styled.DurationArea>
<Styled.DurationInput
type="number"
min="1"
value={durationTime}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;
const v = Number.parseInt(value, 10);
setDurationTime(v);
setDurationIsValid(v >= MIN_BREAKOUT_TIME);
}}
onBlur={(e: React.FocusEvent<HTMLInputElement>) => {
const { value } = e.target;
const v = Number.parseInt(value, 10);
setDurationTime((v && !(v <= 0)) ? v : MIN_BREAKOUT_TIME);
setDurationIsValid(true);
}}
aria-label={intl.formatMessage(intlMessages.duration)}
data-test="durationTime"
/>
</Styled.DurationArea>
<Styled.SpanWarn data-test="minimumDurationWarnBreakout" valid={durationIsValid}>
{
intl.formatMessage(
intlMessages.minimumDurationWarnBreakout,
{ 0: MIN_BREAKOUT_TIME },
)
}
</Styled.SpanWarn>
</Styled.DurationLabel>
<Styled.CheckBoxesContainer key="breakout-checkboxes">
{checkboxesInfo
.filter((item) => item.allowed)
.map((item) => (
<Styled.FreeJoinLabel htmlFor={item.htmlFor} key={item.key}>
<Styled.FreeJoinCheckbox
type="checkbox"
id={item.id}
onChange={item.onChange}
aria-label={item.label}
/>
<span aria-hidden>{item.label}</span>
</Styled.FreeJoinLabel>
))}
</Styled.CheckBoxesContainer>
</Styled.BreakoutSettings>
<Styled.SpanWarn valid={numberOfRoomsIsValid}>
{intl.formatMessage(intlMessages.numberOfRoomsIsValid)}
</Styled.SpanWarn>
<span aria-hidden id="randomlyAssignDesc" className="sr-only">
{intl.formatMessage(intlMessages.randomlyAssignDesc)}
</span>
<Styled.Separator />
</React.Fragment>
);
}, [durationTime, durationIsValid, numberOfRooms, numberOfRoomsIsValid]);
return (
<ModalFullscreen
title={
isUpdate
? intl.formatMessage(intlMessages.updateTitle)
: intl.formatMessage(intlMessages.breakoutRoomTitle)
}
confirm={
{
label: isUpdate
? intl.formatMessage(intlMessages.updateConfirm)
: intl.formatMessage(intlMessages.confirmButton),
callback: isUpdate ? userUpdate : createRoom,
disabled: !leastOneUserIsValid || !numberOfRoomsIsValid || !durationIsValid,
}
}
dismiss={{
label: isUpdate
? intl.formatMessage(intlMessages.cancelLabel)
: intl.formatMessage(intlMessages.dismissLabel),
callback: () => setIsOpen(false),
disabled: false,
}}
isOpen={isOpen}
priority={priority}
>
<Styled.Content>
{title}
{form}
<RoomManagmentState
numberOfRooms={numberOfRooms}
users={users}
RendererComponent={isMobile ? BreakoutRoomUserAssignmentMobile : BreakoutRoomUserAssignment}
runningRooms={runningRooms}
setRoomsRef={setRoomsRef}
setMoveRegisterRef={setMoveRegisterRef}
setFormIsValid={setLeastOneUserIsValid}
/>
</Styled.Content>
</ModalFullscreen>
);
};
const CreateBreakoutRoomContainer: React.FC<CreateBreakoutRoomContainerProps> = ({
isOpen,
setIsOpen,
priority,
isUpdate = false,
}) => {
const [fetchedBreakouts, setFetchedBreakouts] = React.useState(false);
// isBreakoutRecordable - get from meeting breakout policies breakoutPolicies/record
const {
data: currentMeeting,
} = useMeeting((m) => ({
breakoutPolicies: m.breakoutPolicies,
}));
const {
data: usersData,
loading: usersLoading,
error: usersError,
} = useQuery<getUserResponse>(getUser, {
fetchPolicy: 'network-only',
});
const [
loadBreakouts,
{
data: breakoutsData,
loading: breakoutsLoading,
error: breakoutsError,
},
] = useLazyQuery<getBreakoutsResponse>(getBreakouts, {
fetchPolicy: 'network-only',
});
if (usersLoading || breakoutsLoading || !currentMeeting) {
return null;
}
if (true && !fetchedBreakouts) {
loadBreakouts();
setFetchedBreakouts(true);
}
if (true && breakoutsLoading) return null;
if (usersError || breakoutsError) {
logger.info('Error loading users', usersError);
logger.info('Error loading breakouts', breakoutsError);
return (
<div>
{JSON.stringify(usersError) || JSON.stringify(breakoutsError)}
</div>
);
}
return (
<CreateBreakoutRoom
isOpen={isOpen}
setIsOpen={setIsOpen}
priority={priority}
isUpdate={isUpdate}
isBreakoutRecordable={currentMeeting?.breakoutPolicies?.record ?? true}
users={usersData?.user ?? []}
runningRooms={breakoutsData?.breakoutRoom ?? []}
/>
);
};
export default CreateBreakoutRoomContainer;