From d58c445d74201f334c33bb49e0afb768c47442d5 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sun, 24 May 2026 00:02:19 -0400 Subject: [PATCH] ux: reply null state, location error feedback, retry send, reaction keyboard nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reply: distinguish loading (placeholder) from not-found (null) — show "Original message not available" instead of a stuck loading bar - RoomInput: geolocation errors now surface inline (denied / timed out / unsupported); location button shows Spinner during fetch and is disabled - Message menu: Retry Send + Cancel Message items appear when a message is in NOT_SENT or CANCELLED state, calling mx.resendEvent / cancelPendingEvent - ReactionViewer: sidebar gains role=listbox / role=option and ArrowUp/Down keyboard navigation between reactions Co-Authored-By: Claude Sonnet 4.6 --- src/app/components/message/Reply.tsx | 14 ++++-- src/app/features/room/RoomInput.tsx | 46 +++++++++++++------ src/app/features/room/message/Message.tsx | 46 +++++++++++++++++++ .../room/reaction-viewer/ReactionViewer.tsx | 29 +++++++++++- 4 files changed, 115 insertions(+), 20 deletions(-) diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index 28096b507..477c66e79 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -118,11 +118,7 @@ export const Reply = as<'div', ReplyProps>( data-event-id={replyEventId} onClick={onClick} > - {replyEvent !== undefined ? ( - - {badEncryption ? : bodyJSX} - - ) : ( + {replyEvent === undefined ? ( ( maxWidth: '100%', }} /> + ) : replyEvent === null ? ( + + Original message not available + + ) : ( + + {badEncryption ? : bodyJSX} + )} diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index c069a46e0..690a2b5c3 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -24,6 +24,7 @@ import { OverlayCenter, PopOut, Scroll, + Spinner, Text, config, toRem, @@ -183,8 +184,13 @@ export const RoomInput = forwardRef( const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar'); const [locating, setLocating] = React.useState(false); + const [locationError, setLocationError] = React.useState(null); const handleShareLocation = () => { - if (!navigator.geolocation) return; + if (!navigator.geolocation) { + setLocationError('Geolocation not supported.'); + setTimeout(() => setLocationError(null), 4000); + return; + } setLocating(true); navigator.geolocation.getCurrentPosition( (pos) => { @@ -197,7 +203,17 @@ export const RoomInput = forwardRef( geo_uri: geoUri, } as any); }, - () => setLocating(false), + (err) => { + setLocating(false); + const msg = + err.code === 1 + ? 'Location access denied.' + : err.code === 3 + ? 'Location timed out.' + : 'Failed to get location.'; + setLocationError(msg); + setTimeout(() => setLocationError(null), 4000); + }, { timeout: 10000 }, ); }; @@ -858,8 +874,22 @@ export const RoomInput = forwardRef( {gifError} )} + {locationError && ( + + {locationError} + + )} ( title="Share location" > {locating ? ( - - ... - + ) : ( )} diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index c736f2c59..1691063a1 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -1189,6 +1189,52 @@ export const Message = React.memo( )} + {(mEvent.status === EventStatus.NOT_SENT || + mEvent.status === EventStatus.CANCELLED) && ( + <> + + + } + radii="300" + onClick={() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mx as any).resendEvent(mEvent, room); + closeMenu(); + }} + > + + Retry Send + + + } + radii="300" + onClick={() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mx as any).cancelPendingEvent(mEvent); + closeMenu(); + }} + > + + Cancel Message + + + + + )} {((!mEvent.isRedacted() && canDelete) || mEvent.getSender() !== mx.getUserId()) && ( <> diff --git a/src/app/features/room/reaction-viewer/ReactionViewer.tsx b/src/app/features/room/reaction-viewer/ReactionViewer.tsx index 9561979f9..434599cf7 100644 --- a/src/app/features/room/reaction-viewer/ReactionViewer.tsx +++ b/src/app/features/room/reaction-viewer/ReactionViewer.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; import classNames from 'classnames'; import { Avatar, @@ -51,6 +51,21 @@ export const ReactionViewer = as<'div', ReactionViewerProps>( const defaultReaction = reactions.find((reaction) => typeof reaction[0] === 'string'); return defaultReaction ? defaultReaction[0] : ''; }); + const sidebarRef = useRef(null); + + const handleSidebarKeyDown = (e: React.KeyboardEvent) => { + const keys = reactions.map(([k]) => k).filter((k): k is string => typeof k === 'string'); + const currentIdx = keys.indexOf(selectedKey); + if (e.key === 'ArrowDown') { + e.preventDefault(); + const next = keys[(currentIdx + 1) % keys.length]; + if (next) setSelectedKey(next); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + const prev = keys[(currentIdx - 1 + keys.length) % keys.length]; + if (prev) setSelectedKey(prev); + } + }; const getName = (member: RoomMember) => getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId; @@ -74,7 +89,16 @@ export const ReactionViewer = as<'div', ReactionViewerProps>( {...props} ref={ref} > - + {reactions.map(([key, evts]) => { @@ -85,6 +109,7 @@ export const ReactionViewer = as<'div', ReactionViewerProps>( mx={mx} reaction={key} count={evts.size} + role="option" aria-selected={key === selectedKey} onClick={() => setSelectedKey(key)} useAuthentication={useAuthentication}