ux: reply null state, location error feedback, retry send, reaction keyboard nav
- 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:
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user