Files
cinny/src/app/features/room/jump-to-time/JumpToTime.tsx
T
jaredandClaude Opus 4.8 dcad282749 fix(jump-to-time): friendly message when homeserver lacks MSC3030
Jump to Time uses timestampToEvent (MSC3030). On a homeserver that doesn't
support it, the dialog showed the raw "M_UNRECOGNIZED: Unrecognized request"
error. Show a clear explanation instead when errcode is M_UNRECOGNIZED.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:22:43 -04:00

265 lines
9.9 KiB
TypeScript

import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Dialog,
Overlay,
OverlayCenter,
OverlayBackdrop,
Header,
config,
Box,
Text,
IconButton,
Icon,
Icons,
color,
Button,
Spinner,
Chip,
PopOut,
RectCords,
} from 'folds';
import { Direction, MatrixError } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { stopPropagation } from '../../../utils/keyboard';
import { useAlive } from '../../../hooks/useAlive';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { useRoom } from '../../../hooks/useRoom';
import { StateEvent } from '../../../../types/matrix/room';
import { getToday, getYesterday, timeDayMonthYear, timeHourMinute } from '../../../utils/time';
import { DatePicker, TimePicker } from '../../../components/time-date';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { useModalStyle } from '../../../hooks/useModalStyle';
type JumpToTimeProps = {
onCancel: () => void;
onSubmit: (eventId: string) => void;
};
export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
const mx = useMatrixClient();
const modalStyle = useModalStyle(480);
const room = useRoom();
const alive = useAlive();
const createStateEvent = useStateEvent(room, StateEvent.RoomCreate);
const todayTs = getToday();
const yesterdayTs = getYesterday();
const createTs = useMemo(() => createStateEvent?.getTs() ?? 0, [createStateEvent]);
const [ts, setTs] = useState(() => Date.now());
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [timePickerCords, setTimePickerCords] = useState<RectCords>();
const [datePickerCords, setDatePickerCords] = useState<RectCords>();
const handleTimePicker: MouseEventHandler<HTMLButtonElement> = (evt) => {
setTimePickerCords(evt.currentTarget.getBoundingClientRect());
};
const handleDatePicker: MouseEventHandler<HTMLButtonElement> = (evt) => {
setDatePickerCords(evt.currentTarget.getBoundingClientRect());
};
const handleToday = () => {
setTs(todayTs < createTs ? createTs : todayTs);
};
const handleYesterday = () => {
setTs(yesterdayTs < createTs ? createTs : yesterdayTs);
};
const handleBeginning = () => setTs(createTs);
const [timestampState, timestampToEvent] = useAsyncCallback<string, MatrixError, [number]>(
useCallback(
async (newTs) => {
const result = await mx.timestampToEvent(room.roomId, newTs, Direction.Forward);
return result.event_id;
},
[mx, room],
),
);
const handleSubmit = () => {
timestampToEvent(ts).then((eventId) => {
if (alive()) {
onSubmit(eventId);
}
});
};
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onCancel,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface" style={modalStyle}>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Jump to Time</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300" aria-label="Cancel">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="500">
<Box direction="Row" gap="300">
<Box direction="Column" gap="100">
<Text size="L400" priority="400">
Time
</Text>
<Box gap="100" alignItems="Center">
<Chip
size="500"
variant="Surface"
fill="None"
outlined
radii="300"
aria-pressed={!!timePickerCords}
after={<Icon size="50" src={Icons.ChevronBottom} />}
onClick={handleTimePicker}
>
<Text size="B300">{timeHourMinute(ts, hour24Clock)}</Text>
</Chip>
<PopOut
anchor={timePickerCords}
offset={5}
position="Bottom"
align="Center"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setTimePickerCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<TimePicker min={createTs} max={Date.now()} value={ts} onChange={setTs} />
</FocusTrap>
}
/>
</Box>
</Box>
<Box direction="Column" gap="100">
<Text size="L400" priority="400">
Date
</Text>
<Box gap="100" alignItems="Center">
<Chip
size="500"
variant="Surface"
fill="None"
outlined
radii="300"
aria-pressed={!!datePickerCords}
after={<Icon size="50" src={Icons.ChevronBottom} />}
onClick={handleDatePicker}
>
<Text size="B300">{timeDayMonthYear(ts)}</Text>
</Chip>
<PopOut
anchor={datePickerCords}
offset={5}
position="Bottom"
align="Center"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setDatePickerCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<DatePicker min={createTs} max={Date.now()} value={ts} onChange={setTs} />
</FocusTrap>
}
/>
</Box>
</Box>
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Preset</Text>
<Box gap="200">
{createTs < todayTs && (
<Chip
variant={ts === todayTs ? 'Success' : 'SurfaceVariant'}
radii="Pill"
aria-pressed={ts === todayTs}
onClick={handleToday}
>
<Text size="B300">Today</Text>
</Chip>
)}
{createTs < yesterdayTs && (
<Chip
variant={ts === yesterdayTs ? 'Success' : 'SurfaceVariant'}
radii="Pill"
aria-pressed={ts === yesterdayTs}
onClick={handleYesterday}
>
<Text size="B300">Yesterday</Text>
</Chip>
)}
<Chip
variant={ts === createTs ? 'Success' : 'SurfaceVariant'}
radii="Pill"
aria-pressed={ts === createTs}
onClick={handleBeginning}
>
<Text size="B300">Beginning</Text>
</Chip>
</Box>
</Box>
{timestampState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
{timestampState.error.errcode === 'M_UNRECOGNIZED'
? "Your homeserver doesn't support jumping to a date or time (MSC3030)."
: timestampState.error.message}
</Text>
)}
<Button
type="submit"
variant="Primary"
before={
timestampState.status === AsyncStatus.Loading ? (
<Spinner fill="Solid" variant="Primary" size="200" />
) : undefined
}
aria-disabled={
timestampState.status === AsyncStatus.Loading ||
timestampState.status === AsyncStatus.Success
}
onClick={handleSubmit}
>
<Text size="B400">Open Timeline</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}