diff --git a/config.json b/config.json index adc16608..a4c430aa 100644 --- a/config.json +++ b/config.json @@ -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", diff --git a/public/locales/en.json b/public/locales/en.json index 016491f7..f99fb1c2 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -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." } } diff --git a/public/locales/ru.json b/public/locales/ru.json index 15239532..35d197ae 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -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." } } diff --git a/src/app/features/bots/BotCard.tsx b/src/app/features/bots/BotCard.tsx new file mode 100644 index 00000000..3294080a --- /dev/null +++ b/src/app/features/bots/BotCard.tsx @@ -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 ( + + + + + + + {initial} + + + + + + {preset.name} + + + + {preset.mxid} + + + + + + + ); +} diff --git a/src/app/features/bots/catalog.ts b/src/app/features/bots/catalog.ts new file mode 100644 index 00000000..c11f2850 --- /dev/null +++ b/src/app/features/bots/catalog.ts @@ -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/`. 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(); + const seenMxids = new Set(); + 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); diff --git a/src/app/features/bots/room.ts b/src/app/features/bots/room.ts new file mode 100644 index 00000000..650f55c4 --- /dev/null +++ b/src/app/features/bots/room.ts @@ -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([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)); diff --git a/src/app/features/bots/useBotRoom.ts b/src/app/features/bots/useBotRoom.ts new file mode 100644 index 00000000..0c5595a5 --- /dev/null +++ b/src/app/features/bots/useBotRoom.ts @@ -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; + +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 = { + ready: 0, + 'self-invite': 1, + 'bot-invite': 2, + 'unsafe-membership': 3, + 'bot-kicked': 4, + none: 5, +}; + +const DIRECT_ACCOUNT_DATA = EventType.Direct; + +const getBotRoomIds = (mx: MatrixClient, preset: BotPreset, mDirect: MDirectMap): string[] => { + const ids = new Set(mDirect[preset.mxid] ?? []); + mx.getRooms().forEach((room) => { + if (isBotControlRoom(mx, room, preset)) { + ids.add(room.roomId); + } + }); + return Array.from(ids); +}; + +const computeBotRoomState = (mx: MatrixClient, preset: BotPreset): BotRoomState => { + const mDirectsEvent = mx.getAccountData(DIRECT_ACCOUNT_DATA); + const mDirect = (mDirectsEvent?.getContent() ?? {}) as MDirectMap; + const roomIds = getBotRoomIds(mx, preset, mDirect); + + // Sweep dead pointers (purged rooms, my=Leave/Ban) from m.direct so the + // array doesn't grow unbounded across reconnect cycles. Fire-and-forget; + // the next reactive tick will recompute against the cleaned account data. + const deadIds: string[] = []; + const candidates: BotRoomState[] = []; + roomIds.forEach((roomId) => { + const room = mx.getRoom(roomId); + if (!room) { + deadIds.push(roomId); + return; + } + const my = room.getMyMembership(); + if (my === Membership.Leave || my === Membership.Ban) { + deadIds.push(roomId); + return; + } + const classified = classifyRoom(mx, room, preset); + if (classified) candidates.push(classified); + }); + + if (deadIds.length > 0) { + Promise.all(deadIds.map((id) => removeRoomIdFromMDirect(mx, id))).catch(() => undefined); + } + + if (candidates.length === 0) return { kind: 'none' }; + + candidates.sort((a, b) => { + const byKind = STATE_PRIORITY[a.kind] - STATE_PRIORITY[b.kind]; + if (byKind !== 0) return byKind; + const aRoom = 'room' in a ? a.room : undefined; + const bRoom = 'room' in b ? b.room : undefined; + const aTs = aRoom?.getLastActiveTimestamp() ?? 0; + const bTs = bRoom?.getLastActiveTimestamp() ?? 0; + return bTs - aTs; + }); + + return candidates[0]; +}; + +// Subscribe to client-level events that can change the (room set × membership) +// tuple. Listeners are gated by `roomId` membership of the bot's room set so +// we don't recompute on every member event in every room of the account. +// +// - ClientEvent.AccountData — m.direct mutated (createRoom flow) +// - ClientEvent.Room — first sync of a freshly-created room +// - RoomEvent.MyMembership — accept/decline of self-invite +// - RoomStateEvent.Members — bot accepted invite, was kicked, etc. +// - RoomStateEvent.Events — late m.bridge state demotes a portal +// room out of the Robots control surface +// +// We snapshot the «interesting» room id set on each recompute so the listeners +// know which room ids to react to. Bot `m.direct` arrays are tiny (≤ a couple +// of entries per bot) so the set lookup is O(1) effectively. +export const useBotRoom = (preset: BotPreset | undefined): BotRoomState => { + const mx = useMatrixClient(); + const [state, setState] = useState(() => + preset ? computeBotRoomState(mx, preset) : { kind: 'none' } + ); + + useEffect(() => { + if (!preset) { + setState({ kind: 'none' }); + return undefined; + } + + const interestingRoomIds = new Set(); + const refreshInterestingRoomIds = () => { + const mDirect = (mx.getAccountData(DIRECT_ACCOUNT_DATA)?.getContent() ?? {}) as MDirectMap; + interestingRoomIds.clear(); + getBotRoomIds(mx, preset, mDirect).forEach((id) => interestingRoomIds.add(id)); + }; + + const reEvaluate = () => { + refreshInterestingRoomIds(); + setState(computeBotRoomState(mx, preset)); + }; + reEvaluate(); + + const onAccountData = (ev: MatrixEvent) => { + if (ev.getType() === DIRECT_ACCOUNT_DATA) reEvaluate(); + }; + const onRoom = (room: Room) => { + // A freshly-created bot DM may not be in m.direct yet — recompute always. + // Otherwise gate by mxid membership so we ignore unrelated room events. + if (interestingRoomIds.has(room.roomId) || room.getMember(preset.mxid)) { + reEvaluate(); + } + }; + const onMyMembership = (room: Room) => { + if (interestingRoomIds.has(room.roomId) || room.getMember(preset.mxid)) reEvaluate(); + }; + const onMembers = (ev: MatrixEvent) => { + const roomId = ev.getRoomId(); + if (roomId && (interestingRoomIds.has(roomId) || ev.getStateKey() === preset.mxid)) { + reEvaluate(); + } + }; + const onStateEvent = (ev: MatrixEvent) => { + if (!isBridgeStateEvent(ev)) return; + const roomId = ev.getRoomId(); + const room = roomId ? mx.getRoom(roomId) : undefined; + if (roomId && (interestingRoomIds.has(roomId) || room?.getMember(preset.mxid))) { + reEvaluate(); + } + }; + + mx.on(ClientEvent.AccountData, onAccountData); + mx.on(ClientEvent.Room, onRoom); + mx.on(RoomEvent.MyMembership, onMyMembership); + mx.on(RoomStateEvent.Members, onMembers); + mx.on(RoomStateEvent.Events, onStateEvent); + + return () => { + mx.removeListener(ClientEvent.AccountData, onAccountData); + mx.removeListener(ClientEvent.Room, onRoom); + mx.removeListener(RoomEvent.MyMembership, onMyMembership); + mx.removeListener(RoomStateEvent.Members, onMembers); + mx.removeListener(RoomStateEvent.Events, onStateEvent); + }; + }, [mx, preset]); + + return state; +}; diff --git a/src/app/features/room/RoomViewHeaderDm.tsx b/src/app/features/room/RoomViewHeaderDm.tsx index 54dbaa59..8db4aeea 100644 --- a/src/app/features/room/RoomViewHeaderDm.tsx +++ b/src/app/features/room/RoomViewHeaderDm.tsx @@ -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; diff --git a/src/app/hooks/router/useDirectSelected.ts b/src/app/hooks/router/useDirectSelected.ts index adb2851e..1e1a9a81 100644 --- a/src/app/hooks/router/useDirectSelected.ts +++ b/src/app/hooks/router/useDirectSelected.ts @@ -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 => { diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 2abe6ca2..3c3371d6 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -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(null); diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 101d6b12..c525e810 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -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) } /> - {/* 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/. */} {mobile ? null : } />} + } /> } /> } + title={t('Bots.unknown_title')} + description={t('Bots.unknown_description')} + /> + ); + } + + if (botRoomState.kind === 'none') { + return ; + } + + if (botRoomState.kind === 'self-invite') { + return ; + } + + if (botRoomState.kind === 'bot-invite') { + return ; + } + + if (botRoomState.kind === 'bot-kicked') { + return ; + } + + if (botRoomState.kind === 'unsafe-membership') { + return ; + } + + const { room } = botRoomState; + + return ( + + + + ); +} diff --git a/src/app/pages/client/bots/BotInvitePending.tsx b/src/app/pages/client/bots/BotInvitePending.tsx new file mode 100644 index 00000000..778c9fe7 --- /dev/null +++ b/src/app/pages/client/bots/BotInvitePending.tsx @@ -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( + 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( + 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 ( + } + 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 && ( + + + + {error.message || (acceptError ? t('Bots.accept_error') : t('Bots.reset_error'))} + + + )} + {selfInvite && ( + + )} + {selfInvite && ( + + )} + + + ); +} diff --git a/src/app/pages/client/bots/BotKicked.tsx b/src/app/pages/client/bots/BotKicked.tsx new file mode 100644 index 00000000..119be372 --- /dev/null +++ b/src/app/pages/client/bots/BotKicked.tsx @@ -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( + 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 ( + } + title={t('Bots.kicked_title', { name: preset.name })} + description={t('Bots.kicked_description', { mxid: preset.mxid })} + > + {error && ( + + + + {error.message || t('Bots.reinvite_error')} + + + )} + + + + ); +} diff --git a/src/app/pages/client/bots/BotNotConnected.tsx b/src/app/pages/client/bots/BotNotConnected.tsx new file mode 100644 index 00000000..9437e43b --- /dev/null +++ b/src/app/pages/client/bots/BotNotConnected.tsx @@ -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; + +const DIRECT_ACCOUNT_DATA = EventType.Direct; + +export function BotNotConnected({ preset }: BotNotConnectedProps) { + const { t } = useTranslation(); + const mx = useMatrixClient(); + + const [connectState, connect] = useAsyncCallback( + 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 . + // - 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 ( + } + title={t('Bots.not_connected_title', { name: preset.name })} + description={t('Bots.not_connected_description', { mxid: preset.mxid })} + > + {error && ( + + + + {error.message || t('Bots.connect_error')} + + + )} + + + ); +} diff --git a/src/app/pages/client/bots/BotOpenChatAction.tsx b/src/app/pages/client/bots/BotOpenChatAction.tsx new file mode 100644 index 00000000..fbf01291 --- /dev/null +++ b/src/app/pages/client/bots/BotOpenChatAction.tsx @@ -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 ( + + ); +} diff --git a/src/app/pages/client/bots/BotRoomProvider.tsx b/src/app/pages/client/bots/BotRoomProvider.tsx new file mode 100644 index 00000000..a0635be2 --- /dev/null +++ b/src/app/pages/client/bots/BotRoomProvider.tsx @@ -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 ( + + {children} + + ); +} diff --git a/src/app/pages/client/bots/BotStatePage.tsx b/src/app/pages/client/bots/BotStatePage.tsx new file mode 100644 index 00000000..df6dc66b --- /dev/null +++ b/src/app/pages/client/bots/BotStatePage.tsx @@ -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 ( + + {screenSize === ScreenSize.Mobile && ( + + + + {(onBack) => ( + + + + )} + + + {title} + + + + )} + + + + + + } + title={title} + subTitle={description} + > + {children && ( + + {children} + + )} + + + + + + + + ); +} diff --git a/src/app/pages/client/bots/BotUnsafeRoom.tsx b/src/app/pages/client/bots/BotUnsafeRoom.tsx new file mode 100644 index 00000000..3d2e051b --- /dev/null +++ b/src/app/pages/client/bots/BotUnsafeRoom.tsx @@ -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 ( + } + title={t('Bots.unsafe_title')} + description={t('Bots.unsafe_description')} + > + + + ); +} diff --git a/src/app/pages/client/bots/Bots.tsx b/src/app/pages/client/bots/Bots.tsx index 14c83a2e..1a967910 100644 --- a/src/app/pages/client/bots/Bots.tsx +++ b/src/app/pages/client/bots/Bots.tsx @@ -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 ; +} export function Bots() { - const { t } = useTranslation(); - useNavToActivePathMapper('bots'); + const bots = useBotPresets(); return ( - - } - title={ - - {t('Bots.title')} - - } - /> - + + + {bots.map((preset) => ( + + ))} + + ); } diff --git a/src/app/pages/client/bots/index.ts b/src/app/pages/client/bots/index.ts index 34877baa..d38a5ec3 100644 --- a/src/app/pages/client/bots/index.ts +++ b/src/app/pages/client/bots/index.ts @@ -1 +1,2 @@ export * from './Bots'; +export * from './BotExperienceHost'; diff --git a/src/app/pages/client/direct/DirectStreamHeader.tsx b/src/app/pages/client/direct/DirectStreamHeader.tsx index 10728d43..027f0a21 100644 --- a/src/app/pages/client/direct/DirectStreamHeader.tsx +++ b/src/app/pages/client/direct/DirectStreamHeader.tsx @@ -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() { /> )} - navigate(BOTS_PATH, navOpts)} - /> + {showBotsSegment && ( + navigate(BOTS_PATH, navOpts)} + /> + )} ); diff --git a/src/app/pages/client/direct/useDirectRooms.ts b/src/app/pages/client/direct/useDirectRooms.ts index e0ab97a7..69481bd2 100644 --- a/src/app/pages/client/direct/useDirectRooms.ts +++ b/src/app/pages/client/direct/useDirectRooms.ts @@ -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(); - 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(); + 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; }; diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts index 27136b12..514dbb9b 100644 --- a/src/app/pages/pathUtils.ts +++ b/src/app/pages/pathUtils.ts @@ -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) }); diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts index 3be9e955..532a6cd6 100644 --- a/src/app/pages/paths.ts +++ b/src/app/pages/paths.ts @@ -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/'; diff --git a/src/app/utils/routeParent.ts b/src/app/utils/routeParent.ts index c5bf77ab..38fddb6f 100644 --- a/src/app/utils/routeParent.ts +++ b/src/app/utils/routeParent.ts @@ -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;