diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..2a12b3ef --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,16 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: ['@remix-run/eslint-config', '@remix-run/eslint-config/node'], + rules: { + '@typescript-eslint/no-extra-semi': ['off'], + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + // vars: "all", + varsIgnorePattern: '^_', + // args: "after-used", + argsIgnorePattern: '^_', + }, + ], + }, +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..d7996c5c --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules +.DS_STORE +.wrangler + +/.cache +/build +/dist +/public/build +/.mf +.env +.dev.vars diff --git a/.hintrc b/.hintrc new file mode 100644 index 00000000..be96c4f9 --- /dev/null +++ b/.hintrc @@ -0,0 +1,15 @@ +{ + "extends": [ + "development" + ], + "hints": { + "no-inline-styles": "off", + "axe/name-role-value": [ + "default", + { + "button-name": "off" + } + ], + "apple-touch-icons": "off" + } +} \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000..10352a41 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,10 @@ +#!/usr/bin/env sh + +# This loads nvm.sh and sets the correct PATH before running hook +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + +. "$(dirname -- "$0")/_/husky.sh" + +npx git-format-staged -f 'prettier --ignore-unknown --stdin --stdin-filepath "{}"' . +npm run typecheck diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..1efaa627 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "useTabs": true, + "semi": false, + "singleQuote": true, + "plugins": ["prettier-plugin-organize-imports"], + "trailingComma": "es5" +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..c7a60243 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "dbaeumer.vscode-eslint", // https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint + "esbenp.prettier-vscode" // https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode + ], + "unwantedRecommendations": [] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..97d7d01f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "lit-plugin.disable": true, + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode" +} diff --git a/README.md b/README.md new file mode 100644 index 00000000..09cc1abf --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# Welcome to Orange Meets + +## Variables + +Go to the [Cloudflare Calls dashboard](https://dash.cloudflare.com/?to=/:account/calls) and create an application. + +Put these variables into `.dev.vars` + +``` +CALLS_APP_ID= +CALLS_APP_SECRET= +``` + +## Development + +```sh +npm run dev +``` + +Open up [http://127.0.0.1:8787](http://127.0.0.1:8787) and you should be ready to go! + +## Deployment + +First you will need to create the feedback queue: + +```sh +wrangler queues create orange-meets-feedback-queue +``` + +Then you can run + +```sh +npm run deploy +``` + +You will also need to set the token as a secret by running: + +```sh +wrangler secret put CALLS_APP_SECRET +``` diff --git a/app/api/roomsApi.server.ts b/app/api/roomsApi.server.ts new file mode 100644 index 00000000..0a333d01 --- /dev/null +++ b/app/api/roomsApi.server.ts @@ -0,0 +1,89 @@ +import type { AppLoadContext } from '@remix-run/cloudflare' + +export async function handleApiRequest( + path: string[], + request: Request, + env: AppLoadContext +) { + // We've received at API request. Route the request based on the path. + + switch (path[0]) { + case 'room': { + // Request for `/api/room/...`. + + if (!path[1]) { + // The request is for just "/api/room", with no ID. + if (request.method == 'POST') { + // POST to /api/room creates a private room. + // + // Incidentally, this code doesn't actually store anything. It just generates a valid + // unique ID for this namespace. Each durable object namespace has its own ID space, but + // IDs from one namespace are not valid for any other. + // + // The IDs returned by `newUniqueId()` are unguessable, so are a valid way to implement + // "anyone with the link can access" sharing. Additionally, IDs generated this way have + // a performance benefit over IDs generated from names: When a unique ID is generated, + // the system knows it is unique without having to communicate with the rest of the + // world -- i.e., there is no way that someone in the UK and someone in New Zealand + // could coincidentally create the same ID at the same time, because unique IDs are, + // well, unique! + let id = env.rooms.newUniqueId() + return new Response(id.toString(), { + headers: { 'Access-Control-Allow-Origin': '*' }, + }) + } else { + // If we wanted to support returning a list of public rooms, this might be a place to do + // it. The list of room names might be a good thing to store in KV, though a singleton + // Durable Object is also a possibility as long as the Cache API is used to cache reads. + // (A caching layer would be needed because a single Durable Object is single-threaded, + // so the amount of traffic it can handle is limited. Also, caching would improve latency + // for users who don't happen to be located close to the singleton.) + // + // For this demo, though, we're not implementing a public room list, mainly because + // inevitably some trolls would probably register a bunch of offensive room names. Sigh. + return new Response('Method not allowed', { status: 405 }) + } + } + + // OK, the request is for `/api/room//...`. It's time to route to the Durable Object + // for the specific room. + let name = path[1] + + // Each Durable Object has a 256-bit unique ID. IDs can be derived from string names, or + // chosen randomly by the system. + let id + if (name.match(/^[0-9a-f]{64}$/)) { + // The name is 64 hex digits, so let's assume it actually just encodes an ID. We use this + // for private rooms. `idFromString()` simply parses the text as a hex encoding of the raw + // ID (and verifies that this is a valid ID for this namespace). + id = env.rooms.idFromString(name) + } else if (name.length <= 32) { + // Treat as a string room name (limited to 32 characters). `idFromName()` consistently + // derives an ID from a string. + id = env.rooms.idFromName(name) + } else { + return new Response('Name too long', { status: 404 }) + } + + // Get the Durable Object stub for this room! The stub is a client object that can be used + // to send messages to the remote Durable Object instance. The stub is returned immediately; + // there is no need to await it. This is important because you would not want to wait for + // a network round trip before you could start sending requests. Since Durable Objects are + // created on-demand when the ID is first used, there's nothing to wait for anyway; we know + // an object will be available somewhere to receive our requests. + let roomObject = env.rooms.get(id) + + // Compute a new URL with `/api/room/` removed. We'll forward the rest of the path + // to the Durable Object. + let newUrl = new URL(request.url) + newUrl.pathname = '/' + path.slice(2).join('/') + + // Send the request to the object. The `fetch()` method of a Durable Object stub has the + // same signature as the global `fetch()` function, but the request is always sent to the + // object, regardless of the request's URL. + return roomObject.fetch(newUrl.toString(), request) + } + default: + return new Response('Not found', { status: 404 }) + } +} diff --git a/app/components/AlertDialog.tsx b/app/components/AlertDialog.tsx new file mode 100644 index 00000000..18aac533 --- /dev/null +++ b/app/components/AlertDialog.tsx @@ -0,0 +1,89 @@ +import * as AlertDialog from '@radix-ui/react-alert-dialog' +import type { FC, ReactNode } from 'react' +import { forwardRef } from 'react' +import { cn } from '~/utils/style' + +export const Overlay = forwardRef< + HTMLDivElement, + AlertDialog.AlertDialogOverlayProps +>(({ className, ...rest }, ref) => ( + +)) + +Overlay.displayName = 'Overlay' + +export const Content = forwardRef< + HTMLDivElement, + AlertDialog.AlertDialogContentProps +>(({ className, children, ...rest }, ref) => ( + + {children} + +)) + +Content.displayName = 'Content' + +const Title = forwardRef( + ({ className, ...rest }, ref) => ( + + ) +) + +Title.displayName = 'Title' + +const Description = forwardRef< + HTMLParagraphElement, + AlertDialog.AlertDialogDescriptionProps +>(({ className, ...rest }, ref) => ( + +)) + +Description.displayName = 'Description' + +const Actions: FC<{ children: ReactNode; className?: string }> = ({ + children, + className, +}) => { + return ( +
{children}
+ ) +} + +export default { ...AlertDialog, Overlay, Content, Title, Description, Actions } diff --git a/app/components/AudioGlow.tsx b/app/components/AudioGlow.tsx new file mode 100644 index 00000000..03079fa5 --- /dev/null +++ b/app/components/AudioGlow.tsx @@ -0,0 +1,32 @@ +import type { FC, ReactNode } from 'react' +import useAudioLevel from '~/hooks/useAudioLevel' +import { cn } from '~/utils/style' + +interface AudioGlowProps { + audioTrack?: MediaStreamTrack + children?: ReactNode + className?: string + type: 'text' | 'box' +} + +export const AudioGlow: FC = ({ + audioTrack, + children, + className, + type, +}) => { + const audioLevel = useAudioLevel(audioTrack) + return ( + + {children} + + ) +} diff --git a/app/components/AudioIndicator.tsx b/app/components/AudioIndicator.tsx new file mode 100644 index 00000000..2ca6191e --- /dev/null +++ b/app/components/AudioIndicator.tsx @@ -0,0 +1,35 @@ +import type { FC } from 'react' +import useAudioLevel from '~/hooks/useAudioLevel' + +interface AudioIndicatorProps { + audioTrack: MediaStreamTrack + className?: string +} + +export const AudioIndicator: FC = ({ audioTrack }) => { + const audioLevel = useAudioLevel(audioTrack) + const minSize = 0.6 + const scaleModifier = 0.8 + return ( +
+
+
+
+ ) +} diff --git a/app/components/AudioInputSelector.tsx b/app/components/AudioInputSelector.tsx new file mode 100644 index 00000000..7010381c --- /dev/null +++ b/app/components/AudioInputSelector.tsx @@ -0,0 +1,43 @@ +import type { FC } from 'react' +import { useAudioInputDeviceId } from '~/hooks/globalPersistedState' +import useMediaDevices from '~/hooks/useMediaDevices' +import { useRoomContext } from '~/hooks/useRoomContext' +import { errorMessageMap } from '~/hooks/useUserMedia' +import { Option, Select } from './Select' + +export const AudioInputSelector: FC<{ id?: string }> = ({ id }) => { + const audioInputDevices = useMediaDevices((d) => d.kind === 'audioinput') + const [audioDeviceId, setAudioDeviceId] = useAudioInputDeviceId() + + const { + userMedia: { audioUnavailableReason }, + } = useRoomContext() + + if (audioUnavailableReason) { + return ( +
+ +
+ ) + } + + if (!audioDeviceId) return null + + return ( +
+ +
+ ) +} diff --git a/app/components/AudioStream.tsx b/app/components/AudioStream.tsx new file mode 100644 index 00000000..95983190 --- /dev/null +++ b/app/components/AudioStream.tsx @@ -0,0 +1,20 @@ +import type { FC } from 'react' +import { useEffect, useRef } from 'react' + +interface AudioStreamProps { + mediaStreamTrack: MediaStreamTrack +} + +export const AudioStream: FC = ({ mediaStreamTrack }) => { + const ref = useRef(null) + + useEffect(() => { + const audio = ref.current + if (!audio) return + const mediaStream = new MediaStream() + mediaStream.addTrack(mediaStreamTrack) + audio.srcObject = mediaStream + }, [mediaStreamTrack]) + + return