Files
cinny/src/app/features/settings/account/ProfileDecoration.tsx
T
Lotus CIandClaude Opus 5.5 78b1a2ff6f
CI / Build & Quality Checks (push) Successful in 3m34s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 15s
CI / Playwright smoke (e2e) (push) Successful in 10m56s
feat(decorations): 530 new avatar decorations, thumbnail-first picker
Adds 530 decorations in 19 new categories (631 total). Decorations that
contain Discord branding (the Clyde-visor helmets) are excluded.

The decorations are ~1 MB animated PNGs, so a picker that rendered every
one would pull ~590 MB while scrolling. The picker now:
- shows static 144px WebP thumbnails (~9.5 KB each, `thumbs/` on the CDN)
  and loads the animated file only on hover, focus or selection, with a
  fallback to the full file if a thumbnail is missing;
- mounts one category at a time behind tabs, plus a name search across
  all categories.

scripts/makeDecorationThumbs.py builds the thumbnails from the busiest
frame of each animation (many start on an empty frame).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 23:50:12 -04:00

361 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box, Button, Chip, Icon, Icons, Input, Text, Spinner, color } 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 {
ALL_DECORATIONS,
DECORATION_CATEGORIES,
decorationThumbUrl,
decorationUrl,
} from '../../lotus/avatarDecorations';
import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
const CELL_SIZE = 72;
function DecorationPreviewCell({
slug,
name,
selected,
onSelect,
}: {
slug: string;
name: string;
selected: boolean;
onSelect: (slug: string) => void;
}) {
// The full decorations are ~1 MB animated PNGs. Show the small static
// thumbnail and only pull the animated file while the cell is hovered,
// focused or selected. A missing thumbnail falls back to the full file.
const [active, setActive] = useState(false);
const [thumbFailed, setThumbFailed] = useState(false);
const animate = active || selected || thumbFailed;
return (
<button
type="button"
title={name}
aria-label={name}
aria-pressed={selected}
onClick={() => onSelect(slug)}
onMouseEnter={() => setActive(true)}
onMouseLeave={() => setActive(false)}
onFocus={() => setActive(true)}
onBlur={() => setActive(false)}
style={{
position: 'relative',
width: CELL_SIZE,
height: CELL_SIZE,
flexShrink: 0,
border: `2px solid ${selected ? color.Primary.Main : 'transparent'}`,
borderRadius: '50%',
background: color.SurfaceVariant.Container,
cursor: 'pointer',
padding: 0,
boxShadow: selected ? `0 0 0 1px ${color.Primary.Main}` : 'none',
overflow: 'hidden',
outline: 'none',
}}
>
<img
src={animate ? decorationUrl(slug) : decorationThumbUrl(slug)}
alt={name}
loading="lazy"
decoding="async"
onError={() => setThumbFailed(true)}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'contain',
pointerEvents: 'none',
}}
/>
</button>
);
}
function categoryOf(slug: string | null): string {
const found =
slug && DECORATION_CATEGORIES.find((c) => c.decorations.some((d) => d.slug === slug));
return found ? found.id : DECORATION_CATEGORIES[0].id;
}
export function ProfileDecoration() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const [current, setCurrent] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
// Distinguish "confirmed no decoration" from "failed to load": a fetch
// failure must not be shown as (and saved over) "None".
const [loadError, setLoadError] = useState(false);
const [loading, setLoading] = useState(true);
// True once the user has picked/cleared a decoration — guards against the
// mount-time fetch below clobbering a fresh selection if it resolves late
// (mirrors ProfileStatus's statusDirtyRef in Profile.tsx).
const dirtyRef = useRef(false);
const [activeCategory, setActiveCategory] = useState(DECORATION_CATEGORIES[0].id);
const [search, setSearch] = useState('');
const query = search.trim().toLowerCase();
const visible = useMemo(
() =>
query
? ALL_DECORATIONS.filter((d) => d.name.toLowerCase().includes(query))
: (DECORATION_CATEGORIES.find((c) => c.id === activeCategory)?.decorations ?? []),
[query, activeCategory],
);
const fetchDecoration = useCallback(() => {
let cancelled = false;
setLoading(true);
// Fetch the whole profile, not the `/{field}` sub-resource: an unset field
// 404s (a console error for anyone without a decoration). The full profile
// returns 200 with all fields incl. custom MSC4133 ones — read it out.
mx.http
.authedRequest<Record<string, string>>(Method.Get, `/profile/${encodeURIComponent(userId)}`)
.then((res) => {
if (cancelled) return;
setLoadError(false);
setLoading(false);
if (dirtyRef.current) return;
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
setCurrent(val);
setSelected(val);
setActiveCategory(categoryOf(val));
})
.catch(() => {
if (cancelled) return;
setLoading(false);
// Do NOT touch current/selected here — a network failure is not proof
// there's no decoration, and defaulting to null risks the user saving
// "None" over a real, still-set decoration (see #46).
setLoadError(true);
});
return () => {
cancelled = true;
};
}, [mx, userId]);
useEffect(() => fetchDecoration(), [fetchDecoration]);
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) => {
dirtyRef.current = true;
setSelected((prev) => (prev === slug ? null : slug));
};
const handleClear = () => {
dirtyRef.current = true;
setSelected(null);
};
const handleSave = () => {
// Refuse to save while the initial load failed: `current`/`selected` are
// not known-good, so saving could silently overwrite a real decoration.
if (!hasChanges || saving || loadError) return;
save(selected);
};
const handleRetry = () => {
dirtyRef.current = false;
fetchDecoration();
};
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: CELL_SIZE,
height: CELL_SIZE,
flexShrink: 0,
borderRadius: '50%',
background: color.SurfaceVariant.Container,
overflow: 'hidden',
}}
>
{selected && (
<img
src={decorationUrl(selected)}
alt="Selected decoration preview"
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'contain',
pointerEvents: 'none',
}}
/>
)}
</div>
<Box grow="Yes" direction="Column" gap="100">
<Text size="T300">
{loadError
? 'Failed to load'
: selected
? (ALL_DECORATIONS.find((d) => d.slug === selected)?.name ?? selected)
: 'None'}
</Text>
{selected && !loadError && (
<Button
type="button"
size="300"
radii="300"
variant="Critical"
fill="None"
onClick={handleClear}
>
<Text size="B300">Remove</Text>
</Button>
)}
</Box>
{hasChanges && !loadError && (
<Button
type="button"
size="400"
radii="300"
variant="Success"
fill="Solid"
onClick={handleSave}
disabled={saving}
before={saving ? <Spinner size="100" variant="Success" /> : undefined}
>
<Text size="B300">{saving ? 'Saving…' : 'Save'}</Text>
</Button>
)}
</Box>
{loadError && (
<Box alignItems="Center" gap="200">
<Text size="T200" style={{ color: color.Critical.Main }}>
Could not load your current decoration. Saving is disabled until this succeeds, so you
don’t overwrite it based on a wrong display.
</Text>
<Button
type="button"
size="300"
radii="300"
variant="Secondary"
fill="Soft"
onClick={handleRetry}
disabled={loading}
>
<Text size="B300">{loading ? 'Retrying…' : 'Retry'}</Text>
</Button>
</Box>
)}
{saveState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
Failed to save. Try again.
</Text>
)}
{/* Search + category tabs. Only one category (or the search results)
is mounted at a time so the picker never loads the whole catalog. */}
<Input
aria-label="Search decorations"
placeholder="Search decorations"
value={search}
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
variant="Secondary"
size="400"
radii="300"
before={<Icon size="100" src={Icons.Search} />}
/>
{!query && (
<Box gap="100" wrap="Wrap" role="tablist" aria-label="Decoration categories">
{DECORATION_CATEGORIES.map((category) => (
<Chip
key={category.id}
type="button"
role="tab"
aria-selected={category.id === activeCategory}
variant={category.id === activeCategory ? 'Primary' : 'Secondary'}
fill="Soft"
outlined={category.id === activeCategory}
radii="Pill"
onClick={() => setActiveCategory(category.id)}
>
<Text as="span" size="B300">
{category.label}
</Text>
</Chip>
))}
</Box>
)}
<div
role={query ? undefined : 'tabpanel'}
style={{
maxHeight: 360,
overflowY: 'auto',
overflowX: 'hidden',
paddingRight: 4,
}}
>
{visible.length === 0 ? (
<Text size="T300" priority="300">
No decorations match “{search.trim()}”.
</Text>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(auto-fill, ${CELL_SIZE}px)`,
gap: 20,
padding: 2,
}}
>
{visible.map((d) => (
<DecorationPreviewCell
key={d.slug}
slug={d.slug}
name={d.name}
selected={selected === d.slug}
onSelect={handleSelect}
/>
))}
</div>
)}
</div>
</Box>
</SettingTile>
);
}