Files
cinny/src/app/features/room/message/EditHistoryModal.tsx
T

187 lines
6.8 KiB
TypeScript
Raw Normal View History

import React, { useCallback, useEffect } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
Header,
Icon,
IconButton,
Icons,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll,
Spinner,
Text,
} from 'folds';
import { MatrixEvent, Method, Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { stopPropagation } from '../../../utils/keyboard';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { timeDayMonYear, timeHourMinute } from '../../../utils/time';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
type EditHistoryResponse = {
chunk: Array<Record<string, unknown>>;
next_batch?: string;
};
type EditHistoryModalProps = {
room: Room;
mEvent: MatrixEvent;
onClose: () => void;
};
export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProps) {
const mx = useMatrixClient();
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const eventId = mEvent.getId();
const roomId = room.roomId;
const [historyState, fetchHistory] = useAsyncCallback<MatrixEvent[], unknown, []>(
useCallback(async () => {
const path = `/rooms/${encodeURIComponent(roomId)}/relations/${encodeURIComponent(eventId ?? '')}/m.replace`;
const res = await mx.http.authedRequest<EditHistoryResponse>(Method.Get, path, {
limit: '50',
});
const rawEvents = res.chunk ?? [];
// Sort oldest first
const events = rawEvents
.map((raw) => {
// Build a lightweight representation for display; we just need content + ts
return raw as { type: string; content: Record<string, unknown>; origin_server_ts: number; event_id: string };
})
.sort((a, b) => a.origin_server_ts - b.origin_server_ts);
// Convert to MatrixEvent-like objects using the raw data
// We use MatrixEvent if available from the timeline, otherwise parse the raw data
return events.map((raw) => {
const existing = room.findEventById(raw.event_id);
if (existing) return existing;
// Create a minimal event wrapper
const evt = new MatrixEvent({
type: raw.type,
content: raw.content,
origin_server_ts: raw.origin_server_ts,
event_id: raw.event_id,
room_id: roomId,
sender: mEvent.getSender() ?? '',
});
return evt;
});
}, [mx, roomId, eventId, room, mEvent]),
);
useEffect(() => {
fetchHistory();
}, [fetchHistory]);
const formatTs = (ts: number): string => {
const time = timeHourMinute(ts, hour24Clock);
const date = timeDayMonYear(ts, dateFormatString);
return `${date} at ${time}`;
};
const getVersionBody = (evt: MatrixEvent): string => {
const content = evt.getContent();
const newContent = content['m.new_content'] as Record<string, unknown> | undefined;
const body = newContent?.body ?? content.body;
return typeof body === 'string' ? body : '(no text)';
};
// The original message body (before any edits)
const originalBody = (() => {
const content = mEvent.getContent();
const body = content.body;
return typeof body === 'string' ? body : '(no text)';
})();
const originalTs = mEvent.getTs();
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: onClose,
escapeDeactivates: stopPropagation,
}}
>
<Modal variant="Surface" size="500">
<Header variant="Surface" size="500" style={{ padding: '0 var(--mx-spacing-s200) 0 var(--mx-spacing-s400)' }}>
<Box grow="Yes">
<Text as="h2" size="H4" truncate>
Edit History
</Text>
</Box>
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Scroll size="300" hideTrack style={{ maxHeight: '60vh' }}>
<Box direction="Column" gap="200" style={{ padding: 'var(--mx-spacing-s400)', paddingBottom: 'var(--mx-spacing-s700)' }}>
{historyState.status === AsyncStatus.Loading && (
<Box justifyContent="Center" alignItems="Center" style={{ padding: 'var(--mx-spacing-s400)' }}>
<Spinner size="200" />
</Box>
)}
{historyState.status === AsyncStatus.Error && (
<Text size="T300" priority="300">
Failed to load edit history.
</Text>
)}
{historyState.status === AsyncStatus.Success && (
<Box direction="Column" gap="300">
{/* Original message always shown first */}
<Box direction="Column" gap="100">
<Box gap="200" alignItems="Center">
<Text size="L400">Original</Text>
<Text size="T200" priority="300">
{formatTs(originalTs)}
</Text>
</Box>
<Text size="T300" style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{originalBody}
</Text>
</Box>
{historyState.data.map((editEvt, index) => (
<Box key={editEvt.getId() ?? index} direction="Column" gap="100">
<Box gap="200" alignItems="Center">
<Text size="L400">
{index === historyState.data.length - 1
? `Edit ${index + 1} (current)`
: `Edit ${index + 1}`}
</Text>
<Text size="T200" priority="300">
{formatTs(editEvt.getTs())}
</Text>
</Box>
<Text size="T300" style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{getVersionBody(editEvt)}
</Text>
</Box>
))}
{historyState.data.length === 0 && (
<Text size="T300" priority="300">
No edit history found.
</Text>
)}
</Box>
)}
</Box>
</Scroll>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}