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(); 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]); };