From af644990bc38c5532cb89e4957a4155b83c8c0ef Mon Sep 17 00:00:00 2001 From: Ahmad Farhat <ahmad.af.farhat@gmail.com> Date: Tue, 20 Jun 2023 16:46:34 -0400 Subject: [PATCH] Public recordings: Update UI to list public recordings. (#5270) * * Added Public recordings UI components. * Added Public recordings page. * Extended Room join page. * Update RecordingRow.jsx * Update RecordingRow.jsx * Eslint --------- Co-authored-by: kh-amir-tn <amir.khemissi@insat.ucar.tn> --- app/assets/locales/en.json | 6 + .../components/recordings/RecordingRow.jsx | 20 +- .../components/rooms/room/join/JoinCard.jsx | 267 ++++++++++++++++++ .../components/rooms/room/join/RoomJoin.jsx | 242 +--------------- .../rooms/room/join/RoomJoinPlaceholder.jsx | 52 ++-- .../public_recordings/PublicRecordingRow.jsx | 98 +++++++ .../public_recordings/PublicRecordings.jsx | 34 +++ .../PublicRecordingsCard.jsx | 33 +++ .../PublicRecordingsList.jsx | 119 ++++++++ .../PublicRecordingsRowPlaceHolder.jsx | 45 +++ .../recordings/usePublicRecordings.jsx | 37 +++ app/javascript/main.jsx | 2 + 12 files changed, 688 insertions(+), 267 deletions(-) create mode 100644 app/javascript/components/rooms/room/join/JoinCard.jsx create mode 100644 app/javascript/components/rooms/room/public_recordings/PublicRecordingRow.jsx create mode 100644 app/javascript/components/rooms/room/public_recordings/PublicRecordings.jsx create mode 100644 app/javascript/components/rooms/room/public_recordings/PublicRecordingsCard.jsx create mode 100644 app/javascript/components/rooms/room/public_recordings/PublicRecordingsList.jsx create mode 100644 app/javascript/components/rooms/room/public_recordings/PublicRecordingsRowPlaceHolder.jsx create mode 100644 app/javascript/hooks/queries/recordings/usePublicRecordings.jsx diff --git a/app/assets/locales/en.json b/app/assets/locales/en.json index cb3e6c7f..da2e688e 100644 --- a/app/assets/locales/en.json +++ b/app/assets/locales/en.json @@ -23,6 +23,8 @@ "are_you_sure": "Are you sure?", "return_home": "Return Home", "created_at": "Created at", + "view_recordings": "View Recordings", + "join_session": "Join Session", "no_result_search_input": "Could not find any results for \"{{ searchInput }}\"", "action_permanent": "This action cannot be undone.", "homepage": { @@ -171,11 +173,15 @@ "published": "Published", "unpublished": "Unpublished", "protected": "Protected", + "public": "Public", + "public_protected": "Public/Protected", "length_in_minutes": "{{recording.length}} min.", "processing_recording": "Processing recording, this may take several minutes...", "copy_recording_urls": "Copy Recording Url(s)", "recordings_list_empty": "You don't have any recordings yet!", + "public_recordings_list_empty": "There's no shared recordings yet!", "recordings_list_empty_description": "Recordings will appear here after you start a meeting and record it.", + "public_recordings_list_empty_description": "Recordings will appear here after a mentor share it.", "delete_recording": "Delete Recording", "are_you_sure_delete_recording": "Are you sure you want to delete this recording?", "search_not_found": "No Recordings Found" diff --git a/app/javascript/components/recordings/RecordingRow.jsx b/app/javascript/components/recordings/RecordingRow.jsx index 576db2da..8fead84a 100644 --- a/app/javascript/components/recordings/RecordingRow.jsx +++ b/app/javascript/components/recordings/RecordingRow.jsx @@ -99,7 +99,7 @@ export default function RecordingRow({ </Stack> </Stack> </td> - <td className="border-0"> { t('recording.length_in_minutes', { recording }) } </td> + <td className="border-0"> {t('recording.length_in_minutes', { recording })} </td> <td className="border-0"> {recording.participants} </td> <td className="border-0"> {/* TODO: Refactor this. */} @@ -111,10 +111,16 @@ export default function RecordingRow({ defaultValue={recording.visibility} disabled={visibilityAPI.isLoading} > - <option value="Published">{ t('recording.published') }</option> - <option value="Unpublished">{ t('recording.unpublished') }</option> + <option value="Published">{t('recording.published')}</option> + <option value="Unpublished">{t('recording.unpublished')}</option> {recording?.protectable === true - && <option value="Protected">{ t('recording.protected') }</option>} + && ( + <> + <option value="Protected">{t('recording.protected')}</option> + <option value="Public/Protected">{t('recording.public_protected')}</option> + </> + )} + <option value="Public">{t('recording.public')}</option> </Form.Select> </td> <td className="border-0"> @@ -136,16 +142,16 @@ export default function RecordingRow({ <Dropdown.Menu> <Dropdown.Item onClick={() => copyRecordingUrl.mutate({ record_id: recording.record_id })}> <ClipboardDocumentIcon className="hi-s me-2" /> - { t('recording.copy_recording_urls') } + {t('recording.copy_recording_urls')} </Dropdown.Item> <Modal - modalButton={<Dropdown.Item><TrashIcon className="hi-s me-2" />{ t('delete') }</Dropdown.Item>} + modalButton={<Dropdown.Item><TrashIcon className="hi-s me-2" />{t('delete')}</Dropdown.Item>} body={( <DeleteRecordingForm mutation={useDeleteAPI} recordId={recording.record_id} /> - )} + )} /> </Dropdown.Menu> </Dropdown> diff --git a/app/javascript/components/rooms/room/join/JoinCard.jsx b/app/javascript/components/rooms/room/join/JoinCard.jsx new file mode 100644 index 00000000..8862a1b2 --- /dev/null +++ b/app/javascript/components/rooms/room/join/JoinCard.jsx @@ -0,0 +1,267 @@ +// BigBlueButton open source conferencing system - http://www.bigbluebutton.org/. +// +// Copyright (c) 2022 BigBlueButton Inc. and by respective authors (see below). +// +// This program is free software; you can redistribute it and/or modify it under the +// terms of the GNU Lesser General Public License as published by the Free Software +// Foundation; either version 3.0 of the License, or (at your option) any later +// version. +// +// Greenlight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +// PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License along +// with Greenlight; if not, see <http://www.gnu.org/licenses/>. + +/* eslint-disable consistent-return */ +import React, { useState, useEffect } from 'react'; +import Card from 'react-bootstrap/Card'; +import { + Navigate, Link, useParams, +} from 'react-router-dom'; +import { + Button, Col, Row, Stack, Form as RegularForm, +} from 'react-bootstrap'; +import { toast } from 'react-toastify'; +import { useTranslation } from 'react-i18next'; +import { VideoCameraIcon } from '@heroicons/react/24/outline'; +import usePublicRoom from '../../../../hooks/queries/rooms/usePublicRoom'; +import { useAuth } from '../../../../contexts/auth/AuthProvider'; +import useRoomStatus from '../../../../hooks/mutations/rooms/useRoomStatus'; +import useEnv from '../../../../hooks/queries/env/useEnv'; +import subscribeToRoom from '../../../../channels/rooms_channel'; +import RequireAuthentication from './RequireAuthentication'; +import GGSpinner from '../../../shared_components/utilities/GGSpinner'; +import Spinner from '../../../shared_components/utilities/Spinner'; +import Avatar from '../../../users/user/Avatar'; +import Form from '../../../shared_components/forms/Form'; +import FormControl from '../../../shared_components/forms/FormControl'; +import FormControlGeneric from '../../../shared_components/forms/FormControlGeneric'; +import RoomJoinPlaceholder from './RoomJoinPlaceholder'; +import useRoomJoinForm from '../../../../hooks/forms/rooms/useRoomJoinForm'; +import ButtonLink from '../../../shared_components/utilities/ButtonLink'; +import Title from '../../../shared_components/utilities/Title'; + +export default function JoinCard() { + const { t } = useTranslation(); + const currentUser = useAuth(); + const { friendlyId } = useParams(); + const [hasStarted, setHasStarted] = useState(false); + + const publicRoom = usePublicRoom(friendlyId); + const roomStatusAPI = useRoomStatus(friendlyId); + + const { data: env } = useEnv(); + + const { methods, fields } = useRoomJoinForm(); + + const path = encodeURIComponent(document.location.pathname); + + useEffect(() => { // set cookie to return to if needed + const date = new Date(); + date.setTime(date.getTime() + (60 * 1000)); // expire the cookie in 1min + document.cookie = `location=${path};path=/;expires=${date.toGMTString()}`; + + return () => { // delete redirect location when unmounting + document.cookie = `location=${path};path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT`; + }; + }, []); + + const handleJoin = (data) => { + document.cookie = 'location=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT'; // delete redirect location + + if (publicRoom?.data.viewer_access_code && !methods.getValues('access_code')) { + return methods.setError('access_code', { type: 'required', message: t('room.settings.access_code_required') }, { shouldFocus: true }); + } + + roomStatusAPI.mutate(data); + }; + const reset = () => { setHasStarted(false); };// Reset pipeline; + + useEffect(() => { + // Default Join name to authenticated user full name. + if (currentUser?.name) { + methods.setValue('name', currentUser.name); + } + }, [currentUser?.name]); + + useEffect(() => { + // Room channel subscription: + if (roomStatusAPI.isSuccess) { + // When the user provides valid input (name, codes) the UI will subscribe to the room channel. + const channel = subscribeToRoom(friendlyId, { onReceived: () => { setHasStarted(true); } }); + + // Cleanup: On component unmounting any opened channel subscriptions will be closed. + return () => { + channel.unsubscribe(); + console.info(`WS: unsubscribed from room(friendly_id): ${friendlyId} channel.`); + }; + } + }, [roomStatusAPI.isSuccess]); + + // Play a sound and displays a toast when the meeting starts if the user was in a waiting queue + const notifyMeetingStarted = () => { + const audio = new Audio(`${process.env.RELATIVE_URL_ROOT}/audios/notify.mp3`); + audio.play() + .catch((err) => { + console.error(err); + }); + toast.success(t('toast.success.room.meeting_started')); + }; + + // Returns a random delay between 2 and 5 seconds, in increments of 250 ms + // The delay is to let the BBB server settle before attempting to join the meeting + // The randomness is to prevent multiple users from joining the meeting at the same time + const joinDelay = () => { + const min = 4000; + const max = 7000; + const step = 250; + + // Calculate the number of possible steps within the given range + const numSteps = (max - min) / step; + + // Generate a random integer from 0 to numSteps (inclusive) + const randomStep = Math.floor(Math.random() * (numSteps + 1)); + + // Calculate and return the random delay + return min + (randomStep * step); + }; + + useEffect(() => { + // Meeting started: + // When meeting starts this logic will be fired, indicating the event to waiting users (through a toast) for UX matter. + // Logging the event for debugging purposes and refetching the join logic with the user's given input (name & codes). + if (hasStarted) { + console.info(`Attempting to join the room(friendly_id): ${friendlyId} meeting.`); + const delay = joinDelay(); + setTimeout(notifyMeetingStarted, delay - 1000); + setTimeout(methods.handleSubmit(handleJoin), delay); // TODO: Amir - Improve this race condition handling by the backend. + reset();// Resetting the Join component. + } + }, [hasStarted]); + + useEffect(() => { + // UI synchronization on failing join attempt: + // When the room status API returns an error indicating a failed join attempt it's highly due to stale credentials. + // In such case, users from a UX perspective will appreciate having the UI updated informing them about the case. + // i.e: Indicating the lack of providing access code value for cases where access code was generated while the user was waiting. + if (roomStatusAPI.isError) { + // Invalid Access Code SSE (Server Side Error): + if (roomStatusAPI.error.response.status === 403) { + methods.setError('access_code', { type: 'SSE', message: t('room.settings.wrong_access_code') }, { shouldFocus: true }); + } + + publicRoom.refetch();// Refetching room public information. + reset();// Resetting the Join component. + } + }, [roomStatusAPI.isError]); + + if (publicRoom.isLoading) return <RoomJoinPlaceholder />; + + if (!currentUser.signed_in && publicRoom.data.require_authentication === 'true') { + return <RequireAuthentication path={path} />; + } + + if (publicRoom.data.owner_id === currentUser?.id || publicRoom.data.shared_user_ids.includes(currentUser?.id)) { + return <Navigate to={`/rooms/${publicRoom.data.friendly_id}`} />; + } + + const hasAccessCode = publicRoom.data?.viewer_access_code || publicRoom.data?.moderator_access_code; + + if (publicRoom.data?.viewer_access_code || !publicRoom.data?.moderator_access_code) { + fields.accessCode.label = t('room.settings.access_code'); + // for the case where anyone_join_as_moderator is true and only the moderator access code is required + } else if (publicRoom.data?.anyone_join_as_moderator === 'true') { + fields.accessCode.label = t('room.settings.mod_access_code'); + } else { + fields.accessCode.label = t('room.settings.mod_access_code_optional'); + } + + const WaitingPage = ( + <Stack direction="horizontal" className="py-4"> + <div> + <h5>{t('room.meeting.meeting_not_started')}</h5> + <span className="text-muted">{t('room.meeting.join_meeting_automatically')}</span> + </div> + <div className="d-block ms-auto"> + <GGSpinner /> + </div> + </Stack> + ); + + return ( + <Card className="col-md-6 mx-auto p-0 border-0 card-shadow"> + <Title>{publicRoom?.data.name}</Title> + <Card.Body className="pt-4 px-5"> + <Row> + <Col className="col-xxl-8"> + <span className="text-muted">{t('room.meeting.meeting_invitation')}</span> + <h1 className="mt-2"> + {publicRoom?.data.name} + </h1> + <ButtonLink + variant="brand-outline" + className="mt-3 mb-0 cursor-pointer" + to={`/rooms/${friendlyId}/public_recordings`} + > + <span> <VideoCameraIcon className="hi-s text-brand" /> {t('view_recordings')} </span> + </ButtonLink> + </Col> + <Col> + <Stack direction="vertical" gap={3}> + <Avatar className="d-block ms-auto me-auto" avatar={publicRoom?.data.owner_avatar} size="medium" /> + <h5 className="text-center">{publicRoom?.data.owner_name}</h5> + </Stack> + </Col> + </Row> + </Card.Body> + <Card.Footer className="px-5 pb-3 bg-white border-2"> + <Row> + {(roomStatusAPI.isSuccess && !roomStatusAPI.data.status) ? WaitingPage : ( + <Form methods={methods} onSubmit={handleJoin}> + <FormControl field={fields.name} type="text" disabled={currentUser?.signed_in} autoFocus={!currentUser?.signed_in} /> + {hasAccessCode && <FormControl field={fields.accessCode} type="text" autoFocus={currentUser?.signed_in} />} + {publicRoom?.data?.recording_consent === 'true' && ( + <FormControlGeneric + id={fields.recordingConsent.controlId} + className="text-muted" + field={fields.recordingConsent} + label={fields.recordingConsent.label} + control={RegularForm.Check} + type="checkbox" + /> + )} + + <Button + variant="brand" + className="mt-3 d-block float-end" + type="submit" + disabled={publicRoom.isFetching || roomStatusAPI.isLoading} + > + {roomStatusAPI.isLoading && <Spinner className="me-2" />} + {t('room.meeting.join_meeting')} + </Button> + </Form> + )} + </Row> + <Row> + {!currentUser?.signed_in && ( + env?.OPENID_CONNECT ? ( + <Stack direction="horizontal" className="d-flex justify-content-center text-muted mt-3"> {t('authentication.already_have_account')} + <RegularForm action={process.env.OMNIAUTH_PATH} method="POST" data-turbo="false"> + <input type="hidden" name="authenticity_token" value={document.querySelector('meta[name="csrf-token"]').content} /> + <Button variant="link" className="btn-sm fs-6 cursor-pointer ms-2 ps-0" type="submit">{t('authentication.sign_in')}</Button> + </RegularForm> + </Stack> + ) : ( + <div className="text-center text-muted mt-3"> {t('authentication.already_have_account')} + <Link to={`/signin?location=${path}`} className="text-link ms-1"> {t('authentication.sign_in')} </Link> + </div> + ) + )} + </Row> + </Card.Footer> + </Card> + ); +} diff --git a/app/javascript/components/rooms/room/join/RoomJoin.jsx b/app/javascript/components/rooms/room/join/RoomJoin.jsx index 7e4b57da..8f64fb8c 100644 --- a/app/javascript/components/rooms/room/join/RoomJoin.jsx +++ b/app/javascript/components/rooms/room/join/RoomJoin.jsx @@ -15,246 +15,20 @@ // with Greenlight; if not, see <http://www.gnu.org/licenses/>. /* eslint-disable consistent-return */ -import React, { useState, useEffect } from 'react'; -import Card from 'react-bootstrap/Card'; -import { - Navigate, Link, useParams, -} from 'react-router-dom'; -import { - Button, Col, Row, Stack, Form as RegularForm, -} from 'react-bootstrap'; -import { toast } from 'react-toastify'; -import { useTranslation } from 'react-i18next'; -import usePublicRoom from '../../../../hooks/queries/rooms/usePublicRoom'; -import { useAuth } from '../../../../contexts/auth/AuthProvider'; -import useRoomStatus from '../../../../hooks/mutations/rooms/useRoomStatus'; -import useEnv from '../../../../hooks/queries/env/useEnv'; -import subscribeToRoom from '../../../../channels/rooms_channel'; -import RequireAuthentication from './RequireAuthentication'; -import GGSpinner from '../../../shared_components/utilities/GGSpinner'; -import Spinner from '../../../shared_components/utilities/Spinner'; +import React from 'react'; +import { Row } from 'react-bootstrap'; +import JoinCard from './JoinCard'; import Logo from '../../../shared_components/Logo'; -import Avatar from '../../../users/user/Avatar'; -import Form from '../../../shared_components/forms/Form'; -import FormControl from '../../../shared_components/forms/FormControl'; -import FormControlGeneric from '../../../shared_components/forms/FormControlGeneric'; -import RoomJoinPlaceholder from './RoomJoinPlaceholder'; -import useRoomJoinForm from '../../../../hooks/forms/rooms/useRoomJoinForm'; -import Title from '../../../shared_components/utilities/Title'; export default function RoomJoin() { - const { t } = useTranslation(); - const currentUser = useAuth(); - const { friendlyId } = useParams(); - const [hasStarted, setHasStarted] = useState(false); - - const publicRoom = usePublicRoom(friendlyId); - const roomStatusAPI = useRoomStatus(friendlyId); - - const { data: env } = useEnv(); - - const { methods, fields } = useRoomJoinForm(); - - const path = encodeURIComponent(document.location.pathname); - - useEffect(() => { // set cookie to return to if needed - const date = new Date(); - date.setTime(date.getTime() + (60 * 1000)); // expire the cookie in 1min - document.cookie = `location=${path};path=/;expires=${date.toGMTString()}`; - - return () => { // delete redirect location when unmounting - document.cookie = `location=${path};path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT`; - }; - }, []); - - const handleJoin = (data) => { - document.cookie = 'location=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT'; // delete redirect location - - if (publicRoom?.data.viewer_access_code && !methods.getValues('access_code')) { - return methods.setError('access_code', { type: 'required', message: t('room.settings.access_code_required') }, { shouldFocus: true }); - } - - roomStatusAPI.mutate(data); - }; - const reset = () => { setHasStarted(false); };// Reset pipeline; - - useEffect(() => { - // Default Join name to authenticated user full name. - if (currentUser?.name) { - methods.setValue('name', currentUser.name); - } - }, [currentUser?.name]); - - useEffect(() => { - // Room channel subscription: - if (roomStatusAPI.isSuccess) { - // When the user provides valid input (name, codes) the UI will subscribe to the room channel. - const channel = subscribeToRoom(friendlyId, { onReceived: () => { setHasStarted(true); } }); - - // Cleanup: On component unmounting any opened channel subscriptions will be closed. - return () => { - channel.unsubscribe(); - console.info(`WS: unsubscribed from room(friendly_id): ${friendlyId} channel.`); - }; - } - }, [roomStatusAPI.isSuccess]); - - // Play a sound and displays a toast when the meeting starts if the user was in a waiting queue - const notifyMeetingStarted = () => { - const audio = new Audio(`${process.env.RELATIVE_URL_ROOT}/audios/notify.mp3`); - audio.play() - .catch((err) => { - console.error(err); - }); - toast.success(t('toast.success.room.meeting_started')); - }; - - // Returns a random delay between 2 and 5 seconds, in increments of 250 ms - // The delay is to let the BBB server settle before attempting to join the meeting - // The randomness is to prevent multiple users from joining the meeting at the same time - const joinDelay = () => { - const min = 4000; - const max = 7000; - const step = 250; - - // Calculate the number of possible steps within the given range - const numSteps = (max - min) / step; - - // Generate a random integer from 0 to numSteps (inclusive) - const randomStep = Math.floor(Math.random() * (numSteps + 1)); - - // Calculate and return the random delay - return min + (randomStep * step); - }; - - useEffect(() => { - // Meeting started: - // When meeting starts this logic will be fired, indicating the event to waiting users (through a toast) for UX matter. - // Logging the event for debugging purposes and refetching the join logic with the user's given input (name & codes). - if (hasStarted) { - console.info(`Attempting to join the room(friendly_id): ${friendlyId} meeting.`); - const delay = joinDelay(); - setTimeout(notifyMeetingStarted, delay - 1000); - setTimeout(methods.handleSubmit(handleJoin), delay); // TODO: Amir - Improve this race condition handling by the backend. - reset();// Resetting the Join component. - } - }, [hasStarted]); - - useEffect(() => { - // UI synchronization on failing join attempt: - // When the room status API returns an error indicating a failed join attempt it's highly due to stale credentials. - // In such case, users from a UX perspective will appreciate having the UI updated informing them about the case. - // i.e: Indicating the lack of providing access code value for cases where access code was generated while the user was waiting. - if (roomStatusAPI.isError) { - // Invalid Access Code SSE (Server Side Error): - if (roomStatusAPI.error.response.status === 403) { - methods.setError('access_code', { type: 'SSE', message: t('room.settings.wrong_access_code') }, { shouldFocus: true }); - } - - publicRoom.refetch();// Refetching room public information. - reset();// Resetting the Join component. - } - }, [roomStatusAPI.isError]); - - if (publicRoom.isLoading) return <RoomJoinPlaceholder />; - - if (!currentUser.signed_in && publicRoom.data.require_authentication === 'true') { - return <RequireAuthentication path={path} />; - } - - if (publicRoom.data.owner_id === currentUser?.id || publicRoom.data.shared_user_ids.includes(currentUser?.id)) { - return <Navigate to={`/rooms/${publicRoom.data.friendly_id}`} />; - } - - const hasAccessCode = publicRoom.data?.viewer_access_code || publicRoom.data?.moderator_access_code; - - if (publicRoom.data?.viewer_access_code || !publicRoom.data?.moderator_access_code) { - fields.accessCode.label = t('room.settings.access_code'); - // for the case where anyone_join_as_moderator is true and only the moderator access code is required - } else if (publicRoom.data?.anyone_join_as_moderator === 'true') { - fields.accessCode.label = t('room.settings.mod_access_code'); - } else { - fields.accessCode.label = t('room.settings.mod_access_code_optional'); - } - - const WaitingPage = ( - <Stack direction="horizontal" className="py-4"> - <div> - <h5>{t('room.meeting.meeting_not_started')}</h5> - <span className="text-muted">{t('room.meeting.join_meeting_automatically')}</span> - </div> - <div className="d-block ms-auto"> - <GGSpinner /> - </div> - </Stack> - ); - return ( <div className="vertical-center"> - <Title>{publicRoom?.data.name}</Title> - <div className="text-center pb-4"> + <Row className="text-center pb-4"> <Logo /> - </div> - <Card className="col-md-6 mx-auto p-0 border-0 card-shadow"> - <Card.Body className="pt-4 px-5"> - <Row> - <Col className="col-xxl-8"> - <span className="text-muted">{t('room.meeting.meeting_invitation')}</span> - <h1 className="mt-2"> - {publicRoom?.data.name} - </h1> - </Col> - <Col> - <Stack direction="vertical" gap={3}> - <Avatar className="d-block ms-auto me-auto" avatar={publicRoom?.data.owner_avatar} size="medium" /> - <h5 className="text-center">{publicRoom?.data.owner_name}</h5> - </Stack> - </Col> - </Row> - </Card.Body> - <Card.Footer className="px-5 pb-3 bg-white border-2"> - {(roomStatusAPI.isSuccess && !roomStatusAPI.data.status) ? WaitingPage : ( - <Form methods={methods} onSubmit={handleJoin}> - <FormControl field={fields.name} type="text" disabled={currentUser?.signed_in} autoFocus={!currentUser?.signed_in} /> - {hasAccessCode && <FormControl field={fields.accessCode} type="text" autoFocus={currentUser?.signed_in} />} - {publicRoom?.data?.recording_consent === 'true' && ( - <FormControlGeneric - id={fields.recordingConsent.controlId} - className="text-muted" - field={fields.recordingConsent} - label={fields.recordingConsent.label} - control={RegularForm.Check} - type="checkbox" - /> - )} - - <Button - variant="brand" - className="mt-3 d-block float-end" - type="submit" - disabled={publicRoom.isFetching || roomStatusAPI.isLoading} - > - {roomStatusAPI.isLoading && <Spinner className="me-2" />} - {t('room.meeting.join_meeting')} - </Button> - </Form> - )} - </Card.Footer> - </Card> - {!currentUser?.signed_in && ( - env?.OPENID_CONNECT ? ( - <Stack direction="horizontal" className="d-flex justify-content-center text-muted mt-3"> {t('authentication.already_have_account')} - <RegularForm action={process.env.OMNIAUTH_PATH} method="POST" data-turbo="false"> - <input type="hidden" name="authenticity_token" value={document.querySelector('meta[name="csrf-token"]').content} /> - <Button variant="link" className="btn-sm fs-6 cursor-pointer ms-2 ps-0" type="submit">{t('authentication.sign_in')}</Button> - </RegularForm> - </Stack> - ) : ( - <div className="text-center text-muted mt-3"> {t('authentication.already_have_account')} - <Link to={`/signin?location=${path}`} className="text-link ms-1"> {t('authentication.sign_in')} </Link> - </div> - ) - )} + </Row> + <Row> + <JoinCard /> + </Row> </div> ); } diff --git a/app/javascript/components/rooms/room/join/RoomJoinPlaceholder.jsx b/app/javascript/components/rooms/room/join/RoomJoinPlaceholder.jsx index 78461d91..d31146dd 100644 --- a/app/javascript/components/rooms/room/join/RoomJoinPlaceholder.jsx +++ b/app/javascript/components/rooms/room/join/RoomJoinPlaceholder.jsx @@ -20,35 +20,32 @@ import Card from 'react-bootstrap/Card'; import { Button, Col, Row, Stack, } from 'react-bootstrap'; -import Logo from '../../../shared_components/Logo'; import Placeholder from '../../../shared_components/utilities/Placeholder'; import RoundPlaceholder from '../../../shared_components/utilities/RoundPlaceholder'; +import Form from '../../../shared_components/forms/Form'; export default function RoomJoinPlaceholder() { const { t } = useTranslation(); return ( - <div className="vertical-center"> - <div className="text-center pb-4"> - <Logo /> - </div> - <Card className="col-md-6 mx-auto p-0 border-0 card-shadow"> - <Card.Body className="pt-4 px-5"> - <Row> - <Col className="col-xxl-8"> - <Placeholder width={6} size="md" className="mt-1" /> - <Placeholder width={12} size="lg" /> - </Col> - <Col> - <Stack direction="vertical" gap={3}> - <RoundPlaceholder size="medium" className="d-block mx-auto" /> - <Placeholder width={10} size="md" className="d-block mx-auto" /> - </Stack> - </Col> - </Row> - </Card.Body> - <Card.Footer className="px-5 pb-3 bg-white border-2"> - <div className="mt-4"> + <Card className="col-md-6 mx-auto p-0 border-0 card-shadow"> + <Card.Body className="pt-4 px-5"> + <Row> + <Col className="col-xxl-8"> + <Placeholder width={6} size="md" className="mt-1" /> + <Placeholder width={12} size="lg" /> + </Col> + <Col> + <Stack direction="vertical" gap={3}> + <RoundPlaceholder size="medium" className="d-block mx-auto" /> + <Placeholder width={10} size="md" className="d-block mx-auto" /> + </Stack> + </Col> + </Row> + </Card.Body> + <Card.Footer className="px-5 pb-3 bg-white border-2 text-center"> + <Row className="my-4"> + <Form> <Placeholder width={12} size="lg" /> <Button variant="brand" @@ -57,9 +54,12 @@ export default function RoomJoinPlaceholder() { > {t('room.meeting.join_meeting')} </Button> - </div> - </Card.Footer> - </Card> - </div> + </Form> + </Row> + <Row> + <Placeholder width={6} size="md" /> + </Row> + </Card.Footer> + </Card> ); } diff --git a/app/javascript/components/rooms/room/public_recordings/PublicRecordingRow.jsx b/app/javascript/components/rooms/room/public_recordings/PublicRecordingRow.jsx new file mode 100644 index 00000000..8850e613 --- /dev/null +++ b/app/javascript/components/rooms/room/public_recordings/PublicRecordingRow.jsx @@ -0,0 +1,98 @@ +// BigBlueButton open source conferencing system - http://www.bigbluebutton.org/. +// +// Copyright (c) 2022 BigBlueButton Inc. and by respective authors (see below). +// +// This program is free software; you can redistribute it and/or modify it under the +// terms of the GNU Lesser General Public License as published by the Free Software +// Foundation; either version 3.0 of the License, or (at your option) any later +// version. +// +// Greenlight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +// PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License along +// with Greenlight; if not, see <http://www.gnu.org/licenses/>. + +import React from 'react'; +import { + VideoCameraIcon, ClipboardDocumentIcon, +} from '@heroicons/react/24/outline'; +import PropTypes from 'prop-types'; +import { + Button, Stack, +} from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; +import { useAuth } from '../../../../contexts/auth/AuthProvider'; +import { localizeDateTimeString } from '../../../../helpers/DateTimeHelper'; +import useRedirectRecordingUrl from '../../../../hooks/mutations/recordings/useRedirectRecordingUrl'; +import useCopyRecordingUrl from '../../../../hooks/mutations/recordings/useCopyRecordingUrl'; + +// TODO: Amir - Refactor this. +export default function PublicRecordingRow({ + recording, +}) { + const { t } = useTranslation(); + + const currentUser = useAuth(); + const redirectRecordingUrl = useRedirectRecordingUrl(); + const copyRecordingUrl = useCopyRecordingUrl(); + + const localizedTime = localizeDateTimeString(recording?.recorded_at, currentUser?.language); + const formats = recording.formats.sort( + (a, b) => (a.recording_type.toLowerCase() > b.recording_type.toLowerCase() ? 1 : -1), + ); + + return ( + <tr key={recording.id} className="align-middle text-muted border border-2"> + <td className="border-end-0 text-dark"> + <Stack direction="horizontal" className="py-2"> + <div className="recording-icon-circle rounded-circle me-3 d-flex justify-content-center"> + <VideoCameraIcon className="hi-s text-brand" /> + </div> + <Stack> + <strong> {recording.name} </strong> + <span className="small text-muted"> {localizedTime} </span> + </Stack> + </Stack> + </td> + <td className="border-0"> {t('recording.length_in_minutes', { recording })} </td> + <td className="border-0"> + {formats.map((format) => ( + <Button + onClick={() => redirectRecordingUrl.mutate({ record_id: recording.record_id, format: format.recording_type })} + className={`btn-sm rounded-pill me-1 mt-1 border-0 btn-format-${format.recording_type.toLowerCase()}`} + key={`${format.recording_type}-${recording.record_id}`} + > + {format.recording_type} + </Button> + ))} + </td> + <td className="border-start-0"> + <Stack direction="horizontal" className="float-end recordings-icons"> + <Button + variant="icon" + className="mt-1 me-3" + onClick={() => copyRecordingUrl.mutate({ record_id: recording.record_id })} + > + <ClipboardDocumentIcon className="hi-s text-muted" /> + </Button> + </Stack> + </td> + </tr> + ); +} + +PublicRecordingRow.propTypes = { + recording: PropTypes.shape({ + id: PropTypes.string.isRequired, + record_id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + length: PropTypes.number.isRequired, + formats: PropTypes.arrayOf(PropTypes.shape({ + url: PropTypes.string.isRequired, + recording_type: PropTypes.string.isRequired, + })), + recorded_at: PropTypes.string.isRequired, + }).isRequired, +}; diff --git a/app/javascript/components/rooms/room/public_recordings/PublicRecordings.jsx b/app/javascript/components/rooms/room/public_recordings/PublicRecordings.jsx new file mode 100644 index 00000000..922a1426 --- /dev/null +++ b/app/javascript/components/rooms/room/public_recordings/PublicRecordings.jsx @@ -0,0 +1,34 @@ +// BigBlueButton open source conferencing system - http://www.bigbluebutton.org/. +// +// Copyright (c) 2022 BigBlueButton Inc. and by respective authors (see below). +// +// This program is free software; you can redistribute it and/or modify it under the +// terms of the GNU Lesser General Public License as published by the Free Software +// Foundation; either version 3.0 of the License, or (at your option) any later +// version. +// +// Greenlight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +// PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License along +// with Greenlight; if not, see <http://www.gnu.org/licenses/>. + +/* eslint-disable consistent-return */ +import React from 'react'; +import { Row } from 'react-bootstrap'; +import Logo from '../../../shared_components/Logo'; +import PublicRecordingsCard from './PublicRecordingsCard'; + +export default function RoomJoin() { + return ( + <div className="vertical-center"> + <Row className="text-center pb-4"> + <Logo /> + </Row> + <Row> + <PublicRecordingsCard /> + </Row> + </div> + ); +} diff --git a/app/javascript/components/rooms/room/public_recordings/PublicRecordingsCard.jsx b/app/javascript/components/rooms/room/public_recordings/PublicRecordingsCard.jsx new file mode 100644 index 00000000..ea570c0e --- /dev/null +++ b/app/javascript/components/rooms/room/public_recordings/PublicRecordingsCard.jsx @@ -0,0 +1,33 @@ +// BigBlueButton open source conferencing system - http://www.bigbluebutton.org/. +// +// Copyright (c) 2022 BigBlueButton Inc. and by respective authors (see below). +// +// This program is free software; you can redistribute it and/or modify it under the +// terms of the GNU Lesser General Public License as published by the Free Software +// Foundation; either version 3.0 of the License, or (at your option) any later +// version. +// +// Greenlight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +// PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License along +// with Greenlight; if not, see <http://www.gnu.org/licenses/>. + +/* eslint-disable consistent-return */ +import React from 'react'; +import Card from 'react-bootstrap/Card'; +import { useParams } from 'react-router-dom'; +import PublicRecordingsList from './PublicRecordingsList'; + +export default function PublicRecordingsCard() { + const { friendlyId } = useParams(); + + return ( + <Card className="mx-auto p-0 border-0 card-shadow"> + <Card.Body className="pt-4 px-5"> + <PublicRecordingsList friendlyId={friendlyId} /> + </Card.Body> + </Card> + ); +} diff --git a/app/javascript/components/rooms/room/public_recordings/PublicRecordingsList.jsx b/app/javascript/components/rooms/room/public_recordings/PublicRecordingsList.jsx new file mode 100644 index 00000000..11fd08d8 --- /dev/null +++ b/app/javascript/components/rooms/room/public_recordings/PublicRecordingsList.jsx @@ -0,0 +1,119 @@ +// BigBlueButton open source conferencing system - http://www.bigbluebutton.org/. +// +// Copyright (c) 2022 BigBlueButton Inc. and by respective authors (see below). +// +// This program is free software; you can redistribute it and/or modify it under the +// terms of the GNU Lesser General Public License as published by the Free Software +// Foundation; either version 3.0 of the License, or (at your option) any later +// version. +// +// Greenlight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +// PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License along +// with Greenlight; if not, see <http://www.gnu.org/licenses/>. + +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { VideoCameraIcon } from '@heroicons/react/24/outline'; +import { Card, Stack, Table } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; +import SortBy from '../../../shared_components/search/SortBy'; +import NoSearchResults from '../../../shared_components/search/NoSearchResults'; +import Pagination from '../../../shared_components/Pagination'; +import SearchBar from '../../../shared_components/search/SearchBar'; +import usePublicRecordings from '../../../../hooks/queries/recordings/usePublicRecordings'; +import PublicRecordingRow from './PublicRecordingRow'; +import PublicRecordingsRowPlaceHolder from './PublicRecordingsRowPlaceHolder'; +import ButtonLink from '../../../shared_components/utilities/ButtonLink'; +import UserBoardIcon from '../../UserBoardIcon'; + +export default function PublicRecordingsList({ friendlyId }) { + const { t } = useTranslation(); + const [page, setPage] = useState(1); + const [searchInput, setSearchInput] = useState(''); + const { data: recordings, ...publicRecordingsAPI } = usePublicRecordings({ friendlyId, page, search: searchInput }); + + if (!publicRecordingsAPI.isLoading && recordings?.data?.length === 0 && !searchInput) { + return ( + <div className="text-center my-4"> + <div className="icon-circle rounded-circle d-block mx-auto mb-3"> + <VideoCameraIcon className="hi-l pt-4 text-brand d-block mx-auto" /> + </div> + <h2 className="text-brand"> {t('recording.public_recordings_list_empty')}</h2> + <p> + {t('recording.public_recordings_list_empty_description')} + </p> + </div> + ); + } + + return ( + <> + <Stack direction="horizontal" className="w-100"> + <div> + <SearchBar searchInput={searchInput} setSearchInput={setSearchInput} /> + </div> + <ButtonLink + variant="brand-outline" + className="ms-auto my-0 py-2" + to={`/rooms/${friendlyId}/join`} + > + <span> <UserBoardIcon className="hi-s text-brand cursor-pointer" /> {t('join_session')} </span> + </ButtonLink> + </Stack> + { + (searchInput && recordings?.data.length === 0) + ? ( + <div className="mt-5"> + <NoSearchResults text={t('recording.search_not_found')} searchInput={searchInput} /> + </div> + ) : ( + <Card className="border-0 card-shadow p-0 mt-4 mb-5"> + <Table id="recordings-table" className="table-bordered border border-2 mb-0 recordings-list" hover responsive> + <thead> + <tr className="text-muted small"> + <th className="fw-normal border-end-0">{t('recording.name')}<SortBy fieldName="name" /></th> + <th className="fw-normal border-0">{t('recording.length')}<SortBy fieldName="length" /></th> + <th className="fw-normal border-0">{t('recording.formats')}</th> + </tr> + </thead> + <tbody className="border-top-0"> + { + (publicRecordingsAPI.isLoading && [...Array(7)].map((val, idx) => ( + // eslint-disable-next-line react/no-array-index-key + <PublicRecordingsRowPlaceHolder key={idx} /> + ))) + } + { + (recordings?.data?.length > 0 && recordings?.data?.map((recording) => ( + <PublicRecordingRow key={recording.id} recording={recording} /> + ))) + } + </tbody> + {(recordings?.meta?.pages > 1) + && ( + <tfoot> + <tr> + <td colSpan={12}> + <Pagination + page={recordings?.meta?.page} + totalPages={recordings?.meta?.pages} + setPage={setPage} + /> + </td> + </tr> + </tfoot> + )} + </Table> + </Card> + ) + } + </> + ); +} + +PublicRecordingsList.propTypes = { + friendlyId: PropTypes.string.isRequired, +}; diff --git a/app/javascript/components/rooms/room/public_recordings/PublicRecordingsRowPlaceHolder.jsx b/app/javascript/components/rooms/room/public_recordings/PublicRecordingsRowPlaceHolder.jsx new file mode 100644 index 00000000..ef790aad --- /dev/null +++ b/app/javascript/components/rooms/room/public_recordings/PublicRecordingsRowPlaceHolder.jsx @@ -0,0 +1,45 @@ +// BigBlueButton open source conferencing system - http://www.bigbluebutton.org/. +// +// Copyright (c) 2022 BigBlueButton Inc. and by respective authors (see below). +// +// This program is free software; you can redistribute it and/or modify it under the +// terms of the GNU Lesser General Public License as published by the Free Software +// Foundation; either version 3.0 of the License, or (at your option) any later +// version. +// +// Greenlight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +// PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License along +// with Greenlight; if not, see <http://www.gnu.org/licenses/>. + +import React from 'react'; +import { Stack } from 'react-bootstrap'; +import Placeholder from '../../../shared_components/utilities/Placeholder'; +import RoundPlaceholder from '../../../shared_components/utilities/RoundPlaceholder'; + +export default function PublicRecordingsRowPlaceHolder() { + return ( + <tr> + {/* Avatar and Name */} + <td className="border-0 pt-2 lg-td-placeholder"> + <Stack direction="horizontal"> + <RoundPlaceholder size="small" className="ms-1 me-3" /> + <Stack> + <Placeholder width={10} size="lg" /> + <Placeholder width={10} size="md" /> + </Stack> + </Stack> + </td> + {/* Length */} + <td className="border-0 pt-3 xs-td-placeholder"> + <Placeholder width={8} size="md" /> + </td> + {/* Formats */} + <td className="border-0 pt-3 md-td-placeholder"> + <Placeholder width={10} size="md" /> + </td> + </tr> + ); +} diff --git a/app/javascript/hooks/queries/recordings/usePublicRecordings.jsx b/app/javascript/hooks/queries/recordings/usePublicRecordings.jsx new file mode 100644 index 00000000..a8e93900 --- /dev/null +++ b/app/javascript/hooks/queries/recordings/usePublicRecordings.jsx @@ -0,0 +1,37 @@ +// BigBlueButton open source conferencing system - http://www.bigbluebutton.org/. +// +// Copyright (c) 2022 BigBlueButton Inc. and by respective authors (see below). +// +// This program is free software; you can redistribute it and/or modify it under the +// terms of the GNU Lesser General Public License as published by the Free Software +// Foundation; either version 3.0 of the License, or (at your option) any later +// version. +// +// Greenlight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +// PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License along +// with Greenlight; if not, see <http://www.gnu.org/licenses/>. + +import { useQuery } from 'react-query'; +import { useSearchParams } from 'react-router-dom'; +import axios from '../../../helpers/Axios'; + +export default function usePublicRecordings({ friendlyId, search, page }) { + const [searchParams] = useSearchParams(); + const params = { + 'sort[column]': searchParams.get('sort[column]'), + 'sort[direction]': searchParams.get('sort[direction]'), + search, + page, + }; + + return useQuery( + ['getPublicRecordings', { ...params }], + () => axios.get(`/rooms/${friendlyId}/public_recordings.json`, { params }).then((resp) => resp.data), + { + keepPreviousData: true, + }, + ); +} diff --git a/app/javascript/main.jsx b/app/javascript/main.jsx index e3c39ea5..aa132f73 100644 --- a/app/javascript/main.jsx +++ b/app/javascript/main.jsx @@ -51,6 +51,7 @@ import PendingRegistration from './components/users/registration/PendingRegistra import RootBoundary from './RootBoundary'; import Tenants from './components/admin/tenants/Tenants'; import RoomIdRouter from './routes/RoomIdRouter'; +import PublicRecordings from './components/rooms/room/public_recordings/PublicRecordings'; const queryClientConfig = { defaultOptions: { @@ -101,6 +102,7 @@ const router = createBrowserRouter( </Route> <Route path="/rooms/:friendlyId/join" element={<RoomJoin />} /> + <Route path="/rooms/:friendlyId/public_recordings" element={<PublicRecordings />} /> <Route path="/:roomId" element={<RoomIdRouter />} /> </Route>, ), -- GitLab