2026-09-25 23:50:12 -04:00
|
|
|
|
import React, { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
|
|
import { Box, Button, Chip, Icon, Icons, Input, Text, Spinner, color } from 'folds';
|
2026-06-14 11:24:04 -04:00
|
|
|
|
import { Method } from 'matrix-js-sdk';
|
|
|
|
|
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
|
|
|
|
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
|
|
|
|
|
import { SettingTile } from '../../../components/setting-tile';
|
2026-09-25 23:50:12 -04:00
|
|
|
|
import {
|
|
|
|
|
|
ALL_DECORATIONS,
|
|
|
|
|
|
DECORATION_CATEGORIES,
|
|
|
|
|
|
decorationThumbUrl,
|
|
|
|
|
|
decorationUrl,
|
|
|
|
|
|
} from '../../lotus/avatarDecorations';
|
2026-06-14 11:24:04 -04:00
|
|
|
|
import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
|
|
|
|
|
|
|
|
|
|
|
|
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
2026-06-14 14:49:42 -04:00
|
|
|
|
const CELL_SIZE = 72;
|
2026-06-14 11:24:04 -04:00
|
|
|
|
|
|
|
|
|
|
function DecorationPreviewCell({
|
|
|
|
|
|
slug,
|
|
|
|
|
|
name,
|
|
|
|
|
|
selected,
|
|
|
|
|
|
onSelect,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
slug: string;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
selected: boolean;
|
|
|
|
|
|
onSelect: (slug: string) => void;
|
|
|
|
|
|
}) {
|
2026-09-25 23:50:12 -04:00
|
|
|
|
// 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;
|
2026-06-14 11:24:04 -04:00
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
title={name}
|
|
|
|
|
|
aria-label={name}
|
|
|
|
|
|
aria-pressed={selected}
|
|
|
|
|
|
onClick={() => onSelect(slug)}
|
2026-09-25 23:50:12 -04:00
|
|
|
|
onMouseEnter={() => setActive(true)}
|
|
|
|
|
|
onMouseLeave={() => setActive(false)}
|
|
|
|
|
|
onFocus={() => setActive(true)}
|
|
|
|
|
|
onBlur={() => setActive(false)}
|
2026-06-14 11:24:04 -04:00
|
|
|
|
style={{
|
|
|
|
|
|
position: 'relative',
|
2026-06-14 14:49:42 -04:00
|
|
|
|
width: CELL_SIZE,
|
|
|
|
|
|
height: CELL_SIZE,
|
2026-06-14 11:24:04 -04:00
|
|
|
|
flexShrink: 0,
|
2026-06-28 21:58:57 -04:00
|
|
|
|
border: `2px solid ${selected ? color.Primary.Main : 'transparent'}`,
|
2026-06-14 14:49:42 -04:00
|
|
|
|
borderRadius: '50%',
|
2026-06-28 21:58:57 -04:00
|
|
|
|
background: color.SurfaceVariant.Container,
|
2026-06-14 11:24:04 -04:00
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
|
padding: 0,
|
2026-06-28 21:58:57 -04:00
|
|
|
|
boxShadow: selected ? `0 0 0 1px ${color.Primary.Main}` : 'none',
|
2026-06-14 14:49:42 -04:00
|
|
|
|
overflow: 'hidden',
|
2026-06-14 11:24:04 -04:00
|
|
|
|
outline: 'none',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<img
|
2026-09-25 23:50:12 -04:00
|
|
|
|
src={animate ? decorationUrl(slug) : decorationThumbUrl(slug)}
|
2026-06-14 11:24:04 -04:00
|
|
|
|
alt={name}
|
2026-07-02 21:40:07 -04:00
|
|
|
|
loading="lazy"
|
2026-06-14 11:24:04 -04:00
|
|
|
|
decoding="async"
|
2026-09-25 23:50:12 -04:00
|
|
|
|
onError={() => setThumbFailed(true)}
|
2026-06-14 11:24:04 -04:00
|
|
|
|
style={{
|
|
|
|
|
|
position: 'absolute',
|
2026-06-14 14:49:42 -04:00
|
|
|
|
inset: 0,
|
|
|
|
|
|
width: '100%',
|
|
|
|
|
|
height: '100%',
|
2026-06-14 11:24:04 -04:00
|
|
|
|
objectFit: 'contain',
|
|
|
|
|
|
pointerEvents: 'none',
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-25 23:50:12 -04:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 11:24:04 -04:00
|
|
|
|
export function ProfileDecoration() {
|
|
|
|
|
|
const mx = useMatrixClient();
|
|
|
|
|
|
const userId = mx.getUserId()!;
|
|
|
|
|
|
|
|
|
|
|
|
const [current, setCurrent] = useState<string | null>(null);
|
|
|
|
|
|
const [selected, setSelected] = useState<string | null>(null);
|
2026-09-12 19:46:06 -04:00
|
|
|
|
// 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);
|
2026-09-25 23:50:12 -04:00
|
|
|
|
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],
|
|
|
|
|
|
);
|
2026-06-14 11:24:04 -04:00
|
|
|
|
|
2026-09-12 19:46:06 -04:00
|
|
|
|
const fetchDecoration = useCallback(() => {
|
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
setLoading(true);
|
2026-07-24 15:37:16 -04:00
|
|
|
|
// 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.
|
2026-06-14 11:24:04 -04:00
|
|
|
|
mx.http
|
2026-07-24 15:37:16 -04:00
|
|
|
|
.authedRequest<Record<string, string>>(Method.Get, `/profile/${encodeURIComponent(userId)}`)
|
2026-06-14 11:24:04 -04:00
|
|
|
|
.then((res) => {
|
2026-09-12 19:46:06 -04:00
|
|
|
|
if (cancelled) return;
|
|
|
|
|
|
setLoadError(false);
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
if (dirtyRef.current) return;
|
2026-06-14 11:24:04 -04:00
|
|
|
|
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
|
|
|
|
|
setCurrent(val);
|
|
|
|
|
|
setSelected(val);
|
2026-09-25 23:50:12 -04:00
|
|
|
|
setActiveCategory(categoryOf(val));
|
2026-06-14 11:24:04 -04:00
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {
|
2026-09-12 19:46:06 -04:00
|
|
|
|
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);
|
2026-06-14 11:24:04 -04:00
|
|
|
|
});
|
2026-09-12 19:46:06 -04:00
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true;
|
|
|
|
|
|
};
|
2026-06-14 11:24:04 -04:00
|
|
|
|
}, [mx, userId]);
|
|
|
|
|
|
|
2026-09-12 19:46:06 -04:00
|
|
|
|
useEffect(() => fetchDecoration(), [fetchDecoration]);
|
|
|
|
|
|
|
2026-06-14 11:24:04 -04:00
|
|
|
|
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) => {
|
2026-09-12 19:46:06 -04:00
|
|
|
|
dirtyRef.current = true;
|
2026-06-14 11:24:04 -04:00
|
|
|
|
setSelected((prev) => (prev === slug ? null : slug));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-12 19:46:06 -04:00
|
|
|
|
const handleClear = () => {
|
|
|
|
|
|
dirtyRef.current = true;
|
|
|
|
|
|
setSelected(null);
|
|
|
|
|
|
};
|
2026-06-14 11:24:04 -04:00
|
|
|
|
|
|
|
|
|
|
const handleSave = () => {
|
2026-09-12 19:46:06 -04:00
|
|
|
|
// 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;
|
2026-06-14 11:24:04 -04:00
|
|
|
|
save(selected);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-12 19:46:06 -04:00
|
|
|
|
const handleRetry = () => {
|
|
|
|
|
|
dirtyRef.current = false;
|
|
|
|
|
|
fetchDecoration();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-14 11:24:04 -04:00
|
|
|
|
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',
|
2026-06-14 14:49:42 -04:00
|
|
|
|
width: CELL_SIZE,
|
|
|
|
|
|
height: CELL_SIZE,
|
2026-06-14 11:24:04 -04:00
|
|
|
|
flexShrink: 0,
|
2026-06-14 14:49:42 -04:00
|
|
|
|
borderRadius: '50%',
|
2026-06-28 21:58:57 -04:00
|
|
|
|
background: color.SurfaceVariant.Container,
|
2026-06-14 14:49:42 -04:00
|
|
|
|
overflow: 'hidden',
|
2026-06-14 11:24:04 -04:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{selected && (
|
|
|
|
|
|
<img
|
|
|
|
|
|
src={decorationUrl(selected)}
|
|
|
|
|
|
alt="Selected decoration preview"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
position: 'absolute',
|
2026-06-14 14:49:42 -04:00
|
|
|
|
inset: 0,
|
|
|
|
|
|
width: '100%',
|
|
|
|
|
|
height: '100%',
|
2026-06-14 11:24:04 -04:00
|
|
|
|
objectFit: 'contain',
|
|
|
|
|
|
pointerEvents: 'none',
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Box grow="Yes" direction="Column" gap="100">
|
|
|
|
|
|
<Text size="T300">
|
2026-09-12 19:46:06 -04:00
|
|
|
|
{loadError
|
|
|
|
|
|
? 'Failed to load'
|
|
|
|
|
|
: selected
|
2026-09-25 23:50:12 -04:00
|
|
|
|
? (ALL_DECORATIONS.find((d) => d.slug === selected)?.name ?? selected)
|
2026-09-12 19:46:06 -04:00
|
|
|
|
: 'None'}
|
2026-06-14 11:24:04 -04:00
|
|
|
|
</Text>
|
2026-09-12 19:46:06 -04:00
|
|
|
|
{selected && !loadError && (
|
2026-06-18 22:46:19 -04:00
|
|
|
|
<Button
|
2026-06-14 11:24:04 -04:00
|
|
|
|
type="button"
|
2026-06-18 22:46:19 -04:00
|
|
|
|
size="300"
|
|
|
|
|
|
radii="300"
|
|
|
|
|
|
variant="Critical"
|
|
|
|
|
|
fill="None"
|
2026-06-14 11:24:04 -04:00
|
|
|
|
onClick={handleClear}
|
|
|
|
|
|
>
|
2026-06-18 22:46:19 -04:00
|
|
|
|
<Text size="B300">Remove</Text>
|
|
|
|
|
|
</Button>
|
2026-06-14 11:24:04 -04:00
|
|
|
|
)}
|
|
|
|
|
|
</Box>
|
2026-09-12 19:46:06 -04:00
|
|
|
|
{hasChanges && !loadError && (
|
2026-06-18 22:46:19 -04:00
|
|
|
|
<Button
|
2026-06-14 11:24:04 -04:00
|
|
|
|
type="button"
|
2026-06-18 22:46:19 -04:00
|
|
|
|
size="400"
|
|
|
|
|
|
radii="300"
|
|
|
|
|
|
variant="Success"
|
|
|
|
|
|
fill="Solid"
|
2026-06-14 11:24:04 -04:00
|
|
|
|
onClick={handleSave}
|
|
|
|
|
|
disabled={saving}
|
2026-06-18 22:46:19 -04:00
|
|
|
|
before={saving ? <Spinner size="100" variant="Success" /> : undefined}
|
2026-06-14 11:24:04 -04:00
|
|
|
|
>
|
2026-06-18 22:46:19 -04:00
|
|
|
|
<Text size="B300">{saving ? 'Saving…' : 'Save'}</Text>
|
|
|
|
|
|
</Button>
|
2026-06-14 11:24:04 -04:00
|
|
|
|
)}
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
|
2026-09-12 19:46:06 -04:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-06-14 11:24:04 -04:00
|
|
|
|
{saveState.status === AsyncStatus.Error && (
|
2026-06-18 22:46:19 -04:00
|
|
|
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
2026-06-14 11:24:04 -04:00
|
|
|
|
Failed to save. Try again.
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-09-25 23:50:12 -04:00
|
|
|
|
{/* 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>
|
|
|
|
|
|
)}
|
2026-06-14 15:32:19 -04:00
|
|
|
|
<div
|
2026-09-25 23:50:12 -04:00
|
|
|
|
role={query ? undefined : 'tabpanel'}
|
2026-06-14 11:24:04 -04:00
|
|
|
|
style={{
|
2026-09-25 23:50:12 -04:00
|
|
|
|
maxHeight: 360,
|
2026-06-14 11:24:04 -04:00
|
|
|
|
overflowY: 'auto',
|
2026-06-14 15:32:19 -04:00
|
|
|
|
overflowX: 'hidden',
|
2026-06-14 11:24:04 -04:00
|
|
|
|
paddingRight: 4,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-09-25 23:50:12 -04:00
|
|
|
|
{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}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
2026-06-14 15:32:19 -04:00
|
|
|
|
</div>
|
2026-09-25 23:50:12 -04:00
|
|
|
|
)}
|
2026-06-14 15:32:19 -04:00
|
|
|
|
</div>
|
2026-06-14 11:24:04 -04:00
|
|
|
|
</Box>
|
|
|
|
|
|
</SettingTile>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|