113 lines
5.1 KiB
TypeScript
113 lines
5.1 KiB
TypeScript
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';
|
||
import { isBridgedRoom } from '../../../utils/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
|
||
// (Robots tab) and bridged portal rooms (mautrix-telegram/whatsapp/discord/…).
|
||
// Bridged rooms are filtered out here so they live exclusively in their
|
||
// per-bridge personal filtering space (Channels tab) — otherwise a 1:1
|
||
// Telegram chat that the bridge tagged `m.direct` AND placed inside the
|
||
// Telegram space would appear twice. Implementation =
|
||
// `useOrphanRooms ∪ useDirects`, minus catalog bot control rooms and bridged
|
||
// portals:
|
||
//
|
||
// - `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);
|
||
if (!room) return true;
|
||
if (isCatalogBotControlRoom(mx, room, bots)) return false;
|
||
if (isBridgedRoom(room)) return false;
|
||
return true;
|
||
};
|
||
|
||
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]);
|
||
};
|