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
@@ -48,6 +48,7 @@ import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { useCapabilities } from '../../../hooks/useCapabilities';
import { useUserPresence } from '../../../hooks/useUserPresence';
import { ProfileDecoration } from './ProfileDecoration';
import { EmojiBoard } from '../../../components/emoji-board';
type ProfileProps = {
@@ -892,6 +893,7 @@ export function Profile() {
<ProfileStatus />
<ProfilePronouns />
<ProfileTimezone />
<ProfileDecoration />
</SequenceCard>
</Box>
);
@@ -0,0 +1,275 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Box, Text, Spinner } from 'folds';
import { Method } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { SettingTile } from '../../../components/setting-tile';
import {
DECORATION_CATEGORIES,
DECORATION_CDN,
decorationUrl,
} from '../../lotus/avatarDecorations';
import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
const INSET = 8;
function DecorationPreviewCell({
slug,
name,
selected,
onSelect,
}: {
slug: string;
name: string;
selected: boolean;
onSelect: (slug: string) => void;
}) {
return (
<button
type="button"
title={name}
aria-label={name}
aria-pressed={selected}
onClick={() => onSelect(slug)}
style={{
position: 'relative',
width: 52,
height: 52,
flexShrink: 0,
border: `2px solid ${selected ? 'var(--accent-cyan)' : 'transparent'}`,
borderRadius: '0.75rem',
background: 'var(--bg-surface-variant)',
cursor: 'pointer',
padding: 0,
boxShadow: selected ? '0 0 0 1px var(--accent-cyan)' : 'none',
overflow: 'visible',
outline: 'none',
}}
>
{/* Avatar placeholder tint */}
<div
style={{
position: 'absolute',
inset: 0,
borderRadius: '0.75rem',
background: 'var(--bg-surface-variant)',
overflow: 'hidden',
}}
/>
<img
src={`${DECORATION_CDN}/${slug}.png`}
alt={name}
loading="lazy"
decoding="async"
style={{
position: 'absolute',
top: -INSET,
left: -INSET,
width: `calc(100% + ${INSET * 2}px)`,
height: `calc(100% + ${INSET * 2}px)`,
objectFit: 'contain',
pointerEvents: 'none',
}}
/>
</button>
);
}
export function ProfileDecoration() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const [current, setCurrent] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
useEffect(() => {
mx.http
.authedRequest<Record<string, string>>(
Method.Get,
`/profile/${encodeURIComponent(userId)}/${PROFILE_FIELD}`,
)
.then((res) => {
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
setCurrent(val);
setSelected(val);
})
.catch(() => {
setCurrent(null);
setSelected(null);
});
}, [mx, userId]);
const [saveState, save] = useAsyncCallback(
useCallback(
async (slug: string | null) => {
await mx.http.authedRequest(
Method.Put,
`/profile/${encodeURIComponent(userId)}/${PROFILE_FIELD}`,
undefined,
{ [PROFILE_FIELD]: slug ?? '' },
);
setCurrent(slug);
invalidateDecorationCache(userId);
},
[mx, userId],
),
);
const saving = saveState.status === AsyncStatus.Loading;
const hasChanges = selected !== current;
const handleSelect = (slug: string) => {
setSelected((prev) => (prev === slug ? null : slug));
};
const handleClear = () => setSelected(null);
const handleSave = () => {
if (!hasChanges || saving) return;
save(selected);
};
return (
<SettingTile
title={
<Text as="span" size="L400">
Avatar Decoration
</Text>
}
description={
<Text size="T200" priority="300">
Shown on your avatar to all Lotus Chat users.
</Text>
}
>
<Box direction="Column" gap="300">
{/* Current selection preview */}
<Box alignItems="Center" gap="300">
<div
style={{
position: 'relative',
width: 52,
height: 52,
flexShrink: 0,
borderRadius: '0.75rem',
background: 'var(--bg-surface-variant)',
overflow: 'visible',
}}
>
{selected && (
<img
src={decorationUrl(selected)}
alt="Selected decoration preview"
style={{
position: 'absolute',
top: -INSET,
left: -INSET,
width: `calc(100% + ${INSET * 2}px)`,
height: `calc(100% + ${INSET * 2}px)`,
objectFit: 'contain',
pointerEvents: 'none',
}}
/>
)}
</div>
<Box grow="Yes" direction="Column" gap="100">
<Text size="T300">
{selected
? (DECORATION_CATEGORIES.flatMap((c) => c.decorations).find(
(d) => d.slug === selected,
)?.name ?? selected)
: 'None'}
</Text>
{selected && (
<button
type="button"
onClick={handleClear}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
color: 'var(--tc-surface-low-contrast)',
fontSize: '0.8rem',
textAlign: 'left',
}}
>
Remove
</button>
)}
</Box>
{hasChanges && (
<button
type="button"
onClick={handleSave}
disabled={saving}
style={{
padding: '6px 14px',
borderRadius: 6,
border: '1px solid var(--accent-cyan)',
background: 'transparent',
color: 'var(--accent-cyan)',
cursor: saving ? 'not-allowed' : 'pointer',
fontSize: '0.85rem',
display: 'flex',
alignItems: 'center',
gap: 6,
opacity: saving ? 0.6 : 1,
flexShrink: 0,
}}
>
{saving && <Spinner size="100" variant="Secondary" />}
{saving ? 'Saving…' : 'Save'}
</button>
)}
</Box>
{saveState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: 'var(--tc-critical-normal)' }}>
Failed to save. Try again.
</Text>
)}
{/* Category grid */}
<Box
direction="Column"
gap="300"
style={{
maxHeight: 420,
overflowY: 'auto',
paddingRight: 4,
}}
>
{DECORATION_CATEGORIES.map((category) => (
<Box key={category.id} direction="Column" gap="200">
<Text size="L400" style={{ opacity: 0.7 }}>
{category.label}
</Text>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 20,
paddingBottom: 4,
paddingLeft: INSET,
paddingTop: INSET,
}}
>
{category.decorations.map((d) => (
<DecorationPreviewCell
key={d.slug}
slug={d.slug}
name={d.name}
selected={selected === d.slug}
onSelect={handleSelect}
/>
))}
</div>
</Box>
))}
</Box>
</Box>
</SettingTile>
);
}