🔊 refactor: Optimize Aria-Live Announcements for macOS VoiceOver (#3851)

This commit is contained in:
Danny Avila 2024-08-30 00:14:37 -04:00 committed by GitHub
parent 757b6d3275
commit dc40e577af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 59 additions and 170 deletions

View file

@ -3,9 +3,7 @@ import React from 'react';
export interface AnnounceOptions {
message: string;
id?: string;
isStream?: boolean;
isComplete?: boolean;
isStatus?: boolean;
}
interface AnnouncerContextType {

View file

@ -3,17 +3,17 @@ import React from 'react';
interface AnnouncerProps {
statusMessage: string;
responseMessage: string;
logMessage: string;
}
const Announcer: React.FC<AnnouncerProps> = ({ statusMessage, responseMessage }) => {
const Announcer: React.FC<AnnouncerProps> = ({ statusMessage, logMessage }) => {
return (
<div className="sr-only">
<div aria-live="assertive" aria-atomic="true">
<div aria-live="polite" aria-atomic="true">
{statusMessage}
</div>
<div aria-live="polite" aria-atomic="true">
{responseMessage}
{logMessage}
</div>
</div>
);

View file

@ -1,6 +1,5 @@
// 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';
import AnnouncerContext from '~/Providers/AnnouncerContext';
import useLocalize from '~/hooks/useLocalize';
@ -10,161 +9,53 @@ interface LiveAnnouncerProps {
children: React.ReactNode;
}
interface AnnouncementItem {
message: string;
id: string;
isAssertive: boolean;
}
/** 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<LiveAnnouncerProps> = ({ children }) => {
const [statusMessage, setStatusMessage] = useState('');
const [responseMessage, setResponseMessage] = useState('');
const [logMessage, setLogMessage] = useState('');
const counterRef = useRef(0);
const isAnnouncingRef = useRef(false);
const politeProcessedTextRef = useRef('');
const queueRef = useRef<AnnouncementItem[]>([]);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastAnnouncementTimeRef = useRef(0);
const statusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
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<string>) => {
const remainingText = text.slice(processedTextRef.current.length);
if (remainingText.length < CHUNK_SIZE) {
return ''; /* Not enough characters to process */
}
let separatorIndex = -1;
let startIndex = CHUNK_SIZE;
while (separatorIndex === -1 && startIndex <= remainingText.length) {
separatorIndex = findLastSeparatorIndex(remainingText.slice(startIndex));
if (separatorIndex !== -1) {
separatorIndex += startIndex; /* Adjust the index to account for the starting position */
} else {
startIndex += CHUNK_SIZE; /* Move the starting position by another CHUNK_SIZE characters */
}
}
if (separatorIndex === -1) {
return ''; /* No separator found, wait for more text */
}
const chunkText = remainingText.slice(0, separatorIndex + 1);
processedTextRef.current += chunkText;
return chunkText.trim();
};
/** Localized event announcements, i.e., "the AI is replying, finished, etc." */
const events: Record<string, string | undefined> = useMemo(
() => ({ start: localize('com_a11y_start'), end: localize('com_a11y_end') }),
() => ({
start: localize('com_a11y_start'),
end: localize('com_a11y_end'),
composing: localize('com_a11y_ai_composing'),
}),
[localize],
);
const announceMessage = useCallback(
(message: string, isAssertive: boolean) => {
const setMessage = isAssertive ? setStatusMessage : setResponseMessage;
setMessage(message);
const announceStatus = useCallback((message: string) => {
if (statusTimeoutRef.current) {
clearTimeout(statusTimeoutRef.current);
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
setStatusMessage(message);
lastAnnouncementTimeRef.current = Date.now();
isAnnouncingRef.current = true;
statusTimeoutRef.current = setTimeout(() => {
setStatusMessage('');
}, 1000);
}, []);
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 announceLog = useCallback((message: string) => {
setLogMessage(message);
}, []);
const addToQueue = useCallback(
(item: AnnouncementItem) => {
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);
}
}
}
},
[events, announceMessage],
);
/** Announces a polite message */
const announcePolite = useCallback(
({ message, id, isStream = false, isComplete = false }: AnnounceOptions) => {
const announcementId = id ?? generateUniqueId('polite');
if (isStream || isComplete) {
const chunk = processChunks(message, politeProcessedTextRef);
if (chunk) {
addToQueue({ message: chunk, 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 = '';
}
({ message, isStatus = false }: AnnounceOptions) => {
const finalMessage = (events[message] ?? message).replace(/[*`_]/g, '');
if (isStatus) {
announceStatus(finalMessage);
} else {
addToQueue({ message, id: announcementId, isAssertive: false });
politeProcessedTextRef.current = '';
announceLog(finalMessage);
}
},
[addToQueue],
[events, announceStatus, announceLog],
);
/** Announces an assertive message */
const announceAssertive = useCallback(
({ message, id }: AnnounceOptions) => {
const announcementId = id ?? generateUniqueId('assertive');
addToQueue({ message, id: announcementId, isAssertive: true });
},
[addToQueue],
);
const announceAssertive = announcePolite;
const contextValue = {
announcePolite,
@ -173,10 +64,8 @@ const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
useEffect(() => {
return () => {
queueRef.current = [];
isAnnouncingRef.current = false;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
if (statusTimeoutRef.current) {
clearTimeout(statusTimeoutRef.current);
}
};
}, []);
@ -184,7 +73,7 @@ const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
return (
<AnnouncerContext.Provider value={contextValue}>
{children}
<Announcer statusMessage={statusMessage} responseMessage={responseMessage} />
<Announcer statusMessage={statusMessage} logMessage={logMessage} />
</AnnouncerContext.Provider>
);
};

View file

@ -1,5 +1,5 @@
import { v4 } from 'uuid';
import { useCallback } from 'react';
import { useCallback, useRef } from 'react';
import { useSetRecoilState } from 'recoil';
import { useParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
@ -54,6 +54,8 @@ export type EventHandlerParams = {
resetLatestMessage?: Resetter;
};
const MESSAGE_UPDATE_INTERVAL = 7000;
export default function useEventHandlers({
genTitle,
setMessages,
@ -68,8 +70,9 @@ export default function useEventHandlers({
}: EventHandlerParams) {
const queryClient = useQueryClient();
const setAbortScroll = useSetRecoilState(store.abortScroll);
const { announcePolite, announceAssertive } = useLiveAnnouncer();
const { announcePolite } = useLiveAnnouncer();
const lastAnnouncementTimeRef = useRef(Date.now());
const { conversationId: paramId } = useParams();
const { token } = useAuthContext();
@ -87,11 +90,11 @@ export default function useEventHandlers({
} = submission;
const text = data ?? '';
setIsSubmitting(true);
if (text.length > 0) {
announcePolite({
message: text,
isStream: true,
});
const currentTime = Date.now();
if (currentTime - lastAnnouncementTimeRef.current > MESSAGE_UPDATE_INTERVAL) {
announcePolite({ message: 'composing', isStatus: true });
lastAnnouncementTimeRef.current = currentTime;
}
if (isRegenerate) {
@ -187,9 +190,9 @@ export default function useEventHandlers({
},
]);
announceAssertive({
announcePolite({
message: 'start',
id: `start-${Date.now()}`,
isStatus: true,
});
let update = {} as TConversation;
@ -245,8 +248,8 @@ export default function useEventHandlers({
queryClient,
setMessages,
isAddedRequest,
announcePolite,
setConversation,
announceAssertive,
setShowStopButton,
resetLatestMessage,
],
@ -267,9 +270,10 @@ export default function useEventHandlers({
}
const { conversationId, parentMessageId } = userMessage;
announceAssertive({
lastAnnouncementTimeRef.current = Date.now();
announcePolite({
message: 'start',
id: `start-${Date.now()}`,
isStatus: true,
});
let update = {} as TConversation;
@ -323,8 +327,8 @@ export default function useEventHandlers({
queryClient,
setAbortScroll,
isAddedRequest,
announcePolite,
setConversation,
announceAssertive,
resetLatestMessage,
],
);
@ -345,16 +349,13 @@ export default function useEventHandlers({
/* a11y announcements */
announcePolite({
message: responseMessage?.text ?? '',
isComplete: true,
message: 'end',
isStatus: true,
});
setTimeout(() => {
announcePolite({
message: 'end',
id: `end-${Date.now()}`,
});
}, 100);
announcePolite({
message: responseMessage?.text ?? '',
});
/* Update messages; if assistants endpoint, client doesn't receive responseMessage */
if (runMessages) {

View file

@ -14,7 +14,8 @@ export default {
com_nav_info_custom_prompt_mode:
'When enabled, the default artifacts system prompt will not be included. All artifact-generating instructions must be provided manually in this mode.',
com_ui_artifact_click: 'Click to open',
com_a11y_start: 'The AI is replying.',
com_a11y_start: 'The AI has started their reply.',
com_a11y_ai_composing: 'The AI is still composing.',
com_a11y_end: 'The AI has finished their reply.',
com_error_moderation:
'It appears that the content submitted has been flagged by our moderation system for not aligning with our community guidelines. We\'re unable to proceed with this specific topic. If you have any other questions or topics you\'d like to explore, please edit your message, or create a new conversation.',