Add Discord collectibles to profiles, messages, and settings.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s

Integrate profile effects, nameplates, and avatar decorations with live catalog browsing, Matrix profile storage, and rendering across user heroes, DMs, messages, and the sidebar avatar.
This commit is contained in:
2026-08-24 00:54:48 +10:00
parent 6cb1e14632
commit 0cbc5d9a1c
25 changed files with 2473 additions and 174 deletions

View File

@@ -37,6 +37,10 @@ import { AvatarPresence, PresenceBadge } from '../../../components/presence';
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
import colorMXID, { getColorMXIDValue } from '../../../../util/colorMXID';
import { getCurrentAccessToken } from '../../../utils/auth';
import { CollectiblesSection } from './CollectiblesSection';
import { useUserCollectibles } from '../../../hooks/useUserCollectibles';
import { pickAvatarDecorationUrl } from '../../../utils/collectibleAssets';
import * as avatarDecorationCss from '../../../styles/AvatarDecoration.css';
import {
ColorPreference,
hasColorPreference,
@@ -53,6 +57,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
const profile = useUserProfile(userId);
const presence = useUserPresence(userId);
const [userBanner, setUserBanner, loading] = useUserBanner();
const [collectibles, updateCollectible] = useUserCollectibles();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string>();
const [hoveredArea, setHoveredArea] = useState<string | null>(null);
@@ -84,6 +89,17 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const avatarDecorationUrl = useMemo(() => {
const decoration = collectibles.avatar_decoration;
if (!decoration) return undefined;
const assetUrls: Record<string, string | undefined> = {};
for (const [role, mxc] of Object.entries(decoration.assets)) {
assetUrls[`avatar_decoration:${role}`] =
mxcUrlToHttp(mx, mxc, useAuthentication) ?? undefined;
}
return pickAvatarDecorationUrl(assetUrls);
}, [collectibles.avatar_decoration, mx, useAuthentication]);
// Larger avatar URL for the blurred cover fallback
const avatarCoverUrl = profile.avatarUrl
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication) ?? undefined
@@ -376,15 +392,28 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
resolveColorForTheme(previewPreference, theme.kind) || getColorMXIDValue(userId, theme.kind === ThemeKind.Dark);
const previewTextShadow = `0 1px 4px ${getTextShadowColor(previewProfileColor)}`;
const hasSavedColors = hasColorPreference(colorPreference);
const colorPickerSize = toRem(200);
const colorPreviewPlateStyle = {
width: '100%',
boxSizing: 'border-box' as const,
padding: `${toRem(4)} ${toRem(8)}`,
borderRadius: toRem(8),
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
lineHeight: 1.2,
};
const displayName = profile.displayName || getMxIdLocalPart(userId) || userId;
return (
<Box direction="Column" gap="300">
<Box direction="Column" gap="300" style={{ width: '100%', alignItems: 'center' }}>
<Box
direction="Column"
style={{
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
borderRadius: toRem(8),
width: toRem(340),
width: '60%',
overflow: 'hidden',
position: 'relative',
}}
@@ -521,14 +550,10 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
onMouseLeave={() => setHoveredArea(null)}
>
<Box
as="button"
onClick={handleAvatarClick}
style={{
backgroundColor: color.Surface.Container,
border: 'none',
padding: 0,
cursor: 'pointer',
borderRadius: '50%',
position: 'relative',
width: toRem(76),
height: toRem(76),
}}
>
<AvatarPresence
@@ -538,21 +563,48 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
)
}
>
<Avatar
size="500"
<Box
as="button"
onClick={handleAvatarClick}
style={{
width: toRem(72),
height: toRem(72),
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
position: 'relative',
width: '100%',
height: '100%',
backgroundColor: avatarUrl ? 'transparent' : color.Surface.Container,
border: 'none',
padding: 0,
cursor: 'pointer',
borderRadius: '50%',
}}
>
<UserAvatar
userId={userId}
src={avatarUrl}
alt={userId}
renderFallback={() => <Icon size="500" src={Icons.User} filled />}
/>
</Avatar>
<Avatar
size="500"
style={{
width: toRem(76),
height: toRem(76),
...(avatarUrl
? { outline: 'none', border: 'none', boxShadow: 'none' }
: {
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
}),
}}
>
<UserAvatar
userId={userId}
src={avatarUrl}
alt={userId}
renderFallback={() => <Icon size="500" src={Icons.User} filled />}
/>
</Avatar>
{avatarDecorationUrl && (
<img
className={avatarDecorationCss.AvatarDecorationOverlay}
src={avatarDecorationUrl}
alt=""
draggable={false}
/>
)}
</Box>
</AvatarPresence>
</Box>
{/* Avatar action icons - shown on hover */}
@@ -845,67 +897,100 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
direction="Column"
gap="300"
style={{
padding: config.space.S300,
padding: config.space.S400,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
borderRadius: toRem(8),
width: '100%',
boxSizing: 'border-box',
}}
>
<Text size="H6">Username colors</Text>
<Text size="T200" style={{ opacity: 0.8 }}>
Set how your name appears on dark and light themes (MSC4522). Other clients that support this
spec will see your chosen colors.
</Text>
<Box gap="400" wrap="Wrap">
<Box direction="Column" gap="200">
<Box direction="Row" gap="400" wrap="Wrap" alignItems="Start">
<Box direction="Column" gap="200" style={{ flex: '1 1 0', minWidth: toRem(140) }}>
<Text size="T300">On dark themes</Text>
<Text size="T200" style={{ opacity: 0.7 }}>Bright colors work best</Text>
<HexColorPicker color={localOnDark} onChange={(c) => { setLocalOnDark(c); setColorError(undefined); }} />
<Box
style={{
...colorPreviewPlateStyle,
backgroundColor: '#262626',
}}
>
<Text
size="H4"
className={classNames(BreakWord, LineClamp3)}
title={displayName}
style={{
color: localOnDark,
textShadow: `0 1px 4px ${getTextShadowColor(localOnDark)}`,
textAlign: 'center',
maxWidth: '100%',
}}
>
{displayName}
</Text>
</Box>
<HexColorPicker
color={localOnDark}
onChange={(c) => {
setLocalOnDark(c);
setColorError(undefined);
}}
style={{ width: '100%', height: colorPickerSize }}
/>
<Input
size="300"
variant="Secondary"
style={{ width: toRem(120) }}
style={{ width: '100%' }}
value={localOnDark}
onChange={(e) => {
setLocalOnDark(e.target.value);
setColorError(undefined);
}}
/>
<Box
style={{
width: toRem(48),
height: toRem(48),
borderRadius: toRem(8),
backgroundColor: localOnDark,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
}}
/>
</Box>
<Box direction="Column" gap="200">
<Box direction="Column" gap="200" style={{ flex: '1 1 0', minWidth: toRem(140) }}>
<Text size="T300">On light themes</Text>
<Text size="T200" style={{ opacity: 0.7 }}>Darker colors work best</Text>
<HexColorPicker color={localOnLight} onChange={(c) => { setLocalOnLight(c); setColorError(undefined); }} />
<Box
style={{
...colorPreviewPlateStyle,
backgroundColor: '#F0F0F0',
}}
>
<Text
size="H4"
className={classNames(BreakWord, LineClamp3)}
title={displayName}
style={{
color: localOnLight,
textShadow: `0 1px 4px ${getTextShadowColor(localOnLight)}`,
textAlign: 'center',
maxWidth: '100%',
}}
>
{displayName}
</Text>
</Box>
<HexColorPicker
color={localOnLight}
onChange={(c) => {
setLocalOnLight(c);
setColorError(undefined);
}}
style={{ width: '100%', height: colorPickerSize }}
/>
<Input
size="300"
variant="Secondary"
style={{ width: toRem(120) }}
style={{ width: '100%' }}
value={localOnLight}
onChange={(e) => {
setLocalOnLight(e.target.value);
setColorError(undefined);
}}
/>
<Box
style={{
width: toRem(48),
height: toRem(48),
borderRadius: toRem(8),
backgroundColor: localOnLight,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
}}
/>
</Box>
</Box>
@@ -939,6 +1024,21 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
)}
</Box>
<Box
direction="Column"
gap="300"
style={{
padding: config.space.S400,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
borderRadius: toRem(8),
width: '100%',
boxSizing: 'border-box',
}}
>
<Text size="H6">Discord profile overlays</Text>
<CollectiblesSection collectibles={collectibles} onApply={updateCollectible} />
</Box>
{uploadAtom && (
<Box gap="200" direction="Column" style={{ width: '100%' }}>
<CompactUploadCardRenderer