diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index 92e50db1..67318399 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -17,6 +17,7 @@ "@tanstack/react-query": "5.81.5", "@timephy/rnnoise-wasm": "1.0.0", "crisp-sdk-web": "1.0.25", + "derive-valtio": "0.2.0", "hoofd": "1.7.3", "humanize-duration": "3.33.0", "i18next": "25.3.1", @@ -5294,6 +5295,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/derive-valtio": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/derive-valtio/-/derive-valtio-0.2.0.tgz", + "integrity": "sha512-6slhaFHtfaL3t5dLYaQt6s4G2xZymhu0Ktdl7OMeVk8+46RgR8ft6FL0Tr4F31W+yPH03nJe1SSP4JFy2hSMRA==", + "license": "MIT", + "peerDependencies": { + "valtio": ">=2.0.0-rc.0" + } + }, "node_modules/detect-libc": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", diff --git a/src/frontend/package.json b/src/frontend/package.json index 27828e21..d3318a4b 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -22,6 +22,7 @@ "@tanstack/react-query": "5.81.5", "@timephy/rnnoise-wasm": "1.0.0", "crisp-sdk-web": "1.0.25", + "derive-valtio": "0.2.0", "hoofd": "1.7.3", "humanize-duration": "3.33.0", "i18next": "25.3.1", diff --git a/src/frontend/public/assets/camera_mic_permission.svg b/src/frontend/public/assets/camera_mic_permission.svg new file mode 100644 index 00000000..ea7af92f --- /dev/null +++ b/src/frontend/public/assets/camera_mic_permission.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/frontend/src/features/rooms/components/Permissions.tsx b/src/frontend/src/features/rooms/components/Permissions.tsx index cda2f7ef..63b9bb9b 100644 --- a/src/frontend/src/features/rooms/components/Permissions.tsx +++ b/src/frontend/src/features/rooms/components/Permissions.tsx @@ -1,4 +1,12 @@ import { useWatchPermissions } from '@/features/rooms/hooks/useWatchPermissions' +import { css } from '@/styled-system/css' +import { Dialog, H } from '@/primitives' +import { RiEqualizer2Line } from '@remixicon/react' +import { useEffect, useMemo } from 'react' +import { useSnapshot } from 'valtio' +import { permissionsStore } from '@/stores/permissions' +import { useTranslation } from 'react-i18next' +import { injectIconIntoTranslation } from '@/utils/translation' /** * Singleton component - ensures permissions sync runs only once across the app. @@ -6,6 +14,90 @@ import { useWatchPermissions } from '@/features/rooms/hooks/useWatchPermissions' * Multiple instances may cause unexpected behavior or performance issues. */ export const Permissions = () => { + const { t } = useTranslation('rooms', { keyPrefix: 'permissionErrorDialog' }) + useWatchPermissions() - return null + + const permissions = useSnapshot(permissionsStore) + + const permissionLabel = useMemo(() => { + if (permissions.isMicrophoneDenied && permissions.isCameraDenied) { + return 'cameraAndMicrophone' + } else if (permissions.isCameraDenied) { + return 'camera' + } else if (permissions.isMicrophoneDenied) { + return 'microphone' + } else { + return 'default' + } + }, [permissions]) + + const [descriptionBeforeIcon, descriptionAfterIcon] = + injectIconIntoTranslation(t('body.openMenu')) + + useEffect(() => { + if ( + permissions.isPermissionDialogOpen && + permissions.isCameraGranted && + permissions.isMicrophoneGranted + ) { + permissionsStore.isPermissionDialogOpen = false + } + }, [permissions]) + + const appTitle = `${import.meta.env.VITE_APP_TITLE}` + + return ( + (permissionsStore.isPermissionDialogOpen = false)} + > +
+ +
+ + {t(`heading.${permissionLabel}`, { + appTitle, + })} + +
    +
  1. + {descriptionBeforeIcon} + + + + {descriptionAfterIcon} +
  2. +
  3. {t(`body.details.${permissionLabel}`)}
  4. +
