feat(decorations): 530 new avatar decorations, thumbnail-first picker
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
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
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
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4323babfb9
commit
78b1a2ff6f
@@ -1,10 +1,15 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Box, Button, Text, Spinner, color } from 'folds';
|
||||
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 { DECORATION_CATEGORIES, decorationUrl } from '../../lotus/avatarDecorations';
|
||||
import {
|
||||
ALL_DECORATIONS,
|
||||
DECORATION_CATEGORIES,
|
||||
decorationThumbUrl,
|
||||
decorationUrl,
|
||||
} from '../../lotus/avatarDecorations';
|
||||
import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
|
||||
|
||||
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
||||
@@ -21,6 +26,12 @@ function DecorationPreviewCell({
|
||||
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"
|
||||
@@ -28,6 +39,10 @@ function DecorationPreviewCell({
|
||||
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,
|
||||
@@ -44,10 +59,11 @@ function DecorationPreviewCell({
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={decorationUrl(slug)}
|
||||
src={animate ? decorationUrl(slug) : decorationThumbUrl(slug)}
|
||||
alt={name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => setThumbFailed(true)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
@@ -61,6 +77,12 @@ function DecorationPreviewCell({
|
||||
);
|
||||
}
|
||||
|
||||
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()!;
|
||||
@@ -75,6 +97,16 @@ export function ProfileDecoration() {
|
||||
// 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;
|
||||
@@ -92,6 +124,7 @@ export function ProfileDecoration() {
|
||||
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
||||
setCurrent(val);
|
||||
setSelected(val);
|
||||
setActiveCategory(categoryOf(val));
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
@@ -196,9 +229,7 @@ export function ProfileDecoration() {
|
||||
{loadError
|
||||
? 'Failed to load'
|
||||
: selected
|
||||
? (DECORATION_CATEGORIES.flatMap((c) => c.decorations).find(
|
||||
(d) => d.slug === selected,
|
||||
)?.name ?? selected)
|
||||
? (ALL_DECORATIONS.find((d) => d.slug === selected)?.name ?? selected)
|
||||
: 'None'}
|
||||
</Text>
|
||||
{selected && !loadError && (
|
||||
@@ -256,42 +287,72 @@ export function ProfileDecoration() {
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Category grid */}
|
||||
{/* 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: 480,
|
||||
maxHeight: 360,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
paddingRight: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 24,
|
||||
}}
|
||||
>
|
||||
{DECORATION_CATEGORIES.map((category) => (
|
||||
<div key={category.id} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<Text size="L400" priority="400">
|
||||
{category.label}
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(auto-fill, ${CELL_SIZE}px)`,
|
||||
gap: 20,
|
||||
}}
|
||||
>
|
||||
{category.decorations.map((d) => (
|
||||
<DecorationPreviewCell
|
||||
key={d.slug}
|
||||
slug={d.slug}
|
||||
name={d.name}
|
||||
selected={selected === d.slug}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user