Files
cinny/src/app/features/settings/account/ProfileDecoration.tsx
T

361 lines
11 KiB
TypeScript
Raw Normal View History

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';
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';
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;
}) {
// 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)}
onMouseEnter={() => setActive(true)}
onMouseLeave={() => setActive(false)}
onFocus={() => setActive(true)}
onBlur={() => setActive(false)}
2026-06-14 11:24:04 -04:00
style={{
position: 'relative',
width: CELL_SIZE,
height: CELL_SIZE,
2026-06-14 11:24:04 -04:00
flexShrink: 0,
border: `2px solid ${selected ? color.Primary.Main : 'transparent'}`,
borderRadius: '50%',
background: color.SurfaceVariant.Container,
2026-06-14 11:24:04 -04:00
cursor: 'pointer',
padding: 0,
boxShadow: selected ? `0 0 0 1px ${color.Primary.Main}` : 'none',
overflow: 'hidden',
2026-06-14 11:24:04 -04:00
outline: 'none',
}}
>
<img
src={animate ? decorationUrl(slug) : decorationThumbUrl(slug)}
2026-06-14 11:24:04 -04:00
alt={name}
loading="lazy"
2026-06-14 11:24:04 -04:00
decoding="async"
onError={() => setThumbFailed(true)}
2026-06-14 11:24:04 -04:00
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
2026-06-14 11:24:04 -04:00
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;
}
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);
// 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],
);
2026-06-14 11:24:04 -04:00
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.
2026-06-14 11:24:04 -04:00
mx.http
.authedRequest<Record<string, string>>(Method.Get, `/profile/${encodeURIComponent(userId)}`)
2026-06-14 11:24:04 -04:00
.then((res) => {
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);
setActiveCategory(categoryOf(val));
2026-06-14 11:24:04 -04:00
})
.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);
2026-06-14 11:24:04 -04:00
});
return () => {
cancelled = true;
};
2026-06-14 11:24:04 -04:00
}, [mx, userId]);
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) => {
dirtyRef.current = true;
2026-06-14 11:24:04 -04:00
setSelected((prev) => (prev === slug ? null : slug));
};
const handleClear = () => {
dirtyRef.current = true;
setSelected(null);
};
2026-06-14 11:24:04 -04:00
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;
2026-06-14 11:24:04 -04:00
save(selected);
};
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',
width: CELL_SIZE,
height: CELL_SIZE,
2026-06-14 11:24:04 -04:00
flexShrink: 0,
borderRadius: '50%',
background: color.SurfaceVariant.Container,
overflow: 'hidden',
2026-06-14 11:24:04 -04:00
}}
>
{selected && (
<img
src={decorationUrl(selected)}
alt="Selected decoration preview"
style={{
position: 'absolute',
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">
{loadError
? 'Failed to load'
: selected
? (ALL_DECORATIONS.find((d) => d.slug === selected)?.name ?? selected)
: 'None'}
2026-06-14 11:24:04 -04:00
</Text>
{selected && !loadError && (
<Button
2026-06-14 11:24:04 -04:00
type="button"
size="300"
radii="300"
variant="Critical"
fill="None"
2026-06-14 11:24:04 -04:00
onClick={handleClear}
>
<Text size="B300">Remove</Text>
</Button>
2026-06-14 11:24:04 -04:00
)}
</Box>
{hasChanges && !loadError && (
<Button
2026-06-14 11:24:04 -04:00
type="button"
size="400"
radii="300"
variant="Success"
fill="Solid"
2026-06-14 11:24:04 -04:00
onClick={handleSave}
disabled={saving}
before={saving ? <Spinner size="100" variant="Success" /> : undefined}
2026-06-14 11:24:04 -04:00
>
<Text size="B300">{saving ? 'Saving…' : 'Save'}</Text>
</Button>
2026-06-14 11:24:04 -04:00
)}
</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>
)}
2026-06-14 11:24:04 -04:00
{saveState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
2026-06-14 11:24:04 -04:00
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'}
2026-06-14 11:24:04 -04:00
style={{
maxHeight: 360,
2026-06-14 11:24:04 -04:00
overflowY: 'auto',
overflowX: 'hidden',
2026-06-14 11:24:04 -04:00
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>
2026-06-14 11:24:04 -04:00
</Box>
</SettingTile>
);
}