+
+
+
+ ) } diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json index 0ad2ad5e..638c74e5 100644 --- a/src/frontend/src/locales/de/rooms.json +++ b/src/frontend/src/locales/de/rooms.json @@ -77,6 +77,23 @@ }, "close": "OK" }, + "permissionErrorDialog": { + "heading": { + "camera": "{{appTitle}} darf Ihre Kamera nicht verwenden", + "microphone": "{{appTitle}} darf Ihr Mikrofon nicht verwenden", + "cameraAndMicrophone": "{{appTitle}} darf weder Ihr Mikrofon noch Ihre Kamera verwenden", + "default": "{{appTitle}} hat keine Berechtigung für bestimmte Zugriffe" + }, + "body": { + "openMenu": "Klicken Sie auf das Einstellungen-Symbol ICON_PLACEHOLDER in der Adressleiste Ihres Browsers", + "details": { + "camera": "Zugriff auf die Kamera erlauben", + "microphone": "Zugriff auf das Mikrofon erlauben", + "cameraAndMicrophone": "Zugriff auf Kamera und Mikrofon erlauben", + "default": "Aktivieren Sie die erforderlichen Berechtigungen." + } + } + }, "error": { "createRoom": { "heading": "Authentifizierung erforderlich", diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index 4653c8ab..eb80028f 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -77,6 +77,23 @@ }, "close": "OK" }, + "permissionErrorDialog": { + "heading": { + "camera": "{{appTitle}} is not allowed to use your camera", + "microphone": "{{appTitle}} is not allowed to use your microphone", + "cameraAndMicrophone": "{{appTitle}} is not allowed to use your microphone or camera", + "default": "{{appTitle}} is not allowed to use certain permissions" + }, + "body": { + "openMenu": "Click on the settings icon ICON_PLACEHOLDER in your browser’s address bar", + "details": { + "camera": "Allow access to the camera", + "microphone": "Allow access to the microphone", + "cameraAndMicrophone": "Allow access to the camera and microphone", + "default": "Enable the necessary permissions." + } + } + }, "error": { "createRoom": { "heading": "Authentication Required", diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index 032ba0ae..74b65dde 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -77,6 +77,23 @@ }, "close": "OK" }, + "permissionErrorDialog": { + "heading": { + "camera": "{{appTitle}} n'est pas autorisé à utiliser votre caméra", + "microphone": "{{appTitle}} n'est pas autorisé à utiliser votre micro", + "cameraAndMicrophone": "{{appTitle}} n'est pas autorisé à utiliser votre micro ni votre caméra", + "default": "{{appTitle}} n'est pas autorisé à utiliser certaines fonctionnalités nécessaires." + }, + "body": { + "openMenu": "Cliquez sur l'icône des paramètres ICON_PLACEHOLDER dans la barre d'adresse de votre navigateur", + "details": { + "camera": "Autorisez l'accès à la caméra", + "microphone": "Autorisez l'accès au microphone", + "cameraAndMicrophone": "Autorisez l'accès à la caméra et au microphone", + "default": "Activez les autorisations nécessaires." + } + } + }, "error": { "createRoom": { "heading": "Authentification requise", diff --git a/src/frontend/src/locales/nl/rooms.json b/src/frontend/src/locales/nl/rooms.json index 5d64a045..ef65b2fa 100644 --- a/src/frontend/src/locales/nl/rooms.json +++ b/src/frontend/src/locales/nl/rooms.json @@ -77,6 +77,23 @@ }, "close": "OK" }, + "permissionErrorDialog": { + "heading": { + "camera": "{{appTitle}} mag uw camera niet gebruiken", + "microphone": "{{appTitle}} mag uw microfoon niet gebruiken", + "cameraAndMicrophone": "{{appTitle}} mag uw microfoon en camera niet gebruiken", + "default": "{{appTitle}} heeft geen toestemming voor bepaalde rechten" + }, + "body": { + "openMenu": "Klik op het instellingenpictogram ICON_PLACEHOLDER in de adresbalk van uw browser", + "details": { + "camera": "Toegang tot de camera toestaan", + "microphone": "Toegang tot de microfoon toestaan", + "cameraAndMicrophone": "Toegang tot camera en microfoon toestaan", + "default": "Schakel de vereiste machtigingen in." + } + } + }, "error": { "createRoom": { "heading": "Verificatie vereist", diff --git a/src/frontend/src/stores/permissions.ts b/src/frontend/src/stores/permissions.ts index a91653ef..32641c11 100644 --- a/src/frontend/src/stores/permissions.ts +++ b/src/frontend/src/stores/permissions.ts @@ -1,4 +1,5 @@ import { proxy } from 'valtio' +import { derive } from 'derive-valtio' type PermissionState = | undefined @@ -11,10 +12,27 @@ type State = { cameraPermission: PermissionState microphonePermission: PermissionState isLoading: boolean + isPermissionDialogOpen: boolean } export const permissionsStore = proxy({ cameraPermission: undefined, microphonePermission: undefined, isLoading: true, + isPermissionDialogOpen: false, }) + +derive( + { + isCameraGranted: (get) => + get(permissionsStore).cameraPermission == 'granted', + isMicrophoneGranted: (get) => + get(permissionsStore).microphonePermission == 'granted', + isCameraDenied: (get) => get(permissionsStore).cameraPermission == 'denied', + isMicrophoneDenied: (get) => + get(permissionsStore).microphonePermission == 'denied', + }, + { + proxy: permissionsStore, + } +) diff --git a/src/frontend/src/utils/translation.ts b/src/frontend/src/utils/translation.ts new file mode 100644 index 00000000..fe060100 --- /dev/null +++ b/src/frontend/src/utils/translation.ts @@ -0,0 +1,14 @@ +/** + * FRAGILE: Splits translated text on placeholder to inject icons inline. + * + * Fragile because: + * - Relies on exact string matching - typos break it silently + * - Translators may accidentally modify/remove placeholders + * - No validation or error handling + */ +export const injectIconIntoTranslation = ( + translation: string, + placeholder: string = 'ICON_PLACEHOLDER' +) => { + return translation.split(placeholder) +}