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

@@ -0,0 +1,28 @@
import React from 'react';
import {
pickStoredProfileEffectIntroUrl,
pickStoredProfileEffectLoopUrl,
} from '../../utils/collectibleAssets';
import { ProfileEffectMedia } from './ProfileEffectMedia';
import * as css from './styles.css';
type ProfileCollectibleOverlaysProps = {
assetUrls: Record<string, string | undefined>;
};
export function ProfileCollectibleOverlays({ assetUrls }: ProfileCollectibleOverlaysProps) {
const introUrl = pickStoredProfileEffectIntroUrl(assetUrls);
const loopUrl = pickStoredProfileEffectLoopUrl(assetUrls);
if (!introUrl && !loopUrl) return null;
return (
<div className={css.UserHeroCollectibleLayer} aria-hidden="true">
<ProfileEffectMedia
introUrl={introUrl}
loopUrl={loopUrl}
className={css.ProfileEffectOverlay}
/>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import React, { useEffect, useState } from 'react';
import { isCatalogVideoPreview } from '../../utils/collectibleAssets';
const DEFAULT_INTRO_DURATION_MS = 3000;
type ProfileEffectMediaProps = {
introUrl?: string;
loopUrl?: string;
introDurationMs?: number;
className?: string;
restartToken?: number;
};
export function ProfileEffectMedia({
introUrl,
loopUrl,
introDurationMs,
className,
restartToken = 0,
}: ProfileEffectMediaProps) {
const resolvedLoopUrl = loopUrl ?? introUrl;
const hasSequence = Boolean(introUrl && resolvedLoopUrl && introUrl !== resolvedLoopUrl);
const [phase, setPhase] = useState<'intro' | 'loop'>(hasSequence ? 'intro' : 'loop');
const [playbackEpoch, setPlaybackEpoch] = useState(0);
useEffect(() => {
setPhase(hasSequence ? 'intro' : 'loop');
}, [introUrl, resolvedLoopUrl, hasSequence]);
useEffect(() => {
setPhase(hasSequence ? 'intro' : 'loop');
setPlaybackEpoch((epoch) => epoch + 1);
}, [restartToken, hasSequence]);
const activeUrl = phase === 'intro' && introUrl ? introUrl : resolvedLoopUrl;
const shouldLoop = !hasSequence || phase === 'loop';
useEffect(() => {
if (!hasSequence || phase !== 'intro' || !introUrl) return undefined;
if (isCatalogVideoPreview(introUrl)) return undefined;
const duration = introDurationMs ?? DEFAULT_INTRO_DURATION_MS;
const timer = window.setTimeout(() => setPhase('loop'), duration);
return () => window.clearTimeout(timer);
}, [hasSequence, phase, introUrl, introDurationMs, playbackEpoch]);
if (!activeUrl) return null;
if (isCatalogVideoPreview(activeUrl)) {
return (
<video
key={`${playbackEpoch}:${activeUrl}`}
className={className}
src={activeUrl}
autoPlay
loop={shouldLoop}
muted
playsInline
onEnded={() => {
if (hasSequence && phase === 'intro') {
setPhase('loop');
}
}}
/>
);
}
return (
<img
key={`${playbackEpoch}:${activeUrl}`}
className={className}
src={activeUrl}
alt=""
draggable={false}
/>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { ReactNode, useState } from 'react';
import { Avatar, Box, Overlay, Text, toRem } from 'folds';
import { Icon, Icons } from '../icons';
import classNames from 'classnames';
@@ -14,22 +14,36 @@ import { ImageViewer } from '../image-viewer';
import { stopPropagation } from '../../utils/keyboard';
import { useOtherUserColor } from '../../hooks/useUserColor';
import { useOtherUserBanner } from '../../hooks/useUserBanner';
import { useOtherUserCollectibles } from '../../hooks/useUserCollectibles';
import { pickAvatarDecorationUrl } from '../../utils/collectibleAssets';
import * as avatarDecorationCss from '../../styles/AvatarDecoration.css';
import { ProfileCollectibleOverlays } from './ProfileCollectibleOverlays';
type UserHeroProps = {
userId: string;
avatarUrl?: string;
avatarMxc?: string;
presence?: UserPresence;
children?: ReactNode;
};
export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroProps) {
export function UserHero({ userId, avatarUrl, avatarMxc, presence, children }: UserHeroProps) {
const [viewAvatar, setViewAvatar] = useState<string>();
const bannerUrl = useOtherUserBanner(userId);
const { assetUrls } = useOtherUserCollectibles(userId);
const avatarDecorationUrl = pickAvatarDecorationUrl(assetUrls);
return (
<Box
direction="Column"
className={css.UserHero}
>
<Box direction="Column" className={css.UserHeroZone}>
{bannerUrl && (
<div className={css.UserHeroBannerReflection} aria-hidden="true">
<img
className={css.UserHeroBannerReflectionImg}
src={bannerUrl}
alt=""
draggable={false}
/>
</div>
)}
<div
className={css.UserHeroCoverContainer}
style={{
@@ -47,29 +61,45 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
</div>
<div className={css.UserHeroAvatarContainer}>
<AvatarPresence
className={css.UserAvatarContainer}
className={classNames(
css.UserAvatarContainer,
!avatarUrl && css.UserAvatarContainerFallback
)}
badge={
presence && <PresenceBadge presence={presence.presence} status={presence.status} />
}
>
<Avatar
as={avatarUrl ? 'button' : 'div'}
onClick={avatarUrl ? () => setViewAvatar(avatarUrl) : undefined}
className={css.UserHeroAvatar}
size="500"
style={{
width: toRem(72),
height: toRem(72),
}}
>
<UserAvatar
className={css.UserHeroAvatarImg}
userId={userId}
src={avatarUrl}
alt={userId}
renderFallback={() => <Icon size="500" src={Icons.User} filled />}
/>
</Avatar>
<div className={css.UserHeroAvatarStack}>
<Avatar
as={avatarUrl ? 'button' : 'div'}
onClick={avatarUrl ? () => setViewAvatar(avatarUrl) : undefined}
className={classNames(
css.UserHeroAvatar,
avatarUrl ? css.UserHeroAvatarWithImage : css.UserHeroAvatarBorder
)}
size="500"
style={{
width: toRem(72),
height: toRem(72),
}}
>
<UserAvatar
className={css.UserHeroAvatarImg}
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}
/>
)}
</div>
</AvatarPresence>
{viewAvatar && (
<Overlay open backdrop={null}>
@@ -90,6 +120,12 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
</Overlay>
)}
</div>
{children && (
<Box direction="Column" className={css.UserHeroInfo}>
{children}
</Box>
)}
<ProfileCollectibleOverlays assetUrls={assetUrls} />
</Box>
);
}

View File

@@ -2,7 +2,7 @@ import { Box, Button, color, config, Text, toRem } from 'folds';
import { Icon, Icons } from '../icons';
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { UserHero, UserHeroName } from './UserHero';
import { UserHero } from './UserHero';
import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -94,53 +94,55 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
avatarUrl={avatarUrl}
avatarMxc={avatarMxc}
presence={presence && presence.lastActiveTs !== 0 ? presence : undefined}
/>
<Box
direction="Column"
gap="200"
alignItems="Center"
style={{
padding: config.space.S400,
paddingTop: `calc(${config.space.S200} + ${toRem(36)})`,
marginTop: toRem(-36),
textAlign: 'center',
}}
>
{/* Display Name */}
<Text
size="H4"
className={classNames(BreakWord, LineClamp3)}
style={{ color: getMemberDisplayName(room, userId) !== userId ? profileColor : undefined, textShadow: profileTextShadow }}
<Box
direction="Column"
gap="200"
alignItems="Center"
style={{
padding: config.space.S400,
paddingTop: `calc(${config.space.S200} + ${toRem(36)})`,
marginTop: toRem(-36),
}}
>
{getMemberDisplayName(room, userId)}
</Text>
{/* Username */}
<Text
size="T200"
className={BreakWord}
style={{ color: profileColor, textShadow: profileTextShadow }}
>
{userId}
</Text>
{/* Status Pill */}
{presence?.status && (
<Box
<Text
size="H4"
className={classNames(BreakWord, LineClamp3)}
style={{
backgroundColor: color.Surface.Container,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
padding: `${toRem(6)} ${toRem(10)}`,
borderRadius: toRem(16),
maxWidth: toRem(250),
color: getMemberDisplayName(room, userId) !== userId ? profileColor : undefined,
textShadow: profileTextShadow,
}}
>
<Text size="T300" className={BreakWord}>
{presence.status}
</Text>
</Box>
)}
{getMemberDisplayName(room, userId)}
</Text>
<Text
size="T200"
className={BreakWord}
style={{ color: profileColor, textShadow: profileTextShadow }}
>
{userId}
</Text>
{presence?.status && (
<Box
style={{
backgroundColor: color.Surface.Container,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
padding: `${toRem(6)} ${toRem(10)}`,
borderRadius: toRem(16),
maxWidth: toRem(250),
}}
>
<Text size="T300" className={BreakWord}>
{presence.status}
</Text>
</Box>
)}
</Box>
</UserHero>
<Box direction="Column" gap="200" alignItems="Center" style={{ padding: config.space.S400, textAlign: 'center' }}>
{/* Chips Row */}
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
{server && <ServerChip server={server} />}
@@ -170,7 +172,8 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
</Box>
)}
</Box>
{hasBottomContent && <Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
{hasBottomContent && (
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
{ignored && <IgnoredUserAlert />}
{member && membership === Membership.Ban && (
<UserBanAlert
@@ -206,7 +209,8 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
canKick={canKickUser && membership === Membership.Join}
canBan={canBanUser && membership !== Membership.Ban}
/>
</Box>}
</Box>
)}
</Box>
);
}

View File

@@ -1,4 +1,4 @@
import { style } from '@vanilla-extract/css';
import { globalStyle, style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
export const UserHeader = style({
@@ -10,14 +10,26 @@ export const UserHeader = style({
padding: config.space.S200,
});
export const UserHero = style({
export const UserHeroZone = style({
position: 'relative',
display: 'flex',
flexDirection: 'column',
isolation: 'isolate',
});
export const UserHeroCollectibleLayer = style({
position: 'absolute',
inset: 0,
zIndex: 10,
pointerEvents: 'none',
overflow: 'visible',
});
export const UserHeroCoverContainer = style({
position: 'relative',
height: toRem(140),
overflow: 'hidden',
zIndex: 1,
});
export const UserHeroCover = style({
height: '100%',
@@ -36,12 +48,68 @@ export const UserHeroBanner = style({
export const UserHeroAvatarContainer = style({
position: 'relative',
height: toRem(29),
zIndex: 4,
overflow: 'visible',
});
export const UserHeroBannerReflection = style({
position: 'absolute',
left: 0,
right: 0,
top: toRem(140),
bottom: 0,
overflow: 'hidden',
zIndex: 0,
pointerEvents: 'none',
});
export const UserHeroBannerReflectionImg = style({
position: 'absolute',
top: toRem(12),
left: '-10%',
width: '120%',
height: toRem(160),
objectFit: 'cover',
transformOrigin: 'top center',
transform: 'scaleY(-1) scale(1.2)',
filter: 'blur(48px) saturate(1.15)',
});
export const UserHeroInfo = style({
position: 'relative',
zIndex: 4,
textAlign: 'center',
backgroundColor: 'transparent',
});
globalStyle(`${UserHeroBannerReflection}::after`, {
content: '',
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: toRem(72),
background: `linear-gradient(
to bottom,
transparent 0%,
color-mix(in srgb, ${color.Surface.Container} 55%, transparent) 55%,
${color.Surface.Container} 100%
)`,
pointerEvents: 'none',
});
export const UserAvatarContainer = style({
position: 'absolute',
left: '50%',
top: 0,
transform: 'translate(-50%, -50%)',
zIndex: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const UserAvatarContainerFallback = style({
backgroundColor: color.Surface.Container,
});
@@ -53,14 +121,31 @@ export const UserStatusBubble = style({
zIndex: 2,
});
export const UserHeroAvatarStack = style({
position: 'relative',
width: toRem(72),
height: toRem(72),
flexShrink: 0,
});
export const UserHeroAvatar = style({
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
selectors: {
'button&': {
cursor: 'pointer',
},
},
});
export const UserHeroAvatarBorder = style({
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
border: 'none',
});
export const UserHeroAvatarWithImage = style({
outline: 'none',
border: 'none',
boxShadow: 'none',
});
export const UserHeroAvatarImg = style({
selectors: {
[`button${UserHeroAvatar}:hover &`]: {
@@ -68,3 +153,15 @@ export const UserHeroAvatarImg = style({
},
},
});
export const ProfileEffectOverlay = style({
position: 'absolute',
left: 0,
right: 0,
top: 0,
width: '100%',
aspectRatio: '450 / 880',
objectFit: 'contain',
objectPosition: 'top center',
pointerEvents: 'none',
});