[
  {
    "path": ".eslintrc.json",
    "content": "{\n  \"extends\": [\"next/core-web-vitals\", \"prettier\"]\n}\n"
  },
  {
    "path": ".gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.js\n\n# testing\n/coverage\n\n# next.js\n/.next/\n/out/\n\n# production\n/build\n\n# misc\n.DS_Store\n*.pem\n\n# debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# local env files\n.env\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\n# vercel\n.vercel\n\n# typescript\n*.tsbuildinfo\n\n# VScode\n.vscode\n"
  },
  {
    "path": ".husky/pre-commit",
    "content": "#!/bin/sh\n. \"$(dirname \"$0\")/_/husky.sh\"\n\nnpx lint-staged\n"
  },
  {
    "path": ".prettierignore",
    "content": "# dependencies\n/node_modules\n/.pnp\n.pnp.js\n\n# testing\n/coverage\n\n# next.js\n/.next/\n/out/\n\n# production\n/build\n\n# debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# typescript\n*.tsbuildinfo\n"
  },
  {
    "path": ".prettierrc.json",
    "content": "{}\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2023 Mux\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Mux Meet\n\n> [!WARNING]\n> 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.\n\nMux 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.\n\n![Four users in a Mux Meet call](https://user-images.githubusercontent.com/1211390/216212346-b319d137-0d2e-405a-bbab-703cc32763b3.jpg)\n\n# Getting Started\n\nIn 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.\n\nThe easiest way to use Mux Meet is to deploy it to Vercel.\n\n[![Deploy with Vercel](https://vercel.com/button)](<https://vercel.com/new/clone?demo-title=Mux%20Meet&demo-description=Real-time%20conferencing%20(meeting)%20SaaS%20app%2C%20built%20with%20Next.js%2C%20Mux%2C%20and%20Vercel&demo-url=https%3A%2F%2Fmuxmeet.vercel.app%2F&demo-image=%2F%2Fimages.ctfassets.net%2Fe5382hct74si%2F7ISNSvmomH7w7KUPCy8gn0%2F97ab315dcd21aa1d4b23e54dc123b562%2Fmux-meet.png&project-name=Mux%20Meet&repository-name=mux-meet&repository-url=https%3A%2F%2Fgithub.com%2Fmuxinc%2Fmeet&from=templates&skippable-integrations=1&env=MUX_TOKEN_ID%2CMUX_TOKEN_SECRET%2CMUX_SIGNING_KEY%2CMUX_PRIVATE_KEY&envDescription=How%20to%20get%20these%20env%20variables%3A&envLink=https%3A%2F%2Fgithub.com%2Fmuxinc%2Fmeet%23getting-started>)\n\nAfter creating your project, you will be prompted to configure it.\n\n![Configure Vercel Environment Variables](https://user-images.githubusercontent.com/1211390/216212169-251d87ef-83ae-4b9b-82e8-ae42cb430b02.jpg)\n\nIn 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.\n\nFrom 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.\n\n![Create new Mux Access Token](https://user-images.githubusercontent.com/1211390/216212226-d98b377b-7105-4db7-89f7-8b3f6aadd805.jpg)\n\nOnce your token is generated, copy and paste the ID and Secret as the values for `MUX_TOKEN_ID` and `MUX_TOKEN_SECRET` in Vercel.\n\nNow go back to https://dashboard.mux.com to generate the Signing Key.\n\nFrom 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.\n\n![Create new Mux Signing Key](https://user-images.githubusercontent.com/1211390/216212263-f8fe2d0a-e8f4-4ba6-8197-bbbe745c9cb1.jpg)\n\nOnce your key is generated, copy and paste the ID and Secret as the values for `MUX_SIGNING_KEY` and `MUX_PRIVATE_KEY` in Vercel.\n\n_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._\n\nOnce all 4 environment variables are filled in. Click Deploy for Vercel to build and start your app.\n\n## Cleanup Spaces after Meeting\n\nJoining 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).\n\nFrom 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`.\n\n![Create new Mux webhook](https://user-images.githubusercontent.com/1211390/216212296-93dad4a3-1b91-4402-8eed-c0325dea0d69.jpg)\n\nNow generate the Webhook and copy the Signing Secret by clicking Show Signing Secret.\n\nConfigure your deployed app with a new environment variable named `WEBHOOK_SECRET` with the value of the Webhook Signing Secret.\n\n_Make sure you redeploy for your new environment variable to take affect._\n\n## Limit Access\n\nTo limit the number of active Spaces, set an integer value for an environment variable `ACTIVE_SPACE_LIMIT`.\n\nTo 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`.\n\n# Develop locally\n\nCreate an .env.local file at the root of the repo with the following secrets:\n\n```bash\nMUX_TOKEN_ID=\nMUX_TOKEN_SECRET=\nMUX_SIGNING_KEY=\nMUX_PRIVATE_KEY=\n```\n\n```bash\nnpm run dev\n```\n\nOpen [http://localhost:3000](http://localhost:3000) with your browser to see the result.\n\nYou can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.\n\n# Learn More\n\n## Mux\n\n- [Mux Real-Time Video](https://mux.com/real-time-video)\n- [Real-Time Video in React](https://docs.mux.com/guides/video/send-and-receive-real-time-video-from-a-react-application)\n- [Real-Time Video on Android](https://docs.mux.com/guides/video/send-and-receive-real-time-video-from-an-android-application)\n- [Real-Time Video on iOS](https://docs.mux.com/guides/video/send-and-receive-real-time-video-from-an-ios-application)\n\n## Next.js\n\n- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.\n- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.\n"
  },
  {
    "path": "client/token.ts",
    "content": "import axios from \"axios\";\n\ninterface TokenParams {\n  spaceId: string;\n  participantId: string;\n}\n\ninterface TokenResponse {\n  spaceJWT: string;\n}\n\nexport const tokenPOST = async (\n  params: TokenParams\n): Promise<TokenResponse> => {\n  return axios.post(`/api/token`, params).then(({ data }) => data);\n};\n"
  },
  {
    "path": "components/Controls.tsx",
    "content": "import React, { useCallback } from \"react\";\nimport { useRouter } from \"next/router\";\nimport { Flex, useDisclosure } from \"@chakra-ui/react\";\n\nimport { useSpace } from \"hooks/useSpace\";\n\nimport ControlsRight from \"./controls/ControlsRight\";\nimport ControlsLeft from \"./controls/ControlsLeft\";\nimport ControlsCenter from \"./controls/ControlsCenter\";\nimport ACRScoreDialog from \"./modals/ACRScoreDialog\";\n\nexport default function Controls(): JSX.Element {\n  const router = useRouter();\n  const { leaveSpace } = useSpace();\n  const { isOpen: isACRScoreDialogOpen, onOpen: onACRScoreDialogOpen } =\n    useDisclosure();\n\n  const leaveSpacePage = useCallback(() => {\n    router.push(\"/\");\n  }, [router]);\n\n  const promptForACR = useCallback(() => {\n    try {\n      leaveSpace();\n      onACRScoreDialogOpen();\n    } catch (error) {\n      console.error(`Unable to properly leave space: ${error}`);\n      leaveSpacePage();\n    }\n  }, [leaveSpace, leaveSpacePage, onACRScoreDialogOpen]);\n\n  return (\n    <>\n      <ACRScoreDialog isOpen={isACRScoreDialogOpen} onClose={leaveSpacePage} />\n      <Flex\n        alignItems=\"center\"\n        backgroundColor=\"#0a0a0b\"\n        bottom=\"0px\"\n        flexDirection=\"row\"\n        height={{ base: \"60px\", sm: \"80px\" }}\n        justifyContent=\"space-between\"\n        left=\"0px\"\n        padding=\"10px 40px\"\n        position=\"fixed\"\n        width=\"100%\"\n        zIndex={1000}\n      >\n        <ControlsLeft />\n        {!isACRScoreDialogOpen && (\n          <>\n            <ControlsCenter onLeave={promptForACR} />\n            <ControlsRight onLeave={promptForACR} />\n          </>\n        )}\n      </Flex>\n    </>\n  );\n}\n"
  },
  {
    "path": "components/Gallery.tsx",
    "content": "import React, { useCallback, useMemo, useState } from \"react\";\nimport { IconButton, Center, Flex } from \"@chakra-ui/react\";\n\nimport UserContext from \"context/User\";\n\nimport { useSpace } from \"hooks/useSpace\";\n\nimport Participant from \"./Participant\";\nimport GalleryLayout from \"./GalleryLayout\";\nimport ParticipantAudio from \"./ParticipantAudio\";\nimport ChevronLeftIcon from \"components/icons/ChevronLeftIcon\";\nimport ChevronRightIcon from \"components/icons/ChevronRightIcon\";\n\nfunction pushToFront<T>(array: T[], element: T) {\n  const index = array.findIndex((el) => el === element);\n  if (index) {\n    array.splice(index, 1);\n    array.unshift(element);\n  }\n}\n\ninterface Props {\n  gap: number;\n  width: number;\n  height: number;\n  participantsPerPage: number;\n}\n\nexport default function Gallery({\n  gap,\n  width,\n  height,\n  participantsPerPage,\n}: Props): JSX.Element {\n  const [currentPage, setCurrentPage] = useState(1);\n  const {\n    connectionIds,\n    participantCount,\n    localParticipantConnectionId,\n    screenShareParticipantConnectionId,\n  } = useSpace();\n  const { pinnedConnectionId } = React.useContext(UserContext);\n\n  const orderedConnectionIds = useMemo(() => {\n    const ids = [...connectionIds];\n    [\n      screenShareParticipantConnectionId,\n      pinnedConnectionId,\n      localParticipantConnectionId,\n    ].forEach((id) => {\n      if (id) {\n        pushToFront(ids, id);\n      }\n    });\n\n    return ids;\n  }, [\n    connectionIds,\n    localParticipantConnectionId,\n    pinnedConnectionId,\n    screenShareParticipantConnectionId,\n  ]);\n\n  const numberPages = useMemo(() => {\n    if (participantCount >= participantsPerPage) {\n      return Math.ceil(participantCount / participantsPerPage);\n    } else {\n      return 1;\n    }\n  }, [participantCount, participantsPerPage]);\n\n  const goToPreviousPage = useCallback(() => {\n    if (currentPage > 1) {\n      setCurrentPage((page) => page - 1);\n    }\n  }, [currentPage]);\n\n  const paginatedConnectionIds = useMemo(() => {\n    const startIndex = currentPage * participantsPerPage - participantsPerPage;\n    const endIndex = startIndex + participantsPerPage;\n    const pageParticipants = orderedConnectionIds.slice(startIndex, endIndex);\n    // if there are no participants, then only the local view will show up on the page\n    // we need to go back to the previous page.\n    if (pageParticipants.length === 0) {\n      goToPreviousPage();\n    }\n    return pageParticipants;\n  }, [\n    orderedConnectionIds,\n    currentPage,\n    participantsPerPage,\n    goToPreviousPage,\n  ]);\n\n  const hidePaginateCtrlRight = currentPage === numberPages;\n\n  const hidePaginateCtrlLeft = currentPage === 1;\n\n  const goToNextPage = () => {\n    if (currentPage < numberPages) {\n      setCurrentPage((page) => page + 1);\n    }\n  };\n\n  const widthBetweenPagination = numberPages === 1 ? width : width - 80;\n\n  return (\n    <Flex height=\"100%\" justifyContent=\"space-between\">\n      <Center w=\"40px\" marginLeft=\"12px\">\n        <IconButton\n          aria-label=\"Paginate left\"\n          icon={<ChevronLeftIcon />}\n          isRound={true}\n          onClick={goToPreviousPage}\n          opacity={numberPages === 1 ? 0 : 1}\n          hidden={hidePaginateCtrlLeft}\n          variant=\"outline\"\n          border=\"1px\"\n          borderColor=\"#666666\"\n          backgroundColor=\"#383838\"\n          _hover={{\n            border: \"1px solid #CCCCCC\",\n            backgroundColor: \"#383838\",\n          }}\n          _active={{\n            border: \"1px solid #CCCCCC\",\n            backgroundColor: \"#444444\",\n          }}\n        />\n      </Center>\n      <Center width={width} zIndex={100}>\n        {connectionIds.map((connectionId) => (\n          <ParticipantAudio key={connectionId} connectionId={connectionId} />\n        ))}\n        <GalleryLayout\n          width={widthBetweenPagination}\n          height={height}\n          gap={gap - 6}\n        >\n          {paginatedConnectionIds.map((connectionId) => {\n            return (\n              <Participant key={connectionId} connectionId={connectionId} />\n            );\n          })}\n        </GalleryLayout>\n      </Center>\n      <Center w=\"40px\" marginRight=\"12px\">\n        <IconButton\n          aria-label=\"Paginate right\"\n          icon={<ChevronRightIcon />}\n          isRound={true}\n          opacity={numberPages === 1 ? 0 : 1}\n          hidden={hidePaginateCtrlRight}\n          variant=\"outline\"\n          border=\"1px\"\n          borderColor=\"#666666\"\n          onClick={goToNextPage}\n          backgroundColor=\"#383838\"\n          _hover={{\n            border: \"1px solid #CCCCCC\",\n            backgroundColor: \"#383838\",\n          }}\n          _active={{\n            border: \"1px solid #CCCCCC\",\n            backgroundColor: \"#444444\",\n          }}\n          zIndex={2}\n        />\n      </Center>\n    </Flex>\n  );\n}\n"
  },
  {
    "path": "components/GalleryLayout.tsx",
    "content": "import React, {\n  Children,\n  cloneElement,\n  isValidElement,\n  ReactNode,\n  useMemo,\n} from \"react\";\nimport { Flex } from \"@chakra-ui/react\";\n\nimport { calcOptimalBoxes } from \"lib/gallery\";\n\ninterface Props {\n  gap?: number;\n  width: number;\n  height: number;\n  children: ReactNode;\n}\n\nconst GalleryLayout = ({ children, width, height, gap = 10 }: Props) => {\n  const bestFit = useMemo(() => {\n    if (children) {\n      return calcOptimalBoxes(\n        width,\n        height,\n        Children.count(children),\n        16 / 9,\n        gap\n      );\n    }\n  }, [children, width, height, gap]);\n\n  return (\n    <Flex\n      wrap=\"wrap\"\n      width=\"100%\"\n      height=\"100%\"\n      gap={`${gap}px`}\n      overflow=\"hidden\"\n      alignItems=\"center\"\n      alignContent=\"center\"\n      justifyContent=\"center\"\n    >\n      {Children.map(children, (child) => {\n        if (\n          isValidElement(child) &&\n          bestFit &&\n          bestFit.width &&\n          bestFit.height\n        ) {\n          return cloneElement(child as React.ReactElement<any>, {\n            width: bestFit.width,\n            height: bestFit.height,\n          });\n        } else {\n          return child;\n        }\n      })}\n    </Flex>\n  );\n};\n\nexport default GalleryLayout;\n"
  },
  {
    "path": "components/Header.tsx",
    "content": "import React from \"react\";\nimport Image from \"next/image\";\nimport { AiFillGithub } from \"react-icons/ai\";\nimport { Flex, Spacer } from \"@chakra-ui/react\";\n\nimport muxLogo from \"../public/mux-logo.svg\";\n\nexport default function Header(): JSX.Element {\n  return (\n    <Flex\n      zIndex={2}\n      padding=\"2\"\n      alignItems=\"center\"\n      justifyContent=\"space-between\"\n      backgroundColor=\"#292929\"\n      borderBottom=\"1px solid #e8e8e8\"\n    >\n      <Flex alignItems=\"center\" padding=\"10px\" width=\"290px\">\n        <a\n          href=\"https://www.mux.com/real-time-video\"\n          target=\"_blank\"\n          rel=\"noreferrer\"\n        >\n          <Image\n            priority\n            alt=\"logo\"\n            width={150}\n            src={muxLogo}\n            style={{ height: \"auto\" }}\n          />\n        </a>\n      </Flex>\n      <Spacer />\n      <Flex alignItems=\"center\" padding=\"10px\">\n        <a\n          href=\"https://github.com/muxinc/meet\"\n          target=\"_blank\"\n          rel=\"noreferrer\"\n        >\n          <AiFillGithub color=\"white\" size=\"40px\" />\n        </a>\n      </Flex>\n    </Flex>\n  );\n}\n"
  },
  {
    "path": "components/Meeting.tsx",
    "content": "import React, { useContext } from \"react\";\nimport { Center, Flex } from \"@chakra-ui/react\";\n\nimport { useSpace } from \"hooks/useSpace\";\nimport useWindowDimensions from \"hooks/useWindowDimension\";\n\nimport Gallery from \"./Gallery\";\nimport Timer from \"./Timer\";\nimport ScreenShareRenderer from \"./renderers/ScreenShareRenderer\";\nimport ChatRenderer from \"./renderers/ChatRenderer\";\nimport ChatContext from \"context/Chat\";\n\nconst headerHeight = 80;\nconst chatWidth = 300;\n\nexport default function Meeting(): JSX.Element {\n  let gap = 10;\n  const {\n    participantCount,\n    attachScreenShare,\n    isScreenShareActive,\n    spaceEndsAt,\n  } = useSpace();\n  const { isChatOpen } = useContext(ChatContext);\n  const { width = 0, height = 0 } = useWindowDimensions();\n\n  const availableWidth = width - (isChatOpen && width > 800 ? chatWidth : 0);\n\n  const paddingY = height < 600 ? 10 : 40;\n  const paddingX = availableWidth < 800 ? 40 : 60;\n\n  let galleryWidth = availableWidth - paddingX * 2;\n  if (isScreenShareActive) {\n    if (participantCount < 6) {\n      galleryWidth = availableWidth * 0.25 - paddingX;\n    } else {\n      galleryWidth = availableWidth * 0.33 - paddingX / 2;\n    }\n    galleryWidth = Math.max(galleryWidth, 160);\n  }\n  let galleryHeight = height - headerHeight - paddingY * 2;\n\n  let screenShareWidth = isScreenShareActive\n    ? availableWidth - galleryWidth\n    : 0;\n\n  let direction: \"row\" | \"column\" = \"row\";\n  if (width < height) {\n    gap = 8;\n    galleryWidth = availableWidth - paddingX * 2;\n    if (isScreenShareActive) {\n      direction = \"column\";\n      screenShareWidth = availableWidth;\n      galleryHeight = height - headerHeight - (availableWidth / 4) * 3;\n    }\n  }\n\n  let scaleFactor = 2.25;\n  const rows = Math.max(Math.ceil(galleryHeight / (90 * scaleFactor)), 1);\n  const columns = Math.max(Math.ceil(galleryWidth / (160 * scaleFactor)), 1);\n\n  const participantsPerPage = Math.round(rows * columns);\n\n  return (\n    <Flex width=\"100%\" height=\"100%\" direction=\"row\" position=\"relative\">\n      {spaceEndsAt && <Timer />}\n      <Flex\n        maxWidth={availableWidth}\n        height=\"100%\"\n        alignItems=\"center\"\n        justifyContent=\"center\"\n        direction={direction}\n        flexGrow={1}\n      >\n        <Center width={`${screenShareWidth}px`} maxHeight=\"100%\">\n          <ScreenShareRenderer attach={attachScreenShare} />\n        </Center>\n        <Gallery\n          gap={gap}\n          width={galleryWidth}\n          height={galleryHeight}\n          participantsPerPage={participantsPerPage}\n        />\n      </Flex>\n      <ChatRenderer show={width > 800 && isChatOpen} />\n    </Flex>\n  );\n}\n"
  },
  {
    "path": "components/MuteIndicator.tsx",
    "content": "import React from \"react\";\nimport { Box, Flex, Icon } from \"@chakra-ui/react\";\nimport { IoMicOffOutline } from \"react-icons/io5\";\n\ninterface Props {\n  isMuted: boolean;\n  parentHeight: number;\n}\n\nexport default function MuteIndicator({\n  isMuted,\n  parentHeight,\n}: Props): JSX.Element {\n  let left = \"2px\";\n  let bottom = \"15%\";\n  let marginLeft = \"3\";\n  let iconWidth = \"5\";\n  let iconHeight = \"5\";\n  let borderRadius = \"10px\";\n  let paddingX = \"2\";\n  let paddingY = \"1\";\n  if (parentHeight <= 250) {\n    left = \"0\";\n    bottom = \"5%\";\n    marginLeft = \"1\";\n  }\n  if (parentHeight <= 200) {\n    paddingX = \"1\";\n    borderRadius = \"0 10px 0 0\";\n    bottom = \"0\";\n    marginLeft = \"0\";\n    iconWidth = \"12px\";\n    iconHeight = \"12px\";\n  }\n  if (parentHeight <= 90) {\n    paddingX = \"0\";\n    paddingY = \"1.5px\";\n    borderRadius = \"0\";\n  }\n\n  return (\n    <Box\n      _groupHover={{\n        opacity: 1,\n        backgroundColor: \"transparent\",\n      }}\n      background=\"rgba(68, 68, 68, 0.75)\"\n      borderRadius={borderRadius}\n      color=\"white\"\n      marginLeft={marginLeft}\n      marginY=\"0\"\n      py={paddingY}\n      zIndex={10}\n      position=\"absolute\"\n      left={left}\n      bottom={bottom}\n    >\n      {isMuted && (\n        <Flex alignItems=\"center\" color=\"#FFFFFF\" px={paddingX}>\n          <Icon w={iconWidth} h={iconHeight} as={IoMicOffOutline} />\n        </Flex>\n      )}\n    </Box>\n  );\n}\n"
  },
  {
    "path": "components/Notifications.tsx",
    "content": "import { useState, useEffect } from \"react\";\nimport { useDisclosure } from \"@chakra-ui/react\";\n\nimport { useSpace } from \"hooks/useSpace\";\nimport { useUserMedia } from \"hooks/useUserMedia\";\n\nimport Sounds from \"./Sounds\";\nimport Toasts from \"./Toasts\";\nimport ErrorModal from \"./modals/ErrorModal\";\n\nexport default function Notifications(): JSX.Element {\n  const { joinError, screenShareError } = useSpace();\n  const { userMediaError } = useUserMedia();\n  const [errorModalTitle, setErrorModalTitle] = useState(\"\");\n  const [errorModalMessage, setErrorModalMessage] = useState(\"\");\n  const {\n    isOpen: isErrorModalOpen,\n    onOpen: onErrorModalOpen,\n    onClose: onErrorModalClose,\n  } = useDisclosure();\n\n  useEffect(() => {\n    if (joinError) {\n      setErrorModalTitle(\"Error joining space\");\n      setErrorModalMessage(joinError);\n      onErrorModalOpen();\n    }\n  }, [joinError, onErrorModalOpen]);\n\n  useEffect(() => {\n    if (screenShareError === \"Permission denied by system\") {\n      setErrorModalTitle(\"Can't share your screen\");\n      setErrorModalMessage(\n        \"Please check your browser has screen capture permissions and try restarting your browser if you continue to have issues.\"\n      );\n      onErrorModalOpen();\n    }\n  }, [screenShareError, onErrorModalOpen]);\n\n  useEffect(() => {\n    if (userMediaError === \"NotAllowedError\") {\n      setErrorModalTitle(\"Can't show your media\");\n      setErrorModalMessage(\n        \"Please check your browser has media capture (webcam and microphone) permissions and try restarting your browser if you continue to have issues.\"\n      );\n      onErrorModalOpen();\n    }\n    if (userMediaError === \"OverconstrainedError\") {\n    }\n\n    return () => {\n      onErrorModalClose();\n    };\n  }, [userMediaError, onErrorModalOpen, onErrorModalClose]);\n  return (\n    <>\n      <Sounds />\n      <Toasts />\n      <ErrorModal\n        title={errorModalTitle}\n        message={errorModalMessage}\n        isOpen={isErrorModalOpen}\n        onClose={onErrorModalClose}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "components/Participant.tsx",
    "content": "import React, { useMemo } from \"react\";\nimport { Box, Center, Flex } from \"@chakra-ui/react\";\n\nimport UserContext from \"context/User\";\nimport { useParticipant } from \"hooks/useParticipant\";\n\nimport Pin from \"./Pin\";\nimport VideoRenderer from \"./renderers/VideoRenderer\";\nimport ParticipantInfoBar from \"./ParticipantInfoBar\";\nimport ParticipantName from \"./ParticipantName\";\n\ninterface Props {\n  width?: number;\n  height?: number;\n  connectionId: string;\n}\n\nexport default function Participant({\n  width,\n  height,\n  connectionId,\n}: Props): JSX.Element {\n  const {\n    id,\n    isLocal,\n    isSpeaking,\n    hasMicTrack,\n    isMicTrackMuted,\n    isCameraOff,\n    cameraWidth,\n    cameraHeight,\n    displayName,\n    attachVideoElement,\n  } = useParticipant(connectionId);\n\n  const outlineWidth = 3;\n\n  return (\n    <Box\n      width={`${width! - outlineWidth * 2}px`}\n      height={`${height! - outlineWidth * 2}px`}\n      minWidth=\"160px\"\n      minHeight=\"90px\"\n      background=\"black\"\n      boxShadow={`0 0 0 ${\n        !isMicTrackMuted && isSpeaking ? outlineWidth : 1\n      }px ${!isMicTrackMuted && isSpeaking ? \"#FA50B5\" : \"black\"}`}\n      borderRadius=\"5px\"\n      margin={`${outlineWidth}px`}\n      overflow=\"hidden\"\n      position=\"relative\"\n      role=\"group\"\n    >\n      <VideoRenderer\n        local={isLocal}\n        width={cameraWidth}\n        height={cameraHeight}\n        attachFunc={attachVideoElement}\n        connectionId={connectionId}\n      />\n\n      <ParticipantInfoBar\n        name={displayName || id}\n        isMuted={!hasMicTrack || isMicTrackMuted}\n        parentHeight={height!}\n      />\n\n      {isCameraOff && (\n        <ParticipantName isSmall={width! <= 400}>\n          {displayName || id}\n        </ParticipantName>\n      )}\n\n      {!isLocal && <Pin connectionId={connectionId} />}\n    </Box>\n  );\n}\n"
  },
  {
    "path": "components/ParticipantAudio.tsx",
    "content": "import React from \"react\";\n\nimport { useParticipant } from \"hooks/useParticipant\";\n\nimport AudioRenderer from \"./renderers/AudioRenderer\";\n\ninterface Props {\n  connectionId: string;\n}\n\nconst ParticipantAudio = ({ connectionId }: Props) => {\n  const { isLocal, attachAudioElement } = useParticipant(connectionId);\n\n  return !isLocal ? <AudioRenderer attachFunc={attachAudioElement} /> : null;\n};\n\nexport default ParticipantAudio;\n"
  },
  {
    "path": "components/ParticipantInfoBar.tsx",
    "content": "import React from \"react\";\nimport { Flex, Text } from \"@chakra-ui/react\";\n\nimport MuteIndicator from \"./MuteIndicator\";\n\ninterface Props {\n  name: string;\n  isMuted: boolean;\n  parentHeight: number;\n}\n\nexport default function ParticipantInfoBar({\n  name,\n  isMuted,\n  parentHeight,\n}: Props): JSX.Element {\n  let height = \"40px\";\n  let fontSize = \"14px\";\n  if (parentHeight <= 250) {\n    height = \"30px\";\n  }\n  if (parentHeight <= 200) {\n    height = \"20px\";\n    fontSize = \"10px\";\n  }\n  if (parentHeight <= 90) {\n    height = \"15px\";\n    fontSize = \"10px\";\n  }\n\n  return (\n    <Flex\n      position=\"absolute\"\n      bottom=\"0\"\n      left=\"0\"\n      width=\"100%\"\n      height={height}\n      color=\"transparent\"\n      backgroundColor=\"transparent\"\n      _groupHover={{\n        color: \"white\",\n        background: \"rgba(50, 50, 50, 0.5)\",\n      }}\n      justifyContent=\"center\"\n      alignItems=\"center\"\n    >\n      <MuteIndicator parentHeight={parentHeight} isMuted={isMuted} />\n      <Text fontSize={fontSize} fontWeight=\"700\">\n        {name}\n      </Text>\n    </Flex>\n  );\n}\n"
  },
  {
    "path": "components/ParticipantName.tsx",
    "content": "import { memo } from \"react\";\nimport { Box, Center, Flex } from \"@chakra-ui/react\";\n\ninterface Props {\n  isSmall: boolean;\n  children: string;\n}\n\nfunction ParticipantName({ isSmall, children }: Props): JSX.Element {\n  return (\n    <Center\n      background=\"black\"\n      color=\"white\"\n      fontSize={isSmall ? \"20px\" : \"45px\"}\n      h=\"100%\"\n      position=\"absolute\"\n      top=\"0\"\n      w=\"100%\"\n    >\n      <Flex width=\"100%\" direction=\"column\" textAlign=\"center\">\n        <Box overflowWrap=\"anywhere\">{children}</Box>\n      </Flex>\n    </Center>\n  );\n}\n\nconst MemoizedName = memo(ParticipantName);\nexport default MemoizedName;\n"
  },
  {
    "path": "components/Pin.tsx",
    "content": "import React from \"react\";\nimport { IconButton } from \"@chakra-ui/react\";\nimport { MdPushPin, MdOutlinePushPin } from \"react-icons/md\";\n\nimport UserContext from \"context/User\";\n\ninterface Props {\n  connectionId: string;\n}\n\nexport default function Pin({ connectionId }: Props): JSX.Element {\n  const { pinnedConnectionId, setPinnedConnectionId } =\n    React.useContext(UserContext);\n\n  return (\n    <IconButton\n      size=\"sm\"\n      opacity={pinnedConnectionId === connectionId ? 1 : 0}\n      _groupHover={{\n        opacity: 1,\n      }}\n      aria-label=\"pin\"\n      onClick={() => {\n        if (setPinnedConnectionId) {\n          if (pinnedConnectionId === connectionId) {\n            setPinnedConnectionId(\"\");\n          } else {\n            setPinnedConnectionId(connectionId);\n          }\n        }\n      }}\n      variant=\"ghost\"\n      position=\"absolute\"\n      right={0}\n      top={0}\n      icon={\n        pinnedConnectionId === connectionId ? (\n          <MdPushPin />\n        ) : (\n          <MdOutlinePushPin />\n        )\n      }\n    />\n  );\n}\n"
  },
  {
    "path": "components/Sounds.tsx",
    "content": "import useSound from \"use-sound\";\nimport { SpaceEvent } from \"@mux/spaces-web\";\n\nimport { useSpaceEvent } from \"hooks/useSpaceEvent\";\nimport { useCallback } from \"react\";\nimport { useSpace } from \"hooks/useSpace\";\n\nconst participantSoundCutoff = 5;\n\nexport default function Sounds(): JSX.Element {\n  const { isJoined } = useSpace();\n\n  return <>{isJoined && <JoinedSounds />}</>;\n}\n\nfunction JoinedSounds(): JSX.Element {\n  const { participantCount } = useSpace();\n  const [playJoinSound] = useSound(\"/sounds/meet-join.mp3\");\n  const [playLeaveSound] = useSound(\"/sounds/meet-leave.mp3\");\n\n  useSpaceEvent(\n    SpaceEvent.ParticipantJoined,\n    useCallback(() => {\n      if (document[\"hidden\"] && participantCount < participantSoundCutoff) {\n        playJoinSound();\n      }\n    }, [playJoinSound, participantCount])\n  );\n\n  useSpaceEvent(\n    SpaceEvent.ParticipantLeft,\n    useCallback(() => {\n      if (document[\"hidden\"] && participantCount < participantSoundCutoff) {\n        playLeaveSound();\n      }\n    }, [playLeaveSound, participantCount])\n  );\n\n  return <></>;\n}\n"
  },
  {
    "path": "components/Stage.tsx",
    "content": "import React from \"react\";\nimport { Center, Heading, Spinner } from \"@chakra-ui/react\";\n\nimport { useSpace } from \"hooks/useSpace\";\n\nimport Controls from \"./Controls\";\nimport Meeting from \"./Meeting\";\nimport Notifications from \"./Notifications\";\nimport { ChatProvider } from \"context/Chat\";\n\nconst LoadingSpinner = () => {\n  return (\n    <>\n      <Spinner color=\"#FA50B5\" size=\"xl\" />\n      <Heading color=\"white\" ml={5}>\n        Joining Space...\n      </Heading>\n    </>\n  );\n};\n\nexport default function Stage(): JSX.Element {\n  const { isJoined } = useSpace();\n\n  return (\n    <>\n      <Notifications />\n      <ChatProvider>\n        <Center height=\"calc(100% - 80px)\" zIndex={1}>\n          {!isJoined ? <LoadingSpinner /> : <Meeting />}\n        </Center>\n        <Controls />\n      </ChatProvider>\n    </>\n  );\n}\n"
  },
  {
    "path": "components/Timer.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { useRouter } from \"next/router\";\nimport { Tooltip } from \"@chakra-ui/react\";\nimport styled from \"@emotion/styled\";\nimport moment from \"moment\";\n\nimport { useSpace } from \"hooks/useSpace\";\nimport { transientOptions } from \"lib/utils\";\n\nconst Display = styled(\"div\", transientOptions)<{ $isUrgent: boolean }>`\n  position: absolute;\n  display: flex;\n  flex-direction: column;\n  align-items: start;\n  background-color: #383838;\n  padding: 0.5em;\n  top: 12px;\n  left: 12px;\n  font-size: 1.5em;\n  font-variant-numeric: tabular-nums;\n  color: white;\n  user-select: none;\n  border-radius: 3px;\n  z-index: 200;\n\n  ${(props) =>\n    props.$isUrgent &&\n    `\n  #countdown {\n    color: red;\n  }\n  `}\n`;\n\nconst getClock = (diff: number) => {\n  const minutes = Math.floor((diff / 1000 / 60) % 60);\n  const seconds = Math.floor((diff / 1000) % 60);\n\n  const minuteDisplay = 10 > minutes ? \"0\" + minutes : minutes;\n  const secondDisplay = 10 > seconds ? \"0\" + seconds : seconds;\n\n  return `${minuteDisplay}:${secondDisplay}`;\n};\n\nconst Timer = (): JSX.Element => {\n  const { spaceEndsAt, leaveSpace } = useSpace();\n  const diff = moment(spaceEndsAt).diff(moment());\n  const [timeDisplay, setTimeDisplay] = useState<string>(\n    spaceEndsAt ? getClock(diff) : \"00:00\"\n  );\n  const router = useRouter();\n  const [isTwoMinutesLeft, setIsTwoMinutesLeft] = useState(\n    120 >= Math.floor(diff / 1000)\n  );\n\n  useEffect(() => {\n    const timer = setInterval(() => {\n      const diff = moment(spaceEndsAt).diff(moment());\n\n      if (diff > 0) {\n        const clock = getClock(diff);\n        setTimeDisplay(getClock(diff));\n        setIsTwoMinutesLeft(120 >= Math.floor(diff / 1000));\n      } else {\n        leaveSpace();\n        router.push(\"/\");\n      }\n    }, 1000);\n\n    return () => clearInterval(timer);\n  }, [router, leaveSpace, spaceEndsAt, isTwoMinutesLeft]);\n\n  return (\n    <Tooltip label=\"This demo space will close after the timer expires.\">\n      <Display $isUrgent={isTwoMinutesLeft}>\n        <span>Time Left</span>\n        <span id=\"countdown\">{timeDisplay}</span>\n      </Display>\n    </Tooltip>\n  );\n};\n\nexport default Timer;\n"
  },
  {
    "path": "components/Toasts.tsx",
    "content": "import styled from \"@emotion/styled\";\nimport { useRouter } from \"next/router\";\nimport { useCallback, useEffect, useRef } from \"react\";\nimport { Box, ToastId, useToast } from \"@chakra-ui/react\";\n\nimport { useSpace } from \"hooks/useSpace\";\n\nimport {\n  broadcastingToastConfig,\n  participantEventToastConfig,\n  sharingScreenToastConfig,\n  ToastIds,\n  viewingSharedScreenToastConfig,\n} from \"shared/toastConfigs\";\n\nconst ToastBox = styled(Box)`\n  color: #0a0a0b;\n  background: #cff1fc;\n  font-size: 14px;\n  padding: 15px 50px;\n  text-align: center;\n`;\n\nexport default function Toasts(): JSX.Element {\n  const toast = useToast();\n  const router = useRouter();\n  const { isReady: isRouterReady } = router;\n  const {\n    isBroadcasting,\n    isScreenShareActive,\n    stopScreenShare,\n    isLocalScreenShare,\n    screenShareParticipantName,\n  } = useSpace();\n  const screenshareToastRef = useRef<ToastId>();\n  const broadcastingToastRef = useRef<ToastId>();\n  const participantEventToastIdRefs = useRef<Array<ToastId>>([]);\n\n  const showLocalScreenshareToast = useCallback(() => {\n    if (!toast.isActive(ToastIds.SHARING_SCREEN_TOAST_ID)) {\n      screenshareToastRef.current = toast({\n        ...sharingScreenToastConfig,\n        render: ({ onClose }) => {\n          return (\n            <ToastBox>\n              You are sharing your screen.\n              <Box\n                as=\"button\"\n                backgroundColor=\"transparent\"\n                border=\"1px solid #0A0A0B\"\n                height=\"30px\"\n                borderRadius=\"15px\"\n                paddingLeft=\"15px\"\n                paddingRight=\"15px\"\n                marginLeft=\"20px\"\n                onClick={() => {\n                  stopScreenShare();\n                  onClose();\n                }}\n              >\n                Stop sharing\n              </Box>\n            </ToastBox>\n          );\n        },\n      });\n    }\n  }, [toast, stopScreenShare]);\n\n  const showRemoteScreenshareToast = useCallback(() => {\n    if (\n      !toast.isActive(ToastIds.VIEWING_SHARED_SCREEN_TOAST_ID) &&\n      screenShareParticipantName\n    ) {\n      screenshareToastRef.current = toast({\n        ...viewingSharedScreenToastConfig,\n        render: () => (\n          <ToastBox>\n            {screenShareParticipantName} is sharing their screen.\n          </ToastBox>\n        ),\n      });\n    }\n  }, [toast, screenShareParticipantName]);\n\n  const hideScreenshareToast = useCallback(() => {\n    if (screenshareToastRef.current) {\n      toast.close(screenshareToastRef.current);\n    }\n  }, [toast]);\n\n  const showBroadcastToast = useCallback(() => {\n    if (!toast.isActive(ToastIds.BROADCASTING_SCREEN_TOAST_ID)) {\n      broadcastingToastRef.current = toast({\n        ...broadcastingToastConfig,\n        render: () => (\n          <ToastBox>{\"⦿ Space is currently being broadcast\"}</ToastBox>\n        ),\n      });\n    }\n  }, [toast]);\n\n  const hideBroadcastToast = useCallback(() => {\n    if (broadcastingToastRef.current) {\n      toast.close(broadcastingToastRef.current);\n    }\n  }, [toast]);\n\n  const pruneParticipantEventRefs = useCallback(() => {\n    // Don't show more than 10 participant event toasts on screen at a time, close the oldest if necessary\n    while (participantEventToastIdRefs.current.length > 9) {\n      let oldest = participantEventToastIdRefs.current.shift();\n      if (oldest) {\n        toast.close(oldest);\n      }\n    }\n\n    // Prune ids of toasts that have already closed on their own\n    participantEventToastIdRefs.current =\n      participantEventToastIdRefs.current.filter((ref) => toast.isActive(ref));\n  }, [participantEventToastIdRefs, toast]);\n\n  const showParticipantEventToast = useCallback(\n    (eventDescription: string) => {\n      pruneParticipantEventRefs();\n      participantEventToastIdRefs.current.push(\n        toast({\n          ...participantEventToastConfig,\n          render: () => <ToastBox>{eventDescription}</ToastBox>,\n        })\n      );\n    },\n    [toast, pruneParticipantEventRefs]\n  );\n\n  useEffect(() => {\n    if (isScreenShareActive) {\n      if (isLocalScreenShare) {\n        showLocalScreenshareToast();\n      } else {\n        showRemoteScreenshareToast();\n      }\n    } else {\n      hideScreenshareToast();\n    }\n  }, [\n    isScreenShareActive,\n    isLocalScreenShare,\n    showLocalScreenshareToast,\n    showRemoteScreenshareToast,\n    hideScreenshareToast,\n  ]);\n\n  useEffect(() => {\n    if (isBroadcasting) {\n      showBroadcastToast();\n    } else {\n      hideBroadcastToast();\n    }\n  }, [isBroadcasting, showBroadcastToast, hideBroadcastToast]);\n\n  useEffect(() => {\n    if (!isRouterReady) return;\n\n    function closeAllToasts() {\n      hideBroadcastToast();\n      hideScreenshareToast();\n      for (let toastRef in participantEventToastIdRefs.current) {\n        toast.close(toastRef);\n      }\n      pruneParticipantEventRefs();\n    }\n\n    router.events.on(\"routeChangeStart\", closeAllToasts);\n    return () => {\n      router.events.off(\"routeChangeStart\", closeAllToasts);\n    };\n  }, [\n    isRouterReady,\n    router,\n    toast,\n    hideBroadcastToast,\n    hideScreenshareToast,\n    pruneParticipantEventRefs,\n  ]);\n\n  return <></>;\n}\n"
  },
  {
    "path": "components/UserInteractionPrompt.tsx",
    "content": "import React, {\n  useState,\n  useEffect,\n  useMemo,\n  useRef,\n  useContext,\n  MutableRefObject,\n} from \"react\";\nimport Image from \"next/image\";\nimport {\n  Button,\n  Flex,\n  FormControl,\n  FormHelperText,\n  FormLabel,\n  HStack,\n  Input,\n  Stack,\n} from \"@chakra-ui/react\";\nimport { HiOutlineArrowNarrowRight } from \"react-icons/hi\";\n\nimport UserContext from \"context/User\";\nimport { useUserMedia } from \"hooks/useUserMedia\";\n\nimport MicrophoneButton from \"components/controls/buttons/MicrophoneButton\";\nimport CameraButton from \"components/controls/buttons/CameraButton\";\n\nimport muxLogo from \"../public/mux-logo.svg\";\n\ninterface Props {\n  onInteraction: () => void;\n  participantNameRef: MutableRefObject<string>;\n}\n\nexport default function UserInteractionPrompt({\n  onInteraction,\n  participantNameRef,\n}: Props): JSX.Element {\n  const nameInputRef = useRef<HTMLInputElement>(null);\n  const didPopulateDevicesRef = useRef(false);\n  const user = useContext(UserContext);\n  const [participantName, setParticipantName] = useState(\"\");\n  const [hasBlurredNameInput, setHasBlurredNameInput] = useState(false);\n\n  const { requestPermissionAndPopulateDevices } = useUserMedia();\n\n  useEffect(() => {\n    if (nameInputRef.current) {\n      nameInputRef.current.focus();\n    }\n  }, []);\n\n  useEffect(() => {\n    setParticipantName(user.participantName);\n  }, [user.participantName]);\n\n  const isNameInputInvalid = useMemo(\n    () => !participantName && hasBlurredNameInput,\n    [participantName, hasBlurredNameInput]\n  );\n\n  const handleParticipantNameChange = (event: {\n    target: { value: string };\n  }) => {\n    setParticipantName(event.target.value);\n  };\n\n  useEffect(() => {\n    if (didPopulateDevicesRef.current === false) {\n      didPopulateDevicesRef.current = true;\n      requestPermissionAndPopulateDevices();\n    }\n  }, [requestPermissionAndPopulateDevices]);\n\n  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {\n    event?.preventDefault();\n    participantNameRef.current = participantName;\n    user.setParticipantName(participantName);\n    user.setInteractionRequired(false);\n    onInteraction();\n  };\n\n  return (\n    <Flex\n      gap=\"2rem\"\n      height=\"100%\"\n      overflow=\"hidden\"\n      direction=\"column\"\n      alignItems=\"center\"\n      justifyContent=\"center\"\n    >\n      <Image\n        priority\n        alt=\"logo\"\n        width={300}\n        src={muxLogo}\n        style={{ zIndex: 0, height: \"auto\" }}\n      />\n      <form onSubmit={handleSubmit}>\n        <Stack spacing=\"4\">\n          <FormControl\n            isInvalid={isNameInputInvalid}\n            onBlur={() => setHasBlurredNameInput(true)}\n          >\n            <FormLabel textAlign=\"center\" color=\"white\">\n              Enter your name\n            </FormLabel>\n            <Input\n              placeholder=\"Your name\"\n              color=\"white\"\n              size=\"lg\"\n              maxLength={40}\n              id=\"participant_name\"\n              value={participantName}\n              onChange={handleParticipantNameChange}\n              variant=\"flushed\"\n              isRequired={true}\n              ref={nameInputRef}\n            />\n            <FormHelperText\n              color={!isNameInputInvalid ? \"transparent\" : \"#E22C3E\"}\n            >\n              This cannot be empty.\n            </FormHelperText>\n          </FormControl>\n\n          <Button\n            type=\"submit\"\n            isDisabled={!participantName}\n            size=\"lg\"\n            rightIcon={<HiOutlineArrowNarrowRight />}\n            variant=\"flushed\"\n            color=\"white\"\n          >\n            Join Space\n          </Button>\n        </Stack>\n      </form>\n      <HStack marginTop=\"2rem\">\n        <MicrophoneButton />\n        <CameraButton />\n      </HStack>\n    </Flex>\n  );\n}\n"
  },
  {
    "path": "components/controls/ControlsCenter.tsx",
    "content": "import React from \"react\";\nimport { HStack } from \"@chakra-ui/react\";\n\nimport MicrophoneButton from \"./buttons/MicrophoneButton\";\nimport CameraButton from \"./buttons/CameraButton\";\nimport ScreenShareButton from \"./buttons/ScreenShareButton\";\nimport SettingsButton from \"./buttons/SettingsButton\";\nimport ChatButton from \"./buttons/ChatButton\";\nimport useWindowDimensions from \"hooks/useWindowDimension\";\nimport { useSpace } from \"hooks/useSpace\";\n\ninterface Props {\n  onLeave: () => void;\n}\n\nexport default function ControlsCenter({ onLeave }: Props): JSX.Element {\n  const { width = 0 } = useWindowDimensions();\n  const { isLocalScreenShareSupported } = useSpace();\n\n  return (\n    <HStack spacing=\"24px\">\n      <MicrophoneButton />\n      <CameraButton />\n      {isLocalScreenShareSupported && <ScreenShareButton />}\n      {width > 800 && <ChatButton />}\n      <SettingsButton onLeave={onLeave} />\n    </HStack>\n  );\n}\n"
  },
  {
    "path": "components/controls/ControlsLeft.tsx",
    "content": "import React from \"react\";\nimport Image from \"next/image\";\nimport { Flex } from \"@chakra-ui/react\";\n\nimport muxLogo from \"../../public/mux-logo.svg\";\n\nexport default function ControlsLeft(): JSX.Element {\n  return (\n    <Flex\n      alignItems=\"center\"\n      display={{ base: \"none\", md: \"flex\" }}\n      width=\"290px\"\n    >\n      <a\n        href=\"https://www.mux.com/real-time-video\"\n        target=\"_blank\"\n        rel=\"noreferrer\"\n      >\n        <Image\n          priority\n          alt=\"logo\"\n          width={150}\n          src={muxLogo}\n          style={{ height: \"auto\" }}\n        />\n      </a>\n    </Flex>\n  );\n}\n"
  },
  {
    "path": "components/controls/ControlsRight.tsx",
    "content": "import React from \"react\";\nimport { Button, Flex, Text } from \"@chakra-ui/react\";\nimport LeaveIcon from \"components/icons/LeaveIcon\";\nimport SpaceContext from \"context/Space\";\nimport styled from \"@emotion/styled\";\nimport useWindowDimensions from \"hooks/useWindowDimension\";\n\ninterface Props {\n  onLeave: () => void;\n}\n\nconst ParticipantsLabel = styled.span`\n  color: #cccccc;\n  font-size: 12px;\n  line-height: 12px;\n  text-transform: uppercase;\n  letter-spacing: 0.8px;\n`;\n\nconst ParticipantCount = styled.span`\n  font-size: 21px;\n  line-height: 21px;\n  letter-spacing: -0.5px;\n  margin-top: 5px;\n`;\n\nexport default function ControlsRight({ onLeave }: Props): JSX.Element {\n  const { participantCount } = React.useContext(SpaceContext);\n\n  const { width } = useWindowDimensions();\n  const hideParticipantCount = (width && width < 1000) || false;\n\n  return (\n    <Flex\n      alignItems=\"center\"\n      direction=\"row-reverse\"\n      width=\"290px\"\n      height=\"46px\"\n    >\n      <Button\n        variant=\"muxDestructive\"\n        display={{ base: \"none\", sm: \"flex\" }}\n        flexDirection=\"row\"\n        marginLeft=\"30px\"\n        padding=\"10px 20px\"\n        onClick={onLeave}\n      >\n        <LeaveIcon />\n        <Text paddingLeft=\"10px\">Leave</Text>\n      </Button>\n      <Flex\n        alignItems=\"center\"\n        direction=\"column\"\n        color=\"white\"\n        fontWeight=\"bold\"\n        marginTop=\"2px\"\n      >\n        {!hideParticipantCount && (\n          <>\n            <ParticipantsLabel>Participants</ParticipantsLabel>\n            <ParticipantCount>{participantCount}</ParticipantCount>\n          </>\n        )}\n      </Flex>\n    </Flex>\n  );\n}\n"
  },
  {
    "path": "components/controls/buttons/CameraButton.tsx",
    "content": "import React, { useCallback } from \"react\";\nimport {\n  Box,\n  ButtonGroup,\n  Flex,\n  IconButton,\n  Menu,\n  MenuButton,\n  MenuItem,\n  MenuList,\n  Tooltip,\n  Text,\n} from \"@chakra-ui/react\";\nimport { useHotkeys } from \"react-hotkeys-hook\";\nimport { AiOutlineCheck } from \"react-icons/ai\";\n\nimport UserContext from \"context/User\";\nimport { useSpace } from \"hooks/useSpace\";\nimport { useUserMedia } from \"hooks/useUserMedia\";\n\nimport ChevronIcon from \"components/icons/ChevronIcon\";\nimport MuteCameraIcon from \"components/icons/MuteCameraIcon\";\nimport UnmuteCameraIcon from \"components/icons/UnmuteCameraIcon\";\n\nexport default function CameraButton(): JSX.Element {\n  const { cameraOff, setCameraOff, cameraDeviceId, setCameraDeviceId } =\n    React.useContext(UserContext);\n  const { isJoined, publishCamera, unPublishDevice } = useSpace();\n  const { cameraDevices, stopActiveCamera } = useUserMedia();\n\n  const selectCameraDevice = useCallback(\n    async (deviceId: string) => {\n      setCameraDeviceId(deviceId);\n\n      if (isJoined && !cameraOff) {\n        publishCamera(deviceId);\n      }\n    },\n    [isJoined, setCameraDeviceId, cameraOff, publishCamera]\n  );\n\n  const toggleCamera = useCallback(async () => {\n    if (cameraOff) {\n      setCameraOff(false);\n      if (isJoined) {\n        publishCamera(cameraDeviceId);\n      }\n    } else {\n      setCameraOff(true);\n      stopActiveCamera();\n      if (isJoined) {\n        unPublishDevice(cameraDeviceId);\n      }\n    }\n  }, [\n    isJoined,\n    cameraOff,\n    setCameraOff,\n    cameraDeviceId,\n    stopActiveCamera,\n    publishCamera,\n    unPublishDevice,\n  ]);\n\n  const hotkeyText = \"⌘ + e\"; // adding this here to make it easy to change later. appears in tooltip on button.\n  useHotkeys(\n    \"meta+e\",\n    () => {\n      toggleCamera();\n    },\n    { preventDefault: true },\n    [toggleCamera]\n  );\n\n  const ariaLabel = cameraOff ? \"Unhide\" : \"Hide\";\n\n  return (\n    <ButtonGroup position=\"relative\">\n      <Tooltip\n        label={\n          cameraOff\n            ? `Enable Video (${hotkeyText})`\n            : `Disable Video (${hotkeyText})`\n        }\n      >\n        <IconButton\n          variant=\"control\"\n          aria-label={ariaLabel}\n          icon={cameraOff ? <UnmuteCameraIcon /> : <MuteCameraIcon />}\n          onClick={toggleCamera}\n        />\n      </Tooltip>\n      <Menu placement=\"top\">\n        {({ isOpen }) => (\n          <>\n            <MenuButton\n              position=\"absolute\"\n              top=\"0\"\n              right=\"0\"\n              as={IconButton}\n              variant=\"controlMenu\"\n              aria-label=\"Options\"\n              icon={<ChevronIcon />}\n              zIndex={100}\n              minWidth=\"20px\"\n              {...(isOpen && { transform: \"rotate(180deg)\" })}\n            />\n            <MenuList\n              background=\"#383838\"\n              border=\"1px solid #323232\"\n              color=\"#CCCCCC\"\n              padding=\"5px 10px\"\n            >\n              <Text userSelect=\"none\" paddingX=\"12px\" paddingY=\"6px\">\n                CAMERA\n              </Text>\n              {cameraDevices.map((device) => {\n                return (\n                  <MenuItem\n                    key={device.deviceId}\n                    onClick={() => selectCameraDevice(device.deviceId)}\n                  >\n                    <Flex alignItems=\"center\">\n                      {device.label}\n                      {cameraDeviceId === device.deviceId && (\n                        <Box marginLeft=\"10px\">\n                          <AiOutlineCheck />\n                        </Box>\n                      )}\n                    </Flex>\n                  </MenuItem>\n                );\n              })}\n            </MenuList>\n          </>\n        )}\n      </Menu>\n    </ButtonGroup>\n  );\n}\n"
  },
  {
    "path": "components/controls/buttons/ChatButton.tsx",
    "content": "import React, { useContext } from \"react\";\nimport { IconButton, Tooltip } from \"@chakra-ui/react\";\n\nimport ChatIcon from \"../../icons/ChatIcon\";\nimport ChatContext from \"context/Chat\";\nimport styled from \"@emotion/styled\";\n\ninterface Props {\n  isActive: boolean;\n  hasNotifications: boolean;\n}\n\nconst UnreadCircle = styled.div`\n  position: absolute;\n  width: 10px;\n  height: 10px;\n  border-radius: 5px;\n  background-color: #fa50b5;\n  top: calc(50% - 17.5px);\n  right: calc(50% - 17.5px);\n`;\n\nexport default function ChatButton(): JSX.Element {\n  const { numUnreadMessages, isChatOpen, openChat, closeChat } =\n    useContext(ChatContext);\n\n  return (\n    <Tooltip label={isChatOpen ? \"Close chat\" : \"Open chat\"}>\n      <IconButton\n        variant=\"control\"\n        aria-label=\"Toggle chat\"\n        icon={\n          <>\n            <ChatIcon />\n            {numUnreadMessages > 0 && <UnreadCircle />}\n          </>\n        }\n        {...(isChatOpen && {\n          background: \"#3E4247\",\n          border: \"1px solid #FFFFFF\",\n        })}\n        onClick={isChatOpen ? closeChat : openChat}\n      />\n    </Tooltip>\n  );\n}\n"
  },
  {
    "path": "components/controls/buttons/MicrophoneButton.tsx",
    "content": "import React, { useCallback, useEffect, useState } from \"react\";\nimport {\n  Box,\n  Flex,\n  IconButton,\n  Menu,\n  MenuButton,\n  MenuItem,\n  MenuList,\n  Tooltip,\n  Text,\n  ButtonGroup,\n} from \"@chakra-ui/react\";\nimport { useHotkeys } from \"react-hotkeys-hook\";\nimport { AiOutlineCheck } from \"react-icons/ai\";\n\nimport UserContext from \"context/User\";\nimport { useSpace } from \"hooks/useSpace\";\nimport { useUserMedia } from \"hooks/useUserMedia\";\n\nimport ChevronIcon from \"components/icons/ChevronIcon\";\nimport MuteMicrophoneIcon from \"components/icons/MuteMicrophoneIcon\";\nimport UnmuteMicrophoneIcon from \"components/icons/UnmuteMicrophoneIcon\";\n\nexport default function MicrophoneButton(): JSX.Element {\n  const {\n    userWantsMicMuted,\n    setUserWantsMicMuted,\n    microphoneDeviceId,\n    setMicrophoneDeviceId,\n  } = React.useContext(UserContext);\n  const {\n    microphoneDevices,\n    muteActiveMicrophone,\n    unMuteActiveMicrophone,\n    getActiveMicrophoneLevel,\n  } = useUserMedia();\n  const { isJoined, publishMicrophone } = useSpace();\n  const [temporaryUnmute, setTemporaryUnmute] = useState(false);\n  const [mouseOver, setMouseOver] = useState(false);\n  const [micLevelPercent, setMicLevelPercent] = useState(0);\n  const [micDistorted, setMicDistorted] = useState(false);\n\n  const requestRef = React.useRef<number>();\n\n  const selectAudioDevice = useCallback(\n    async (deviceId: string) => {\n      setMicrophoneDeviceId(deviceId);\n      if (isJoined) {\n        publishMicrophone(deviceId);\n      }\n    },\n    [isJoined, setMicrophoneDeviceId, publishMicrophone]\n  );\n\n  const toggleMicrophone = useCallback(() => {\n    if (userWantsMicMuted) {\n      setUserWantsMicMuted(false);\n      unMuteActiveMicrophone();\n    } else {\n      setUserWantsMicMuted(true);\n      muteActiveMicrophone();\n    }\n  }, [\n    userWantsMicMuted,\n    setUserWantsMicMuted,\n    muteActiveMicrophone,\n    unMuteActiveMicrophone,\n  ]);\n\n  const animateMeter = useCallback(() => {\n    if (requestRef.current != undefined && getActiveMicrophoneLevel) {\n      let levels = getActiveMicrophoneLevel();\n\n      if (levels) {\n        // Convert to 0-100 scale\n        let scaled = (Math.max(-60, levels.avgDb) + 60) * 1.66;\n        setMicLevelPercent(scaled);\n\n        if (levels.peakDb > -0.5) {\n          setMicDistorted(true);\n        } else {\n          setMicDistorted(false);\n        }\n      } else {\n        setMicLevelPercent(0);\n        setMicDistorted(false);\n      }\n    }\n    requestRef.current = requestAnimationFrame(animateMeter);\n  }, [getActiveMicrophoneLevel]);\n\n  useEffect(() => {\n    requestRef.current = requestAnimationFrame(animateMeter);\n    return () => {\n      if (requestRef.current) {\n        cancelAnimationFrame(requestRef.current);\n      }\n    };\n  }, [animateMeter]);\n\n  const hotkeyText = \"⌘ + d\"; // adding this here to make it easy to change later. appears in tooltip on button.\n  useHotkeys(\n    \"meta+d\",\n    () => {\n      toggleMicrophone();\n    },\n    { preventDefault: true },\n    [toggleMicrophone]\n  );\n\n  useHotkeys(\n    \"space\",\n    () => {\n      if (userWantsMicMuted && !temporaryUnmute) {\n        unMuteActiveMicrophone();\n        setTemporaryUnmute(true);\n      }\n    },\n    { keydown: true, preventDefault: true },\n    [unMuteActiveMicrophone, userWantsMicMuted, temporaryUnmute]\n  );\n  useHotkeys(\n    \"space\",\n    () => {\n      if (temporaryUnmute) {\n        setTemporaryUnmute(false);\n        if (userWantsMicMuted) {\n          muteActiveMicrophone();\n        }\n      }\n    },\n    { keyup: true, preventDefault: true },\n    [muteActiveMicrophone, userWantsMicMuted, temporaryUnmute]\n  );\n\n  const ariaLabel = userWantsMicMuted ? \"Unmute\" : \"Mute\";\n\n  return (\n    <ButtonGroup position=\"relative\">\n      <Tooltip\n        label={\n          userWantsMicMuted ? `Unmute (${hotkeyText})` : `Mute (${hotkeyText})`\n        }\n      >\n        <IconButton\n          variant=\"control\"\n          aria-label={ariaLabel}\n          onMouseEnter={() => setMouseOver(true)}\n          onMouseLeave={() => setMouseOver(false)}\n          icon={\n            userWantsMicMuted && !temporaryUnmute ? (\n              <MuteMicrophoneIcon />\n            ) : (\n              <UnmuteMicrophoneIcon />\n            )\n          }\n          onClick={toggleMicrophone}\n          {...(isJoined &&\n            (micDistorted\n              ? {\n                  background: `radial-gradient(50% 50% at 50% 50%, rgba(255, 92, 56, 0.75) 0%, rgba(255, 92, 56, 0) ${micLevelPercent}%)`,\n                }\n              : {\n                  background: `radial-gradient(50% 50% at 50% 50%, rgba(27, 227, 73, 0.75) 0%, rgba(27, 227, 73, 0) ${micLevelPercent}%)`,\n                }))}\n        />\n      </Tooltip>\n      <Menu placement=\"top\">\n        {({ isOpen }) => (\n          <>\n            <MenuButton\n              position=\"absolute\"\n              top=\"0px\"\n              right=\"0px\"\n              zIndex={100}\n              as={IconButton}\n              aria-label=\"Options\"\n              icon={<ChevronIcon />}\n              variant=\"controlMenu\"\n              minWidth=\"20px\"\n              {...(isOpen && { transform: \"rotate(180deg)\" })}\n            />\n            <MenuList\n              background=\"#383838\"\n              border=\"1px solid #323232\"\n              color=\"#CCCCCC\"\n              padding=\"5px 10px\"\n            >\n              <Text userSelect=\"none\" paddingX=\"12px\" paddingY=\"6px\">\n                MICROPHONE\n              </Text>\n              {microphoneDevices.map((device: MediaDeviceInfo) => {\n                return (\n                  <MenuItem\n                    key={device.deviceId}\n                    onClick={() => selectAudioDevice(device.deviceId)}\n                  >\n                    <Flex alignItems=\"center\">\n                      {device.label}\n                      {microphoneDeviceId == device.deviceId && (\n                        <Box marginLeft=\"10px\">\n                          <AiOutlineCheck />\n                        </Box>\n                      )}\n                    </Flex>\n                  </MenuItem>\n                );\n              })}\n            </MenuList>\n          </>\n        )}\n      </Menu>\n      ;\n    </ButtonGroup>\n  );\n}\n"
  },
  {
    "path": "components/controls/buttons/ScreenShareButton.tsx",
    "content": "import React from \"react\";\nimport { IconButton, Tooltip } from \"@chakra-ui/react\";\n\nimport { useSpace } from \"hooks/useSpace\";\n\nimport ScreenShareIcon from \"../../icons/ScreenShareIcon\";\n\nexport default function ScreenShareButton(): JSX.Element {\n  const {\n    isLocalScreenShare,\n    isScreenShareActive,\n    startScreenShare,\n    stopScreenShare,\n  } = useSpace();\n\n  return (\n    <Tooltip\n      label={\n        isScreenShareActive\n          ? isLocalScreenShare\n            ? \"Stop screen-share\"\n            : \"Someone else is currently screen-sharing\"\n          : \"Share Screen\"\n      }\n    >\n      <IconButton\n        variant=\"control\"\n        aria-label=\"Share Screen\"\n        isDisabled={isScreenShareActive && !isLocalScreenShare}\n        icon={<ScreenShareIcon />}\n        {...(isLocalScreenShare && {\n          background: \"#3E4247\",\n          border: \"1px solid #FFFFFF\",\n        })}\n        onClick={isLocalScreenShare ? stopScreenShare : startScreenShare}\n      />\n    </Tooltip>\n  );\n}\n"
  },
  {
    "path": "components/controls/buttons/SendButton.tsx",
    "content": "import React, { useContext } from \"react\";\nimport { IconButton, Tooltip } from \"@chakra-ui/react\";\n\nimport styled from \"@emotion/styled\";\nimport SendIcon from \"components/icons/SendIcon\";\nimport { transientOptions } from \"lib/utils\";\n\ninterface Props {\n  isButtonEnabled: boolean;\n  handleOnClick: () => void;\n}\n\nconst StatefulSendButton = styled(IconButton, transientOptions)<{\n  $isButtonEnabled: boolean;\n}>`\n  opacity: ${(props) => (props.$isButtonEnabled ? \"1\" : \"0.25\")};\n  user-select: none;\n`;\n\nexport default function SendButton({\n  isButtonEnabled,\n  handleOnClick,\n}: Props): JSX.Element {\n  return (\n    <StatefulSendButton\n      tabIndex={-1}\n      $isButtonEnabled={isButtonEnabled}\n      width=\"18px\"\n      height=\"15px\"\n      variant=\"link\"\n      aria-label=\"Send message\"\n      icon={<SendIcon />}\n      onClick={handleOnClick}\n      disabled={!isButtonEnabled}\n    />\n  );\n}\n"
  },
  {
    "path": "components/controls/buttons/SettingsButton.tsx",
    "content": "import React from \"react\";\nimport {\n  Box,\n  IconButton,\n  Menu,\n  MenuButton,\n  MenuItem,\n  MenuList,\n  useDisclosure,\n  useToast,\n} from \"@chakra-ui/react\";\nimport { useRouter } from \"next/router\";\n\nimport { copyLinkToastConfig, ToastIds } from \"shared/toastConfigs\";\nimport { useSpace } from \"hooks/useSpace\";\nimport useWindowDimensions from \"hooks/useWindowDimension\";\n\nimport SettingsIcon from \"components/icons/SettingsIcon\";\nimport RenameParticipantModal from \"components/modals/RenameParticipantModal\";\n\ninterface Props {\n  onLeave: () => void;\n}\n\nexport default function SettingsButton({ onLeave }: Props): JSX.Element {\n  const toast = useToast();\n  const router = useRouter();\n  const { width } = useWindowDimensions();\n  const { isLocalScreenShare } = useSpace();\n\n  const {\n    isOpen: isRenameModalOpen,\n    onOpen: onRenameModalOpen,\n    onClose: onRenameModalClose,\n  } = useDisclosure();\n\n  const smallWindowWidth = (width && width < 480) || false;\n\n  const shareLink = () => {\n    navigator.clipboard.writeText(\n      `${window.location.protocol}//${window.location.host}/space/${router.query[\"id\"]}`\n    );\n    if (!toast.isActive(ToastIds.COPY_LINK_TOAST_ID)) {\n      toast(copyLinkToastConfig);\n    }\n  };\n\n  return (\n    <>\n      <RenameParticipantModal\n        isOpen={isRenameModalOpen}\n        onClose={onRenameModalClose}\n      />\n      <Box>\n        <Menu placement=\"top\">\n          <MenuButton\n            as={IconButton}\n            variant=\"control\"\n            aria-label=\"Options\"\n            icon={<SettingsIcon />}\n          />\n          <MenuList\n            background=\"#383838\"\n            border=\"1px solid #323232\"\n            color=\"#CCCCCC\"\n            padding=\"5px 10px\"\n            width=\"200px\"\n          >\n            <MenuItem disabled={isLocalScreenShare} onClick={onRenameModalOpen}>\n              Change Name\n            </MenuItem>\n            <MenuItem onClick={shareLink}>Copy Invite Link</MenuItem>\n            {smallWindowWidth && <MenuItem onClick={onLeave}>Leave</MenuItem>}\n          </MenuList>\n        </Menu>\n      </Box>\n    </>\n  );\n}\n"
  },
  {
    "path": "components/icons/ChatIcon.tsx",
    "content": "import React from \"react\";\nimport Image from \"next/image\";\n\nimport chat from \"../../public/chat.svg\";\n\nexport default function ChatIcon(): JSX.Element {\n  return (\n    <Image\n      priority\n      alt=\"toggle chat\"\n      width={25}\n      height={25}\n      src={chat}\n      style={{ width: \"25px\", height: \"25px\" }}\n    />\n  );\n}\n"
  },
  {
    "path": "components/icons/ChevronIcon.tsx",
    "content": "import React from \"react\";\nimport Image from \"next/image\";\n\nimport chevronUp from \"../../public/chevronUp.svg\";\n\nexport default function ChevronIcon(): JSX.Element {\n  return (\n    <Image priority alt=\"open menu\" width={12} height={8} src={chevronUp} />\n  );\n}\n"
  },
  {
    "path": "components/icons/ChevronLeftIcon.tsx",
    "content": "import React from \"react\";\nimport Image from \"next/image\";\n\nimport chevronLeft from \"../../public/chevronLeft.svg\";\n\nexport default function ChevronLeftIcon(): JSX.Element {\n  return <Image alt=\"paginate left\" width={7} height={12} src={chevronLeft} />;\n}\n"
  },
  {
    "path": "components/icons/ChevronRightIcon.tsx",
    "content": "import React from \"react\";\nimport Image from \"next/image\";\n\nimport chevronRight from \"../../public/chevronRight.svg\";\n\nexport default function ChevronRightIcon(): JSX.Element {\n  return (\n    <Image alt=\"paginate right\" width={7} height={12} src={chevronRight} />\n  );\n}\n"
  },
  {
    "path": "components/icons/LeaveIcon.tsx",
    "content": "import React from \"react\";\n\nexport default function LeaveIcon(): JSX.Element {\n  return (\n    <svg\n      width=\"17\"\n      height=\"16\"\n      viewBox=\"0 0 17 16\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M0.5 0C0.223858 0 0 0.223858 0 0.5V15.5C0 15.7761 0.223858 16 0.5 16H5.5C5.77614 16 6 15.7761 6 15.5C6 15.2239 5.77614 15 5.5 15H1V1H5.5C5.77614 1 6 0.776142 6 0.5C6 0.223858 5.77614 0 5.5 0H0.5ZM3.5 8C3.22386 8 3 8.22386 3 8.5C3 8.77614 3.22386 9 3.5 9H14.29L10.6336 12.6564C10.4383 12.8517 10.4383 13.1683 10.6336 13.3636C10.8288 13.5588 11.1454 13.5588 11.3407 13.3636L15.8201 8.88415C15.8805 8.83374 15.9289 8.76934 15.9602 8.69592C15.9931 8.62072 16.0067 8.53907 16.0009 8.45855C16.011 8.31876 15.9626 8.17552 15.8557 8.06864L11.3407 3.55355C11.1454 3.35829 10.8288 3.35829 10.6336 3.55355C10.4383 3.74882 10.4383 4.0654 10.6336 4.26066L14.3729 8H3.5Z\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/icons/MuteCameraIcon.tsx",
    "content": "import React from \"react\";\nimport Image from \"next/image\";\n\nimport cameraOnIcon from \"../../public/cameraOn.svg\";\n\nexport default function MuteCameraIcon(): JSX.Element {\n  return (\n    <Image\n      alt=\"mute camera\"\n      width={25}\n      height={25}\n      src={cameraOnIcon}\n      style={{ width: \"25px\", height: \"25px\" }}\n    />\n  );\n}\n"
  },
  {
    "path": "components/icons/MuteMicrophoneIcon.tsx",
    "content": "import React from \"react\";\nimport Image from \"next/image\";\n\nimport microphoneOffIcon from \"../../public/microphoneOff.svg\";\n\nexport default function MuteMicrophoneIcon(): JSX.Element {\n  return (\n    <Image\n      alt=\"mute mic\"\n      width={25}\n      height={25}\n      src={microphoneOffIcon}\n      style={{ width: \"25px\", height: \"25px\" }}\n    />\n  );\n}\n"
  },
  {
    "path": "components/icons/ScreenShareIcon.tsx",
    "content": "import React from \"react\";\nimport Image from \"next/image\";\n\nimport screenShare from \"../../public/screen-share.svg\";\n\nexport default function ScreenShareIcon(): JSX.Element {\n  return (\n    <Image\n      priority\n      alt=\"Screen share\"\n      width={25}\n      height={25}\n      src={screenShare}\n      style={{ width: \"25px\", height: \"25px\" }}\n    />\n  );\n}\n"
  },
  {
    "path": "components/icons/SendIcon.tsx",
    "content": "import React from \"react\";\nimport Image from \"next/image\";\n\nimport send from \"../../public/send.svg\";\n\nexport default function SendIcon(): JSX.Element {\n  return (\n    <Image\n      priority\n      alt=\"Send message\"\n      draggable={false}\n      width={18}\n      height={15}\n      src={send}\n      style={{ width: \"18px\", height: \"15px\" }}\n    />\n  );\n}\n"
  },
  {
    "path": "components/icons/SettingsIcon.tsx",
    "content": "import Image from \"next/image\";\n\nimport elipsisIcon from \"../../public/elipsis.svg\";\n\nexport default function SettingsIcon() {\n  return (\n    <Image\n      priority\n      alt=\"settings\"\n      width={25}\n      height={25}\n      src={elipsisIcon}\n      style={{ width: \"25px\", height: \"25px\" }}\n    />\n  );\n}\n"
  },
  {
    "path": "components/icons/UnmuteCameraIcon.tsx",
    "content": "import React from \"react\";\nimport Image from \"next/image\";\n\nimport cameraOffIcon from \"../../public/cameraOff.svg\";\n\nexport default function UnmuteCameraIcon(): JSX.Element {\n  return (\n    <Image\n      alt=\"unmute camera\"\n      width={25}\n      height={25}\n      src={cameraOffIcon}\n      style={{ width: \"25px\", height: \"25px\" }}\n    />\n  );\n}\n"
  },
  {
    "path": "components/icons/UnmuteMicrophoneIcon.tsx",
    "content": "import React from \"react\";\nimport Image from \"next/image\";\n\nimport microphoneOnIcon from \"../../public/microphoneOn.svg\";\n\nexport default function UnmuteMicrophoneIcon(): JSX.Element {\n  return (\n    <Image\n      priority\n      alt=\"unmute mic\"\n      width={25}\n      height={25}\n      src={microphoneOnIcon}\n      style={{ width: \"25px\", height: \"25px\" }}\n    />\n  );\n}\n"
  },
  {
    "path": "components/modals/ACRScoreDialog.tsx",
    "content": "import React, { useCallback, useRef, useState } from \"react\";\nimport {\n  useDisclosure,\n  Button,\n  AlertDialog,\n  AlertDialogOverlay,\n  AlertDialogContent,\n  AlertDialogHeader,\n  AlertDialogBody,\n  AlertDialogFooter,\n  AlertDialogCloseButton,\n  Box,\n  useRadio,\n  useRadioGroup,\n  VStack,\n  RadioProps,\n  Divider,\n} from \"@chakra-ui/react\";\nimport { AcrScore } from \"@mux/spaces-web\";\n\nimport { useSpace } from \"hooks/useSpace\";\n\nfunction RadioCard(props: RadioProps) {\n  const { getInputProps, getCheckboxProps } = useRadio(props);\n\n  const input = getInputProps();\n  const checkbox = getCheckboxProps();\n\n  return (\n    <Box as=\"label\" width=\"100%\">\n      <input {...input} />\n      <Box\n        {...checkbox}\n        color=\"#242628\"\n        fontSize=\"14px\"\n        cursor=\"pointer\"\n        borderWidth=\"1px\"\n        borderRadius=\"5px\"\n        borderColor=\"#B2BAC2\"\n        _checked={{\n          background: \"#3E4247\",\n          color: \"white\",\n          borderColor: \"#3E4247\",\n        }}\n        _hover={\n          !props.isChecked\n            ? {\n                borderColor: \"#242628\",\n              }\n            : {}\n        }\n        _focus={{\n          boxShadow: \"none\",\n        }}\n        px=\"20px\"\n        py=\"10px\"\n      >\n        {props.children}\n      </Box>\n    </Box>\n  );\n}\n\ntype Props = Pick<ReturnType<typeof useDisclosure>, \"isOpen\" | \"onClose\">;\n\nconst options = [\"Excellent\", \"Good\", \"Fair\", \"Poor\", \"Bad\"];\n\nexport default function ACRScoreDialog({ isOpen, onClose }: Props) {\n  const cancelRef = useRef(null);\n  const { submitAcrScore } = useSpace();\n  const [acrScore, setAcrScore] = useState<string>();\n  const [submitting, setSubmitting] = useState(false);\n  const [closing, setClosing] = useState(false);\n\n  const { getRootProps, getRadioProps } = useRadioGroup({\n    name: \"acr\",\n    onChange: (nextValue) => setAcrScore(nextValue),\n  });\n\n  const handleSubmittingAcrScore = useCallback(async () => {\n    if (acrScore) {\n      setSubmitting(true);\n      const numericScore = AcrScore[acrScore as keyof typeof AcrScore];\n      try {\n        await submitAcrScore(numericScore);\n      } catch (e) {\n        console.error(e);\n      }\n      onClose();\n    }\n  }, [acrScore, submitAcrScore, onClose]);\n\n  const handleClose = useCallback(() => {\n    setClosing(true);\n    onClose();\n  }, [onClose]);\n\n  const group = getRootProps();\n  const disableSubmission = closing || !acrScore;\n\n  return (\n    <>\n      <AlertDialog\n        motionPreset=\"slideInBottom\"\n        leastDestructiveRef={cancelRef}\n        onClose={handleClose}\n        isOpen={isOpen}\n        isCentered\n      >\n        <AlertDialogOverlay />\n\n        <AlertDialogContent borderRadius=\"0px\">\n          <AlertDialogHeader\n            color=\"#242628\"\n            fontSize=\"18px\"\n            fontWeight=\"normal\"\n          >\n            How was the call quality?\n          </AlertDialogHeader>\n          <AlertDialogCloseButton\n            color=\"#666666\"\n            marginTop=\"6px\"\n            marginRight=\"3px\"\n          />\n          <Divider color=\"#E8E8E8\" opacity={1} />\n          <AlertDialogBody my=\"17px\">\n            <VStack {...group} spacing=\"10px\">\n              {options.map((value) => {\n                const radio = getRadioProps({ value });\n                return (\n                  <RadioCard key={value} {...radio}>\n                    {value}\n                  </RadioCard>\n                );\n              })}\n            </VStack>\n          </AlertDialogBody>\n          <AlertDialogFooter>\n            <Button ref={cancelRef} variant=\"muxDefault\" onClick={handleClose}>\n              Cancel\n            </Button>\n            <Button\n              variant=\"muxConfirmation\"\n              isLoading={submitting}\n              isDisabled={disableSubmission}\n              onClick={handleSubmittingAcrScore}\n              marginLeft=\"10px\"\n            >\n              Submit\n            </Button>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </>\n  );\n}\n"
  },
  {
    "path": "components/modals/ErrorModal.tsx",
    "content": "import {\n  Button,\n  Modal,\n  ModalBody,\n  ModalCloseButton,\n  ModalContent,\n  ModalFooter,\n  ModalHeader,\n  ModalOverlay,\n} from \"@chakra-ui/react\";\n\ninterface Props {\n  title: string;\n  message: string;\n  isOpen: boolean;\n  onClose: () => void;\n}\n\nexport default function ErrorModal({ title, message, isOpen, onClose }: Props) {\n  return (\n    <Modal isOpen={isOpen} onClose={onClose}>\n      <ModalOverlay />\n      <ModalContent>\n        <ModalHeader>{title}</ModalHeader>\n        <ModalCloseButton />\n        <ModalBody>{message}</ModalBody>\n\n        <ModalFooter>\n          <Button variant=\"ghost\" colorScheme=\"blue\" mr={3} onClick={onClose}>\n            Dismiss\n          </Button>\n        </ModalFooter>\n      </ModalContent>\n    </Modal>\n  );\n}\n"
  },
  {
    "path": "components/modals/RenameParticipantModal.tsx",
    "content": "import React, {\n  useCallback,\n  useMemo,\n  useRef,\n  useState,\n  useContext,\n} from \"react\";\nimport {\n  Modal,\n  ModalOverlay,\n  ModalContent,\n  ModalHeader,\n  ModalFooter,\n  ModalBody,\n  ModalCloseButton,\n  Button,\n  useDisclosure,\n  FormControl,\n  FormHelperText,\n  Input,\n  Divider,\n} from \"@chakra-ui/react\";\n\nimport UserContext from \"context/User\";\nimport SpaceContext from \"context/Space\";\n\ntype Props = Pick<ReturnType<typeof useDisclosure>, \"isOpen\" | \"onClose\"> & {};\n\nexport default function RenameParticipantModal({\n  isOpen,\n  onClose,\n}: Props): JSX.Element {\n  const user = React.useContext(UserContext);\n  const nameInputRef = useRef<HTMLInputElement>(null);\n  const [participantName, setParticipantName] = useState(user.participantName);\n  const { setDisplayName } = useContext(SpaceContext);\n\n  const invalidParticipantName = useMemo(\n    () => !participantName,\n    [participantName]\n  );\n\n  const handleDisplayNameChanged = (event: { target: { value: string } }) => {\n    setParticipantName(event.target.value);\n  };\n\n  const handleClose = useCallback(() => {\n    setParticipantName(user.participantName);\n    onClose();\n  }, [onClose, setParticipantName, user]);\n\n  const submit = useCallback(async () => {\n    if (invalidParticipantName) return;\n    if (user.participantName !== participantName) {\n      user.setParticipantName(participantName);\n      await setDisplayName(participantName);\n    }\n    onClose();\n  }, [invalidParticipantName, onClose, participantName, setDisplayName, user]);\n\n  return (\n    <Modal onClose={handleClose} isOpen={isOpen} isCentered>\n      <ModalOverlay />\n\n      <ModalContent borderRadius=\"0px\">\n        <ModalHeader color=\"#242628\" fontSize=\"18px\" fontWeight=\"normal\">\n          {\"What's your name?\"}\n        </ModalHeader>\n        <ModalCloseButton color=\"#666666\" marginTop=\"6px\" marginRight=\"3px\" />\n        <Divider color=\"#E8E8E8\" opacity={1} />\n        <ModalBody my=\"17px\">\n          <FormControl isInvalid={invalidParticipantName}>\n            <Input\n              maxLength={64}\n              ref={nameInputRef}\n              id=\"participant_id\"\n              value={participantName}\n              onChange={handleDisplayNameChanged}\n              onKeyPress={(e) => {\n                if (e.key === \"Enter\") {\n                  submit();\n                }\n              }}\n            />\n            <FormHelperText hidden={!invalidParticipantName}>\n              This cannot be empty.\n            </FormHelperText>\n          </FormControl>\n        </ModalBody>\n        <ModalFooter>\n          <Button variant=\"muxDefault\" onClick={handleClose}>\n            Cancel\n          </Button>\n          <Button\n            variant=\"muxConfirmation\"\n            type=\"submit\"\n            marginLeft=\"10px\"\n            onClick={submit}\n            isDisabled={invalidParticipantName}\n          >\n            Enter\n          </Button>\n        </ModalFooter>\n      </ModalContent>\n    </Modal>\n  );\n}\n"
  },
  {
    "path": "components/renderers/AudioRenderer.tsx",
    "content": "import { useEffect, useRef } from \"react\";\n\ninterface AudioTrackProps {\n  attachFunc: (element: HTMLAudioElement) => void;\n}\n\nconst AudioRenderer = ({ attachFunc }: AudioTrackProps) => {\n  const audioEl = useRef<HTMLAudioElement | null>(null);\n\n  useEffect(() => {\n    const el = audioEl.current;\n    if (!el) return;\n\n    attachFunc(el);\n  }, [attachFunc]);\n\n  return <audio ref={audioEl} autoPlay />;\n};\n\nexport default AudioRenderer;\n"
  },
  {
    "path": "components/renderers/ChatRenderer.tsx",
    "content": "import React, {\n  FormEvent,\n  KeyboardEvent,\n  UIEvent,\n  useCallback,\n  useContext,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { Box, Textarea } from \"@chakra-ui/react\";\nimport styled from \"@emotion/styled\";\nimport ChatContext, { ChatMessage } from \"context/Chat\";\nimport SendButton from \"components/controls/buttons/SendButton\";\nimport { transientOptions } from \"lib/utils\";\n\nconst ChatContainer = styled(Box, transientOptions)<{ $show: boolean }>`\n  position: absolute;\n  top: 0px;\n  right: 0px;\n  width: 300px;\n  height: 100%;\n  background-color: #292929;\n  border-left: 1px solid #666666;\n  display: flex;\n  flex-direction: column;\n  transform: translateX(100%);\n\n  ${(props) =>\n    props.$show && \"transition: transform 100ms; transform: translateX(0%);\"}\n`;\n\nconst ChatMessageListContainer = styled(Box)`\n  flex-grow: 1;\n  overflow-y: scroll;\n  padding-top: 30px;\n  padding-bottom: 30px;\n`;\n\nconst ChatMessageContainer = styled(Box, transientOptions)<{\n  $isLastMessage: boolean;\n}>`\n  position: relative;\n  padding-left: 30px;\n  padding-right: 30px;\n  margin-bottom: ${(props) => (props.$isLastMessage ? \"0px\" : \"10px\")};\n  color: #f9f9f9;\n  font-size: 14px;\n  white-space: pre-line;\n`;\n\nconst ChatInputContainer = styled(Box)`\n  position: relative;\n  width: 100%;\n  height: 70x;\n  padding: 5px;\n  border-top: 1px solid #666666;\n`;\n\nconst ChatTextarea = styled(Textarea)`\n  height: 60px;\n  border: 1px solid #e8e8e8;\n  border-radius: 2px;\n  background-color: white;\n  padding-right: 40px;\n  resize: none;\n`;\n\nconst SendButtonContainer = styled(Box)`\n  position: absolute;\n  bottom: 10px;\n  right: 10px;\n  z-index: 100;\n`;\n\nconst Name = styled.span`\n  font-weight: bold;\n`;\n\nconst Time = styled.span`\n  font-size: 12px;\n  color: #cccccc;\n  margin-left: 6px;\n  vertical-align: top;\n`;\n\nconst Url = styled.a`\n  text-decoration: underline;\n`;\n\ninterface ChatMessageProps {\n  isLast: boolean;\n  isConsecutive: boolean;\n  message: ChatMessage;\n}\n\nconst ChatMessage = ({ message, isConsecutive, isLast }: ChatMessageProps) => {\n  const urlPositions = [];\n  const regexp = /\\b(https?:\\/\\/\\S*\\b)/g;\n\n  let match;\n  while ((match = regexp.exec(message.content)) !== null) {\n    urlPositions.push({\n      start: match.index,\n      end: regexp.lastIndex,\n    });\n  }\n\n  let content;\n  if (urlPositions.length > 0) {\n    let oldContent = message.content;\n    content = [];\n\n    let currentIndex = 0;\n    for (const position of urlPositions) {\n      const { start, end } = position;\n      content.push(\n        <span key={`${currentIndex},${position.start}`}>\n          {oldContent.substring(currentIndex, position.start)}\n        </span>\n      );\n      content.push(\n        <Url\n          target=\"_blank\"\n          href={oldContent.substring(start, end)}\n          key={`${start},${end}`}\n        >\n          {oldContent.substring(start, end)}\n        </Url>\n      );\n      currentIndex = end;\n    }\n\n    content.push(\n      <span key={currentIndex}>{oldContent.substring(currentIndex)}</span>\n    );\n  } else {\n    content = message.content;\n  }\n\n  return (\n    <ChatMessageContainer $isLastMessage={isLast}>\n      {!isConsecutive && (\n        <>\n          <Name>{message.name}</Name>\n          <Time>{message.time}</Time>\n          <br />\n        </>\n      )}\n      {content}\n    </ChatMessageContainer>\n  );\n};\n\nexport default function ChatRenderer({ show }: { show: boolean }): JSX.Element {\n  const { canSendMessage, sendChatMessage, chatMessages } =\n    useContext(ChatContext);\n  const [input, setInput] = useState(\"\");\n  const messageListRef = useRef<HTMLDivElement>(null);\n  const isScrollNearBottomRef = useRef<boolean>(true);\n\n  const handleOnChange = useCallback((e: FormEvent<HTMLTextAreaElement>) => {\n    const { value } = e.currentTarget;\n    if (value === \"\\n\" || value.endsWith(\"\\n\\n\\n\") || value.endsWith(\"\\n \")) {\n      return;\n    }\n\n    setInput(value);\n  }, []);\n\n  const handleSubmit = useCallback(async () => {\n    const message = input.trim();\n    if (canSendMessage && input !== \"\") {\n      setInput(\"\");\n      try {\n        await sendChatMessage(message);\n      } catch (error) {}\n    }\n  }, [input, canSendMessage, sendChatMessage]);\n\n  const handleOnKeyDown = useCallback(\n    (e: KeyboardEvent<HTMLTextAreaElement>) => {\n      if (e.code === \"Enter\" && !e.shiftKey) {\n        e.preventDefault();\n        handleSubmit();\n      }\n    },\n    [handleSubmit]\n  );\n\n  const handleScroll = useCallback((e: UIEvent<HTMLDivElement>) => {\n    const el = e.currentTarget;\n    const isNearBottom =\n      100 > el.scrollHeight - (el.offsetHeight + el.scrollTop);\n\n    isScrollNearBottomRef.current = isNearBottom;\n  }, []);\n\n  const renderMessages = useCallback(() => {\n    return chatMessages.map((message, index) => (\n      <ChatMessage\n        key={message.id}\n        message={message}\n        isConsecutive={\n          index !== 0 &&\n          message.connectionId === chatMessages[index - 1].connectionId\n        }\n        isLast={chatMessages.length - 1 === index}\n      />\n    ));\n  }, [chatMessages]);\n\n  useEffect(() => {\n    const el = messageListRef.current;\n    if (!el || chatMessages.length === 0) return;\n\n    const isLocalMessage = \"state\" in chatMessages[chatMessages.length - 1];\n    if (isScrollNearBottomRef.current || isLocalMessage) {\n      el.scrollTop = el.scrollHeight;\n    }\n  }, [chatMessages]);\n\n  return (\n    <ChatContainer $show={show}>\n      <ChatMessageListContainer ref={messageListRef} onScroll={handleScroll}>\n        {renderMessages()}\n      </ChatMessageListContainer>\n      <ChatInputContainer>\n        <ChatTextarea\n          placeholder=\"Send a message\"\n          draggable={false}\n          onChange={handleOnChange}\n          value={input}\n          onKeyDown={handleOnKeyDown}\n        />\n        {show && (\n          <SendButtonContainer>\n            <SendButton\n              handleOnClick={handleSubmit}\n              isButtonEnabled={canSendMessage && input !== \"\"}\n            />\n          </SendButtonContainer>\n        )}\n      </ChatInputContainer>\n    </ChatContainer>\n  );\n}\n"
  },
  {
    "path": "components/renderers/ScreenShareRenderer.tsx",
    "content": "import React, { useEffect, useRef } from \"react\";\nimport { Flex } from \"@chakra-ui/react\";\n\ninterface Props {\n  attach: (element: HTMLVideoElement) => void;\n}\n\nexport default function ScreenShareRenderer({ attach }: Props): JSX.Element {\n  const videoEl = useRef<HTMLVideoElement | null>(null);\n\n  useEffect(() => {\n    const el = videoEl.current;\n    if (!el) return;\n\n    attach(el);\n  }, [attach]);\n\n  return (\n    <Flex\n      height=\"100%\"\n      justifyContent=\"center\"\n      maxHeight=\"100%\"\n      maxWidth=\"100%\"\n      position=\"relative\"\n    >\n      <video\n        style={{ height: \"100%\", margin: \"0px auto\" }}\n        ref={videoEl}\n        autoPlay\n        playsInline\n      />\n    </Flex>\n  );\n}\n"
  },
  {
    "path": "components/renderers/VideoRenderer.tsx",
    "content": "import React, { useEffect, useRef, useState } from \"react\";\n\nimport poster from \"../../public/poster.jpg\";\nimport posterFlipped from \"../../public/poster-flipped.jpg\";\n\ninterface Props {\n  width: number;\n  height: number;\n  local: boolean;\n  connectionId: string;\n  attachFunc: (element: HTMLVideoElement) => void;\n}\n\nexport default function VideoRenderer({\n  width,\n  height,\n  local,\n  connectionId,\n  attachFunc,\n}: Props): JSX.Element {\n  const videoEl = useRef<HTMLVideoElement | null>(null);\n  const [disableFlip, setDisableFlip] = useState(false);\n\n  const handleEnterPiP = () => {\n    setDisableFlip(true);\n  };\n\n  const handleLeavePiP = () => {\n    setDisableFlip(false);\n  };\n\n  useEffect(() => {\n    const el = videoEl.current;\n    if (!el) return;\n\n    attachFunc(el);\n\n    el.addEventListener(\"enterpictureinpicture\", handleEnterPiP);\n    el.addEventListener(\"leavepictureinpicture\", handleLeavePiP);\n    return () => {\n      el.removeEventListener(\"enterpictureinpicture\", handleEnterPiP);\n      el.removeEventListener(\"leavepictureinpicture\", handleLeavePiP);\n    };\n  }, [attachFunc]);\n\n  return (\n    <video\n      id={connectionId}\n      poster={!disableFlip && local ? posterFlipped.src : poster.src}\n      ref={videoEl}\n      autoPlay\n      playsInline\n      style={{\n        width: \"100%\",\n        height: \"100%\",\n        objectFit: height > width ? \"contain\" : \"cover\",\n        transform: !disableFlip && local ? \"scaleX(-1)\" : \"\",\n      }}\n    />\n  );\n}\n"
  },
  {
    "path": "context/Chat.tsx",
    "content": "import { CustomEvent, SpaceEvent } from \"@mux/spaces-web\";\nimport { useSpaceEvent } from \"hooks/useSpaceEvent\";\nimport React, {\n  createContext,\n  ReactNode,\n  useCallback,\n  useContext,\n  useEffect,\n  useState,\n} from \"react\";\nimport SpaceContext from \"./Space\";\nimport { v4 } from \"uuid\";\nimport moment from \"moment\";\n\ninterface ChatMessagePayloadValue {\n  connectionId: string;\n  name: string;\n  id: string;\n  content: string;\n}\n\ninterface RemoteChatMessage extends ChatMessagePayloadValue {\n  time: string;\n}\n\ninterface LocalChatMessage extends RemoteChatMessage {\n  state: SendState;\n}\n\nenum SendState {\n  Pending,\n  Succeeded,\n  Failed,\n}\n\nexport type ChatMessage = RemoteChatMessage | LocalChatMessage;\n\ninterface IChatContext {\n  isChatOpen: boolean;\n  openChat: () => void;\n  closeChat: () => void;\n  chatMessages: ChatMessage[];\n  canSendMessage: boolean;\n  numUnreadMessages: number;\n  sendChatMessage: (content: string) => Promise<void>;\n}\n\nexport const ChatContext = createContext({} as IChatContext);\n\nexport default ChatContext;\n\ntype Props = {\n  children: ReactNode;\n};\n\nexport const ChatProvider: React.FC<Props> = ({ children }) => {\n  const { publishCustomEvent, localParticipant } = useContext(SpaceContext);\n  const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);\n  const [isChatOpen, setIsChatOpen] = useState(false);\n  const [numUnreadMessages, setNumUnreadMessages] = useState(0);\n  const [canSendMessage, setCanSendMessage] = useState(!!localParticipant);\n\n  const sendChatMessage = useCallback(\n    async (content: string) => {\n      if (!localParticipant) {\n        throw new Error(\n          \"Cannot send chat message without having joined the space\"\n        );\n      } else if (!canSendMessage) {\n        return;\n      }\n\n      const id = v4();\n      const payload = {\n        type: \"chat\",\n        value: {\n          connectionId: localParticipant.connectionId,\n          name: localParticipant.displayName || localParticipant.id,\n          id,\n          content,\n        },\n      };\n\n      const time = moment().format(\"hh:mm A\");\n      let state = SendState.Pending;\n\n      const localChatMessage = { ...payload.value, id, state, time };\n\n      setChatMessages([...chatMessages, localChatMessage]);\n\n      try {\n        setCanSendMessage(false);\n        await publishCustomEvent(JSON.stringify(payload));\n        state = SendState.Succeeded;\n      } catch (error) {\n        state = SendState.Failed;\n        throw error;\n      } finally {\n        const messageIndex = chatMessages.findIndex(\n          (message) => message.id === id\n        );\n\n        if (messageIndex !== -1) {\n          const message = chatMessages[messageIndex];\n          if (\"state\" in message) {\n            message.state = state;\n          }\n\n          setChatMessages([\n            ...chatMessages.slice(0, messageIndex),\n            { ...message },\n            ...chatMessages.slice(messageIndex + 1),\n          ]);\n        }\n      }\n    },\n    [localParticipant, chatMessages, publishCustomEvent, canSendMessage]\n  );\n\n  useEffect(() => {\n    if (localParticipant && !canSendMessage) {\n      const timeout = setTimeout(() => {\n        setCanSendMessage(true);\n      }, 1000);\n\n      return () => {\n        clearTimeout(timeout);\n      };\n    }\n  }, [localParticipant, canSendMessage]);\n\n  useSpaceEvent(\n    SpaceEvent.ParticipantCustomEventPublished,\n    useCallback(\n      (participant, customEvent: CustomEvent) => {\n        const payload: { type: string; value: ChatMessagePayloadValue } =\n          JSON.parse(customEvent.payload);\n        if (participant !== localParticipant && payload.type === \"chat\") {\n          setChatMessages([\n            ...chatMessages,\n            { ...payload.value, time: moment().format(\"hh:mm A\") },\n          ]);\n\n          if (!isChatOpen) {\n            setNumUnreadMessages(numUnreadMessages + 1);\n          }\n        }\n      },\n      [chatMessages, localParticipant, isChatOpen, numUnreadMessages]\n    )\n  );\n\n  const openChat = useCallback(() => {\n    setIsChatOpen(true);\n    setNumUnreadMessages(0);\n  }, []);\n\n  const closeChat = useCallback(() => {\n    setIsChatOpen(false);\n  }, []);\n\n  return (\n    <ChatContext.Provider\n      value={{\n        isChatOpen,\n        openChat,\n        closeChat,\n        chatMessages,\n        canSendMessage,\n        numUnreadMessages,\n        sendChatMessage,\n      }}\n    >\n      {children}\n    </ChatContext.Provider>\n  );\n};\n"
  },
  {
    "path": "context/Space.tsx",
    "content": "import React, {\n  createContext,\n  ReactNode,\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useState,\n} from \"react\";\nimport { useRouter } from \"next/router\";\nimport {\n  AcrScore,\n  ActiveSpeaker,\n  CustomEvent,\n  getDisplayMedia,\n  LocalParticipant,\n  LocalTrack,\n  RemoteParticipant,\n  Space,\n  SpaceEvent,\n  SpaceOptionsParams,\n  Track,\n  TrackSource,\n} from \"@mux/spaces-web\";\n\nimport { MAX_PARTICIPANTS_PER_PAGE } from \"lib/constants\";\n\nimport UserContext from \"./User\";\nimport UserMediaContext from \"./UserMedia\";\n\ninterface SpaceState {\n  space: Space | null;\n  localParticipant: LocalParticipant | null;\n  remoteParticipants: RemoteParticipant[];\n\n  joinSpace: (\n    jwt: string,\n    endsAt?: number,\n    displayName?: string\n  ) => Promise<void>;\n  joinError: string | null;\n  isJoined: boolean;\n\n  connectionIds: string[];\n  isBroadcasting: boolean;\n  participantCount: number;\n  publishCamera: (deviceId: string) => void;\n  publishMicrophone: (deviceId: string) => void;\n  unPublishDevice: (deviceId: string) => void;\n\n  isLocalScreenShareSupported: boolean;\n  isScreenShareActive: boolean;\n  isLocalScreenShare: boolean;\n  screenShareError: string | null;\n  attachScreenShare: (element: HTMLVideoElement) => void;\n  startScreenShare: () => void;\n  stopScreenShare: () => void;\n  screenShareParticipantConnectionId?: string;\n  screenShareParticipantName?: string;\n\n  spaceEndsAt: number | null;\n  leaveSpace: () => void;\n  submitAcrScore: (score: AcrScore) => Promise<void> | undefined;\n\n  setDisplayName: (name: string) => Promise<LocalParticipant | undefined>;\n  publishCustomEvent: (payload: string) => Promise<CustomEvent | undefined>;\n}\n\nexport const SpaceContext = createContext({} as SpaceState);\n\nexport default SpaceContext;\n\ntype Props = {\n  children: ReactNode;\n};\n\nexport const SpaceProvider: React.FC<Props> = ({ children }) => {\n  const { userWantsMicMuted, microphoneDeviceId, cameraOff, cameraDeviceId } =\n    useContext(UserContext);\n  const { getMicrophone, getCamera } = useContext(UserMediaContext);\n\n  const [space, setSpace] = useState<Space | null>(null);\n  const [spaceEndsAt, setSpaceEndsAt] = useState<number | null>(null);\n  const [localParticipant, setLocalParticipant] =\n    useState<LocalParticipant | null>(null);\n  const [remoteParticipants, setRemoteParticipants] = useState<\n    RemoteParticipant[]\n  >([]);\n  const [isJoined, setIsJoined] = useState(false);\n  const [joinError, setJoinError] = useState<string | null>(null);\n  const [isBroadcasting, setIsBroadcasting] = useState(false);\n  const [isLocalScreenShareSupported, setIsLocalScreenShareSupported] =\n    useState(\n      typeof window !== \"undefined\"\n        ? !!navigator?.mediaDevices?.getDisplayMedia\n        : false\n    );\n  const [screenShareTrack, setScreenShareTrack] = useState<Track>();\n  const [screenShareError, setScreenShareError] = useState<string | null>(null);\n  const [participantScreenSharing, setParticipantScreenSharing] = useState<\n    LocalParticipant | RemoteParticipant | null\n  >(null);\n\n  const screenShareParticipantName = useMemo(() => {\n    return participantScreenSharing?.displayName;\n  }, [participantScreenSharing]);\n\n  const screenShareParticipantConnectionId = useMemo(() => {\n    return participantScreenSharing?.connectionId;\n  }, [participantScreenSharing]);\n\n  useEffect(() => {\n    setIsLocalScreenShareSupported(!!navigator.mediaDevices.getDisplayMedia);\n  }, []);\n\n  const isScreenShareActive = useMemo(() => {\n    return !!screenShareTrack;\n  }, [screenShareTrack]);\n\n  const isLocalScreenShare = useMemo(() => {\n    return participantScreenSharing instanceof LocalParticipant;\n  }, [participantScreenSharing]);\n\n  const participantCount = useMemo(() => {\n    return (localParticipant ? 1 : 0) + remoteParticipants.length;\n  }, [localParticipant, remoteParticipants]);\n\n  const connectionIds = useMemo(() => {\n    return (localParticipant ? [localParticipant.connectionId] : []).concat(\n      remoteParticipants.map((p) => p.connectionId)\n    );\n  }, [localParticipant, remoteParticipants]);\n\n  const publishForLocalParticipant = useCallback(\n    async (localParticipant: LocalParticipant) => {\n      const tracksToPublish = [];\n      if (cameraDeviceId && !cameraOff) {\n        const cameraTrack = await getCamera(cameraDeviceId);\n        if (cameraTrack) {\n          tracksToPublish.push(cameraTrack);\n        }\n      }\n      if (microphoneDeviceId) {\n        const microphoneTrack = await getMicrophone(microphoneDeviceId);\n        if (microphoneTrack) {\n          tracksToPublish.push(microphoneTrack);\n        }\n      }\n      if (tracksToPublish.length > 0) {\n        const publishedTracks = await localParticipant.publishTracks(\n          tracksToPublish\n        );\n        const publishedMicrophone = publishedTracks.find(\n          (track) => track.source === TrackSource.Microphone\n        );\n        if (publishedMicrophone && userWantsMicMuted) {\n          publishedMicrophone.mute();\n        }\n      }\n    },\n    [\n      cameraOff,\n      getMicrophone,\n      microphoneDeviceId,\n      getCamera,\n      cameraDeviceId,\n      userWantsMicMuted,\n    ]\n  );\n\n  const router = useRouter();\n\n  const joinSpace = useCallback(\n    async (jwt: string, endsAt?: number, displayName?: string) => {\n      let _space: Space;\n      try {\n        let spaceOpts: SpaceOptionsParams = { displayName };\n        if (router.isReady && typeof router.query.auto_sub_limit === \"string\") {\n          spaceOpts.automaticParticipantLimit = parseInt(\n            router.query.auto_sub_limit\n          );\n        }\n        _space = new Space(jwt, spaceOpts);\n      } catch (e: any) {\n        setJoinError(e.message);\n        return;\n      }\n\n      if (endsAt) {\n        setSpaceEndsAt(endsAt);\n      }\n\n      const handleBroadcastStateChange = (broadcastState: boolean) => {\n        setIsBroadcasting(broadcastState);\n      };\n\n      const handleParticipantJoined = (newParticipant: RemoteParticipant) => {\n        setRemoteParticipants((oldParticipantArray) => {\n          const found = oldParticipantArray.find(\n            (p) => p.connectionId === newParticipant.connectionId\n          );\n          if (!found) {\n            return [...oldParticipantArray, newParticipant];\n          }\n          return oldParticipantArray;\n        });\n      };\n\n      const handleParticipantLeft = (participantLeaving: RemoteParticipant) => {\n        setRemoteParticipants((oldParticipantArray) =>\n          oldParticipantArray.filter(\n            (p) => p.connectionId !== participantLeaving.connectionId\n          )\n        );\n      };\n\n      const handleActiveSpeakerChanged = (\n        activeSpeakerChanges: ActiveSpeaker[]\n      ) => {\n        setRemoteParticipants((oldParticipantArray) => {\n          const updatedParticipants = [...oldParticipantArray];\n\n          activeSpeakerChanges.forEach((activeSpeaker: ActiveSpeaker) => {\n            if (activeSpeaker.participant instanceof RemoteParticipant) {\n              const participantIndex = updatedParticipants.findIndex(\n                (p) => p.connectionId === activeSpeaker.participant.connectionId\n              );\n\n              if (participantIndex >= MAX_PARTICIPANTS_PER_PAGE - 1) {\n                updatedParticipants.splice(participantIndex, 1);\n                updatedParticipants.unshift(activeSpeaker.participant);\n              }\n            }\n          });\n          return updatedParticipants;\n        });\n      };\n\n      const setupScreenShare = (\n        participant: LocalParticipant | RemoteParticipant,\n        track: Track\n      ) => {\n        setScreenShareTrack(track);\n        setParticipantScreenSharing(participant);\n      };\n\n      const tearDownScreenShare = () => {\n        setScreenShareTrack(undefined);\n        setParticipantScreenSharing(null);\n      };\n\n      const handleParticipantTrackPublished = (\n        participant: LocalParticipant | RemoteParticipant,\n        track: Track\n      ) => {\n        if (track.source === TrackSource.Screenshare && track.hasMedia()) {\n          setupScreenShare(participant, track);\n        }\n      };\n\n      const handleParticipantTrackSubscribed = (\n        participant: LocalParticipant | RemoteParticipant,\n        track: Track\n      ) => {\n        if (participant instanceof RemoteParticipant) {\n          reorderRemoteParticipantsBySubscription(participant);\n        }\n        if (track.source === TrackSource.Screenshare && track.hasMedia()) {\n          setupScreenShare(participant, track);\n        }\n      };\n\n      const handleParticipantTrackUnpublished = (\n        _participant: LocalParticipant | RemoteParticipant,\n        track: Track\n      ) => {\n        if (track.source === TrackSource.Screenshare) {\n          tearDownScreenShare();\n        }\n      };\n\n      const handleParticipantTrackUnsubscribed = (\n        participant: LocalParticipant | RemoteParticipant,\n        track: Track\n      ) => {\n        if (participant instanceof RemoteParticipant) {\n          reorderRemoteParticipantsBySubscription(participant);\n        }\n        if (track.source === TrackSource.Screenshare) {\n          tearDownScreenShare();\n        }\n      };\n\n      const handleParticipantDisplayNameChanged = (\n        updated: LocalParticipant | RemoteParticipant\n      ) => {\n        if (updated instanceof RemoteParticipant) {\n          setRemoteParticipants((oldParticipantArray) => {\n            const found = oldParticipantArray.find(\n              (p) => p.connectionId === updated.connectionId\n            );\n            if (found) {\n              found.displayName = updated.displayName;\n            }\n            return oldParticipantArray;\n          });\n          // no participant found for this update\n        } else {\n          setLocalParticipant((local) => {\n            if (!local) {\n              return null;\n            }\n            local.displayName = updated.displayName;\n            return local;\n          });\n        }\n      };\n\n      const reorderRemoteParticipantsBySubscription = (\n        participantWhoChanged: RemoteParticipant\n      ) => {\n        setRemoteParticipants((oldParticipantArray) => {\n          const updatedSubscriptionParticipants = oldParticipantArray.map(\n            (oldParticipant) =>\n              oldParticipant.connectionId === participantWhoChanged.connectionId\n                ? participantWhoChanged\n                : oldParticipant\n          );\n\n          return [\n            ...updatedSubscriptionParticipants.filter((p) => p.isSubscribed()),\n            ...updatedSubscriptionParticipants.filter((p) => !p.isSubscribed()),\n          ];\n        });\n      };\n\n      _space.on(SpaceEvent.ParticipantJoined, handleParticipantJoined);\n      _space.on(SpaceEvent.ParticipantLeft, handleParticipantLeft);\n\n      _space.on(SpaceEvent.ActiveSpeakersChanged, handleActiveSpeakerChanged);\n      _space.on(SpaceEvent.BroadcastStateChanged, handleBroadcastStateChange);\n\n      _space.on(\n        SpaceEvent.ParticipantTrackPublished,\n        handleParticipantTrackPublished\n      );\n      _space.on(\n        SpaceEvent.ParticipantTrackSubscribed,\n        handleParticipantTrackSubscribed\n      );\n      _space.on(\n        SpaceEvent.ParticipantTrackUnpublished,\n        handleParticipantTrackUnpublished\n      );\n      _space.on(\n        SpaceEvent.ParticipantTrackUnsubscribed,\n        handleParticipantTrackUnsubscribed\n      );\n      _space.on(\n        SpaceEvent.ParticipantDisplayNameChanged,\n        handleParticipantDisplayNameChanged\n      );\n\n      setSpace(_space);\n\n      try {\n        const _localParticipant = await _space.join();\n        publishForLocalParticipant(_localParticipant);\n        setLocalParticipant(_localParticipant);\n        setIsBroadcasting(_space.broadcasting);\n        setIsJoined(true);\n      } catch (error: any) {\n        setJoinError(error.message);\n        setIsBroadcasting(false);\n        setIsJoined(false);\n      }\n    },\n    [publishForLocalParticipant, router.isReady, router.query.auto_sub_limit]\n  );\n\n  const publishMicrophone = useCallback(\n    async (deviceId: string) => {\n      if (!localParticipant) {\n        throw new Error(\"Join a space before publishing a device.\");\n      }\n      if (microphoneDeviceId !== deviceId) {\n        const microphoneTrack = await getMicrophone(deviceId);\n        localParticipant.updateTracks([microphoneTrack]);\n      } else {\n        const publishedMicrophone = localParticipant\n          .getAudioTracks()\n          .filter((track) => track.source === TrackSource.Microphone)\n          .find((track) => track.deviceId === deviceId);\n        if (publishedMicrophone) {\n          throw new Error(\"That microphone is already published.\");\n        }\n        const microphoneTrack = await getMicrophone(deviceId);\n        await localParticipant.publishTracks([microphoneTrack]);\n        if (userWantsMicMuted) {\n          microphoneTrack.mute();\n        }\n      }\n    },\n    [localParticipant, microphoneDeviceId, getMicrophone, userWantsMicMuted]\n  );\n\n  const publishCamera = useCallback(\n    async (deviceId: string) => {\n      if (!localParticipant) {\n        throw new Error(\"Join a space before publishing a device.\");\n      }\n      if (cameraDeviceId !== deviceId) {\n        const cameraTrack = await getCamera(deviceId);\n        localParticipant.updateTracks([cameraTrack]);\n      } else {\n        const publishedCamera = localParticipant\n          .getVideoTracks()\n          .filter((track) => track.source === TrackSource.Camera)\n          .find((track) => track.deviceId === deviceId);\n        if (publishedCamera) {\n          throw new Error(\"That camera is already published.\");\n        }\n        const cameraTrack = await getCamera(deviceId);\n        localParticipant.publishTracks([cameraTrack]);\n      }\n    },\n    [localParticipant, cameraDeviceId, getCamera]\n  );\n\n  const unPublishDevice = useCallback(\n    (deviceId: string): void => {\n      if (!localParticipant) {\n        throw new Error(\n          \"Join a space and publish a device before un-publishing the device.\"\n        );\n      }\n      const publishedDevice = localParticipant\n        .getTracks()\n        .find((track) => track.deviceId === deviceId);\n      if (publishedDevice) {\n        localParticipant.unpublishTracks([publishedDevice]);\n      } else {\n        throw new Error(\"Device to un-published was not found.\");\n      }\n    },\n    [localParticipant]\n  );\n\n  const startScreenShare = useCallback(async () => {\n    try {\n      const screenStreams = await getDisplayMedia({\n        video: true,\n        audio: false,\n      });\n      const screenStream = screenStreams?.find(\n        (track) => track.source === \"screenshare\"\n      );\n      if (screenStream) {\n        if (localParticipant) {\n          return localParticipant\n            .publishTracks([screenStream])\n            .then((publishedTracks: LocalTrack[]) => {\n              if (publishedTracks.length < 1) {\n                throw new Error(\"Failed to publish track.\");\n              }\n              return publishedTracks[0];\n            });\n        } else {\n          throw new Error(\"Join a space before starting a screen share.\");\n        }\n      }\n    } catch (error) {\n      if (error instanceof Error) {\n        if (error.message === \"Permission denied\") {\n          // do nothing, they pressed cancel\n        } else if (error.message === \"Permission denied by system\") {\n          // chrome does not have permission to screen share\n          setScreenShareError(error.message);\n        } else {\n          // unhandled exception\n          console.error(error);\n        }\n      }\n    }\n  }, [localParticipant, setScreenShareError]);\n\n  const stopScreenShare = useCallback(async () => {\n    if (screenShareTrack && screenShareTrack instanceof LocalTrack) {\n      if (localParticipant) {\n        localParticipant.unpublishTracks([screenShareTrack]);\n      } else {\n        throw new Error(\"Join a space before stopping the screen share.\");\n      }\n    } else {\n      throw new Error(\"No screen share to stop.\");\n    }\n  }, [localParticipant, screenShareTrack]);\n\n  const attachScreenShare = useCallback(\n    (element: HTMLVideoElement) => {\n      screenShareTrack?.attachedElements.forEach((attachedEl) =>\n        screenShareTrack.detach(attachedEl)\n      );\n      screenShareTrack?.attach(element);\n    },\n    [screenShareTrack]\n  );\n\n  const submitAcrScore = useCallback(\n    (score: AcrScore) => {\n      if (!space) {\n        throw new Error(\n          \"You must join a space before submitting an ACR score.\"\n        );\n      }\n\n      try {\n        return space.submitAcrScore(score);\n      } catch (error) {\n        throw new Error(`Error when submitting ACR score: ${error}`);\n      }\n    },\n    [space]\n  );\n\n  const leaveSpace = useCallback(() => {\n    try {\n      space?.removeAllListeners();\n      space?.leave();\n    } finally {\n      setJoinError(null);\n      setRemoteParticipants([]);\n      setIsBroadcasting(false);\n      setLocalParticipant(null);\n      setIsJoined(false);\n      setSpaceEndsAt(null);\n      // Don't call setSpace(null) here, as things like ACR Score submission depend on it\n    }\n  }, [space]);\n\n  const publishCustomEvent = useCallback(\n    async (payload: string) => {\n      try {\n        return await space?.localParticipant?.publishCustomEvent(payload);\n      } catch (error) {\n        throw error;\n      }\n    },\n    [space]\n  );\n\n  const setDisplayName = useCallback(\n    async (name: string) => {\n      return await space?.localParticipant?.setDisplayName(name);\n    },\n    [space]\n  );\n\n  return (\n    <SpaceContext.Provider\n      value={{\n        space,\n        localParticipant,\n        remoteParticipants,\n\n        joinSpace,\n        joinError,\n        isJoined,\n\n        connectionIds,\n        isBroadcasting,\n        participantCount,\n        publishCamera,\n        publishMicrophone,\n        unPublishDevice,\n\n        isLocalScreenShareSupported,\n        isScreenShareActive,\n        isLocalScreenShare,\n        screenShareError,\n        attachScreenShare,\n        startScreenShare,\n        stopScreenShare,\n        screenShareParticipantConnectionId,\n        screenShareParticipantName,\n\n        leaveSpace,\n        submitAcrScore,\n        spaceEndsAt,\n\n        setDisplayName,\n        publishCustomEvent,\n      }}\n    >\n      {children}\n    </SpaceContext.Provider>\n  );\n};\n"
  },
  {
    "path": "context/User.tsx",
    "content": "import React, { createContext, ReactNode, useCallback, useState } from \"react\";\nimport { v4 as uuidv4 } from \"uuid\";\n\nimport { useLocalStorage } from \"../hooks/useLocalStorage\";\n\ninterface UserState {\n  id: string;\n  participantName: string;\n  setParticipantName: (newName: string) => string;\n  interactionRequired: boolean;\n  setInteractionRequired: (requiresInteraction: boolean) => void;\n  userWantsMicMuted: boolean;\n  setUserWantsMicMuted: (mute: boolean) => void;\n  cameraOff: boolean;\n  setCameraOff: (mute: boolean) => void;\n  microphoneDeviceId: string;\n  setMicrophoneDeviceId: (deviceId: string) => void;\n  cameraDeviceId: string;\n  setCameraDeviceId: (deviceId: string) => void;\n  pinnedConnectionId: string;\n  setPinnedConnectionId: (newConnectionId: string) => void;\n}\n\nconst UserContext = createContext({} as UserState);\n\nexport default UserContext;\n\ninterface Props {\n  children: ReactNode;\n}\n\nexport const UserProvider = ({ children }: Props) => {\n  const [interactionRequired, setInteractionRequired] = useState(true);\n  const [participantName, setParticipantName] = useLocalStorage(\n    \"participantName\",\n    \"\"\n  );\n\n  // This should never change unless we reload\n  const id = uuidv4();\n\n  const [userWantsMicMuted, setUserWantsMicMuted] = useState(false);\n  const [cameraOff, setCameraOff] = useState(false);\n\n  const [microphoneDeviceId, setMicrophoneDeviceId] = useLocalStorage(\n    \"audioDeviceId\",\n    \"\"\n  );\n  const [cameraDeviceId, setCameraDeviceId] = useLocalStorage(\n    \"videoDeviceId\",\n    \"\"\n  );\n  const [pinnedConnectionId, setPinnedConnectionId] = useState(\"\");\n\n  const handleSetParticipantName = useCallback(\n    (name: string) => {\n      setParticipantName(name);\n      return name;\n    },\n    [setParticipantName]\n  );\n\n  return (\n    <UserContext.Provider\n      value={{\n        id,\n        participantName,\n        setParticipantName: handleSetParticipantName,\n        interactionRequired,\n        setInteractionRequired,\n        userWantsMicMuted,\n        setUserWantsMicMuted,\n        cameraOff,\n        setCameraOff,\n        microphoneDeviceId,\n        setMicrophoneDeviceId,\n        cameraDeviceId,\n        setCameraDeviceId,\n        pinnedConnectionId,\n        setPinnedConnectionId,\n      }}\n    >\n      {children}\n    </UserContext.Provider>\n  );\n};\n"
  },
  {
    "path": "context/UserMedia.tsx",
    "content": "import React, {\n  createContext,\n  ReactNode,\n  useCallback,\n  useEffect,\n  useMemo,\n  useState,\n} from \"react\";\nimport {\n  CreateLocalMediaOptions,\n  getUserMedia,\n  LocalTrack,\n  TrackSource,\n} from \"@mux/spaces-web\";\n\nimport UserContext from \"./User\";\n\nimport { defaultAudioConstraints } from \"shared/defaults\";\n\ninterface UserMediaState {\n  activeCamera?: LocalTrack;\n  activeMicrophone?: LocalTrack;\n\n  userMediaError?: string;\n  requestPermissionAndPopulateDevices: () => void;\n  requestPermissionAndStartDevices: (\n    microphoneDeviceId?: string,\n    cameraDeviceId?: string\n  ) => Promise<void>;\n\n  getCamera: (deviceId: string) => Promise<LocalTrack>;\n  cameraDevices: MediaDeviceInfo[];\n  activeCameraId?: string;\n  stopActiveCamera: () => void;\n  changeActiveCamera: (deviceId: string) => Promise<void>;\n\n  getMicrophone: (deviceId: string) => Promise<LocalTrack>;\n  microphoneDevices: MediaDeviceInfo[];\n  activeMicrophoneId?: string;\n  muteActiveMicrophone: () => void;\n  unMuteActiveMicrophone: () => void;\n  changeActiveMicrophone: (deviceId: string) => Promise<void>;\n  getActiveMicrophoneLevel: () => {\n    avgDb: number;\n    peakDb: number;\n  } | null;\n}\n\nexport const UserMediaContext = createContext({} as UserMediaState);\n\nexport default UserMediaContext;\n\nconst defaultCameraOption: CreateLocalMediaOptions = {\n  video: {},\n};\n\nconst defaultMicrophoneOption: CreateLocalMediaOptions = {\n  audio: { constraints: defaultAudioConstraints },\n};\n\nconst noCameraOption: CreateLocalMediaOptions = {\n  video: false,\n};\n\nconst noMicrophoneOption: CreateLocalMediaOptions = {\n  audio: false,\n};\n\nconst defaultMicrophoneCameraOptions: CreateLocalMediaOptions = {\n  ...defaultCameraOption,\n  ...defaultMicrophoneOption,\n};\n\ntype Props = {\n  children: ReactNode;\n};\n\nexport const UserMediaProvider: React.FC<Props> = ({ children }) => {\n  const {\n    cameraDeviceId,\n    setCameraDeviceId,\n    microphoneDeviceId,\n    setMicrophoneDeviceId,\n    userWantsMicMuted,\n  } = React.useContext(UserContext);\n  const [microphoneDevices, setMicrophoneDevices] = useState<InputDeviceInfo[]>(\n    []\n  );\n  const [activeMicrophone, setActiveMicrophone] = useState<LocalTrack>();\n  const [cameraDevices, setCameraDevices] = useState<InputDeviceInfo[]>([]);\n  const [activeCamera, setActiveCamera] = useState<LocalTrack>();\n  const [localAudioAnalyser, setLocalAudioAnalyser] = useState<AnalyserNode>();\n  const [userMediaError, setUserMediaError] = useState<string>();\n\n  const activeCameraId = useMemo(() => {\n    return activeCamera?.deviceId;\n  }, [activeCamera]);\n\n  const activeMicrophoneId = useMemo(() => {\n    return activeMicrophone?.deviceId;\n  }, [activeMicrophone]);\n\n  const setupLocalMicrophoneAnalyser = useCallback((track: LocalTrack) => {\n    let stream = new MediaStream([track.track]);\n\n    const audioCtx = new AudioContext();\n    const streamSource = audioCtx.createMediaStreamSource(stream);\n    const analyser = audioCtx.createAnalyser();\n    streamSource.connect(analyser);\n    analyser.fftSize = 2048;\n\n    setLocalAudioAnalyser(analyser);\n  }, []);\n\n  const loadDevices = useCallback(async () => {\n    const availableDevices = await navigator.mediaDevices.enumerateDevices();\n\n    const audioInputDevices = availableDevices.filter(\n      (device) => device.kind === \"audioinput\"\n    );\n    setMicrophoneDevices(audioInputDevices);\n\n    const videoInputDevices = availableDevices.filter(\n      (device) => device.kind === \"videoinput\"\n    );\n    setCameraDevices(videoInputDevices);\n  }, []);\n\n  const requestPermissionAndPopulateDevices = useCallback(async () => {\n    let tracks: LocalTrack[] = [];\n    try {\n      tracks = await getUserMedia({\n        audio: { constraints: { deviceId: microphoneDeviceId } }, // loose constraint, will fail back if missing\n        video: { constraints: { deviceId: cameraDeviceId } }, // loose constraint, will fail back if missing\n      });\n    } catch (e) {\n      console.log(\"Failed to request default devices from your browser.\");\n    }\n\n    try {\n      tracks.forEach((track) => {\n        if (track.deviceId) {\n          if (track.source === TrackSource.Camera) {\n            setCameraDeviceId(track.deviceId);\n          } else if (track.source === TrackSource.Microphone) {\n            setMicrophoneDeviceId(track.deviceId);\n          }\n        }\n      });\n    } catch (e) {\n      console.log(\"Error thrown while stopping devices.\");\n    }\n\n    await loadDevices();\n\n    // Need to wait to stop the tracks until we've gotten the device list, or they'll have no labels\n    tracks.forEach((track) => track.track.stop());\n  }, [\n    cameraDeviceId,\n    loadDevices,\n    microphoneDeviceId,\n    setCameraDeviceId,\n    setMicrophoneDeviceId,\n  ]);\n\n  const requestPermissionAndStartDevices = useCallback(\n    async (microphoneDeviceId?: string, cameraDeviceId?: string) => {\n      let options = { ...defaultMicrophoneCameraOptions };\n      if (typeof microphoneDeviceId === \"undefined\") {\n        options[\"audio\"] = false;\n      } else if (microphoneDeviceId !== \"\") {\n        options[\"audio\"] = {\n          constraints: {\n            deviceId: { exact: microphoneDeviceId },\n            ...defaultAudioConstraints,\n          },\n        };\n      }\n      if (typeof cameraDeviceId === \"undefined\") {\n        options[\"video\"] = false;\n      } else if (cameraDeviceId !== \"\") {\n        options[\"video\"] = {\n          constraints: {\n            deviceId: { exact: cameraDeviceId },\n          },\n        };\n      }\n\n      let tracks: LocalTrack[] = [];\n      try {\n        tracks = await getUserMedia(options);\n      } catch (e: any) {\n        if (\n          e.name == \"NotAllowedError\" ||\n          e.name == \"PermissionDeniedError\" ||\n          e instanceof DOMException\n        ) {\n          // permission denied to camera\n          setUserMediaError(\"NotAllowedError\");\n        } else if (\n          e.name == \"OverconstrainedError\" ||\n          e.name == \"ConstraintNotSatisfiedError\"\n        ) {\n          tracks = await getUserMedia({ audio: true, video: true });\n        } else {\n          setUserMediaError(e.name);\n        }\n      }\n\n      tracks.forEach((track) => {\n        switch (track.source) {\n          case TrackSource.Microphone:\n            setActiveMicrophone(track);\n            setupLocalMicrophoneAnalyser(track);\n            if (track.deviceId) {\n              setMicrophoneDeviceId(track.deviceId);\n            }\n            if (userWantsMicMuted) {\n              track.mute();\n            }\n            break;\n          case TrackSource.Camera:\n            setActiveCamera(track);\n            if (track.deviceId) {\n              setCameraDeviceId(track.deviceId);\n            }\n            break;\n        }\n      });\n\n      await loadDevices();\n    },\n    [\n      setupLocalMicrophoneAnalyser,\n      setMicrophoneDeviceId,\n      setCameraDeviceId,\n      userWantsMicMuted,\n    ]\n  );\n\n  const muteActiveMicrophone = useCallback(() => {\n    try {\n      activeMicrophone?.mute();\n    } catch (error) {\n      throw new Error(\"Select an active microphone before muting.\");\n    }\n  }, [activeMicrophone]);\n\n  const unMuteActiveMicrophone = useCallback(() => {\n    try {\n      activeMicrophone?.unMute();\n    } catch (error) {\n      throw new Error(\"Select an active microphone before muting.\");\n    }\n  }, [activeMicrophone]);\n\n  const getMicrophone = useCallback(\n    async (deviceId: string) => {\n      let options = {\n        ...defaultMicrophoneOption,\n        ...noCameraOption,\n      };\n      if (deviceId !== \"\") {\n        options[\"audio\"] = {\n          constraints: {\n            deviceId: { exact: deviceId },\n            ...defaultAudioConstraints,\n          },\n        };\n      }\n\n      let tracks: LocalTrack[] = [];\n      try {\n        tracks = await getUserMedia(options);\n      } catch (e: any) {\n        // May occur if previously set device IDs are no longer available\n        if (\n          e.name == \"NotAllowedError\" ||\n          e.name == \"PermissionDeniedError\" ||\n          e instanceof DOMException\n        ) {\n          // permission denied to camera\n          setUserMediaError(\"NotAllowedError\");\n        } else if (\n          e.name == \"OverconstrainedError\" ||\n          e.name == \"ConstraintNotSatisfiedError\"\n        ) {\n          setUserMediaError(\"OverconstrainedError\");\n        } else {\n          setUserMediaError(e.name);\n        }\n      }\n\n      tracks.forEach((track) => {\n        switch (track.source) {\n          case TrackSource.Microphone:\n            setActiveMicrophone(track);\n            setupLocalMicrophoneAnalyser(track);\n            if (track.deviceId) {\n              setMicrophoneDeviceId(track.deviceId);\n            }\n            if (userWantsMicMuted) {\n              track.mute();\n            }\n            break;\n        }\n      });\n\n      return tracks[0];\n    },\n    [setupLocalMicrophoneAnalyser, setMicrophoneDeviceId, userWantsMicMuted]\n  );\n\n  const changeActiveMicrophone = useCallback(\n    async (deviceId: string) => {\n      await getMicrophone(deviceId);\n    },\n    [getMicrophone]\n  );\n\n  const getActiveMicrophoneLevel = useCallback(() => {\n    if (!localAudioAnalyser) {\n      return null;\n    }\n\n    const sampleBuffer = new Float32Array(localAudioAnalyser.fftSize);\n    localAudioAnalyser.getFloatTimeDomainData(sampleBuffer);\n\n    // Compute average power over the interval.\n    let sumOfSquares = 0;\n    for (let i = 0; i < sampleBuffer.length; i++) {\n      sumOfSquares += sampleBuffer[i] ** 2;\n    }\n    const avgPowerDecibels =\n      10 * Math.log10(sumOfSquares / sampleBuffer.length);\n\n    // Compute peak instantaneous power over the interval.\n    let peakInstantaneousPower = 0;\n    for (let i = 0; i < sampleBuffer.length; i++) {\n      const power = sampleBuffer[i] ** 2;\n      peakInstantaneousPower = Math.max(power, peakInstantaneousPower);\n    }\n    const peakInstantaneousPowerDecibels =\n      10 * Math.log10(peakInstantaneousPower);\n\n    return {\n      avgDb: avgPowerDecibels,\n      peakDb: peakInstantaneousPowerDecibels,\n    };\n  }, [localAudioAnalyser]);\n\n  const getCamera = useCallback(\n    async (deviceId: string) => {\n      let options = {\n        ...defaultCameraOption,\n        ...noMicrophoneOption,\n      };\n      if (deviceId !== \"\") {\n        options[\"video\"] = {\n          constraints: {\n            deviceId: { exact: deviceId },\n          },\n        };\n      }\n\n      let tracks: LocalTrack[] = [];\n      try {\n        tracks = await getUserMedia(options);\n      } catch (e: any) {\n        // May occur if previously set device IDs are no longer available\n        if (\n          e.name == \"NotAllowedError\" ||\n          e.name == \"PermissionDeniedError\" ||\n          e instanceof DOMException\n        ) {\n          // permission denied to camera\n          setUserMediaError(\"NotAllowedError\");\n        } else if (\n          e.name == \"OverconstrainedError\" ||\n          e.name == \"ConstraintNotSatisfiedError\"\n        ) {\n          setUserMediaError(\"OverconstrainedError\");\n        } else {\n          setUserMediaError(e.name);\n        }\n      }\n\n      tracks.forEach((track) => {\n        switch (track.source) {\n          case TrackSource.Camera:\n            setActiveCamera(track);\n            if (track.deviceId) {\n              setCameraDeviceId(track.deviceId);\n            }\n            break;\n        }\n      });\n\n      return tracks[0];\n    },\n    [setCameraDeviceId]\n  );\n\n  const changeActiveCamera = useCallback(\n    async (deviceId: string) => {\n      await getCamera(deviceId);\n    },\n    [getCamera]\n  );\n\n  const stopActiveCamera = useCallback(() => {\n    if (activeCamera) {\n      activeCamera.stop();\n      setActiveCamera(undefined);\n    }\n  }, [activeCamera]);\n\n  const onDeviceChange = useCallback(async () => {\n    console.log(\"Detected device change, refreshing device list\");\n    await loadDevices();\n  }, [loadDevices]);\n\n  useEffect(() => {\n    if (userWantsMicMuted && !activeMicrophone?.muted) {\n      activeMicrophone?.mute();\n    } else if (!userWantsMicMuted && activeMicrophone?.muted) {\n      activeMicrophone?.unMute();\n    }\n  }, [userWantsMicMuted, activeMicrophone]);\n\n  useEffect(() => {\n    navigator.mediaDevices.addEventListener(\"devicechange\", onDeviceChange);\n    return () => {\n      navigator.mediaDevices.removeEventListener(\n        \"devicechange\",\n        onDeviceChange\n      );\n    };\n  }, [onDeviceChange]);\n\n  return (\n    <UserMediaContext.Provider\n      value={{\n        activeCamera,\n        activeMicrophone,\n\n        userMediaError,\n        requestPermissionAndPopulateDevices,\n        requestPermissionAndStartDevices,\n\n        getCamera,\n        cameraDevices,\n        activeCameraId,\n        stopActiveCamera,\n        changeActiveCamera,\n\n        getMicrophone,\n        microphoneDevices,\n        activeMicrophoneId,\n        muteActiveMicrophone,\n        unMuteActiveMicrophone,\n        changeActiveMicrophone,\n        getActiveMicrophoneLevel,\n      }}\n    >\n      {children}\n    </UserMediaContext.Provider>\n  );\n};\n"
  },
  {
    "path": "hooks/useLocalStorage.tsx",
    "content": "import {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\n\ndeclare global {\n  interface WindowEventMap {\n    \"local-storage\": CustomEvent;\n  }\n}\n\ntype SetValue<T> = Dispatch<SetStateAction<T>>;\n\nexport function useLocalStorage<T>(\n  key: string,\n  initialValue: T\n): [T, SetValue<T>] {\n  // Get from local storage then\n  // parse stored json or return initialValue\n  const readValue = useCallback((): T => {\n    // Prevent build error \"window is undefined\" but keep keep working\n    if (typeof window === \"undefined\") {\n      return initialValue;\n    }\n\n    try {\n      const item = window.localStorage.getItem(key);\n      const json = parseJSON(item) as T;\n      if (json === undefined) {\n        window.localStorage.removeItem(key);\n      }\n      return item ? json : initialValue;\n    } catch (error) {\n      console.warn(`Error reading localStorage key “${key}”:`, error);\n      return initialValue;\n    }\n  }, [initialValue, key]);\n\n  // State to store our value\n  // Pass initial state function to useState so logic is only executed once\n  const [storedValue, setStoredValue] = useState<T>(readValue);\n\n  const setValueRef = useRef<SetValue<T>>();\n\n  setValueRef.current = (value) => {\n    // Prevent build error \"window is undefined\" but keeps working\n    if (typeof window == \"undefined\") {\n      console.warn(\n        `Tried setting localStorage key “${key}” even though environment is not a client`\n      );\n    }\n\n    try {\n      // Allow value to be a function so we have the same API as useState\n      const newValue = value instanceof Function ? value(storedValue) : value;\n\n      // Save state\n      setStoredValue(newValue);\n\n      // Save to local storage\n      window.localStorage.setItem(key, JSON.stringify(newValue));\n\n      // We dispatch a custom event so every useLocalStorage hook are notified\n      window.dispatchEvent(new Event(\"local-storage\"));\n    } catch (error) {\n      console.warn(`Error setting localStorage key “${key}”:`, error);\n    }\n  };\n\n  // Return a wrapped version of useState's setter function that ...\n  // ... persists the new value to localStorage.\n  const setValue: SetValue<T> = useCallback(\n    (value) => setValueRef.current?.(value),\n    []\n  );\n\n  useEffect(() => {\n    setStoredValue(readValue());\n  }, []);\n\n  return [storedValue, setValue];\n}\n\n// A wrapper for \"JSON.parse()\"\" to support \"undefined\" value\n\nfunction parseJSON<T>(value: string | null): T | undefined | null {\n  if (!value) return null; // don't attempt to parse a null value\n  try {\n    return value === \"undefined\" ? undefined : JSON.parse(value ?? \"\");\n  } catch {\n    console.log(\"parsing error on\", { value });\n    return undefined;\n  }\n}\n"
  },
  {
    "path": "hooks/useParticipant.ts",
    "content": "import React, {\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useState,\n} from \"react\";\nimport {\n  LocalParticipant,\n  LocalTrack,\n  ParticipantEvent,\n  RemoteParticipant,\n  Track,\n  TrackSource,\n} from \"@mux/spaces-web\";\n\nimport SpaceContext from \"../context/Space\";\nimport UserContext from \"../context/User\";\n\nexport interface Participant {\n  id: string;\n  isLocal: boolean;\n  isSpeaking: boolean;\n  hasMicTrack: boolean;\n  isMicTrackMuted: boolean;\n  isCameraOff: boolean;\n  cameraWidth: number;\n  cameraHeight: number;\n  displayName: string;\n  attachVideoElement: (element: HTMLVideoElement) => void;\n  attachAudioElement: (element: HTMLAudioElement) => void;\n}\n\nexport function useParticipant(connectionId: string): Participant {\n  const { localParticipant, remoteParticipants } = useContext(SpaceContext);\n  let participant: LocalParticipant | RemoteParticipant | undefined =\n    remoteParticipants.find((p) => p.connectionId === connectionId);\n  if (\n    !participant &&\n    localParticipant &&\n    localParticipant.connectionId === connectionId\n  ) {\n    participant = localParticipant;\n  }\n\n  if (typeof participant === \"undefined\") {\n    throw new Error(`No participant found with connectionId: ${connectionId}`);\n  }\n\n  const id = useMemo(() => (participant ? participant.id : \"\"), [participant]);\n  const isLocal = useMemo(\n    () => participant instanceof LocalParticipant,\n    [participant]\n  );\n  const [isSpeaking, setIsSpeaking] = useState(false);\n  const [cameraTrack, setCameraTrack] = useState<Track>();\n  const [microphoneTrack, setMicrophoneTrack] = useState<Track>();\n  const [isMicTrackMuted, setIsMicTrackMuted] = useState(false);\n  const [isCameraOff, setIsCameraOff] = useState(true);\n  const [displayName, setDisplayName] = useState(participant.displayName);\n  const { userWantsMicMuted } = React.useContext(UserContext);\n\n  const hasMicTrack = useMemo(() => {\n    return !!microphoneTrack;\n  }, [microphoneTrack]);\n\n  const cameraDimensions = useMemo(() => {\n    return { width: cameraTrack?.width || 0, height: cameraTrack?.height || 0 };\n  }, [cameraTrack]);\n\n  const attachVideoElement = useCallback(\n    (element: HTMLVideoElement) => {\n      cameraTrack?.attachedElements.forEach((attachedEl) =>\n        cameraTrack.detach(attachedEl)\n      );\n      cameraTrack?.attach(element);\n    },\n    [cameraTrack]\n  );\n\n  const attachAudioElement = useCallback(\n    (element: HTMLAudioElement) => {\n      microphoneTrack?.attachedElements.forEach((attachedEl) =>\n        microphoneTrack.detach(attachedEl)\n      );\n      microphoneTrack?.attach(element);\n    },\n    [microphoneTrack]\n  );\n\n  const handleTrackAdded = useCallback(\n    (track: Track) => {\n      if (track.hasMedia()) {\n        if (track.source === TrackSource.Camera) {\n          setCameraTrack(track);\n          setIsCameraOff(track.isMuted());\n        }\n        if (track.source === TrackSource.Microphone) {\n          setMicrophoneTrack(track);\n          if (isLocal && userWantsMicMuted) {\n            (track as LocalTrack).mute();\n          }\n          if (track.isMuted()) {\n            setIsMicTrackMuted(true);\n          }\n        }\n      }\n    },\n    [setCameraTrack, setMicrophoneTrack]\n  );\n\n  const handleSetDisplayName = useCallback(() => {\n    setDisplayName(participant ? participant.displayName : \"\");\n  }, [setDisplayName]);\n\n  const handleTrackRemoved = useCallback(\n    (track: Track) => {\n      if (track.source === TrackSource.Camera) {\n        setCameraTrack(undefined);\n        setIsCameraOff(true);\n      }\n      if (track.source === TrackSource.Microphone) {\n        setMicrophoneTrack(undefined);\n      }\n    },\n    [setCameraTrack, setMicrophoneTrack]\n  );\n\n  useEffect(() => {\n    if (isLocal && microphoneTrack instanceof LocalTrack) {\n      if (userWantsMicMuted && !microphoneTrack.muted) {\n        microphoneTrack.mute();\n      } else if (!userWantsMicMuted && microphoneTrack.muted) {\n        microphoneTrack.unMute();\n      }\n    }\n  }, [userWantsMicMuted, isLocal, microphoneTrack]);\n\n  useEffect(() => {\n    if (!participant) return;\n\n    const onMuted = (track: Track) => {\n      if (track.source == TrackSource.Microphone) {\n        setIsMicTrackMuted(true);\n      } else if (track.source == TrackSource.Camera) {\n        setIsCameraOff(true);\n      }\n    };\n    const onUnmuted = (track: Track) => {\n      if (track.source == TrackSource.Microphone) {\n        setIsMicTrackMuted(false);\n      } else if (track.source == TrackSource.Camera) {\n        setIsCameraOff(false);\n      }\n    };\n    const onSpeaking = () => {\n      setIsSpeaking(true);\n    };\n    const stoppedSpeaking = () => {\n      setIsSpeaking(false);\n    };\n\n    participant.on(ParticipantEvent.TrackMuted, onMuted);\n    participant.on(ParticipantEvent.TrackUnmuted, onUnmuted);\n    participant.on(ParticipantEvent.StartedSpeaking, onSpeaking);\n    participant.on(ParticipantEvent.StoppedSpeaking, stoppedSpeaking);\n    participant.on(ParticipantEvent.TrackPublished, handleTrackAdded);\n    participant.on(ParticipantEvent.TrackUnpublished, handleTrackRemoved);\n    participant.on(ParticipantEvent.TrackSubscribed, handleTrackAdded);\n    participant.on(ParticipantEvent.TrackUnsubscribed, handleTrackRemoved);\n    participant.on(ParticipantEvent.DisplayNameChanged, handleSetDisplayName);\n\n    participant.getAudioTracks().forEach((track) => {\n      handleTrackAdded(track);\n    });\n    participant.getVideoTracks().forEach((track) => {\n      handleTrackAdded(track);\n    });\n\n    return () => {\n      if (!participant) return;\n\n      participant.off(ParticipantEvent.TrackMuted, onMuted);\n      participant.off(ParticipantEvent.TrackUnmuted, onUnmuted);\n      participant.off(ParticipantEvent.StartedSpeaking, onSpeaking);\n      participant.off(ParticipantEvent.StoppedSpeaking, stoppedSpeaking);\n      participant.off(ParticipantEvent.TrackPublished, handleTrackAdded);\n      participant.off(ParticipantEvent.TrackUnpublished, handleTrackRemoved);\n      participant.off(ParticipantEvent.TrackSubscribed, handleTrackAdded);\n      participant.off(ParticipantEvent.TrackUnsubscribed, handleTrackRemoved);\n      participant.off(\n        ParticipantEvent.DisplayNameChanged,\n        handleSetDisplayName\n      );\n    };\n  }, [participant, handleTrackAdded, handleTrackRemoved]);\n\n  return {\n    id,\n    isLocal,\n    isSpeaking,\n    isCameraOff,\n    hasMicTrack,\n    isMicTrackMuted,\n    cameraWidth: cameraDimensions.width,\n    cameraHeight: cameraDimensions.height,\n    displayName,\n    attachVideoElement,\n    attachAudioElement,\n  };\n}\n"
  },
  {
    "path": "hooks/useSpace.ts",
    "content": "import { useContext } from \"react\";\nimport { AcrScore } from \"@mux/spaces-web\";\n\nimport SpaceContext from \"../context/Space\";\n\ninterface Space {\n  joinSpace: (\n    jwt: string,\n    endsAt?: number,\n    displayName?: string\n  ) => Promise<void>;\n  joinError: string | null;\n  isJoined: boolean;\n\n  connectionIds: string[];\n  localParticipantConnectionId?: string;\n  isBroadcasting: boolean;\n  participantCount: number;\n  publishCamera: (deviceId: string) => void;\n  publishMicrophone: (deviceId: string) => void;\n  unPublishDevice: (deviceId: string) => void;\n\n  isLocalScreenShareSupported: boolean;\n  isScreenShareActive: boolean;\n  isLocalScreenShare: boolean;\n  screenShareError: string | null;\n  attachScreenShare: (element: HTMLVideoElement) => void;\n  startScreenShare: () => void;\n  stopScreenShare: () => void;\n  screenShareParticipantConnectionId?: string;\n  screenShareParticipantName?: string;\n\n  spaceEndsAt: number | null;\n  leaveSpace: () => void;\n  submitAcrScore: (score: AcrScore) => Promise<void> | undefined;\n}\n\nexport const useSpace = (): Space => {\n  const {\n    space,\n\n    joinSpace,\n    joinError,\n    isJoined,\n\n    connectionIds,\n    isBroadcasting,\n    participantCount,\n    publishCamera,\n    publishMicrophone,\n    unPublishDevice,\n\n    isLocalScreenShareSupported,\n    isScreenShareActive,\n    isLocalScreenShare,\n    screenShareError,\n    attachScreenShare,\n    startScreenShare,\n    stopScreenShare,\n    screenShareParticipantConnectionId,\n    screenShareParticipantName,\n\n    spaceEndsAt,\n    leaveSpace,\n    submitAcrScore,\n  } = useContext(SpaceContext);\n\n  return {\n    joinSpace,\n    joinError,\n    isJoined,\n\n    connectionIds,\n    localParticipantConnectionId: space?.localParticipant?.connectionId,\n    isBroadcasting,\n    participantCount,\n    publishCamera,\n    publishMicrophone,\n    unPublishDevice,\n\n    isLocalScreenShareSupported,\n    isScreenShareActive,\n    isLocalScreenShare,\n    screenShareError,\n    attachScreenShare,\n    startScreenShare,\n    stopScreenShare,\n    screenShareParticipantConnectionId,\n    screenShareParticipantName,\n\n    spaceEndsAt,\n    leaveSpace,\n    submitAcrScore,\n  };\n};\n"
  },
  {
    "path": "hooks/useSpaceEvent.tsx",
    "content": "import { SpaceEvent } from \"@mux/spaces-web\";\nimport SpaceContext from \"context/Space\";\nimport { useContext, useEffect, useRef } from \"react\";\n\n/**\n * @param event\n * @param callback called whenever the event is fired. Warning: The callback should be provided using the `useCallback` hook to avoid this hook being called at every re-render of the component.\n * @param startListening if false, the callback will not be registered until this is set to true. Useful for registering callbacks conditionally.\n */\nexport const useSpaceEvent = (\n  event: SpaceEvent,\n  callback: (...args: any) => void,\n  startListening = true\n): void => {\n  const { space } = useContext(SpaceContext);\n\n  useEffect(() => {\n    if (!startListening) {\n      return;\n    }\n\n    try {\n      space?.on(event, callback);\n    } catch (_error) {\n      throw new Error(\n        `Unable to register useSpaceEvent callback ${callback.name} as the Space does not exist yet.`\n      );\n    }\n\n    return () => {\n      space?.off(event, callback);\n    };\n  }, [event, callback, space, startListening]);\n};\n"
  },
  {
    "path": "hooks/useUserMedia.ts",
    "content": "import { useContext } from \"react\";\n\nimport UserMediaContext from \"../context/UserMedia\";\n\ninterface UserMedia {\n  userMediaError?: string;\n  requestPermissionAndPopulateDevices: () => void;\n  requestPermissionAndStartDevices: (\n    microphoneDeviceId?: string,\n    cameraDeviceId?: string\n  ) => Promise<void>;\n\n  cameraDevices: MediaDeviceInfo[];\n  activeCameraId?: string;\n  stopActiveCamera: () => void;\n  changeActiveCamera: (deviceId: string) => Promise<void>;\n\n  microphoneDevices: MediaDeviceInfo[];\n  activeMicrophoneId?: string;\n  muteActiveMicrophone: () => void;\n  unMuteActiveMicrophone: () => void;\n  changeActiveMicrophone: (deviceId: string) => Promise<void>;\n  getActiveMicrophoneLevel: () => {\n    avgDb: number;\n    peakDb: number;\n  } | null;\n}\n\nexport function useUserMedia(): UserMedia {\n  const {\n    userMediaError,\n    requestPermissionAndPopulateDevices,\n    requestPermissionAndStartDevices,\n\n    cameraDevices,\n    activeCameraId,\n    stopActiveCamera,\n    changeActiveCamera,\n\n    microphoneDevices,\n    activeMicrophoneId,\n    muteActiveMicrophone,\n    unMuteActiveMicrophone,\n    changeActiveMicrophone,\n    getActiveMicrophoneLevel,\n  } = useContext(UserMediaContext);\n\n  return {\n    userMediaError,\n    requestPermissionAndPopulateDevices,\n    requestPermissionAndStartDevices,\n\n    cameraDevices,\n    activeCameraId,\n    stopActiveCamera,\n    changeActiveCamera,\n\n    microphoneDevices,\n    activeMicrophoneId,\n    muteActiveMicrophone,\n    unMuteActiveMicrophone,\n    changeActiveMicrophone,\n    getActiveMicrophoneLevel,\n  };\n}\n"
  },
  {
    "path": "hooks/useWindowDimension.ts",
    "content": "import { useEffect, useState } from \"react\";\n\ntype WindowDimensions = {\n  width: number | undefined;\n  height: number | undefined;\n};\n\nconst useWindowDimensions = (): WindowDimensions => {\n  const [windowDimensions, setWindowDimensions] = useState<WindowDimensions>({\n    width: undefined,\n    height: undefined,\n  });\n  useEffect(() => {\n    function handleResize(): void {\n      setWindowDimensions({\n        width: window.innerWidth,\n        height: window.innerHeight,\n      });\n    }\n    handleResize();\n    window.addEventListener(\"resize\", handleResize);\n    return (): void => window.removeEventListener(\"resize\", handleResize);\n  }, []); // Empty array ensures that effect is only run on mount\n\n  return windowDimensions;\n};\n\nexport default useWindowDimensions;\n"
  },
  {
    "path": "lib/constants.ts",
    "content": "export const MAX_PARTICIPANTS_PER_PAGE = 20;\nexport const TEMPORARY_SPACE_PASSTHROUGH = \"Temporary Meet Space\";\n"
  },
  {
    "path": "lib/gallery.ts",
    "content": "type LayoutDescription = {\n  area: number;\n  cols: number;\n  rows: number;\n  width: number;\n  height: number;\n};\n\n/**\n * Calculate optimal layout (most area used) of a number of boxes within a larger frame.\n * Given number of boxes, aspectRatio of those boxes, and spacing between them.\n *\n * Thanks to Anton Dosov for algorithm shown in this article:\n * https://dev.to/antondosov/building-a-video-gallery-just-like-in-zoom-4mam\n *\n * @param frameWidth width of the space holding the boxes\n * @param frameHeight height of the space holding the boxes\n * @param boxCount number of boxes to place (all same aspect ratio)\n * @param aspectRatio ratio of width to height of the boxes (usually 16/9)\n * @param spacing amount of space (margin) between boxes to spread them out\n * @returns A description of the optimal layout\n */\nexport function calcOptimalBoxes(\n  frameWidth: number,\n  frameHeight: number,\n  boxCount: number,\n  aspectRatio: number,\n  spacing: number\n): LayoutDescription {\n  let bestLayout: LayoutDescription = {\n    area: 0,\n    cols: 0,\n    rows: 0,\n    width: 0,\n    height: 0,\n  };\n\n  // try each possible number of columns to find the one with the highest area (optimum use of space)\n  for (let cols = 1; cols <= boxCount; cols++) {\n    const rows = Math.ceil(boxCount / cols);\n    // pack the frames together by removing the spacing between them\n    const packedWidth = frameWidth - spacing * (cols - 1);\n    const packedHeight = frameHeight - spacing * (rows - 1);\n    const hScale = packedWidth / (cols * aspectRatio);\n    const vScale = packedHeight / rows;\n    let width;\n    let height;\n    if (hScale <= vScale) {\n      width = Math.floor(packedWidth / cols);\n      height = Math.floor(width / aspectRatio);\n    } else {\n      height = Math.floor(packedHeight / rows);\n      width = Math.floor(height * aspectRatio);\n    }\n    const area = width * height;\n    if (area > bestLayout.area) {\n      bestLayout = { area, width, height, rows, cols };\n    }\n  }\n\n  return bestLayout;\n}\n"
  },
  {
    "path": "lib/theme.ts",
    "content": "import localFont from \"@next/font/local\";\nimport { extendTheme, defineStyle } from \"@chakra-ui/react\";\n\nconst akkuratFont = localFont({\n  src: \"./Akkurat-Regular.woff2\",\n});\n\nconst baseMuxButtonStyles = {\n  height: \"40px\",\n  borderRadius: \"20px\",\n  padding: \"10px 20px 10px 20px\",\n  borderWidth: \"1px\",\n  fontSize: \"14px\",\n};\n\nconst control = defineStyle({\n  background: \"#0a0a0b\",\n  width: \"60px\",\n  height: \"60px\",\n  border: \"1px solid #3E4247\",\n  borderRadius: \"50%\",\n  _hover: {\n    background: \"#242628\",\n    border: \"1px solid #FFFFFF\",\n  },\n  _active: {\n    background: \"#3E4247\",\n    border: \"1px solid #FFFFFF\",\n  },\n});\n\nconst controlMenu = defineStyle({\n  background: \"#707C89\",\n  width: \"20px\",\n  height: \"20px\",\n  border: \"1px solid #565E67\",\n  borderRadius: \"50%\",\n  fontWeight: \"bold\",\n  _hover: {\n    background: \"#3E4247\",\n    border: \"1px solid #FFFFFF\",\n  },\n  _active: {\n    background: \"#3E4247\",\n    border: \"1px solid #FFFFFF\",\n  },\n});\n\nconst muxDefault = defineStyle({\n  ...baseMuxButtonStyles,\n  background: \"#FFFFFF\",\n  borderColor: \"#808C99\",\n  fontWeight: \"normal\",\n  _hover: {\n    borderColor: \"#242628\",\n  },\n  _active: {\n    background: \"#F3F5F6\",\n    borderColor: \"#242628\",\n  },\n});\n\nconst muxConfirmation = defineStyle({\n  ...baseMuxButtonStyles,\n  background: \"#00AA3C\",\n  borderColor: \"#00802D\",\n  color: \"#FFFFFF\",\n  _hover: {\n    background: \"#00802D\",\n    borderColor: \"#005C20\",\n    _disabled: {\n      fontWeight: \"normal\",\n      background: \"#E5E8EB\",\n      borderColor: \"#E5E8EB\",\n      color: \"#707C89\",\n    },\n  },\n  _active: {\n    background: \"#005C20\",\n    borderColor: \"#003D16\",\n  },\n  _disabled: {\n    fontWeight: \"normal\",\n    background: \"#E5E8EB\",\n    borderColor: \"#E5E8EB\",\n    color: \"#707C89\",\n  },\n});\n\nconst muxDestructive = defineStyle({\n  ...baseMuxButtonStyles,\n  background: \"#FDA89B\",\n  borderColor: \"#F87B6D\",\n  fontWeight: \"bold\",\n  _hover: {\n    borderColor: \"#F85C54\",\n    background: \"#F87B6D\",\n  },\n  _active: {\n    borderColor: \"#EA3737\",\n    background: \"#F85C54\",\n  },\n});\n\nexport const theme = extendTheme({\n  fonts: {\n    heading: `${akkuratFont.style.fontFamily}, sans-serif`,\n    body: `${akkuratFont.style.fontFamily}, sans-serif`,\n  },\n  styles: {\n    global: () => ({\n      body: {\n        width: \"100%\",\n        height: \"100%\",\n        position: \"fixed\",\n      },\n    }),\n  },\n  colors: {\n    red: {\n      50: \"#FFE0E3\",\n      100: \"#FFC0C6\",\n      200: \"#FF949E\",\n      300: \"#FF6877\",\n      400: \"#FB3C4E\",\n      500: \"#E22C3E\",\n      600: \"#B71928\",\n      700: \"#950D1A\",\n      800: \"#73040E\",\n    },\n\n    green: {\n      50: \"#E0FFFA\",\n      100: \"#BAF8EE\",\n      200: \"#82EDDC\",\n      300: \"#49DFC6\",\n      400: \"#1FC3A8\",\n      500: \"#17A089\",\n      600: \"#047F6B\",\n      700: \"#036353\",\n      800: \"#00473C\",\n    },\n\n    blue: {\n      50: \"#DBEFFF\",\n      100: \"#B5E0FF\",\n      200: \"#82CBFF\",\n      300: \"#4FB6FF\",\n      400: \"#1CA0FD\",\n      500: \"#0B85DB\",\n      600: \"#006DB9\",\n      700: \"#005997\",\n      800: \"#003C66\",\n    },\n\n    purple: {\n      50: \"#F3E0FF\",\n      100: \"#E7BDFF\",\n      200: \"#CF86F9\",\n      300: \"#BE52FA\",\n      400: \"#9620D8\",\n      500: \"#7A10B6\",\n      600: \"#600494\",\n      700: \"#490072\",\n      800: \"#330050\",\n    },\n    orange: {\n      50: \"#FB501D\",\n      100: \"#DF491E\",\n    },\n  },\n  semanticTokens: {\n    colors: {\n      gradient: \"linear-gradient(90deg, #fb3c4e 0%, #fb2491 100%)\",\n    },\n  },\n  components: {\n    Menu: {\n      parts: [\"item\"],\n      baseStyle: {\n        item: {\n          background: \"#383838\",\n          _focus: {\n            background: \"transparent\",\n          },\n          _active: {\n            background: \"rgba(0,0,0,0.5)\",\n          },\n        },\n      },\n    },\n    Button: {\n      variants: {\n        control,\n        controlMenu,\n        muxDefault,\n        muxConfirmation,\n        muxDestructive,\n      },\n      baseStyle: { transition: \"none\" },\n    },\n  },\n});\n"
  },
  {
    "path": "lib/utils.ts",
    "content": "import { CreateStyled } from \"@emotion/styled\";\n\nexport const transientOptions: Parameters<CreateStyled>[1] = {\n  shouldForwardProp: (propName: string) => !propName.startsWith(\"$\"),\n};\n"
  },
  {
    "path": "next-env.d.ts",
    "content": "/// <reference types=\"next\" />\n/// <reference types=\"next/image-types/global\" />\n\n// NOTE: This file should not be edited\n// see https://nextjs.org/docs/basic-features/typescript for more information.\n"
  },
  {
    "path": "next.config.js",
    "content": "/** @type {import('next').NextConfig} */\nconst nextConfig = {\n  reactStrictMode: true,\n};\n\nmodule.exports = nextConfig;\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"@mux/mux-meet-nextjs\",\n  \"private\": true,\n  \"scripts\": {\n    \"dev\": \"next dev\",\n    \"build\": \"next build\",\n    \"start\": \"next start\",\n    \"lint\": \"next lint\",\n    \"prettier\": \"prettier --write .\",\n    \"prepare\": \"husky install\"\n  },\n  \"dependencies\": {\n    \"@chakra-ui/react\": \"^2.6.1\",\n    \"@emotion/react\": \"^11.11.0\",\n    \"@emotion/styled\": \"^11.11.0\",\n    \"@mux/mux-node\": \"^7.3.0\",\n    \"@mux/mux-player-react\": \"^1.11.0\",\n    \"@mux/spaces-web\": \"1.2.0\",\n    \"@next/font\": \"^13.4.1\",\n    \"axios\": \"^1.4.0\",\n    \"framer-motion\": \"^8.5.5\",\n    \"http-status-codes\": \"^2.2.0\",\n    \"jsonwebtoken\": \"^9.0.0\",\n    \"loglevel\": \"^1.8.1\",\n    \"moment\": \"^2.29.4\",\n    \"next\": \"13.1.6\",\n    \"react\": \"18.2.0\",\n    \"react-dom\": \"18.2.0\",\n    \"react-hotkeys-hook\": \"^4.4.0\",\n    \"react-icons\": \"^4.8.0\",\n    \"react-query\": \"^3.39.3\",\n    \"sharp\": \"^0.31.3\",\n    \"use-sound\": \"^4.0.1\",\n    \"uuid\": \"^9.0.0\"\n  },\n  \"devDependencies\": {\n    \"@types/jsonwebtoken\": \"^9.0.2\",\n    \"@types/node\": \"18.11.18\",\n    \"@types/react\": \"18.0.27\",\n    \"@types/uuid\": \"^9.0.1\",\n    \"eslint\": \"8.33.0\",\n    \"eslint-config-next\": \"13.1.6\",\n    \"eslint-config-prettier\": \"^8.8.0\",\n    \"husky\": \"^8.0.3\",\n    \"lint-staged\": \"^13.2.2\",\n    \"prettier\": \"2.8.3\",\n    \"typescript\": \"4.9.5\"\n  },\n  \"lint-staged\": {\n    \"**/*\": \"prettier --write --ignore-unknown\"\n  }\n}\n"
  },
  {
    "path": "pages/_app.tsx",
    "content": "import type { AppProps } from \"next/app\";\nimport { ChakraProvider } from \"@chakra-ui/react\";\nimport { QueryClient, QueryClientProvider } from \"react-query\";\n\nimport { theme } from \"lib/theme\";\nimport { UserProvider } from \"context/User\";\nimport { SpaceProvider } from \"context/Space\";\nimport { UserMediaProvider } from \"context/UserMedia\";\n\nconst queryClient = new QueryClient({\n  defaultOptions: {\n    queries: {\n      refetchOnWindowFocus: false,\n    },\n  },\n});\n\nfunction MyApp({ Component, pageProps }: AppProps) {\n  return (\n    <QueryClientProvider client={queryClient}>\n      <ChakraProvider theme={theme}>\n        <UserProvider>\n          <UserMediaProvider>\n            <SpaceProvider>\n              <Component {...pageProps} />\n            </SpaceProvider>\n          </UserMediaProvider>\n        </UserProvider>\n      </ChakraProvider>\n    </QueryClientProvider>\n  );\n}\n\nexport default MyApp;\n"
  },
  {
    "path": "pages/_document.tsx",
    "content": "import { ColorModeScript } from \"@chakra-ui/react\";\nimport Document, { Html, Head, Main, NextScript } from \"next/document\";\n\nexport default class MyDocument extends Document {\n  render() {\n    return (\n      <Html lang=\"en\">\n        <Head />\n        <body>\n          <ColorModeScript />\n          <Main />\n          <NextScript />\n        </body>\n      </Html>\n    );\n  }\n}\n"
  },
  {
    "path": "pages/api/spaces/[id].ts",
    "content": "import { StatusCodes } from \"http-status-codes\";\nimport { NextApiRequest, NextApiResponse } from \"next\";\nimport { muxClient } from \"server-lib/services\";\nimport axios from \"axios\";\n\nconst fetchSpace = async (id: string) => {\n  let response;\n\n  try {\n    response = await muxClient.get(`/video/v1/spaces/${id}`);\n  } catch (error) {\n    if (axios.isAxiosError(error)) {\n      throw new Error(`Error: ${error.response?.status}`);\n    }\n\n    throw new Error(\"Unknown error\");\n  }\n\n  return response.data.data;\n};\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse\n) {\n  if (req.method === \"GET\" && typeof req.query.id === \"string\") {\n    const space = await fetchSpace(req.query.id);\n    res.status(StatusCodes.OK).json(space);\n  } else {\n    res.status(StatusCodes.METHOD_NOT_ALLOWED);\n  }\n}\n\nexport { fetchSpace };\n"
  },
  {
    "path": "pages/api/spaces.ts",
    "content": "import axios from \"axios\";\nimport { StatusCodes } from \"http-status-codes\";\nimport { TEMPORARY_SPACE_PASSTHROUGH } from \"lib/constants\";\nimport { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { muxClient } from \"../../server-lib/services\";\n\ntype Space = {\n  id: string;\n  type: string;\n  created_at: string;\n  status: \"active\" | \"idle\";\n  passthrough?: string;\n};\n\nconst fetchSpaces = async (): Promise<Space[]> => {\n  let response;\n\n  try {\n    response = await muxClient.get(`/video/v1/spaces?limit=100`);\n  } catch (error) {\n    if (axios.isAxiosError(error)) {\n      throw new Error(`Error: ${error.response?.status}`);\n    }\n\n    throw new Error(\"Unknown error\");\n  }\n\n  return response.data.data;\n};\n\nconst createSpace = async () => {\n  let response;\n\n  try {\n    response = await muxClient.post(`/video/v1/spaces`, {\n      passthrough: TEMPORARY_SPACE_PASSTHROUGH,\n    });\n  } catch (error) {\n    if (axios.isAxiosError(error)) {\n      throw new Error(`Error: ${error.response?.status}`);\n    }\n\n    throw new Error(\"Unknown error\");\n  }\n\n  return response.data.data;\n};\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse\n) {\n  if (req.method === \"POST\") {\n    const activeSpaceLimit = process.env.ACTIVE_SPACE_LIMIT;\n    if (activeSpaceLimit) {\n      const limit = parseInt(activeSpaceLimit, 10);\n      const spaces = await fetchSpaces();\n      const activeSpaces = spaces.filter(({ status }) => status === \"active\");\n      if (activeSpaces.length >= limit) {\n        return res.status(StatusCodes.INSUFFICIENT_SPACE_ON_RESOURCE).end();\n      }\n    }\n\n    try {\n      const space = await createSpace();\n      res.status(StatusCodes.OK).json(space);\n    } catch (error) {\n      const message = (error as Error).message as string;\n      if (message.includes(\"401\")) {\n        res.status(StatusCodes.UNAUTHORIZED).end();\n      }\n    }\n  } else {\n    res.status(StatusCodes.METHOD_NOT_ALLOWED).end();\n  }\n}\n"
  },
  {
    "path": "pages/api/token.ts",
    "content": "import { StatusCodes } from \"http-status-codes\";\nimport jwt from \"jsonwebtoken\";\nimport { NextApiRequest, NextApiResponse } from \"next\";\n\nconst { MUX_SIGNING_KEY, MUX_PRIVATE_KEY } = process.env;\n\ntype ResponseData = {\n  spaceJWT: string;\n};\n\nfunction signJWT(spaceId: string, participantId: string): ResponseData {\n  const JWT = jwt.sign(\n    {\n      kid: MUX_SIGNING_KEY ?? \"\",\n      aud: \"rt\",\n      sub: spaceId,\n      participant_id: participantId,\n    },\n    Buffer.from(MUX_PRIVATE_KEY ?? \"\", \"base64\"),\n    { algorithm: \"RS256\", expiresIn: \"1h\" }\n  );\n  return { spaceJWT: JWT };\n}\n\nexport default function handler(\n  req: NextApiRequest,\n  res: NextApiResponse<ResponseData>\n) {\n  const {\n    body: { spaceId, participantId },\n    method,\n  } = req;\n  if (method === \"POST\") {\n    res\n      .status(StatusCodes.OK)\n      .json(signJWT(spaceId as string, participantId as string));\n  } else {\n    res.status(StatusCodes.METHOD_NOT_ALLOWED);\n  }\n}\n"
  },
  {
    "path": "pages/api/webhooks.ts",
    "content": "import Mux from \"@mux/mux-node\";\nimport { StatusCodes } from \"http-status-codes\";\nimport { TEMPORARY_SPACE_PASSTHROUGH } from \"lib/constants\";\nimport { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { muxClient } from \"server-lib/services\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse\n) {\n  const headers = req.headers;\n  const muxSignature = headers[\"mux-signature\"] as string;\n  const secret = process.env.WEBHOOK_SECRET;\n\n  if (!secret) {\n    console.error(\"WEBHOOK_SECRET not specified\");\n    return res.status(StatusCodes.INTERNAL_SERVER_ERROR).end();\n  }\n\n  if (\n    req.method === \"POST\" &&\n    Mux.Webhooks.verifyHeader(JSON.stringify(req.body), muxSignature, secret)\n  ) {\n    const { body } = req;\n\n    if (\n      body.type === \"video.space.idle\" &&\n      body.data.passthrough.startsWith(TEMPORARY_SPACE_PASSTHROUGH)\n    ) {\n      try {\n        await muxClient.delete(`/video/v1/spaces/${body.data.id}`);\n        return res.status(StatusCodes.OK).end();\n      } catch (error) {\n        return res.status(StatusCodes.BAD_REQUEST).end();\n      }\n    }\n\n    return res.status(StatusCodes.OK).end();\n  }\n\n  return res.status(StatusCodes.METHOD_NOT_ALLOWED).end();\n}\n"
  },
  {
    "path": "pages/index.tsx",
    "content": "import React, { useEffect, useMemo, useRef, useState } from \"react\";\nimport Head from \"next/head\";\nimport { useRouter } from \"next/router\";\nimport {\n  Button,\n  Box,\n  FormControl,\n  FormLabel,\n  Stack,\n  Heading,\n  Input,\n  FormHelperText,\n  Flex,\n  Center,\n  HStack,\n  useDisclosure,\n} from \"@chakra-ui/react\";\nimport { useMutation } from \"react-query\";\n\nimport UserContext from \"context/User\";\nimport { useUserMedia } from \"hooks/useUserMedia\";\n\nimport Header from \"components/Header\";\nimport MicrophoneButton from \"components/controls/buttons/MicrophoneButton\";\nimport CameraButton from \"components/controls/buttons/CameraButton\";\nimport ErrorModal from \"components/modals/ErrorModal\";\n\nconst Home = () => {\n  const router = useRouter();\n  const didPopulateDevicesRef = useRef(false);\n  const user = React.useContext(UserContext);\n  const [participantName, setParticipantName] = useState(\"\");\n  const [joining, setJoining] = useState(false);\n  const [hasBlurredNameInput, setHasBlurredNameInput] = useState(false);\n  const { requestPermissionAndPopulateDevices } = useUserMedia();\n  const [errorModalTitle, setErrorModalTitle] = useState(\"\");\n  const [errorModalMessage, setErrorModalMessage] = useState(\"\");\n  const {\n    isOpen: isErrorModalOpen,\n    onOpen: onErrorModalOpen,\n    onClose: onErrorModalClose,\n  } = useDisclosure();\n\n  const createSpaceMutation = useMutation([\"Spaces\"], () =>\n    fetch(`/api/spaces`, {\n      method: \"POST\",\n      mode: \"no-cors\",\n    }).then((res) => {\n      if (res.ok) {\n        return res.json();\n      } else if (res.status === 401) {\n        throw new Error(\"Not authorized to create space\");\n      } else if (res.status === 419) {\n        throw new Error(\"Maximum active spaces reached\");\n      } else {\n        throw new Error(\"Error creating space\");\n      }\n    })\n  );\n\n  useEffect(() => {\n    if (didPopulateDevicesRef.current === false) {\n      didPopulateDevicesRef.current = true;\n      requestPermissionAndPopulateDevices();\n    }\n  }, [requestPermissionAndPopulateDevices]);\n\n  useEffect(() => {\n    setParticipantName(user.participantName);\n  }, [user.participantName]);\n\n  const invalidParticipantName = useMemo(\n    () => !participantName,\n    [participantName]\n  );\n\n  const disableJoin = useMemo(\n    () => invalidParticipantName,\n    [invalidParticipantName]\n  );\n\n  const isNameInputInvalid = useMemo(\n    () => invalidParticipantName && hasBlurredNameInput,\n    [invalidParticipantName, hasBlurredNameInput]\n  );\n\n  const handleParticipantNameChange = (event: {\n    target: { value: string };\n  }) => {\n    setParticipantName(event.target.value);\n  };\n\n  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {\n    e.preventDefault();\n    setJoining(true);\n    user.setParticipantName!(participantName);\n    user.setInteractionRequired(false);\n\n    createSpaceMutation.mutate(undefined, {\n      onError: (error) => {\n        const message = (error as Error).message;\n\n        if (message.includes(\"Not authorized\")) {\n          setErrorModalTitle(\"Not authorized to create a new space\");\n          setErrorModalMessage(\n            \"Make sure MUX_TOKEN_ID and MUX_TOKEN_SECRET are set. Refer to the README in https://github.com/muxinc/meet for more details.\"\n          );\n          onErrorModalOpen();\n        } else if (message.includes(\"Maximum active spaces reached\")) {\n          setErrorModalTitle(\"Maximum active space limit reached\");\n          setErrorModalMessage(\n            \"There are too many active spaces being used. Please try again later.\"\n          );\n          onErrorModalOpen();\n        }\n\n        setJoining(false);\n      },\n      onSuccess: (newSpace) => {\n        if (newSpace) {\n          router.push({\n            pathname: `/space/${newSpace.id}`,\n          });\n        }\n      },\n    });\n  }\n\n  return (\n    <>\n      <Head>\n        <title>Mux Meet</title>\n        <meta name=\"description\" content=\"Real-time meetings powered by Mux\" />\n        <link rel=\"icon\" href=\"/favicon.png\" />\n      </Head>\n\n      <Flex direction=\"column\" height=\"100vh\" backgroundColor=\"#323232\">\n        <Header />\n        <Center height=\"100%\" zIndex={1}>\n          <Flex direction=\"column\" align=\"center\">\n            <Box background=\"white\" padding=\"4\" borderRadius=\"4\" width=\"360px\">\n              <form onSubmit={handleSubmit}>\n                <Stack spacing=\"4\">\n                  <Heading>Join a Space</Heading>\n\n                  <FormControl\n                    isInvalid={isNameInputInvalid}\n                    onBlur={() => setHasBlurredNameInput(true)}\n                  >\n                    <FormLabel>Your Name</FormLabel>\n                    <Input\n                      maxLength={40}\n                      id=\"participant_name\"\n                      value={participantName}\n                      onChange={handleParticipantNameChange}\n                    />\n                    <FormHelperText\n                      color={!isNameInputInvalid ? \"white\" : \"#E22C3E\"}\n                    >\n                      This cannot be empty.\n                    </FormHelperText>\n                  </FormControl>\n\n                  <Button\n                    type=\"submit\"\n                    width=\"full\"\n                    isDisabled={disableJoin}\n                    isLoading={joining}\n                  >\n                    Join a New Space\n                  </Button>\n                </Stack>\n              </form>\n            </Box>\n            <HStack marginTop=\"1rem\">\n              <MicrophoneButton />\n              <CameraButton />\n            </HStack>\n          </Flex>\n        </Center>\n      </Flex>\n\n      <ErrorModal\n        title={errorModalTitle}\n        message={errorModalMessage}\n        isOpen={isErrorModalOpen}\n        onClose={onErrorModalClose}\n      />\n    </>\n  );\n};\n\nexport default Home;\n"
  },
  {
    "path": "pages/space/[id].tsx",
    "content": "import React, { useCallback, useEffect, useRef, useState } from \"react\";\nimport { useMutation } from \"react-query\";\nimport Head from \"next/head\";\nimport Image from \"next/image\";\nimport { useRouter } from \"next/router\";\nimport type { NextPage, GetServerSideProps } from \"next\";\nimport { Flex } from \"@chakra-ui/react\";\nimport styled from \"@emotion/styled\";\nimport moment from \"moment\";\n\nimport UserContext from \"context/User\";\nimport { useSpace } from \"hooks/useSpace\";\nimport { tokenPOST } from \"client/token\";\nimport { fetchSpace } from \"pages/api/spaces/[id]\";\n\nimport Stage from \"components/Stage\";\nimport UserInteractionPrompt from \"components/UserInteractionPrompt\";\n\nimport starfield from \"../../public/starfield-bg.jpg\";\nimport { TEMPORARY_SPACE_PASSTHROUGH } from \"lib/constants\";\n\nconst BackgroundImageWrap = styled.div`\n  position: fixed;\n  height: 100vh;\n  width: 100vw;\n  overflow: hidden;\n  z-index: 0;\n`;\n\ninterface Props {\n  heliosURL: string;\n  spaceBackendURL: string;\n  title: string;\n  endsAt?: number;\n}\n\nconst SpacePage: NextPage<Props> = ({\n  heliosURL,\n  spaceBackendURL,\n  title,\n  endsAt,\n}: Props) => {\n  const router = useRouter();\n  const { id } = router.query;\n  const { isReady: isRouterReady } = router;\n  const user = React.useContext(UserContext);\n  const { joinSpace, leaveSpace } = useSpace();\n  const [canJoinSpace, setCanJoinSpace] = useState(true);\n  const participantNameRef = useRef<string>(\"\");\n\n  useEffect(() => {\n    setCanJoinSpace((endsAt && moment(endsAt).diff(moment()) > 0) || !endsAt);\n  }, [endsAt]);\n\n  useEffect(() => {\n    if (spaceBackendURL) {\n      (window as any).MUX_SPACES_BACKEND_URL = spaceBackendURL;\n    }\n    if (heliosURL) {\n      (window as any).MUX_SPACES_HELIOS_URL = heliosURL;\n    }\n  }, [spaceBackendURL, heliosURL]);\n\n  const mutation = useMutation(tokenPOST, {\n    onSuccess: async (data) => {\n      await joinSpace(\n        data.spaceJWT,\n        endsAt,\n        participantNameRef.current || user.participantName\n      );\n    },\n  });\n\n  const authenticate = useCallback(\n    (spaceId: string, participantId: string) => {\n      mutation.mutate({\n        spaceId,\n        participantId,\n      });\n    },\n    [mutation]\n  );\n\n  const handleJoin = useCallback(() => {\n    if (typeof id === \"string\" && canJoinSpace) {\n      authenticate(id, `${participantNameRef.current}|${user.id}`);\n    }\n  }, [id, canJoinSpace, authenticate, user.id]);\n\n  useEffect(() => {\n    if (!isRouterReady) return;\n    if (!id || Array.isArray(id)) {\n      console.warn(\"No space selected\");\n      return;\n    }\n    router.events.on(\"routeChangeStart\", leaveSpace);\n    router.events.on(\"routeChangeComplete\", handleJoin);\n    return () => {\n      router.events.off(\"routeChangeStart\", leaveSpace);\n      router.events.off(\"routeChangeComplete\", handleJoin);\n    };\n  }, [\n    id,\n    user,\n    router,\n    handleJoin,\n    leaveSpace,\n    isRouterReady,\n    user.participantName,\n  ]);\n\n  return (\n    <>\n      <Head>\n        <title>{title}</title>\n        <meta name=\"description\" content=\"This is your space room\" />\n        <link rel=\"icon\" href=\"/favicon.png\" />\n      </Head>\n\n      <Flex height=\"100vh\" overflow=\"hidden\" direction=\"column\">\n        <BackgroundImageWrap>\n          <Image\n            alt=\"Starfield\"\n            src={starfield}\n            placeholder=\"blur\"\n            quality={100}\n            fill\n            sizes=\"100vw\"\n            style={{\n              objectFit: \"cover\",\n            }}\n          />\n        </BackgroundImageWrap>\n\n        {/* required to handle auto play https://developer.chrome.com/blog/autoplay/ */}\n        {user.interactionRequired ? (\n          <UserInteractionPrompt\n            onInteraction={handleJoin}\n            participantNameRef={participantNameRef}\n          />\n        ) : (\n          <Stage />\n        )}\n      </Flex>\n    </>\n  );\n};\n\nconst { MUX_SPACES_BACKEND_URL = \"\", MUX_SPACES_HELIOS_URL = \"\" } = process.env;\nexport const getServerSideProps: GetServerSideProps = async (context) => {\n  const { id } = context.query;\n  let passthrough;\n  let createdAt;\n\n  try {\n    if (typeof id === \"string\") {\n      ({ passthrough, created_at: createdAt } = await fetchSpace(id));\n    }\n  } catch (error) {}\n\n  let props: Record<string, any> = {\n    heliosURL: MUX_SPACES_HELIOS_URL,\n    spaceBackendURL: MUX_SPACES_BACKEND_URL,\n    title: passthrough ? `${passthrough} | Mux Meet` : \"Mux Meet Space\",\n  };\n\n  if (\n    process.env.SPACE_DURATION_SECONDS &&\n    passthrough === TEMPORARY_SPACE_PASSTHROUGH &&\n    createdAt\n  ) {\n    props.endsAt = moment(createdAt * 1000)\n      .add(process.env.SPACE_DURATION_SECONDS, \"seconds\")\n      .valueOf();\n  }\n\n  return {\n    props,\n  };\n};\n\nexport default SpacePage;\n"
  },
  {
    "path": "server-lib/services.ts",
    "content": "import axios from \"axios\";\n\nconst {\n  MUX_TOKEN_ID,\n  MUX_TOKEN_SECRET,\n  MUX_API_HOST = \"api.mux.com\",\n} = process.env;\n\nconst muxOptions = {\n  auth: { username: MUX_TOKEN_ID ?? \"\", password: MUX_TOKEN_SECRET ?? \"\" },\n  baseURL: `https://${MUX_API_HOST}`,\n};\n\nconst muxClient = axios.create(muxOptions);\n\nexport { muxClient };\n"
  },
  {
    "path": "shared/defaults.tsx",
    "content": "export const defaultAudioConstraints: MediaTrackConstraints = {\n  autoGainControl: true,\n  echoCancellation: true,\n  noiseSuppression: true,\n  channelCount: 1,\n};\n"
  },
  {
    "path": "shared/toastConfigs.tsx",
    "content": "import { Box } from \"@chakra-ui/react\";\nimport { UseToastOptions } from \"@chakra-ui/react\";\n\nexport enum ToastIds {\n  COPY_LINK_TOAST_ID,\n  SHARING_SCREEN_TOAST_ID,\n  BROADCASTING_SCREEN_TOAST_ID,\n  VIEWING_SHARED_SCREEN_TOAST_ID,\n}\n\nexport const copyLinkToastConfig: UseToastOptions = {\n  id: ToastIds.COPY_LINK_TOAST_ID,\n  position: \"top\",\n  render: () => {\n    return (\n      <Box\n        color=\"#0a0a0b\"\n        background=\"#cff1fc\"\n        fontSize=\"14px\"\n        padding=\"15px 50px\"\n        textAlign=\"center\"\n      >\n        Share link copied to your clipboard.\n      </Box>\n    );\n  },\n};\n\nexport const sharingScreenToastConfig: UseToastOptions = {\n  id: ToastIds.SHARING_SCREEN_TOAST_ID,\n  position: \"top\",\n  duration: null,\n};\n\nexport const viewingSharedScreenToastConfig: UseToastOptions = {\n  id: ToastIds.VIEWING_SHARED_SCREEN_TOAST_ID,\n  position: \"top\",\n  duration: null,\n};\n\nexport const broadcastingToastConfig: UseToastOptions = {\n  id: ToastIds.BROADCASTING_SCREEN_TOAST_ID,\n  position: \"top-left\",\n  duration: null,\n};\n\nexport const participantEventToastConfig: UseToastOptions = {\n  // Don't set id, as we expect to have multiple on screen at the same time\n  isClosable: true,\n  position: \"top-right\",\n  duration: 3000,\n};\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"target\": \"es5\",\n    \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n    \"allowJs\": true,\n    \"skipLibCheck\": true,\n    \"strict\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"noEmit\": true,\n    \"esModuleInterop\": true,\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"node\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"jsx\": \"preserve\",\n    \"incremental\": true\n  },\n  \"include\": [\"next-env.d.ts\", \"**/*.ts\", \"**/*.tsx\"],\n  \"exclude\": [\"node_modules\"]\n}\n"
  }
]