ux: reply null state, location error feedback, retry send, reaction keyboard nav
CI / Build & Quality Checks (push) Successful in 10m17s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 00:02:19 -04:00
parent f3b5e550f9
commit f8cc11e125
4 changed files with 115 additions and 20 deletions
+9 -5
View File
@@ -118,11 +118,7 @@ export const Reply = as<'div', ReplyProps>(
data-event-id={replyEventId} data-event-id={replyEventId}
onClick={onClick} onClick={onClick}
> >
{replyEvent !== undefined ? ( {replyEvent === undefined ? (
<Text size="T300" truncate>
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
</Text>
) : (
<LinePlaceholder <LinePlaceholder
style={{ style={{
backgroundColor: color.SurfaceVariant.ContainerActive, backgroundColor: color.SurfaceVariant.ContainerActive,
@@ -130,6 +126,14 @@ export const Reply = as<'div', ReplyProps>(
maxWidth: '100%', maxWidth: '100%',
}} }}
/> />
) : replyEvent === null ? (
<Text size="T300" truncate style={{ opacity: 0.5 }}>
<i>Original message not available</i>
</Text>
) : (
<Text size="T300" truncate>
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
</Text>
)} )}
</ReplyLayout> </ReplyLayout>
</Box> </Box>
+33 -13
View File
@@ -24,6 +24,7 @@ import {
OverlayCenter, OverlayCenter,
PopOut, PopOut,
Scroll, Scroll,
Spinner,
Text, Text,
config, config,
toRem, toRem,
@@ -183,8 +184,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar'); const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [locating, setLocating] = React.useState(false); const [locating, setLocating] = React.useState(false);
const [locationError, setLocationError] = React.useState<string | null>(null);
const handleShareLocation = () => { const handleShareLocation = () => {
if (!navigator.geolocation) return; if (!navigator.geolocation) {
setLocationError('Geolocation not supported.');
setTimeout(() => setLocationError(null), 4000);
return;
}
setLocating(true); setLocating(true);
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
(pos) => { (pos) => {
@@ -197,7 +203,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
geo_uri: geoUri, geo_uri: geoUri,
} as any); } 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 }, { timeout: 10000 },
); );
}; };
@@ -858,8 +874,22 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
{gifError} {gifError}
</Text> </Text>
)} )}
{locationError && (
<Text
size="T200"
style={{
color: 'var(--tc-danger-normal)',
padding: '2px 6px',
alignSelf: 'center',
whiteSpace: 'nowrap',
}}
>
{locationError}
</Text>
)}
<IconButton <IconButton
onClick={handleShareLocation} onClick={handleShareLocation}
disabled={locating}
aria-label="Share location" aria-label="Share location"
variant="SurfaceVariant" variant="SurfaceVariant"
size="300" size="300"
@@ -867,17 +897,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
title="Share location" title="Share location"
> >
{locating ? ( {locating ? (
<Text <Spinner variant="Secondary" size="100" />
size="T200"
style={{
fontWeight: 800,
fontSize: '10px',
letterSpacing: '0.04em',
lineHeight: 1,
}}
>
...
</Text>
) : ( ) : (
<Icon src={Icons.SpaceGlobe} size="100" /> <Icon src={Icons.SpaceGlobe} size="100" />
)} )}
+46
View File
@@ -1189,6 +1189,52 @@ export const Message = React.memo(
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} /> <MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
)} )}
</Box> </Box>
{(mEvent.status === EventStatus.NOT_SENT ||
mEvent.status === EventStatus.CANCELLED) && (
<>
<Line size="300" />
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Send} />}
radii="300"
onClick={() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mx as any).resendEvent(mEvent, room);
closeMenu();
}}
>
<Text
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
Retry Send
</Text>
</MenuItem>
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Cross} />}
radii="300"
onClick={() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mx as any).cancelPendingEvent(mEvent);
closeMenu();
}}
>
<Text
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
Cancel Message
</Text>
</MenuItem>
</Box>
</>
)}
{((!mEvent.isRedacted() && canDelete) || {((!mEvent.isRedacted() && canDelete) ||
mEvent.getSender() !== mx.getUserId()) && ( mEvent.getSender() !== mx.getUserId()) && (
<> <>
@@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useRef, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { import {
Avatar, Avatar,
@@ -51,6 +51,21 @@ export const ReactionViewer = as<'div', ReactionViewerProps>(
const defaultReaction = reactions.find((reaction) => typeof reaction[0] === 'string'); const defaultReaction = reactions.find((reaction) => typeof reaction[0] === 'string');
return defaultReaction ? defaultReaction[0] : ''; return defaultReaction ? defaultReaction[0] : '';
}); });
const sidebarRef = useRef<HTMLDivElement>(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) => const getName = (member: RoomMember) =>
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId; getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
@@ -74,7 +89,16 @@ export const ReactionViewer = as<'div', ReactionViewerProps>(
{...props} {...props}
ref={ref} ref={ref}
> >
<Box shrink="No" className={css.Sidebar}> <Box
shrink="No"
className={css.Sidebar}
ref={sidebarRef}
role="listbox"
aria-label="Reactions"
tabIndex={0}
onKeyDown={handleSidebarKeyDown}
style={{ outline: 'none' }}
>
<Scroll visibility="Hover" hideTrack size="300"> <Scroll visibility="Hover" hideTrack size="300">
<Box className={css.SidebarContent} direction="Column" gap="200"> <Box className={css.SidebarContent} direction="Column" gap="200">
{reactions.map(([key, evts]) => { {reactions.map(([key, evts]) => {
@@ -85,6 +109,7 @@ export const ReactionViewer = as<'div', ReactionViewerProps>(
mx={mx} mx={mx}
reaction={key} reaction={key}
count={evts.size} count={evts.size}
role="option"
aria-selected={key === selectedKey} aria-selected={key === selectedKey}
onClick={() => setSelectedKey(key)} onClick={() => setSelectedKey(key)}
useAuthentication={useAuthentication} useAuthentication={useAuthentication}