8dc4c4d072
Fixes N1–N94 findings from LOTUS_BUGS.md audit pass. Key changes: - ProfileDecoration: raw <button> → folds <Button> for save/remove; remove undefined --accent-cyan var - UserRoomProfile: textarea border uses color.SurfaceVariant.ContainerLine and config tokens instead of undefined --border-interactive var - LotusToastContainer: z-index raised from 9997 → 10001 so toasts appear above Night Light overlay (9998) and modals (9999) - Message.tsx: DeliveryStatus replaces Unicode glyphs with Icon components; MessageQuickReactions returns null instead of <span />; forward menu item gets correct size="100" on after icon - AudioContent: speed chip variant/radii now matches Play chip (Secondary/300) - ReadReceiptAvatars: pill border/radius/padding → folds config tokens; remove dead receipt-pill-btn className - EventReaders: Header size 600→500; close button gets radii="300"; borderBottom shorthand → borderBottomWidth token; remove raw fontSize - General.tsx: selected background/seasonal picker border uses color.Primary.Main instead of color.Critical.Main (error red) - RoomInsights: SectionHeader drops textTransform/letterSpacing/opacity; chart borderRadius → config tokens; remove raw fontSize:9; warning banner → SequenceCard - RoomProfile.tsx: formatting toolbar raw <button> → folds <Button>; topic read-mode renders formatted_body via sanitizeCustomHtml - MsgTypeRenderers: location Open button Chip→Button; opacity:0.65→priority - UploadCardRenderer: caption raw <input> → folds <Input> - VoiceMessageRecorder: replace undefined --bg-surface-variant/--tc-* vars with color.* tokens; replace bare <audio controls> with IconButton play/pause toggle - App.tsx: mention highlight uses WCAG 2.1 relative luminance (gamma linearization) instead of simplified approximation; border now rgba semi-transparent instead of same color as background - RoomNavItem: Mute MenuItem icon moved to before prop - SearchFilters: HasLink chip variant="Success" outlined to match filter bar - RoomViewHeader: Server Notice chip radii Pill→300; fix jotai import order - Fix ESLint import/order errors in DeviceVerificationSetup, RoomTopicViewer, MediaGallery, and RoomViewHeader Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
248 lines
6.8 KiB
TypeScript
248 lines
6.8 KiB
TypeScript
import React, { useCallback, useEffect, 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,
|
|
DECORATION_CDN,
|
|
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 ? 'var(--accent-cyan)' : 'transparent'}`,
|
|
borderRadius: '50%',
|
|
background: 'var(--bg-surface-variant)',
|
|
cursor: 'pointer',
|
|
padding: 0,
|
|
boxShadow: selected ? '0 0 0 1px var(--accent-cyan)' : 'none',
|
|
overflow: 'hidden',
|
|
outline: 'none',
|
|
}}
|
|
>
|
|
<img
|
|
src={`${DECORATION_CDN}/${slug}.png`}
|
|
alt={name}
|
|
loading="eager"
|
|
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);
|
|
|
|
useEffect(() => {
|
|
mx.http
|
|
.authedRequest<Record<string, string>>(
|
|
Method.Get,
|
|
`/profile/${encodeURIComponent(userId)}/${PROFILE_FIELD}`,
|
|
)
|
|
.then((res) => {
|
|
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
|
setCurrent(val);
|
|
setSelected(val);
|
|
})
|
|
.catch(() => {
|
|
setCurrent(null);
|
|
setSelected(null);
|
|
});
|
|
}, [mx, userId]);
|
|
|
|
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) => {
|
|
setSelected((prev) => (prev === slug ? null : slug));
|
|
};
|
|
|
|
const handleClear = () => setSelected(null);
|
|
|
|
const handleSave = () => {
|
|
if (!hasChanges || saving) return;
|
|
save(selected);
|
|
};
|
|
|
|
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: 'var(--bg-surface-variant)',
|
|
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">
|
|
{selected
|
|
? (DECORATION_CATEGORIES.flatMap((c) => c.decorations).find(
|
|
(d) => d.slug === selected,
|
|
)?.name ?? selected)
|
|
: 'None'}
|
|
</Text>
|
|
{selected && (
|
|
<Button
|
|
type="button"
|
|
size="300"
|
|
radii="300"
|
|
variant="Critical"
|
|
fill="None"
|
|
onClick={handleClear}
|
|
>
|
|
<Text size="B300">Remove</Text>
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
{hasChanges && (
|
|
<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>
|
|
|
|
{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" style={{ opacity: 0.7 }}>
|
|
{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>
|
|
);
|
|
}
|