Add runtime-configured bot tab
This commit is contained in:
parent
357a2024f4
commit
83e246da1f
26 changed files with 1038 additions and 49 deletions
|
|
@ -14,7 +14,13 @@
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"basename": "/"
|
"basename": "/"
|
||||||
},
|
},
|
||||||
|
"bots": [
|
||||||
|
{
|
||||||
|
"id": "telegram",
|
||||||
|
"mxid": "@telegrambot:vojo.chat",
|
||||||
|
"name": "Telegram"
|
||||||
|
}
|
||||||
|
],
|
||||||
"push": {
|
"push": {
|
||||||
"vapidPublicKey": "BHmGRaixeMlWHyxMuRIYDA72dqQIV6mSdap4smklDixZsWS4ZhL01cv9YRHEW6NO0iumXeQ-T0_yirtcHNB5tZw",
|
"vapidPublicKey": "BHmGRaixeMlWHyxMuRIYDA72dqQIV6mSdap4smklDixZsWS4ZhL01cv9YRHEW6NO0iumXeQ-T0_yirtcHNB5tZw",
|
||||||
"gatewayUrl": "http://sygnal:5000/_matrix/push/v1/notify",
|
"gatewayUrl": "http://sygnal:5000/_matrix/push/v1/notify",
|
||||||
|
|
|
||||||
|
|
@ -919,6 +919,25 @@
|
||||||
"invite_body_generic": "New invitation"
|
"invite_body_generic": "New invitation"
|
||||||
},
|
},
|
||||||
"Bots": {
|
"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": "Новое приглашение"
|
"invite_body_generic": "Новое приглашение"
|
||||||
},
|
},
|
||||||
"Bots": {
|
"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 { markAsRead } from '../../utils/notifications';
|
||||||
import { getMatrixToRoom } from '../../plugins/matrix-to';
|
import { getMatrixToRoom } from '../../plugins/matrix-to';
|
||||||
import { getViaServers } from '../../plugins/via-servers';
|
import { getViaServers } from '../../plugins/via-servers';
|
||||||
|
import { useBotPresets } from '../bots/catalog';
|
||||||
|
import { isCatalogBotControlRoom } from '../bots/room';
|
||||||
import { JumpToTime } from './jump-to-time';
|
import { JumpToTime } from './jump-to-time';
|
||||||
import { RoomPinMenu } from './room-pin-menu';
|
import { RoomPinMenu } from './room-pin-menu';
|
||||||
import * as css from './RoomViewHeaderDm.css';
|
import * as css from './RoomViewHeaderDm.css';
|
||||||
|
|
@ -385,7 +387,10 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
|
||||||
// `isBridgedRoom(room)` call would miss.
|
// `isBridgedRoom(room)` call would miss.
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
const isBridged = useIsBridgedRoom(room);
|
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 encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
|
||||||
const encryptedRoom = !!encryptionEvent;
|
const encryptedRoom = !!encryptionEvent;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useMatch } from 'react-router-dom';
|
import { useMatch } from 'react-router-dom';
|
||||||
import { getDirectCreatePath, getDirectPath } from '../../pages/pathUtils';
|
import { getBotsPath, getDirectCreatePath, getDirectPath } from '../../pages/pathUtils';
|
||||||
|
|
||||||
export const useDirectSelected = (): boolean => {
|
export const useDirectSelected = (): boolean => {
|
||||||
const directMatch = useMatch({
|
const directMatch = useMatch({
|
||||||
|
|
@ -7,8 +7,13 @@ export const useDirectSelected = (): boolean => {
|
||||||
caseSensitive: true,
|
caseSensitive: true,
|
||||||
end: false,
|
end: false,
|
||||||
});
|
});
|
||||||
|
const botsMatch = useMatch({
|
||||||
|
path: getBotsPath(),
|
||||||
|
caseSensitive: true,
|
||||||
|
end: false,
|
||||||
|
});
|
||||||
|
|
||||||
return !!directMatch;
|
return !!directMatch || !!botsMatch;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useDirectCreateSelected = (): boolean => {
|
export const useDirectCreateSelected = (): boolean => {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,12 @@ export type PushConfig = {
|
||||||
fcmAppId?: string;
|
fcmAppId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BotConfig = {
|
||||||
|
id?: string;
|
||||||
|
mxid?: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ClientConfig = {
|
export type ClientConfig = {
|
||||||
defaultHomeserver?: number;
|
defaultHomeserver?: number;
|
||||||
homeserverList?: string[];
|
homeserverList?: string[];
|
||||||
|
|
@ -27,6 +33,8 @@ export type ClientConfig = {
|
||||||
hashRouter?: HashRouterConfig;
|
hashRouter?: HashRouterConfig;
|
||||||
|
|
||||||
push?: PushConfig;
|
push?: PushConfig;
|
||||||
|
|
||||||
|
bots?: BotConfig[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const ClientConfigContext = createContext<ClientConfig | null>(null);
|
const ClientConfigContext = createContext<ClientConfig | null>(null);
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ import { getMxIdServer, isUserId } from '../utils/matrix';
|
||||||
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
|
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
|
||||||
import { HomeRouteRoomProvider } from './client/home';
|
import { HomeRouteRoomProvider } from './client/home';
|
||||||
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
|
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 { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
|
||||||
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
|
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
|
||||||
import { Notifications, Inbox, Invites } from './client/inbox';
|
import { Notifications, Inbox, Invites } from './client/inbox';
|
||||||
|
|
@ -245,7 +245,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</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
|
<Route
|
||||||
path={BOTS_PATH}
|
path={BOTS_PATH}
|
||||||
element={
|
element={
|
||||||
|
|
@ -261,6 +261,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{mobile ? null : <Route index element={<WelcomePage />} />}
|
{mobile ? null : <Route index element={<WelcomePage />} />}
|
||||||
|
<Route path=":botId" element={<BotExperienceHost />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/bots/*" element={<Navigate to={BOTS_PATH} replace />} />
|
<Route path="/bots/*" element={<Navigate to={BOTS_PATH} replace />} />
|
||||||
<Route
|
<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 React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { Box, color, config, toRem } from 'folds';
|
||||||
import { Icon, Icons, Text } from 'folds';
|
import { useMatch } from 'react-router-dom';
|
||||||
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
|
import { PageNav, PageNavContent } from '../../../components/page';
|
||||||
import { NavEmptyCenter, NavEmptyLayout } from '../../../components/nav';
|
|
||||||
import { PageNav } from '../../../components/page';
|
|
||||||
import { DirectStreamHeader } from '../direct/DirectStreamHeader';
|
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() {
|
export function Bots() {
|
||||||
const { t } = useTranslation();
|
const bots = useBotPresets();
|
||||||
useNavToActivePathMapper('bots');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageNav size="500">
|
<PageNav size="500">
|
||||||
<DirectStreamHeader />
|
<DirectStreamHeader />
|
||||||
<NavEmptyCenter>
|
<PageNavContent>
|
||||||
<NavEmptyLayout
|
<Box
|
||||||
icon={<Icon size="600" src={Icons.Bulb} />}
|
direction="Column"
|
||||||
title={
|
gap="100"
|
||||||
<Text size="H5" align="Center">
|
style={{
|
||||||
{t('Bots.title')}
|
padding: `${toRem(6)} ${config.space.S100}`,
|
||||||
</Text>
|
borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
}
|
}}
|
||||||
/>
|
>
|
||||||
</NavEmptyCenter>
|
{bots.map((preset) => (
|
||||||
|
<BotRow key={preset.id} preset={preset} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</PageNavContent>
|
||||||
</PageNav>
|
</PageNav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export * from './Bots';
|
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 { PageNavHeader } from '../../../components/page';
|
||||||
import { BOTS_PATH, DIRECT_PATH } from '../../paths';
|
import { BOTS_PATH, DIRECT_PATH } from '../../paths';
|
||||||
import { isNativePlatform } from '../../../utils/capacitor';
|
import { isNativePlatform } from '../../../utils/capacitor';
|
||||||
|
import { useBotPresets } from '../../../features/bots/catalog';
|
||||||
|
|
||||||
type SegmentProps = {
|
type SegmentProps = {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
|
@ -44,9 +45,11 @@ export function DirectStreamHeader() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const comingSoon = t('Direct.segment_coming_soon');
|
const comingSoon = t('Direct.segment_coming_soon');
|
||||||
|
const bots = useBotPresets();
|
||||||
|
|
||||||
const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: false });
|
const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: false });
|
||||||
const botsMatch = useMatch({ path: BOTS_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() };
|
const navOpts = { replace: isNativePlatform() };
|
||||||
|
|
||||||
|
|
@ -76,11 +79,13 @@ export function DirectStreamHeader() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
<Segment
|
{showBotsSegment && (
|
||||||
active={!!botsMatch}
|
<Segment
|
||||||
label={t('Direct.segment_bots')}
|
active={!!botsMatch}
|
||||||
onClick={() => navigate(BOTS_PATH, navOpts)}
|
label={t('Direct.segment_bots')}
|
||||||
/>
|
onClick={() => navigate(BOTS_PATH, navOpts)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</PageNavHeader>
|
</PageNavHeader>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
import { useAtomValue } from 'jotai';
|
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 { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { mDirectAtom } from '../../../state/mDirectList';
|
import { mDirectAtom } from '../../../state/mDirectList';
|
||||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||||
import { useDirects, useOrphanRooms } from '../../../state/hooks/roomList';
|
import { useDirects, useOrphanRooms } from '../../../state/hooks/roomList';
|
||||||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
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»
|
// 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 ∪ useDirects`:
|
||||||
//
|
//
|
||||||
// - `useOrphanRooms` = `isRoom && !mDirects.has && !roomToParents.has` →
|
// - `useOrphanRooms` = `isRoom && !mDirects.has && !roomToParents.has` →
|
||||||
|
|
@ -32,21 +37,54 @@ export const useDirectRooms = (): string[] => {
|
||||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
const orphanRooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents);
|
const orphanRooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents);
|
||||||
const directs = useDirects(mx, allRoomsAtom, mDirects);
|
const directs = useDirects(mx, allRoomsAtom, mDirects);
|
||||||
return useMemo(() => {
|
const bots = useBotPresets();
|
||||||
const seen = new Set<string>();
|
const directCandidates = useMemo(
|
||||||
const out: string[] = [];
|
() => new Set([...orphanRooms, ...directs]),
|
||||||
orphanRooms.forEach((id) => {
|
[orphanRooms, directs]
|
||||||
if (!seen.has(id)) {
|
);
|
||||||
seen.add(id);
|
const [, setRoomStateTick] = useState(0);
|
||||||
out.push(id);
|
|
||||||
|
useEffect(() => {
|
||||||
|
const bumpIfCandidate = (roomId: string | undefined) => {
|
||||||
|
if (roomId && directCandidates.has(roomId)) {
|
||||||
|
setRoomStateTick((tick) => tick + 1);
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
directs.forEach((id) => {
|
const onMembers = (ev: MatrixEvent) => {
|
||||||
if (!seen.has(id)) {
|
bumpIfCandidate(ev.getRoomId());
|
||||||
seen.add(id);
|
};
|
||||||
out.push(id);
|
const onStateEvent = (ev: MatrixEvent) => {
|
||||||
}
|
if (isBridgeStateEvent(ev)) bumpIfCandidate(ev.getRoomId());
|
||||||
});
|
};
|
||||||
return out;
|
|
||||||
}, [orphanRooms, directs]);
|
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 { generatePath, Path } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
|
BOTS_BOT_PATH,
|
||||||
BOTS_PATH,
|
BOTS_PATH,
|
||||||
DIRECT_CREATE_PATH,
|
DIRECT_CREATE_PATH,
|
||||||
DIRECT_PATH,
|
DIRECT_PATH,
|
||||||
|
|
@ -161,3 +162,5 @@ export const getInboxNotificationsPath = (): string => INBOX_NOTIFICATIONS_PATH;
|
||||||
export const getInboxInvitesPath = (): string => INBOX_INVITES_PATH;
|
export const getInboxInvitesPath = (): string => INBOX_INVITES_PATH;
|
||||||
|
|
||||||
export const getBotsPath = (): string => BOTS_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 USER_LINK_PATH = '/u/:userIdOrLocalPart';
|
||||||
|
|
||||||
export const BOTS_PATH = '/bots/';
|
export const BOTS_PATH = '/bots/';
|
||||||
|
export const BOTS_BOT_PATH = '/bots/:botId/';
|
||||||
|
|
||||||
export const _NOTIFICATIONS_PATH = 'notifications/';
|
export const _NOTIFICATIONS_PATH = 'notifications/';
|
||||||
export const _INVITES_PATH = 'invites/';
|
export const _INVITES_PATH = 'invites/';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
import { matchPath } from 'react-router-dom';
|
import { matchPath } from 'react-router-dom';
|
||||||
import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from '../pages/paths';
|
|
||||||
import {
|
import {
|
||||||
|
BOTS_PATH,
|
||||||
|
DIRECT_PATH,
|
||||||
|
EXPLORE_PATH,
|
||||||
|
HOME_PATH,
|
||||||
|
INBOX_PATH,
|
||||||
|
SPACE_PATH,
|
||||||
|
} from '../pages/paths';
|
||||||
|
import {
|
||||||
|
getBotsPath,
|
||||||
getDirectPath,
|
getDirectPath,
|
||||||
getExplorePath,
|
getExplorePath,
|
||||||
getHomePath,
|
getHomePath,
|
||||||
|
|
@ -22,6 +30,7 @@ export const getRouteSectionParent = (pathname: string): string | null => {
|
||||||
|
|
||||||
if (under(HOME_PATH)) return atRoot(HOME_PATH) ? null : getHomePath();
|
if (under(HOME_PATH)) return atRoot(HOME_PATH) ? null : getHomePath();
|
||||||
if (under(DIRECT_PATH)) return atRoot(DIRECT_PATH) ? null : getDirectPath();
|
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 spaceMatch = matchPath({ path: SPACE_PATH, caseSensitive: true, end: false }, pathname);
|
||||||
const encodedSpaceIdOrAlias = spaceMatch?.params.spaceIdOrAlias;
|
const encodedSpaceIdOrAlias = spaceMatch?.params.spaceIdOrAlias;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue