Repository: muxinc/meet Branch: main Commit: 51eb0fbf9f79 Files: 82 Total size: 155.8 KB Directory structure: gitextract_6okmy78h/ ├── .eslintrc.json ├── .gitignore ├── .husky/ │ └── pre-commit ├── .prettierignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── client/ │ └── token.ts ├── components/ │ ├── Controls.tsx │ ├── Gallery.tsx │ ├── GalleryLayout.tsx │ ├── Header.tsx │ ├── Meeting.tsx │ ├── MuteIndicator.tsx │ ├── Notifications.tsx │ ├── Participant.tsx │ ├── ParticipantAudio.tsx │ ├── ParticipantInfoBar.tsx │ ├── ParticipantName.tsx │ ├── Pin.tsx │ ├── Sounds.tsx │ ├── Stage.tsx │ ├── Timer.tsx │ ├── Toasts.tsx │ ├── UserInteractionPrompt.tsx │ ├── controls/ │ │ ├── ControlsCenter.tsx │ │ ├── ControlsLeft.tsx │ │ ├── ControlsRight.tsx │ │ └── buttons/ │ │ ├── CameraButton.tsx │ │ ├── ChatButton.tsx │ │ ├── MicrophoneButton.tsx │ │ ├── ScreenShareButton.tsx │ │ ├── SendButton.tsx │ │ └── SettingsButton.tsx │ ├── icons/ │ │ ├── ChatIcon.tsx │ │ ├── ChevronIcon.tsx │ │ ├── ChevronLeftIcon.tsx │ │ ├── ChevronRightIcon.tsx │ │ ├── LeaveIcon.tsx │ │ ├── MuteCameraIcon.tsx │ │ ├── MuteMicrophoneIcon.tsx │ │ ├── ScreenShareIcon.tsx │ │ ├── SendIcon.tsx │ │ ├── SettingsIcon.tsx │ │ ├── UnmuteCameraIcon.tsx │ │ └── UnmuteMicrophoneIcon.tsx │ ├── modals/ │ │ ├── ACRScoreDialog.tsx │ │ ├── ErrorModal.tsx │ │ └── RenameParticipantModal.tsx │ └── renderers/ │ ├── AudioRenderer.tsx │ ├── ChatRenderer.tsx │ ├── ScreenShareRenderer.tsx │ └── VideoRenderer.tsx ├── context/ │ ├── Chat.tsx │ ├── Space.tsx │ ├── User.tsx │ └── UserMedia.tsx ├── hooks/ │ ├── useLocalStorage.tsx │ ├── useParticipant.ts │ ├── useSpace.ts │ ├── useSpaceEvent.tsx │ ├── useUserMedia.ts │ └── useWindowDimension.ts ├── lib/ │ ├── constants.ts │ ├── gallery.ts │ ├── theme.ts │ └── utils.ts ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages/ │ ├── _app.tsx │ ├── _document.tsx │ ├── api/ │ │ ├── spaces/ │ │ │ └── [id].ts │ │ ├── spaces.ts │ │ ├── token.ts │ │ └── webhooks.ts │ ├── index.tsx │ └── space/ │ └── [id].tsx ├── server-lib/ │ └── services.ts ├── shared/ │ ├── defaults.tsx │ └── toastConfigs.tsx └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintrc.json ================================================ { "extends": ["next/core-web-vitals", "prettier"] } ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* # local env files .env .env.local .env.development.local .env.test.local .env.production.local # vercel .vercel # typescript *.tsbuildinfo # VScode .vscode ================================================ FILE: .husky/pre-commit ================================================ #!/bin/sh . "$(dirname "$0")/_/husky.sh" npx lint-staged ================================================ FILE: .prettierignore ================================================ # dependencies /node_modules /.pnp .pnp.js # testing /coverage # next.js /.next/ /out/ # production /build # debug npm-debug.log* yarn-debug.log* yarn-error.log* # typescript *.tsbuildinfo ================================================ FILE: .prettierrc.json ================================================ {} ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2023 Mux Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Mux Meet > [!WARNING] > Mux Real-Time Video has been sunset and is unavailable for new usage. Existing access will end on December 31, 2023. [We recommend migrating your application to our partner, LiveKit](https://livekit.io/mux-livekit). Please contact [Mux Support](https://mux.com/support) if you need more help or details. Mux Meet is no-longer maintained and the repository will soon be archived. Mux Meet is a video conferencing app powered by [Mux Real-Time Video](https://mux.com/real-time-video), written in React, using the [Next.js](https://nextjs.org/) framework. ![Four users in a Mux Meet call](https://user-images.githubusercontent.com/1211390/216212346-b319d137-0d2e-405a-bbab-703cc32763b3.jpg) # Getting Started In order for Meet to connect to Mux's APIs, an access token and signing key must be provided. These are generated in the Mux Dashboard and should be set as environment variables. The easiest way to use Mux Meet is to deploy it to Vercel. [![Deploy with Vercel](https://vercel.com/button)]() After creating your project, you will be prompted to configure it. ![Configure Vercel Environment Variables](https://user-images.githubusercontent.com/1211390/216212169-251d87ef-83ae-4b9b-82e8-ae42cb430b02.jpg) In a separate window, open https://dashboard.mux.com and sign in. You will need to create an account, if you don't already have one. From the [Mux Dashboard](https://dashboard.mux.com), open Settings from the bottom left and click Access Tokens. Then click "Generate new token" and select Mux Video from the list of permissions. Optionally, give the access token a name like Mux Meet. ![Create new Mux Access Token](https://user-images.githubusercontent.com/1211390/216212226-d98b377b-7105-4db7-89f7-8b3f6aadd805.jpg) Once your token is generated, copy and paste the ID and Secret as the values for `MUX_TOKEN_ID` and `MUX_TOKEN_SECRET` in Vercel. Now go back to https://dashboard.mux.com to generate the Signing Key. From the [Mux Dashboard](https://dashboard.mux.com), open Settings from the bottom left and click Signing Keys. Then click "Generate new key" and make sure you use the same environment as you did for the Access Token. The Product selection should default to Video. ![Create new Mux Signing Key](https://user-images.githubusercontent.com/1211390/216212263-f8fe2d0a-e8f4-4ba6-8197-bbbe745c9cb1.jpg) Once your key is generated, copy and paste the ID and Secret as the values for `MUX_SIGNING_KEY` and `MUX_PRIVATE_KEY` in Vercel. _Both the Access Token and Signing Key are sensitive. DO NOT MAKE THEM PUBLIC. It is safe to store them in Vercel as Environment Variables or locally during development._ Once all 4 environment variables are filled in. Click Deploy for Vercel to build and start your app. ## Cleanup Spaces after Meeting Joining a new Space creates a Space in Mux, but in order to clean up Spaces after a meeting is over, set up a simple [webhook from Mux](https://docs.mux.com/guides/video/listen-for-webhooks). From the [Mux Dashboard](https://dashboard.mux.com), open Settings from the bottom left and click Webhooks. Then click "Generate new webhook" and make sure you use the same environment as you did for the Access Token. The URL to notify will be your app's URL + `/api/webhooks`. ![Create new Mux webhook](https://user-images.githubusercontent.com/1211390/216212296-93dad4a3-1b91-4402-8eed-c0325dea0d69.jpg) Now generate the Webhook and copy the Signing Secret by clicking Show Signing Secret. Configure your deployed app with a new environment variable named `WEBHOOK_SECRET` with the value of the Webhook Signing Secret. _Make sure you redeploy for your new environment variable to take affect._ ## Limit Access To limit the number of active Spaces, set an integer value for an environment variable `ACTIVE_SPACE_LIMIT`. To limit the amount of time participants are allowed to spend in a temporary Space, set an integer value in seconds for an environment variable `SPACE_DURATION_SECONDS`. # Develop locally Create an .env.local file at the root of the repo with the following secrets: ```bash MUX_TOKEN_ID= MUX_TOKEN_SECRET= MUX_SIGNING_KEY= MUX_PRIVATE_KEY= ``` ```bash npm run dev ``` Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. # Learn More ## Mux - [Mux Real-Time Video](https://mux.com/real-time-video) - [Real-Time Video in React](https://docs.mux.com/guides/video/send-and-receive-real-time-video-from-a-react-application) - [Real-Time Video on Android](https://docs.mux.com/guides/video/send-and-receive-real-time-video-from-an-android-application) - [Real-Time Video on iOS](https://docs.mux.com/guides/video/send-and-receive-real-time-video-from-an-ios-application) ## Next.js - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. ================================================ FILE: client/token.ts ================================================ import axios from "axios"; interface TokenParams { spaceId: string; participantId: string; } interface TokenResponse { spaceJWT: string; } export const tokenPOST = async ( params: TokenParams ): Promise => { return axios.post(`/api/token`, params).then(({ data }) => data); }; ================================================ FILE: components/Controls.tsx ================================================ import React, { useCallback } from "react"; import { useRouter } from "next/router"; import { Flex, useDisclosure } from "@chakra-ui/react"; import { useSpace } from "hooks/useSpace"; import ControlsRight from "./controls/ControlsRight"; import ControlsLeft from "./controls/ControlsLeft"; import ControlsCenter from "./controls/ControlsCenter"; import ACRScoreDialog from "./modals/ACRScoreDialog"; export default function Controls(): JSX.Element { const router = useRouter(); const { leaveSpace } = useSpace(); const { isOpen: isACRScoreDialogOpen, onOpen: onACRScoreDialogOpen } = useDisclosure(); const leaveSpacePage = useCallback(() => { router.push("/"); }, [router]); const promptForACR = useCallback(() => { try { leaveSpace(); onACRScoreDialogOpen(); } catch (error) { console.error(`Unable to properly leave space: ${error}`); leaveSpacePage(); } }, [leaveSpace, leaveSpacePage, onACRScoreDialogOpen]); return ( <> {!isACRScoreDialogOpen && ( <> )} ); } ================================================ FILE: components/Gallery.tsx ================================================ import React, { useCallback, useMemo, useState } from "react"; import { IconButton, Center, Flex } from "@chakra-ui/react"; import UserContext from "context/User"; import { useSpace } from "hooks/useSpace"; import Participant from "./Participant"; import GalleryLayout from "./GalleryLayout"; import ParticipantAudio from "./ParticipantAudio"; import ChevronLeftIcon from "components/icons/ChevronLeftIcon"; import ChevronRightIcon from "components/icons/ChevronRightIcon"; function pushToFront(array: T[], element: T) { const index = array.findIndex((el) => el === element); if (index) { array.splice(index, 1); array.unshift(element); } } interface Props { gap: number; width: number; height: number; participantsPerPage: number; } export default function Gallery({ gap, width, height, participantsPerPage, }: Props): JSX.Element { const [currentPage, setCurrentPage] = useState(1); const { connectionIds, participantCount, localParticipantConnectionId, screenShareParticipantConnectionId, } = useSpace(); const { pinnedConnectionId } = React.useContext(UserContext); const orderedConnectionIds = useMemo(() => { const ids = [...connectionIds]; [ screenShareParticipantConnectionId, pinnedConnectionId, localParticipantConnectionId, ].forEach((id) => { if (id) { pushToFront(ids, id); } }); return ids; }, [ connectionIds, localParticipantConnectionId, pinnedConnectionId, screenShareParticipantConnectionId, ]); const numberPages = useMemo(() => { if (participantCount >= participantsPerPage) { return Math.ceil(participantCount / participantsPerPage); } else { return 1; } }, [participantCount, participantsPerPage]); const goToPreviousPage = useCallback(() => { if (currentPage > 1) { setCurrentPage((page) => page - 1); } }, [currentPage]); const paginatedConnectionIds = useMemo(() => { const startIndex = currentPage * participantsPerPage - participantsPerPage; const endIndex = startIndex + participantsPerPage; const pageParticipants = orderedConnectionIds.slice(startIndex, endIndex); // if there are no participants, then only the local view will show up on the page // we need to go back to the previous page. if (pageParticipants.length === 0) { goToPreviousPage(); } return pageParticipants; }, [ orderedConnectionIds, currentPage, participantsPerPage, goToPreviousPage, ]); const hidePaginateCtrlRight = currentPage === numberPages; const hidePaginateCtrlLeft = currentPage === 1; const goToNextPage = () => { if (currentPage < numberPages) { setCurrentPage((page) => page + 1); } }; const widthBetweenPagination = numberPages === 1 ? width : width - 80; return (
} isRound={true} onClick={goToPreviousPage} opacity={numberPages === 1 ? 0 : 1} hidden={hidePaginateCtrlLeft} variant="outline" border="1px" borderColor="#666666" backgroundColor="#383838" _hover={{ border: "1px solid #CCCCCC", backgroundColor: "#383838", }} _active={{ border: "1px solid #CCCCCC", backgroundColor: "#444444", }} />
{connectionIds.map((connectionId) => ( ))} {paginatedConnectionIds.map((connectionId) => { return ( ); })}
} isRound={true} opacity={numberPages === 1 ? 0 : 1} hidden={hidePaginateCtrlRight} variant="outline" border="1px" borderColor="#666666" onClick={goToNextPage} backgroundColor="#383838" _hover={{ border: "1px solid #CCCCCC", backgroundColor: "#383838", }} _active={{ border: "1px solid #CCCCCC", backgroundColor: "#444444", }} zIndex={2} />
); } ================================================ FILE: components/GalleryLayout.tsx ================================================ import React, { Children, cloneElement, isValidElement, ReactNode, useMemo, } from "react"; import { Flex } from "@chakra-ui/react"; import { calcOptimalBoxes } from "lib/gallery"; interface Props { gap?: number; width: number; height: number; children: ReactNode; } const GalleryLayout = ({ children, width, height, gap = 10 }: Props) => { const bestFit = useMemo(() => { if (children) { return calcOptimalBoxes( width, height, Children.count(children), 16 / 9, gap ); } }, [children, width, height, gap]); return ( {Children.map(children, (child) => { if ( isValidElement(child) && bestFit && bestFit.width && bestFit.height ) { return cloneElement(child as React.ReactElement, { width: bestFit.width, height: bestFit.height, }); } else { return child; } })} ); }; export default GalleryLayout; ================================================ FILE: components/Header.tsx ================================================ import React from "react"; import Image from "next/image"; import { AiFillGithub } from "react-icons/ai"; import { Flex, Spacer } from "@chakra-ui/react"; import muxLogo from "../public/mux-logo.svg"; export default function Header(): JSX.Element { return ( logo ); } ================================================ FILE: components/Meeting.tsx ================================================ import React, { useContext } from "react"; import { Center, Flex } from "@chakra-ui/react"; import { useSpace } from "hooks/useSpace"; import useWindowDimensions from "hooks/useWindowDimension"; import Gallery from "./Gallery"; import Timer from "./Timer"; import ScreenShareRenderer from "./renderers/ScreenShareRenderer"; import ChatRenderer from "./renderers/ChatRenderer"; import ChatContext from "context/Chat"; const headerHeight = 80; const chatWidth = 300; export default function Meeting(): JSX.Element { let gap = 10; const { participantCount, attachScreenShare, isScreenShareActive, spaceEndsAt, } = useSpace(); const { isChatOpen } = useContext(ChatContext); const { width = 0, height = 0 } = useWindowDimensions(); const availableWidth = width - (isChatOpen && width > 800 ? chatWidth : 0); const paddingY = height < 600 ? 10 : 40; const paddingX = availableWidth < 800 ? 40 : 60; let galleryWidth = availableWidth - paddingX * 2; if (isScreenShareActive) { if (participantCount < 6) { galleryWidth = availableWidth * 0.25 - paddingX; } else { galleryWidth = availableWidth * 0.33 - paddingX / 2; } galleryWidth = Math.max(galleryWidth, 160); } let galleryHeight = height - headerHeight - paddingY * 2; let screenShareWidth = isScreenShareActive ? availableWidth - galleryWidth : 0; let direction: "row" | "column" = "row"; if (width < height) { gap = 8; galleryWidth = availableWidth - paddingX * 2; if (isScreenShareActive) { direction = "column"; screenShareWidth = availableWidth; galleryHeight = height - headerHeight - (availableWidth / 4) * 3; } } let scaleFactor = 2.25; const rows = Math.max(Math.ceil(galleryHeight / (90 * scaleFactor)), 1); const columns = Math.max(Math.ceil(galleryWidth / (160 * scaleFactor)), 1); const participantsPerPage = Math.round(rows * columns); return ( {spaceEndsAt && }
800 && isChatOpen} />
); } ================================================ FILE: components/MuteIndicator.tsx ================================================ import React from "react"; import { Box, Flex, Icon } from "@chakra-ui/react"; import { IoMicOffOutline } from "react-icons/io5"; interface Props { isMuted: boolean; parentHeight: number; } export default function MuteIndicator({ isMuted, parentHeight, }: Props): JSX.Element { let left = "2px"; let bottom = "15%"; let marginLeft = "3"; let iconWidth = "5"; let iconHeight = "5"; let borderRadius = "10px"; let paddingX = "2"; let paddingY = "1"; if (parentHeight <= 250) { left = "0"; bottom = "5%"; marginLeft = "1"; } if (parentHeight <= 200) { paddingX = "1"; borderRadius = "0 10px 0 0"; bottom = "0"; marginLeft = "0"; iconWidth = "12px"; iconHeight = "12px"; } if (parentHeight <= 90) { paddingX = "0"; paddingY = "1.5px"; borderRadius = "0"; } return ( {isMuted && ( )} ); } ================================================ FILE: components/Notifications.tsx ================================================ import { useState, useEffect } from "react"; import { useDisclosure } from "@chakra-ui/react"; import { useSpace } from "hooks/useSpace"; import { useUserMedia } from "hooks/useUserMedia"; import Sounds from "./Sounds"; import Toasts from "./Toasts"; import ErrorModal from "./modals/ErrorModal"; export default function Notifications(): JSX.Element { const { joinError, screenShareError } = useSpace(); const { userMediaError } = useUserMedia(); const [errorModalTitle, setErrorModalTitle] = useState(""); const [errorModalMessage, setErrorModalMessage] = useState(""); const { isOpen: isErrorModalOpen, onOpen: onErrorModalOpen, onClose: onErrorModalClose, } = useDisclosure(); useEffect(() => { if (joinError) { setErrorModalTitle("Error joining space"); setErrorModalMessage(joinError); onErrorModalOpen(); } }, [joinError, onErrorModalOpen]); useEffect(() => { if (screenShareError === "Permission denied by system") { setErrorModalTitle("Can't share your screen"); setErrorModalMessage( "Please check your browser has screen capture permissions and try restarting your browser if you continue to have issues." ); onErrorModalOpen(); } }, [screenShareError, onErrorModalOpen]); useEffect(() => { if (userMediaError === "NotAllowedError") { setErrorModalTitle("Can't show your media"); setErrorModalMessage( "Please check your browser has media capture (webcam and microphone) permissions and try restarting your browser if you continue to have issues." ); onErrorModalOpen(); } if (userMediaError === "OverconstrainedError") { } return () => { onErrorModalClose(); }; }, [userMediaError, onErrorModalOpen, onErrorModalClose]); return ( <> ); } ================================================ FILE: components/Participant.tsx ================================================ import React, { useMemo } from "react"; import { Box, Center, Flex } from "@chakra-ui/react"; import UserContext from "context/User"; import { useParticipant } from "hooks/useParticipant"; import Pin from "./Pin"; import VideoRenderer from "./renderers/VideoRenderer"; import ParticipantInfoBar from "./ParticipantInfoBar"; import ParticipantName from "./ParticipantName"; interface Props { width?: number; height?: number; connectionId: string; } export default function Participant({ width, height, connectionId, }: Props): JSX.Element { const { id, isLocal, isSpeaking, hasMicTrack, isMicTrackMuted, isCameraOff, cameraWidth, cameraHeight, displayName, attachVideoElement, } = useParticipant(connectionId); const outlineWidth = 3; return ( {isCameraOff && ( {displayName || id} )} {!isLocal && } ); } ================================================ FILE: components/ParticipantAudio.tsx ================================================ import React from "react"; import { useParticipant } from "hooks/useParticipant"; import AudioRenderer from "./renderers/AudioRenderer"; interface Props { connectionId: string; } const ParticipantAudio = ({ connectionId }: Props) => { const { isLocal, attachAudioElement } = useParticipant(connectionId); return !isLocal ? : null; }; export default ParticipantAudio; ================================================ FILE: components/ParticipantInfoBar.tsx ================================================ import React from "react"; import { Flex, Text } from "@chakra-ui/react"; import MuteIndicator from "./MuteIndicator"; interface Props { name: string; isMuted: boolean; parentHeight: number; } export default function ParticipantInfoBar({ name, isMuted, parentHeight, }: Props): JSX.Element { let height = "40px"; let fontSize = "14px"; if (parentHeight <= 250) { height = "30px"; } if (parentHeight <= 200) { height = "20px"; fontSize = "10px"; } if (parentHeight <= 90) { height = "15px"; fontSize = "10px"; } return ( {name} ); } ================================================ FILE: components/ParticipantName.tsx ================================================ import { memo } from "react"; import { Box, Center, Flex } from "@chakra-ui/react"; interface Props { isSmall: boolean; children: string; } function ParticipantName({ isSmall, children }: Props): JSX.Element { return (
{children}
); } const MemoizedName = memo(ParticipantName); export default MemoizedName; ================================================ FILE: components/Pin.tsx ================================================ import React from "react"; import { IconButton } from "@chakra-ui/react"; import { MdPushPin, MdOutlinePushPin } from "react-icons/md"; import UserContext from "context/User"; interface Props { connectionId: string; } export default function Pin({ connectionId }: Props): JSX.Element { const { pinnedConnectionId, setPinnedConnectionId } = React.useContext(UserContext); return ( { if (setPinnedConnectionId) { if (pinnedConnectionId === connectionId) { setPinnedConnectionId(""); } else { setPinnedConnectionId(connectionId); } } }} variant="ghost" position="absolute" right={0} top={0} icon={ pinnedConnectionId === connectionId ? ( ) : ( ) } /> ); } ================================================ FILE: components/Sounds.tsx ================================================ import useSound from "use-sound"; import { SpaceEvent } from "@mux/spaces-web"; import { useSpaceEvent } from "hooks/useSpaceEvent"; import { useCallback } from "react"; import { useSpace } from "hooks/useSpace"; const participantSoundCutoff = 5; export default function Sounds(): JSX.Element { const { isJoined } = useSpace(); return <>{isJoined && }; } function JoinedSounds(): JSX.Element { const { participantCount } = useSpace(); const [playJoinSound] = useSound("/sounds/meet-join.mp3"); const [playLeaveSound] = useSound("/sounds/meet-leave.mp3"); useSpaceEvent( SpaceEvent.ParticipantJoined, useCallback(() => { if (document["hidden"] && participantCount < participantSoundCutoff) { playJoinSound(); } }, [playJoinSound, participantCount]) ); useSpaceEvent( SpaceEvent.ParticipantLeft, useCallback(() => { if (document["hidden"] && participantCount < participantSoundCutoff) { playLeaveSound(); } }, [playLeaveSound, participantCount]) ); return <>; } ================================================ FILE: components/Stage.tsx ================================================ import React from "react"; import { Center, Heading, Spinner } from "@chakra-ui/react"; import { useSpace } from "hooks/useSpace"; import Controls from "./Controls"; import Meeting from "./Meeting"; import Notifications from "./Notifications"; import { ChatProvider } from "context/Chat"; const LoadingSpinner = () => { return ( <> Joining Space... ); }; export default function Stage(): JSX.Element { const { isJoined } = useSpace(); return ( <>
{!isJoined ? : }
); } ================================================ FILE: components/Timer.tsx ================================================ import { useEffect, useState } from "react"; import { useRouter } from "next/router"; import { Tooltip } from "@chakra-ui/react"; import styled from "@emotion/styled"; import moment from "moment"; import { useSpace } from "hooks/useSpace"; import { transientOptions } from "lib/utils"; const Display = styled("div", transientOptions)<{ $isUrgent: boolean }>` position: absolute; display: flex; flex-direction: column; align-items: start; background-color: #383838; padding: 0.5em; top: 12px; left: 12px; font-size: 1.5em; font-variant-numeric: tabular-nums; color: white; user-select: none; border-radius: 3px; z-index: 200; ${(props) => props.$isUrgent && ` #countdown { color: red; } `} `; const getClock = (diff: number) => { const minutes = Math.floor((diff / 1000 / 60) % 60); const seconds = Math.floor((diff / 1000) % 60); const minuteDisplay = 10 > minutes ? "0" + minutes : minutes; const secondDisplay = 10 > seconds ? "0" + seconds : seconds; return `${minuteDisplay}:${secondDisplay}`; }; const Timer = (): JSX.Element => { const { spaceEndsAt, leaveSpace } = useSpace(); const diff = moment(spaceEndsAt).diff(moment()); const [timeDisplay, setTimeDisplay] = useState( spaceEndsAt ? getClock(diff) : "00:00" ); const router = useRouter(); const [isTwoMinutesLeft, setIsTwoMinutesLeft] = useState( 120 >= Math.floor(diff / 1000) ); useEffect(() => { const timer = setInterval(() => { const diff = moment(spaceEndsAt).diff(moment()); if (diff > 0) { const clock = getClock(diff); setTimeDisplay(getClock(diff)); setIsTwoMinutesLeft(120 >= Math.floor(diff / 1000)); } else { leaveSpace(); router.push("/"); } }, 1000); return () => clearInterval(timer); }, [router, leaveSpace, spaceEndsAt, isTwoMinutesLeft]); return ( Time Left {timeDisplay} ); }; export default Timer; ================================================ FILE: components/Toasts.tsx ================================================ import styled from "@emotion/styled"; import { useRouter } from "next/router"; import { useCallback, useEffect, useRef } from "react"; import { Box, ToastId, useToast } from "@chakra-ui/react"; import { useSpace } from "hooks/useSpace"; import { broadcastingToastConfig, participantEventToastConfig, sharingScreenToastConfig, ToastIds, viewingSharedScreenToastConfig, } from "shared/toastConfigs"; const ToastBox = styled(Box)` color: #0a0a0b; background: #cff1fc; font-size: 14px; padding: 15px 50px; text-align: center; `; export default function Toasts(): JSX.Element { const toast = useToast(); const router = useRouter(); const { isReady: isRouterReady } = router; const { isBroadcasting, isScreenShareActive, stopScreenShare, isLocalScreenShare, screenShareParticipantName, } = useSpace(); const screenshareToastRef = useRef(); const broadcastingToastRef = useRef(); const participantEventToastIdRefs = useRef>([]); const showLocalScreenshareToast = useCallback(() => { if (!toast.isActive(ToastIds.SHARING_SCREEN_TOAST_ID)) { screenshareToastRef.current = toast({ ...sharingScreenToastConfig, render: ({ onClose }) => { return ( You are sharing your screen. { stopScreenShare(); onClose(); }} > Stop sharing ); }, }); } }, [toast, stopScreenShare]); const showRemoteScreenshareToast = useCallback(() => { if ( !toast.isActive(ToastIds.VIEWING_SHARED_SCREEN_TOAST_ID) && screenShareParticipantName ) { screenshareToastRef.current = toast({ ...viewingSharedScreenToastConfig, render: () => ( {screenShareParticipantName} is sharing their screen. ), }); } }, [toast, screenShareParticipantName]); const hideScreenshareToast = useCallback(() => { if (screenshareToastRef.current) { toast.close(screenshareToastRef.current); } }, [toast]); const showBroadcastToast = useCallback(() => { if (!toast.isActive(ToastIds.BROADCASTING_SCREEN_TOAST_ID)) { broadcastingToastRef.current = toast({ ...broadcastingToastConfig, render: () => ( {"⦿ Space is currently being broadcast"} ), }); } }, [toast]); const hideBroadcastToast = useCallback(() => { if (broadcastingToastRef.current) { toast.close(broadcastingToastRef.current); } }, [toast]); const pruneParticipantEventRefs = useCallback(() => { // Don't show more than 10 participant event toasts on screen at a time, close the oldest if necessary while (participantEventToastIdRefs.current.length > 9) { let oldest = participantEventToastIdRefs.current.shift(); if (oldest) { toast.close(oldest); } } // Prune ids of toasts that have already closed on their own participantEventToastIdRefs.current = participantEventToastIdRefs.current.filter((ref) => toast.isActive(ref)); }, [participantEventToastIdRefs, toast]); const showParticipantEventToast = useCallback( (eventDescription: string) => { pruneParticipantEventRefs(); participantEventToastIdRefs.current.push( toast({ ...participantEventToastConfig, render: () => {eventDescription}, }) ); }, [toast, pruneParticipantEventRefs] ); useEffect(() => { if (isScreenShareActive) { if (isLocalScreenShare) { showLocalScreenshareToast(); } else { showRemoteScreenshareToast(); } } else { hideScreenshareToast(); } }, [ isScreenShareActive, isLocalScreenShare, showLocalScreenshareToast, showRemoteScreenshareToast, hideScreenshareToast, ]); useEffect(() => { if (isBroadcasting) { showBroadcastToast(); } else { hideBroadcastToast(); } }, [isBroadcasting, showBroadcastToast, hideBroadcastToast]); useEffect(() => { if (!isRouterReady) return; function closeAllToasts() { hideBroadcastToast(); hideScreenshareToast(); for (let toastRef in participantEventToastIdRefs.current) { toast.close(toastRef); } pruneParticipantEventRefs(); } router.events.on("routeChangeStart", closeAllToasts); return () => { router.events.off("routeChangeStart", closeAllToasts); }; }, [ isRouterReady, router, toast, hideBroadcastToast, hideScreenshareToast, pruneParticipantEventRefs, ]); return <>; } ================================================ FILE: components/UserInteractionPrompt.tsx ================================================ import React, { useState, useEffect, useMemo, useRef, useContext, MutableRefObject, } from "react"; import Image from "next/image"; import { Button, Flex, FormControl, FormHelperText, FormLabel, HStack, Input, Stack, } from "@chakra-ui/react"; import { HiOutlineArrowNarrowRight } from "react-icons/hi"; import UserContext from "context/User"; import { useUserMedia } from "hooks/useUserMedia"; import MicrophoneButton from "components/controls/buttons/MicrophoneButton"; import CameraButton from "components/controls/buttons/CameraButton"; import muxLogo from "../public/mux-logo.svg"; interface Props { onInteraction: () => void; participantNameRef: MutableRefObject; } export default function UserInteractionPrompt({ onInteraction, participantNameRef, }: Props): JSX.Element { const nameInputRef = useRef(null); const didPopulateDevicesRef = useRef(false); const user = useContext(UserContext); const [participantName, setParticipantName] = useState(""); const [hasBlurredNameInput, setHasBlurredNameInput] = useState(false); const { requestPermissionAndPopulateDevices } = useUserMedia(); useEffect(() => { if (nameInputRef.current) { nameInputRef.current.focus(); } }, []); useEffect(() => { setParticipantName(user.participantName); }, [user.participantName]); const isNameInputInvalid = useMemo( () => !participantName && hasBlurredNameInput, [participantName, hasBlurredNameInput] ); const handleParticipantNameChange = (event: { target: { value: string }; }) => { setParticipantName(event.target.value); }; useEffect(() => { if (didPopulateDevicesRef.current === false) { didPopulateDevicesRef.current = true; requestPermissionAndPopulateDevices(); } }, [requestPermissionAndPopulateDevices]); const handleSubmit = async (e: React.FormEvent) => { event?.preventDefault(); participantNameRef.current = participantName; user.setParticipantName(participantName); user.setInteractionRequired(false); onInteraction(); }; return ( logo
setHasBlurredNameInput(true)} > Enter your name This cannot be empty.
); } ================================================ FILE: components/controls/ControlsCenter.tsx ================================================ import React from "react"; import { HStack } from "@chakra-ui/react"; import MicrophoneButton from "./buttons/MicrophoneButton"; import CameraButton from "./buttons/CameraButton"; import ScreenShareButton from "./buttons/ScreenShareButton"; import SettingsButton from "./buttons/SettingsButton"; import ChatButton from "./buttons/ChatButton"; import useWindowDimensions from "hooks/useWindowDimension"; import { useSpace } from "hooks/useSpace"; interface Props { onLeave: () => void; } export default function ControlsCenter({ onLeave }: Props): JSX.Element { const { width = 0 } = useWindowDimensions(); const { isLocalScreenShareSupported } = useSpace(); return ( {isLocalScreenShareSupported && } {width > 800 && } ); } ================================================ FILE: components/controls/ControlsLeft.tsx ================================================ import React from "react"; import Image from "next/image"; import { Flex } from "@chakra-ui/react"; import muxLogo from "../../public/mux-logo.svg"; export default function ControlsLeft(): JSX.Element { return ( logo ); } ================================================ FILE: components/controls/ControlsRight.tsx ================================================ import React from "react"; import { Button, Flex, Text } from "@chakra-ui/react"; import LeaveIcon from "components/icons/LeaveIcon"; import SpaceContext from "context/Space"; import styled from "@emotion/styled"; import useWindowDimensions from "hooks/useWindowDimension"; interface Props { onLeave: () => void; } const ParticipantsLabel = styled.span` color: #cccccc; font-size: 12px; line-height: 12px; text-transform: uppercase; letter-spacing: 0.8px; `; const ParticipantCount = styled.span` font-size: 21px; line-height: 21px; letter-spacing: -0.5px; margin-top: 5px; `; export default function ControlsRight({ onLeave }: Props): JSX.Element { const { participantCount } = React.useContext(SpaceContext); const { width } = useWindowDimensions(); const hideParticipantCount = (width && width < 1000) || false; return ( {!hideParticipantCount && ( <> Participants {participantCount} )} ); } ================================================ FILE: components/controls/buttons/CameraButton.tsx ================================================ import React, { useCallback } from "react"; import { Box, ButtonGroup, Flex, IconButton, Menu, MenuButton, MenuItem, MenuList, Tooltip, Text, } from "@chakra-ui/react"; import { useHotkeys } from "react-hotkeys-hook"; import { AiOutlineCheck } from "react-icons/ai"; import UserContext from "context/User"; import { useSpace } from "hooks/useSpace"; import { useUserMedia } from "hooks/useUserMedia"; import ChevronIcon from "components/icons/ChevronIcon"; import MuteCameraIcon from "components/icons/MuteCameraIcon"; import UnmuteCameraIcon from "components/icons/UnmuteCameraIcon"; export default function CameraButton(): JSX.Element { const { cameraOff, setCameraOff, cameraDeviceId, setCameraDeviceId } = React.useContext(UserContext); const { isJoined, publishCamera, unPublishDevice } = useSpace(); const { cameraDevices, stopActiveCamera } = useUserMedia(); const selectCameraDevice = useCallback( async (deviceId: string) => { setCameraDeviceId(deviceId); if (isJoined && !cameraOff) { publishCamera(deviceId); } }, [isJoined, setCameraDeviceId, cameraOff, publishCamera] ); const toggleCamera = useCallback(async () => { if (cameraOff) { setCameraOff(false); if (isJoined) { publishCamera(cameraDeviceId); } } else { setCameraOff(true); stopActiveCamera(); if (isJoined) { unPublishDevice(cameraDeviceId); } } }, [ isJoined, cameraOff, setCameraOff, cameraDeviceId, stopActiveCamera, publishCamera, unPublishDevice, ]); const hotkeyText = "⌘ + e"; // adding this here to make it easy to change later. appears in tooltip on button. useHotkeys( "meta+e", () => { toggleCamera(); }, { preventDefault: true }, [toggleCamera] ); const ariaLabel = cameraOff ? "Unhide" : "Hide"; return ( : } onClick={toggleCamera} /> {({ isOpen }) => ( <> } zIndex={100} minWidth="20px" {...(isOpen && { transform: "rotate(180deg)" })} /> CAMERA {cameraDevices.map((device) => { return ( selectCameraDevice(device.deviceId)} > {device.label} {cameraDeviceId === device.deviceId && ( )} ); })} )} ); } ================================================ FILE: components/controls/buttons/ChatButton.tsx ================================================ import React, { useContext } from "react"; import { IconButton, Tooltip } from "@chakra-ui/react"; import ChatIcon from "../../icons/ChatIcon"; import ChatContext from "context/Chat"; import styled from "@emotion/styled"; interface Props { isActive: boolean; hasNotifications: boolean; } const UnreadCircle = styled.div` position: absolute; width: 10px; height: 10px; border-radius: 5px; background-color: #fa50b5; top: calc(50% - 17.5px); right: calc(50% - 17.5px); `; export default function ChatButton(): JSX.Element { const { numUnreadMessages, isChatOpen, openChat, closeChat } = useContext(ChatContext); return ( {numUnreadMessages > 0 && } } {...(isChatOpen && { background: "#3E4247", border: "1px solid #FFFFFF", })} onClick={isChatOpen ? closeChat : openChat} /> ); } ================================================ FILE: components/controls/buttons/MicrophoneButton.tsx ================================================ import React, { useCallback, useEffect, useState } from "react"; import { Box, Flex, IconButton, Menu, MenuButton, MenuItem, MenuList, Tooltip, Text, ButtonGroup, } from "@chakra-ui/react"; import { useHotkeys } from "react-hotkeys-hook"; import { AiOutlineCheck } from "react-icons/ai"; import UserContext from "context/User"; import { useSpace } from "hooks/useSpace"; import { useUserMedia } from "hooks/useUserMedia"; import ChevronIcon from "components/icons/ChevronIcon"; import MuteMicrophoneIcon from "components/icons/MuteMicrophoneIcon"; import UnmuteMicrophoneIcon from "components/icons/UnmuteMicrophoneIcon"; export default function MicrophoneButton(): JSX.Element { const { userWantsMicMuted, setUserWantsMicMuted, microphoneDeviceId, setMicrophoneDeviceId, } = React.useContext(UserContext); const { microphoneDevices, muteActiveMicrophone, unMuteActiveMicrophone, getActiveMicrophoneLevel, } = useUserMedia(); const { isJoined, publishMicrophone } = useSpace(); const [temporaryUnmute, setTemporaryUnmute] = useState(false); const [mouseOver, setMouseOver] = useState(false); const [micLevelPercent, setMicLevelPercent] = useState(0); const [micDistorted, setMicDistorted] = useState(false); const requestRef = React.useRef(); const selectAudioDevice = useCallback( async (deviceId: string) => { setMicrophoneDeviceId(deviceId); if (isJoined) { publishMicrophone(deviceId); } }, [isJoined, setMicrophoneDeviceId, publishMicrophone] ); const toggleMicrophone = useCallback(() => { if (userWantsMicMuted) { setUserWantsMicMuted(false); unMuteActiveMicrophone(); } else { setUserWantsMicMuted(true); muteActiveMicrophone(); } }, [ userWantsMicMuted, setUserWantsMicMuted, muteActiveMicrophone, unMuteActiveMicrophone, ]); const animateMeter = useCallback(() => { if (requestRef.current != undefined && getActiveMicrophoneLevel) { let levels = getActiveMicrophoneLevel(); if (levels) { // Convert to 0-100 scale let scaled = (Math.max(-60, levels.avgDb) + 60) * 1.66; setMicLevelPercent(scaled); if (levels.peakDb > -0.5) { setMicDistorted(true); } else { setMicDistorted(false); } } else { setMicLevelPercent(0); setMicDistorted(false); } } requestRef.current = requestAnimationFrame(animateMeter); }, [getActiveMicrophoneLevel]); useEffect(() => { requestRef.current = requestAnimationFrame(animateMeter); return () => { if (requestRef.current) { cancelAnimationFrame(requestRef.current); } }; }, [animateMeter]); const hotkeyText = "⌘ + d"; // adding this here to make it easy to change later. appears in tooltip on button. useHotkeys( "meta+d", () => { toggleMicrophone(); }, { preventDefault: true }, [toggleMicrophone] ); useHotkeys( "space", () => { if (userWantsMicMuted && !temporaryUnmute) { unMuteActiveMicrophone(); setTemporaryUnmute(true); } }, { keydown: true, preventDefault: true }, [unMuteActiveMicrophone, userWantsMicMuted, temporaryUnmute] ); useHotkeys( "space", () => { if (temporaryUnmute) { setTemporaryUnmute(false); if (userWantsMicMuted) { muteActiveMicrophone(); } } }, { keyup: true, preventDefault: true }, [muteActiveMicrophone, userWantsMicMuted, temporaryUnmute] ); const ariaLabel = userWantsMicMuted ? "Unmute" : "Mute"; return ( setMouseOver(true)} onMouseLeave={() => setMouseOver(false)} icon={ userWantsMicMuted && !temporaryUnmute ? ( ) : ( ) } onClick={toggleMicrophone} {...(isJoined && (micDistorted ? { background: `radial-gradient(50% 50% at 50% 50%, rgba(255, 92, 56, 0.75) 0%, rgba(255, 92, 56, 0) ${micLevelPercent}%)`, } : { background: `radial-gradient(50% 50% at 50% 50%, rgba(27, 227, 73, 0.75) 0%, rgba(27, 227, 73, 0) ${micLevelPercent}%)`, }))} /> {({ isOpen }) => ( <> } variant="controlMenu" minWidth="20px" {...(isOpen && { transform: "rotate(180deg)" })} /> MICROPHONE {microphoneDevices.map((device: MediaDeviceInfo) => { return ( selectAudioDevice(device.deviceId)} > {device.label} {microphoneDeviceId == device.deviceId && ( )} ); })} )} ; ); } ================================================ FILE: components/controls/buttons/ScreenShareButton.tsx ================================================ import React from "react"; import { IconButton, Tooltip } from "@chakra-ui/react"; import { useSpace } from "hooks/useSpace"; import ScreenShareIcon from "../../icons/ScreenShareIcon"; export default function ScreenShareButton(): JSX.Element { const { isLocalScreenShare, isScreenShareActive, startScreenShare, stopScreenShare, } = useSpace(); return ( } {...(isLocalScreenShare && { background: "#3E4247", border: "1px solid #FFFFFF", })} onClick={isLocalScreenShare ? stopScreenShare : startScreenShare} /> ); } ================================================ FILE: components/controls/buttons/SendButton.tsx ================================================ import React, { useContext } from "react"; import { IconButton, Tooltip } from "@chakra-ui/react"; import styled from "@emotion/styled"; import SendIcon from "components/icons/SendIcon"; import { transientOptions } from "lib/utils"; interface Props { isButtonEnabled: boolean; handleOnClick: () => void; } const StatefulSendButton = styled(IconButton, transientOptions)<{ $isButtonEnabled: boolean; }>` opacity: ${(props) => (props.$isButtonEnabled ? "1" : "0.25")}; user-select: none; `; export default function SendButton({ isButtonEnabled, handleOnClick, }: Props): JSX.Element { return ( } onClick={handleOnClick} disabled={!isButtonEnabled} /> ); } ================================================ FILE: components/controls/buttons/SettingsButton.tsx ================================================ import React from "react"; import { Box, IconButton, Menu, MenuButton, MenuItem, MenuList, useDisclosure, useToast, } from "@chakra-ui/react"; import { useRouter } from "next/router"; import { copyLinkToastConfig, ToastIds } from "shared/toastConfigs"; import { useSpace } from "hooks/useSpace"; import useWindowDimensions from "hooks/useWindowDimension"; import SettingsIcon from "components/icons/SettingsIcon"; import RenameParticipantModal from "components/modals/RenameParticipantModal"; interface Props { onLeave: () => void; } export default function SettingsButton({ onLeave }: Props): JSX.Element { const toast = useToast(); const router = useRouter(); const { width } = useWindowDimensions(); const { isLocalScreenShare } = useSpace(); const { isOpen: isRenameModalOpen, onOpen: onRenameModalOpen, onClose: onRenameModalClose, } = useDisclosure(); const smallWindowWidth = (width && width < 480) || false; const shareLink = () => { navigator.clipboard.writeText( `${window.location.protocol}//${window.location.host}/space/${router.query["id"]}` ); if (!toast.isActive(ToastIds.COPY_LINK_TOAST_ID)) { toast(copyLinkToastConfig); } }; return ( <> } /> Change Name Copy Invite Link {smallWindowWidth && Leave} ); } ================================================ FILE: components/icons/ChatIcon.tsx ================================================ import React from "react"; import Image from "next/image"; import chat from "../../public/chat.svg"; export default function ChatIcon(): JSX.Element { return ( toggle chat ); } ================================================ FILE: components/icons/ChevronIcon.tsx ================================================ import React from "react"; import Image from "next/image"; import chevronUp from "../../public/chevronUp.svg"; export default function ChevronIcon(): JSX.Element { return ( open menu ); } ================================================ FILE: components/icons/ChevronLeftIcon.tsx ================================================ import React from "react"; import Image from "next/image"; import chevronLeft from "../../public/chevronLeft.svg"; export default function ChevronLeftIcon(): JSX.Element { return paginate left; } ================================================ FILE: components/icons/ChevronRightIcon.tsx ================================================ import React from "react"; import Image from "next/image"; import chevronRight from "../../public/chevronRight.svg"; export default function ChevronRightIcon(): JSX.Element { return ( paginate right ); } ================================================ FILE: components/icons/LeaveIcon.tsx ================================================ import React from "react"; export default function LeaveIcon(): JSX.Element { return ( ); } ================================================ FILE: components/icons/MuteCameraIcon.tsx ================================================ import React from "react"; import Image from "next/image"; import cameraOnIcon from "../../public/cameraOn.svg"; export default function MuteCameraIcon(): JSX.Element { return ( mute camera ); } ================================================ FILE: components/icons/MuteMicrophoneIcon.tsx ================================================ import React from "react"; import Image from "next/image"; import microphoneOffIcon from "../../public/microphoneOff.svg"; export default function MuteMicrophoneIcon(): JSX.Element { return ( mute mic ); } ================================================ FILE: components/icons/ScreenShareIcon.tsx ================================================ import React from "react"; import Image from "next/image"; import screenShare from "../../public/screen-share.svg"; export default function ScreenShareIcon(): JSX.Element { return ( Screen share ); } ================================================ FILE: components/icons/SendIcon.tsx ================================================ import React from "react"; import Image from "next/image"; import send from "../../public/send.svg"; export default function SendIcon(): JSX.Element { return ( Send message ); } ================================================ FILE: components/icons/SettingsIcon.tsx ================================================ import Image from "next/image"; import elipsisIcon from "../../public/elipsis.svg"; export default function SettingsIcon() { return ( settings ); } ================================================ FILE: components/icons/UnmuteCameraIcon.tsx ================================================ import React from "react"; import Image from "next/image"; import cameraOffIcon from "../../public/cameraOff.svg"; export default function UnmuteCameraIcon(): JSX.Element { return ( unmute camera ); } ================================================ FILE: components/icons/UnmuteMicrophoneIcon.tsx ================================================ import React from "react"; import Image from "next/image"; import microphoneOnIcon from "../../public/microphoneOn.svg"; export default function UnmuteMicrophoneIcon(): JSX.Element { return ( unmute mic ); } ================================================ FILE: components/modals/ACRScoreDialog.tsx ================================================ import React, { useCallback, useRef, useState } from "react"; import { useDisclosure, Button, AlertDialog, AlertDialogOverlay, AlertDialogContent, AlertDialogHeader, AlertDialogBody, AlertDialogFooter, AlertDialogCloseButton, Box, useRadio, useRadioGroup, VStack, RadioProps, Divider, } from "@chakra-ui/react"; import { AcrScore } from "@mux/spaces-web"; import { useSpace } from "hooks/useSpace"; function RadioCard(props: RadioProps) { const { getInputProps, getCheckboxProps } = useRadio(props); const input = getInputProps(); const checkbox = getCheckboxProps(); return ( {props.children} ); } type Props = Pick, "isOpen" | "onClose">; const options = ["Excellent", "Good", "Fair", "Poor", "Bad"]; export default function ACRScoreDialog({ isOpen, onClose }: Props) { const cancelRef = useRef(null); const { submitAcrScore } = useSpace(); const [acrScore, setAcrScore] = useState(); const [submitting, setSubmitting] = useState(false); const [closing, setClosing] = useState(false); const { getRootProps, getRadioProps } = useRadioGroup({ name: "acr", onChange: (nextValue) => setAcrScore(nextValue), }); const handleSubmittingAcrScore = useCallback(async () => { if (acrScore) { setSubmitting(true); const numericScore = AcrScore[acrScore as keyof typeof AcrScore]; try { await submitAcrScore(numericScore); } catch (e) { console.error(e); } onClose(); } }, [acrScore, submitAcrScore, onClose]); const handleClose = useCallback(() => { setClosing(true); onClose(); }, [onClose]); const group = getRootProps(); const disableSubmission = closing || !acrScore; return ( <> How was the call quality? {options.map((value) => { const radio = getRadioProps({ value }); return ( {value} ); })} ); } ================================================ FILE: components/modals/ErrorModal.tsx ================================================ import { Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, } from "@chakra-ui/react"; interface Props { title: string; message: string; isOpen: boolean; onClose: () => void; } export default function ErrorModal({ title, message, isOpen, onClose }: Props) { return ( {title} {message} ); } ================================================ FILE: components/modals/RenameParticipantModal.tsx ================================================ import React, { useCallback, useMemo, useRef, useState, useContext, } from "react"; import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalFooter, ModalBody, ModalCloseButton, Button, useDisclosure, FormControl, FormHelperText, Input, Divider, } from "@chakra-ui/react"; import UserContext from "context/User"; import SpaceContext from "context/Space"; type Props = Pick, "isOpen" | "onClose"> & {}; export default function RenameParticipantModal({ isOpen, onClose, }: Props): JSX.Element { const user = React.useContext(UserContext); const nameInputRef = useRef(null); const [participantName, setParticipantName] = useState(user.participantName); const { setDisplayName } = useContext(SpaceContext); const invalidParticipantName = useMemo( () => !participantName, [participantName] ); const handleDisplayNameChanged = (event: { target: { value: string } }) => { setParticipantName(event.target.value); }; const handleClose = useCallback(() => { setParticipantName(user.participantName); onClose(); }, [onClose, setParticipantName, user]); const submit = useCallback(async () => { if (invalidParticipantName) return; if (user.participantName !== participantName) { user.setParticipantName(participantName); await setDisplayName(participantName); } onClose(); }, [invalidParticipantName, onClose, participantName, setDisplayName, user]); return ( {"What's your name?"} { if (e.key === "Enter") { submit(); } }} /> ); } ================================================ FILE: components/renderers/AudioRenderer.tsx ================================================ import { useEffect, useRef } from "react"; interface AudioTrackProps { attachFunc: (element: HTMLAudioElement) => void; } const AudioRenderer = ({ attachFunc }: AudioTrackProps) => { const audioEl = useRef(null); useEffect(() => { const el = audioEl.current; if (!el) return; attachFunc(el); }, [attachFunc]); return