Compare commits

..

15 Commits

Author SHA1 Message Date
Emmanuel Pelletier acb024b64a (join) add audio output selection
new button to select audio output and pass it to the conference.

this is in its own commit because we might not want to add this directly
in the code: we can choose output in the join screen but not in the
conference screen for now. this might be a bit misleading and better to
not have it entirely for now?
2024-08-06 13:16:44 +02:00
Emmanuel Pelletier 31dcbddcf3 (frontend) new join screen with homemade buttons
- do not touch current Join screen as we might need it still for now
- add a new HomemadeJoin, that is meant to be renamed simply "Join" when
ready. It contains basically the same stuff as the livekit join but with
homemade react aria buttons and a different layout. This will allow us
to precisely customize how we want this screen later
- store user device selections and name in a valtio store, synced with
localstorage. This should end up in the same UX as before with livekit,
but now we can store more things (like audio output) in the same place
2024-08-06 13:16:44 +02:00
Emmanuel Pelletier d6ca3ed202 (frontend) new DialogContainer component
this will help making sure dialog components are not called before being
actually opened. Wrap inside a <DialogContainer> some component that
uses hooks + renders a <Dialog>: this component will be rendered only
when the dialog is opened, and its hooks will be called only on that
moment too
2024-08-06 12:37:46 +02:00
Emmanuel Pelletier 249011156e 🐛(pandacss) use recipes instead of bare style objects to fix gen issues
it seems panda-css didn't like my way of sharing css code. When sharing
bare objects matching panda-css interface, panda-css didn't understand
those were styles and didn't parse them. Now using actual recipes via
the `cva` helper, panda understands correctly those styles need to be
updated on change.

