import { useEffect, useState } from 'react'; import { ClientEvent, EventType, MatrixClient, MatrixEvent, Room, RoomEvent, RoomStateEvent, } from 'matrix-js-sdk'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { Membership } from '../../../types/matrix/room'; import { removeRoomIdFromMDirect } from '../../utils/matrix'; import { isBotControlRoom, isBridgeStateEvent, isPortalRoomForOtherBridge } from './room'; import type { BotPreset } from './catalog'; // Discriminated union over the lifecycle of a bot DM. Mount-eligible only at // `kind === 'ready'`; everything else is a UI state with a different CTA. // // Using getDMRoomFor here would be wrong: it returns only self-Joined active // 1:1 rooms, so self-invite and bot-kicked rooms would silently degrade to // `none` and the «Connect» button would create a duplicate control DM. // Instead we read m.direct[preset.mxid] raw plus legacy untagged control rooms, // walk the rooms, and classify by (myMembership, botMembership) tuple. export type BotRoomState = | { kind: 'none' } | { kind: 'self-invite'; room: Room } | { kind: 'bot-invite'; room: Room } | { kind: 'bot-kicked'; room: Room } | { kind: 'unsafe-membership'; room: Room } | { kind: 'ready'; room: Room }; type MDirectMap = Record; const classifyRoom = ( mx: MatrixClient, room: Room, preset: BotPreset ): BotRoomState | undefined => { if (isPortalRoomForOtherBridge(room, preset.mxid)) return undefined; const my = room.getMyMembership(); const bot = room.getMember(preset.mxid)?.membership; // A bot control room is mount-ready only while active membership is exactly // self + bot. Extra joined/invited members block the bot surface before any // sensitive commands can be read or sent. const activeMembers = room .getMembers() .filter((m) => m.membership === Membership.Join || m.membership === Membership.Invite); const allowedMembers = new Set([mx.getSafeUserId(), preset.mxid]); if (activeMembers.some((m) => !allowedMembers.has(m.userId))) { return { kind: 'unsafe-membership', room }; } if (my === Membership.Invite) return { kind: 'self-invite', room }; if (my !== Membership.Join) return undefined; if (bot === Membership.Invite) return { kind: 'bot-invite', room }; if (bot === Membership.Leave || bot === Membership.Ban) return { kind: 'bot-kicked', room }; if (bot === Membership.Join && activeMembers.length === 2) return { kind: 'ready', room }; return undefined; }; // Order: prefer `ready` first, then live invites, then stale states. Within a // kind, sort by recent activity (last room timestamp). Without this, walking // `m.direct[mxid]` in JSON order can pin the UI on a Leave-state stale room // while a fresh Join-state room sits later in the array. const STATE_PRIORITY: Record = { ready: 0, 'self-invite': 1, 'bot-invite': 2, 'unsafe-membership': 3, 'bot-kicked': 4, none: 5, }; const DIRECT_ACCOUNT_DATA = EventType.Direct; const getBotRoomIds = (mx: MatrixClient, preset: BotPreset, mDirect: MDirectMap): string[] => { const ids = new Set(mDirect[preset.mxid] ?? []); mx.getRooms().forEach((room) => { if (isBotControlRoom(mx, room, preset)) { ids.add(room.roomId); } }); return Array.from(ids); }; const computeBotRoomState = (mx: MatrixClient, preset: BotPreset): BotRoomState => { const mDirectsEvent = mx.getAccountData(DIRECT_ACCOUNT_DATA); const mDirect = (mDirectsEvent?.getContent() ?? {}) as MDirectMap; const roomIds = getBotRoomIds(mx, preset, mDirect); // Sweep dead pointers (purged rooms, my=Leave/Ban) from m.direct so the // array doesn't grow unbounded across reconnect cycles. Fire-and-forget; // the next reactive tick will recompute against the cleaned account data. const deadIds: string[] = []; const candidates: BotRoomState[] = []; roomIds.forEach((roomId) => { const room = mx.getRoom(roomId); if (!room) { deadIds.push(roomId); return; } const my = room.getMyMembership(); if (my === Membership.Leave || my === Membership.Ban) { deadIds.push(roomId); return; } const classified = classifyRoom(mx, room, preset); if (classified) candidates.push(classified); }); if (deadIds.length > 0) { Promise.all(deadIds.map((id) => removeRoomIdFromMDirect(mx, id))).catch(() => undefined); } if (candidates.length === 0) return { kind: 'none' }; candidates.sort((a, b) => { const byKind = STATE_PRIORITY[a.kind] - STATE_PRIORITY[b.kind]; if (byKind !== 0) return byKind; const aRoom = 'room' in a ? a.room : undefined; const bRoom = 'room' in b ? b.room : undefined; const aTs = aRoom?.getLastActiveTimestamp() ?? 0; const bTs = bRoom?.getLastActiveTimestamp() ?? 0; return bTs - aTs; }); return candidates[0]; }; // Subscribe to client-level events that can change the (room set × membership) // tuple. Listeners are gated by `roomId` membership of the bot's room set so // we don't recompute on every member event in every room of the account. // // - ClientEvent.AccountData — m.direct mutated (createRoom flow) // - ClientEvent.Room — first sync of a freshly-created room // - RoomEvent.MyMembership — accept/decline of self-invite // - RoomStateEvent.Members — bot accepted invite, was kicked, etc. // - RoomStateEvent.Events — late m.bridge state demotes a portal // room out of the Robots control surface // // We snapshot the «interesting» room id set on each recompute so the listeners // know which room ids to react to. Bot `m.direct` arrays are tiny (≤ a couple // of entries per bot) so the set lookup is O(1) effectively. export const useBotRoom = (preset: BotPreset | undefined): BotRoomState => { const mx = useMatrixClient(); const [state, setState] = useState(() => preset ? computeBotRoomState(mx, preset) : { kind: 'none' } ); useEffect(() => { if (!preset) { setState({ kind: 'none' }); return undefined; } const interestingRoomIds = new Set(); const refreshInterestingRoomIds = () => { const mDirect = (mx.getAccountData(DIRECT_ACCOUNT_DATA)?.getContent() ?? {}) as MDirectMap; interestingRoomIds.clear(); getBotRoomIds(mx, preset, mDirect).forEach((id) => interestingRoomIds.add(id)); }; const reEvaluate = () => { refreshInterestingRoomIds(); setState(computeBotRoomState(mx, preset)); }; reEvaluate(); const onAccountData = (ev: MatrixEvent) => { if (ev.getType() === DIRECT_ACCOUNT_DATA) reEvaluate(); }; const onRoom = (room: Room) => { // A freshly-created bot DM may not be in m.direct yet — recompute always. // Otherwise gate by mxid membership so we ignore unrelated room events. if (interestingRoomIds.has(room.roomId) || room.getMember(preset.mxid)) { reEvaluate(); } }; const onMyMembership = (room: Room) => { if (interestingRoomIds.has(room.roomId) || room.getMember(preset.mxid)) reEvaluate(); }; const onMembers = (ev: MatrixEvent) => { const roomId = ev.getRoomId(); if (roomId && (interestingRoomIds.has(roomId) || ev.getStateKey() === preset.mxid)) { reEvaluate(); } }; const onStateEvent = (ev: MatrixEvent) => { if (!isBridgeStateEvent(ev)) return; const roomId = ev.getRoomId(); const room = roomId ? mx.getRoom(roomId) : undefined; if (roomId && (interestingRoomIds.has(roomId) || room?.getMember(preset.mxid))) { reEvaluate(); } }; mx.on(ClientEvent.AccountData, onAccountData); mx.on(ClientEvent.Room, onRoom); mx.on(RoomEvent.MyMembership, onMyMembership); mx.on(RoomStateEvent.Members, onMembers); mx.on(RoomStateEvent.Events, onStateEvent); return () => { mx.removeListener(ClientEvent.AccountData, onAccountData); mx.removeListener(ClientEvent.Room, onRoom); mx.removeListener(RoomEvent.MyMembership, onMyMembership); mx.removeListener(RoomStateEvent.Members, onMembers); mx.removeListener(RoomStateEvent.Events, onStateEvent); }; }, [mx, preset]); return state; };