216 lines
8.1 KiB
TypeScript
216 lines
8.1 KiB
TypeScript
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<string, string[]>;
|
||
|
||
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<BotRoomState['kind'], number> = {
|
||
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<BotRoomState>(() =>
|
||
preset ? computeBotRoomState(mx, preset) : { kind: 'none' }
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!preset) {
|
||
setState({ kind: 'none' });
|
||
return undefined;
|
||
}
|
||
|
||
const interestingRoomIds = new Set<string>();
|
||
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;
|
||
};
|