Compare commits

..

8 Commits

Author SHA1 Message Date
Cyril a7da853af7 ️(frontend) improve aria-label with accessible emoji description
replace raw emoji with descriptive label to enhance screen reader support
2026-01-19 23:25:15 +01:00
Cyril 5d39629297 📝(docs) add changelog file to document project changes
helps track notable changes and improvements over time
2026-01-19 23:25:15 +01:00
Cyril 5351ebb4bb 💄(frontend) add globally available sr-only utility class
provides reusable hidden style for screen reader-only content
2026-01-19 23:25:15 +01:00
Cyril d05186b5b3 📝(frontend) update a11y store labels and link for clarity
improves naming and navigation for better user understanding of options
2026-01-19 23:25:15 +01:00
Cyril f497cb8dae (frontend) create a11y store to manage user option toggles
sets up state handling for enabling or disabling a11y preferences
2026-01-19 23:25:15 +01:00
Cyril 3f83d5cef2 (frontend) add placeholder for accessibility menu in settings panel
prepares UI for future accessibility options without implementing logic yet
2026-01-19 23:22:29 +01:00
Cyril 6a19c8ece8 (frontend) getEmojiLabel util for accessible emoji labeling across app
centralizes emoji label logic to ensure consistency and reuse in UI components
2026-01-19 23:22:29 +01:00
Cyril d42deffbd4 ️(frontend) add screen reader announcements for reactions interactions
ensures users get feedback when adding reactions via assistive tech
2026-01-19 23:22:28 +01:00
9 changed files with 113 additions and 134 deletions
-4
View File
@@ -14,16 +14,12 @@ and this project adheres to
- ♿️(frontend) improve participants toggle a11y label #880
- ♿️(frontend) make carousel image decorative #871
- ♿️(frontend) reactions are now vocalized and configurable #849
- ♿️(frontend) improve background effect announcements #879
### Fixed
- 🔒(backend) prevent automatic upgrade setuptools
- ♿(frontend) improve contrast for selected options #863
- ♿️(frontend) announce copy state in invite dialog #877
- 📝(frontend) align close dialog label in rooms locale #878
- 🩹(backend) use case-insensitive email matching in the external api #887
- 🐛(frontend) ensure transcript segments are sorted by their timestamp #899
## [1.3.0] - 2026-01-13
+2 -6
View File
@@ -5,7 +5,7 @@ from logging import getLogger
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.core.exceptions import SuspiciousOperation, ValidationError
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
import jwt
@@ -93,7 +93,7 @@ class ApplicationViewSet(viewsets.GenericViewSet):
)
try:
user = models.User.objects.get(email__iexact=email)
user = models.User.objects.get(email=email)
except models.User.DoesNotExist as e:
if (
settings.APPLICATION_ALLOW_USER_CREATION
@@ -123,10 +123,6 @@ class ApplicationViewSet(viewsets.GenericViewSet):
)
else:
raise drf_exceptions.NotFound("User not found.") from e
except models.User.MultipleObjectsReturned as e:
raise SuspiciousOperation(
"Multiple user accounts share a common email."
) from e
now = datetime.now(timezone.utc)
scope = " ".join(application.scopes or [])
@@ -22,7 +22,7 @@ pytestmark = pytest.mark.django_db
def test_api_applications_generate_token_success(settings):
"""Valid credentials should return a JWT token."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
UserFactory(email="User.Family@example.com")
user = UserFactory(email="user@example.com")
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
@@ -40,7 +40,7 @@ def test_api_applications_generate_token_success(settings):
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": "user.family@example.com",
"scope": user.email,
},
format="json",
)
@@ -56,11 +56,11 @@ export const EffectsConfiguration = ({
const [processorPending, setProcessorPending] = useState(false)
const processorPendingReveal = useSyncAfterDelay(processorPending)
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
const [effectStatusMessage, setEffectStatusMessage] = useState('')
const effectAnnouncementTimeout = useRef<ReturnType<
typeof setTimeout
> | null>(null)
const effectAnnouncementId = useRef(0)
const [blurStatusMessage, setBlurStatusMessage] = useState('')
const blurAnnouncementTimeout = useRef<ReturnType<typeof setTimeout> | null>(
null
)
const blurAnnouncementId = useRef(0)
useEffect(() => {
const videoElement = videoRef.current
@@ -89,27 +89,27 @@ export const EffectsConfiguration = ({
useEffect(
() => () => {
if (effectAnnouncementTimeout.current) {
clearTimeout(effectAnnouncementTimeout.current)
if (blurAnnouncementTimeout.current) {
clearTimeout(blurAnnouncementTimeout.current)
}
},
[]
)
const announceEffectStatusMessage = (message: string) => {
effectAnnouncementId.current += 1
const currentId = effectAnnouncementId.current
const announceBlurStatusMessage = (message: string) => {
blurAnnouncementId.current += 1
const currentId = blurAnnouncementId.current
if (effectAnnouncementTimeout.current) {
clearTimeout(effectAnnouncementTimeout.current)
if (blurAnnouncementTimeout.current) {
clearTimeout(blurAnnouncementTimeout.current)
}
// Clear the region first so screen readers drop queued announcements.
setEffectStatusMessage('')
setBlurStatusMessage('')
effectAnnouncementTimeout.current = setTimeout(() => {
if (currentId !== effectAnnouncementId.current) return
setEffectStatusMessage(message)
blurAnnouncementTimeout.current = setTimeout(() => {
if (currentId !== blurAnnouncementId.current) return
setBlurStatusMessage(message)
}, 80)
}
@@ -118,42 +118,25 @@ export const EffectsConfiguration = ({
onSubmit?.(undefined)
}
const getVirtualBackgroundName = (imagePath?: string) => {
if (!imagePath) return ''
const match = imagePath.match(/\/backgrounds\/(\d+)\.jpg$/)
if (!match) return ''
const index = Number(match[1]) - 1
if (Number.isNaN(index)) return ''
return t(`virtual.descriptions.${index}`)
}
const updateEffectStatusMessage = (
const updateBlurStatusMessage = (
type: ProcessorType,
options: BackgroundOptions,
wasSelectedBeforeToggle: boolean
) => {
if (type !== ProcessorType.BLUR) return
let message = ''
if (wasSelectedBeforeToggle) {
announceEffectStatusMessage(t('blur.status.none'))
return
message = t('blur.status.none')
} else if (options.blurRadius === BlurRadius.LIGHT) {
message = t('blur.status.light')
} else if (options.blurRadius === BlurRadius.NORMAL) {
message = t('blur.status.strong')
}
if (type === ProcessorType.BLUR) {
const message =
options.blurRadius === BlurRadius.LIGHT
? t('blur.status.light')
: t('blur.status.strong')
announceEffectStatusMessage(message)
return
}
if (type === ProcessorType.VIRTUAL) {
const backgroundName = getVirtualBackgroundName(options.imagePath)
if (backgroundName) {
announceEffectStatusMessage(
`${t('virtual.selectedLabel')} ${backgroundName}`
)
return
}
if (message) {
announceBlurStatusMessage(message)
}
}
@@ -217,7 +200,7 @@ export const EffectsConfiguration = ({
onSubmit?.(processor)
}
updateEffectStatusMessage(type, options, wasSelectedBeforeToggle)
updateBlurStatusMessage(type, options, wasSelectedBeforeToggle)
} catch (error) {
console.error('Error applying effect:', error)
} finally {
@@ -424,7 +407,7 @@ export const EffectsConfiguration = ({
</ToggleButton>
</div>
<div aria-live="polite" className="sr-only">
{effectStatusMessage}
{blurStatusMessage}
</div>
</div>
<div
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useState } from 'react'
import { useSubtitles } from '../hooks/useSubtitles'
import { css, cva } from '@/styled-system/css'
import { styled } from '@/styled-system/jsx'
@@ -13,63 +13,101 @@ export interface TranscriptionSegment {
id: string
text: string
language: string
startTime?: number
startTime: number
endTime: number
final: boolean
firstReceivedTime: number
lastReceivedTime: number
}
export interface TranscriptionSegmentWithParticipant
extends TranscriptionSegment {
participant: Participant
}
export interface TranscriptionRow {
id: string
participant: Participant
segments: TranscriptionSegment[]
startTime?: number
startTime: number
lastUpdateTime: number
lastReceivedTime: number
}
const useTranscriptionState = () => {
const [transcriptionSegments, setTranscriptionSegments] = useState<
TranscriptionSegmentWithParticipant[]
const [transcriptionRows, setTranscriptionRows] = useState<
TranscriptionRow[]
>([])
const [lastActiveParticipantIdentity, setLastActiveParticipantIdentity] =
useState<string | null>(null)
const updateTranscriptionSegments = (
const updateTranscriptions = (
segments: TranscriptionSegment[],
participant?: Participant
) => {
console.log(participant, segments)
if (!participant || segments.length === 0) return
if (segments.length > 1) {
console.warn('Unexpected error more segments')
return
}
setTranscriptionRows((prevRows) => {
const updatedRows = [...prevRows]
const now = Date.now()
const segment = segments[0]
const shouldAppendToLastRow =
lastActiveParticipantIdentity === participant.identity &&
updatedRows.length > 0
setTranscriptionSegments((prevSegments) => {
const existingSegmentIds = new Set(prevSegments.map((s) => s.id))
if (existingSegmentIds.has(segment.id)) return prevSegments
return [
...prevSegments,
{
participant: participant,
...segment,
},
]
if (shouldAppendToLastRow) {
const lastRowIndex = updatedRows.length - 1
const lastRow = updatedRows[lastRowIndex]
const existingSegmentIds = new Set(lastRow.segments.map((s) => s.id))
const newSegments = segments.filter(
(segment) => !existingSegmentIds.has(segment.id)
)
const updatedSegments = lastRow.segments.map((existing) => {
const update = segments.find((s) => s.id === existing.id)
return update && update.final ? update : existing
})
updatedRows[lastRowIndex] = {
...lastRow,
segments: [...updatedSegments, ...newSegments],
lastUpdateTime: now,
}
} else {
const newRow: TranscriptionRow = {
id: `${participant.identity}-${now}`,
participant,
segments: [...segments],
startTime: Math.min(...segments.map((s) => s.startTime)),
lastUpdateTime: now,
}
updatedRows.push(newRow)
}
return updatedRows
})
setLastActiveParticipantIdentity(participant.identity)
}
const clearTranscriptions = () => {
setTranscriptionRows([])
setLastActiveParticipantIdentity(null)
}
const updateParticipant = (_name: string, participant: Participant) => {
setTranscriptionRows((prevRows) => {
return prevRows.map((row) => {
if (row.participant.identity === participant.identity) {
return {
...row,
participant,
}
}
return row
})
})
}
return {
updateTranscriptionSegments,
transcriptionSegments,
transcriptionRows,
updateTranscriptions,
clearTranscriptions,
updateParticipant,
}
}
@@ -154,54 +192,24 @@ const SubtitlesWrapper = styled(
export const Subtitles = () => {
const { areSubtitlesOpen } = useSubtitles()
const room = useRoomContext()
const { transcriptionSegments, updateTranscriptionSegments } =
const { transcriptionRows, updateTranscriptions, updateParticipant } =
useTranscriptionState()
useEffect(() => {
if (!room) return
room.on(RoomEvent.TranscriptionReceived, updateTranscriptionSegments)
room.on(RoomEvent.TranscriptionReceived, updateTranscriptions)
return () => {
room.off(RoomEvent.TranscriptionReceived, updateTranscriptionSegments)
room.off(RoomEvent.TranscriptionReceived, updateTranscriptions)
}
}, [room, updateTranscriptionSegments])
}, [room, updateTranscriptions])
const transcriptionRows = useMemo(() => {
if (transcriptionSegments.length === 0) return []
const rows: TranscriptionRow[] = []
let currentRow: TranscriptionRow | null = null
for (const segment of transcriptionSegments) {
const shouldStartNewRow =
!currentRow ||
currentRow.participant.identity !== segment.participant.identity
if (shouldStartNewRow) {
currentRow = {
id: `${segment.participant.identity}-${segment.firstReceivedTime}`,
participant: segment.participant,
segments: [segment],
startTime: segment.startTime,
lastUpdateTime: segment.lastReceivedTime,
lastReceivedTime: segment.lastReceivedTime,
}
rows.push(currentRow)
} else if (currentRow) {
currentRow.segments.push(segment)
currentRow.lastUpdateTime = Math.max(
currentRow.lastUpdateTime,
segment.lastReceivedTime
)
currentRow.lastReceivedTime = Math.max(
currentRow.lastReceivedTime,
segment.lastReceivedTime
)
}
useEffect(() => {
if (!room) return
room.on(RoomEvent.ParticipantNameChanged, updateParticipant)
return () => {
room.off(RoomEvent.ParticipantNameChanged, updateParticipant)
}
return rows
}, [transcriptionSegments])
}, [room, updateParticipant])
return (
<SubtitlesWrapper areOpen={areSubtitlesOpen}>
-1
View File
@@ -86,7 +86,6 @@
"heading": "Ihr Meeting ist bereit",
"description": "Teilen Sie diesen Link mit Personen, die Sie zum Meeting einladen möchten.",
"permissions": "Personen mit diesem Link benötigen keine Erlaubnis, um diesem Meeting beizutreten.",
"closeDialog": "Dialogfenster schließen",
"phone": {
"call": "Rufen Sie an:",
"pinCode": "Code:"
-1
View File
@@ -86,7 +86,6 @@
"heading": "Your meeting is ready",
"description": "Share this link with people you want to invite to the meeting.",
"permissions": "People with this link do not need your permission to join this meeting.",
"closeDialog": "Close dialog",
"phone": {
"call": "Call:",
"pinCode": "Code:"
-1
View File
@@ -86,7 +86,6 @@
"heading": "Votre réunion est prête",
"description": "Partagez ces informations avec les personnes que vous souhaitez inviter à la réunion.",
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion.",
"closeDialog": "Fermer la fenêtre modale",
"phone": {
"call": "Appelez le :",
"pinCode": "Code :"
-1
View File
@@ -86,7 +86,6 @@
"heading": "Uw vergadering is klaar",
"description": "Deel deze link met mensen die u wilt uitnodigen voor de vergadering.",
"permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering.",
"closeDialog": "Sluit het dialoogvenster",
"phone": {
"call": "Bel:",
"pinCode": "Code:"