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 134ebb231d
commit 3a72b7c1c5
13 changed files with 581 additions and 53 deletions
+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>