feat: personal room name overrides (MSC4431-style)
Users can right-click any room and 'Rename for me...' to set a local display name visible only to them. Stored in account data under io.lotus.room_names. Shows a pencil indicator on renamed rooms. useLocalRoomName() hook overrides useRoomName() when a local name exists. Also includes: - Rich room topic rendering via RoomTopicContent object (formatted_body support in RoomTopicViewer with HTML sanitization via sanitizeCustomHtml) - Edit history viewer: clicking '(edited)' on a message opens a modal showing all prior versions with timestamps (EditHistoryModal.tsx) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -128,6 +128,7 @@ import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/u
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||||
import { EditHistoryModal } from './message/EditHistoryModal';
|
||||
|
||||
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
|
||||
({ position, className, ...props }, ref) => (
|
||||
@@ -480,6 +481,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
|
||||
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
|
||||
const [editId, setEditId] = useState<string>();
|
||||
const [editHistoryEvent, setEditHistoryEvent] = useState<MatrixEvent | undefined>();
|
||||
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||
@@ -1123,6 +1125,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
msgType={mEvent.getContent().msgtype ?? ''}
|
||||
ts={mEvent.getTs()}
|
||||
edited={!!editedEvent}
|
||||
onEditHistoryClick={
|
||||
editedEvent ? () => setEditHistoryEvent(mEvent) : undefined
|
||||
}
|
||||
getContent={getContent}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
urlPreview={showUrlPreview}
|
||||
@@ -1229,6 +1234,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
msgType={mEvent.getContent().msgtype ?? ''}
|
||||
ts={mEvent.getTs()}
|
||||
edited={!!editedEvent}
|
||||
onEditHistoryClick={
|
||||
editedEvent ? () => setEditHistoryEvent(mEvent) : undefined
|
||||
}
|
||||
getContent={getContent}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
urlPreview={showUrlPreview}
|
||||
@@ -2191,6 +2199,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
</TimelineFloat>
|
||||
)}
|
||||
</Box>
|
||||
{editHistoryEvent && (
|
||||
<EditHistoryModal
|
||||
room={room}
|
||||
mEvent={editHistoryEvent}
|
||||
onClose={() => setEditHistoryEvent(undefined)}
|
||||
/>
|
||||
)}
|
||||
</ReadPositionsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -544,7 +544,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
priority="300"
|
||||
truncate
|
||||
>
|
||||
{topic}
|
||||
{topic.topic}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user