128 lines
5.1 KiB
TypeScript
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>
|
|
);
|
|
}
|