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
+25 -20
View File
@@ -69,6 +69,7 @@ import { useCrossSigningActive } from '../../hooks/useCrossSigning';
import { MemberVerificationBadge } from '../../components/MemberVerificationBadge';
import { useUserPresence } from '../../hooks/useUserPresence';
import { PresenceBadge, PresenceRingAvatar } from '../../components/presence';
import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration';
type MemberDrawerHeaderProps = {
room: Room;
@@ -150,16 +151,18 @@ function MemberItem({
radii="400"
onClick={onClick}
before={
<PresenceRingAvatar userId={member.userId}>
<Avatar size="200">
<UserAvatar
userId={member.userId}
src={avatarUrl ?? undefined}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
<AvatarDecoration userId={member.userId}>
<PresenceRingAvatar userId={member.userId}>
<Avatar size="200">
<UserAvatar
userId={member.userId}
src={avatarUrl ?? undefined}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
</AvatarDecoration>
}
after={
<>
@@ -442,16 +445,18 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
gap="200"
style={{ padding: `0 ${config.space.S200}` }}
>
<PresenceRingAvatar userId={knockMember.userId}>
<Avatar size="200">
<UserAvatar
userId={knockMember.userId}
src={knockAvatarUrl ?? undefined}
alt={knockName}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
<AvatarDecoration userId={knockMember.userId}>
<PresenceRingAvatar userId={knockMember.userId}>
<Avatar size="200">
<UserAvatar
userId={knockMember.userId}
src={knockAvatarUrl ?? undefined}
alt={knockName}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
</AvatarDecoration>
<Box grow="Yes" direction="Column">
<Text size="T400" truncate>
{knockName}