Files
cinny/src/app/features/settings/account/ProfileDecoration.tsx
T
Lotus CIandClaude Opus 5.5 449d0c740e feat(settings): split General into pages; collapse the big pickers (#459, #460)
#459 General held 76 of ~110 settings (11 screens). Its sections now live
on pages named for what people come to do:
  Account, Appearance, Messages (incl. editor, date and time),
  Notifications, Calls & audio, Privacy & security (privacy options +
  the former Devices page), Data & storage (storage, sync, your data),
  Desktop (desktop app only), Emojis & stickers, Advanced (the former
  Developer Tools + "show hidden events" / "legacy username colour"),
  About.
The longest page is now ~3 screens (Appearance). Settings opens on
Account. Deep links keep working: "devices" lands on Privacy & security,
the old "general" on Appearance, the update notice on Desktop. Text that
pointed at the old page names is updated. The push-rule editor gets one
line explaining the Matrix terms. Remaining Title Case labels on Account
and Devices are sentence case.

#460 Chat background, seasonal theme and avatar decoration grids sit
behind one row: the current choice and a Change button (the first two
close after a choice; decorations keep their Save step).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-25 11:56:24 -04:00

418 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Avatar, Box, Button, Chip, Icon, Icons, Input, Text, Spinner, color } from 'folds';
import { Method } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useUserProfile } from '../../../hooks/useUserProfile';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { nameInitials } from '../../../utils/common';
import { UserAvatar } from '../../../components/user-avatar';
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;
// Matches AvatarDecoration's default inset, so the preview looks like the timeline.
const PREVIEW_INSET = 8;
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 (
<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)}
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={animate ? decorationUrl(slug) : decorationThumbUrl(slug)}
alt={name}
loading="lazy"
decoding="async"
onError={() => setThumbFailed(true)}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
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;
}
export function ProfileDecoration() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const useAuthentication = useMediaAuthentication();
const profile = useUserProfile(userId);
const myName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const myAvatarUrl = profile.avatarUrl
? (mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined;
const [current, setCurrent] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
// [Gitea #460] The catalog stays closed until "Change"; it was always open.
const [browsing, setBrowsing] = useState(false);
// 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<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);
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 (
<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">
{/* [Gitea #272] Your own avatar with the decoration overlaid, as the
timeline draws it (AvatarDecoration: the image is the avatar plus
an 8px margin on every side). With no decoration the circle gets
a visible dashed outline instead of vanishing into the card. */}
<div
style={{
position: 'relative',
width: CELL_SIZE,
height: CELL_SIZE,
flexShrink: 0,
borderRadius: '50%',
boxSizing: 'border-box',
border: selected
? 'none'
: `2px dashed color-mix(in srgb, ${color.SurfaceVariant.OnContainer} 45%, transparent)`,
}}
>
<Avatar
size="500"
radii="Pill"
style={{
position: 'absolute',
inset: PREVIEW_INSET,
width: CELL_SIZE - PREVIEW_INSET * 2,
height: CELL_SIZE - PREVIEW_INSET * 2,
}}
>
<UserAvatar
userId={userId}
src={myAvatarUrl}
alt=""
renderFallback={() => <Text size="H5">{nameInitials(myName)}</Text>}
/>
</Avatar>
{selected && (
<img
src={decorationUrl(selected)}
alt="Selected decoration preview"
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'contain',
pointerEvents: 'none',
zIndex: 1,
}}
/>
)}
</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'}
</Text>
{selected && !loadError && (
<Button
type="button"
size="300"
radii="300"
variant="Critical"
fill="None"
onClick={handleClear}
>
<Text size="B300">Remove</Text>
</Button>
)}
</Box>
{!loadError && (
<Button
type="button"
size="300"
radii="300"
variant="Secondary"
fill="Soft"
outlined
aria-expanded={browsing}
onClick={() => setBrowsing((b) => !b)}
>
<Text size="B300">{browsing ? 'Done' : 'Change'}</Text>
</Button>
)}
{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>
)}
{browsing && (
<>
{/* 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: 360,
overflowY: 'auto',
overflowX: 'hidden',
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>
</>
)}
</Box>
</SettingTile>
);
}