From e7f9522943953cce95e04e355c24cf53a3fff53e Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Sun, 11 Oct 2026 01:03:37 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20allow=20hosts=20to=20make?= =?UTF-8?q?=20a=20participant=20speaker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a host locks microphone, camera or screen sharing for the room, the only way to let one person speak was to promote them to co-host, which also grants moderation powers, or to unlock the room for everyone. Promotion is not even possible for anonymous participants, who are often the ones invited to ask a question. A speaker is exempt from the room locks without any other privilege. Speakers get the same publish sources as co-hosts, so a deployment that restricts its default sources stays consistent. Room-wide toggles leave speakers untouched; once nothing is locked anymore the role has no purpose and is cleared. The role lives in LiveKit only, through the existing update-participant endpoint, so it is lost on reconnection. The host can grant it again; persisting it would require a backend change that is not worth it yet. In an upcoming commit, when a participant who is not a speaker raises their hand in a locked room, the notification will let the host make them speaker directly. --- .../notifications/MainNotificationToast.tsx | 37 +++++++---- .../notifications/NotificationType.ts | 1 + .../notifications/components/ToastRegion.tsx | 4 ++ .../components/ToastSpeakerChanged.tsx | 27 ++++++++ .../api/updateParticipantSpeaker.ts | 62 +++++++++++++++++++ .../components/ParticipantRow.tsx | 18 ++++-- .../components/menu/ParticipantMenu.tsx | 10 +++ .../components/menu/items/SpeakerMenuItem.tsx | 50 +++++++++++++++ .../livekit/hooks/usePublishSourcesManager.ts | 48 ++++++++------ .../livekit/hooks/useRoomPublishSources.ts | 30 +++++++++ .../src/locales/de/notifications.json | 4 ++ src/frontend/src/locales/de/rooms.json | 9 +++ .../src/locales/en/notifications.json | 4 ++ src/frontend/src/locales/en/rooms.json | 9 +++ .../src/locales/es/notifications.json | 4 ++ src/frontend/src/locales/es/rooms.json | 9 +++ .../src/locales/fr/notifications.json | 4 ++ src/frontend/src/locales/fr/rooms.json | 9 +++ .../src/locales/nl/notifications.json | 4 ++ src/frontend/src/locales/nl/rooms.json | 9 +++ 20 files changed, 315 insertions(+), 37 deletions(-) create mode 100644 src/frontend/src/features/notifications/components/ToastSpeakerChanged.tsx create mode 100644 src/frontend/src/features/participants/api/updateParticipantSpeaker.ts create mode 100644 src/frontend/src/features/participants/components/menu/items/SpeakerMenuItem.tsx create mode 100644 src/frontend/src/features/rooms/livekit/hooks/useRoomPublishSources.ts diff --git a/src/frontend/src/features/notifications/MainNotificationToast.tsx b/src/frontend/src/features/notifications/MainNotificationToast.tsx index fe3aa2ff2..a5418ae90 100644 --- a/src/frontend/src/features/notifications/MainNotificationToast.tsx +++ b/src/frontend/src/features/notifications/MainNotificationToast.tsx @@ -177,18 +177,31 @@ export const MainNotificationToast = () => { changedAttributes: Record, participant: Participant ) => { - if (!participant.isLocal || !('room_role' in changedAttributes)) return - const newRole = changedAttributes['room_role'] - toastQueue.add( - { - participant, - type: NotificationType.RoleChanged, - newRole: newRole, - }, - { - timeout: NotificationDuration.ROLE_CHANGED, - } - ) + if (!participant.isLocal) return + if ('room_role' in changedAttributes) { + toastQueue.add( + { + participant, + type: NotificationType.RoleChanged, + newRole: changedAttributes['room_role'], + }, + { + timeout: NotificationDuration.ROLE_CHANGED, + } + ) + } + if ('is_speaker' in changedAttributes) { + toastQueue.add( + { + participant, + type: NotificationType.SpeakerChanged, + isSpeaker: changedAttributes['is_speaker'] === 'true', + }, + { + timeout: NotificationDuration.ROLE_CHANGED, + } + ) + } } room.on(RoomEvent.ParticipantAttributesChanged, handleAttributeChanged) diff --git a/src/frontend/src/features/notifications/NotificationType.ts b/src/frontend/src/features/notifications/NotificationType.ts index e1a49030b..74f0fed53 100644 --- a/src/frontend/src/features/notifications/NotificationType.ts +++ b/src/frontend/src/features/notifications/NotificationType.ts @@ -22,4 +22,5 @@ export enum NotificationType { RecordingSaving = 'recordingSaving', PermissionsRemoved = 'permissionsRemoved', RoleChanged = 'roleChanged', + SpeakerChanged = 'speakerChanged', } diff --git a/src/frontend/src/features/notifications/components/ToastRegion.tsx b/src/frontend/src/features/notifications/components/ToastRegion.tsx index ee9bb205d..eecd8aba1 100644 --- a/src/frontend/src/features/notifications/components/ToastRegion.tsx +++ b/src/frontend/src/features/notifications/components/ToastRegion.tsx @@ -15,6 +15,7 @@ import { ToastPermissionsRemoved } from './ToastPermissionsRemoved' import { ToastRecordingRequest } from './ToastRecordingRequest' import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom' import { ToastRoleChanged } from '@/features/notifications/components/ToastRoleChanged' +import { ToastSpeakerChanged } from './ToastSpeakerChanged' interface ToastRegionProps extends AriaToastRegionProps { state: ToastState @@ -78,6 +79,9 @@ const renderToast = ( case NotificationType.RoleChanged: return + case NotificationType.SpeakerChanged: + return + default: return } diff --git a/src/frontend/src/features/notifications/components/ToastSpeakerChanged.tsx b/src/frontend/src/features/notifications/components/ToastSpeakerChanged.tsx new file mode 100644 index 000000000..008a17cf4 --- /dev/null +++ b/src/frontend/src/features/notifications/components/ToastSpeakerChanged.tsx @@ -0,0 +1,27 @@ +import { useToast } from 'react-aria' +import { useRef } from 'react' + +import { type ToastProps } from './Toast' +import { HStack } from '@/styled-system/jsx' +import { useTranslation } from 'react-i18next' +import { StyledToastContainer } from './StyledToastContainer' + +export function ToastSpeakerChanged({ state, ...props }: Readonly) { + const { t } = useTranslation('notifications', { keyPrefix: 'speakerChanged' }) + const ref = useRef(null) + const { toastProps, contentProps } = useToast(props, state, ref) + + return ( + + + {t(props.toast.content.isSpeaker ? 'granted' : 'removed')} + + + ) +} diff --git a/src/frontend/src/features/participants/api/updateParticipantSpeaker.ts b/src/frontend/src/features/participants/api/updateParticipantSpeaker.ts new file mode 100644 index 000000000..e91e71f76 --- /dev/null +++ b/src/frontend/src/features/participants/api/updateParticipantSpeaker.ts @@ -0,0 +1,62 @@ +import type { Participant, Track } from 'livekit-client' +import { fetchApi } from '@/api/fetchApi' +import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' +import { useDefaultPublishSources } from '@/features/rooms/livekit/hooks/useRoomPublishSources' +import { reportError } from '@/features/analytics/telemetry' + +export const SPEAKER_ATTRIBUTE = 'is_speaker' + +export const getParticipantIsSpeaker = (participant: Participant): boolean => + participant.attributes?.[SPEAKER_ATTRIBUTE] === 'true' + +export const useParticipantSpeaker = () => { + const data = useRoomData() + const defaultSources = useDefaultPublishSources() + + const updateSpeaker = async ( + participant: Participant, + isSpeaker: boolean, + sources: Track.Source[] + ) => { + if (!data?.id) { + throw new Error('Room id is not available') + } + + const attributes: Record = { + [SPEAKER_ATTRIBUTE]: isSpeaker ? 'true' : '', + } + + try { + return await fetchApi(`rooms/${data.id}/update-participant/`, { + method: 'POST', + body: JSON.stringify({ + participant_identity: participant.identity, + attributes, + permission: { + can_subscribe: participant.permissions?.canSubscribe, + can_publish_data: participant.permissions?.canPublishData, + can_update_metadata: participant.permissions?.canUpdateMetadata, + can_subscribe_metrics: participant.permissions?.canSubscribeMetrics, + can_publish: sources.length > 0, + can_publish_sources: sources, + }, + }), + }) + } catch (error) { + reportError('permissions_api_failure', error, { + context: `Failed to update speaker status of ${participant.identity}`, + }) + throw error + } + } + + const makeSpeaker = (participant: Participant) => + updateSpeaker(participant, true, defaultSources) + + const removeSpeaker = ( + participant: Participant, + roomSources: Track.Source[] + ) => updateSpeaker(participant, false, roomSources) + + return { makeSpeaker, removeSpeaker } +} diff --git a/src/frontend/src/features/participants/components/ParticipantRow.tsx b/src/frontend/src/features/participants/components/ParticipantRow.tsx index 93029c3f6..fb20e945c 100644 --- a/src/frontend/src/features/participants/components/ParticipantRow.tsx +++ b/src/frontend/src/features/participants/components/ParticipantRow.tsx @@ -14,6 +14,7 @@ import { type LocalParticipant, type Participant, Track } from 'livekit-client' import { isLocal } from '@/utils/livekit' import { useIsSpeaking, + useParticipantAttribute, useTrackMutedIndicator, } from '@livekit/components-react' import Source = Track.Source @@ -86,6 +87,16 @@ type ParticipantListItemProps = { export const ParticipantRow = ({ participant }: ParticipantListItemProps) => { const { t } = useTranslation('rooms') const name = participant.name || participant.identity + const isSpeaker = + useParticipantAttribute('is_speaker', { participant }) === 'true' + + const roleLabels = [ + getParticipantIsRoomOwner(participant) && t('participants.host'), + getParticipantIsRoomAdmin(participant) && t('participants.cohost'), + getParticipantIsRoomMember(participant) && t('participants.member'), + isSpeaker && t('participants.speaker'), + ].filter(Boolean) + return ( { displayedName={name} isLocal={isLocal(participant)} /> - - {getParticipantIsRoomOwner(participant) && t('participants.host')} - {getParticipantIsRoomAdmin(participant) && t('participants.cohost')} - {getParticipantIsRoomMember(participant) && - t('participants.member')} - + {roleLabels.join(' · ')} diff --git a/src/frontend/src/features/participants/components/menu/ParticipantMenu.tsx b/src/frontend/src/features/participants/components/menu/ParticipantMenu.tsx index bdba8cbdf..dd3fdd2cf 100644 --- a/src/frontend/src/features/participants/components/menu/ParticipantMenu.tsx +++ b/src/frontend/src/features/participants/components/menu/ParticipantMenu.tsx @@ -3,8 +3,10 @@ import type { Participant } from 'livekit-client' import { PinMenuItem } from './items/PinMenuItem' import { RemoveMenuItem } from './items/RemoveMenuItem' import { PromoteMenuItem } from './items/PromoteMenuItem' +import { SpeakerMenuItem } from './items/SpeakerMenuItem' import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner' import { useParticipantAttributes } from '@livekit/components-react' +import { useHasLockedSource } from '@/features/rooms/livekit/hooks/useRoomPublishSources' export const ParticipantMenu = ({ participant, @@ -18,8 +20,13 @@ export const ParticipantMenu = ({ const isAdmin = attributes?.room_role === 'administrator' const isOwner = attributes?.room_role === 'owner' const isAuthenticated = attributes?.is_authenticated == 'true' + const isSpeaker = attributes?.is_speaker === 'true' + + const hasLockedSource = useHasLockedSource() const canManage = !participant.isLocal && isLocalUserAdminOrOwner && !isOwner + const canToggleSpeaker = + canManage && !isAdmin && (isSpeaker || hasLockedSource) return ( )} + {canToggleSpeaker && ( + + )} ) } diff --git a/src/frontend/src/features/participants/components/menu/items/SpeakerMenuItem.tsx b/src/frontend/src/features/participants/components/menu/items/SpeakerMenuItem.tsx new file mode 100644 index 000000000..639e45afe --- /dev/null +++ b/src/frontend/src/features/participants/components/menu/items/SpeakerMenuItem.tsx @@ -0,0 +1,50 @@ +import React, { useCallback } from 'react' +import { MenuItem } from 'react-aria-components' +import { useTranslation } from 'react-i18next' +import type { Participant } from 'livekit-client' +import { RiMicOffLine, RiUserVoiceLine } from '@remixicon/react' +import { useParticipantSpeaker } from '@/features/participants/api/updateParticipantSpeaker' +import { useRoomPublishSources } from '@/features/rooms/livekit/hooks/useRoomPublishSources' +import { menuRecipe } from '@/primitives/menuRecipe' +import { HStack } from '@/styled-system/jsx' + +type SpeakerMenuItemProps = { + participant: Participant + isSpeaker: boolean +} + +export const SpeakerMenuItem = React.memo( + ({ participant, isSpeaker }: SpeakerMenuItemProps) => { + const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu' }) + const { makeSpeaker, removeSpeaker } = useParticipantSpeaker() + const roomSources = useRoomPublishSources() + + const label = isSpeaker ? 'removeSpeaker' : 'makeSpeaker' + const Icon = isSpeaker ? RiMicOffLine : RiUserVoiceLine + + const toggleSpeaker = useCallback( + () => + isSpeaker + ? removeSpeaker(participant, roomSources) + : makeSpeaker(participant), + [isSpeaker, participant, roomSources, makeSpeaker, removeSpeaker] + ) + + return ( + + + + {t(`${label}.label`)} + + + ) + } +) + +SpeakerMenuItem.displayName = 'SpeakerMenuItem' diff --git a/src/frontend/src/features/rooms/livekit/hooks/usePublishSourcesManager.ts b/src/frontend/src/features/rooms/livekit/hooks/usePublishSourcesManager.ts index 3108749b2..2e00b5364 100644 --- a/src/frontend/src/features/rooms/livekit/hooks/usePublishSourcesManager.ts +++ b/src/frontend/src/features/rooms/livekit/hooks/usePublishSourcesManager.ts @@ -1,12 +1,19 @@ import { RoomEvent, Track } from 'livekit-client' -import { useCallback, useMemo } from 'react' -import { useConfig } from '@/api/useConfig' +import { useCallback } from 'react' import { usePatchRoom } from '@/features/rooms/api/patchRoom' import { useRemoteParticipants } from '@livekit/components-react' import { useUpdateParticipantsPermissions } from '@/features/rooms/api/updateParticipantsPermissions' import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf' import { getParticipantIsRoomAdminOrOwner } from '@/features/rooms/utils/getParticipantIsRoomAdminOrOwner' +import { + getParticipantIsSpeaker, + useParticipantSpeaker, +} from '@/features/participants/api/updateParticipantSpeaker' +import { + useDefaultPublishSources, + useRoomPublishSources, +} from '@/features/rooms/livekit/hooks/useRoomPublishSources' import Source = Track.Source import { NotificationType, @@ -33,8 +40,9 @@ export const usePublishSourcesManager = () => { const { mutateAsync: patchRoom } = usePatchRoom() const data = useRoomData() - const { data: configData } = useConfig() const configuration = data?.configuration + const currentSources = useRoomPublishSources() + const defaultSources = useDefaultPublishSources() const { notifyParticipants } = useNotifyParticipants() @@ -42,25 +50,18 @@ export const usePublishSourcesManager = () => { const roomId = data?.slug const { updateParticipantsPermissions } = useUpdateParticipantsPermissions() + const { removeSpeaker } = useParticipantSpeaker() const remoteParticipants = useRemoteParticipants({ updateOnlyOn: [RoomEvent.ParticipantAttributesChanged], }) - const unprivilegedRemoteParticipants = remoteParticipants.filter( - (participant) => !getParticipantIsRoomAdminOrOwner(participant) + const restrictedRemoteParticipants = remoteParticipants.filter( + (participant) => + !getParticipantIsRoomAdminOrOwner(participant) && + !getParticipantIsSpeaker(participant) ) - const currentSources = useMemo(() => { - const defaultSources = configData?.livekit?.default_sources ?? [] - - if ( - configuration?.can_publish_sources == undefined || - !Array.isArray(configuration?.can_publish_sources) - ) { - return defaultSources - } - return configuration.can_publish_sources - }, [configData, configuration?.can_publish_sources]) + const speakers = remoteParticipants.filter(getParticipantIsSpeaker) const updateSource = useCallback( async (sources: Source[], enabled: boolean) => { @@ -84,10 +85,16 @@ export const usePublishSourcesManager = () => { }) await updateParticipantsPermissions( - unprivilegedRemoteParticipants, + restrictedRemoteParticipants, newSources ) + if (isSubsetOf(defaultSources, newSources)) { + await Promise.all( + speakers.map((speaker) => removeSpeaker(speaker, newSources)) + ) + } + if (!enabled) { /* * We can't rely solely on the ParticipantPermissionsChanged event here, @@ -96,7 +103,7 @@ export const usePublishSourcesManager = () => { * */ await notifyParticipants({ type: NotificationType.PermissionsRemoved, - destinationIdentities: unprivilegedRemoteParticipants.map( + destinationIdentities: restrictedRemoteParticipants.map( (p) => p.identity ), additionalData: { @@ -119,9 +126,12 @@ export const usePublishSourcesManager = () => { notifyParticipants, configuration, currentSources, + defaultSources, roomId, patchRoom, - unprivilegedRemoteParticipants, + restrictedRemoteParticipants, + speakers, + removeSpeaker, updateParticipantsPermissions, ] ) diff --git a/src/frontend/src/features/rooms/livekit/hooks/useRoomPublishSources.ts b/src/frontend/src/features/rooms/livekit/hooks/useRoomPublishSources.ts new file mode 100644 index 000000000..0f383f463 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/hooks/useRoomPublishSources.ts @@ -0,0 +1,30 @@ +import { Track } from 'livekit-client' +import { useMemo } from 'react' +import { useConfig } from '@/api/useConfig' +import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' +import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf' +import Source = Track.Source + +const EMPTY_SOURCES: Source[] = [] + +export const useDefaultPublishSources = (): Source[] => { + const { data: configData } = useConfig() + return configData?.livekit?.default_sources ?? EMPTY_SOURCES +} + +export const useRoomPublishSources = (): Source[] => { + const data = useRoomData() + const defaultSources = useDefaultPublishSources() + const roomSources = data?.configuration?.can_publish_sources + + return useMemo( + () => (Array.isArray(roomSources) ? roomSources : defaultSources), + [defaultSources, roomSources] + ) +} + +export const useHasLockedSource = (): boolean => { + const defaultSources = useDefaultPublishSources() + const roomSources = useRoomPublishSources() + return !isSubsetOf(defaultSources, roomSources) +} diff --git a/src/frontend/src/locales/de/notifications.json b/src/frontend/src/locales/de/notifications.json index 3b48dde07..b67391ce7 100644 --- a/src/frontend/src/locales/de/notifications.json +++ b/src/frontend/src/locales/de/notifications.json @@ -68,5 +68,9 @@ "member": "Mitglied" } }, + "speakerChanged": { + "granted": "Du bist jetzt Sprecher. Du kannst dein Mikrofon, deine Kamera und die Bildschirmfreigabe nutzen.", + "removed": "Du bist nicht mehr Sprecher." + }, "openMenu": "Menü öffnen" } diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json index f9f714fa0..93261231c 100644 --- a/src/frontend/src/locales/de/rooms.json +++ b/src/frontend/src/locales/de/rooms.json @@ -659,6 +659,7 @@ "host": "Host", "cohost": "Co-host", "member": "Mitglied", + "speaker": "Sprecher", "contributors": "Teilnehmende", "collapsable": { "open": "{{name}}-Liste öffnen", @@ -714,6 +715,14 @@ "demote": { "label": "Co-Host-Rolle entfernen", "ariaLabel": "Die Co-Host-Rolle von {{name}} entfernen" + }, + "makeSpeaker": { + "label": "Zum Sprecher machen", + "ariaLabel": "{{name}} zum Sprecher machen" + }, + "removeSpeaker": { + "label": "Sprecherrolle entfernen", + "ariaLabel": "Sprecherrolle von {{name}} entfernen" } }, "pinAnnouncements": { diff --git a/src/frontend/src/locales/en/notifications.json b/src/frontend/src/locales/en/notifications.json index 380503ed3..938e4d979 100644 --- a/src/frontend/src/locales/en/notifications.json +++ b/src/frontend/src/locales/en/notifications.json @@ -68,5 +68,9 @@ "member": "Member" } }, + "speakerChanged": { + "granted": "You are now a speaker. You can use your microphone, camera and screen sharing.", + "removed": "You are no longer a speaker." + }, "openMenu": "Open menu" } diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index 35f5d4235..749501758 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -659,6 +659,7 @@ "host": "Host", "cohost": "Co-host", "member": "Member", + "speaker": "Speaker", "contributors": "Contributors", "collapsable": { "open": "Open {{name}} list", @@ -714,6 +715,14 @@ "demote": { "label": "Remove co-host role", "ariaLabel": "Remove {{name}}'s co-host role" + }, + "makeSpeaker": { + "label": "Make speaker", + "ariaLabel": "Make {{name}} a speaker" + }, + "removeSpeaker": { + "label": "Remove speaker", + "ariaLabel": "Remove {{name}} as speaker" } }, "pinAnnouncements": { diff --git a/src/frontend/src/locales/es/notifications.json b/src/frontend/src/locales/es/notifications.json index d3f5fe382..50b2f71a9 100644 --- a/src/frontend/src/locales/es/notifications.json +++ b/src/frontend/src/locales/es/notifications.json @@ -68,5 +68,9 @@ "member": "Miembro" } }, + "speakerChanged": { + "granted": "Ahora eres ponente. Puedes usar tu micrófono, tu cámara y compartir pantalla.", + "removed": "Ya no eres ponente." + }, "openMenu": "Abrir el menú" } diff --git a/src/frontend/src/locales/es/rooms.json b/src/frontend/src/locales/es/rooms.json index caa239c8d..417c7eb84 100644 --- a/src/frontend/src/locales/es/rooms.json +++ b/src/frontend/src/locales/es/rooms.json @@ -659,6 +659,7 @@ "host": "Organizador de la reunión", "cohost": "Coorganizador", "member": "Miembro", + "speaker": "Ponente", "collapsable": { "open": "Abrir la lista {{name}}", "close": "Cerrar la lista {{name}}" @@ -713,6 +714,14 @@ "demote": { "label": "Retirar el rol de coorganizador", "ariaLabel": "Retirar el rol de coorganizador a {{name}}" + }, + "makeSpeaker": { + "label": "Nombrar ponente", + "ariaLabel": "Nombrar ponente a {{name}}" + }, + "removeSpeaker": { + "label": "Quitar rol de ponente", + "ariaLabel": "Quitar el rol de ponente a {{name}}" } }, "pinAnnouncements": { diff --git a/src/frontend/src/locales/fr/notifications.json b/src/frontend/src/locales/fr/notifications.json index 0d948cbd9..535b29fc0 100644 --- a/src/frontend/src/locales/fr/notifications.json +++ b/src/frontend/src/locales/fr/notifications.json @@ -68,5 +68,9 @@ "member": "Membre" } }, + "speakerChanged": { + "granted": "Vous êtes maintenant intervenant. Vous pouvez utiliser votre micro, votre caméra et partager votre écran.", + "removed": "Vous n'êtes plus intervenant." + }, "openMenu": "Ouvrir le menu" } diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index bbfb5e06d..f06b38d40 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -660,6 +660,7 @@ "host": "Organisateur de la réunion", "cohost": "Co-organisateur", "member": "Membre", + "speaker": "Intervenant", "collapsable": { "open": "Ouvrir la liste {{name}}", "close": "Fermer la liste {{name}}" @@ -714,6 +715,14 @@ "demote": { "label": "Retirer le rôle de co-organisateur", "ariaLabel": "Retirer le rôle de co-organisateur à {{name}}" + }, + "makeSpeaker": { + "label": "Désigner comme intervenant", + "ariaLabel": "Désigner {{name}} comme intervenant" + }, + "removeSpeaker": { + "label": "Retirer le rôle d'intervenant", + "ariaLabel": "Retirer le rôle d'intervenant de {{name}}" } }, "pinAnnouncements": { diff --git a/src/frontend/src/locales/nl/notifications.json b/src/frontend/src/locales/nl/notifications.json index 01fb757fc..ca07a3204 100644 --- a/src/frontend/src/locales/nl/notifications.json +++ b/src/frontend/src/locales/nl/notifications.json @@ -68,5 +68,9 @@ "member": "Lid" } }, + "speakerChanged": { + "granted": "Je bent nu spreker. Je kunt je microfoon, camera en schermdeling gebruiken.", + "removed": "Je bent geen spreker meer." + }, "openMenu": "Menu openen" } diff --git a/src/frontend/src/locales/nl/rooms.json b/src/frontend/src/locales/nl/rooms.json index 07db7bfc1..84a0dcbe2 100644 --- a/src/frontend/src/locales/nl/rooms.json +++ b/src/frontend/src/locales/nl/rooms.json @@ -659,6 +659,7 @@ "host": "Host", "cohost": "Co-host", "member": "Lid", + "speaker": "Spreker", "contributors": "Deelnemers", "collapsable": { "open": "Open {{name}} lijst", @@ -714,6 +715,14 @@ "demote": { "label": "Co-hostrol verwijderen", "ariaLabel": "De co-hostrol van {{name}} verwijderen" + }, + "makeSpeaker": { + "label": "Spreker maken", + "ariaLabel": "{{name}} spreker maken" + }, + "removeSpeaker": { + "label": "Sprekersrol verwijderen", + "ariaLabel": "Sprekersrol van {{name}} verwijderen" } }, "pinAnnouncements": {