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
+95 -5
View File
@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { ClientEvent, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { StateEvent } from '../../types/matrix/room';
import { useStateEvent } from './useStateEvent';
import { useMatrixClient } from './useMatrixClient';
export const useRoomAvatar = (room: Room, dm?: boolean): string | undefined => {
const avatarEvent = useStateEvent(room, StateEvent.RoomAvatar);
@@ -34,13 +35,102 @@ export const useRoomName = (room: Room): string => {
return name;
};
export const useRoomTopic = (room: Room): string | undefined => {
export const LOCAL_ROOM_NAMES_KEY = 'io.lotus.room_names';
export type LocalRoomNamesContent = { rooms: Record<string, string> };
function getLocalRoomNamesContent(mx: ReturnType<typeof useMatrixClient>): LocalRoomNamesContent {
// Use any-cast because LOCAL_ROOM_NAMES_KEY is not in matrix-js-sdk AccountDataEvents
// 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: {} };
}
export const useLocalRoomName = (room: Room): string => {
const mx = useMatrixClient();
const getLocalName = useCallback((): string => {
const content = getLocalRoomNamesContent(mx);
return content.rooms[room.roomId] ?? room.name;
}, [mx, room]);
const [name, setName] = useState(getLocalName);
useEffect(() => {
setName(getLocalName());
const handleAccountData = (event: MatrixEvent) => {
if (event.getType() !== LOCAL_ROOM_NAMES_KEY) return;
setName(getLocalName());
};
mx.on(ClientEvent.AccountData, handleAccountData);
const handleRoomNameChange: RoomEventHandlerMap[RoomEvent.Name] = () => {
setName(getLocalName());
};
room.on(RoomEvent.Name, handleRoomNameChange);
return () => {
mx.removeListener(ClientEvent.AccountData, handleAccountData);
room.removeListener(RoomEvent.Name, handleRoomNameChange);
};
}, [mx, room, getLocalName]);
return name;
};
export const useHasLocalRoomName = (roomId: string): boolean => {
const mx = useMatrixClient();
const check = useCallback((): boolean => {
const content = getLocalRoomNamesContent(mx);
return !!content.rooms[roomId];
}, [mx, roomId]);
const [hasLocal, setHasLocal] = useState(check);
useEffect(() => {
setHasLocal(check());
const handleAccountData = (event: MatrixEvent) => {
if (event.getType() !== LOCAL_ROOM_NAMES_KEY) return;
setHasLocal(check());
};
mx.on(ClientEvent.AccountData, handleAccountData);
return () => {
mx.removeListener(ClientEvent.AccountData, handleAccountData);
};
}, [mx, check]);
return hasLocal;
};
export type RoomTopicContent = {
topic: string;
formatted_body?: string;
format?: string;
};
export const useRoomTopic = (room: Room): RoomTopicContent | undefined => {
const topicEvent = useStateEvent(room, StateEvent.RoomTopic);
const content = topicEvent?.getContent();
const topic = content && typeof content.topic === 'string' ? content.topic : undefined;
if (!content || typeof content.topic !== 'string') return undefined;
return topic;
return {
topic: content.topic,
formatted_body: typeof content.formatted_body === 'string' ? content.formatted_body : undefined,
format: typeof content.format === 'string' ? content.format : undefined,
};
};
export const useRoomJoinRule = (room: Room): RoomJoinRulesEventContent | undefined => {