Compare commits

..

1 Commits

Author SHA1 Message Date
Ovgodd 3773e43d93 ♿️(frontend) show a visible focus outline on menu items
Keyboard focus was cleared with outline:none.
2026-10-09 18:13:14 +02:00
22 changed files with 47 additions and 317 deletions
+1
View File
@@ -18,6 +18,7 @@ and this project adheres to
- ⚡️(backend) reduce domain queries on the application token endpoint
- ♻️️️(backend) use a dedicated auth scheme for LiveKit token auth
- ♻️(all) stop relying on cookies for the lobby flow
- ♿️(frontend) show a visible focus outline on menu items #1797
### Fixed
@@ -177,31 +177,18 @@ export const MainNotificationToast = () => {
changedAttributes: Record<string, string>,
participant: Participant
) => {
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,
}
)
}
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,
}
)
}
room.on(RoomEvent.ParticipantAttributesChanged, handleAttributeChanged)
@@ -22,5 +22,4 @@ export enum NotificationType {
RecordingSaving = 'recordingSaving',
PermissionsRemoved = 'permissionsRemoved',
RoleChanged = 'roleChanged',
SpeakerChanged = 'speakerChanged',
}
@@ -15,7 +15,6 @@ 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<ToastData>
@@ -79,9 +78,6 @@ const renderToast = (
case NotificationType.RoleChanged:
return <ToastRoleChanged key={toast.key} toast={toast} state={state} />
case NotificationType.SpeakerChanged:
return <ToastSpeakerChanged key={toast.key} toast={toast} state={state} />
default:
return <Toast key={toast.key} toast={toast} state={state} />
}
@@ -1,27 +0,0 @@
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<ToastProps>) {
const { t } = useTranslation('notifications', { keyPrefix: 'speakerChanged' })
const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref)
return (
<StyledToastContainer {...toastProps} ref={ref}>
<HStack
justify="center"
alignItems="center"
{...contentProps}
padding={14}
gap={0}
>
{t(props.toast.content.isSpeaker ? 'granted' : 'removed')}
</HStack>
</StyledToastContainer>
)
}
@@ -1,62 +0,0 @@
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<string, string> = {
[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 }
}
@@ -14,7 +14,6 @@ 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
@@ -87,16 +86,6 @@ 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 (
<HStack
role="listitem"
@@ -118,7 +107,12 @@ export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
displayedName={name}
isLocal={isLocal(participant)}
/>
<Text variant="xsNote">{roleLabels.join(' · ')}</Text>
<Text variant="xsNote">
{getParticipantIsRoomOwner(participant) && t('participants.host')}
{getParticipantIsRoomAdmin(participant) && t('participants.cohost')}
{getParticipantIsRoomMember(participant) &&
t('participants.member')}
</Text>
</VStack>
</HStack>
<HStack flexShrink={0}>
@@ -3,10 +3,8 @@ 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,
@@ -20,13 +18,8 @@ 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 (
<RACMenu
@@ -48,9 +41,6 @@ export const ParticipantMenu = ({
displayedName={participant.name}
/>
)}
{canToggleSpeaker && (
<SpeakerMenuItem participant={participant} isSpeaker={isSpeaker} />
)}
</RACMenu>
)
}
@@ -1,50 +0,0 @@
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 (
<MenuItem
aria-label={t(`${label}.ariaLabel`, {
name: participant.name || participant.identity,
})}
className={menuRecipe({ icon: true }).item}
onAction={toggleSpeaker}
>
<HStack gap={0.25} minWidth={280}>
<Icon size={20} aria-hidden />
{t(`${label}.label`)}
</HStack>
</MenuItem>
)
}
)
SpeakerMenuItem.displayName = 'SpeakerMenuItem'
@@ -1,19 +1,12 @@
import { RoomEvent, Track } from 'livekit-client'
import { useCallback } from 'react'
import { useCallback, useMemo } from 'react'
import { useConfig } from '@/api/useConfig'
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,
@@ -40,9 +33,8 @@ 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()
@@ -50,18 +42,25 @@ export const usePublishSourcesManager = () => {
const roomId = data?.slug
const { updateParticipantsPermissions } = useUpdateParticipantsPermissions()
const { removeSpeaker } = useParticipantSpeaker()
const remoteParticipants = useRemoteParticipants({
updateOnlyOn: [RoomEvent.ParticipantAttributesChanged],
})
const restrictedRemoteParticipants = remoteParticipants.filter(
(participant) =>
!getParticipantIsRoomAdminOrOwner(participant) &&
!getParticipantIsSpeaker(participant)
const unprivilegedRemoteParticipants = remoteParticipants.filter(
(participant) => !getParticipantIsRoomAdminOrOwner(participant)
)
const speakers = remoteParticipants.filter(getParticipantIsSpeaker)
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 updateSource = useCallback(
async (sources: Source[], enabled: boolean) => {
@@ -85,16 +84,10 @@ export const usePublishSourcesManager = () => {
})
await updateParticipantsPermissions(
restrictedRemoteParticipants,
unprivilegedRemoteParticipants,
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,
@@ -103,7 +96,7 @@ export const usePublishSourcesManager = () => {
* */
await notifyParticipants({
type: NotificationType.PermissionsRemoved,
destinationIdentities: restrictedRemoteParticipants.map(
destinationIdentities: unprivilegedRemoteParticipants.map(
(p) => p.identity
),
additionalData: {
@@ -126,12 +119,9 @@ export const usePublishSourcesManager = () => {
notifyParticipants,
configuration,
currentSources,
defaultSources,
roomId,
patchRoom,
restrictedRemoteParticipants,
speakers,
removeSpeaker,
unprivilegedRemoteParticipants,
updateParticipantsPermissions,
]
)
@@ -1,30 +0,0 @@
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)
}
@@ -68,9 +68,5 @@
"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"
}
-9
View File
@@ -659,7 +659,6 @@
"host": "Host",
"cohost": "Co-host",
"member": "Mitglied",
"speaker": "Sprecher",
"contributors": "Teilnehmende",
"collapsable": {
"open": "{{name}}-Liste öffnen",
@@ -715,14 +714,6 @@
"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": {
@@ -68,9 +68,5 @@
"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"
}
-9
View File
@@ -659,7 +659,6 @@
"host": "Host",
"cohost": "Co-host",
"member": "Member",
"speaker": "Speaker",
"contributors": "Contributors",
"collapsable": {
"open": "Open {{name}} list",
@@ -715,14 +714,6 @@
"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": {
@@ -68,9 +68,5 @@
"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ú"
}
-9
View File
@@ -659,7 +659,6 @@
"host": "Organizador de la reunión",
"cohost": "Coorganizador",
"member": "Miembro",
"speaker": "Ponente",
"collapsable": {
"open": "Abrir la lista {{name}}",
"close": "Cerrar la lista {{name}}"
@@ -714,14 +713,6 @@
"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": {
@@ -68,9 +68,5 @@
"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"
}
-9
View File
@@ -660,7 +660,6 @@
"host": "Organisateur de la réunion",
"cohost": "Co-organisateur",
"member": "Membre",
"speaker": "Intervenant",
"collapsable": {
"open": "Ouvrir la liste {{name}}",
"close": "Fermer la liste {{name}}"
@@ -715,14 +714,6 @@
"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": {
@@ -68,9 +68,5 @@
"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"
}
-9
View File
@@ -659,7 +659,6 @@
"host": "Host",
"cohost": "Co-host",
"member": "Lid",
"speaker": "Spreker",
"contributors": "Deelnemers",
"collapsable": {
"open": "Open {{name}} lijst",
@@ -715,14 +714,6 @@
"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": {
+9 -2
View File
@@ -25,12 +25,15 @@ export const menuRecipe = sva({
'&[data-focused]': {
color: 'primary.text',
backgroundColor: 'primaryDark.100',
outline: 'none!',
},
'&[data-hovered]': {
color: 'primary.text',
backgroundColor: 'primaryDark.100',
outline: 'none!',
},
'&[data-focus-visible]': {
outline: '2px solid',
outlineColor: 'focusRing',
outlineOffset: '2px',
},
},
},
@@ -49,6 +52,10 @@ export const menuRecipe = sva({
dark: {
item: {
color: 'white',
'&[data-focus-visible]': {
outlineColor: 'white!',
outlineOffset: '2px!',
},
},
},
},