ps: cleaned a few panda imports that didn't use our "@" alias
2024-08-06 12:37:46 +02:00
Emmanuel Pelletier dbbb4b356e 💄(buttons) new button styles ("invisible" toggled + danger)
this will be used for toggled on/off mic and camera buttons. We want
toggled-on buttons that don't look pressed as it looks a bit weird
(we'll change icons on pressed/unpressed state). And we want red buttons
for when the buttons are not pressed, so adding the "danger" variant
too.

the whole colorpalette stuff needs a bit of work to be clean but it'll
do for now
2024-08-06 12:37:46 +02:00
Emmanuel Pelletier 4714067a51 🌐(frontend) make react aria use current language
react aria has default strings for a few UI elements (like "select an
item" on an empty select), make sure it uses currently defined language.
2024-08-06 12:37:46 +02:00
Emmanuel Pelletier 6cc13fa39a (frontend) new ToggleButton component
new ToggleButton component that wraps react aria ToggleButton. This will
be used to toggle cam/mic on/off.
2024-08-06 12:37:46 +02:00
Emmanuel Pelletier 1c93f04b1f (frontend) new Menu component
ditch the "PopoverList" component that was basically a poor Menu. Now
dropdown buttons can be made with react aria Menu+MenuItems components
via the Menu+MenuList components.

The difference now is dropdown menus behave better with keyboard (they
use up/down arrows instead of tabs), like selects.

Popovers are there if we need to show any content in a big tooltip, not
for actionable list items.
2024-08-06 12:37:44 +02:00
Emmanuel Pelletier 71811155b3 🔥(frontend) remove languageselector code
we don't have any language selector dropdown button anymore, we select
language in the settings
2024-08-06 12:34:10 +02:00
lebaudantoine 5d35161ae3 👷(frontend) add linting and formatting checks for frontend
Added CI job to run linting and formatting checks in the frontend
codebase. Please note, we should cache frontend dependencies,
to avoid re-installing them. Future improvement!
2024-08-06 12:25:22 +02:00
lebaudantoine 0b6f58bf9c 🚨(frontend) run Prettier on the codebase
Ran Prettier on the entire codebase to fix formatting issues. My IDE was
previously misconfigured, causing most of these errors. The IDE configuration
has been corrected.
2024-08-06 12:25:22 +02:00
lebaudantoine 79519fef26 🔧(frontend) configure Prettier and ESLint
Set up Prettier with minimal configuration. Installed eslint-config-prettier to
prevent conflicts between ESLint and Prettier.
2024-08-06 12:25:22 +02:00
lebaudantoine d7b1fbaf28 ♻️(mail) refactor mail to use npm instead of yarn
Following @manuhabitela's recommendation, we've decided to switch
from Yarn to npm for managing our Node.js dependencies. This update
aligns all remaining parts of the codebase that were still using
Yarn to now utilize npm.
2024-08-06 11:16:54 +02:00
renovate[bot] 26bc67d1b4 ⬆️(dependencies) update boto3 to v1.34.154 2024-08-06 10:42:41 +02:00
lebaudantoine b783a8bac6 📝(doc) add an example for the frontend image
I added an example in the documentation to ensure we remember to upgrade the
frontend image during the release process. This will help prevent any issues
related to outdated images when deploying new versions.

(I did this mistake while releasing)
2024-08-05 23:14:06 +02:00
53 changed files with 2789 additions and 1691 deletions
+15
View File
@@ -160,6 +160,21 @@ jobs:
- name: Run tests
run: ~/.local/bin/pytest -n 2
lint-front:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install dependencies
run: cd src/frontend/ && npm ci
- name: Check linting
run: cd src/frontend/ && npm run lint
- name: Check format
run: cd src/frontend/ && npm run check
i18n-crowdin:
runs-on: ubuntu-latest
steps:
+5 -5
View File
@@ -48,7 +48,7 @@ WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
MAIL_YARN = $(COMPOSE_RUN) -w /app/src/mail node yarn # FIXME : use npm
MAIL_NPM = $(COMPOSE_RUN) -w /app/src/mail node npm
# -- Frontend
PATH_FRONT = ./src/frontend
@@ -259,19 +259,19 @@ i18n-generate-and-upload: \
# -- Mail generator
mails-build: ## Convert mjml files to html and text
@$(MAIL_YARN) build
@$(MAIL_NPM) run build
.PHONY: mails-build
mails-build-html-to-plain-text: ## Convert html files to text
@$(MAIL_YARN) build-html-to-plain-text
@$(MAIL_NPM) run build-html-to-plain-text
.PHONY: mails-build-html-to-plain-text
mails-build-mjml-to-html: ## Convert mjml files to html and text
@$(MAIL_YARN) build-mjml-to-html
@$(MAIL_NPM) run build-mjml-to-html
.PHONY: mails-build-mjml-to-html
mails-install: ## install the mail generator
@$(MAIL_YARN) install
@$(MAIL_NPM) install
.PHONY: mails-install
+19 -11
View File
@@ -3,20 +3,28 @@
Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standard procedure described below:
1. Create a new branch named: `release/4.18.1`.
2. Bump the release number for backend project, frontend projects, and Helm files:
2. Bump the release number for backend project, frontend projects, and Helm files:
- for backend, update the version number by hand in `pyproject.toml`,
- for each frontend projects (`src/frontend`, `src/mail`), run `npm version 4.18.1` in their directory. This will update both their `package.json` and `package-lock.json` for you,
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
- for backend, update the version number by hand in `pyproject.toml`,
- for each frontend projects (`src/frontend`, `src/mail`), run `npm version 4.18.1` in their directory. This will update both their `package.json` and `package-lock.json` for you,
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
```yaml
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
```
```yaml
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
...
frontend:
image:
repository: lasuite/meet-frontend
pullPolicy: Always
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
```
The new images don't exist _yet_: they will be created automatically later in the process.
The new images don't exist _yet_: they will be created automatically later in the process.
3. ~~Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations~~ _we don't keep a changelog yet for now as the project is still in its infancy. Soon™!_
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version:
+1 -1
View File
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.34.153",
"boto3==1.34.154",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
+1
View File
@@ -7,6 +7,7 @@ module.exports = {
'plugin:react-hooks/recommended',
'plugin:@tanstack/eslint-plugin-query/recommended',
'plugin:jsx-a11y/recommended',
'prettier'
],
ignorePatterns: ['dist', '.eslintrc.cjs', 'styled-system'],
parser: '@typescript-eslint/parser',
+33 -3
View File
@@ -37,10 +37,12 @@
"@typescript-eslint/parser": "7.13.1",
"@vitejs/plugin-react": "4.3.1",
"eslint": "8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-jsx-a11y": "6.9.0",
"eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-react-refresh": "0.4.7",
"postcss": "8.4.39",
"prettier": "3.3.3",
"typescript": "5.5.2",
"vite": "5.3.1",
"vite-tsconfig-paths": "4.3.2"
@@ -452,6 +454,7 @@
},
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
"version": "1.3.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"engines": {
@@ -1588,6 +1591,21 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/@pandacss/node/node_modules/prettier": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
"integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
"dev": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/@pandacss/parser": {
"version": "0.41.0",
"resolved": "https://registry.npmjs.org/@pandacss/parser/-/parser-0.41.0.tgz",
@@ -5700,6 +5718,18 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-config-prettier": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz",
"integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
"dev": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
"peerDependencies": {
"eslint": ">=7.0.0"
}
},
"node_modules/eslint-plugin-jsx-a11y": {
"version": "6.9.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.9.0.tgz",
@@ -8919,9 +8949,9 @@
}
},
"node_modules/prettier": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
"integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
"dev": true,
"bin": {
"prettier": "bin/prettier.cjs"
+5 -1
View File
@@ -8,7 +8,9 @@
"build": "panda codegen && tsc -b && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"i18n:extract": "npx i18next -c i18next-parser.config.json"
"i18n:extract": "npx i18next -c i18next-parser.config.json",
"format": "prettier --write ./src",
"check": "prettier --check ./src"
},
"dependencies": {
"@livekit/components-react": "2.3.3",
@@ -40,10 +42,12 @@
"@typescript-eslint/parser": "7.13.1",
"@vitejs/plugin-react": "4.3.1",
"eslint": "8.57.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-jsx-a11y": "6.9.0",
"eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-react-refresh": "0.4.7",
"postcss": "8.4.39",
"prettier": "3.3.3",
"typescript": "5.5.2",
"vite": "5.3.1",
"vite-tsconfig-paths": "4.3.2"
+1
View File
@@ -199,6 +199,7 @@ const config: Config = {
text: { value: '{colors.white}' },
subtle: { value: '{colors.red.100}' },
'subtle-text': { value: '{colors.red.700}' },
...pandaPreset.theme.tokens.colors.red,
},
success: {
DEFAULT: { value: '{colors.green.700}' },
+14 -12
View File
@@ -6,13 +6,13 @@ import { QueryClientProvider } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useLang } from 'hoofd'
import { Switch, Route } from 'wouter'
import { I18nProvider } from 'react-aria-components'
import { Layout } from './layout/Layout'
import { NotFoundScreen } from './components/NotFoundScreen'
import { routes } from './routes'
import './i18n/init'
import { silenceLiveKitLogs } from "@/utils/livekit.ts";
import { queryClient } from "@/api/queryClient";
import { silenceLiveKitLogs } from '@/utils/livekit.ts'
import { queryClient } from '@/api/queryClient'
function App() {
const { i18n } = useTranslation()
@@ -24,15 +24,17 @@ function App() {
return (
<QueryClientProvider client={queryClient}>
<Suspense fallback={null}>
<Layout>
<Switch>
{Object.entries(routes).map(([, route], i) => (
<Route key={i} path={route.path} component={route.Component} />
))}
<Route component={NotFoundScreen} />
</Switch>
</Layout>
<ReactQueryDevtools initialIsOpen={false} />
<I18nProvider locale={i18n.language}>
<Layout>
<Switch>
{Object.entries(routes).map(([, route], i) => (
<Route key={i} path={route.path} component={route.Component} />
))}
<Route component={NotFoundScreen} />
</Switch>
</Layout>
<ReactQueryDevtools initialIsOpen={false} />
</I18nProvider>
</Suspense>
</QueryClientProvider>
)
+1 -1
View File
@@ -1,3 +1,3 @@
import { QueryClient } from "@tanstack/react-query";
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient()
+4 -1
View File
@@ -4,7 +4,10 @@ import { useTranslation } from 'react-i18next'
import { Center } from '@/styled-system/jsx'
import { Text } from '@/primitives'
export const ErrorScreen = ({ title, body }: {
export const ErrorScreen = ({
title,
body,
}: {
title?: string
body?: string
}) => {
@@ -4,5 +4,7 @@ export const authUrl = ({
silent = false,
returnTo = window.location.href,
} = {}) => {
return apiUrl(`/authenticate?silent=${encodeURIComponent(silent)}&returnTo=${encodeURIComponent(returnTo)}`)
return apiUrl(
`/authenticate?silent=${encodeURIComponent(silent)}&returnTo=${encodeURIComponent(returnTo)}`
)
}
@@ -1,20 +1,20 @@
import { authUrl } from "@/features/auth";
import { authUrl } from '@/features/auth'
const SILENT_LOGIN_RETRY_KEY = 'silent-login-retry'
const isRetryAllowed = () => {
const lastRetryDate = localStorage.getItem(SILENT_LOGIN_RETRY_KEY);
const lastRetryDate = localStorage.getItem(SILENT_LOGIN_RETRY_KEY)
if (!lastRetryDate) {
return true;
return true
}
const now = new Date();
const now = new Date()
return now.getTime() > Number(lastRetryDate)
}
const setNextRetryTime = (retryIntervalInSeconds: number) => {
const now = new Date()
const nextRetryTime = now.getTime() + (retryIntervalInSeconds * 1000);
localStorage.setItem(SILENT_LOGIN_RETRY_KEY, String(nextRetryTime));
const nextRetryTime = now.getTime() + retryIntervalInSeconds * 1000
localStorage.setItem(SILENT_LOGIN_RETRY_KEY, String(nextRetryTime))
}
const initiateSilentLogin = () => {
@@ -0,0 +1,2 @@
export { useAudioOutputs } from './utils/useAudioOutputs'
export { usePersistedMediaDeviceSelect } from './utils/usePersistedMediaDeviceSelect'
@@ -0,0 +1,36 @@
import { useState, useEffect } from 'react'
const getOutputDevices = () => {
return navigator.mediaDevices
.getUserMedia({ audio: true, video: false })
.then(() => navigator.mediaDevices.enumerateDevices())
.then((devices) => devices.filter(({ kind }) => kind === 'audiooutput'))
}
/**
* custom hook to fetch audio outputs
*
* this is used instead of livekit's useMediaDevices because the livekit integrated one seems to request
* outputs in a weird order, resulting in empty results in firefox if we didn't ask for input before
*/
export const useAudioOutputs = () => {
const [audioOutputs, setAudioOutputs] = useState<MediaDeviceInfo[]>([])
useEffect(() => {
const retrieveOutputDevices = () => {
getOutputDevices()
.then(setAudioOutputs)
.catch((error) => {
console.error('Audio outputs retrieval error :', error)
})
}
retrieveOutputDevices()
const onDeviceChange = () => {
retrieveOutputDevices()
}
navigator?.mediaDevices?.addEventListener('devicechange', onDeviceChange)
return () => {
navigator.mediaDevices.removeEventListener('devicechange', onDeviceChange)
}
}, [])
return audioOutputs
}
@@ -0,0 +1,27 @@
import { useMediaDeviceSelect } from '@livekit/components-react'
import { settingsStore } from '@/features/settings'
/**
* wrap livekit's useMediaDeviceSelect to automatically save in our devices state user selection
*
* note: audiooutput devices are not handled here as we dont use useMediaDeviceSelect for them
*/
export const usePersistedMediaDeviceSelect = (
...args: Parameters<typeof useMediaDeviceSelect>
): ReturnType<typeof useMediaDeviceSelect> => {
const results = useMediaDeviceSelect(...args)
const originalSetter = results.setActiveMediaDevice
results.setActiveMediaDevice = (
...activeMediaDeviceArgs: Parameters<typeof results.setActiveMediaDevice>
) => {
const id = activeMediaDeviceArgs[0]
if (args[0].kind === 'audioinput') {
settingsStore.devices.micDeviceId = id
}
if (args[0].kind === 'videoinput') {
settingsStore.devices.cameraDeviceId = id
}
return originalSetter(...activeMediaDeviceArgs)
}
return results
}
@@ -19,8 +19,8 @@ export const Home = () => {
navigateTo('room', data.slug, {
state: { create: true, initialRoomData: data },
})
}
});
},
})
return (
<UserAware>
@@ -43,9 +43,9 @@ export const Home = () => {
onPress={
isLoggedIn
? async () => {
const slug = generateRoomId()
await createRoom({slug})
}
const slug = generateRoomId()
await createRoom({ slug })
}
: undefined
}
href={isLoggedIn ? undefined : authUrl()}
@@ -9,6 +9,6 @@ export type ApiRoom = {
token: string
}
configuration?: {
[key: string]: string | number | boolean;
[key: string]: string | number | boolean
}
}
@@ -1,25 +1,26 @@
import { useMutation, UseMutationOptions } from "@tanstack/react-query";
import { fetchApi } from '@/api/fetchApi';
import { ApiError } from "@/api/ApiError";
import { ApiRoom } from "./ApiRoom";
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
import { fetchApi } from '@/api/fetchApi'
import { ApiError } from '@/api/ApiError'
import { ApiRoom } from './ApiRoom'
export interface CreateRoomParams {
slug: string;
slug: string
}
const createRoom = ({slug}: CreateRoomParams): Promise<ApiRoom> => {
const createRoom = ({ slug }: CreateRoomParams): Promise<ApiRoom> => {
return fetchApi(`rooms/`, {
method: 'POST',
body: JSON.stringify({
name: slug,
}),
})
};
}
export function useCreateRoom(options?: UseMutationOptions<ApiRoom, ApiError, CreateRoomParams>) {
export function useCreateRoom(
options?: UseMutationOptions<ApiRoom, ApiError, CreateRoomParams>
) {
return useMutation<ApiRoom, ApiError, CreateRoomParams>({
mutationFn: createRoom,
onSuccess: options?.onSuccess,
});
})
}
@@ -5,7 +5,6 @@ import {
formatChatMessageLinks,
LiveKitRoom,
VideoConference,
type LocalUserChoices,
} from '@livekit/components-react'
import { Room, RoomOptions } from 'livekit-client'
import { keys } from '@/api/queryKeys'
@@ -18,6 +17,7 @@ import { fetchRoom } from '../api/fetchRoom'
import { ApiRoom } from '../api/ApiRoom'
import { useCreateRoom } from '../api/createRoom'
import { InviteDialog } from './InviteDialog'
import { type SettingsState } from '@/features/settings'
export const Conference = ({
roomId,
@@ -26,19 +26,30 @@ export const Conference = ({
mode = 'join',
}: {
roomId: string
userConfig: LocalUserChoices
userConfig: {
devices: SettingsState['devices']
username: SettingsState['username']
}
mode?: 'join' | 'create'
initialRoomData?: ApiRoom
}) => {
const fetchKey = [keys.room, roomId, userConfig.username]
const { mutateAsync: createRoom, status: createStatus, isError: isCreateError} = useCreateRoom({
const {
mutateAsync: createRoom,
status: createStatus,
isError: isCreateError,
} = useCreateRoom({
onSuccess: (data) => {
queryClient.setQueryData(fetchKey, data)
},
});
})
const { status: fetchStatus, isError: isFetchError, data } = useQuery({
const {
status: fetchStatus,
isError: isFetchError,
data,
} = useQuery({
queryKey: fetchKey,
enabled: !initialRoomData,
initialData: initialRoomData,
@@ -48,7 +59,7 @@ export const Conference = ({
username: userConfig.username,
}).catch((error) => {
if (error.statusCode == '404') {
createRoom({slug: roomId})
createRoom({ slug: roomId })
}
}),
retry: false,
@@ -57,14 +68,21 @@ export const Conference = ({
const roomOptions = useMemo((): RoomOptions => {
return {
videoCaptureDefaults: {
deviceId: userConfig.videoDeviceId ?? undefined,
deviceId: userConfig.devices.cameraDeviceId ?? undefined,
},
audioCaptureDefaults: {
deviceId: userConfig.audioDeviceId ?? undefined,
deviceId: userConfig.devices.micDeviceId ?? undefined,
},
audioOutput: {
deviceId: userConfig.devices.speakerDeviceId ?? undefined,
},
}
// do not rely on the userConfig object directly as its reference may change on every render
}, [userConfig.videoDeviceId, userConfig.audioDeviceId])
}, [
userConfig.devices.cameraDeviceId,
userConfig.devices.micDeviceId,
userConfig.devices.speakerDeviceId,
])
const room = useMemo(() => new Room(roomOptions), [roomOptions])
@@ -92,7 +110,12 @@ export const Conference = ({
const { t } = useTranslation('rooms')
if (isCreateError) {
// this error screen should be replaced by a proper waiting room for anonymous user.
return <ErrorScreen title={t('error.createRoom.heading')} body={t('error.createRoom.body')} />
return (
<ErrorScreen
title={t('error.createRoom.heading')}
body={t('error.createRoom.body')}
/>
)
}
return (
@@ -103,12 +126,10 @@ export const Conference = ({
serverUrl={data?.livekit?.url}
token={data?.livekit?.token}
connect={true}
audio={userConfig.audioEnabled}
video={userConfig.videoEnabled}
audio={userConfig.devices.enableMic}
video={userConfig.devices.enableCamera}
>
<VideoConference
chatMessageFormatter={formatChatMessageLinks}
/>
<VideoConference chatMessageFormatter={formatChatMessageLinks} />
{showInviteDialog && (
<InviteDialog
isOpen={showInviteDialog}
@@ -0,0 +1,358 @@
import { Screen } from '@/layout/Screen'
import {
Button,
Div,
Field,
Form,
H,
Menu,
MenuList,
ToggleButton,
VerticallyOffCenter,
} from '@/primitives'
import { Center, HStack, VStack } from '@/styled-system/jsx'
import {
RiArrowDropDownLine,
RiMicLine,
RiMicOffLine,
RiVideoOffLine,
RiVideoOnLine,
RiVolumeUpLine,
} from '@remixicon/react'
import {
useMaybeRoomContext,
usePreviewTracks,
} from '@livekit/components-react'
import { Track, LocalVideoTrack, LocalAudioTrack } from 'livekit-client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import {
usePersistedMediaDeviceSelect,
useAudioOutputs,
} from '@/features/devices'
import { settingsStore, type SettingsState } from '@/features/settings'
import { css } from '@/styled-system/css'
export const HomemadeJoin = ({
onSubmit,
}: {
onSubmit: (choices: {
devices: SettingsState['devices']
username: SettingsState['username']
}) => void
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const settingsSnap = useSnapshot(settingsStore)
const [initialUserChoices] = useState({ ...settingsSnap.devices })
const tracks = usePreviewTracks({
audio: settingsSnap.devices.enableMic
? { deviceId: initialUserChoices.micDeviceId }
: false,
video: settingsSnap.devices.enableCamera
? { deviceId: initialUserChoices.cameraDeviceId }
: false,
})
const videoEl = useRef(null)
const videoTrack = useMemo(
() =>
tracks?.filter(
(track) => track.kind === Track.Kind.Video
)[0] as LocalVideoTrack,
[tracks]
)
const audioTrack = useMemo(
() =>
tracks?.filter(
(track) => track.kind === Track.Kind.Audio
)[0] as LocalAudioTrack,
[tracks]
)
useEffect(() => {
if (videoEl.current && videoTrack) {
videoTrack.unmute()
videoTrack.attach(videoEl.current)
}
return () => {
videoTrack?.detach()
}
}, [videoTrack])
const room = useMaybeRoomContext()
const {
devices: micDevices,
activeDeviceId: activeMicDeviceId,
setActiveMediaDevice: setActiveMicDevice,
} = usePersistedMediaDeviceSelect({
kind: 'audioinput',
room,
track: audioTrack,
requestPermissions: true,
})
const {
devices: cameraDevices,
activeDeviceId: activeCameraDeviceId,
setActiveMediaDevice: setActiveCameraDevice,
} = usePersistedMediaDeviceSelect({
kind: 'videoinput',
room,
track: videoTrack,
requestPermissions: true,
})
const speakerDevices = useAudioOutputs()
useEffect(() => {
if (settingsStore.devices.micDeviceId) {
setActiveMicDevice(settingsStore.devices.micDeviceId)
}
if (settingsStore.devices.cameraDeviceId) {
setActiveCameraDevice(settingsStore.devices.cameraDeviceId)
}
}, [setActiveCameraDevice, setActiveMicDevice])
return (
<Screen>
<VerticallyOffCenter>
<Div
className={css({
margin: 'auto',
flexWrap: 'wrap',
width: 'fit-content',
maxWidth: 'full',
display: 'flex',
gap: 2,
paddingX: 1,
flexDirection: 'column',
alignItems: 'center',
lg: {
alignItems: 'stretch',
flexDirection: 'row',
},
})}
>
<VStack
className={css({
width: 'full',
maxWidth: '38rem',
margin: '0 auto',
alignItems: 'center',
flexShrink: '1',
})}
>
<Center
className={css({
width: '38rem',
maxWidth: 'full',
height: 'auto',
aspectRatio: '16 / 9',
background: 'gray.900',
color: 'white',
borderRadius: 16,
overflow: 'hidden',
})}
>
{videoTrack && settingsSnap.devices.enableCamera ? (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video
ref={videoEl}
width="608"
height="342"
className={css({
width: 'full',
height: 'auto',
})}
/>
) : (
settingsSnap.devices.enableCamera === false && (
<p>{t('cameraPlaceholder')}</p>
)
)}
</Center>
<HStack gap={1} justify="center" flexWrap={'wrap'}>
{/* audio output dropdown */}
<Menu>
<Button
tooltip={t('chooseSpeaker')}
aria-label={t('chooseSpeaker')}
>
<RiVolumeUpLine />
<RiArrowDropDownLine />
</Button>
<MenuList
items={speakerDevices.map((d) => ({
value: d.deviceId,
label: d.label,
}))}
selectedItem={settingsSnap.devices.speakerDeviceId}
onAction={(value) => {
settingsStore.devices.speakerDeviceId = value as string
}}
/>
</Menu>
{/* audio input toggle + dropdown */}
<HStack gap={0}>
<ToggleButton
isSelected={settingsSnap.devices.enableMic}
variant={
settingsSnap.devices.enableMic ? undefined : 'danger'
}
toggledStyles={false}
onChange={(enabled) =>
(settingsStore.devices.enableMic = enabled)
}
aria-label={
settingsSnap.devices.enableMic
? `${t('micIsOn')} ${t('toggleOff')}`
: `${t('micIsOff')} ${t('toggleOn')}`
}
tooltip={
settingsSnap.devices.enableMic ? (
<>
{t('micIsOn')}
<br />
{t('toggleOff')}
</>
) : (
<>
{t('micIsOff')}
<br />
{t('toggleOn')}
</>
)
}
groupPosition="left"
>
{settingsSnap.devices.enableMic ? (
<RiMicLine />
) : (
<RiMicOffLine />
)}
</ToggleButton>
<Menu>
<Button
tooltip={t('chooseMic')}
aria-label={t('chooseMic')}
groupPosition="right"
square
>
<RiArrowDropDownLine />
</Button>
<MenuList
items={micDevices.map((d) => ({
value: d.deviceId,
label: d.label,
}))}
selectedItem={activeMicDeviceId}
onAction={(value) => {
setActiveMicDevice(value as string)
}}
/>
</Menu>
</HStack>
{/* video toggle + dropdown */}
<HStack gap={0}>
<ToggleButton
isSelected={settingsSnap.devices.enableCamera}
variant={
settingsSnap.devices.enableCamera ? undefined : 'danger'
}
toggledStyles={false}
onChange={(enabled) =>
(settingsStore.devices.enableCamera = enabled)
}
aria-label={
settingsSnap.devices.enableMic
? `${t('cameraIsOn')} ${t('toggleOff')}`
: `${t('cameraIsOff')} ${t('toggleOn')}`
}
tooltip={
settingsSnap.devices.enableMic ? (
<>
{t('cameraIsOn')}
<br />
{t('toggleOff')}
</>
) : (
<>
{t('cameraIsOff')}
<br />
{t('toggleOn')}
</>
)
}
groupPosition="left"
>
{settingsSnap.devices.enableCamera ? (
<RiVideoOnLine />
) : (
<RiVideoOffLine />
)}
</ToggleButton>
<Menu>
<Button
tooltip={t('chooseCamera')}
aria-label={t('chooseCamera')}
groupPosition="right"
square
>
<RiArrowDropDownLine />
</Button>
<MenuList
items={cameraDevices.map((d) => ({
value: d.deviceId,
label: d.label,
}))}
selectedItem={activeCameraDeviceId}
onAction={(value) => {
setActiveCameraDevice(value as string)
}}
/>
</Menu>
</HStack>
</HStack>
</VStack>
<Div width="24rem" maxWidth="full" flexShrink="1">
<VerticallyOffCenter>
<Center>
<H lvl={1}>{t('heading')}</H>
</Center>
<Form
onSubmit={(data) => {
settingsStore.username = (data.username as string).trim()
onSubmit({
devices: { ...settingsStore.devices },
username: settingsStore.username,
})
}}
submitLabel={t('joinMeeting')}
withSubmitButton={false}
>
<Field
type="text"
name="username"
defaultValue={settingsSnap.username}
label={t('usernameLabel')}
description={t('usernameHint')}
isRequired
/>
<Center>
<Button type="submit" variant="primary">
{t('joinMeeting')}
</Button>
</Center>
</Form>
</VerticallyOffCenter>
</Div>
</Div>
</VerticallyOffCenter>
</Screen>
)
}
@@ -8,7 +8,9 @@ export const FeedbackRoute = () => {
return (
<Screen layout="centered">
<CenteredContent title={t('feedback.heading')} withBackButton>
<Text as="p" variant="h3" centered>{t('feedback.body')}</Text>
<Text as="p" variant="h3" centered>
{t('feedback.body')}
</Text>
</CenteredContent>
</Screen>
)
@@ -1,18 +1,23 @@
import { useState } from 'react'
import {
usePersistentUserChoices,
type LocalUserChoices,
} from '@livekit/components-react'
import { useParams } from 'wouter'
import { ErrorScreen } from '@/components/ErrorScreen'
import { useUser, UserAware } from '@/features/auth'
import { Conference } from '../components/Conference'
import { Join } from '../components/Join'
import { HomemadeJoin } from '../components/HomemadeJoin'
import { settingsStore, type SettingsState } from '@/features/settings'
import { useSnapshot } from 'valtio'
export const Room = () => {
const { isLoggedIn } = useUser()
const { userChoices: existingUserChoices } = usePersistentUserChoices()
const [userConfig, setUserConfig] = useState<LocalUserChoices | null>(null)
const settingsSnap = useSnapshot(settingsStore)
const existingUserConfig = {
username: settingsSnap.username,
devices: settingsSnap.devices,
}
const [userConfig, setUserConfig] = useState<null | {
username: SettingsState['username']
devices: SettingsState['devices']
}>(null)
const { roomId } = useParams()
const initialRoomData = history.state?.initialRoomData
@@ -26,7 +31,7 @@ export const Room = () => {
if (!userConfig && !skipJoinScreen) {
return (
<UserAware>
<Join onSubmit={setUserConfig} />
<HomemadeJoin onSubmit={setUserConfig} />
</UserAware>
)
}
@@ -38,7 +43,7 @@ export const Room = () => {
roomId={roomId}
mode={mode}
userConfig={{
...existingUserChoices,
...existingUserConfig,
...userConfig,
}}
/>
@@ -1,2 +1,4 @@
export { SettingsButton } from './components/SettingsButton'
export { SettingsDialog } from './components/SettingsDialog'
export { settingsStore } from './stores/settings'
export { type SettingsState } from './stores/settings'
@@ -0,0 +1,50 @@
import { proxy, subscribe } from 'valtio'
import { devtools } from 'valtio/utils'
export type SettingsState = {
username: string | undefined
devices: {
/**
* MediaDeviceInfo id
*/
speakerDeviceId: string | undefined
/**
* MediaDeviceInfo id
*/
micDeviceId: string | undefined
/**
* MediaDeviceInfo id
*/
cameraDeviceId: string | undefined
enableMic: boolean
enableCamera: boolean
}
}
// sync the valtio store with localstorage data
// @TODO: make it easier to have "persisted" stores as we will definitely use it quite often
const localData = localStorage.getItem('meet.settings')
export const settingsStore = proxy<SettingsState>(
localData
? JSON.parse(localData)
: {
username: undefined,
devices: {
speakerDeviceId: undefined,
micDeviceId: undefined,
cameraDeviceId: undefined,
enableMic: false,
enableCamera: false,
},
}
)
subscribe(settingsStore, () => {
localStorage.setItem('meet.settings', JSON.stringify(settingsStore))
})
if (import.meta.env.DEV) {
devtools(settingsStore, { name: 'settings', enabled: true })
}
@@ -1,28 +0,0 @@
import { useTranslation } from 'react-i18next'
import { Button, Popover, PopoverList } from '@/primitives'
import { useLanguageLabels } from './useLanguageLabels'
export const LanguageSelector = () => {
const { t, i18n } = useTranslation()
const { languagesList, currentLanguage } = useLanguageLabels()
return (
<Popover aria-label={t('languageSelector.popoverLabel')}>
<Button
aria-label={t('languageSelector.buttonLabel', {
currentLanguage: currentLanguage.label,
})}
size="sm"
variant="primary"
outline
>
{i18n.language}
</Button>
<PopoverList
items={languagesList}
onAction={(lang) => {
i18n.changeLanguage(lang)
}}
/>
</Popover>
)
}
+1 -1
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import { Div, VerticallyOffCenter } from '@/primitives'
import type { SystemStyleObject } from '../styled-system/types'
import type { SystemStyleObject } from '@/styled-system/types'
export const Centered = ({
width = '38rem',
+7 -5
View File
@@ -2,11 +2,13 @@ import { Link } from 'wouter'
import { css } from '@/styled-system/css'
import { Stack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { A, Button, Popover, PopoverList, Text } from '@/primitives'
import { A, Text, Button } from '@/primitives'
import { SettingsButton } from '@/features/settings'
import { authUrl, logoutUrl, useUser } from '@/features/auth'
import { useMatchesRoute } from '@/navigation/useMatchesRoute'
import { Feedback } from '@/components/Feedback'
import { Menu } from '@/primitives/Menu'
import { MenuList } from '@/primitives/MenuList'
export const Header = () => {
const { t } = useTranslation()
@@ -64,7 +66,7 @@ export const Header = () => {
<A href={authUrl()}>{t('login')}</A>
)}
{!!user && (
<Popover aria-label={t('logout')}>
<Menu>
<Button
size="sm"
invisible
@@ -73,15 +75,15 @@ export const Header = () => {
>
{user.email}
</Button>
<PopoverList
items={[{ key: 'logout', value: 'logout', label: t('logout') }]}
<MenuList
items={[{ value: 'logout', label: t('logout') }]}
onAction={(value) => {
if (value === 'logout') {
window.location.href = logoutUrl()
}
}}
/>
</Popover>
</Menu>
)}
<SettingsButton />
</Stack>
-7
View File
@@ -7,13 +7,6 @@
"heading": ""
},
"feedbackAlert": "",
"forbidden": {
"heading": ""
},
"languageSelector": {
"buttonLabel": "",
"popoverLabel": ""
},
"loading": "",
"loggedInUserTooltip": "",
"login": "Anmelden",
+14 -1
View File
@@ -4,11 +4,24 @@
"heading": ""
},
"join": {
"cameraIsOff": "",
"cameraIsOn": "",
"cameraPlaceholder": "",
"camlabel": "",
"chooseCamera": "",
"chooseMic": "",
"chooseSpeaker": "",
"heading": "",
"joinLabel": "",
"joinMeeting": "",
"micIsOff": "",
"micIsOn": "",
"micLabel": "",
"userLabel": ""
"toggleOff": "",
"toggleOn": "",
"userLabel": "",
"usernameHint": "",
"usernameLabel": ""
},
"leaveRoomPrompt": "",
"shareDialog": {
-7
View File
@@ -7,13 +7,6 @@
"heading": "An error occured while loading the page"
},
"feedbackAlert": "Give us feedback",
"forbidden": {
"heading": "You don't have the permission to view this page"
},
"languageSelector": {
"buttonLabel": "Change language (currently {{currentLanguage}})",
"popoverLabel": "Choose language"
},
"loading": "Loading…",
"loggedInUserTooltip": "Logged in as…",
"login": "Login",
+18 -5
View File
@@ -4,11 +4,24 @@
"heading": "Help us improve Meet"
},
"join": {
"camlabel": "Camera",
"heading": "Join the meeting",
"joinLabel": "Join",
"micLabel": "Microphone",
"userLabel": "Your name"
"cameraIsOff": "Camera is off.",
"cameraIsOn": "Camera is on.",
"cameraPlaceholder": "Turn on the camera to see the preview",
"camlabel": "",
"chooseCamera": "Select camera",
"chooseMic": "Select microphone",
"chooseSpeaker": "Select speakers",
"heading": "Verify your settings",
"joinLabel": "",
"joinMeeting": "Join meeting",
"micIsOff": "Microphone is off.",
"micIsOn": "Microphone is on.",
"micLabel": "",
"toggleOff": "Click to turn off",
"toggleOn": "Click to turn on",
"userLabel": "",
"usernameHint": "Shown to other participants",
"usernameLabel": "Your name"
},
"leaveRoomPrompt": "This will make you leave the meeting.",
"shareDialog": {
-7
View File
@@ -7,13 +7,6 @@
"heading": "Une erreur est survenue lors du chargement de la page"
},
"feedbackAlert": "Donnez-nous votre avis",
"forbidden": {
"heading": "Accès interdit"
},
"languageSelector": {
"buttonLabel": "Changer de langue (actuellement {{currentLanguage}})",
"popoverLabel": "Choix de la langue"
},
"loading": "Chargement…",
"loggedInUserTooltip": "Connecté en tant que…",
"login": "Se connecter",
+18 -5
View File
@@ -4,11 +4,24 @@
"heading": "Aidez-nous à améliorer Meet"
},
"join": {
"camlabel": "Webcam",
"heading": "Rejoindre la réunion",
"joinLabel": "Rejoindre",
"micLabel": "Micro",
"userLabel": "Votre nom"
"cameraIsOff": "Webcam coupée.",
"cameraIsOn": "Webcam activée.",
"cameraPlaceholder": "Activez la webcam pour prévisualiser l'affichage",
"camlabel": "",
"chooseCamera": "Choisir la webcam",
"chooseMic": "Choisir le micro",
"chooseSpeaker": "Choisir la sortie audio",
"heading": "Vérifiez vos paramètres",
"joinLabel": "",
"joinMeeting": "Rejoindre la réjoindre",
"micIsOff": "Micro coupé.",
"micIsOn": "Micro activé.",
"micLabel": "",
"toggleOff": "Cliquez pour désactiver",
"toggleOn": "Cliquez pour activer",
"userLabel": "",
"usernameHint": "Affiché aux autres participants",
"usernameLabel": "Votre nom"
},
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
"shareDialog": {
+1 -1
View File
@@ -1,5 +1,5 @@
import { cva } from '@/styled-system/css'
import { styled } from '../styled-system/jsx'
import { styled } from '@/styled-system/jsx'
const box = cva({
base: {
+11 -133
View File
@@ -1,125 +1,20 @@
import { type ReactNode } from 'react'
import {
Button as RACButton,
type ButtonProps as RACButtonsProps,
TooltipTrigger,
Link,
LinkProps,
} from 'react-aria-components'
import { cva, type RecipeVariantProps } from '@/styled-system/css'
import { Tooltip } from './Tooltip'
import { type RecipeVariantProps } from '@/styled-system/css'
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
const button = cva({
base: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transition: 'background 200ms, outline 200ms, border-color 200ms',
cursor: 'pointer',
border: '1px solid transparent',
color: 'colorPalette.text',
backgroundColor: 'colorPalette',
'&[data-hovered]': {
backgroundColor: 'colorPalette.hover',
},
'&[data-pressed]': {
backgroundColor: 'colorPalette.active',
},
},
variants: {
size: {
default: {
borderRadius: 8,
paddingX: '1',
paddingY: '0.625',
'--square-padding': '{spacing.0.625}',
},
sm: {
borderRadius: 4,
paddingX: '0.5',
paddingY: '0.25',
'--square-padding': '{spacing.0.25}',
},
xs: {
borderRadius: 4,
'--square-padding': '0',
},
},
square: {
true: {
paddingX: 'var(--square-padding)',
paddingY: 'var(--square-padding)',
},
},
variant: {
default: {
colorPalette: 'control',
},
primary: {
colorPalette: 'primary',
},
// @TODO: better handling of colors… this is a mess
success: {
colorPalette: 'success',
borderColor: 'success.300',
color: 'success.subtle-text',
backgroundColor: 'success.subtle',
'&[data-hovered]': {
backgroundColor: 'success.200',
},
'&[data-pressed]': {
backgroundColor: 'success.subtle!',
},
},
},
outline: {
true: {
color: 'colorPalette',
backgroundColor: 'transparent!',
borderColor: 'currentcolor!',
'&[data-hovered]': {
backgroundColor: 'colorPalette.subtle!',
},
'&[data-pressed]': {
backgroundColor: 'colorPalette.subtle!',
},
},
},
invisible: {
true: {
borderColor: 'none!',
backgroundColor: 'none!',
'&[data-hovered]': {
backgroundColor: 'none!',
borderColor: 'colorPalette.active!',
},
'&[data-pressed]': {
borderColor: 'currentcolor',
},
},
},
fullWidth: {
true: {
width: 'full',
},
},
},
defaultVariants: {
size: 'default',
variant: 'default',
outline: false,
},
})
type Tooltip = {
tooltip?: string
tooltipType?: 'instant' | 'delayed'
}
export type ButtonProps = RecipeVariantProps<typeof button> &
export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
RACButtonsProps &
Tooltip
TooltipWrapperProps
type LinkButtonProps = RecipeVariantProps<typeof button> & LinkProps & Tooltip
type LinkButtonProps = RecipeVariantProps<ButtonRecipe> &
LinkProps &
TooltipWrapperProps
type ButtonOrLinkProps = ButtonProps | LinkButtonProps
@@ -128,37 +23,20 @@ export const Button = ({
tooltipType = 'instant',
...props
}: ButtonOrLinkProps) => {
const [variantProps, componentProps] = button.splitVariantProps(props)
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
if ((props as LinkButtonProps).href !== undefined) {
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<Link className={button(variantProps)} {...componentProps} />
<Link className={buttonRecipe(variantProps)} {...componentProps} />
</TooltipWrapper>
)
}
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACButton
className={button(variantProps)}
className={buttonRecipe(variantProps)}
{...(componentProps as RACButtonsProps)}
/>
</TooltipWrapper>
)
}
const TooltipWrapper = ({
tooltip,
tooltipType,
children,
}: {
children: ReactNode
} & Tooltip) => {
return tooltip ? (
<TooltipTrigger delay={tooltipType === 'instant' ? 300 : 1000}>
{children}
<Tooltip>{tooltip}</Tooltip>
</TooltipTrigger>
) : (
children
)
}
@@ -0,0 +1,12 @@
import { ReactNode, useContext } from 'react'
import { OverlayTriggerStateContext } from 'react-aria-components'
/**
* Small helper you can use as a wrapper of a <Dialog> component if you want to make sure it is not rendered when it is closed.
*
* Not required all the time, it's mostly helpful to avoid calling hooks of a child component that uses a Dialog.
*/
export const DialogContainer = ({ children }: { children: ReactNode }) => {
const state = useContext(OverlayTriggerStateContext)!
return state.isOpen ? children : null
}
+13 -9
View File
@@ -13,6 +13,7 @@ import { Button, useCloseDialog } from '@/primitives'
export const Form = ({
onSubmit,
submitLabel,
withSubmitButton = true,
withCancelButton = true,
onCancelButtonPress,
children,
@@ -25,6 +26,7 @@ export const Form = ({
event: FormEvent<HTMLFormElement>
) => void
submitLabel: string
withSubmitButton?: boolean
withCancelButton?: boolean
onCancelButtonPress?: () => void
}) => {
@@ -46,16 +48,18 @@ export const Form = ({
}}
>
{children}
<HStack gap="gutter">
<Button type="submit" variant="primary">
{submitLabel}
</Button>
{!!onCancel && (
<Button variant="primary" outline onPress={() => onCancel()}>
{t('cancel')}
{withSubmitButton && (
<HStack gap="gutter">
<Button type="submit" variant="primary">
{submitLabel}
</Button>
)}
</HStack>
{!!onCancel && (
<Button variant="primary" outline onPress={() => onCancel()}>
{t('cancel')}
</Button>
)}
</HStack>
)}
</RACForm>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { ReactNode } from 'react'
import { MenuTrigger } from 'react-aria-components'
import { StyledPopover } from './Popover'
import { Box } from './Box'
/**
* a Menu is a tuple of a trigger component (most usually a Button) that toggles menu items in a tooltip around the trigger
*/
export const Menu = ({
children,
}: {
children: [trigger: ReactNode, menu: ReactNode]
}) => {
const [trigger, menu] = children
return (
<MenuTrigger>
{trigger}
<StyledPopover>
<Box size="sm" type="popover">
{menu}
</Box>
</StyledPopover>
</MenuTrigger>
)
}
+42
View File
@@ -0,0 +1,42 @@
import { ReactNode } from 'react'
import { Menu, MenuProps, MenuItem } from 'react-aria-components'
import { menuItemRecipe } from './menuItemRecipe'
/**
* render a Button primitive that shows a popover showing a list of pressable items
*/
export const MenuList = <T extends string | number = string>({
onAction,
selectedItem,
items = [],
...menuProps
}: {
onAction: (key: T) => void
selectedItem?: T
items: Array<string | { value: T; label: ReactNode }>
} & MenuProps<unknown>) => {
return (
<Menu
selectionMode={selectedItem !== undefined ? 'single' : undefined}
selectedKeys={selectedItem !== undefined ? [selectedItem] : undefined}
{...menuProps}
>
{items.map((item) => {
const value = typeof item === 'string' ? item : item.value
const label = typeof item === 'string' ? item : item.label
return (
<MenuItem
className={menuItemRecipe()}
key={value}
id={value as string}
onAction={() => {
onAction(value as T)
}}
>
{label}
</MenuItem>
)
})}
</Menu>
)
}
+4 -1
View File
@@ -49,7 +49,10 @@ const StyledOverlayArrow = styled(OverlayArrow, {
})
/**
* a Popover is a tuple of a trigger component (most usually a Button) that toggles some interactive content in a tooltip around the trigger
* a Popover is a tuple of a trigger component (most usually a Button) that toggles some content in a tooltip around the trigger
*
* Note: to show a list of actionable items, like a dropdown menu, prefer using a <Menu> or <Select>.
* This is here when needing to show unrestricted content in a box.
*/
export const Popover = ({
children,
@@ -1,72 +0,0 @@
import { ReactNode, useContext } from 'react'
import {
ButtonProps,
Button,
OverlayTriggerStateContext,
} from 'react-aria-components'
import { styled } from '@/styled-system/jsx'
const ListItem = styled(Button, {
base: {
paddingY: 0.125,
paddingX: 0.5,
textAlign: 'left',
width: 'full',
borderRadius: 4,
cursor: 'pointer',
color: 'box.text',
border: '1px solid transparent',
'&[data-selected]': {
fontWeight: 'bold',
},
'&[data-focused]': {
color: 'primary.text',
backgroundColor: 'primary',
outline: 'none!',
},
'&[data-hovered]': {
color: 'primary.text',
backgroundColor: 'primary',
outline: 'none!',
},
},
})
/**
* render a Button primitive that shows a popover showing a list of pressable items
*/
export const PopoverList = <T extends string | number = string>({
onAction,
closeOnAction = true,
items = [],
}: {
closeOnAction?: boolean
onAction: (key: T) => void
items: Array<string | { key: string, value: T; label: ReactNode }>
} & ButtonProps) => {
const popoverState = useContext(OverlayTriggerStateContext)!
return (
<ul>
{items.map((item) => {
const value = typeof item === 'string' ? item : item.value
const label = typeof item === 'string' ? item : item.label
const key = typeof item === 'string' ? item : item.key
return (
<li key={key}>
<ListItem
key={value}
onPress={() => {
onAction(value as T)
if (closeOnAction) {
popoverState.close()
}
}}
>
{label}
</ListItem>
</li>
)
})}
</ul>
)
}
+7 -23
View File
@@ -11,6 +11,7 @@ import {
} from 'react-aria-components'
import { Box } from './Box'
import { StyledPopover } from './Popover'
import { menuItemRecipe } from './menuItemRecipe'
const StyledButton = styled(Button, {
base: {
@@ -43,27 +44,6 @@ const StyledSelectValue = styled(SelectValue, {
},
})
const StyledListBoxItem = styled(ListBoxItem, {
base: {
paddingY: 0.125,
paddingX: 0.5,
textAlign: 'left',
width: 'full',
borderRadius: 4,
cursor: 'pointer',
color: 'box.text',
border: '1px solid transparent',
'&[data-selected]': {
fontWeight: 'bold',
},
'&[data-focused]': {
color: 'primary.text',
backgroundColor: 'primary',
outline: 'none!',
},
},
})
export const Select = <T extends string | number>({
label,
items,
@@ -85,9 +65,13 @@ export const Select = <T extends string | number>({
<Box size="sm" type="popover" variant="control">
<ListBox>
{items.map((item) => (
<StyledListBoxItem id={item.value} key={item.value}>
<ListBoxItem
className={menuItemRecipe()}
id={item.value}
key={item.value}
>
{item.label}
</StyledListBoxItem>
</ListBoxItem>
))}
</ListBox>
</Box>
@@ -0,0 +1,25 @@
import {
ToggleButton as RACToggleButton,
ToggleButtonProps,
} from 'react-aria-components'
import { type ButtonRecipeProps, buttonRecipe } from './buttonRecipe'
import { TooltipWrapper, TooltipWrapperProps } from './TooltipWrapper'
/**
* React aria ToggleButton with our button styles, that can take a tooltip if needed
*/
export const ToggleButton = ({
tooltip,
tooltipType,
...props
}: ToggleButtonProps & ButtonRecipeProps & TooltipWrapperProps) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACToggleButton
{...componentProps}
className={buttonRecipe(variantProps)}
/>
</TooltipWrapper>
)
}
@@ -2,16 +2,41 @@ import { type ReactNode } from 'react'
import {
OverlayArrow,
Tooltip as RACTooltip,
TooltipProps,
TooltipTrigger,
type TooltipProps,
} from 'react-aria-components'
import { styled } from '@/styled-system/jsx'
export type TooltipWrapperProps = {
tooltip?: ReactNode
tooltipType?: 'instant' | 'delayed'
}
/**
* Wrap a component you want to apply a tooltip on (for example a Button)
*
* If no tooltip is given, just returns children
*/
export const TooltipWrapper = ({
tooltip,
tooltipType,
children,
}: {
children: ReactNode
} & TooltipWrapperProps) => {
return tooltip ? (
<TooltipTrigger delay={tooltipType === 'instant' ? 300 : 1000}>
{children}
<Tooltip>{tooltip}</Tooltip>
</TooltipTrigger>
) : (
children
)
}
/**
* Styled react aria Tooltip component.
*
* Note that tooltips are directly handled by Buttons via the `tooltip` prop,
* so you should not need to use this component directly.
*
* Style taken from example at https://react-spectrum.adobe.com/react-aria/Tooltip.html
*/
const StyledTooltip = styled(RACTooltip, {
@@ -80,7 +105,7 @@ const TooltipArrow = () => {
)
}
export const Tooltip = ({
const Tooltip = ({
children,
...props
}: Omit<TooltipProps, 'children'> & { children: ReactNode }) => {
+1 -1
View File
@@ -1,4 +1,4 @@
import { styled } from '../styled-system/jsx'
import { styled } from '@/styled-system/jsx'
export const Ul = styled('ul', {
base: {
+149
View File
@@ -0,0 +1,149 @@
import { type RecipeVariantProps, cva } from '@/styled-system/css'
export type ButtonRecipe = typeof buttonRecipe
export type ButtonRecipeProps = RecipeVariantProps<ButtonRecipe>
export const buttonRecipe = cva({
base: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transition: 'background 200ms, outline 200ms, border-color 200ms',
cursor: 'pointer',
border: '1px solid transparent',
color: 'colorPalette.text',
backgroundColor: 'colorPalette',
'&[data-hovered]': {
backgroundColor: 'colorPalette.hover',
},
'&[data-pressed]': {
backgroundColor: 'colorPalette.active',
},
'&[data-selected]': {
backgroundColor: 'colorPalette.active',
},
},
variants: {
size: {
default: {
borderRadius: 8,
paddingX: '1',
paddingY: '0.625',
'--square-padding': '{spacing.0.625}',
},
sm: {
borderRadius: 4,
paddingX: '0.5',
paddingY: '0.25',
'--square-padding': '{spacing.0.25}',
},
xs: {
borderRadius: 4,
'--square-padding': '0',
},
},
square: {
true: {
paddingX: 'var(--square-padding)',
paddingY: 'var(--square-padding)',
},
},
variant: {
default: {
colorPalette: 'control',
borderColor: 'control.subtle',
},
primary: {
colorPalette: 'primary',
},
// @TODO: better handling of colors… this is a mess
success: {
colorPalette: 'success',
borderColor: 'success.300',
color: 'success.subtle-text',
backgroundColor: 'success.subtle',
'&[data-hovered]': {
backgroundColor: 'success.200',
},
'&[data-pressed]': {
backgroundColor: 'success.subtle!',
},
},
danger: {
colorPalette: 'danger',
borderColor: 'danger.600',
color: 'danger.subtle-text',
backgroundColor: 'danger.subtle',
'&[data-hovered]': {
backgroundColor: 'danger.200',
},
'&[data-pressed]': {
backgroundColor: 'danger.subtle!',
},
},
},
outline: {
true: {
color: 'colorPalette',
backgroundColor: 'transparent!',
borderColor: 'currentcolor!',
'&[data-hovered]': {
backgroundColor: 'colorPalette.subtle!',
},
'&[data-pressed]': {
backgroundColor: 'colorPalette.subtle!',
},
},
},
invisible: {
true: {
borderColor: 'none!',
backgroundColor: 'none!',
'&[data-hovered]': {
backgroundColor: 'none!',
borderColor: 'colorPalette.active!',
},
'&[data-pressed]': {
borderColor: 'currentcolor',
},
},
},
fullWidth: {
true: {
width: 'full',
},
},
/**
* if the button is next to other ones to make a "button group", tell where the button is to handle radius
*/
groupPosition: {
left: {
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
},
right: {
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderLeft: 0,
},
center: {
borderRadius: 0,
},
},
// some toggle buttons make more sense without a "pushed button" style when enabled because their content changes to mark the state
toggledStyles: {
false: {
'&[data-selected]': {
backgroundColor: 'colorPalette',
},
},
},
},
defaultVariants: {
size: 'default',
variant: 'default',
outline: false,
toggledStyles: true,
},
})
+10 -1
View File
@@ -1,3 +1,9 @@
/**
* exposes all primitives we want to use in other parts of the app.
*
* It's intended not everything is exported: some primitives are meant as building-blocks
* for other primitives and don't have any value being exposed.
*/
export { A } from './A'
export { Badge } from './Badge'
export { Bold } from './Bold'
@@ -5,6 +11,7 @@ export { Box } from './Box'
export { Button } from './Button'
export { useCloseDialog } from './useCloseDialog'
export { Dialog, type DialogProps } from './Dialog'
export { DialogContainer } from './DialogContainer'
export { Div } from './Div'
export { Field } from './Field'
export { Form } from './Form'
@@ -13,9 +20,11 @@ export { Hr } from './Hr'
export { Italic } from './Italic'
export { Input } from './Input'
export { Link } from './Link'
export { Menu } from './Menu'
export { MenuList } from './MenuList'
export { P } from './P'
export { Popover } from './Popover'
export { PopoverList } from './PopoverList'
export { Text } from './Text'
export { ToggleButton } from './ToggleButton'
export { Ul } from './Ul'
export { VerticallyOffCenter } from './VerticallyOffCenter'
@@ -0,0 +1,40 @@
import { cva } from '@/styled-system/css'
/**
* reusable styles for a menu item, select item, etc… to be used with panda `css()` or `styled()`
*
* these are in their own files because react hot refresh doesn't like exporting stuff
* that aren't components in component files
*/
export const menuItemRecipe = cva({
base: {
paddingY: 0.125,
paddingX: 0.5,
paddingLeft: 1.5,
textAlign: 'left',
width: 'full',
borderRadius: 4,
cursor: 'pointer',
color: 'box.text',
border: '1px solid transparent',
position: 'relative',
'&[data-selected]': {
'&::before': {
content: '"✓"',
position: 'absolute',
top: '2px',
left: '6px',
},
},
'&[data-focused]': {
color: 'primary.text',
backgroundColor: 'primary',
outline: 'none!',
},
'&[data-hovered]': {
color: 'primary.text',
backgroundColor: 'primary',
outline: 'none!',
},
},
})
+2 -3
View File
@@ -1,6 +1,5 @@
import { LogLevel, setLogLevel } from "livekit-client";
import { LogLevel, setLogLevel } from 'livekit-client'
export const silenceLiveKitLogs = (shouldSilenceLogs: boolean) => {
setLogLevel(shouldSilenceLogs ? LogLevel.silent : LogLevel.debug);
setLogLevel(shouldSilenceLogs ? LogLevel.silent : LogLevel.debug)
}
+1705
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -11,7 +11,7 @@
"scripts": {
"build-mjml-to-html": "bash ./bin/mjml-to-html",
"build-html-to-plain-text": "bash ./bin/html-to-plain-text",
"build": "yarn build-mjml-to-html && yarn build-html-to-plain-text"
"build": "npm run build-mjml-to-html && npm run build-html-to-plain-text"
},
"volta": {
"node": "16.15.1"
-1292
View File
File diff suppressed because it is too large Load Diff