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:
2026-06-01 17:21:11 -04:00
parent 6135db3405
commit 683159bed8
13 changed files with 581 additions and 53 deletions
@@ -303,7 +303,7 @@ export function RoomProfile({ permissions }: RoomProfileProps) {
canEditTopic={canEditTopic}
avatar={avatar}
name={name ?? ''}
topic={topic ?? ''}
topic={topic?.topic ?? ''}
onClose={handleCloseEdit}
/>
) : (
@@ -315,7 +315,7 @@ export function RoomProfile({ permissions }: RoomProfileProps) {
</Text>
{topic && (
<Text className={classNames(BreakWord, LineClamp3)} size="T200">
<Linkify options={LINKIFY_OPTS}>{topic}</Linkify>
<Linkify options={LINKIFY_OPTS}>{topic.topic}</Linkify>
</Text>
)}
</Box>
+1 -1
View File
@@ -71,7 +71,7 @@ export function LobbyHero() {
size="Inherit"
priority="300"
>
{topic}
{topic.topic}
</Text>
</>
)}
+179 -4
View File
@@ -1,14 +1,21 @@
import React, { MouseEventHandler, forwardRef, useState } from 'react';
import React, { MouseEventHandler, forwardRef, useCallback, useRef, useState } from 'react';
import { Room } from 'matrix-js-sdk';
import {
Avatar,
Box,
Button,
Dialog,
Header,
Icon,
IconButton,
Icons,
Input,
Text,
Menu,
MenuItem,
Overlay,
OverlayBackdrop,
OverlayCenter,
config,
PopOut,
toRem,
@@ -52,7 +59,12 @@ import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationS
import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators';
import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { useRoomName } from '../../hooks/useRoomMeta';
import {
LOCAL_ROOM_NAMES_KEY,
LocalRoomNamesContent,
useHasLocalRoomName,
useLocalRoomName,
} from '../../hooks/useRoomMeta';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { callChatAtom } from '../../state/callEmbed';
@@ -62,6 +74,139 @@ import { livekitSupport } from '../../hooks/useLivekitSupport';
import { StateEvent } from '../../../types/matrix/room';
import { webRTCSupported } from '../../utils/rtc';
type RenameRoomDialogProps = {
room: Room;
onClose: () => void;
};
function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
const mx = useMatrixClient();
const inputRef = useRef<HTMLInputElement>(null);
const getExistingContent = useCallback((): LocalRoomNamesContent => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const raw: unknown = (mx as any).getAccountData(LOCAL_ROOM_NAMES_KEY)?.getContent();
if (
raw &&
typeof raw === 'object' &&
'rooms' in raw &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
typeof (raw as any).rooms === 'object'
) {
return raw as LocalRoomNamesContent;
}
return { rooms: {} };
}, [mx]);
const getCurrentLocalName = useCallback((): string => {
const content = getExistingContent();
return content.rooms[room.roomId] ?? '';
}, [getExistingContent, room.roomId]);
const handleSave = useCallback(() => {
const newName = inputRef.current?.value.trim() ?? '';
const existing = getExistingContent();
if (newName === '') {
const { [room.roomId]: _removed, ...rest } = existing.rooms;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mx as any).setAccountData(LOCAL_ROOM_NAMES_KEY, { rooms: rest });
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mx as any).setAccountData(LOCAL_ROOM_NAMES_KEY, {
rooms: { ...existing.rooms, [room.roomId]: newName },
});
}
onClose();
}, [mx, room.roomId, getExistingContent, onClose]);
const handleClear = useCallback(() => {
const existing = getExistingContent();
const { [room.roomId]: _removed, ...rest } = existing.rooms;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mx as any).setAccountData(LOCAL_ROOM_NAMES_KEY, { rooms: rest });
onClose();
}, [mx, room.roomId, getExistingContent, onClose]);
const handleKeyDown = (evt: React.KeyboardEvent<HTMLInputElement>) => {
if (evt.key === 'Enter') handleSave();
if (evt.key === 'Escape') onClose();
};
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface" aria-labelledby="rename-room-dialog-title">
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4" id="rename-room-dialog-title">
Rename room (for you only)
</Text>
</Box>
<IconButton size="300" onClick={onClose} radii="300" aria-label="Cancel">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="200">
<Text size="L400">Custom name</Text>
<Input
ref={inputRef}
defaultValue={getCurrentLocalName()}
placeholder={room.name}
variant="Secondary"
radii="300"
onKeyDown={handleKeyDown}
autoFocus
/>
<Text size="T300" priority="300">
Only visible to you. Leave blank to use the original name.
</Text>
</Box>
<Box gap="300">
<Box grow="Yes">
<Button
onClick={handleClear}
variant="Secondary"
fill="Soft"
radii="300"
style={{ width: '100%' }}
>
<Text size="B400">Clear</Text>
</Button>
</Box>
<Box grow="Yes">
<Button
onClick={handleSave}
variant="Primary"
radii="300"
style={{ width: '100%' }}
>
<Text size="B400">Save</Text>
</Button>
</Box>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
type RoomNavItemMenuProps = {
room: Room;
requestClose: () => void;
@@ -81,6 +226,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
const space = useSpaceOptionally();
const [invitePrompt, setInvitePrompt] = useState(false);
const [renameDialog, setRenameDialog] = useState(false);
const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity);
@@ -114,6 +260,15 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
}}
/>
)}
{renameDialog && (
<RenameRoomDialog
room={room}
onClose={() => {
setRenameDialog(false);
requestClose();
}}
/>
)}
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
@@ -174,6 +329,17 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
Copy Link
</Text>
</MenuItem>
<MenuItem
onClick={() => setRenameDialog(true)}
size="300"
after={<Icon size="100" src={Icons.Pencil} />}
radii="300"
aria-pressed={renameDialog}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Rename for me
</Text>
</MenuItem>
<MenuItem
onClick={handleRoomSettings}
size="300"
@@ -264,7 +430,8 @@ function RoomNavItem_({
(receipt) => receipt.userId !== mx.getUserId(),
);
const roomName = useRoomName(room);
const roomName = useLocalRoomName(room);
const hasLocalName = useHasLocalRoomName(room.roomId);
const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
evt.preventDefault();
@@ -357,10 +524,18 @@ function RoomNavItem_({
/>
)}
</Avatar>
<Box as="span" grow="Yes">
<Box as="span" grow="Yes" alignItems="Center" gap="100">
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
{roomName}
</Text>
{hasLocalName && (
<Icon
size="50"
src={Icons.Pencil}
aria-label="Custom local name"
style={{ opacity: 0.6, flexShrink: 0 }}
/>
)}
</Box>
{!optionsVisible && !unread && !selected && typingMember.length > 0 && (
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
+15
View File
@@ -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>
);
}
+1 -1
View File
@@ -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>
);
}