vojo/src/app/pages/client/direct/useDirectRooms.ts

105 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useAtomValue } from 'jotai';
import { MatrixEvent, RoomStateEvent } from 'matrix-js-sdk';
import { useEffect, useMemo, useState } from 'react';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { mDirectAtom } from '../../../state/mDirectList';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { useDirects, useOrphanRooms } from '../../../state/hooks/roomList';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { useBotPresets } from '../../../features/bots/catalog';
import { isBridgeStateEvent, isCatalogBotControlRoom } from '../../../features/bots/room';
// After P3c the Direct tab is universal: every joined non-space «orphan»
// room renders here, regardless of `m.direct`, except curated bot control DMs.
// Those belong to the Robots tab, while bridged Telegram portal rooms remain
// normal Direct candidates. Implementation =
// `useOrphanRooms useDirects`:
//
// - `useOrphanRooms` = `isRoom && !mDirects.has && !roomToParents.has` →
// non-space rooms that don't live inside any space and aren't m.direct-
// tagged. The formerly-Home tab.
// - `useDirects` = `isRoom && mDirects.has` → every m.direct-tagged
// non-space room. **No `roomToParents` filter** — a room that is BOTH a
// space child AND m.direct-tagged appears here. This is intentional and
// pre-dates P3c: the m.direct flag wins over the space-child membership
// for routing purposes (user sees the DM in /direct/, opening it loses
// space context). The same room is hidden from the parent space's child-
// room navigation list (`useChildRoomScopeFactory` excludes m.direct);
// it stays reachable from space message search via
// `useRecursiveChildNonSpaceRoomScopeFactory` (post-P3c universal).
//
// Duplicates inside the union are deduped via Set semantics below. The
// product call (DM-tagged-space-child resolves to /direct/, not /space/{id}/)
// is open for revision — see desired_features §21 (Channels plan).
export const useDirectRooms = (): string[] => {
const mx = useMatrixClient();
const mDirects = useAtomValue(mDirectAtom);
const roomToParents = useAtomValue(roomToParentsAtom);
const orphanRooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents);
const directs = useDirects(mx, allRoomsAtom, mDirects);
const bots = useBotPresets();
const directCandidates = useMemo(
() => new Set([...orphanRooms, ...directs]),
[orphanRooms, directs]
);
const [roomStateTick, setRoomStateTick] = useState(0);
useEffect(() => {
const bumpIfCandidate = (roomId: string | undefined) => {
if (roomId && directCandidates.has(roomId)) {
setRoomStateTick((tick) => tick + 1);
}
};
const onMembers = (ev: MatrixEvent) => {
bumpIfCandidate(ev.getRoomId());
};
const onStateEvent = (ev: MatrixEvent) => {
if (isBridgeStateEvent(ev)) bumpIfCandidate(ev.getRoomId());
};
mx.on(RoomStateEvent.Members, onMembers);
mx.on(RoomStateEvent.Events, onStateEvent);
return () => {
mx.removeListener(RoomStateEvent.Members, onMembers);
mx.removeListener(RoomStateEvent.Events, onStateEvent);
};
}, [mx, directCandidates]);
// The output MUST be a stable reference across renders that don't change
// input identity — `useRoomsUnread` (state/hooks/unread.ts:37) feeds this
// array into a `useCallback([rooms])` whose result is then handed to
// `selectAtom(...)` on every render. If `rooms` identity flips each render,
// `selectAtom` produces a fresh atom subscription which triggers another
// render — that's the React-detected "Maximum update depth exceeded" loop
// observed in DirectTab. `roomStateTick` is in deps so member/bridge state
// updates still recompute the visible set.
return useMemo(() => {
const seen = new Set<string>();
const out: string[] = [];
const isVisibleDirect = (id: string): boolean => {
const room = mx.getRoom(id);
return !room || !isCatalogBotControlRoom(mx, room, bots);
};
orphanRooms.forEach((id) => {
if (!seen.has(id) && isVisibleDirect(id)) {
seen.add(id);
out.push(id);
}
});
directs.forEach((id) => {
if (!seen.has(id) && isVisibleDirect(id)) {
seen.add(id);
out.push(id);
}
});
return out;
// roomStateTick is intentional: the listener bumps it on member/bridge
// state events to force re-evaluation, even though the value isn't read
// inside the body. Removing it would make the visible-direct set go
// stale until one of the input arrays' identities changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mx, orphanRooms, directs, bots, roomStateTick]);
};