Add runtime-configured bot tab
This commit is contained in:
parent
f59944bb4e
commit
e53339820f
26 changed files with 1038 additions and 49 deletions
|
|
@ -14,7 +14,13 @@
|
|||
"enabled": false,
|
||||
"basename": "/"
|
||||
},
|
||||
|
||||
"bots": [
|
||||
{
|
||||
"id": "telegram",
|
||||
"mxid": "@telegrambot:vojo.chat",
|
||||
"name": "Telegram"
|
||||
}
|
||||
],
|
||||
"push": {
|
||||
"vapidPublicKey": "BHmGRaixeMlWHyxMuRIYDA72dqQIV6mSdap4smklDixZsWS4ZhL01cv9YRHEW6NO0iumXeQ-T0_yirtcHNB5tZw",
|
||||
"gatewayUrl": "http://sygnal:5000/_matrix/push/v1/notify",
|
||||
|
|
|
|||
|
|
@ -919,6 +919,25 @@
|
|||
"invite_body_generic": "New invitation"
|
||||
},
|
||||
"Bots": {
|
||||
"title": "Robots"
|
||||
"not_connected_title": "{{name}} is not connected",
|
||||
"not_connected_description": "Create a private chat with {{mxid}} to use this robot.",
|
||||
"connect": "Connect",
|
||||
"connect_error": "Failed to connect robot.",
|
||||
"pending_title": "{{name}} is connecting",
|
||||
"pending_bot_invite_description": "The chat exists. Waiting for {{mxid}} to join.",
|
||||
"pending_self_invite_description": "You have been invited to the chat with this robot. Accept the invite to continue.",
|
||||
"accept_invite": "Accept invite",
|
||||
"accept_error": "Failed to accept invite.",
|
||||
"decline_invite": "Decline invite",
|
||||
"reset_error": "Failed to reset connection.",
|
||||
"kicked_title": "{{name}} disconnected",
|
||||
"kicked_description": "The chat exists, but {{mxid}} is no longer in it.",
|
||||
"reinvite": "Re-invite {{name}}",
|
||||
"reinvite_error": "Failed to re-invite robot.",
|
||||
"unsafe_title": "This robot chat is not private",
|
||||
"unsafe_description": "The robot is blocked because another active member is present in the chat.",
|
||||
"open_chat": "Open chat",
|
||||
"unknown_title": "Robot not found",
|
||||
"unknown_description": "This robot is not in the Vojo catalog."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -923,6 +923,25 @@
|
|||
"invite_body_generic": "Новое приглашение"
|
||||
},
|
||||
"Bots": {
|
||||
"title": "Роботы"
|
||||
"not_connected_title": "{{name}} не подключён",
|
||||
"not_connected_description": "Создайте приватный чат с {{mxid}}, чтобы пользоваться роботом.",
|
||||
"connect": "Подключить",
|
||||
"connect_error": "Не удалось подключить робота.",
|
||||
"pending_title": "{{name}} подключается",
|
||||
"pending_bot_invite_description": "Чат уже создан. Ждём, пока {{mxid}} присоединится.",
|
||||
"pending_self_invite_description": "Вас пригласили в чат с роботом. Примите приглашение, чтобы продолжить.",
|
||||
"accept_invite": "Принять приглашение",
|
||||
"accept_error": "Не удалось принять приглашение.",
|
||||
"decline_invite": "Отклонить приглашение",
|
||||
"reset_error": "Не удалось сбросить подключение.",
|
||||
"kicked_title": "{{name}} отключён",
|
||||
"kicked_description": "Чат с роботом существует, но {{mxid}} больше не в нём.",
|
||||
"reinvite": "Пригласить {{name}} заново",
|
||||
"reinvite_error": "Не удалось пригласить робота заново.",
|
||||
"unsafe_title": "Чат с роботом не приватный",
|
||||
"unsafe_description": "Робот заблокирован: в чате присутствует посторонний участник.",
|
||||
"open_chat": "Открыть чат",
|
||||
"unknown_title": "Робот не найден",
|
||||
"unknown_description": "Этого робота нет в каталоге Vojo."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
65
src/app/features/bots/BotCard.tsx
Normal file
65
src/app/features/bots/BotCard.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import React from 'react';
|
||||
import { Avatar, Box, Text, toRem } from 'folds';
|
||||
import { NavItem, NavItemContent, NavLink } from '../../components/nav';
|
||||
import { getBotPath } from '../../pages/pathUtils';
|
||||
import type { BotPreset } from './catalog';
|
||||
|
||||
const MONO_FONT = '"JetBrains Mono Variable", ui-monospace, monospace';
|
||||
const ROW_MIN_HEIGHT = toRem(56);
|
||||
const AVATAR_BG = '#7ab6d9';
|
||||
|
||||
type BotCardProps = {
|
||||
preset: BotPreset;
|
||||
selected?: boolean;
|
||||
};
|
||||
|
||||
export function BotCard({ preset, selected }: BotCardProps) {
|
||||
const initial = preset.name.trim().charAt(0).toUpperCase() || '?';
|
||||
|
||||
return (
|
||||
<NavItem
|
||||
variant="Background"
|
||||
radii="400"
|
||||
aria-selected={selected}
|
||||
style={{ minHeight: ROW_MIN_HEIGHT }}
|
||||
>
|
||||
<NavLink to={getBotPath(preset.id)}>
|
||||
<NavItemContent>
|
||||
<Box
|
||||
as="span"
|
||||
grow="Yes"
|
||||
alignItems="Center"
|
||||
gap="300"
|
||||
style={{
|
||||
minHeight: ROW_MIN_HEIGHT,
|
||||
boxSizing: 'border-box',
|
||||
padding: `${toRem(6)} 0`,
|
||||
}}
|
||||
>
|
||||
<Avatar size="300" radii="400" style={{ background: AVATAR_BG, color: '#0c0c0e' }}>
|
||||
<Text as="span" size="H6" style={{ color: '#0c0c0e', fontWeight: 700 }}>
|
||||
{initial}
|
||||
</Text>
|
||||
</Avatar>
|
||||
<Box
|
||||
as="span"
|
||||
direction="Column"
|
||||
grow="Yes"
|
||||
gap="100"
|
||||
style={{ minWidth: 0, overflow: 'hidden' }}
|
||||
>
|
||||
<Box as="span" alignItems="Center" gap="200" style={{ minWidth: 0 }}>
|
||||
<Text as="span" size="T300" truncate style={{ fontWeight: 600 }}>
|
||||
{preset.name}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text as="span" size="T200" truncate style={{ opacity: 0.6, fontFamily: MONO_FONT }}>
|
||||
{preset.mxid}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</NavItemContent>
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
);
|
||||
}
|
||||
53
src/app/features/bots/catalog.ts
Normal file
53
src/app/features/bots/catalog.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useClientConfig } from '../../hooks/useClientConfig';
|
||||
import type { BotConfig, ClientConfig } from '../../hooks/useClientConfig';
|
||||
|
||||
export type BotPreset = {
|
||||
/** Stable URL slug — `/bots/<id>`. Never reuse across bots. */
|
||||
id: string;
|
||||
/** Bot user mxid. The DM with this user IS the bot's control room. */
|
||||
mxid: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const BOT_ID_RE = /^[A-Za-z0-9_-]+$/;
|
||||
const MXID_RE = /^@[^:\s]+:[^\s]+$/;
|
||||
|
||||
const isValidBotPreset = (preset: BotConfig | undefined): preset is BotPreset =>
|
||||
typeof preset?.id === 'string' &&
|
||||
BOT_ID_RE.test(preset.id) &&
|
||||
typeof preset.mxid === 'string' &&
|
||||
MXID_RE.test(preset.mxid) &&
|
||||
typeof preset.name === 'string' &&
|
||||
preset.name.trim().length > 0;
|
||||
|
||||
export const getBotPresets = (clientConfig: ClientConfig): BotPreset[] => {
|
||||
const seenIds = new Set<string>();
|
||||
const seenMxids = new Set<string>();
|
||||
const bots: BotPreset[] = [];
|
||||
const configuredBots = Array.isArray(clientConfig.bots) ? clientConfig.bots : [];
|
||||
|
||||
configuredBots.forEach((preset) => {
|
||||
if (!isValidBotPreset(preset)) return;
|
||||
if (seenIds.has(preset.id) || seenMxids.has(preset.mxid)) return;
|
||||
seenIds.add(preset.id);
|
||||
seenMxids.add(preset.mxid);
|
||||
bots.push({
|
||||
id: preset.id,
|
||||
mxid: preset.mxid,
|
||||
name: preset.name.trim(),
|
||||
});
|
||||
});
|
||||
|
||||
return bots;
|
||||
};
|
||||
|
||||
export const useBotPresets = (): BotPreset[] => {
|
||||
const clientConfig = useClientConfig();
|
||||
return useMemo(() => getBotPresets(clientConfig), [clientConfig]);
|
||||
};
|
||||
|
||||
export const findBotPresetById = (
|
||||
presets: readonly BotPreset[],
|
||||
id: string
|
||||
): BotPreset | undefined => presets.find((preset) => preset.id === id);
|
||||
41
src/app/features/bots/room.ts
Normal file
41
src/app/features/bots/room.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
|
||||
import { Membership, StateEvent } from '../../../types/matrix/room';
|
||||
import { isBridgedRoom, isRoom } from '../../utils/room';
|
||||
import type { BotPreset } from './catalog';
|
||||
|
||||
const ACTIVE_MEMBERSHIPS = new Set<string>([Membership.Join, Membership.Invite]);
|
||||
|
||||
export const isBridgeStateEvent = (ev: MatrixEvent): boolean => {
|
||||
const type = ev.getType();
|
||||
return type === StateEvent.RoomBridge || type === StateEvent.RoomBridgeUnstable;
|
||||
};
|
||||
|
||||
export const isBotControlRoom = (mx: MatrixClient, room: Room, preset: BotPreset): boolean => {
|
||||
if (!isRoom(room) || isBridgedRoom(room)) return false;
|
||||
|
||||
const myUserId = mx.getUserId();
|
||||
if (!myUserId) return false;
|
||||
|
||||
const myMembership = room.getMyMembership();
|
||||
if (!ACTIVE_MEMBERSHIPS.has(myMembership)) return false;
|
||||
|
||||
const botMember = room.getMember(preset.mxid);
|
||||
if (!botMember) return false;
|
||||
|
||||
const allowedMembers = new Set([myUserId, preset.mxid]);
|
||||
const activeMembers = room
|
||||
.getMembers()
|
||||
.filter(
|
||||
(member) => member.membership !== undefined && ACTIVE_MEMBERSHIPS.has(member.membership)
|
||||
);
|
||||
|
||||
return (
|
||||
activeMembers.length > 0 && activeMembers.every((member) => allowedMembers.has(member.userId))
|
||||
);
|
||||
};
|
||||
|
||||
export const isCatalogBotControlRoom = (
|
||||
mx: MatrixClient,
|
||||
room: Room,
|
||||
presets: readonly BotPreset[]
|
||||
): boolean => presets.some((preset) => isBotControlRoom(mx, room, preset));
|
||||
217
src/app/features/bots/useBotRoom.ts
Normal file
217
src/app/features/bots/useBotRoom.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
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 { isBridgedRoom } from '../../utils/room';
|
||||
import { isBotControlRoom, isBridgeStateEvent } 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 (isBridgedRoom(room)) 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;
|
||||
};
|
||||
|
|
@ -79,6 +79,8 @@ import { stopPropagation } from '../../utils/keyboard';
|
|||
import { markAsRead } from '../../utils/notifications';
|
||||
import { getMatrixToRoom } from '../../plugins/matrix-to';
|
||||
import { getViaServers } from '../../plugins/via-servers';
|
||||
import { useBotPresets } from '../bots/catalog';
|
||||
import { isCatalogBotControlRoom } from '../bots/room';
|
||||
import { JumpToTime } from './jump-to-time';
|
||||
import { RoomPinMenu } from './room-pin-menu';
|
||||
import * as css from './RoomViewHeaderDm.css';
|
||||
|
|
@ -385,7 +387,10 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
|
|||
// `isBridgedRoom(room)` call would miss.
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const isBridged = useIsBridgedRoom(room);
|
||||
const callButtonVisible = !callView && isOneOnOne && mDirects.has(room.roomId) && !isBridged;
|
||||
const bots = useBotPresets();
|
||||
const isBotControlRoom = isCatalogBotControlRoom(mx, room, bots);
|
||||
const callButtonVisible =
|
||||
!callView && isOneOnOne && mDirects.has(room.roomId) && !isBridged && !isBotControlRoom;
|
||||
|
||||
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
|
||||
const encryptedRoom = !!encryptionEvent;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMatch } from 'react-router-dom';
|
||||
import { getDirectCreatePath, getDirectPath } from '../../pages/pathUtils';
|
||||
import { getBotsPath, getDirectCreatePath, getDirectPath } from '../../pages/pathUtils';
|
||||
|
||||
export const useDirectSelected = (): boolean => {
|
||||
const directMatch = useMatch({
|
||||
|
|
@ -7,8 +7,13 @@ export const useDirectSelected = (): boolean => {
|
|||
caseSensitive: true,
|
||||
end: false,
|
||||
});
|
||||
const botsMatch = useMatch({
|
||||
path: getBotsPath(),
|
||||
caseSensitive: true,
|
||||
end: false,
|
||||
});
|
||||
|
||||
return !!directMatch;
|
||||
return !!directMatch || !!botsMatch;
|
||||
};
|
||||
|
||||
export const useDirectCreateSelected = (): boolean => {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,12 @@ export type PushConfig = {
|
|||
fcmAppId?: string;
|
||||
};
|
||||
|
||||
export type BotConfig = {
|
||||
id?: string;
|
||||
mxid?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type ClientConfig = {
|
||||
defaultHomeserver?: number;
|
||||
homeserverList?: string[];
|
||||
|
|
@ -27,6 +33,8 @@ export type ClientConfig = {
|
|||
hashRouter?: HashRouterConfig;
|
||||
|
||||
push?: PushConfig;
|
||||
|
||||
bots?: BotConfig[];
|
||||
};
|
||||
|
||||
const ClientConfigContext = createContext<ClientConfig | null>(null);
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ import { getMxIdServer, isUserId } from '../utils/matrix';
|
|||
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
|
||||
import { HomeRouteRoomProvider } from './client/home';
|
||||
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
|
||||
import { Bots } from './client/bots';
|
||||
import { BotExperienceHost, Bots } from './client/bots';
|
||||
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
|
||||
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
|
||||
import { Notifications, Inbox, Invites } from './client/inbox';
|
||||
|
|
@ -245,7 +245,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||
}
|
||||
/>
|
||||
</Route>
|
||||
{/* Bots reuses DirectStreamHeader segments. /bots/* is reserved before SPACE_PATH so deep URLs don't fall to /:spaceIdOrAlias/. Real bot list lands in M2. */}
|
||||
{/* Bots reuses DirectStreamHeader segments. /bots/* is reserved before SPACE_PATH so deep URLs don't fall to /:spaceIdOrAlias/. */}
|
||||
<Route
|
||||
path={BOTS_PATH}
|
||||
element={
|
||||
|
|
@ -261,6 +261,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||
}
|
||||
>
|
||||
{mobile ? null : <Route index element={<WelcomePage />} />}
|
||||
<Route path=":botId" element={<BotExperienceHost />} />
|
||||
</Route>
|
||||
<Route path="/bots/*" element={<Navigate to={BOTS_PATH} replace />} />
|
||||
<Route
|
||||
|
|
|
|||
59
src/app/pages/client/bots/BotExperienceHost.tsx
Normal file
59
src/app/pages/client/bots/BotExperienceHost.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Icon, Icons } from 'folds';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { findBotPresetById, useBotPresets } from '../../../features/bots/catalog';
|
||||
import { useBotRoom } from '../../../features/bots/useBotRoom';
|
||||
import { Room } from '../../../features/room';
|
||||
import { BotInvitePending } from './BotInvitePending';
|
||||
import { BotKicked } from './BotKicked';
|
||||
import { BotNotConnected } from './BotNotConnected';
|
||||
import { BotRoomProvider } from './BotRoomProvider';
|
||||
import { BotStatePage } from './BotStatePage';
|
||||
import { BotUnsafeRoom } from './BotUnsafeRoom';
|
||||
|
||||
export function BotExperienceHost() {
|
||||
const { t } = useTranslation();
|
||||
const { botId } = useParams();
|
||||
const bots = useBotPresets();
|
||||
const preset = findBotPresetById(bots, botId ?? '');
|
||||
const botRoomState = useBotRoom(preset);
|
||||
|
||||
if (!preset) {
|
||||
return (
|
||||
<BotStatePage
|
||||
icon={<Icon size="600" src={Icons.Warning} />}
|
||||
title={t('Bots.unknown_title')}
|
||||
description={t('Bots.unknown_description')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (botRoomState.kind === 'none') {
|
||||
return <BotNotConnected preset={preset} />;
|
||||
}
|
||||
|
||||
if (botRoomState.kind === 'self-invite') {
|
||||
return <BotInvitePending preset={preset} room={botRoomState.room} selfInvite />;
|
||||
}
|
||||
|
||||
if (botRoomState.kind === 'bot-invite') {
|
||||
return <BotInvitePending preset={preset} room={botRoomState.room} />;
|
||||
}
|
||||
|
||||
if (botRoomState.kind === 'bot-kicked') {
|
||||
return <BotKicked preset={preset} room={botRoomState.room} />;
|
||||
}
|
||||
|
||||
if (botRoomState.kind === 'unsafe-membership') {
|
||||
return <BotUnsafeRoom room={botRoomState.room} />;
|
||||
}
|
||||
|
||||
const { room } = botRoomState;
|
||||
|
||||
return (
|
||||
<BotRoomProvider room={room}>
|
||||
<Room />
|
||||
</BotRoomProvider>
|
||||
);
|
||||
}
|
||||
96
src/app/pages/client/bots/BotInvitePending.tsx
Normal file
96
src/app/pages/client/bots/BotInvitePending.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { MatrixError, Room as MatrixRoom } from 'matrix-js-sdk';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Box, Button, Icon, Icons, Spinner, Text, color } from 'folds';
|
||||
import type { BotPreset } from '../../../features/bots/catalog';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { removeRoomIdFromMDirect } from '../../../utils/matrix';
|
||||
import { BotOpenChatAction } from './BotOpenChatAction';
|
||||
import { BotStatePage } from './BotStatePage';
|
||||
|
||||
type BotInvitePendingProps = {
|
||||
preset: BotPreset;
|
||||
room: MatrixRoom;
|
||||
selfInvite?: boolean;
|
||||
};
|
||||
|
||||
export function BotInvitePending({ preset, room, selfInvite }: BotInvitePendingProps) {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
|
||||
// Self-invite: user has been invited to the control DM (e.g. from another
|
||||
// device or by the bot itself). Accept inline so the user doesn't have to
|
||||
// bounce through /direct/ to continue.
|
||||
const [acceptState, accept] = useAsyncCallback<void, Error | MatrixError, []>(
|
||||
useCallback(async () => {
|
||||
await mx.joinRoom(room.roomId);
|
||||
}, [mx, room])
|
||||
);
|
||||
|
||||
// Self-invite decline: leave the invited control DM and drop its m.direct
|
||||
// pointer. We intentionally do not expose this reset while waiting for the
|
||||
// bot to join; creating a replacement room there can race with the bridge
|
||||
// accepting the original invite and recreate the duplicate-DM bug.
|
||||
const [resetState, reset] = useAsyncCallback<void, Error | MatrixError, []>(
|
||||
useCallback(async () => {
|
||||
await mx.leave(room.roomId).catch(() => undefined);
|
||||
await removeRoomIdFromMDirect(mx, room.roomId);
|
||||
mx.forget(room.roomId).catch(() => undefined);
|
||||
}, [mx, room])
|
||||
);
|
||||
|
||||
const acceptLoading = acceptState.status === AsyncStatus.Loading;
|
||||
const acceptError = acceptState.status === AsyncStatus.Error ? acceptState.error : undefined;
|
||||
const resetLoading = resetState.status === AsyncStatus.Loading;
|
||||
const resetError =
|
||||
selfInvite && resetState.status === AsyncStatus.Error ? resetState.error : undefined;
|
||||
const error = acceptError ?? resetError;
|
||||
|
||||
return (
|
||||
<BotStatePage
|
||||
icon={<Icon size="600" src={Icons.Info} />}
|
||||
title={t('Bots.pending_title', { name: preset.name })}
|
||||
description={
|
||||
selfInvite
|
||||
? t('Bots.pending_self_invite_description')
|
||||
: t('Bots.pending_bot_invite_description', { mxid: preset.mxid })
|
||||
}
|
||||
>
|
||||
{error && (
|
||||
<Box style={{ color: color.Critical.Main }} alignItems="Center" gap="200">
|
||||
<Icon src={Icons.Warning} filled size="100" />
|
||||
<Text size="T300" style={{ color: color.Critical.Main }}>
|
||||
<b>{error.message || (acceptError ? t('Bots.accept_error') : t('Bots.reset_error'))}</b>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{selfInvite && (
|
||||
<Button
|
||||
variant="Primary"
|
||||
size="500"
|
||||
radii="400"
|
||||
onClick={() => accept().catch(() => undefined)}
|
||||
disabled={acceptLoading || resetLoading}
|
||||
before={acceptLoading && <Spinner variant="Primary" fill="Solid" size="200" />}
|
||||
>
|
||||
<Text size="B500">{t('Bots.accept_invite')}</Text>
|
||||
</Button>
|
||||
)}
|
||||
{selfInvite && (
|
||||
<Button
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="500"
|
||||
radii="400"
|
||||
onClick={() => reset().catch(() => undefined)}
|
||||
disabled={acceptLoading || resetLoading}
|
||||
before={resetLoading && <Spinner variant="Secondary" fill="Soft" size="200" />}
|
||||
>
|
||||
<Text size="B500">{t('Bots.decline_invite')}</Text>
|
||||
</Button>
|
||||
)}
|
||||
<BotOpenChatAction room={room} />
|
||||
</BotStatePage>
|
||||
);
|
||||
}
|
||||
59
src/app/pages/client/bots/BotKicked.tsx
Normal file
59
src/app/pages/client/bots/BotKicked.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { MatrixError, Room as MatrixRoom } from 'matrix-js-sdk';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Box, Button, Icon, Icons, Spinner, Text, color } from 'folds';
|
||||
import type { BotPreset } from '../../../features/bots/catalog';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { BotOpenChatAction } from './BotOpenChatAction';
|
||||
import { BotStatePage } from './BotStatePage';
|
||||
|
||||
type BotKickedProps = {
|
||||
preset: BotPreset;
|
||||
room: MatrixRoom;
|
||||
};
|
||||
|
||||
export function BotKicked({ preset, room }: BotKickedProps) {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
|
||||
// Re-invite the bot back into the existing control DM. useBotRoom will
|
||||
// re-classify on the resulting RoomStateEvent.Members and transition the
|
||||
// experience host to bot-invite → ready without minting a new room.
|
||||
const [reinviteState, reinvite] = useAsyncCallback<void, Error | MatrixError, []>(
|
||||
useCallback(async () => {
|
||||
await mx.invite(room.roomId, preset.mxid);
|
||||
}, [mx, room, preset])
|
||||
);
|
||||
|
||||
const loading = reinviteState.status === AsyncStatus.Loading;
|
||||
const error = reinviteState.status === AsyncStatus.Error ? reinviteState.error : undefined;
|
||||
|
||||
return (
|
||||
<BotStatePage
|
||||
icon={<Icon size="600" src={Icons.Warning} />}
|
||||
title={t('Bots.kicked_title', { name: preset.name })}
|
||||
description={t('Bots.kicked_description', { mxid: preset.mxid })}
|
||||
>
|
||||
{error && (
|
||||
<Box style={{ color: color.Critical.Main }} alignItems="Center" gap="200">
|
||||
<Icon src={Icons.Warning} filled size="100" />
|
||||
<Text size="T300" style={{ color: color.Critical.Main }}>
|
||||
<b>{error.message || t('Bots.reinvite_error')}</b>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Button
|
||||
variant="Primary"
|
||||
size="500"
|
||||
radii="400"
|
||||
onClick={() => reinvite().catch(() => undefined)}
|
||||
disabled={loading}
|
||||
before={loading && <Spinner variant="Primary" fill="Solid" size="200" />}
|
||||
>
|
||||
<Text size="B500">{t('Bots.reinvite', { name: preset.name })}</Text>
|
||||
</Button>
|
||||
<BotOpenChatAction room={room} />
|
||||
</BotStatePage>
|
||||
);
|
||||
}
|
||||
120
src/app/pages/client/bots/BotNotConnected.tsx
Normal file
120
src/app/pages/client/bots/BotNotConnected.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { EventType, MatrixError, Preset, Visibility } from 'matrix-js-sdk';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Box, Button, Icon, Icons, Spinner, Text, color } from 'folds';
|
||||
import type { BotPreset } from '../../../features/bots/catalog';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { Membership } from '../../../../types/matrix/room';
|
||||
import { addRoomIdToMDirect, removeRoomIdFromMDirect } from '../../../utils/matrix';
|
||||
import { isBotControlRoom } from '../../../features/bots/room';
|
||||
import { BotStatePage } from './BotStatePage';
|
||||
|
||||
type BotNotConnectedProps = {
|
||||
preset: BotPreset;
|
||||
};
|
||||
|
||||
type MDirectMap = Record<string, string[]>;
|
||||
|
||||
const DIRECT_ACCOUNT_DATA = EventType.Direct;
|
||||
|
||||
export function BotNotConnected({ preset }: BotNotConnectedProps) {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const [connectState, connect] = useAsyncCallback<string, Error | MatrixError, []>(
|
||||
useCallback(async () => {
|
||||
// Walk m.direct[bot.mxid] and decide what to do with each pointer:
|
||||
// - Active strict control DM (Join/Invite, only self + bot): reuse.
|
||||
// Portal/group rooms are ignored even if broken m.direct account
|
||||
// data points at them.
|
||||
// useBotRoom will pick real control rooms up via reactive listeners
|
||||
// and BotExperienceHost will swap us out of this <BotNotConnected />.
|
||||
// - Dead (Leave/Ban/null/unknown): drop the pointer. Private DMs
|
||||
// created with TrustedPrivateChat cannot be rejoined unilaterally
|
||||
// (server returns 403 «You are not invited»), so the only way
|
||||
// forward is to forget the dead room and mint a new control DM.
|
||||
// The forgotten room is invisible from the SDK's joined-rooms atom,
|
||||
// so it won't surface in /direct/ either.
|
||||
const mDirect = (mx.getAccountData(DIRECT_ACCOUNT_DATA)?.getContent() ?? {}) as MDirectMap;
|
||||
const existingRooms = (mDirect[preset.mxid] ?? []).map((roomId) => {
|
||||
const room = mx.getRoom(roomId);
|
||||
return { roomId, room, myMembership: room?.getMyMembership() };
|
||||
});
|
||||
|
||||
const deadRooms = existingRooms.filter(
|
||||
({ room, myMembership }) =>
|
||||
!room || (myMembership !== Membership.Join && myMembership !== Membership.Invite)
|
||||
);
|
||||
if (deadRooms.length > 0) {
|
||||
// Null room (purged from store), Leave, Ban, or unknown — all dead.
|
||||
// Drop the pointer so this loop doesn't keep finding it forever.
|
||||
await Promise.all(deadRooms.map(({ roomId }) => removeRoomIdFromMDirect(mx, roomId)));
|
||||
deadRooms.forEach(({ room, myMembership, roomId }) => {
|
||||
if (room && myMembership === Membership.Leave) {
|
||||
// Forget so the SDK can fully GC; no-op if forget fails.
|
||||
mx.forget(roomId).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const reusableRoom = existingRooms.find(({ room, myMembership }) => {
|
||||
if (!room || (myMembership !== Membership.Join && myMembership !== Membership.Invite)) {
|
||||
return false;
|
||||
}
|
||||
const botMembership = room.getMember(preset.mxid)?.membership;
|
||||
return (
|
||||
(botMembership === Membership.Join || botMembership === Membership.Invite) &&
|
||||
isBotControlRoom(mx, room, preset)
|
||||
);
|
||||
});
|
||||
if (reusableRoom) {
|
||||
return reusableRoom.roomId;
|
||||
}
|
||||
|
||||
const { room_id: roomId } = await mx.createRoom({
|
||||
is_direct: true,
|
||||
invite: [preset.mxid],
|
||||
visibility: Visibility.Private,
|
||||
preset: Preset.TrustedPrivateChat,
|
||||
});
|
||||
|
||||
await addRoomIdToMDirect(mx, roomId, preset.mxid);
|
||||
return roomId;
|
||||
}, [mx, preset])
|
||||
);
|
||||
|
||||
const loading = connectState.status === AsyncStatus.Loading;
|
||||
const error = connectState.status === AsyncStatus.Error ? connectState.error : undefined;
|
||||
|
||||
const handleConnect = () => {
|
||||
connect().catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<BotStatePage
|
||||
icon={<Icon size="600" src={Icons.Message} />}
|
||||
title={t('Bots.not_connected_title', { name: preset.name })}
|
||||
description={t('Bots.not_connected_description', { mxid: preset.mxid })}
|
||||
>
|
||||
{error && (
|
||||
<Box style={{ color: color.Critical.Main }} alignItems="Center" gap="200">
|
||||
<Icon src={Icons.Warning} filled size="100" />
|
||||
<Text size="T300" style={{ color: color.Critical.Main }}>
|
||||
<b>{error.message || t('Bots.connect_error')}</b>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Button
|
||||
variant="Primary"
|
||||
size="500"
|
||||
radii="400"
|
||||
onClick={handleConnect}
|
||||
disabled={loading}
|
||||
before={loading && <Spinner variant="Primary" fill="Solid" size="200" />}
|
||||
>
|
||||
<Text size="B500">{t('Bots.connect')}</Text>
|
||||
</Button>
|
||||
</BotStatePage>
|
||||
);
|
||||
}
|
||||
31
src/app/pages/client/bots/BotOpenChatAction.tsx
Normal file
31
src/app/pages/client/bots/BotOpenChatAction.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import React from 'react';
|
||||
import { Room as MatrixRoom } from 'matrix-js-sdk';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Text } from 'folds';
|
||||
import { getDirectRoomPath } from '../../pathUtils';
|
||||
|
||||
type BotOpenChatActionProps = {
|
||||
room: MatrixRoom;
|
||||
};
|
||||
|
||||
// Shared «escape hatch» CTA used by BotKicked, BotInvitePending and
|
||||
// BotUnsafeRoom. Drops the user into the underlying control DM via /direct/
|
||||
// so they can see members, accept invites, kick offenders or read raw bot
|
||||
// replies — anything Bots tab refuses to surface.
|
||||
export function BotOpenChatAction({ room }: BotOpenChatActionProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="500"
|
||||
radii="400"
|
||||
onClick={() => navigate(getDirectRoomPath(room.roomId))}
|
||||
>
|
||||
<Text size="B500">{t('Bots.open_chat')}</Text>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
19
src/app/pages/client/bots/BotRoomProvider.tsx
Normal file
19
src/app/pages/client/bots/BotRoomProvider.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import { Room as MatrixRoom } from 'matrix-js-sdk';
|
||||
import { IsOneOnOneProvider, RoomProvider } from '../../../hooks/useRoom';
|
||||
import { useIsOneOnOneRoom } from '../../../hooks/useIsOneOnOneRoom';
|
||||
|
||||
type BotRoomProviderProps = {
|
||||
room: MatrixRoom;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function BotRoomProvider({ room, children }: BotRoomProviderProps) {
|
||||
const isOneOnOne = useIsOneOnOneRoom(room);
|
||||
|
||||
return (
|
||||
<RoomProvider key={room.roomId} value={room}>
|
||||
<IsOneOnOneProvider value={isOneOnOne}>{children}</IsOneOnOneProvider>
|
||||
</RoomProvider>
|
||||
);
|
||||
}
|
||||
70
src/app/pages/client/bots/BotStatePage.tsx
Normal file
70
src/app/pages/client/bots/BotStatePage.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import { Box, Icon, IconButton, Icons, Scroll, Text, config } from 'folds';
|
||||
import { BackRouteHandler } from '../../../components/BackRouteHandler';
|
||||
import {
|
||||
Page,
|
||||
PageContent,
|
||||
PageContentCenter,
|
||||
PageHeader,
|
||||
PageHero,
|
||||
PageHeroSection,
|
||||
} from '../../../components/page';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||
|
||||
type BotStatePageProps = {
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
icon?: ReactNode;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export function BotStatePage({ title, description, icon, children }: BotStatePageProps) {
|
||||
const screenSize = useScreenSizeContext();
|
||||
|
||||
return (
|
||||
<Page>
|
||||
{screenSize === ScreenSize.Mobile && (
|
||||
<PageHeader balance outlined={false}>
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<BackRouteHandler>
|
||||
{(onBack) => (
|
||||
<IconButton onClick={onBack}>
|
||||
<Icon src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
)}
|
||||
</BackRouteHandler>
|
||||
<Text size="H4" truncate>
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
</PageHeader>
|
||||
)}
|
||||
<Box grow="Yes">
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
<PageContentCenter>
|
||||
<PageHeroSection>
|
||||
<PageHero
|
||||
icon={icon ?? <Icon size="600" src={Icons.Info} />}
|
||||
title={title}
|
||||
subTitle={description}
|
||||
>
|
||||
{children && (
|
||||
<Box
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{ paddingTop: config.space.S200 }}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
</PageHero>
|
||||
</PageHeroSection>
|
||||
</PageContentCenter>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
24
src/app/pages/client/bots/BotUnsafeRoom.tsx
Normal file
24
src/app/pages/client/bots/BotUnsafeRoom.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import React from 'react';
|
||||
import { Room as MatrixRoom } from 'matrix-js-sdk';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon, Icons } from 'folds';
|
||||
import { BotOpenChatAction } from './BotOpenChatAction';
|
||||
import { BotStatePage } from './BotStatePage';
|
||||
|
||||
type BotUnsafeRoomProps = {
|
||||
room: MatrixRoom;
|
||||
};
|
||||
|
||||
export function BotUnsafeRoom({ room }: BotUnsafeRoomProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<BotStatePage
|
||||
icon={<Icon size="600" src={Icons.Warning} />}
|
||||
title={t('Bots.unsafe_title')}
|
||||
description={t('Bots.unsafe_description')}
|
||||
>
|
||||
<BotOpenChatAction room={room} />
|
||||
</BotStatePage>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,28 +1,43 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon, Icons, Text } from 'folds';
|
||||
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
|
||||
import { NavEmptyCenter, NavEmptyLayout } from '../../../components/nav';
|
||||
import { PageNav } from '../../../components/page';
|
||||
import { Box, color, config, toRem } from 'folds';
|
||||
import { useMatch } from 'react-router-dom';
|
||||
import { PageNav, PageNavContent } from '../../../components/page';
|
||||
import { DirectStreamHeader } from '../direct/DirectStreamHeader';
|
||||
import { useBotPresets } from '../../../features/bots/catalog';
|
||||
import type { BotPreset } from '../../../features/bots/catalog';
|
||||
import { BotCard } from '../../../features/bots/BotCard';
|
||||
import { BOTS_BOT_PATH } from '../../paths';
|
||||
|
||||
// Static preset links only. Room discovery belongs to /bots/:botId so the list
|
||||
// doesn't install Matrix listeners or scan rooms for every catalog entry.
|
||||
function BotRow({ preset }: { preset: BotPreset }) {
|
||||
// `end: false` so future child routes under :botId keep the parent card
|
||||
// selected. RR-v6 already URL-decodes `params.botId`, no extra decode here.
|
||||
const match = useMatch({ path: BOTS_BOT_PATH, caseSensitive: true, end: false });
|
||||
const selected = match?.params.botId === preset.id;
|
||||
return <BotCard preset={preset} selected={selected} />;
|
||||
}
|
||||
|
||||
export function Bots() {
|
||||
const { t } = useTranslation();
|
||||
useNavToActivePathMapper('bots');
|
||||
const bots = useBotPresets();
|
||||
|
||||
return (
|
||||
<PageNav size="500">
|
||||
<DirectStreamHeader />
|
||||
<NavEmptyCenter>
|
||||
<NavEmptyLayout
|
||||
icon={<Icon size="600" src={Icons.Bulb} />}
|
||||
title={
|
||||
<Text size="H5" align="Center">
|
||||
{t('Bots.title')}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</NavEmptyCenter>
|
||||
<PageNavContent>
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="100"
|
||||
style={{
|
||||
padding: `${toRem(6)} ${config.space.S100}`,
|
||||
borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||
}}
|
||||
>
|
||||
{bots.map((preset) => (
|
||||
<BotRow key={preset.id} preset={preset} />
|
||||
))}
|
||||
</Box>
|
||||
</PageNavContent>
|
||||
</PageNav>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
export * from './Bots';
|
||||
export * from './BotExperienceHost';
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Box, Text, Tooltip, TooltipProvider, color, toRem } from 'folds';
|
|||
import { PageNavHeader } from '../../../components/page';
|
||||
import { BOTS_PATH, DIRECT_PATH } from '../../paths';
|
||||
import { isNativePlatform } from '../../../utils/capacitor';
|
||||
import { useBotPresets } from '../../../features/bots/catalog';
|
||||
|
||||
type SegmentProps = {
|
||||
active: boolean;
|
||||
|
|
@ -44,9 +45,11 @@ export function DirectStreamHeader() {
|
|||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const comingSoon = t('Direct.segment_coming_soon');
|
||||
const bots = useBotPresets();
|
||||
|
||||
const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: false });
|
||||
const botsMatch = useMatch({ path: BOTS_PATH, caseSensitive: true, end: false });
|
||||
const showBotsSegment = bots.length > 0 || !!botsMatch;
|
||||
|
||||
const navOpts = { replace: isNativePlatform() };
|
||||
|
||||
|
|
@ -76,11 +79,13 @@ export function DirectStreamHeader() {
|
|||
/>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<Segment
|
||||
active={!!botsMatch}
|
||||
label={t('Direct.segment_bots')}
|
||||
onClick={() => navigate(BOTS_PATH, navOpts)}
|
||||
/>
|
||||
{showBotsSegment && (
|
||||
<Segment
|
||||
active={!!botsMatch}
|
||||
label={t('Direct.segment_bots')}
|
||||
onClick={() => navigate(BOTS_PATH, navOpts)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</PageNavHeader>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
import { useAtomValue } from 'jotai';
|
||||
import { useMemo } from 'react';
|
||||
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`. Implementation =
|
||||
// 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` →
|
||||
|
|
@ -32,21 +37,54 @@ export const useDirectRooms = (): string[] => {
|
|||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const orphanRooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents);
|
||||
const directs = useDirects(mx, allRoomsAtom, mDirects);
|
||||
return useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
orphanRooms.forEach((id) => {
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
out.push(id);
|
||||
const bots = useBotPresets();
|
||||
const directCandidates = useMemo(
|
||||
() => new Set([...orphanRooms, ...directs]),
|
||||
[orphanRooms, directs]
|
||||
);
|
||||
const [, setRoomStateTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const bumpIfCandidate = (roomId: string | undefined) => {
|
||||
if (roomId && directCandidates.has(roomId)) {
|
||||
setRoomStateTick((tick) => tick + 1);
|
||||
}
|
||||
});
|
||||
directs.forEach((id) => {
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
out.push(id);
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}, [orphanRooms, directs]);
|
||||
};
|
||||
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]);
|
||||
|
||||
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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { generatePath, Path } from 'react-router-dom';
|
||||
import {
|
||||
BOTS_BOT_PATH,
|
||||
BOTS_PATH,
|
||||
DIRECT_CREATE_PATH,
|
||||
DIRECT_PATH,
|
||||
|
|
@ -161,3 +162,5 @@ export const getInboxNotificationsPath = (): string => INBOX_NOTIFICATIONS_PATH;
|
|||
export const getInboxInvitesPath = (): string => INBOX_INVITES_PATH;
|
||||
|
||||
export const getBotsPath = (): string => BOTS_PATH;
|
||||
export const getBotPath = (botId: string): string =>
|
||||
generatePath(BOTS_BOT_PATH, { botId: encodeURIComponent(botId) });
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ export const USER_LINK_HOST = 'vojo.chat';
|
|||
export const USER_LINK_PATH = '/u/:userIdOrLocalPart';
|
||||
|
||||
export const BOTS_PATH = '/bots/';
|
||||
export const BOTS_BOT_PATH = '/bots/:botId/';
|
||||
|
||||
export const _NOTIFICATIONS_PATH = 'notifications/';
|
||||
export const _INVITES_PATH = 'invites/';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import { matchPath } from 'react-router-dom';
|
||||
import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from '../pages/paths';
|
||||
import {
|
||||
BOTS_PATH,
|
||||
DIRECT_PATH,
|
||||
EXPLORE_PATH,
|
||||
HOME_PATH,
|
||||
INBOX_PATH,
|
||||
SPACE_PATH,
|
||||
} from '../pages/paths';
|
||||
import {
|
||||
getBotsPath,
|
||||
getDirectPath,
|
||||
getExplorePath,
|
||||
getHomePath,
|
||||
|
|
@ -22,6 +30,7 @@ export const getRouteSectionParent = (pathname: string): string | null => {
|
|||
|
||||
if (under(HOME_PATH)) return atRoot(HOME_PATH) ? null : getHomePath();
|
||||
if (under(DIRECT_PATH)) return atRoot(DIRECT_PATH) ? null : getDirectPath();
|
||||
if (under(BOTS_PATH)) return atRoot(BOTS_PATH) ? null : getBotsPath();
|
||||
|
||||
const spaceMatch = matchPath({ path: SPACE_PATH, caseSensitive: true, end: false }, pathname);
|
||||
const encodedSpaceIdOrAlias = spaceMatch?.params.spaceIdOrAlias;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue