Pronouns, timezone and avatar decoration applied the mount-time fetch result unconditionally, overwriting a value the user had already edited; the decoration panel also showed "None" on any fetch failure and let the user save over a real decoration. Add cancelled/dirty guards (mirroring ProfileStatus) and an explicit load-error state with Retry. Fixes #46 Fixes #47 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
300 lines
9.1 KiB
TypeScript
300 lines
9.1 KiB
TypeScript
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||
import { Box, Button, 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 { 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;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
title={name}
|
||
aria-label={name}
|
||
aria-pressed={selected}
|
||
onClick={() => onSelect(slug)}
|
||
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={decorationUrl(slug)}
|
||
alt={name}
|
||
loading="lazy"
|
||
decoding="async"
|
||
style={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
width: '100%',
|
||
height: '100%',
|
||
objectFit: 'contain',
|
||
pointerEvents: 'none',
|
||
}}
|
||
/>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
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 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);
|
||
})
|
||
.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
|
||
? (DECORATION_CATEGORIES.flatMap((c) => c.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>
|
||
)}
|
||
|
||
{/* Category grid */}
|
||
<div
|
||
style={{
|
||
maxHeight: 480,
|
||
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>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Box>
|
||
</SettingTile>
|
||
);
|
||
}
|