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