feat: avatar decorations (P5-13)
CI / Build & Quality Checks (push) Successful in 10m30s
Trigger Desktop Build / trigger (push) Successful in 19s

256×256 APNG overlays from avatardecoration.com (open CORS CDN).
Stored in the user's Matrix profile as io.lotus.avatar_decoration via
MSC4133 so all Lotus Chat users see each other's decorations.

- avatarDecorations.ts: curated catalog of 110 original-IP decorations
  across 9 categories (Gaming, Cyber, Space, Fantasy, Elements,
  Japanese, Nature, Spooky, Cozy)
- useAvatarDecoration: per-user profile fetch with module-level cache
  and in-flight deduplication so concurrent renders for the same userId
  share one HTTP request
- AvatarDecoration: position:relative wrapper that overlays the APNG
  8px beyond the avatar on all sides; renders nothing when no decoration
  is set (zero cost for undecorated users)
- ProfileDecoration: scrollable grid picker in Settings → Profile,
  grouped by category with live preview; Save button appears only when
  the selection differs from what's saved
- Applied at all five avatar display sites: message timeline, members
  drawer, knock list, @mention autocomplete, notifications inbox

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 11:24:04 -04:00
parent ca09e8e6ca
commit bf1308dd55
9 changed files with 671 additions and 80 deletions
@@ -0,0 +1,52 @@
import React from 'react';
import { useAvatarDecoration } from '../../hooks/useAvatarDecoration';
import { decorationUrl } from '../../features/lotus/avatarDecorations';
// How far the decoration image extends beyond the avatar on each side (px).
// The APNG files are 256×256 with a transparent center. At this extension
// the center hole lines up naturally with the avatar beneath it.
const INSET = 8;
type AvatarDecorationProps = {
userId: string;
children: React.ReactNode;
};
export function AvatarDecoration({ userId, children }: AvatarDecorationProps) {
const slug = useAvatarDecoration(userId);
if (!slug) {
return <>{children}</>;
}
return (
<div
style={{
position: 'relative',
display: 'inline-flex',
flexShrink: 0,
}}
>
{children}
<img
src={decorationUrl(slug)}
style={{
position: 'absolute',
top: -INSET,
left: -INSET,
right: -INSET,
bottom: -INSET,
width: `calc(100% + ${INSET * 2}px)`,
height: `calc(100% + ${INSET * 2}px)`,
pointerEvents: 'none',
zIndex: 10,
objectFit: 'contain',
}}
alt=""
aria-hidden="true"
loading="lazy"
decoding="async"
/>
</div>
);
}
@@ -21,6 +21,7 @@ import { UserAvatar } from '../../user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { Membership } from '../../../../types/matrix/room';
import { PresenceRingAvatar } from '../../presence';
import { AvatarDecoration } from '../../avatar-decoration/AvatarDecoration';
type MentionAutoCompleteHandler = (userId: string, name: string) => void;
@@ -48,14 +49,16 @@ function UnknownMentionItem({
}
onClick={() => handleAutocomplete(userId, name)}
before={
<PresenceRingAvatar userId={userId}>
<Avatar size="200">
<UserAvatar
userId={userId}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
<AvatarDecoration userId={userId}>
<PresenceRingAvatar userId={userId}>
<Avatar size="200">
<UserAvatar
userId={userId}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
</AvatarDecoration>
}
>
<Text style={{ flexGrow: 1 }} size="B400">
@@ -177,16 +180,18 @@ export function UserMentionAutocomplete({
</Text>
}
before={
<PresenceRingAvatar userId={roomMember.userId}>
<Avatar size="200">
<UserAvatar
userId={roomMember.userId}
src={avatarUrl ?? undefined}
alt={getName(roomMember)}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
<AvatarDecoration userId={roomMember.userId}>
<PresenceRingAvatar userId={roomMember.userId}>
<Avatar size="200">
<UserAvatar
userId={roomMember.userId}
src={avatarUrl ?? undefined}
alt={getName(roomMember)}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
</AvatarDecoration>
}
>
<Text style={{ flexGrow: 1 }} size="B400" truncate>