feat(bots): render Matrix-native bot avatar in BotCard sidebar row and BotShellHero so server-side avatar_url propagates without client patches

This commit is contained in:
heaven 2026-05-03 13:22:10 +03:00
parent 316c3eb9fd
commit bae6761683
3 changed files with 62 additions and 6 deletions

View file

@ -1,7 +1,11 @@
import React from 'react'; import React from 'react';
import { Avatar, Box, Text, toRem } from 'folds'; import { Avatar, AvatarImage, Box, Text, toRem } from 'folds';
import { NavItem, NavItemContent, NavLink } from '../../components/nav'; import { NavItem, NavItemContent, NavLink } from '../../components/nav';
import { getBotPath } from '../../pages/pathUtils'; import { getBotPath } from '../../pages/pathUtils';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useUserProfile } from '../../hooks/useUserProfile';
import { mxcUrlToHttp } from '../../utils/matrix';
import type { BotPreset } from './catalog'; import type { BotPreset } from './catalog';
const MONO_FONT = '"JetBrains Mono Variable", ui-monospace, monospace'; const MONO_FONT = '"JetBrains Mono Variable", ui-monospace, monospace';
@ -14,8 +18,21 @@ type BotCardProps = {
}; };
export function BotCard({ preset, selected }: BotCardProps) { export function BotCard({ preset, selected }: BotCardProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const initial = preset.name.trim().charAt(0).toUpperCase() || '?'; const initial = preset.name.trim().charAt(0).toUpperCase() || '?';
// Standard Matrix avatar resolution. `useUserProfile` returns the cached
// profile synchronously and subscribes to live `UserEvent.AvatarUrl`
// updates — when the operator sets the bot's `avatar_url` server-side
// (mautrix-telegram bridge config), every BotCard remount picks it up
// without client deploys. The fleet-blue letter square remains as the
// fallback when the bot has no profile avatar yet.
const { avatarUrl: avatarMxc } = useUserProfile(preset.mxid);
const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 56, 56, 'crop') ?? undefined
: undefined;
return ( return (
<NavItem <NavItem
variant="Background" variant="Background"
@ -37,9 +54,13 @@ export function BotCard({ preset, selected }: BotCardProps) {
}} }}
> >
<Avatar size="300" radii="400" style={{ background: AVATAR_BG, color: '#0c0c0e' }}> <Avatar size="300" radii="400" style={{ background: AVATAR_BG, color: '#0c0c0e' }}>
<Text as="span" size="H6" style={{ color: '#0c0c0e', fontWeight: 700 }}> {avatarUrl ? (
{initial} <AvatarImage src={avatarUrl} alt={preset.name} />
</Text> ) : (
<Text as="span" size="H6" style={{ color: '#0c0c0e', fontWeight: 700 }}>
{initial}
</Text>
)}
</Avatar> </Avatar>
<Box <Box
as="span" as="span"

View file

@ -99,7 +99,10 @@ export const HeroBack = style([
// 56×56 square avatar with 14px radius, fleet violet (DAWN.fleet) bg. // 56×56 square avatar with 14px radius, fleet violet (DAWN.fleet) bg.
// Fleet color is hardcoded here because it's the canonical bot accent in // Fleet color is hardcoded here because it's the canonical bot accent in
// the mockup and we don't want it varying with Folds palette swaps. // the mockup and we don't want it varying with Folds palette swaps. The
// violet square shows when the bot's Matrix profile has no `avatar_url`
// (fallback to the initial letter); when it does, the inner <img> covers
// the violet — `overflow: hidden` keeps it inside the rounded corners.
export const HeroAvatar = style([ export const HeroAvatar = style([
DefaultReset, DefaultReset,
{ {
@ -114,6 +117,7 @@ export const HeroAvatar = style([
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
flexShrink: 0, flexShrink: 0,
overflow: 'hidden',
'@media': { '@media': {
'(max-width: 600px)': { '(max-width: 600px)': {
@ -126,6 +130,20 @@ export const HeroAvatar = style([
}, },
]); ]);
// Avatar image fills the violet square. `objectFit: cover` plus the
// container's `overflow: hidden` means non-square Matrix avatars (which
// arbitrarily sized) render correctly without showing the violet bg
// around the corners.
export const HeroAvatarImg = style([
DefaultReset,
{
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
},
]);
export const HeroBody = style([ export const HeroBody = style([
DefaultReset, DefaultReset,
{ {

View file

@ -8,6 +8,9 @@ import { BotShellMenu } from './BotShellMenu';
import { BackRouteHandler } from '../../components/BackRouteHandler'; import { BackRouteHandler } from '../../components/BackRouteHandler';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { stopPropagation } from '../../utils/keyboard'; 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'; import * as css from './BotShell.css';
type BotShellHeroProps = { type BotShellHeroProps = {
@ -28,10 +31,24 @@ const heroInitial = (preset: BotPreset): string => {
export function BotShellHero({ preset, room }: BotShellHeroProps) { export function BotShellHero({ preset, room }: BotShellHeroProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile; const isMobile = screenSize === ScreenSize.Mobile;
const [menuAnchor, setMenuAnchor] = useState<RectCords>(); 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) => { const handleOpenMenu: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuAnchor(evt.currentTarget.getBoundingClientRect()); setMenuAnchor(evt.currentTarget.getBoundingClientRect());
}; };
@ -66,7 +83,7 @@ export function BotShellHero({ preset, room }: BotShellHeroProps) {
</BackRouteHandler> </BackRouteHandler>
)} )}
<div className={css.HeroAvatar} aria-hidden="true"> <div className={css.HeroAvatar} aria-hidden="true">
{initial} {avatarUrl ? <img className={css.HeroAvatarImg} src={avatarUrl} alt="" /> : initial}
</div> </div>
<div className={css.HeroBody}> <div className={css.HeroBody}>
<div className={css.HeroTitleRow}> <div className={css.HeroTitleRow}>