From 242e7cb14881d3919e6cf2c1ba015ad52af0c229 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Thu, 28 Aug 2025 18:35:23 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=B8(frontend)=20add=20notifications=20?= =?UTF-8?q?when=20admin=20removes=20participant=20permissions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Send notification to participants when admin revokes their camera, microphone, or screenshare permissions so they understand why their media suddenly stopped. Improves user experience by providing clear feedback about permission changes instead of leaving users confused about unexpected media interruptions during meetings. --- .../notifications/MainNotificationToast.tsx | 13 +++++ .../notifications/NotificationPayload.ts | 1 + .../notifications/NotificationType.ts | 1 + .../components/ToastPermissionsRemoved.tsx | 49 +++++++++++++++++++ .../notifications/components/ToastRegion.tsx | 6 +++ .../livekit/hooks/usePublishSourcesManager.ts | 26 ++++++++++ .../src/locales/de/notifications.json | 5 ++ .../src/locales/en/notifications.json | 5 ++ .../src/locales/fr/notifications.json | 5 ++ .../src/locales/nl/notifications.json | 5 ++ 10 files changed, 116 insertions(+) create mode 100644 src/frontend/src/features/notifications/components/ToastPermissionsRemoved.tsx diff --git a/src/frontend/src/features/notifications/MainNotificationToast.tsx b/src/frontend/src/features/notifications/MainNotificationToast.tsx index 0583278b..67724148 100644 --- a/src/frontend/src/features/notifications/MainNotificationToast.tsx +++ b/src/frontend/src/features/notifications/MainNotificationToast.tsx @@ -104,6 +104,19 @@ export const MainNotificationToast = () => { { timeout: NotificationDuration.ALERT } ) break + case NotificationType.PermissionsRemoved: { + const removedSources = notification?.data?.removedSources + if (!removedSources?.length) break + toastQueue.add( + { + participant, + type: notification.type, + removedSources: removedSources, + }, + { timeout: NotificationDuration.ALERT } + ) + break + } default: return } diff --git a/src/frontend/src/features/notifications/NotificationPayload.ts b/src/frontend/src/features/notifications/NotificationPayload.ts index 3ca55989..5c5a3a29 100644 --- a/src/frontend/src/features/notifications/NotificationPayload.ts +++ b/src/frontend/src/features/notifications/NotificationPayload.ts @@ -4,5 +4,6 @@ export interface NotificationPayload { type: NotificationType data?: { emoji?: string + removedSources?: string[] } } diff --git a/src/frontend/src/features/notifications/NotificationType.ts b/src/frontend/src/features/notifications/NotificationType.ts index 827a2323..58ea6622 100644 --- a/src/frontend/src/features/notifications/NotificationType.ts +++ b/src/frontend/src/features/notifications/NotificationType.ts @@ -13,4 +13,5 @@ export enum NotificationType { ScreenRecordingStopped = 'screenRecordingStopped', ScreenRecordingLimitReached = 'screenRecordingLimitReached', RecordingSaving = 'recordingSaving', + PermissionsRemoved = 'permissionsRemoved', } diff --git a/src/frontend/src/features/notifications/components/ToastPermissionsRemoved.tsx b/src/frontend/src/features/notifications/components/ToastPermissionsRemoved.tsx new file mode 100644 index 00000000..ac5002d3 --- /dev/null +++ b/src/frontend/src/features/notifications/components/ToastPermissionsRemoved.tsx @@ -0,0 +1,49 @@ +import { useToast } from '@react-aria/toast' +import { useMemo, useRef } from 'react' + +import { StyledToastContainer, ToastProps } from './Toast' +import { HStack } from '@/styled-system/jsx' +import { useTranslation } from 'react-i18next' + +export function ToastPermissionsRemoved({ state, ...props }: ToastProps) { + const { t } = useTranslation('notifications', { + keyPrefix: 'permissionsRemoved', + }) + const ref = useRef(null) + const { toastProps, contentProps } = useToast(props, state, ref) + const participant = props.toast.content.participant + + const key = useMemo(() => { + const sources = props.toast.content.removedSources + + if (!Array.isArray(sources) || sources.length === 0) { + return undefined + } + + if (sources.length === 1) { + return sources[0] + } + + if (sources.length === 2 && sources.includes('screen_share')) { + return 'screen_share' + } + + return undefined + }, [props.toast.content.removedSources]) + + if (!participant || !key) return null + + return ( + + + {t(key)} + + + ) +} diff --git a/src/frontend/src/features/notifications/components/ToastRegion.tsx b/src/frontend/src/features/notifications/components/ToastRegion.tsx index 05066c9d..24c724a0 100644 --- a/src/frontend/src/features/notifications/components/ToastRegion.tsx +++ b/src/frontend/src/features/notifications/components/ToastRegion.tsx @@ -11,6 +11,7 @@ import { ToastMessageReceived } from './ToastMessageReceived' import { ToastLowerHand } from './ToastLowerHand' import { ToastAnyRecording } from './ToastAnyRecording' import { ToastRecordingSaving } from './ToastRecordingSaving' +import { ToastPermissionsRemoved } from './ToastPermissionsRemoved' interface ToastRegionProps extends AriaToastRegionProps { state: ToastState @@ -30,6 +31,11 @@ const renderToast = ( case NotificationType.ParticipantMuted: return + case NotificationType.PermissionsRemoved: + return ( + + ) + case NotificationType.MessageReceived: return ( diff --git a/src/frontend/src/features/rooms/livekit/hooks/usePublishSourcesManager.ts b/src/frontend/src/features/rooms/livekit/hooks/usePublishSourcesManager.ts index 71a1bfd1..3ce92da8 100644 --- a/src/frontend/src/features/rooms/livekit/hooks/usePublishSourcesManager.ts +++ b/src/frontend/src/features/rooms/livekit/hooks/usePublishSourcesManager.ts @@ -10,6 +10,10 @@ import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf' import { getParticipantIsRoomAdmin } from '@/features/rooms/utils/getParticipantIsRoomAdmin' import Source = Track.Source +import { + NotificationType, + useNotifyParticipants, +} from '@/features/notifications' export const updatePublishSources = ( currentSources: Source[], @@ -33,6 +37,8 @@ export const usePublishSourcesManager = () => { const { data: configData } = useConfig() const configuration = data?.configuration + const { notifyParticipants } = useNotifyParticipants() + const defaultSources = configData?.livekit?.default_sources?.map((source) => { return source as Source }) @@ -87,6 +93,25 @@ export const usePublishSourcesManager = () => { newSources ) + if (!enabled) { + /* + * We can't rely solely on the ParticipantPermissionsChanged event here, + * because for local participants it is emitted twice (once from Participant, once from LocalParticipant). + * livekit/client-sdk-js/issues/1637 + * */ + await notifyParticipants({ + type: NotificationType.PermissionsRemoved, + destinationIdentities: unprivilegedRemoteParticipants.map( + (p) => p.identity + ), + additionalData: { + data: { + removedSources: sources, + }, + }, + }) + } + return { configuration: newConfiguration } } catch (error) { console.error(`Failed to update ${sources}:`, error) @@ -94,6 +119,7 @@ export const usePublishSourcesManager = () => { } }, [ + notifyParticipants, configuration, currentSources, roomId, diff --git a/src/frontend/src/locales/de/notifications.json b/src/frontend/src/locales/de/notifications.json index f1060ec2..99df3c1b 100644 --- a/src/frontend/src/locales/de/notifications.json +++ b/src/frontend/src/locales/de/notifications.json @@ -16,6 +16,11 @@ "reaction": { "description": "{{name}} hat mit {{emoji}} reagiert" }, + "permissionsRemoved": { + "camera": "Der Organisator hat das Video für alle Teilnehmer deaktiviert.", + "microphone": "Der Organisator hat das Mikrofon für alle Teilnehmer deaktiviert.", + "screen_share": "Der Organisator hat die Bildschirmfreigabe für alle Teilnehmer deaktiviert." + }, "waitingParticipants": { "one": "Eine Person möchte diesem Anruf beitreten.", "several": "Mehrere Personen möchten diesem Anruf beitreten.", diff --git a/src/frontend/src/locales/en/notifications.json b/src/frontend/src/locales/en/notifications.json index 16908fd2..c9de8dfc 100644 --- a/src/frontend/src/locales/en/notifications.json +++ b/src/frontend/src/locales/en/notifications.json @@ -16,6 +16,11 @@ "reaction": { "description": "{{name}} reacted with {{emoji}}" }, + "permissionsRemoved": { + "camera": "The host has disabled video for all participants.", + "microphone": "The host has disabled the microphone for all participants.", + "screen_share": "The host has disabled screen sharing for all participants." + }, "waitingParticipants": { "one": "One person wants to join this call.", "several": "Several people want to join this call.", diff --git a/src/frontend/src/locales/fr/notifications.json b/src/frontend/src/locales/fr/notifications.json index 8f292077..bfec3048 100644 --- a/src/frontend/src/locales/fr/notifications.json +++ b/src/frontend/src/locales/fr/notifications.json @@ -16,6 +16,11 @@ "reaction": { "description": "{{name}} a reagi avec {{emoji}}" }, + "permissionsRemoved": { + "camera": "L'organisateur a désactivé la vidéo de tous les participants.", + "microphone": "L'organisateur a désactivé le micro de tous les participants.", + "screen_share": "L'organisateur a désactivé le partage d'écran de tous les participants." + }, "waitingParticipants": { "one": "Une personne souhaite participer à cet appel.", "several": "Plusieurs personnes souhaitent participer à cet appel.", diff --git a/src/frontend/src/locales/nl/notifications.json b/src/frontend/src/locales/nl/notifications.json index 4b1cd073..76c27469 100644 --- a/src/frontend/src/locales/nl/notifications.json +++ b/src/frontend/src/locales/nl/notifications.json @@ -16,6 +16,11 @@ "reaction": { "description": "{{name}} reageerde met {{emoji}}" }, + "permissionsRemoved": { + "camera": "De organisator heeft de video voor alle deelnemers uitgeschakeld.", + "microphone": "De organisator heeft de microfoon voor alle deelnemers uitgeschakeld.", + "screen_share": "De organisator heeft het schermdelen voor alle deelnemers uitgeschakeld." + }, "waitingParticipants": { "one": "Eén persoon wil deelnemen aan dit gesprek.", "several": "Meerdere mensen willen deelnemen aan dit gesprek.",