CI / Build & Quality Checks (pull_request) Successful in 3m4s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m29s
The personal room name (io.lotus.room_names) was only used by the
sidebar, header, intro and call bar; the quick switcher couldn't find a
room by it ("No match found") and showed the server name, as did the
forward/share pickers, toasts, OS notifications and reminders.
useRoomMeta gains useLocalRoomNames() / getLocalRoomName(); the quick
switcher matches both names and shows the personal one, the forward and
share pickers show and filter by it, and message/reminder notifications
use it for the room title.
Verified headless with general renamed to "Zebra Lounge": Ctrl+K "Zebra"
and "general" both list "Zebra Lounge"; the forward picker filtered by
"zebra" lists "Zebra Lounge".
Not covered here (follow-up): message-search result headers, the inbox
notifications page and the threads list still use the server name.
Refs #336
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
151 lines
4.9 KiB
TypeScript
151 lines
4.9 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
|
import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
|
import { StateEvent } from '../../types/matrix/room';
|
|
import { useStateEvent } from './useStateEvent';
|
|
import { useMatrixClient } from './useMatrixClient';
|
|
import { createAccountDataListStore } from './createAccountDataListStore';
|
|
|
|
export const useRoomAvatar = (room: Room, dm?: boolean): string | undefined => {
|
|
const avatarEvent = useStateEvent(room, StateEvent.RoomAvatar);
|
|
|
|
if (dm) {
|
|
return room.getAvatarFallbackMember()?.getMxcAvatarUrl();
|
|
}
|
|
const content = avatarEvent?.getContent();
|
|
const avatarMxc = content && typeof content.url === 'string' ? content.url : undefined;
|
|
|
|
return avatarMxc;
|
|
};
|
|
|
|
export const useRoomName = (room: Room): string => {
|
|
const [name, setName] = useState(room.name);
|
|
|
|
useEffect(() => {
|
|
setName(room.name);
|
|
|
|
const handleRoomNameChange: RoomEventHandlerMap[RoomEvent.Name] = () => {
|
|
setName(room.name);
|
|
};
|
|
room.on(RoomEvent.Name, handleRoomNameChange);
|
|
return () => {
|
|
room.removeListener(RoomEvent.Name, handleRoomNameChange);
|
|
};
|
|
}, [room]);
|
|
|
|
return name;
|
|
};
|
|
|
|
export const LOCAL_ROOM_NAMES_KEY = 'io.lotus.room_names';
|
|
|
|
export type LocalRoomNamesContent = { rooms: Record<string, string> };
|
|
|
|
type LocalRoomNamesMap = Record<string, string>;
|
|
|
|
// Shared, concurrency-safe store. See createAccountDataListStore for why the
|
|
// snapshot + write queue must be module-scoped: setAccountData does not update
|
|
// the SDK's local cache (it only resolves once the /sync echo lands), so a
|
|
// plain read-modify-write against getAccountData can lose a rename that is
|
|
// still in flight when a second rename is issued (fixed: back-to-back renames
|
|
// of different rooms no longer clobber each other).
|
|
const roomNamesStore = createAccountDataListStore<LocalRoomNamesMap, LocalRoomNamesContent>({
|
|
eventType: LOCAL_ROOM_NAMES_KEY,
|
|
read: (content) =>
|
|
content && typeof content === 'object' && typeof content.rooms === 'object'
|
|
? content.rooms
|
|
: {},
|
|
write: (rooms) => ({ rooms }),
|
|
});
|
|
|
|
export function getLocalRoomNamesContent(
|
|
mx: ReturnType<typeof useMatrixClient>,
|
|
): LocalRoomNamesContent {
|
|
return { rooms: roomNamesStore.getLatest(mx) };
|
|
}
|
|
|
|
/**
|
|
* Set (or clear, when `name` is empty) the local display name for a room.
|
|
* Routed through the shared write queue so back-to-back renames of different
|
|
* rooms are always computed from the latest snapshot instead of a stale one.
|
|
*/
|
|
export function setLocalRoomName(
|
|
mx: ReturnType<typeof useMatrixClient>,
|
|
roomId: string,
|
|
name: string,
|
|
): Promise<void> {
|
|
return roomNamesStore.enqueueWrite(mx, (current) => {
|
|
if (!name) {
|
|
const { [roomId]: _removed, ...rest } = current;
|
|
return rest;
|
|
}
|
|
return { ...current, [roomId]: name };
|
|
});
|
|
}
|
|
|
|
export const useLocalRoomName = (room: Room): string => {
|
|
const mx = useMatrixClient();
|
|
const localNames = roomNamesStore.useValue(mx);
|
|
const [name, setName] = useState(room.name);
|
|
|
|
useEffect(() => {
|
|
setName(room.name);
|
|
|
|
const handleRoomNameChange: RoomEventHandlerMap[RoomEvent.Name] = () => {
|
|
setName(room.name);
|
|
};
|
|
room.on(RoomEvent.Name, handleRoomNameChange);
|
|
return () => {
|
|
room.removeListener(RoomEvent.Name, handleRoomNameChange);
|
|
};
|
|
}, [room]);
|
|
|
|
return localNames[room.roomId] ?? name;
|
|
};
|
|
|
|
/**
|
|
* [Gitea #336] The whole "Rename for me" map, for lists that show many rooms
|
|
* (quick switcher, forward/share pickers) so they use the name the user sees in
|
|
* the sidebar, not the server name.
|
|
*/
|
|
export const useLocalRoomNames = (): Readonly<Record<string, string>> => {
|
|
const mx = useMatrixClient();
|
|
return roomNamesStore.useValue(mx);
|
|
};
|
|
|
|
/** Non-hook read for notification paths (toast / OS notification titles). */
|
|
export const getLocalRoomName = (
|
|
mx: ReturnType<typeof useMatrixClient>,
|
|
roomId: string,
|
|
): string | undefined => roomNamesStore.getLatest(mx)[roomId];
|
|
|
|
export const useHasLocalRoomName = (roomId: string): boolean => {
|
|
const mx = useMatrixClient();
|
|
const localNames = roomNamesStore.useValue(mx);
|
|
return !!localNames[roomId];
|
|
};
|
|
|
|
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();
|
|
if (!content || typeof content.topic !== 'string') return undefined;
|
|
|
|
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 => {
|
|
const mEvent = useStateEvent(room, StateEvent.RoomJoinRules);
|
|
const joinRuleContent = mEvent?.getContent<RoomJoinRulesEventContent>();
|
|
return joinRuleContent;
|
|
};
|