Files
cinny/src/app/features/room-nav/RoomNavItem.tsx
T
jared 3a72b7c1c5 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>
2026-06-01 17:21:11 -04:00

619 lines
21 KiB
TypeScript

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,
Line,
RectCords,
Badge,
Spinner,
} from 'folds';
import { useFocusWithin, useHover } from 'react-aria';
import FocusTrap from 'focus-trap-react';
import { useAtom, useAtomValue } from 'jotai';
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl, getStateEvent } from '../../utils/room';
import { nameInitials } from '../../utils/common';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { getPowersLevelFromMatrixEvent, usePowerLevels } from '../../hooks/usePowerLevels';
import { copyToClipboard } from '../../utils/dom';
import { markAsRead } from '../../utils/notifications';
import { UseStateProvider } from '../../components/UseStateProvider';
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
import { TypingIndicator } from '../../components/typing-indicator';
import { stopPropagation } from '../../utils/keyboard';
import { getMatrixToRoom } from '../../plugins/matrix-to';
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../utils/matrix';
import { getViaServers } from '../../plugins/via-servers';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
import { useSpaceOptionally } from '../../hooks/useSpace';
import {
getRoomNotificationModeIcon,
RoomNotificationMode,
} from '../../hooks/useRoomsNotificationPreferences';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators';
import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
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';
import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
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;
notificationMode?: RoomNotificationMode;
};
const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
({ room, requestClose, notificationMode }, ref) => {
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId());
const openRoomSettings = useOpenRoomSettings();
const space = useSpaceOptionally();
const [invitePrompt, setInvitePrompt] = useState(false);
const [renameDialog, setRenameDialog] = useState(false);
const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity);
requestClose();
};
const handleInvite = () => {
setInvitePrompt(true);
};
const handleCopyLink = () => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers));
requestClose();
};
const handleRoomSettings = () => {
openRoomSettings(room.roomId, space?.roomId);
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
{invitePrompt && room && (
<InviteUserPrompt
room={room}
requestClose={() => {
setInvitePrompt(false);
requestClose();
}}
/>
)}
{renameDialog && (
<RenameRoomDialog
room={room}
onClose={() => {
setRenameDialog(false);
requestClose();
}}
/>
)}
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
{(handleOpen, opened, changing) => (
<MenuItem
size="300"
after={
changing ? (
<Spinner size="100" variant="Secondary" />
) : (
<Icon size="100" src={getRoomNotificationModeIcon(notificationMode)} />
)
}
radii="300"
aria-pressed={opened}
onClick={handleOpen}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Notifications
</Text>
</MenuItem>
)}
</RoomNotificationModeSwitcher>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleInvite}
variant="Primary"
fill="None"
size="300"
after={<Icon size="100" src={Icons.UserPlus} />}
radii="300"
aria-pressed={invitePrompt}
disabled={!canInvite}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Invite
</Text>
</MenuItem>
<MenuItem
onClick={handleCopyLink}
size="300"
after={<Icon size="100" src={Icons.Link} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
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"
after={<Icon size="100" src={Icons.Setting} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Room Settings
</Text>
</MenuItem>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<UseStateProvider initial={false}>
{(promptLeave, setPromptLeave) => (
<>
<MenuItem
onClick={() => setPromptLeave(true)}
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
radii="300"
aria-pressed={promptLeave}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Leave Room
</Text>
</MenuItem>
{promptLeave && (
<LeaveRoomPrompt
roomId={room.roomId}
onDone={requestClose}
onCancel={() => setPromptLeave(false)}
/>
)}
</>
)}
</UseStateProvider>
</Box>
</Menu>
);
},
);
function CallChatToggle() {
const [chat, setChat] = useAtom(callChatAtom);
return (
<IconButton
onClick={() => setChat(!chat)}
aria-pressed={chat}
aria-label="Toggle Chat"
variant="Background"
fill="None"
size="300"
radii="300"
>
<Icon size="50" src={Icons.Message} filled={chat} />
</IconButton>
);
}
type RoomNavItemProps = {
room: Room;
selected: boolean;
linkPath: string;
notificationMode?: RoomNotificationMode;
showAvatar?: boolean;
direct?: boolean;
};
function RoomNavItem_({
room,
selected,
showAvatar,
direct,
notificationMode,
linkPath,
}: RoomNavItemProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [hover, setHover] = useState(false);
const { hoverProps } = useHover({ onHoverChange: setHover });
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const typingMember = useRoomTypingMember(room.roomId).filter(
(receipt) => receipt.userId !== mx.getUserId(),
);
const roomName = useLocalRoomName(room);
const hasLocalName = useHasLocalRoomName(room.roomId);
const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
evt.preventDefault();
setMenuAnchor({
x: evt.clientX,
y: evt.clientY,
width: 0,
height: 0,
});
};
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
const optionsVisible = hover || !!menuAnchor;
const callSession = useCallSession(room);
const callMembers = useCallMembers(callSession);
const startCall = useCallStart(direct);
const callEmbed = useCallEmbed();
const callPref = useAtomValue(useCallPreferencesAtom());
const autoDiscoveryInfo = useAutoDiscoveryInfo();
const handleStartCall: MouseEventHandler<HTMLAnchorElement> = (evt) => {
const powerLevelsEvent = getStateEvent(room, StateEvent.RoomPowerLevels);
const powerLevels = getPowersLevelFromMatrixEvent(powerLevelsEvent);
const creators = getRoomCreatorsForRoomId(mx, room.roomId);
const permissions = getRoomPermissionsAPI(creators, powerLevels);
const hasCallPermission = permissions.stateEvent(
StateEvent.GroupCallMemberPrefix,
mx.getSafeUserId(),
);
// Do not join if missing permissions or no livekit support or no webRTC support
if (!hasCallPermission || !livekitSupport(autoDiscoveryInfo) || !webRTCSupported()) {
return;
}
// Do not join if already in call
if (callEmbed) {
return;
}
// Start call in second click
if (selected) {
evt.preventDefault();
startCall(room, callPref);
}
};
return (
<NavItem
variant="Background"
radii="400"
highlight={unread !== undefined}
aria-selected={selected}
data-hover={!!menuAnchor}
onContextMenu={handleContextMenu}
{...hoverProps}
{...focusWithinProps}
>
<NavLink to={linkPath} onClick={room.isCallRoom() ? handleStartCall : undefined}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
{showAvatar ? (
<RoomAvatar
roomId={room.roomId}
src={
direct
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication)
}
alt={roomName}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(roomName)}
</Text>
)}
/>
) : (
<RoomIcon
style={{
opacity: unread ? config.opacity.P500 : config.opacity.P300,
}}
filled={selected}
size="100"
joinRule={room.getJoinRule()}
roomType={room.getType()}
/>
)}
</Avatar>
<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>
<TypingIndicator size="300" disableAnimation />
</Badge>
)}
{!optionsVisible && unread && (
<UnreadBadgeCenter>
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
</UnreadBadgeCenter>
)}
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
<Icon
size="50"
src={getRoomNotificationModeIcon(notificationMode)}
aria-label={notificationMode}
/>
)}
{callMembers.length > 0 && (
<Badge variant="Critical" fill="Solid" size="400">
<Text as="span" size="L400" truncate>
{callMembers.length} Live
</Text>
</Badge>
)}
</Box>
</NavItemContent>
</NavLink>
{optionsVisible && (
<NavItemOptions>
{selected && (callEmbed?.roomId === room.roomId || room.isCallRoom()) && (
<CallChatToggle />
)}
<PopOut
id={`menu-${room.roomId}`}
aria-expanded={!!menuAnchor}
anchor={menuAnchor}
offset={menuAnchor?.width === 0 ? 0 : undefined}
alignOffset={menuAnchor?.width === 0 ? 0 : -5}
position="Bottom"
align={menuAnchor?.width === 0 ? 'Start' : 'End'}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<RoomNavItemMenu
room={room}
requestClose={() => setMenuAnchor(undefined)}
notificationMode={notificationMode}
/>
</FocusTrap>
}
>
<IconButton
onClick={handleOpenMenu}
aria-pressed={!!menuAnchor}
aria-controls={`menu-${room.roomId}`}
aria-label="More Options"
variant="Background"
fill="None"
size="300"
radii="300"
>
<Icon size="50" src={Icons.VerticalDots} />
</IconButton>
</PopOut>
</NavItemOptions>
)}
</NavItem>
);
}
export const RoomNavItem = React.memo(RoomNavItem_);