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 ( ); } 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(null); const [selected, setSelected] = useState(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>(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 ( Avatar Decoration } description={ Shown on your avatar to all Lotus Chat users. } > {/* Current selection preview */}
{selected && ( Selected decoration preview )}
{loadError ? 'Failed to load' : selected ? (ALL_DECORATIONS.find((d) => d.slug === selected)?.name ?? selected) : 'None'} {selected && !loadError && ( )} {hasChanges && !loadError && ( )}
{loadError && ( Could not load your current decoration. Saving is disabled until this succeeds, so you don’t overwrite it based on a wrong display. )} {saveState.status === AsyncStatus.Error && ( Failed to save. Try again. )} {/* Search + category tabs. Only one category (or the search results) is mounted at a time so the picker never loads the whole catalog. */} ) => setSearch(e.target.value)} variant="Secondary" size="400" radii="300" before={} /> {!query && ( {DECORATION_CATEGORIES.map((category) => ( setActiveCategory(category.id)} > {category.label} ))} )}
{visible.length === 0 ? ( No decorations match “{search.trim()}”. ) : (
{visible.map((d) => ( ))}
)}
); }