From 967e8a1e9233b3ec099b038e3744f24a763de38c Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 23 Aug 2024 10:22:16 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8D=8E=20refactor(a11y):=20Optimize=20Liv?= =?UTF-8?q?e=20Region=20Announcements=20for=20Apple=20VoiceOver=20(#3762)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: first pass rewrite * refactor: update CLEAR_DELAY to 5000 milliseconds in LiveAnnouncer.tsx * refactor: assertive messages to clear queue immediately, fix circular useCallback dependency issue * chore: comment --- client/src/a11y/Announcer.tsx | 20 ++--- client/src/a11y/LiveAnnouncer.tsx | 117 +++++++++++++++++------------- 2 files changed, 79 insertions(+), 58 deletions(-) diff --git a/client/src/a11y/Announcer.tsx b/client/src/a11y/Announcer.tsx index fcbbb51d66..36a6b2a1b8 100644 --- a/client/src/a11y/Announcer.tsx +++ b/client/src/a11y/Announcer.tsx @@ -1,18 +1,20 @@ +// client/src/a11y/Announcer.tsx import React from 'react'; -import MessageBlock from './MessageBlock'; interface AnnouncerProps { - politeMessage: string; - politeMessageId: string; - assertiveMessage: string; - assertiveMessageId: string; + statusMessage: string; + responseMessage: string; } -const Announcer: React.FC = ({ politeMessage, assertiveMessage }) => { +const Announcer: React.FC = ({ statusMessage, responseMessage }) => { return ( -
- - +
+
+ {statusMessage} +
+
+ {responseMessage} +
); }; diff --git a/client/src/a11y/LiveAnnouncer.tsx b/client/src/a11y/LiveAnnouncer.tsx index b5e7213d94..e406856e7c 100644 --- a/client/src/a11y/LiveAnnouncer.tsx +++ b/client/src/a11y/LiveAnnouncer.tsx @@ -1,3 +1,4 @@ +// client/src/a11y/LiveAnnouncer.tsx import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { findLastSeparatorIndex } from 'librechat-data-provider'; import type { AnnounceOptions } from '~/Providers/AnnouncerContext'; @@ -15,30 +16,35 @@ interface AnnouncementItem { isAssertive: boolean; } -const CHUNK_SIZE = 50; -const MIN_ANNOUNCEMENT_DELAY = 400; +/** Chunk size for processing text */ +const CHUNK_SIZE = 200; +/** Minimum delay between announcements */ +const MIN_ANNOUNCEMENT_DELAY = 1000; +/** Delay before clearing the live region */ +const CLEAR_DELAY = 5000; /** Regex to remove *, `, and _ from message text */ const replacementRegex = /[*`_]/g; const LiveAnnouncer: React.FC = ({ children }) => { - const [politeMessageId, setPoliteMessageId] = useState(''); - const [assertiveMessageId, setAssertiveMessageId] = useState(''); - const [announcePoliteMessage, setAnnouncePoliteMessage] = useState(''); - const [announceAssertiveMessage, setAnnounceAssertiveMessage] = useState(''); + const [statusMessage, setStatusMessage] = useState(''); + const [responseMessage, setResponseMessage] = useState(''); const counterRef = useRef(0); const isAnnouncingRef = useRef(false); const politeProcessedTextRef = useRef(''); const queueRef = useRef([]); const timeoutRef = useRef(null); + const lastAnnouncementTimeRef = useRef(0); const localize = useLocalize(); + /** Generates a unique ID for announcement messages */ const generateUniqueId = (prefix: string) => { counterRef.current += 1; return `${prefix}-${counterRef.current}`; }; + /** Processes the text in chunks and returns a chunk of text */ const processChunks = (text: string, processedTextRef: React.MutableRefObject) => { const remainingText = text.slice(processedTextRef.current.length); @@ -73,59 +79,76 @@ const LiveAnnouncer: React.FC = ({ children }) => { [localize], ); - const announceNextInQueue = useCallback(() => { - if (queueRef.current.length > 0 && !isAnnouncingRef.current) { - isAnnouncingRef.current = true; - const nextAnnouncement = queueRef.current.shift(); - if (nextAnnouncement) { - const { message: _msg, id, isAssertive } = nextAnnouncement; - const setMessage = isAssertive ? setAnnounceAssertiveMessage : setAnnouncePoliteMessage; - const setMessageId = isAssertive ? setAssertiveMessageId : setPoliteMessageId; + const announceMessage = useCallback( + (message: string, isAssertive: boolean) => { + const setMessage = isAssertive ? setStatusMessage : setResponseMessage; + setMessage(message); - setMessage(''); - setMessageId(''); - - /* Force a re-render before setting the new message */ - setTimeout(() => { - const message = (events[_msg] ?? _msg).replace(replacementRegex, ''); - setMessage(message); - setMessageId(id); - - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - - timeoutRef.current = setTimeout(() => { - isAnnouncingRef.current = false; - announceNextInQueue(); - }, MIN_ANNOUNCEMENT_DELAY); - }, 0); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); } - } - }, [events]); + + lastAnnouncementTimeRef.current = Date.now(); + isAnnouncingRef.current = true; + + timeoutRef.current = setTimeout( + () => { + isAnnouncingRef.current = false; + setMessage(''); // Clear the message after a delay + if (queueRef.current.length > 0) { + const nextAnnouncement = queueRef.current.shift(); + if (nextAnnouncement) { + const { message: _msg, isAssertive } = nextAnnouncement; + const nextMessage = (events[_msg] ?? _msg).replace(replacementRegex, ''); + announceMessage(nextMessage, isAssertive); + } + } + }, + isAssertive ? MIN_ANNOUNCEMENT_DELAY : CLEAR_DELAY, + ); + }, + [events], + ); const addToQueue = useCallback( (item: AnnouncementItem) => { - queueRef.current.push(item); - announceNextInQueue(); + if (item.isAssertive) { + /* For assertive messages, clear the queue and announce immediately */ + queueRef.current = []; + const { message: _msg, isAssertive } = item; + const message = (events[_msg] ?? _msg).replace(replacementRegex, ''); + announceMessage(message, isAssertive); + } else { + queueRef.current.push(item); + if (!isAnnouncingRef.current) { + const nextAnnouncement = queueRef.current.shift(); + if (nextAnnouncement) { + const { message: _msg, isAssertive } = nextAnnouncement; + const message = (events[_msg] ?? _msg).replace(replacementRegex, ''); + announceMessage(message, isAssertive); + } + } + } }, - [announceNextInQueue], + [events, announceMessage], ); + /** Announces a polite message */ const announcePolite = useCallback( ({ message, id, isStream = false, isComplete = false }: AnnounceOptions) => { const announcementId = id ?? generateUniqueId('polite'); - if (isStream) { + if (isStream || isComplete) { const chunk = processChunks(message, politeProcessedTextRef); if (chunk) { addToQueue({ message: chunk, id: announcementId, isAssertive: false }); } - } else if (isComplete) { - const remainingText = message.slice(politeProcessedTextRef.current.length); - if (remainingText.trim()) { - addToQueue({ message: remainingText.trim(), id: announcementId, isAssertive: false }); + if (isComplete) { + const remainingText = message.slice(politeProcessedTextRef.current.length); + if (remainingText.trim()) { + addToQueue({ message: remainingText.trim(), id: announcementId, isAssertive: false }); + } + politeProcessedTextRef.current = ''; } - politeProcessedTextRef.current = ''; } else { addToQueue({ message, id: announcementId, isAssertive: false }); politeProcessedTextRef.current = ''; @@ -134,6 +157,7 @@ const LiveAnnouncer: React.FC = ({ children }) => { [addToQueue], ); + /** Announces an assertive message */ const announceAssertive = useCallback( ({ message, id }: AnnounceOptions) => { const announcementId = id ?? generateUniqueId('assertive'); @@ -160,12 +184,7 @@ const LiveAnnouncer: React.FC = ({ children }) => { return ( {children} - + ); };