🚸(frontend) add notifications when admin removes participant permissions

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.
This commit is contained in:
lebaudantoine
2025-08-28 18:35:23 +02:00
committed by aleb_the_flash
parent 86a04ed718
commit 242e7cb148
10 changed files with 116 additions and 0 deletions

View File

@@ -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
}

View File

@@ -4,5 +4,6 @@ export interface NotificationPayload {
type: NotificationType
data?: {
emoji?: string
removedSources?: string[]
}
}

View File

@@ -13,4 +13,5 @@ export enum NotificationType {
ScreenRecordingStopped = 'screenRecordingStopped',
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
RecordingSaving = 'recordingSaving',
PermissionsRemoved = 'permissionsRemoved',
}

View File

@@ -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 (
<StyledToastContainer {...toastProps} ref={ref}>
<HStack
justify="center"
alignItems="center"
{...contentProps}
padding={14}
gap={0}
>
{t(key)}
</HStack>
</StyledToastContainer>
)
}

View File

@@ -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<ToastData>
@@ -30,6 +31,11 @@ const renderToast = (
case NotificationType.ParticipantMuted:
return <ToastMuted key={toast.key} toast={toast} state={state} />
case NotificationType.PermissionsRemoved:
return (
<ToastPermissionsRemoved key={toast.key} toast={toast} state={state} />
)
case NotificationType.MessageReceived:
return (
<ToastMessageReceived key={toast.key} toast={toast} state={state} />

View File

@@ -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,

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",