Compare commits

...

26 Commits

Author SHA1 Message Date
Nathan Vasse f11369981e wip 2025-02-06 17:15:11 +01:00
Nathan Vasse 6129a7fb2d wip 2025-02-06 17:09:38 +01:00
Nathan Vasse feea9eb60c wip 2025-02-06 16:58:19 +01:00
Nathan Vasse 06dae1d266 wip 2025-02-06 14:13:28 +01:00
Nathan Vasse 491ca7f509 wip 2025-02-03 18:14:53 +01:00
Nathan Vasse b0e1c0f7a4 wip 2025-02-03 17:35:59 +01:00
Nathan Vasse e2887880c6 wip 2025-02-03 16:53:51 +01:00
Nathan Vasse 414b3720f1 wip 2025-02-03 12:38:11 +01:00
Nathan Vasse c61bae279e wip 2025-01-31 17:31:25 +01:00
Nathan Vasse e538fbdb77 wip 2025-01-31 14:11:02 +01:00
Nathan Vasse 75614181e4 wip 2025-01-31 14:10:52 +01:00
Nathan Vasse fb07dcdbf0 wip 2025-01-31 11:08:24 +01:00
lebaudantoine 4ae3d965f9 🚸(frontend) show support toggle only if support is enabled
Avoid misleading the user, if support is not enabled, as it's optional,
avoid displaying a useless control button.

Requested by Dutch counterparts.
2025-01-29 16:12:47 +01:00
lebaudantoine 8eab45b6d5 🩹(frontend) prevent runtime error when Crisp is not initialized
Prevents runtime error when Crisp chat hasn't initialized before component mount
Previously caused crash since we assumed $crisp global was always available.
2025-01-29 16:12:47 +01:00
lebaudantoine 4347d87f33 🚸(frontend) improve prejoin UX
- Always enable camera/mic by default (like Google Meet)
- Fix video state transitions and add visual feedback
- Simplify form using React Aria components
- Reduce shadow intensity for better visual balance
2025-01-29 14:14:11 +01:00
lebaudantoine 1b52d76168 ♻️(frontend) extract menu items into individual components
Each menu item is now a standalone component, improving:
- Code organization & reusability
- Maintainability by reducing OptionsMenuItems complexity

This breaks down large components.
2025-01-27 23:02:31 +01:00
lebaudantoine a44b6e8e34 (frontend) add fullscreen mode on desktop
Added an option allowing users to trigger the fullscreen mode while on desktop.
Heavily inspired by the PR #279 from @sylvinus.

Yet, this option allow user to enable/disable the fullscreen mode on the whole
ui, in the next iteration I'll add the same feature but for a given video track.

This is on purpose that the feature is available on desktop only.
The hook code has been partially written by Claude and inspired by @sylvinus
first suggestion.
2025-01-27 23:02:31 +01:00
lebaudantoine 126de638cd 🩹(frontend) fix allowed hosts by Vite using the Tilt stack
When using nip.io for local development DNS mapping (which allows hostname-based
access to localhost), we need to explicitly allow these domains in Vite's server
configuration to prevent host security violations.

This check should be ignored when using https.
2025-01-27 22:57:51 +01:00
lebaudantoine bf28c5cb84 📱(frontend) enhance control bar responsiveness for better UX
Implemented collapsible advanced options to maintain usability on narrow screens
following GMeet's UX pattern. Dialog and popover components were chosen
based on GMeet choices, though this introduces potential accessibility concerns
that should be addressed in future iterations.

Current implementation uses JS for breakpoint handling due to challenges with
Panda CSS's pure CSS approach. This workaround was necessary to resolve a
persistent issue where the popover remained open after window expansion beyond
750px, even after the lateral menu trigger was removed from view.

