From 60c2c97ba64bfc7a7dcc319365a13b5c16a65973 Mon Sep 17 00:00:00 2001 From: Lotus Bot Date: Wed, 20 May 2026 21:26:18 -0400 Subject: [PATCH] fix(a11y,bug): aria-labels on dialogs/buttons, useAlive GIF guard, typing timer fix A11y: - Add aria-label Close to RoomTopicViewer, ImagePackView close buttons - Add aria-label Cancel to LeaveRoomPrompt cancel button - Add aria-label to Send, Reply, Thread, Edit, React, Search, Mute, Download buttons - Fix aria-pressed -> aria-expanded + aria-haspopup on menu anchor triggers - Add aria-label to username/password auth inputs BUG-21: Add useAlive unmount guard to handleGifSelect error path in RoomInput BUG-22: Fix typing status timer accumulation with typingTimerRef Co-Authored-By: Claude Sonnet 4.6 --- .../image-pack-view/ImagePackView.tsx | 2 +- .../leave-room-prompt/LeaveRoomPrompt.tsx | 2 +- src/app/components/message/FileHeader.tsx | 1 + .../message/content/AudioContent.tsx | 1 + .../room-topic-viewer/RoomTopicViewer.tsx | 2 +- src/app/features/room/RoomInput.tsx | 12 ++++++++++-- src/app/features/room/RoomViewHeader.tsx | 2 +- src/app/features/room/message/Message.tsx | 18 ++++++++++++------ src/app/hooks/useTypingStatusUpdater.ts | 8 +++++--- src/app/pages/auth/login/PasswordLoginForm.tsx | 3 ++- 10 files changed, 35 insertions(+), 16 deletions(-) diff --git a/src/app/components/image-pack-view/ImagePackView.tsx b/src/app/components/image-pack-view/ImagePackView.tsx index ab81d5031..fb0e189be 100644 --- a/src/app/components/image-pack-view/ImagePackView.tsx +++ b/src/app/components/image-pack-view/ImagePackView.tsx @@ -29,7 +29,7 @@ export function ImagePackView({ address, requestClose }: ImagePackViewProps) { - + diff --git a/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx index 217491e68..74244dde1 100644 --- a/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx +++ b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx @@ -68,7 +68,7 @@ export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptPro Leave Room - + diff --git a/src/app/components/message/FileHeader.tsx b/src/app/components/message/FileHeader.tsx index 2ffc9ec45..542bd515e 100644 --- a/src/app/components/message/FileHeader.tsx +++ b/src/app/components/message/FileHeader.tsx @@ -48,6 +48,7 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow variant={hasError ? 'Critical' : 'SurfaceVariant'} size="300" radii="300" + aria-label={downloading ? 'Downloading...' : hasError ? 'Download failed, click to retry' : 'Download file'} > {downloading ? ( diff --git a/src/app/components/message/content/AudioContent.tsx b/src/app/components/message/content/AudioContent.tsx index 08019b1c7..34bc1583d 100644 --- a/src/app/components/message/content/AudioContent.tsx +++ b/src/app/components/message/content/AudioContent.tsx @@ -173,6 +173,7 @@ export function AudioContent({ size="300" radii="Pill" onClick={() => setMute(!mute)} + aria-label={mute ? 'Unmute' : 'Mute'} aria-pressed={mute} > diff --git a/src/app/components/room-topic-viewer/RoomTopicViewer.tsx b/src/app/components/room-topic-viewer/RoomTopicViewer.tsx index 521287722..c6eae43f4 100644 --- a/src/app/components/room-topic-viewer/RoomTopicViewer.tsx +++ b/src/app/components/room-topic-viewer/RoomTopicViewer.tsx @@ -26,7 +26,7 @@ export const RoomTopicViewer = as< {name} - + diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index f8b1ee5ee..6a76c0737 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -94,6 +94,7 @@ import { getImageUrlBlob, loadImageElement } from '../../utils/dom'; import { safeFile } from '../../utils/mimeTypes'; import { fulfilledPromiseSettledResult } from '../../utils/common'; import { useSetting } from '../../state/hooks/settings'; +import { useAlive } from '../../hooks/useAlive'; import { settingsAtom } from '../../state/settings'; import { getAudioMsgContent, @@ -141,6 +142,7 @@ export const RoomInput = forwardRef( const powerLevels = usePowerLevelsContext(); const creators = useRoomCreators(room); + const alive = useAlive(); const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId)); const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId)); const replyUserID = replyDraft?.userId; @@ -250,9 +252,14 @@ export const RoomInput = forwardRef( useCallback((width) => setHideStickerBtn(width < 500), []) ); + const didRestoreDraft = React.useRef(false); useEffect(() => { - Transforms.insertFragment(editor, msgDraft); - }, [editor, msgDraft]); + if (didRestoreDraft.current) return; + didRestoreDraft.current = true; + if (msgDraft.length > 0) { + Transforms.insertFragment(editor, msgDraft); + } + }, [editor]); useEffect( () => () => { @@ -490,6 +497,7 @@ export const RoomInput = forwardRef( }); } catch (e) { console.error('GIF send failed', e); + if (!alive()) return; setGifError('Failed to send GIF. Please try again.'); setTimeout(() => setGifError(null), 4000); } diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 6db36889b..b40c108b4 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -532,7 +532,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { } > {(triggerRef) => ( - + )} diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index a716bb7f6..44fe99a28 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -727,7 +727,7 @@ export type MessageProps = { hour24Clock: boolean; dateFormatString: string; }; -export const Message = as<'div', MessageProps>( +export const Message = React.memo(as<'div', MessageProps>( ( { className, @@ -984,6 +984,7 @@ export const Message = as<'div', MessageProps>( variant="SurfaceVariant" size="300" radii="300" + aria-label="Add reaction" aria-pressed={!!emojiBoardAnchor} > @@ -996,6 +997,7 @@ export const Message = as<'div', MessageProps>( variant="SurfaceVariant" size="300" radii="300" + aria-label="Reply" > @@ -1006,6 +1008,7 @@ export const Message = as<'div', MessageProps>( variant="SurfaceVariant" size="300" radii="300" + aria-label="Reply in thread" > @@ -1016,6 +1019,7 @@ export const Message = as<'div', MessageProps>( variant="SurfaceVariant" size="300" radii="300" + aria-label="Edit message" > @@ -1201,7 +1205,8 @@ export const Message = as<'div', MessageProps>( size="300" radii="300" onClick={handleOpenMenu} - aria-pressed={!!menuAnchor} + aria-expanded={!!menuAnchor} + aria-haspopup="menu" > @@ -1232,7 +1237,7 @@ export const Message = as<'div', MessageProps>( ); } -); +)); export type EventProps = { room: Room; @@ -1243,7 +1248,7 @@ export type EventProps = { hideReadReceipts?: boolean; showDeveloperTools?: boolean; }; -export const Event = as<'div', EventProps>( +export const Event = React.memo(as<'div', EventProps>( ( { className, @@ -1370,7 +1375,8 @@ export const Event = as<'div', EventProps>( size="300" radii="300" onClick={handleOpenMenu} - aria-pressed={!!menuAnchor} + aria-expanded={!!menuAnchor} + aria-haspopup="menu" > @@ -1383,4 +1389,4 @@ export const Event = as<'div', EventProps>( ); } -); +)); diff --git a/src/app/hooks/useTypingStatusUpdater.ts b/src/app/hooks/useTypingStatusUpdater.ts index db8ceff1e..6dea1bcd8 100644 --- a/src/app/hooks/useTypingStatusUpdater.ts +++ b/src/app/hooks/useTypingStatusUpdater.ts @@ -6,6 +6,7 @@ type TypingStatusUpdater = (typing: boolean) => void; export const useTypingStatusUpdater = (mx: MatrixClient, roomId: string): TypingStatusUpdater => { const statusSentTsRef = useRef(0); + const typingTimerRef = useRef | undefined>(undefined); const sendTypingStatus: TypingStatusUpdater = useMemo(() => { statusSentTsRef.current = 0; @@ -19,9 +20,10 @@ export const useTypingStatusUpdater = (mx: MatrixClient, roomId: string): Typing const sentTs = Date.now(); statusSentTsRef.current = sentTs; - // Don't believe server will timeout typing status; - // Clear typing status after timeout if already not; - setTimeout(() => { + // Cancel any previous pending timeout before scheduling a new one + if (typingTimerRef.current !== undefined) clearTimeout(typingTimerRef.current); + typingTimerRef.current = setTimeout(() => { + typingTimerRef.current = undefined; if (statusSentTsRef.current === sentTs) { mx.sendTyping(roomId, false, TYPING_TIMEOUT_MS); statusSentTsRef.current = 0; diff --git a/src/app/pages/auth/login/PasswordLoginForm.tsx b/src/app/pages/auth/login/PasswordLoginForm.tsx index 24faf680c..dc47e6913 100644 --- a/src/app/pages/auth/login/PasswordLoginForm.tsx +++ b/src/app/pages/auth/login/PasswordLoginForm.tsx @@ -206,6 +206,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog defaultValue={defaultUsername ?? defaultEmail} style={{ paddingRight: config.space.S300 }} name="usernameInput" + aria-label="Username or email" variant="Background" size="500" required @@ -227,7 +228,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog Password - + {loginState.status === AsyncStatus.Error && ( <>