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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user