Technical debt note: Code needs refinement, particularly around breakpoint
management and component architecture. Prioritized shipping over perfection to
meet immediate responsive design needs.
2025-01-27 22:57:24 +01:00
renovate[bot] 4f0e7d2c52 ⬆️(dependencies) update python dependencies 2025-01-27 22:18:32 +01:00
renovate[bot] 3ab5c48296 ⬆️(dependencies) update js dependencies 2025-01-27 18:12:44 +01:00
Nathan Vasse 7f1f573af8 (front) revamp join screen
We want this screen to have a better ux, the join button was invisible on
some small screen sizes, also we want to align the style of this screen with
the ui of the video conference previously made.
2025-01-27 15:31:19 +01:00
Nathan Vasse d48a18b36b (front) make ToggleDevice work outside of a room
This component is needed on the join screen thus out of a room context.
2025-01-27 15:31:19 +01:00
Nathan Vasse eac9158734 (front) add customization to Field
We want to be able to provide some custom attributes to the FieldWrapper
and its label too to manage width, alignment.
2025-01-27 15:31:19 +01:00
Nathan Vasse 994f335266 (front) add customization to SelectToggleDevice
We want to be able to change the variant of this component.
2025-01-27 15:31:19 +01:00
lebaudantoine 30bde2fe66 ♻️(frontend) use pandas utilities
Forgot how panda css is amazing. They provide all kinds of utilities,
and few for accessibility. Amazing!
2025-01-24 13:56:32 +01:00
77 changed files with 12633 additions and 176 deletions
+34 -6
View File
@@ -175,25 +175,53 @@ jobs:
- name: Check format
run: cd src/frontend/ && npm run check
lint-sdk:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install dependencies
run: cd src/sdk/library/ && npm ci
- name: Check linting
run: cd src/sdk/library/ && npm run lint
- name: Check format
run: cd src/sdk/library/ && npm run check
build-sdk:
runs-on: ubuntu-latest
needs: lint-sdk
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install dependencies
run: cd src/sdk/library/ && npm ci
- name: Check linting
run: cd src/sdk/library/ && npm run lint
- name: Check format
run: cd src/sdk/library/ && npm run build
i18n-crowdin:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "infrastructure,secrets"
-
name: Checkout repository
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
- name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
+2 -2
View File
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.36.2",
"boto3==1.36.6",
"Brotli==1.1.0",
"brevo-python==1.1.2",
"celery[redis]==5.4.0",
@@ -83,7 +83,7 @@ dev = [
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.6",
"ruff==0.9.2",
"ruff==0.9.3",
"types-requests==2.32.0.20241016",
]
+35 -32
View File
@@ -8,7 +8,7 @@
"name": "meet",
"version": "0.1.12",
"dependencies": {
"@livekit/components-react": "2.8.0",
"@livekit/components-react": "2.8.1",
"@livekit/components-styles": "1.1.4",
"@livekit/track-processors": "0.3.3",
"@pandacss/preset-panda": "0.51.1",
@@ -17,25 +17,25 @@
"@tanstack/react-query": "5.64.2",
"crisp-sdk-web": "1.0.25",
"hoofd": "1.7.3",
"i18next": "24.2.1",
"i18next": "24.2.2",
"i18next-browser-languagedetector": "8.0.2",
"i18next-parser": "9.1.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.8.1",
"posthog-js": "1.207.0",
"posthog-js": "1.210.2",
"react": "18.3.1",
"react-aria-components": "1.6.0",
"react-dom": "18.3.1",
"react-i18next": "15.1.1",
"use-sound": "4.0.3",
"valtio": "2.1.2",
"wouter": "3.3.5"
"valtio": "2.1.3",
"wouter": "3.4.0"
},
"devDependencies": {
"@pandacss/dev": "0.51.1",
"@tanstack/eslint-plugin-query": "5.64.2",
"@tanstack/react-query-devtools": "5.64.2",
"@types/node": "22.10.7",
"@types/node": "22.10.10",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@typescript-eslint/eslint-plugin": "8.21.0",
@@ -49,7 +49,7 @@
"postcss": "8.5.1",
"prettier": "3.4.2",
"typescript": "5.7.3",
"vite": "6.0.10",
"vite": "6.0.11",
"vite-tsconfig-paths": "5.1.4"
}
},
@@ -436,6 +436,7 @@
},
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
"version": "1.3.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"engines": {
@@ -1240,9 +1241,9 @@
}
},
"node_modules/@livekit/components-react": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.8.0.tgz",
"integrity": "sha512-Zgt2zv+j2gWJad0mcTDR8cS6KQNXYfAeOL9Si5Lb2Q2k3iPUJinPDMqubM6btnduhkPjy+EF4kA8bNAY0xu4Ig==",
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.8.1.tgz",
"integrity": "sha512-XpuDu7iDMcN4pkV8CYNzHf9hLNdYOeEtbmCr7Zesy6Au3BxUl4aS1Ajmg0b75Rx7zTlkyCJt9Lm4VrEqbJCI6Q==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/components-core": "0.12.1",
@@ -3879,9 +3880,9 @@
"integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ=="
},
"node_modules/@types/node": {
"version": "22.10.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz",
"integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==",
"version": "22.10.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.10.tgz",
"integrity": "sha512-X47y/mPNzxviAGY5TcYPtYL8JsY3kAq2n8fMmKoRCxq/c4v4pyGNCzM2R6+M5/umG4ZfHuT+sgqDYqWc9rJ6ww==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6395,9 +6396,9 @@
}
},
"node_modules/i18next": {
"version": "24.2.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.1.tgz",
"integrity": "sha512-Q2wC1TjWcSikn1VAJg13UGIjc+okpFxQTxjVAymOnSA3RpttBQNMPf2ovcgoFVsV4QNxTfNZMAxorXZXsk4fBA==",
"version": "24.2.2",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.2.tgz",
"integrity": "sha512-NE6i86lBCKRYZa5TaUDkU5S4HFgLIEJRLr3Whf2psgaxBleQ2LC1YW1Vc+SCgkAW7VEzndT6al6+CzegSUHcTQ==",
"funding": [
{
"type": "individual",
@@ -8444,9 +8445,9 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.207.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.207.0.tgz",
"integrity": "sha512-Sx+xamhg1/iKGAtUNh3uAUtAAza4j/yBhxcfUxfqR++WrZdw0V6nmh7LSfVNl7+QVl2qmiPSoZA7z+5ojaWDDQ==",
"version": "1.210.2",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.210.2.tgz",
"integrity": "sha512-rIbn/h9ur7uA0PS4dClOr9w6txLfHS94yh9yafA5VM2eXToM951XtMYtIQ6bi6wFzpvpFvTQFeYLQ/9/xZ59AQ==",
"license": "MIT",
"dependencies": {
"core-js": "^3.38.1",
@@ -8503,9 +8504,10 @@
}
},
"node_modules/proxy-compare": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.0.tgz",
"integrity": "sha512-y44MCkgtZUCT9tZGuE278fB7PWVf7fRYy0vbRXAts2o5F0EfC4fIQrvQQGBJo1WJbFcVLXzApOscyJuZqHQc1w=="
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.1.tgz",
"integrity": "sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
@@ -9791,12 +9793,12 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/valtio": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/valtio/-/valtio-2.1.2.tgz",
"integrity": "sha512-fhekN5Rq7dvHULHHBlJeXHrQDl0Jj9GXfNavCm3gkD06crGchaG1nf/J7gSlfZU2wPcRdVS5jBKWHtE2NNz97A==",
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/valtio/-/valtio-2.1.3.tgz",
"integrity": "sha512-q+dVczY5zf7kO39JPVq2qR0VXMeEpF1t+P1njHU7hAhwgtyVa/p38YmgyHZq2IQYwnkNrkvh412k4sfDmcIJ2g==",
"license": "MIT",
"dependencies": {
"proxy-compare": "^3.0.0"
"proxy-compare": "^3.0.1"
},
"engines": {
"node": ">=12.20.0"
@@ -9890,9 +9892,9 @@
}
},
"node_modules/vite": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.0.10.tgz",
"integrity": "sha512-MEszunEcMo6pFsfXN1GhCFQqnE25tWRH0MA4f0Q7uanACi4y1Us+ZGpTMnITwCTnYzB2b9cpmnelTlxgTBmaBA==",
"version": "6.0.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.0.11.tgz",
"integrity": "sha512-4VL9mQPKoHy4+FE0NnRE/kbY51TOfaknxAjt3fJbGJxhIpBZiqVzlZDEesWWsuREXHwNdAoOFZ9MkPEVXczHwg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10582,9 +10584,10 @@
}
},
"node_modules/wouter": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/wouter/-/wouter-3.3.5.tgz",
"integrity": "sha512-bx3fLQAMn+EhYbBdY3W1gw9ZfO/uchudxYMwOIBzF3HVgqNEEIT199vEoh7FLTC0Vz5+rpMO6NdFsOkGX1QQCw==",
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/wouter/-/wouter-3.4.0.tgz",
"integrity": "sha512-Qx4L+EnYk7AF+pNU9TM+lQtkceL5Qm+oDZU9720DUx+/7K6v7fnlHnRLI78KjEbCqdGFeroW5P0M1t8WYSloQA==",
"license": "Unlicense",
"dependencies": {
"mitt": "^3.0.1",
"regexparam": "^3.0.0",
+7 -7
View File
@@ -13,7 +13,7 @@
"check": "prettier --check ./src"
},
"dependencies": {
"@livekit/components-react": "2.8.0",
"@livekit/components-react": "2.8.1",
"@livekit/components-styles": "1.1.4",
"@livekit/track-processors": "0.3.3",
"@pandacss/preset-panda": "0.51.1",
@@ -22,25 +22,25 @@
"@tanstack/react-query": "5.64.2",
"crisp-sdk-web": "1.0.25",
"hoofd": "1.7.3",
"i18next": "24.2.1",
"i18next": "24.2.2",
"i18next-browser-languagedetector": "8.0.2",
"i18next-parser": "9.1.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.8.1",
"posthog-js": "1.207.0",
"posthog-js": "1.210.2",
"react": "18.3.1",
"react-aria-components": "1.6.0",
"react-dom": "18.3.1",
"react-i18next": "15.1.1",
"use-sound": "4.0.3",
"valtio": "2.1.2",
"wouter": "3.3.5"
"valtio": "2.1.3",
"wouter": "3.4.0"
},
"devDependencies": {
"@pandacss/dev": "0.51.1",
"@tanstack/eslint-plugin-query": "5.64.2",
"@tanstack/react-query-devtools": "5.64.2",
"@types/node": "22.10.7",
"@types/node": "22.10.10",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@typescript-eslint/eslint-plugin": "8.21.0",
@@ -54,7 +54,7 @@
"postcss": "8.5.1",
"prettier": "3.4.2",
"typescript": "5.7.3",
"vite": "6.0.10",
"vite": "6.0.11",
"vite-tsconfig-paths": "5.1.4"
}
}
+26 -10
View File
@@ -13,24 +13,40 @@ import { routes } from './routes'
import './i18n/init'
import { queryClient } from '@/api/queryClient'
import { AppInitialization } from '@/components/AppInitialization'
import { SdkCreateButton } from './features/sdk/routes/CreateButton'
function App() {
const { i18n } = useTranslation()
useLang(i18n.language)
return (
<QueryClientProvider client={queryClient}>
<AppInitialization />
<Suspense fallback={null}>
<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} buttonPosition="top-left" />
<Switch>
{/* We only want support and ReactQueryDevTools in non /sdk routes */}
<Route path={/^(?!\/sdk).*$/}>
<AppInitialization />
<Layout>
{Object.entries(routes).map(([, route], i) => (
<Route
key={i}
path={route.path}
component={route.Component}
/>
))}
</Layout>
<ReactQueryDevtools
initialIsOpen={false}
buttonPosition="top-left"
/>
</Route>
<Route path="/sdk" nest>
<Route path="/create-button">
<SdkCreateButton />
</Route>
</Route>
<Route component={NotFoundScreen} />
</Switch>
</I18nProvider>
</Suspense>
</QueryClientProvider>
+24
View File
@@ -0,0 +1,24 @@
export const VisioIcon = () => {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0.841702 8.95427C0.75 9.42479 0.75 10.0042 0.75 10.9748V13.3096C0.75 14.5138 0.75 15.1159 0.925134 15.6549C1.08009 16.1319 1.33356 16.5709 1.6691 16.9435C2.04833 17.3647 2.56977 17.6658 3.61264 18.2679L5.6346 19.4353C6.55753 19.9681 7.07208 20.2652 7.56247 20.4081V13.2043C7.56247 12.8442 7.36578 12.5129 7.04965 12.3404L0.841702 8.95427Z"
fill="currentColor"
/>
<path
d="M17.747 10.0123V13.7685C17.747 14.2878 17.9727 14.7815 18.3654 15.1214L21.0688 17.4609C21.227 17.5947 21.3912 17.7042 21.5616 17.7894C21.738 17.8685 21.9084 17.9081 22.0726 17.9081C22.4255 17.9081 22.7084 17.7925 22.9213 17.5613C23.1404 17.324 23.2499 17.0168 23.2499 16.6396V7.14866C23.2499 6.77146 23.1404 6.46726 22.9213 6.23607C22.7084 5.9988 22.4255 5.88017 22.0726 5.88017C21.9084 5.88017 21.738 5.91971 21.5616 5.9988C21.3912 6.07789 21.227 6.1874 21.0688 6.32733L18.3675 8.65759C17.9735 8.99746 17.747 9.49201 17.747 10.0123Z"
fill="currentColor"
/>
<path
d="M1.74517 7.61282C1.67 7.57182 1.59117 7.54405 1.51135 7.52872C1.56171 7.46443 1.61433 7.40178 1.66914 7.34091C2.04837 6.91973 2.56981 6.61868 3.61268 6.01657L5.63464 4.84919C6.67751 4.24708 7.19894 3.94603 7.7533 3.82819C8.2438 3.72394 8.75074 3.72394 9.24124 3.82819C9.7956 3.94603 10.317 4.24708 11.3599 4.84919L13.358 6.0028C13.366 6.00738 13.374 6.01197 13.3819 6.01658C14.4248 6.61868 14.9462 6.91973 15.3255 7.34091C15.661 7.71357 15.9145 8.15259 16.0694 8.62951C16.2446 9.16852 16.2446 9.77063 16.2446 10.9748V13.3096C16.2446 14.5138 16.2446 15.1159 16.0694 15.6549C15.9145 16.1319 15.661 16.5709 15.3255 16.9435C14.9462 17.3647 14.4248 17.6657 13.382 18.2678C13.373 18.273 13.364 18.2782 13.3551 18.2833L11.3599 19.4353C10.317 20.0374 9.7956 20.3384 9.24124 20.4562C9.21846 20.4611 9.19564 20.4657 9.17279 20.4701L9.17278 13.2043C9.17278 12.255 8.65422 11.3814 7.82079 10.9268L1.74517 7.61282Z"
fill="currentColor"
/>
</svg>
)
}
+16 -3
View File
@@ -14,10 +14,11 @@ import { RiAddLine, RiLink } from '@remixicon/react'
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
import { IntroSlider } from '@/features/home/components/IntroSlider'
import { MoreLink } from '@/features/home/components/MoreLink'
import { ReactNode, useState } from 'react'
import { ReactNode, useEffect, useState } from 'react'
import { css } from '@/styled-system/css'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
import { SdkReverseClient } from '@/features/sdk/SdkReverseClient'
const Columns = ({ children }: { children?: ReactNode }) => {
return (
@@ -32,10 +33,10 @@ const Columns = ({ children }: { children?: ReactNode }) => {
justifyContent: 'normal',
padding: '0 1rem',
width: 'calc(100% - 2rem)',
'@media(prefers-reduced-motion: reduce)': {
_motionReduce: {
opacity: 1,
},
'@media(prefers-reduced-motion: no-preference)': {
_motionSafe: {
opacity: 0,
animation: '.5s ease-in fade 0s forwards',
},
@@ -156,6 +157,18 @@ export const Home = () => {
const { mutateAsync: createRoom } = useCreateRoom()
const [laterRoomId, setLaterRoomId] = useState<null | string>(null)
const { user } = useUser()
/**
* Used for SDK popup to close automatically.
*/
useEffect(() => {
if (!user) {
return
}
SdkReverseClient.broadcastAuthentication()
}, [user])
return (
<UserAware>
<Screen>
@@ -1,30 +1,281 @@
import { useTranslation } from 'react-i18next'
import { PreJoin, type LocalUserChoices } from '@livekit/components-react'
import {
usePersistentUserChoices,
usePreviewTracks,
type LocalUserChoices,
} from '@livekit/components-react'
import { css } from '@/styled-system/css'
import { Screen } from '@/layout/Screen'
import { CenteredContent } from '@/layout/CenteredContent'
import { useUser } from '@/features/auth'
import { useMemo, useEffect, useRef, useState } from 'react'
import { LocalVideoTrack, Track } from 'livekit-client'
import { H } from '@/primitives/H'
import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice'
import { Field } from '@/primitives/Field'
import { Form } from '@/primitives'
import { HStack, VStack } from '@/styled-system/jsx'
const onError = (e: Error) => console.error('ERROR', e)
export const Join = ({
onSubmit,
}: {
onSubmit: (choices: LocalUserChoices) => void
}) => {
const { t } = useTranslation('rooms')
const { user } = useUser()
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const {
userChoices: initialUserChoices,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
saveUsername,
} = usePersistentUserChoices({})
const [audioDeviceId, setAudioDeviceId] = useState<string>(
initialUserChoices.audioDeviceId
)
const [videoDeviceId, setVideoDeviceId] = useState<string>(
initialUserChoices.videoDeviceId
)
const [username, setUsername] = useState<string>(initialUserChoices.username)
useEffect(() => {
saveAudioInputDeviceId(audioDeviceId)
}, [audioDeviceId, saveAudioInputDeviceId])
useEffect(() => {
saveVideoInputDeviceId(videoDeviceId)
}, [videoDeviceId, saveVideoInputDeviceId])
useEffect(() => {
saveUsername(username)
}, [username, saveUsername])
const [audioEnabled, setAudioEnabled] = useState(true)
const [videoEnabled, setVideoEnabled] = useState(true)
const tracks = usePreviewTracks(
{
audio: { deviceId: initialUserChoices.audioDeviceId },
video: { deviceId: initialUserChoices.videoDeviceId },
},
onError
)
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 LocalVideoTrack,
[tracks]
)
const videoEl = useRef(null)
useEffect(() => {
const videoElement = videoEl.current as HTMLVideoElement | null
const handleVideoLoaded = () => {
if (videoElement) {
videoElement.style.opacity = '1'
}
}
if (videoElement && videoTrack && videoEnabled) {
videoTrack.unmute()
videoTrack.attach(videoElement)
videoElement.addEventListener('loadedmetadata', handleVideoLoaded)
}
return () => {
videoTrack?.detach()
videoElement?.removeEventListener('loadedmetadata', handleVideoLoaded)
}
}, [videoTrack, videoEnabled])
function handleSubmit() {
onSubmit({
audioEnabled,
videoEnabled,
audioDeviceId,
videoDeviceId,
username,
})
}
return (
<Screen layout="centered" footer={false}>
<CenteredContent title={t('join.heading')}>
<PreJoin
persistUserChoices
onSubmit={onSubmit}
micLabel={t('join.audioinput.label')}
camLabel={t('join.videoinput.label')}
joinLabel={t('join.joinLabel')}
userLabel={t('join.usernameLabel')}
defaults={{ username: user?.full_name }}
/>
</CenteredContent>
<Screen footer={false}>
<div
className={css({
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
flexGrow: 1,
lg: {
alignItems: 'center',
},
})}
>
<div
className={css({
display: 'flex',
height: 'auto',
alignItems: 'center',
justifyContent: 'center',
gap: '2rem',
padding: '0 2rem',
flexDirection: 'column',
minWidth: 0,
width: '100%',
lg: {
flexDirection: 'row',
width: 'auto',
height: '570px',
},
})}
>
<div
className={css({
width: '100%',
lg: {
width: '740px',
},
})}
>
<div
className={css({
borderRadius: '1rem',
overflow: 'hidden',
position: 'relative',
width: '100%',
height: 'auto',
aspectRatio: 16 / 9,
'--tw-shadow':
'0 10px 15px -5px #00000010, 0 4px 5px -6px #00000010',
'--tw-shadow-colored':
'0 10px 15px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color)',
boxShadow:
'var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow)',
backgroundColor: 'black',
})}
>
{videoTrack && videoEnabled ? (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video
ref={videoEl}
width="1280"
height="720"
className={css({
display: 'block',
width: '102%',
height: '102%',
objectFit: 'cover',
transform: 'rotateY(180deg)',
opacity: 0,
transition: 'opacity 0.3s ease-in-out',
})}
/>
) : (
<div
className={css({
width: '100%',
height: '100%',
color: 'white',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
})}
>
<p
className={css({
fontSize: '24px',
fontWeight: '300',
})}
>
{!videoEnabled && t('cameraDisabled')}
{videoEnabled && !videoTrack && t('cameraStarting')}
</p>
</div>
)}
</div>
<HStack justify="center" padding={1.5}>
<SelectToggleDevice
source={Track.Source.Microphone}
initialState={audioEnabled}
track={audioTrack}
initialDeviceId={initialUserChoices.audioDeviceId}
onChange={(enabled) => setAudioEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
onActiveDeviceChange={(deviceId) =>
setAudioDeviceId(deviceId ?? '')
}
variant="tertiary"
/>
<SelectToggleDevice
source={Track.Source.Camera}
initialState={videoEnabled}
track={videoTrack}
initialDeviceId={initialUserChoices.videoDeviceId}
onChange={(enabled) => setVideoEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
onActiveDeviceChange={(deviceId) =>
setVideoDeviceId(deviceId ?? '')
}
variant="tertiary"
/>
</HStack>
</div>
<div
className={css({
width: '100%',
flexShrink: 0,
padding: '0',
sm: {
width: '448px',
padding: '0 3rem 9rem 3rem',
},
})}
>
<Form
onSubmit={handleSubmit}
submitLabel={t('joinLabel')}
submitButtonProps={{
fullWidth: true,
}}
>
<VStack marginBottom={1}>
<H lvl={1} margin={false}>
{t('heading')}
</H>
<Field
type="text"
onChange={setUsername}
label={t('usernameLabel')}
aria-label={t('usernameLabel')}
defaultValue={initialUserChoices?.username}
validate={(value) => !value && t('errors.usernameEmpty')}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
labelProps={{
center: true,
}}
maxLength={50}
/>
</VStack>
</Form>
</div>
</div>
</div>
</Screen>
)
}
@@ -0,0 +1,20 @@
import { RiAccountBoxLine } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { useSidePanel } from '../../../hooks/useSidePanel'
export const EffectsMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { toggleEffects } = useSidePanel()
return (
<MenuItem
onAction={() => toggleEffects()}
className={menuRecipe({ icon: true, variant: 'dark' }).item}
>
<RiAccountBoxLine size={20} />
{t('effects')}
</MenuItem>
)
}
@@ -0,0 +1,20 @@
import { RiMegaphoneLine } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { GRIST_FORM } from '@/utils/constants'
export const FeedbackMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
return (
<MenuItem
href={GRIST_FORM}
target="_blank"
className={menuRecipe({ icon: true, variant: 'dark' }).item}
>
<RiMegaphoneLine size={20} />
{t('feedback')}
</MenuItem>
)
}
@@ -0,0 +1,34 @@
import { RiFullscreenExitLine, RiFullscreenLine } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { useFullScreen } from '../../../hooks/useFullScreen'
export const FullScreenMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { toggleFullScreen, isCurrentlyFullscreen, isFullscreenAvailable } =
useFullScreen()
if (!isFullscreenAvailable) {
return null
}
return (
<MenuItem
onAction={() => toggleFullScreen()}
className={menuRecipe({ icon: true, variant: 'dark' }).item}
>
{isCurrentlyFullscreen ? (
<>
<RiFullscreenExitLine size={20} />
{t('fullscreen.exit')}
</>
) : (
<>
<RiFullscreenLine size={20} />
{t('fullscreen.enter')}
</>
)}
</MenuItem>
)
}
@@ -1,21 +1,12 @@
import {
RiAccountBoxLine,
RiMegaphoneLine,
RiSettings3Line,
} from '@remixicon/react'
import { MenuItem, Menu as RACMenu, MenuSection } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { Menu as RACMenu, MenuSection } from 'react-aria-components'
import { Separator } from '@/primitives/Separator'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
import { useSettingsDialog } from '../SettingsDialogContext'
import { GRIST_FORM } from '@/utils/constants'
import { FullScreenMenuItem } from './FullScreenMenuItem'
import { SettingsMenuItem } from './SettingsMenuItem'
import { FeedbackMenuItem } from './FeedbackMenuItem'
import { EffectsMenuItem } from './EffectsMenuItem'
// @todo try refactoring it to use MenuList component
export const OptionsMenuItems = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { toggleEffects } = useSidePanel()
const { setDialogOpen } = useSettingsDialog()
return (
<RACMenu
style={{
@@ -24,31 +15,13 @@ export const OptionsMenuItems = () => {
}}
>
<MenuSection>
<MenuItem
onAction={() => toggleEffects()}
className={menuRecipe({ icon: true, variant: 'dark' }).item}
>
<RiAccountBoxLine size={20} />
{t('effects')}
</MenuItem>
<FullScreenMenuItem />
<EffectsMenuItem />
</MenuSection>
<Separator />
<MenuSection>
<MenuItem
href={GRIST_FORM}
target="_blank"
className={menuRecipe({ icon: true, variant: 'dark' }).item}
>
<RiMegaphoneLine size={20} />
{t('feedback')}
</MenuItem>
<MenuItem
className={menuRecipe({ icon: true, variant: 'dark' }).item}
onAction={() => setDialogOpen(true)}
>
<RiSettings3Line size={20} />
{t('settings')}
</MenuItem>
<FeedbackMenuItem />
<SettingsMenuItem />
</MenuSection>
</RACMenu>
)
@@ -0,0 +1,20 @@
import { RiSettings3Line } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { useSettingsDialog } from '../SettingsDialogContext'
export const SettingsMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { setDialogOpen } = useSettingsDialog()
return (
<MenuItem
className={menuRecipe({ icon: true, variant: 'dark' }).item}
onAction={() => setDialogOpen(true)}
>
<RiSettings3Line size={20} />
{t('settings')}
</MenuItem>
)
}
@@ -13,12 +13,14 @@ import {
RiVideoOffLine,
RiVideoOnLine,
} from '@remixicon/react'
import { Track } from 'livekit-client'
import { LocalAudioTrack, LocalVideoTrack, Track } from 'livekit-client'
import { Shortcut } from '@/features/shortcuts/types'
import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice.tsx'
import { css } from '@/styled-system/css'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { useEffect } from 'react'
export type ToggleSource = Exclude<
Track.Source,
@@ -65,16 +67,22 @@ const selectToggleDeviceConfig: SelectToggleDeviceConfigMap = {
type SelectToggleDeviceProps<T extends ToggleSource> =
UseTrackToggleProps<T> & {
track?: LocalAudioTrack | LocalVideoTrack | undefined
initialDeviceId?: string
onActiveDeviceChange: (deviceId: string) => void
source: SelectToggleSource
variant?: NonNullable<ButtonRecipeProps>['variant']
menuVariant?: 'dark' | 'light'
hideMenu?: boolean
variant?: 'dark' | 'light'
}
export const SelectToggleDevice = <T extends ToggleSource>({
track,
initialDeviceId,
onActiveDeviceChange,
hideMenu,
variant = 'light',
variant = 'primaryDark',
menuVariant = 'light',
...props
}: SelectToggleDeviceProps<T>) => {
const config = selectToggleDeviceConfig[props.source]
@@ -85,7 +93,19 @@ export const SelectToggleDevice = <T extends ToggleSource>({
const trackProps = useTrackToggle(props)
const { devices, activeDeviceId, setActiveMediaDevice } =
useMediaDeviceSelect({ kind: config.kind })
useMediaDeviceSelect({ kind: config.kind, track })
/**
* When providing only track outside of a room context, activeDeviceId is undefined.
* So we need to initialize it with the initialDeviceId.
* nb: I don't understand why useMediaDeviceSelect cannot infer it from track device id.
*/
useEffect(() => {
if (initialDeviceId !== undefined) {
setActiveMediaDevice(initialDeviceId)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setActiveMediaDevice])
const selectLabel = t('choose', { keyPrefix: `join.${config.kind}` })
@@ -99,6 +119,7 @@ export const SelectToggleDevice = <T extends ToggleSource>({
<ToggleDevice
{...trackProps}
config={config}
variant={variant}
toggleButtonProps={{
...(hideMenu
? {
@@ -108,13 +129,13 @@ export const SelectToggleDevice = <T extends ToggleSource>({
}}
/>
{!hideMenu && (
<Menu variant={variant}>
<Menu variant={menuVariant}>
<Button
tooltip={selectLabel}
aria-label={selectLabel}
groupPosition="right"
square
variant={trackProps.enabled ? 'primaryDark' : 'error2'}
variant={trackProps.enabled ? variant : 'error2'}
>
<RiArrowDownSLine />
</Button>
@@ -128,7 +149,7 @@ export const SelectToggleDevice = <T extends ToggleSource>({
setActiveMediaDevice(value as string)
onActiveDeviceChange(value as string)
}}
variant={variant}
variant={menuVariant}
/>
</Menu>
)}
@@ -4,10 +4,14 @@ import { useTranslation } from 'react-i18next'
import { Crisp } from 'crisp-sdk-web'
import { useEffect, useState } from 'react'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useIsSupportEnabled } from '@/features/support/hooks/useSupport'
export const SupportToggle = ({ onPress, ...props }: ToggleButtonProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls' })
const [isOpened, setIsOpened] = useState($crisp.is('chat:opened'))
const [isOpened, setIsOpened] = useState(() => {
return window?.$crisp?.is?.('chat:opened') || false
})
useEffect(() => {
if (!Crisp) {
@@ -26,6 +30,12 @@ export const SupportToggle = ({ onPress, ...props }: ToggleButtonProps) => {
}
}, [])
const isSupportEnabled = useIsSupportEnabled()
if (!isSupportEnabled) {
return
}
return (
<ToggleButton
square
@@ -6,13 +6,19 @@ import { useTranslation } from 'react-i18next'
import { SelectToggleDeviceConfig } from './SelectToggleDevice'
import useLongPress from '@/features/shortcuts/useLongPress'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
import { useIsSpeaking, useLocalParticipant } from '@livekit/components-react'
import {
useIsSpeaking,
useLocalParticipant,
useMaybeRoomContext,
} from '@livekit/components-react'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
export type ToggleDeviceProps = {
enabled: boolean
toggle: () => void
config: SelectToggleDeviceConfig
variant?: NonNullable<ButtonRecipeProps>['variant']
toggleButtonProps?: Partial<ToggleButtonProps>
}
@@ -20,6 +26,7 @@ export const ToggleDevice = ({
config,
enabled,
toggle,
variant = 'primaryDark',
toggleButtonProps,
}: ToggleDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
@@ -51,17 +58,15 @@ export const ToggleDevice = ({
const Icon = enabled ? iconOn : iconOff
const { localParticipant } = useLocalParticipant()
const isSpeaking = useIsSpeaking(localParticipant)
if (kind === 'audioinput' && pushToTalk) {
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
const context = useMaybeRoomContext()
if (kind === 'audioinput' && pushToTalk && context) {
return <ActiveSpeakerWrapper />
}
return (
<ToggleButton
isSelected={!enabled}
variant={enabled ? 'primaryDark' : 'error2'}
variant={enabled ? variant : 'error2'}
shySelected
onPress={() => toggle()}
aria-label={toggleLabel}
@@ -73,3 +78,9 @@ export const ToggleDevice = ({
</ToggleButton>
)
}
const ActiveSpeakerWrapper = () => {
const { localParticipant } = useLocalParticipant()
const isSpeaking = useIsSpeaking(localParticipant)
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
}
@@ -7,7 +7,7 @@ import { css } from '@/styled-system/css'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
export const TranscriptToggle = ({
variant = 'primaryDark',
variant = 'primaryTextDark',
onPress,
...props
}: ToggleButtonProps) => {
@@ -0,0 +1,68 @@
// We use vendor prefix properties
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import { useState } from 'react'
export function useFullScreen() {
const getIsFullscreen = () => {
return !!(
document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement ||
document.msFullscreenElement
)
}
const [isFullscreenAvailable] = useState(
() =>
document.fullscreenEnabled ||
document.webkitFullscreenEnabled ||
document.mozFullScreenEnabled ||
document.msFullscreenEnabled
)
const enterFullscreen = async () => {
try {
const docEl = document.documentElement
if (docEl.requestFullscreen) {
await docEl.requestFullscreen()
} else if (docEl.webkitRequestFullscreen) {
await docEl.webkitRequestFullscreen()
} else if (docEl.msRequestFullscreen) {
await docEl.msRequestFullscreen()
}
} catch (error) {
console.error('Error entering fullscreen:', error)
}
}
const exitFullscreen = async () => {
try {
if (document.exitFullscreen) {
await document.exitFullscreen()
} else if (document.webkitExitFullscreen) {
await document.webkitExitFullscreen()
} else if (document.msExitFullscreen) {
await document.msExitFullscreen()
}
} catch (error) {
console.error('Error exiting fullscreen:', error)
}
}
const toggleFullScreen = async () => {
const isCurrentlyFullscreen = getIsFullscreen()
if (isCurrentlyFullscreen) {
await exitFullscreen()
} else {
await enterFullscreen()
}
}
return {
isCurrentlyFullscreen: getIsFullscreen(),
isFullscreenAvailable,
toggleFullScreen,
}
}
@@ -8,10 +8,8 @@ import { HandToggle } from '../../components/controls/HandToggle'
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
import { OptionsButton } from '../../components/controls/Options/OptionsButton'
import { StartMediaButton } from '../../components/controls/StartMediaButton'
import { ChatToggle } from '../../components/controls/ChatToggle'
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
import { SupportToggle } from '../../components/controls/SupportToggle'
import { TranscriptToggle } from '../../components/controls/TranscriptToggle'
import { MoreOptions } from './MoreOptions'
import { useRef } from 'react'
export function DesktopControlBar({
onDeviceError,
@@ -21,9 +19,11 @@ export function DesktopControlBar({
saveVideoInputDeviceId,
}: ControlBarAuxProps) {
const browserSupportsScreenSharing = supportsScreenSharing()
const desktopControlBarEl = useRef<HTMLDivElement>(null)
return (
<>
<div
ref={desktopControlBarEl}
className={css({
width: '100vw',
display: 'flex',
@@ -62,7 +62,7 @@ export function DesktopControlBar({
onActiveDeviceChange={(deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
variant="dark"
menuVariant="dark"
/>
<SelectToggleDevice
source={Track.Source.Camera}
@@ -73,7 +73,7 @@ export function DesktopControlBar({
onActiveDeviceChange={(deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
variant="dark"
menuVariant="dark"
/>
{browserSupportsScreenSharing && (
<ScreenShareToggle
@@ -87,21 +87,7 @@ export function DesktopControlBar({
<LeaveButton />
<StartMediaButton />
</div>
<div
className={css({
display: 'flex',
justifyContent: 'flex-end',
flex: '1 1 33%',
alignItems: 'center',
gap: '0.5rem',
paddingRight: '0.25rem',
})}
>
<ChatToggle />
<ParticipantsToggle />
<TranscriptToggle />
<SupportToggle />
</div>
<MoreOptions parentElement={desktopControlBarEl} />
</div>
</>
)
@@ -0,0 +1,87 @@
import { css } from '@/styled-system/css'
import { ChatToggle } from '../../components/controls/ChatToggle'
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
import { SupportToggle } from '../../components/controls/SupportToggle'
import { TranscriptToggle } from '../../components/controls/TranscriptToggle'
import { useSize } from '../../hooks/useResizeObserver'
import { useState, RefObject } from 'react'
import { Dialog, DialogTrigger, Popover } from 'react-aria-components'
import { Button } from '@/primitives'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
const CONTROL_BAR_BREAKPOINT = 750
const NavigationControls = ({ onPress }: Partial<ToggleButtonProps>) => (
<>
<ChatToggle onPress={onPress} />
<ParticipantsToggle onPress={onPress} />
<TranscriptToggle onPress={onPress} />
<SupportToggle onPress={onPress} />
</>
)
export const LateralMenu = () => {
const { t } = useTranslation('rooms')
const [isOpen, setIsOpen] = useState(false)
const handlePress = () => setIsOpen(!isOpen)
const handleClose = () => setIsOpen(false)
return (
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<Button
square
variant="secondaryDark"
aria-label={t('controls.moreOptions')}
tooltip={t('controls.moreOptions')}
onPress={handlePress}
>
{isOpen ? <RiArrowDownSLine /> : <RiArrowUpSLine />}
</Button>
<Popover>
<Dialog
className={css({
width: '65px',
backgroundColor: 'primaryDark.50',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
borderRadius: '4px',
paddingTop: '10px',
gap: '0.5rem',
})}
>
<NavigationControls onPress={handleClose} />
</Dialog>
</Popover>
</DialogTrigger>
)
}
export const MoreOptions = ({
parentElement,
}: {
parentElement: RefObject<HTMLDivElement>
}) => {
const { width: parentWidth } = useSize(parentElement)
return (
<div
className={css({
display: 'flex',
justifyContent: 'flex-end',
flex: '1 1 33%',
alignItems: 'center',
gap: '0.5rem',
paddingRight: '0.25rem',
})}
>
{parentWidth > CONTROL_BAR_BREAKPOINT ? (
<NavigationControls />
) : (
<LateralMenu />
)}
</div>
)
}
@@ -0,0 +1,53 @@
export enum ClientMessageType {
ROOM_CREATED = 'ROOM_CREATED',
}
export class SdkReverseClient {
/**
* IDEA: Use API Key. Must be based on some sort of credentials ? No needs for now as now security
* issues are plausible.
*/
static getAllowTargetOrigin() {
return '*'
}
static post(type: ClientMessageType, data: unknown = {}) {
window.parent.postMessage(
{
type,
data,
},
SdkReverseClient.getAllowTargetOrigin()
)
}
static broadcastAuthentication() {
const bc = new BroadcastChannel('APP_CHANNEL')
bc.postMessage({ type: 'AUTHENTICATED' })
/**
* This means the parent window has authenticated has successfully refetched user, then we can close the popup.
*/
bc.onmessage = (event) => {
if (event.data.type === 'AUTHENTICATED_ACK') {
window.close()
}
}
}
static waitForAuthenticationAck() {
return new Promise<void>((resolve) => {
const bc = new BroadcastChannel('APP_CHANNEL')
bc.onmessage = async (event) => {
if (event.data.type === 'AUTHENTICATED') {
resolve()
bc.postMessage({ type: 'AUTHENTICATED_ACK' })
}
}
})
}
static ensureAuth() {
// TODO: ...
}
}
@@ -0,0 +1,116 @@
import { Button } from '@/primitives/Button'
import { useTranslation } from 'react-i18next'
import { usePersistentUserChoices } from '@livekit/components-react'
import { useState } from 'react'
import { getRouteUrl } from '@/navigation/getRouteUrl'
import { css } from '@/styled-system/css'
import { RiCheckLine, RiFileCopyLine } from '@remixicon/react'
import { VisioIcon } from '@/assets/VisioIcon'
import { generateRoomId, useCreateRoom } from '../../rooms'
import { ClientMessageType, SdkReverseClient } from '../SdkReverseClient'
import { authUrl, useUser } from '@/features/auth'
export const SdkCreateButton = () => {
const { isLoggedIn, ...other } = useUser()
const { t } = useTranslation('sdk', { keyPrefix: 'createButton' })
const [roomUrl, setRoomUrl] = useState<string>()
const [isLoading, setIsLoading] = useState(false)
const {
userChoices: { username },
} = usePersistentUserChoices()
const { mutateAsync: createRoom } = useCreateRoom()
const startSSO = () => {
SdkReverseClient.waitForAuthenticationAck().then(async () => {
await other.refetch()
submitCreateRoom()
})
const params = `scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no,
width=400,height=900,left=100,top=100`
window.open(new URL('authenticate/', authUrl()).href, '', params)
}
const submitCreateRoom = () => {
setIsLoading(true)
setTimeout(() => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) => {
const roomUrlTmp = getRouteUrl('room', data.slug)
setRoomUrl(roomUrlTmp)
setIsLoading(false)
SdkReverseClient.post(ClientMessageType.ROOM_CREATED, {
url: roomUrlTmp,
})
})
}, 0)
}
const submit = () => {
if (isLoggedIn) {
submitCreateRoom()
} else {
startSSO()
}
}
return (
<div
className={css({
paddingTop: '3px',
paddingLeft: '3px',
})}
>
{roomUrl ? (
<RoomUrl roomUrl={roomUrl} />
) : (
<Button
variant="primaryDark"
aria-label={t('label')}
onPress={submit}
data-attr="sdk-create"
loading={isLoading}
icon={<VisioIcon />}
>
{t('label')}
</Button>
)}
</div>
)
}
const RoomUrl = ({ roomUrl }: { roomUrl: string }) => {
const [isCopied, setIsCopied] = useState(false)
const copy = () => {
navigator.clipboard.writeText(roomUrl!)
setIsCopied(true)
setTimeout(() => setIsCopied(false), 1000)
}
return (
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
})}
>
<span
className={css({
color: 'greyscale.600',
})}
>
{roomUrl}
</span>
<Button
variant={isCopied ? 'success' : 'quaternaryText'}
data-attr="sdk-create-copy"
onPress={copy}
square
>
{isCopied ? <RiCheckLine /> : <RiFileCopyLine />}
</Button>
</div>
)
}
@@ -1,6 +1,7 @@
import { useEffect } from 'react'
import { Crisp } from 'crisp-sdk-web'
import { ApiUser } from '@/features/auth/api/ApiUser'
import { useConfig } from '@/api/useConfig'
export const initializeSupportSession = (user: ApiUser) => {
if (!Crisp.isCrispInjected()) return
@@ -29,3 +30,8 @@ export const useSupport = ({ id }: useSupportProps) => {
return null
}
export const useIsSupportEnabled = () => {
const { data } = useConfig()
return !!data?.support?.id
}
+13 -3
View File
@@ -24,7 +24,12 @@
"toggleOff": "",
"toggleOn": "",
"usernameHint": "",
"usernameLabel": ""
"usernameLabel": "",
"errors": {
"usernameEmpty": ""
},
"cameraDisabled": "",
"cameraStarting": ""
},
"leaveRoomPrompt": "",
"shareDialog": {
@@ -75,7 +80,8 @@
"open": "",
"closed": ""
},
"support": ""
"support": "",
"moreOptions": ""
},
"options": {
"buttonLabel": "",
@@ -84,7 +90,11 @@
"settings": "",
"username": "",
"effects": "",
"switchCamera": ""
"switchCamera": "",
"fullscreen": {
"enter": "",
"exit": ""
}
}
},
"effects": {
+14 -4
View File
@@ -18,13 +18,18 @@
"enable": "Enable microphone",
"label": "Microphone"
},
"heading": "Verify your settings",
"heading": "Join the meeting",
"joinLabel": "Join",
"joinMeeting": "Join meeting",
"toggleOff": "Click to turn off",
"toggleOn": "Click to turn on",
"usernameHint": "Shown to other participants",
"usernameLabel": "Your name"
"usernameLabel": "Your name",
"errors": {
"usernameEmpty": "Your name cannot be empty"
},
"cameraDisabled": "Camera is disabled.",
"cameraStarting": "Camera is starting."
},
"leaveRoomPrompt": "This will make you leave the meeting.",
"shareDialog": {
@@ -74,7 +79,8 @@
"open": "Hide AI assistant",
"closed": "Show AI assistant"
},
"support": "Support"
"support": "Support",
"moreOptions": "More options"
},
"options": {
"buttonLabel": "More Options",
@@ -83,7 +89,11 @@
"settings": "Settings",
"username": "Update Your Name",
"effects": "Apply effects",
"switchCamera": "Switch camera"
"switchCamera": "Switch camera",
"fullscreen": {
"enter": "Fullscreen",
"exit": "Exit fullscreen mode"
}
}
},
"effects": {
+14 -4
View File
@@ -18,13 +18,18 @@
"enable": "Activer le micro",
"label": "Microphone"
},
"heading": "Vérifiez vos paramètres",
"heading": "Rejoindre la réunion",
"joinLabel": "Rejoindre",
"joinMeeting": "Rejoindre la réjoindre",
"toggleOff": "Cliquez pour désactiver",
"toggleOn": "Cliquez pour activer",
"usernameHint": "Affiché aux autres participants",
"usernameLabel": "Votre nom"
"usernameLabel": "Votre nom",
"errors": {
"usernameEmpty": "Votre nom ne peut pas être vide"
},
"cameraDisabled": "La caméra est désactivée.",
"cameraStarting": "La caméra va démarrer."
},
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
"shareDialog": {
@@ -74,7 +79,8 @@
"open": "Masquer l'assistant IA",
"closed": "Afficher l'assistant IA"
},
"support": "Support"
"support": "Support",
"moreOptions": "Plus d'options"
},
"options": {
"buttonLabel": "Plus d'options",
@@ -83,7 +89,11 @@
"settings": "Paramètres",
"username": "Choisir votre nom",
"effects": "Appliquer des effets",
"switchCamera": "Changer de caméra"
"switchCamera": "Changer de caméra",
"fullscreen": {
"enter": "Plein écran",
"exit": "Quitter le mode plein écran"
}
}
},
"effects": {
+5
View File
@@ -0,0 +1,5 @@
{
"createButton": {
"label": "Créer un lien Visio"
}
}
+8 -2
View File
@@ -6,12 +6,15 @@ import { type RecipeVariantProps } from '@/styled-system/css'
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
import { ReactNode } from 'react'
import { Loader } from './Loader'
export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
RACButtonsProps &
TooltipWrapperProps & {
// Use tooltip as description below the button.
description?: boolean
} & {
icon?: ReactNode
}
export const Button = ({
@@ -20,13 +23,16 @@ export const Button = ({
...props
}: ButtonProps) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
const { className, ...remainingComponentProps } = componentProps
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACButton
className={buttonRecipe(variantProps)}
{...(componentProps as RACButtonsProps)}
className={[buttonRecipe(variantProps), className].join(' ')}
{...(remainingComponentProps as RACButtonsProps)}
>
{!props.loading && props.icon}
{props.loading && <Loader />}
{componentProps.children as ReactNode}
{props.description && <span>{tooltip}</span>}
</RACButton>
+28 -6
View File
@@ -25,11 +25,31 @@ const FieldWrapper = styled('div', {
marginBottom: 'textfield',
minWidth: 0,
},
variants: {
noMargin: {
true: {
marginBottom: 0,
},
},
fullWidth: {
true: {
width: '100%',
},
},
},
})
const StyledLabel = styled(Label, {
base: {
display: 'block',
fontSize: '0.75rem',
},
variants: {
center: {
true: {
textAlign: 'center',
},
},
},
})
@@ -80,6 +100,8 @@ type FieldProps<T extends object> = (
) & {
label: string
description?: string
wrapperProps?: React.ComponentProps<typeof FieldWrapper>
labelProps?: React.ComponentProps<typeof StyledLabel>
}
/**
@@ -104,7 +126,7 @@ export const Field = <T extends object>({
}: FieldProps<T>) => {
const LabelAndDescription = (
<>
<StyledLabel>{label}</StyledLabel>
<StyledLabel {...props.labelProps}>{label}</StyledLabel>
<FieldDescription slot="description">{description}</FieldDescription>
</>
)
@@ -118,7 +140,7 @@ export const Field = <T extends object>({
if (type === 'text') {
return (
<FieldWrapper>
<FieldWrapper {...props.wrapperProps}>
<RACTextField
validate={validate as unknown as TextFieldProps['validate']}
{...(props as PartialTextFieldProps)}
@@ -133,7 +155,7 @@ export const Field = <T extends object>({
if (type === 'checkbox') {
return (
<FieldWrapper>
<FieldWrapper {...props.wrapperProps}>
<Checkbox
validate={validate as unknown as CheckboxProps['validate']}
description={description}
@@ -147,7 +169,7 @@ export const Field = <T extends object>({
if (type === 'checkboxGroup') {
return (
<FieldWrapper>
<FieldWrapper {...props.wrapperProps}>
<CheckboxGroup
validate={validate as unknown as CheckboxGroupProps['validate']}
{...(props as PartialCheckboxGroupProps)}
@@ -170,7 +192,7 @@ export const Field = <T extends object>({
if (type === 'radioGroup') {
return (
<FieldWrapper>
<FieldWrapper {...props.wrapperProps}>
<RadioGroup
validate={validate as unknown as RadioGroupProps['validate']}
{...(props as PartialRadioGroupProps)}
@@ -189,7 +211,7 @@ export const Field = <T extends object>({
if (type === 'select') {
return (
<FieldWrapper>
<FieldWrapper {...props.wrapperProps}>
<Select
validate={validate as unknown as SelectProps<T>['validate']}
{...(props as PartialSelectProps<T>)}
+4 -1
View File
@@ -3,6 +3,7 @@ import { Form as RACForm, type FormProps } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { HStack } from '@/styled-system/jsx'
import { Button, useCloseDialog } from '@/primitives'
import { ButtonProps } from '@/primitives/Button'
/**
* From wrapper that exposes form data on submit and adds submit/cancel buttons
@@ -13,6 +14,7 @@ import { Button, useCloseDialog } from '@/primitives'
export const Form = ({
onSubmit,
submitLabel,
submitButtonProps,
withCancelButton = true,
onCancelButtonPress,
children,
@@ -25,6 +27,7 @@ export const Form = ({
event: FormEvent<HTMLFormElement>
) => void
submitLabel: string
submitButtonProps?: ButtonProps
withCancelButton?: boolean
onCancelButtonPress?: () => void
}) => {
@@ -47,7 +50,7 @@ export const Form = ({
>
{children}
<HStack gap="gutter">
<Button type="submit" variant="primary">
<Button type="submit" variant="primary" {...submitButtonProps}>
{submitLabel}
</Button>
{!!onCancel && (
+4 -2
View File
@@ -1,10 +1,12 @@
import { Text } from './Text'
import { Text, TextProps } from './Text'
export const H = ({
children,
lvl,
...props
}: React.HTMLAttributes<HTMLHeadingElement> & { lvl: 1 | 2 | 3 }) => {
}: React.HTMLAttributes<HTMLHeadingElement> & {
lvl: 1 | 2 | 3
} & TextProps) => {
const tag = `h${lvl}` as const
return (
<Text as={tag} variant={tag} {...props}>
+45
View File
@@ -0,0 +1,45 @@
import { cva } from '@/styled-system/css'
const loader = cva({
base: {
borderRadius: '50%',
position: 'relative',
animation: 'rotate 1s linear infinite',
'&:before, &:after': {
content: '""',
boxSizing: 'border-box',
position: 'absolute',
inset: '0',
borderRadius: '50%',
borderStyle: 'solid',
borderColor: 'white',
},
_before: {
animation: 'prixClipFix 2s linear infinite',
},
_after: {
borderColor: 'white',
animation:
'prixClipFix 2s linear infinite, rotate 0.5s linear infinite reverse',
inset: 6,
},
},
variants: {
size: {
small: {
width: '24px',
height: '24px',
'&:before, &:after': {
borderWidth: '2px',
},
},
},
},
defaultVariants: {
size: 'small',
},
})
export const Loader = () => {
return <div className={loader()}></div>
}
@@ -8,6 +8,7 @@ export const buttonRecipe = cva({
transition: 'background 200ms, outline 200ms, border-color 200ms',
cursor: 'pointer',
border: '1px solid transparent',
gap: '0.5rem',
},
variants: {
size: {
@@ -109,6 +110,21 @@ export const buttonRecipe = cva({
color: 'primaryDark.100 !important',
},
},
secondaryDark: {
backgroundColor: 'primaryDark.50',
color: 'white',
'&[data-pressed]': {
backgroundColor: 'primaryDark.200',
},
'&[data-hovered]': {
backgroundColor: 'primaryDark.100',
color: 'white',
},
'&[data-selected]': {
backgroundColor: 'primaryDark.700 !important',
color: 'primaryDark.100 !important',
},
},
primaryTextDark: {
backgroundColor: 'transparent',
color: 'white',
@@ -124,6 +140,18 @@ export const buttonRecipe = cva({
color: 'primaryDark.100',
},
},
quaternaryText: {
backgroundColor: 'transparent',
color: 'greyscale.600',
'&[data-hovered]': {
backgroundColor: 'greyscale.100',
color: 'greyscale.700',
},
'&[data-pressed]': {
backgroundColor: 'greyscale.100',
color: 'greyscale.700',
},
},
greyscale: {
backgroundColor: 'transparent',
color: 'greyscale.400',
@@ -214,6 +242,9 @@ export const buttonRecipe = cva({
width: 'full',
},
},
loading: {
true: {},
},
// some toggle buttons make more sense without a "pushed button" style when selected because their content changes to mark the state
shySelected: {
true: {},
+1 -1
View File
@@ -2,7 +2,7 @@ import { FeedbackRoute, RoomRoute, roomIdPattern } from '@/features/rooms'
import { HomeRoute } from '@/features/home'
export const routes: Record<
'home' | 'room' | 'feedback',
'home' | 'room' | 'feedback' | 'sdkCreateButton',
{
name: RouteName
path: RegExp | string
+31
View File
@@ -38,3 +38,34 @@ body:has(.lk-video-conference) #crisp-chatbox > div > a {
transform: translateY(0);
}
}
/**
* Loader animations.
*/
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes prixClipFix {
0% {
clip-path: polygon(50% 50%, 0 0, 0 0, 0 0, 0 0, 0 0);
}
25% {
clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 0, 100% 0, 100% 0);
}
50% {
clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 100%, 100% 100%, 100% 100%);
}
75% {
clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 100%);
}
100% {
clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0);
}
}
+1
View File
@@ -10,6 +10,7 @@ export default defineConfig(({ mode }) => {
server: {
port: parseInt(env.VITE_PORT) || 3000,
host: env.VITE_HOST || 'localhost',
allowedHosts: ['.nip.io'],
},
}
})
+1 -1
View File
@@ -14,7 +14,7 @@
"build": "npm run build-mjml-to-html && npm run build-html-to-plain-text"
},
"volta": {
"node": "22.13.0"
"node": "22.13.1"
},
"repository": "https://github.com/numerique-gouv/meet",
"author": "DINUM",
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+50
View File
@@ -0,0 +1,50 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default tseslint.config({
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
- Optionally add `...tseslint.configs.stylisticTypeChecked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
```js
// eslint.config.js
import react from 'eslint-plugin-react'
export default tseslint.config({
// Set the react version
settings: { react: { version: '18.3' } },
plugins: {
// Add the react plugin
react,
},
rules: {
// other rules...
// Enable its recommended rules
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
},
})
```
+28
View File
@@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Visio SDK Demo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3231
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "consumer",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@eslint/js": "9.17.0",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "4.3.4",
"eslint": "8.57.0",
"eslint-plugin-react-hooks": "5.1.0",
"eslint-plugin-react-refresh": "0.4.18",
"globals": "15.14.0",
"typescript": "5.7.3",
"typescript-eslint": "8.18.2",
"vite": "6.0.11"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+5
View File
@@ -0,0 +1,5 @@
#root {
margin: 0 auto;
text-align: center;
}
+42
View File
@@ -0,0 +1,42 @@
import { VisioCreateButton } from "@gouvfr-lasuite/visio-sdk";
import "./App.css";
import { useState } from "react";
function App() {
const [roomUrl, setRoomUrl] = useState("");
return (
<form className="form">
<div className="header">
<h2>Create event</h2>
<span>Visio demo app</span>
</div>
<div className="group">
<label>Subject</label>
<input type="text" />
</div>
<div className="group">
<label>Place</label>
<input type="text" />
</div>
<div className="group">
<label>Visioconference</label>
<VisioCreateButton onRoomCreated={setRoomUrl} />
</div>
<div className="group">
<label>Description</label>
<textarea />
</div>
<button
type="button"
onClick={() => {
alert("Room url: " + roomUrl);
}}
>
Create
</button>
</form>
);
}
export default App;
+74
View File
@@ -0,0 +1,74 @@
* {
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
box-sizing: border-box;
color: rgb(9, 9, 11);
}
body {
background-color: oklch(0.985 0 0);
display: flex;
justify-content: center;
padding-top: 2rem;
}
h2 {
margin: 0;
}
span {
font-size: 14px;
color: oklch(0.372 0.044 257.287);
}
.form {
display: flex;
flex-direction: column;
width: 500px;
gap: 1rem;
border: 1px solid #e5e7eb;
background-color: white;
border-radius: 1rem;
padding: 3rem;
}
.header {
margin-bottom: 2rem;
}
.group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
label {
color: rgb(9, 9, 11);
font-weight: 500;
font-size: 14px;
}
input,
textarea {
border: 1px rgb(228, 228, 231) solid;
border-radius: 6px;
height: 36px;
width: 100%;
padding: 0.75rem;
}
textarea {
height: 200px;
}
button {
background-color: rgb(45, 45, 70);
color: white;
height: 46px;
border-radius: 4px;
font-size: 16px;
font-weight: 300;
cursor: pointer;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})
+8
View File
@@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.0.5/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
+1
View File
@@ -0,0 +1 @@
VITE_VISIO_SDK_URL=https://visio.numerique.gouv.fr/sdk
+21
View File
@@ -0,0 +1,21 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"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",
plugins: ["react-refresh"],
rules: {
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
};
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+5
View File
@@ -0,0 +1,5 @@
{
"semi": false,
"trailingComma": "es5",
"singleQuote": true
}
+7
View File
@@ -0,0 +1,7 @@
# @gouvfr-lasuite/visio-sdk
## 0.0.1
### Major Changes
- Create library with VisioCreateButton
+17
View File
@@ -0,0 +1,17 @@
# Contributing
## Release
1. Run `npm run build`
2. Update CHANGELOG.md according to the Major / Minor / Patch semver convention.
3. Based on semver upgrade the package version in `package.json`.
4. Commit the changes and create a PR named "🔖(release) version packages".
5. Run `npx @changesets/cli publish`. It will publish the new version of the package to NPM and create a git tag.
6. Run `git push origin tag @gouvfr-lasuite/visio-sdk@<VERSION>`
7. Tell everyone 🎉 !
+30
View File
@@ -0,0 +1,30 @@
<div align="center">
# 🎥 Visio SDK
**Easily open a widget to browse user files on any of your project**
<img src="docs/demo.gif" alt="" />
**As simple as**
</div>
```ts
import { VisioCreateButton } from "@gouvfr-lasuite/visio-sdk";
function App() {
const [roomUrl, setRoomUrl] = useState();
return <VisioCreateButton onRoomCreated={setRoomUrl} />;
}
```
## Installation
To install, you can use npm or yarn:
```
$ npm install --save @gouvfr-lasuite/visio-sdk
$ yarn add @gouvfr-lasuite/visio-sdk
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 708 KiB

+52
View File
@@ -0,0 +1,52 @@
{
"name": "@gouvfr-lasuite/visio-sdk",
"private": false,
"publishConfig": {
"access": "public"
},
"version": "0.0.1",
"type": "module",
"main": "./dist/visio-sdk.umd.cjs",
"module": "./dist/visio-sdk.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/visio-sdk.js",
"require": "./dist/visio-sdk.umd.cjs"
}
},
"files": [
"dist/"
],
"scripts": {
"dev": "vite build -w --mode development",
"build": "tsc -b && vite build",
"lint": "eslint . --ext ts,tsx",
"format": "prettier --write ./src",
"check": "prettier --check ./src"
},
"dependencies": {
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@tanstack/eslint-plugin-query": "5.64.2",
"@types/node": "20.14.2",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"@typescript-eslint/eslint-plugin": "7.15.0",
"@typescript-eslint/parser": "7.15.0",
"@vitejs/plugin-react-swc": "3.5.0",
"eslint": "8.57.0",
"eslint-config-prettier": "10.0.1",
"eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-react-refresh": "0.4.7",
"eslint-plugin-jsx-a11y": "6.10.2",
"sass": "1.83.4",
"prettier": "3.4.2",
"typescript": "5.4.5",
"vite": "5.3.4",
"vite-plugin-dts": "3.9.1"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+3
View File
@@ -0,0 +1,3 @@
export const DEFAULT_CONFIG = {
url: import.meta.env.VITE_VISIO_SDK_URL,
}
+7
View File
@@ -0,0 +1,7 @@
import { DEFAULT_CONFIG } from '@/Config'
export type ConfigType = typeof DEFAULT_CONFIG
export enum ClientMessageType {
ROOM_CREATED = 'ROOM_CREATED',
}
@@ -0,0 +1,41 @@
import { DEFAULT_CONFIG } from '@/Config'
import { ClientMessageType } from '@/Types'
import { useEffect } from 'react'
export const VisioCreateButton = ({
onRoomCreated,
}: {
onRoomCreated: (roomUrl: string) => void
}) => {
useEffect(() => {
const onMessage = (event: MessageEvent) => {
// Make sure it is the correct origin.
if (event.origin !== new URL(DEFAULT_CONFIG.url).origin) {
return
}
if (event.data.type === ClientMessageType.ROOM_CREATED) {
const data = event.data.data
const roomUrl = data.url
onRoomCreated(roomUrl)
}
}
window.addEventListener('message', onMessage)
return () => {
window.removeEventListener('message', onMessage)
}
}, [onRoomCreated])
return (
// eslint-disable-next-line jsx-a11y/iframe-has-title
<iframe
allow="clipboard-read; clipboard-write"
src={DEFAULT_CONFIG.url + '/create-button'}
style={{
width: '100%',
height: '52px',
border: 'none',
}}
></iframe>
)
}
+3
View File
@@ -0,0 +1,3 @@
/* eslint-disable react-refresh/only-export-components */
export * from '@/Types'
export * from '@/create/VisioCreateButton'
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_VISIO_SDK_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+36
View File
@@ -0,0 +1,36 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable", "WebWorker"],
"module": "esnext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Local */
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"baseUrl": "./src",
"paths": {
"@/*": ["./*"]
}
},
"include": ["src"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}
+35
View File
@@ -0,0 +1,35 @@
import path, { resolve } from "path";
import react from "@vitejs/plugin-react-swc";
import { AliasOptions, defineConfig } from "vite";
import dts from "vite-plugin-dts";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
dts({ tsconfigPath: "./tsconfig.app.json", rollupTypes: true }),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
} as AliasOptions,
},
build: {
lib: {
entry: resolve(__dirname, "src/index.tsx"),
name: "visio-sdk",
fileName: "visio-sdk",
},
rollupOptions: {
external: ["react", "react-dom"],
output: {
globals: {
react: "React",
"react-dom": "ReactDOM",
},
},
},
copyPublicDir: false,
},
});
+7603
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
{
"name": "sdk",
"version": "0.0.0",
"author": "",
"license": "ISC",
"description": "",
"workspaces": [
"./library",
"./consumer"
]
}
+2 -2
View File
@@ -10,14 +10,14 @@ dependencies = [
"celery==5.4.0",
"redis==5.2.1",
"minio==7.2.15",
"openai==1.59.8",
"openai==1.60.2",
"requests==2.32.3",
"sentry-sdk[fastapi, celery]==2.20.0",
]
[project.optional-dependencies]
dev = [
"ruff==0.9.2",
"ruff==0.9.3",
]
[build-system]