Skip to content

Commit dc40e57

Browse files
authored
🔊 refactor: Optimize Aria-Live Announcements for macOS VoiceOver (danny-avila#3851)
1 parent 757b6d3 commit dc40e57

File tree

5 files changed

+59
-170
lines changed

5 files changed

+59
-170
lines changed

client/src/Providers/AnnouncerContext.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@ import React from 'react';
33

44
export interface AnnounceOptions {
55
message: string;
6-
id?: string;
7-
isStream?: boolean;
8-
isComplete?: boolean;
6+
isStatus?: boolean;
97
}
108

119
interface AnnouncerContextType {

client/src/a11y/Announcer.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,17 @@ import React from 'react';
33

44
interface AnnouncerProps {
55
statusMessage: string;
6-
responseMessage: string;
6+
logMessage: string;
77
}
88

9-
const Announcer: React.FC<AnnouncerProps> = ({ statusMessage, responseMessage }) => {
9+
const Announcer: React.FC<AnnouncerProps> = ({ statusMessage, logMessage }) => {
1010
return (
1111
<div className="sr-only">
12-
<div aria-live="assertive" aria-atomic="true">
12+
<div aria-live="polite" aria-atomic="true">
1313
{statusMessage}
1414
</div>
1515
<div aria-live="polite" aria-atomic="true">
16-
{responseMessage}
16+
{logMessage}
1717
</div>
1818
</div>
1919
);

client/src/a11y/LiveAnnouncer.tsx

Lines changed: 30 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
// client/src/a11y/LiveAnnouncer.tsx
22
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
3-
import { findLastSeparatorIndex } from 'librechat-data-provider';
43
import type { AnnounceOptions } from '~/Providers/AnnouncerContext';
54
import AnnouncerContext from '~/Providers/AnnouncerContext';
65
import useLocalize from '~/hooks/useLocalize';
@@ -10,161 +9,53 @@ interface LiveAnnouncerProps {
109
children: React.ReactNode;
1110
}
1211

13-
interface AnnouncementItem {
14-
message: string;
15-
id: string;
16-
isAssertive: boolean;
17-
}
18-
19-
/** Chunk size for processing text */
20-
const CHUNK_SIZE = 200;
21-
/** Minimum delay between announcements */
22-
const MIN_ANNOUNCEMENT_DELAY = 1000;
23-
/** Delay before clearing the live region */
24-
const CLEAR_DELAY = 5000;
25-
/** Regex to remove *, `, and _ from message text */
26-
const replacementRegex = /[*`_]/g;
27-
2812
const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
2913
const [statusMessage, setStatusMessage] = useState('');
30-
const [responseMessage, setResponseMessage] = useState('');
14+
const [logMessage, setLogMessage] = useState('');
3115

32-
const counterRef = useRef(0);
33-
const isAnnouncingRef = useRef(false);
34-
const politeProcessedTextRef = useRef('');
35-
const queueRef = useRef<AnnouncementItem[]>([]);
36-
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
37-
const lastAnnouncementTimeRef = useRef(0);
16+
const statusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
3817

3918
const localize = useLocalize();
4019

41-
/** Generates a unique ID for announcement messages */
42-
const generateUniqueId = (prefix: string) => {
43-
counterRef.current += 1;
44-
return `${prefix}-${counterRef.current}`;
45-
};
46-
47-
/** Processes the text in chunks and returns a chunk of text */
48-
const processChunks = (text: string, processedTextRef: React.MutableRefObject<string>) => {
49-
const remainingText = text.slice(processedTextRef.current.length);
50-
51-
if (remainingText.length < CHUNK_SIZE) {
52-
return ''; /* Not enough characters to process */
53-
}
54-
55-
let separatorIndex = -1;
56-
let startIndex = CHUNK_SIZE;
57-
58-
while (separatorIndex === -1 && startIndex <= remainingText.length) {
59-
separatorIndex = findLastSeparatorIndex(remainingText.slice(startIndex));
60-
if (separatorIndex !== -1) {
61-
separatorIndex += startIndex; /* Adjust the index to account for the starting position */
62-
} else {
63-
startIndex += CHUNK_SIZE; /* Move the starting position by another CHUNK_SIZE characters */
64-
}
65-
}
66-
67-
if (separatorIndex === -1) {
68-
return ''; /* No separator found, wait for more text */
69-
}
70-
71-
const chunkText = remainingText.slice(0, separatorIndex + 1);
72-
processedTextRef.current += chunkText;
73-
return chunkText.trim();
74-
};
75-
76-
/** Localized event announcements, i.e., "the AI is replying, finished, etc." */
7720
const events: Record<string, string | undefined> = useMemo(
78-
() => ({ start: localize('com_a11y_start'), end: localize('com_a11y_end') }),
21+
() => ({
22+
start: localize('com_a11y_start'),
23+
end: localize('com_a11y_end'),
24+
composing: localize('com_a11y_ai_composing'),
25+
}),
7926
[localize],
8027
);
8128

82-
const announceMessage = useCallback(
83-
(message: string, isAssertive: boolean) => {
84-
const setMessage = isAssertive ? setStatusMessage : setResponseMessage;
85-
setMessage(message);
29+
const announceStatus = useCallback((message: string) => {
30+
if (statusTimeoutRef.current) {
31+
clearTimeout(statusTimeoutRef.current);
32+
}
8633

87-
if (timeoutRef.current) {
88-
clearTimeout(timeoutRef.current);
89-
}
34+
setStatusMessage(message);
9035

91-
lastAnnouncementTimeRef.current = Date.now();
92-
isAnnouncingRef.current = true;
93-
94-
timeoutRef.current = setTimeout(
95-
() => {
96-
isAnnouncingRef.current = false;
97-
setMessage(''); // Clear the message after a delay
98-
if (queueRef.current.length > 0) {
99-
const nextAnnouncement = queueRef.current.shift();
100-
if (nextAnnouncement) {
101-
const { message: _msg, isAssertive } = nextAnnouncement;
102-
const nextMessage = (events[_msg] ?? _msg).replace(replacementRegex, '');
103-
announceMessage(nextMessage, isAssertive);
104-
}
105-
}
106-
},
107-
isAssertive ? MIN_ANNOUNCEMENT_DELAY : CLEAR_DELAY,
108-
);
109-
},
110-
[events],
111-
);
36+
statusTimeoutRef.current = setTimeout(() => {
37+
setStatusMessage('');
38+
}, 1000);
39+
}, []);
11240

113-
const addToQueue = useCallback(
114-
(item: AnnouncementItem) => {
115-
if (item.isAssertive) {
116-
/* For assertive messages, clear the queue and announce immediately */
117-
queueRef.current = [];
118-
const { message: _msg, isAssertive } = item;
119-
const message = (events[_msg] ?? _msg).replace(replacementRegex, '');
120-
announceMessage(message, isAssertive);
121-
} else {
122-
queueRef.current.push(item);
123-
if (!isAnnouncingRef.current) {
124-
const nextAnnouncement = queueRef.current.shift();
125-
if (nextAnnouncement) {
126-
const { message: _msg, isAssertive } = nextAnnouncement;
127-
const message = (events[_msg] ?? _msg).replace(replacementRegex, '');
128-
announceMessage(message, isAssertive);
129-
}
130-
}
131-
}
132-
},
133-
[events, announceMessage],
134-
);
41+
const announceLog = useCallback((message: string) => {
42+
setLogMessage(message);
43+
}, []);
13544

136-
/** Announces a polite message */
13745
const announcePolite = useCallback(
138-
({ message, id, isStream = false, isComplete = false }: AnnounceOptions) => {
139-
const announcementId = id ?? generateUniqueId('polite');
140-
if (isStream || isComplete) {
141-
const chunk = processChunks(message, politeProcessedTextRef);
142-
if (chunk) {
143-
addToQueue({ message: chunk, id: announcementId, isAssertive: false });
144-
}
145-
if (isComplete) {
146-
const remainingText = message.slice(politeProcessedTextRef.current.length);
147-
if (remainingText.trim()) {
148-
addToQueue({ message: remainingText.trim(), id: announcementId, isAssertive: false });
149-
}
150-
politeProcessedTextRef.current = '';
151-
}
46+
({ message, isStatus = false }: AnnounceOptions) => {
47+
const finalMessage = (events[message] ?? message).replace(/[*`_]/g, '');
48+
49+
if (isStatus) {
50+
announceStatus(finalMessage);
15251
} else {
153-
addToQueue({ message, id: announcementId, isAssertive: false });
154-
politeProcessedTextRef.current = '';
52+
announceLog(finalMessage);
15553
}
15654
},
157-
[addToQueue],
55+
[events, announceStatus, announceLog],
15856
);
15957

160-
/** Announces an assertive message */
161-
const announceAssertive = useCallback(
162-
({ message, id }: AnnounceOptions) => {
163-
const announcementId = id ?? generateUniqueId('assertive');
164-
addToQueue({ message, id: announcementId, isAssertive: true });
165-
},
166-
[addToQueue],
167-
);
58+
const announceAssertive = announcePolite;
16859

16960
const contextValue = {
17061
announcePolite,
@@ -173,18 +64,16 @@ const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
17364

17465
useEffect(() => {
17566
return () => {
176-
queueRef.current = [];
177-
isAnnouncingRef.current = false;
178-
if (timeoutRef.current) {
179-
clearTimeout(timeoutRef.current);
67+
if (statusTimeoutRef.current) {
68+
clearTimeout(statusTimeoutRef.current);
18069
}
18170
};
18271
}, []);
18372

18473
return (
18574
<AnnouncerContext.Provider value={contextValue}>
18675
{children}
187-
<Announcer statusMessage={statusMessage} responseMessage={responseMessage} />
76+
<Announcer statusMessage={statusMessage} logMessage={logMessage} />
18877
</AnnouncerContext.Provider>
18978
);
19079
};

client/src/hooks/SSE/useEventHandlers.ts

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { v4 } from 'uuid';
2-
import { useCallback } from 'react';
2+
import { useCallback, useRef } from 'react';
33
import { useSetRecoilState } from 'recoil';
44
import { useParams } from 'react-router-dom';
55
import { useQueryClient } from '@tanstack/react-query';
@@ -54,6 +54,8 @@ export type EventHandlerParams = {
5454
resetLatestMessage?: Resetter;
5555
};
5656

57+
const MESSAGE_UPDATE_INTERVAL = 7000;
58+
5759
export default function useEventHandlers({
5860
genTitle,
5961
setMessages,
@@ -68,8 +70,9 @@ export default function useEventHandlers({
6870
}: EventHandlerParams) {
6971
const queryClient = useQueryClient();
7072
const setAbortScroll = useSetRecoilState(store.abortScroll);
71-
const { announcePolite, announceAssertive } = useLiveAnnouncer();
73+
const { announcePolite } = useLiveAnnouncer();
7274

75+
const lastAnnouncementTimeRef = useRef(Date.now());
7376
const { conversationId: paramId } = useParams();
7477
const { token } = useAuthContext();
7578

@@ -87,11 +90,11 @@ export default function useEventHandlers({
8790
} = submission;
8891
const text = data ?? '';
8992
setIsSubmitting(true);
90-
if (text.length > 0) {
91-
announcePolite({
92-
message: text,
93-
isStream: true,
94-
});
93+
94+
const currentTime = Date.now();
95+
if (currentTime - lastAnnouncementTimeRef.current > MESSAGE_UPDATE_INTERVAL) {
96+
announcePolite({ message: 'composing', isStatus: true });
97+
lastAnnouncementTimeRef.current = currentTime;
9598
}
9699

97100
if (isRegenerate) {
@@ -187,9 +190,9 @@ export default function useEventHandlers({
187190
},
188191
]);
189192

190-
announceAssertive({
193+
announcePolite({
191194
message: 'start',
192-
id: `start-${Date.now()}`,
195+
isStatus: true,
193196
});
194197

195198
let update = {} as TConversation;
@@ -245,8 +248,8 @@ export default function useEventHandlers({
245248
queryClient,
246249
setMessages,
247250
isAddedRequest,
251+
announcePolite,
248252
setConversation,
249-
announceAssertive,
250253
setShowStopButton,
251254
resetLatestMessage,
252255
],
@@ -267,9 +270,10 @@ export default function useEventHandlers({
267270
}
268271

269272
const { conversationId, parentMessageId } = userMessage;
270-
announceAssertive({
273+
lastAnnouncementTimeRef.current = Date.now();
274+
announcePolite({
271275
message: 'start',
272-
id: `start-${Date.now()}`,
276+
isStatus: true,
273277
});
274278

275279
let update = {} as TConversation;
@@ -323,8 +327,8 @@ export default function useEventHandlers({
323327
queryClient,
324328
setAbortScroll,
325329
isAddedRequest,
330+
announcePolite,
326331
setConversation,
327-
announceAssertive,
328332
resetLatestMessage,
329333
],
330334
);
@@ -345,16 +349,13 @@ export default function useEventHandlers({
345349

346350
/* a11y announcements */
347351
announcePolite({
348-
message: responseMessage?.text ?? '',
349-
isComplete: true,
352+
message: 'end',
353+
isStatus: true,
350354
});
351355

352-
setTimeout(() => {
353-
announcePolite({
354-
message: 'end',
355-
id: `end-${Date.now()}`,
356-
});
357-
}, 100);
356+
announcePolite({
357+
message: responseMessage?.text ?? '',
358+
});
358359

359360
/* Update messages; if assistants endpoint, client doesn't receive responseMessage */
360361
if (runMessages) {

client/src/localization/languages/Eng.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ export default {
1414
com_nav_info_custom_prompt_mode:
1515
'When enabled, the default artifacts system prompt will not be included. All artifact-generating instructions must be provided manually in this mode.',
1616
com_ui_artifact_click: 'Click to open',
17-
com_a11y_start: 'The AI is replying.',
17+
com_a11y_start: 'The AI has started their reply.',
18+
com_a11y_ai_composing: 'The AI is still composing.',
1819
com_a11y_end: 'The AI has finished their reply.',
1920
com_error_moderation:
2021
'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.',

0 commit comments

Comments
 (0)