vojo/src/app/features/bots/BotShellHero.tsx

128 lines
5.1 KiB
TypeScript

import React, { useState } from 'react';
import type { Room } from 'matrix-js-sdk';
import { Icon, IconButton, Icons, PopOut, RectCords } from 'folds';
import FocusTrap from 'focus-trap-react';
import { useTranslation } from 'react-i18next';
import type { BotPreset } from './catalog';
import { BotShellMenu } from './BotShellMenu';
import { BackRouteHandler } from '../../components/BackRouteHandler';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { stopPropagation } from '../../utils/keyboard';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { mxcUrlToHttp } from '../../utils/matrix';
import * as css from './BotShell.css';
type BotShellHeroProps = {
preset: BotPreset;
room: Room;
};
// Initial for the avatar block. Prefer the human name's first character
// (matches mockup BOT.name = «build-bot» → «B»); fall back to the mxid
// localpart, then to «?» when neither is usable. The previous «T» literal
// hardcoded the Telegram preset and fails the moment a second bot ships.
const heroInitial = (preset: BotPreset): string => {
const fromName = preset.name.trim().charAt(0);
if (fromName) return fromName.toUpperCase();
const local = preset.mxid.split(':')[0].replace('@', '');
return local.charAt(0).toUpperCase() || '?';
};
export function BotShellHero({ preset, room }: BotShellHeroProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
// Standard Matrix avatar resolution. The bot user's profile carries the
// canonical `avatar_url` (set server-side by the bridge or homeserver
// admin); every Matrix client reads the same source. We pull it via the
// bot's RoomMember in this DM rather than `mx.getUser()` because the
// member event in this room is guaranteed to be loaded by the time we
// render — we wouldn't be on `/bots/<id>` otherwise — and avoids any
// edge case where `mx.getUser()` returns a stale or missing entry.
const avatarMxc = room.getMember(preset.mxid)?.getMxcAvatarUrl();
const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const handleOpenMenu: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
// Operator override from /config.json wins; otherwise fall back to the
// localized `Bots.description.<id>` key. Empty string suppresses the
// line entirely so a missing translation doesn't ship the key.
const description = t(`Bots.description.${preset.id}`, {
defaultValue: preset.description ?? '',
});
const initial = heroInitial(preset);
return (
<header className={css.Hero}>
<div className={css.HeroInner}>
{/* Mobile back chevron — bypasses Cinny's standard RoomViewHeader
* (which BotShell deliberately doesn't mount), so the user retains
* the «walk up the route tree» affordance the rest of the client
* provides on phone. Desktop relies on the sidebar to navigate. */}
{isMobile && (
<BackRouteHandler>
{(onBack) => (
<IconButton
className={css.HeroBack}
fill="None"
onClick={onBack}
aria-label={t('Room.close')}
>
<Icon src={Icons.ChevronLeft} />
</IconButton>
)}
</BackRouteHandler>
)}
<div className={css.HeroAvatar} aria-hidden="true">
{avatarUrl ? <img className={css.HeroAvatarImg} src={avatarUrl} alt="" /> : initial}
</div>
<div className={css.HeroBody}>
<div className={css.HeroTitleRow}>
<span className={css.HeroName}>{preset.name}</span>
<span className={css.HeroHandle}>{preset.mxid}</span>
</div>
{description ? <p className={css.HeroDescription}>{description}</p> : null}
</div>
<button
type="button"
className={css.HeroSettingsButton}
onClick={handleOpenMenu}
aria-pressed={!!menuAnchor}
>
{t('Bots.settings_label')}
</button>
</div>
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<BotShellMenu room={room} requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
</header>
);
}