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:
@@ -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>
|
||||
);
|
||||
}
|
||||
77
src/app/components/user-profile/ProfileEffectMedia.tsx
Normal file
77
src/app/components/user-profile/ProfileEffectMedia.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user