Add Discord collectibles to profiles, messages, and settings.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user