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:
@@ -34,7 +34,7 @@ function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState })
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu style={{ width: toRem(340) }}>
|
<Menu style={{ width: toRem(260) }}>
|
||||||
<SpaceProvider value={space ?? null}>
|
<SpaceProvider value={space ?? null}>
|
||||||
<RoomProvider value={room}>
|
<RoomProvider value={room}>
|
||||||
<UserRoomProfile userId={userId} />
|
<UserRoomProfile userId={userId} />
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ type AvatarPresenceProps = {
|
|||||||
export const AvatarPresence = as<'div', AvatarPresenceProps>(
|
export const AvatarPresence = as<'div', AvatarPresenceProps>(
|
||||||
({ as: AsAvatarPresence, badge, variant = 'Surface', badgeBackgroundColor, children, ...props }, ref) => (
|
({ as: AsAvatarPresence, badge, variant = 'Surface', badgeBackgroundColor, children, ...props }, ref) => (
|
||||||
<Box as={AsAvatarPresence} className={css.AvatarPresence} {...props} ref={ref}>
|
<Box as={AsAvatarPresence} className={css.AvatarPresence} {...props} ref={ref}>
|
||||||
|
{children}
|
||||||
{badge && (
|
{badge && (
|
||||||
<div
|
<div
|
||||||
className={css.AvatarPresenceBadge}
|
className={css.AvatarPresenceBadge}
|
||||||
@@ -75,7 +76,6 @@ export const AvatarPresence = as<'div', AvatarPresenceProps>(
|
|||||||
{badge}
|
{badge}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{children}
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const AvatarPresenceBadge = style({
|
|||||||
bottom: 0,
|
bottom: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
transform: 'translate(25%, 25%)',
|
transform: 'translate(25%, 25%)',
|
||||||
zIndex: 1,
|
zIndex: 10,
|
||||||
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
padding: config.borderWidth.B600,
|
padding: config.borderWidth.B600,
|
||||||
|
|||||||
@@ -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 { Avatar, Box, Overlay, Text, toRem } from 'folds';
|
||||||
import { Icon, Icons } from '../icons';
|
import { Icon, Icons } from '../icons';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
@@ -14,22 +14,36 @@ import { ImageViewer } from '../image-viewer';
|
|||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { useOtherUserColor } from '../../hooks/useUserColor';
|
import { useOtherUserColor } from '../../hooks/useUserColor';
|
||||||
import { useOtherUserBanner } from '../../hooks/useUserBanner';
|
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 = {
|
type UserHeroProps = {
|
||||||
userId: string;
|
userId: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
avatarMxc?: string;
|
avatarMxc?: string;
|
||||||
presence?: UserPresence;
|
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 [viewAvatar, setViewAvatar] = useState<string>();
|
||||||
const bannerUrl = useOtherUserBanner(userId);
|
const bannerUrl = useOtherUserBanner(userId);
|
||||||
|
const { assetUrls } = useOtherUserCollectibles(userId);
|
||||||
|
const avatarDecorationUrl = pickAvatarDecorationUrl(assetUrls);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box direction="Column" className={css.UserHeroZone}>
|
||||||
direction="Column"
|
{bannerUrl && (
|
||||||
className={css.UserHero}
|
<div className={css.UserHeroBannerReflection} aria-hidden="true">
|
||||||
>
|
<img
|
||||||
|
className={css.UserHeroBannerReflectionImg}
|
||||||
|
src={bannerUrl}
|
||||||
|
alt=""
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={css.UserHeroCoverContainer}
|
className={css.UserHeroCoverContainer}
|
||||||
style={{
|
style={{
|
||||||
@@ -47,29 +61,45 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
|
|||||||
</div>
|
</div>
|
||||||
<div className={css.UserHeroAvatarContainer}>
|
<div className={css.UserHeroAvatarContainer}>
|
||||||
<AvatarPresence
|
<AvatarPresence
|
||||||
className={css.UserAvatarContainer}
|
className={classNames(
|
||||||
|
css.UserAvatarContainer,
|
||||||
|
!avatarUrl && css.UserAvatarContainerFallback
|
||||||
|
)}
|
||||||
badge={
|
badge={
|
||||||
presence && <PresenceBadge presence={presence.presence} status={presence.status} />
|
presence && <PresenceBadge presence={presence.presence} status={presence.status} />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Avatar
|
<div className={css.UserHeroAvatarStack}>
|
||||||
as={avatarUrl ? 'button' : 'div'}
|
<Avatar
|
||||||
onClick={avatarUrl ? () => setViewAvatar(avatarUrl) : undefined}
|
as={avatarUrl ? 'button' : 'div'}
|
||||||
className={css.UserHeroAvatar}
|
onClick={avatarUrl ? () => setViewAvatar(avatarUrl) : undefined}
|
||||||
size="500"
|
className={classNames(
|
||||||
style={{
|
css.UserHeroAvatar,
|
||||||
width: toRem(72),
|
avatarUrl ? css.UserHeroAvatarWithImage : css.UserHeroAvatarBorder
|
||||||
height: toRem(72),
|
)}
|
||||||
}}
|
size="500"
|
||||||
>
|
style={{
|
||||||
<UserAvatar
|
width: toRem(72),
|
||||||
className={css.UserHeroAvatarImg}
|
height: toRem(72),
|
||||||
userId={userId}
|
}}
|
||||||
src={avatarUrl}
|
>
|
||||||
alt={userId}
|
<UserAvatar
|
||||||
renderFallback={() => <Icon size="500" src={Icons.User} filled />}
|
className={css.UserHeroAvatarImg}
|
||||||
/>
|
userId={userId}
|
||||||
</Avatar>
|
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>
|
</AvatarPresence>
|
||||||
{viewAvatar && (
|
{viewAvatar && (
|
||||||
<Overlay open backdrop={null}>
|
<Overlay open backdrop={null}>
|
||||||
@@ -90,6 +120,12 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
|
|||||||
</Overlay>
|
</Overlay>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{children && (
|
||||||
|
<Box direction="Column" className={css.UserHeroInfo}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<ProfileCollectibleOverlays assetUrls={assetUrls} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Box, Button, color, config, Text, toRem } from 'folds';
|
|||||||
import { Icon, Icons } from '../icons';
|
import { Icon, Icons } from '../icons';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { UserHero, UserHeroName } from './UserHero';
|
import { UserHero } from './UserHero';
|
||||||
import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
|
import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
@@ -94,53 +94,55 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
|||||||
avatarUrl={avatarUrl}
|
avatarUrl={avatarUrl}
|
||||||
avatarMxc={avatarMxc}
|
avatarMxc={avatarMxc}
|
||||||
presence={presence && presence.lastActiveTs !== 0 ? presence : undefined}
|
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 */}
|
<Box
|
||||||
<Text
|
direction="Column"
|
||||||
size="H4"
|
gap="200"
|
||||||
className={classNames(BreakWord, LineClamp3)}
|
alignItems="Center"
|
||||||
style={{ color: getMemberDisplayName(room, userId) !== userId ? profileColor : undefined, textShadow: profileTextShadow }}
|
style={{
|
||||||
|
padding: config.space.S400,
|
||||||
|
paddingTop: `calc(${config.space.S200} + ${toRem(36)})`,
|
||||||
|
marginTop: toRem(-36),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{getMemberDisplayName(room, userId)}
|
<Text
|
||||||
</Text>
|
size="H4"
|
||||||
|
className={classNames(BreakWord, LineClamp3)}
|
||||||
{/* Username */}
|
|
||||||
<Text
|
|
||||||
size="T200"
|
|
||||||
className={BreakWord}
|
|
||||||
style={{ color: profileColor, textShadow: profileTextShadow }}
|
|
||||||
>
|
|
||||||
{userId}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Status Pill */}
|
|
||||||
{presence?.status && (
|
|
||||||
<Box
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: color.Surface.Container,
|
color: getMemberDisplayName(room, userId) !== userId ? profileColor : undefined,
|
||||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
textShadow: profileTextShadow,
|
||||||
padding: `${toRem(6)} ${toRem(10)}`,
|
|
||||||
borderRadius: toRem(16),
|
|
||||||
maxWidth: toRem(250),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="T300" className={BreakWord}>
|
{getMemberDisplayName(room, userId)}
|
||||||
{presence.status}
|
</Text>
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
<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 */}
|
{/* Chips Row */}
|
||||||
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
|
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
|
||||||
{server && <ServerChip server={server} />}
|
{server && <ServerChip server={server} />}
|
||||||
@@ -170,7 +172,8 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</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 />}
|
{ignored && <IgnoredUserAlert />}
|
||||||
{member && membership === Membership.Ban && (
|
{member && membership === Membership.Ban && (
|
||||||
<UserBanAlert
|
<UserBanAlert
|
||||||
@@ -206,7 +209,8 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
|||||||
canKick={canKickUser && membership === Membership.Join}
|
canKick={canKickUser && membership === Membership.Join}
|
||||||
canBan={canBanUser && membership !== Membership.Ban}
|
canBan={canBanUser && membership !== Membership.Ban}
|
||||||
/>
|
/>
|
||||||
</Box>}
|
</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';
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
export const UserHeader = style({
|
export const UserHeader = style({
|
||||||
@@ -10,14 +10,26 @@ export const UserHeader = style({
|
|||||||
padding: config.space.S200,
|
padding: config.space.S200,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UserHero = style({
|
export const UserHeroZone = style({
|
||||||
position: 'relative',
|
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({
|
export const UserHeroCoverContainer = style({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
height: toRem(140),
|
height: toRem(140),
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
zIndex: 1,
|
||||||
});
|
});
|
||||||
export const UserHeroCover = style({
|
export const UserHeroCover = style({
|
||||||
height: '100%',
|
height: '100%',
|
||||||
@@ -36,12 +48,68 @@ export const UserHeroBanner = style({
|
|||||||
export const UserHeroAvatarContainer = style({
|
export const UserHeroAvatarContainer = style({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
height: toRem(29),
|
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({
|
export const UserAvatarContainer = style({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: '50%',
|
left: '50%',
|
||||||
top: 0,
|
top: 0,
|
||||||
transform: 'translate(-50%, -50%)',
|
transform: 'translate(-50%, -50%)',
|
||||||
|
zIndex: 4,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UserAvatarContainerFallback = style({
|
||||||
backgroundColor: color.Surface.Container,
|
backgroundColor: color.Surface.Container,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,14 +121,31 @@ export const UserStatusBubble = style({
|
|||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const UserHeroAvatarStack = style({
|
||||||
|
position: 'relative',
|
||||||
|
width: toRem(72),
|
||||||
|
height: toRem(72),
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
export const UserHeroAvatar = style({
|
export const UserHeroAvatar = style({
|
||||||
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
|
|
||||||
selectors: {
|
selectors: {
|
||||||
'button&': {
|
'button&': {
|
||||||
cursor: 'pointer',
|
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({
|
export const UserHeroAvatarImg = style({
|
||||||
selectors: {
|
selectors: {
|
||||||
[`button${UserHeroAvatar}:hover &`]: {
|
[`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',
|
||||||
|
});
|
||||||
|
|||||||
@@ -29,7 +29,26 @@ export const TimelineFloat = recipe({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TimelineFloatVariants = RecipeVariants<typeof TimelineFloat>;
|
export const DmNameplateBackground = style({
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DmNameplateBackgroundMedia = style({
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
left: 'auto',
|
||||||
|
width: toRem(280),
|
||||||
|
maxWidth: '70%',
|
||||||
|
height: toRem(64),
|
||||||
|
objectFit: 'cover',
|
||||||
|
objectPosition: 'right top',
|
||||||
|
opacity: 0.12,
|
||||||
|
});
|
||||||
|
|
||||||
export const CarouselScroller = style([
|
export const CarouselScroller = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|||||||
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
||||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
import { useIsDirectRoom } from '../../hooks/useRoom';
|
||||||
|
import { useOtherUserCollectibles } from '../../hooks/useUserCollectibles';
|
||||||
|
import { pickNameplateUrl } from '../../utils/collectibleAssets';
|
||||||
import { setupCopyHandler } from '../../utils/copyHandler';
|
import { setupCopyHandler } from '../../utils/copyHandler';
|
||||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
@@ -570,6 +572,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
||||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||||
const direct = useIsDirectRoom();
|
const direct = useIsDirectRoom();
|
||||||
|
const dmUserId = direct ? room.guessDMUserId() ?? undefined : undefined;
|
||||||
|
const [timelineHovered, setTimelineHovered] = useState(false);
|
||||||
|
const { assetUrls: dmAssetUrls } = useOtherUserCollectibles(dmUserId ?? '');
|
||||||
|
const dmNameplateUrl = pickNameplateUrl(dmAssetUrls);
|
||||||
|
const dmNameplateIsVideo = Boolean(
|
||||||
|
dmAssetUrls['nameplate:animated'] || dmAssetUrls['nameplate:asset.webm']
|
||||||
|
);
|
||||||
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
|
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
|
||||||
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
|
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
|
||||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||||
@@ -2530,7 +2539,33 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const atLiveBottom = atBottom && liveTimelineLinked && rangeAtEnd;
|
const atLiveBottom = atBottom && liveTimelineLinked && rangeAtEnd;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box grow="Yes" style={{ position: 'relative' }}>
|
<Box
|
||||||
|
grow="Yes"
|
||||||
|
style={{ position: 'relative' }}
|
||||||
|
onMouseEnter={() => setTimelineHovered(true)}
|
||||||
|
onMouseLeave={() => setTimelineHovered(false)}
|
||||||
|
>
|
||||||
|
{direct && timelineHovered && dmNameplateUrl && (
|
||||||
|
<div className={css.DmNameplateBackground} aria-hidden="true">
|
||||||
|
{dmNameplateIsVideo ? (
|
||||||
|
<video
|
||||||
|
className={css.DmNameplateBackgroundMedia}
|
||||||
|
src={dmNameplateUrl}
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
className={css.DmNameplateBackgroundMedia}
|
||||||
|
src={dmNameplateUrl}
|
||||||
|
alt=""
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Scroll ref={scrollRef} visibility="Hover" data-room-timeline-scroll="">
|
<Scroll ref={scrollRef} visibility="Hover" data-room-timeline-scroll="">
|
||||||
<Box
|
<Box
|
||||||
ref={timelineContentRef}
|
ref={timelineContentRef}
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ import colorMXID from '../../../../util/colorMXID';
|
|||||||
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
||||||
import { Presence, useUserPresence } from '../../../hooks/useUserPresence';
|
import { Presence, useUserPresence } from '../../../hooks/useUserPresence';
|
||||||
import { useOtherUserColor } from '../../../hooks/useUserColor';
|
import { useOtherUserColor } from '../../../hooks/useUserColor';
|
||||||
|
import { useIsDirectRoom } from '../../../hooks/useRoom';
|
||||||
|
import { useOtherUserCollectibles } from '../../../hooks/useUserCollectibles';
|
||||||
|
import { pickAvatarDecorationUrl, pickNameplateUrl } from '../../../utils/collectibleAssets';
|
||||||
|
import * as avatarDecorationCss from '../../../styles/AvatarDecoration.css';
|
||||||
|
|
||||||
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
|
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
|
||||||
|
|
||||||
@@ -754,8 +758,15 @@ export const Message = as<'div', MessageProps>(
|
|||||||
) => {
|
) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const direct = useIsDirectRoom();
|
||||||
const senderId = mEvent.getSender() ?? '';
|
const senderId = mEvent.getSender() ?? '';
|
||||||
const senderPresence = useUserPresence(senderId);
|
const senderPresence = useUserPresence(senderId);
|
||||||
|
const { assetUrls } = useOtherUserCollectibles(senderId);
|
||||||
|
const nameplateUrl = pickNameplateUrl(assetUrls);
|
||||||
|
const nameplateIsVideo = Boolean(
|
||||||
|
assetUrls['nameplate:animated'] || assetUrls['nameplate:asset.webm']
|
||||||
|
);
|
||||||
|
const avatarDecorationUrl = pickAvatarDecorationUrl(assetUrls);
|
||||||
|
|
||||||
const [hover, setHover] = useState(false);
|
const [hover, setHover] = useState(false);
|
||||||
const { hoverProps } = useHover({ onHoverChange: setHover });
|
const { hoverProps } = useHover({ onHoverChange: setHover });
|
||||||
@@ -796,6 +807,7 @@ export const Message = as<'div', MessageProps>(
|
|||||||
|
|
||||||
// Priority: custom user color > tag color (non-legacy) > colorMXID (legacy)
|
// Priority: custom user color > tag color (non-legacy) > colorMXID (legacy)
|
||||||
const usernameColor = customUserColor ?? (legacyUsernameColor ? colorMXID(senderId) : tagColor);
|
const usernameColor = customUserColor ?? (legacyUsernameColor ? colorMXID(senderId) : tagColor);
|
||||||
|
const showNameplate = direct && hover && Boolean(nameplateUrl);
|
||||||
|
|
||||||
const headerJSX = !collapse && (
|
const headerJSX = !collapse && (
|
||||||
<Box
|
<Box
|
||||||
@@ -824,23 +836,41 @@ export const Message = as<'div', MessageProps>(
|
|||||||
</Username>
|
</Username>
|
||||||
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No" gap="100">
|
<Box shrink="No" className={css.MessageNameplateAnchor}>
|
||||||
{messageLayout === MessageLayout.Modern && hover && (
|
<Box shrink="No" gap="100" alignItems="Center">
|
||||||
<>
|
{messageLayout === MessageLayout.Modern && hover && (
|
||||||
<Text as="span" size="T200" priority="300">
|
<>
|
||||||
{senderId}
|
<Text as="span" size="T200" priority="300">
|
||||||
</Text>
|
{senderId}
|
||||||
<Text as="span" size="T200" priority="300">
|
</Text>
|
||||||
|
|
<Text as="span" size="T200" priority="300">
|
||||||
</Text>
|
|
|
||||||
</>
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Time
|
||||||
|
ts={mEvent.getTs()}
|
||||||
|
compact={messageLayout === MessageLayout.Compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{showNameplate && (
|
||||||
|
<div className={css.MessageNameplateWrap}>
|
||||||
|
{nameplateIsVideo ? (
|
||||||
|
<video
|
||||||
|
className={css.MessageNameplateOverlay}
|
||||||
|
src={nameplateUrl}
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img className={css.MessageNameplateOverlay} src={nameplateUrl} alt="" draggable={false} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<Time
|
|
||||||
ts={mEvent.getTs()}
|
|
||||||
compact={messageLayout === MessageLayout.Compact}
|
|
||||||
hour24Clock={hour24Clock}
|
|
||||||
dateFormatString={dateFormatString}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -857,24 +887,38 @@ export const Message = as<'div', MessageProps>(
|
|||||||
<AvatarBase
|
<AvatarBase
|
||||||
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
|
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
|
||||||
>
|
>
|
||||||
<Avatar
|
<div className={css.MessageAvatarStack}>
|
||||||
className={classNames(css.MessageAvatar, presenceClass)}
|
<Avatar
|
||||||
as="button"
|
className={classNames(
|
||||||
size="300"
|
avatarDecorationUrl ? css.MessageAvatarCircular : css.MessageAvatar,
|
||||||
data-user-id={senderId}
|
!avatarDecorationUrl && presenceClass
|
||||||
onClick={onUserClick}
|
)}
|
||||||
>
|
radii={avatarDecorationUrl ? 'Pill' : undefined}
|
||||||
<UserAvatar
|
as="button"
|
||||||
userId={senderId}
|
size="300"
|
||||||
src={
|
data-user-id={senderId}
|
||||||
senderAvatarMxc
|
onClick={onUserClick}
|
||||||
? mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined
|
>
|
||||||
: undefined
|
<UserAvatar
|
||||||
}
|
userId={senderId}
|
||||||
alt={senderDisplayName}
|
src={
|
||||||
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
|
senderAvatarMxc
|
||||||
/>
|
? mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined
|
||||||
</Avatar>
|
: undefined
|
||||||
|
}
|
||||||
|
alt={senderDisplayName}
|
||||||
|
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
{avatarDecorationUrl && (
|
||||||
|
<img
|
||||||
|
className={avatarDecorationCss.AvatarDecorationOverlay}
|
||||||
|
src={avatarDecorationUrl}
|
||||||
|
alt=""
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</AvatarBase>
|
</AvatarBase>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -954,6 +998,10 @@ export const Message = as<'div', MessageProps>(
|
|||||||
{...focusWithinProps}
|
{...focusWithinProps}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
className={css.MessageContentLayer}
|
||||||
|
data-message-nameplate-readable={showNameplate ? '' : undefined}
|
||||||
|
>
|
||||||
{!edit && (hover || !!menuAnchor || !!emojiBoardAnchor) && (
|
{!edit && (hover || !!menuAnchor || !!emojiBoardAnchor) && (
|
||||||
<div className={css.MessageOptionsBase}>
|
<div className={css.MessageOptionsBase}>
|
||||||
<Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
|
<Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
|
||||||
@@ -1242,6 +1290,7 @@ export const Message = as<'div', MessageProps>(
|
|||||||
{msgContentJSX}
|
{msgContentJSX}
|
||||||
</ModernLayout>
|
</ModernLayout>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</MessageBase>
|
</MessageBase>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,35 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
import { DefaultReset, config, toRem, color } from 'folds';
|
import { DefaultReset, config, toRem, color } from 'folds';
|
||||||
|
import * as layoutCss from '../../../components/message/layout/layout.css';
|
||||||
|
|
||||||
|
const NAMEPLATE_READABLE_TEXT_OUTLINE = [
|
||||||
|
`0 0 2px ${color.Surface.ContainerHover}`,
|
||||||
|
`0 0 4px ${color.Surface.ContainerHover}`,
|
||||||
|
`-1px -1px 0 ${color.Surface.ContainerHover}`,
|
||||||
|
`1px -1px 0 ${color.Surface.ContainerHover}`,
|
||||||
|
`-1px 1px 0 ${color.Surface.ContainerHover}`,
|
||||||
|
`1px 1px 0 ${color.Surface.ContainerHover}`,
|
||||||
|
`0 -1px 0 ${color.Surface.ContainerHover}`,
|
||||||
|
`0 1px 0 ${color.Surface.ContainerHover}`,
|
||||||
|
`-1px 0 0 ${color.Surface.ContainerHover}`,
|
||||||
|
`1px 0 0 ${color.Surface.ContainerHover}`,
|
||||||
|
].join(', ');
|
||||||
|
|
||||||
|
globalStyle(`[data-message-nameplate-readable] [data-message-header] button`, {
|
||||||
|
textShadow: NAMEPLATE_READABLE_TEXT_OUTLINE,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`[data-message-nameplate-readable] [data-message-header] time`, {
|
||||||
|
textShadow: NAMEPLATE_READABLE_TEXT_OUTLINE,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`[data-message-nameplate-readable] [data-message-header] span`, {
|
||||||
|
textShadow: NAMEPLATE_READABLE_TEXT_OUTLINE,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`[data-message-nameplate-readable] .${layoutCss.MessageTextBody.classNames.base}`, {
|
||||||
|
textShadow: NAMEPLATE_READABLE_TEXT_OUTLINE,
|
||||||
|
});
|
||||||
|
|
||||||
export const MessageBase = style({
|
export const MessageBase = style({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -51,6 +81,12 @@ export const MessageAvatar = style({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const MessageAvatarCircular = style({
|
||||||
|
cursor: 'pointer',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'visible',
|
||||||
|
});
|
||||||
|
|
||||||
export const MessageAvatarOnline = style({
|
export const MessageAvatarOnline = style({
|
||||||
'::before': {
|
'::before': {
|
||||||
backgroundColor: '#38842b',
|
backgroundColor: '#38842b',
|
||||||
@@ -69,8 +105,41 @@ export const MessageAvatarOffline = style({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const MessageQuickReaction = style({
|
export const MessageAvatarStack = style({
|
||||||
minWidth: toRem(32),
|
position: 'relative',
|
||||||
|
display: 'inline-flex',
|
||||||
|
lineHeight: 0,
|
||||||
|
overflow: 'visible',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MessageNameplateAnchor = style({
|
||||||
|
position: 'relative',
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MessageNameplateWrap = style({
|
||||||
|
position: 'absolute',
|
||||||
|
top: `calc(100% + ${toRem(6)})`,
|
||||||
|
right: 0,
|
||||||
|
width: toRem(220),
|
||||||
|
maxWidth: toRem(280),
|
||||||
|
height: toRem(42),
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MessageNameplateOverlay = style({
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
objectPosition: 'right center',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
borderRadius: toRem(6),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MessageContentLayer = style({
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const MessageMenuGroup = style({
|
export const MessageMenuGroup = style({
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { DiscordCollectibleItem } from '../../../utils/discordCollectibles';
|
||||||
|
import {
|
||||||
|
isCatalogVideoPreview,
|
||||||
|
pickCatalogAnimatedPreviewUrl,
|
||||||
|
pickCatalogStaticPreviewUrl,
|
||||||
|
} from '../../../utils/collectibleAssets';
|
||||||
|
|
||||||
|
type CollectiblePreviewMediaProps = {
|
||||||
|
item: DiscordCollectibleItem;
|
||||||
|
className?: string;
|
||||||
|
restartToken?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CollectiblePreviewMedia({
|
||||||
|
item,
|
||||||
|
className,
|
||||||
|
restartToken = 0,
|
||||||
|
}: CollectiblePreviewMediaProps) {
|
||||||
|
const staticUrl = pickCatalogStaticPreviewUrl(item);
|
||||||
|
const animatedUrl = pickCatalogAnimatedPreviewUrl(item);
|
||||||
|
const activeUrl = animatedUrl ?? staticUrl;
|
||||||
|
|
||||||
|
if (!activeUrl) return null;
|
||||||
|
|
||||||
|
if (animatedUrl && isCatalogVideoPreview(animatedUrl)) {
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
key={restartToken}
|
||||||
|
className={className}
|
||||||
|
src={animatedUrl}
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
key={restartToken}
|
||||||
|
className={className}
|
||||||
|
src={activeUrl}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
332
src/app/features/settings/account/CollectiblesSection.css.ts
Normal file
332
src/app/features/settings/account/CollectiblesSection.css.ts
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { color, toRem } from 'folds';
|
||||||
|
|
||||||
|
/** Discord nameplate static.png assets are 448×84. */
|
||||||
|
export const NAMEPLATE_ASPECT_RATIO = '448 / 84';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mini profile hero preview — matches UserRoomProfile menu width (260) and UserHero layout,
|
||||||
|
* with extra vertical space below the name so effects have room to play.
|
||||||
|
*/
|
||||||
|
export const PROFILE_HERO_PREVIEW_WIDTH = 260;
|
||||||
|
export const PROFILE_HERO_CONTENT_HEIGHT = 235;
|
||||||
|
export const PROFILE_HERO_PREVIEW_EXTRA_HEIGHT = Math.round(PROFILE_HERO_CONTENT_HEIGHT * 0.5);
|
||||||
|
export const PROFILE_HERO_PREVIEW_HEIGHT = PROFILE_HERO_CONTENT_HEIGHT + PROFILE_HERO_PREVIEW_EXTRA_HEIGHT;
|
||||||
|
export const PROFILE_HERO_PREVIEW_ASPECT_RATIO = `${PROFILE_HERO_PREVIEW_WIDTH} / ${PROFILE_HERO_PREVIEW_HEIGHT}`;
|
||||||
|
|
||||||
|
export const CollectibleGroupGrid = style({
|
||||||
|
display: 'grid',
|
||||||
|
width: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CollectibleGroupGridNameplate = style({
|
||||||
|
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
|
||||||
|
gap: toRem(3),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CollectibleGroupGridEffects = style({
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))',
|
||||||
|
gap: toRem(12),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CollectibleGroupGridDecorations = style({
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||||
|
gap: toRem(12),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CollectibleGroupCard = style({
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: toRem(4),
|
||||||
|
width: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CollectibleGroupPreview = style({
|
||||||
|
position: 'relative',
|
||||||
|
display: 'block',
|
||||||
|
width: '100%',
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
overflow: 'visible',
|
||||||
|
background: 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CollectibleGroupPreviewDisabled = style({
|
||||||
|
cursor: 'wait',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CollectibleGroupPreviewDimmed = style({
|
||||||
|
opacity: 0.5,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CollectiblePreviewFrame = style({
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: toRem(6),
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: color.Surface.ContainerLine,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CollectiblePreviewFrameDecoration = style({
|
||||||
|
overflow: 'visible',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CollectiblePreviewFrameProfileEffect = style({
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
alignItems: 'stretch',
|
||||||
|
justifyContent: 'stretch',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ProfileEffectHeroPreview = style({
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ProfileEffectHeroCover = style({
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
height: `${(140 / PROFILE_HERO_PREVIEW_HEIGHT) * 100}%`,
|
||||||
|
overflow: 'hidden',
|
||||||
|
zIndex: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ProfileEffectHeroBanner = style({
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
display: 'block',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ProfileEffectHeroCoverBlur = style({
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
filter: 'blur(16px)',
|
||||||
|
transform: 'scale(2)',
|
||||||
|
display: 'block',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ProfileEffectHeroAvatarSlot = style({
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: `${(140 / PROFILE_HERO_PREVIEW_HEIGHT) * 100}%`,
|
||||||
|
height: `${(29 / PROFILE_HERO_PREVIEW_HEIGHT) * 100}%`,
|
||||||
|
zIndex: 4,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ProfileEffectHeroAvatar = style({
|
||||||
|
position: 'absolute',
|
||||||
|
left: '50%',
|
||||||
|
top: 0,
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
width: `${(72 / PROFILE_HERO_PREVIEW_WIDTH) * 100}%`,
|
||||||
|
aspectRatio: '1',
|
||||||
|
borderRadius: '50%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: `${toRem(1)} solid ${color.Surface.Container}`,
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ProfileEffectHeroAvatarImg = style({
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'block',
|
||||||
|
objectFit: 'cover',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ProfileEffectHeroInfo = style({
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: `${((140 + 29) / PROFILE_HERO_PREVIEW_HEIGHT) * 100}%`,
|
||||||
|
marginTop: `${(-36 / PROFILE_HERO_PREVIEW_HEIGHT) * 100}%`,
|
||||||
|
paddingTop: `${(44 / PROFILE_HERO_PREVIEW_HEIGHT) * 100}%`,
|
||||||
|
paddingLeft: `${(16 / PROFILE_HERO_PREVIEW_WIDTH) * 100}%`,
|
||||||
|
paddingRight: `${(16 / PROFILE_HERO_PREVIEW_WIDTH) * 100}%`,
|
||||||
|
paddingBottom: `${(16 / PROFILE_HERO_PREVIEW_HEIGHT) * 100}%`,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: `${(8 / PROFILE_HERO_PREVIEW_HEIGHT) * 100}%`,
|
||||||
|
textAlign: 'center',
|
||||||
|
zIndex: 4,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ProfileEffectHeroName = style({
|
||||||
|
width: '100%',
|
||||||
|
fontSize: toRem(11),
|
||||||
|
lineHeight: 1.2,
|
||||||
|
fontWeight: 600,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ProfileEffectHeroEffectLayer = style({
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 10,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
overflow: 'visible',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ProfileEffectHeroEffect = style({
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: '450 / 880',
|
||||||
|
objectFit: 'contain',
|
||||||
|
objectPosition: 'top center',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const NameplatePreview = style({
|
||||||
|
display: 'block',
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: NAMEPLATE_ASPECT_RATIO,
|
||||||
|
objectFit: 'cover',
|
||||||
|
objectPosition: 'center',
|
||||||
|
borderRadius: toRem(4),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const NameplatePreviewFallback = style({
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: NAMEPLATE_ASPECT_RATIO,
|
||||||
|
borderRadius: toRem(4),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CollectibleHoverCaption = style({
|
||||||
|
position: 'absolute',
|
||||||
|
top: `calc(100% + ${toRem(2)})`,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: toRem(1),
|
||||||
|
padding: `${toRem(4)} ${toRem(6)}`,
|
||||||
|
background: color.Surface.Container,
|
||||||
|
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||||
|
borderRadius: toRem(4),
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.12)',
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity 0.15s ease',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 2,
|
||||||
|
selectors: {
|
||||||
|
[`${CollectibleGroupPreview}:hover &`]: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
[`${CollectibleGroupPreview}:focus-visible &`]: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CollectibleHoverLabel = style({
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
fontSize: toRem(12),
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: 1.3,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CollectibleHoverMeta = style({
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
fontSize: toRem(10),
|
||||||
|
lineHeight: 1.3,
|
||||||
|
opacity: 0.7,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const VariantRow = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: toRem(4),
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: `0 ${toRem(2)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const VariantChip = style({
|
||||||
|
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||||
|
borderRadius: toRem(4),
|
||||||
|
padding: 0,
|
||||||
|
background: color.Surface.Container,
|
||||||
|
cursor: 'pointer',
|
||||||
|
overflow: 'hidden',
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const VariantChipSelected = style({
|
||||||
|
border: `${toRem(2)} solid ${color.Primary.Main}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const VariantChipNameplate = style({
|
||||||
|
width: toRem(28),
|
||||||
|
height: toRem(10),
|
||||||
|
borderRadius: toRem(3),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const VariantChipThumbnail = style({
|
||||||
|
width: toRem(28),
|
||||||
|
height: toRem(28),
|
||||||
|
objectFit: 'cover',
|
||||||
|
display: 'block',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const VariantChipDecorationStack = style({
|
||||||
|
position: 'relative',
|
||||||
|
width: toRem(28),
|
||||||
|
height: toRem(28),
|
||||||
|
display: 'block',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const VariantChipDecorationAvatar = style({
|
||||||
|
width: '62.5%',
|
||||||
|
height: '62.5%',
|
||||||
|
borderRadius: '50%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'absolute',
|
||||||
|
left: '50%',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DecorationPreviewAvatar = style({
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'block',
|
||||||
|
objectFit: 'cover',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GridPreviewMedia = style({
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'contain',
|
||||||
|
objectPosition: 'top center',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GridPreviewMediaCover = style({
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
objectPosition: 'top center',
|
||||||
|
});
|
||||||
613
src/app/features/settings/account/CollectiblesSection.tsx
Normal file
613
src/app/features/settings/account/CollectiblesSection.tsx
Normal file
@@ -0,0 +1,613 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { Box, Button, Input, Spinner, Text, color, toRem } from 'folds';
|
||||||
|
import { Icon, Icons } from '../../../components/icons';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useUserProfile } from '../../../hooks/useUserProfile';
|
||||||
|
import { useUserBanner } from '../../../hooks/useUserBanner';
|
||||||
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
|
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
|
import { UserAvatar } from '../../../components/user-avatar';
|
||||||
|
import {
|
||||||
|
CollectibleKind,
|
||||||
|
CollectibleVariantGroup,
|
||||||
|
DiscordCollectibleItem,
|
||||||
|
collectibleKindLabel,
|
||||||
|
defaultPreviewAspectRatio,
|
||||||
|
groupCollectibleItems,
|
||||||
|
isElectronCollectiblesAvailable,
|
||||||
|
uploadCollectibleToMatrix,
|
||||||
|
variantItemLabel,
|
||||||
|
} from '../../../utils/discordCollectibles';
|
||||||
|
import { nameplatePaletteGradient } from '../../../utils/discordNameplatePalettes';
|
||||||
|
import {
|
||||||
|
AVATAR_DECORATION_INNER_PERCENT,
|
||||||
|
AvatarDecorationOverlay,
|
||||||
|
} from '../../../styles/AvatarDecoration.css';
|
||||||
|
import { pickCatalogStaticPreviewUrl } from '../../../utils/collectibleAssets';
|
||||||
|
import * as css from './CollectiblesSection.css';
|
||||||
|
import { CollectiblePreviewMedia } from './CollectiblePreviewMedia';
|
||||||
|
import { ProfileEffectHeroPreview } from './ProfileEffectHeroPreview';
|
||||||
|
import { StoredCollectible, CollectibleProfileFields } from '../../../utils/profileFields';
|
||||||
|
|
||||||
|
type CollectiblesSectionProps = {
|
||||||
|
collectibles: CollectibleProfileFields;
|
||||||
|
onApply: (kind: CollectibleKind, collectible: StoredCollectible | undefined) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_TABS: CollectibleKind[] = ['profile_effect', 'nameplate', 'avatar_decoration'];
|
||||||
|
|
||||||
|
const KIND_FIELD: Record<CollectibleKind, keyof CollectibleProfileFields> = {
|
||||||
|
profile_effect: 'profile_effect',
|
||||||
|
nameplate: 'nameplate',
|
||||||
|
avatar_decoration: 'avatar_decoration',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getItemAspectRatio(item: DiscordCollectibleItem): number {
|
||||||
|
return item.previewAspectRatio ?? defaultPreviewAspectRatio(item.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollectiblePreview({
|
||||||
|
item,
|
||||||
|
kind,
|
||||||
|
previewAvatarUrl,
|
||||||
|
previewBannerUrl,
|
||||||
|
previewDisplayName,
|
||||||
|
previewUserId,
|
||||||
|
restartToken,
|
||||||
|
}: {
|
||||||
|
item: DiscordCollectibleItem;
|
||||||
|
kind: CollectibleKind;
|
||||||
|
previewAvatarUrl?: string;
|
||||||
|
previewBannerUrl?: string;
|
||||||
|
previewDisplayName?: string;
|
||||||
|
previewUserId: string;
|
||||||
|
restartToken: number;
|
||||||
|
}) {
|
||||||
|
if (kind === 'nameplate') {
|
||||||
|
const swatchBackground =
|
||||||
|
item.previewGradient || nameplatePaletteGradient(item.palette) || color.Surface.ContainerLine;
|
||||||
|
|
||||||
|
if (item.thumbnailUrl || item.assets.length > 0) {
|
||||||
|
return (
|
||||||
|
<CollectiblePreviewMedia
|
||||||
|
item={item}
|
||||||
|
className={css.NameplatePreview}
|
||||||
|
restartToken={restartToken}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={css.NameplatePreviewFallback} style={{ background: swatchBackground }} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'avatar_decoration') {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: `${AVATAR_DECORATION_INNER_PERCENT}%`,
|
||||||
|
aspectRatio: '1',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: '50%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
className={css.DecorationPreviewAvatar}
|
||||||
|
userId={previewUserId}
|
||||||
|
src={previewAvatarUrl}
|
||||||
|
alt=""
|
||||||
|
renderFallback={() => <Icon size="300" src={Icons.User} filled />}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<CollectiblePreviewMedia
|
||||||
|
item={item}
|
||||||
|
className={AvatarDecorationOverlay}
|
||||||
|
restartToken={restartToken}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'profile_effect') {
|
||||||
|
return (
|
||||||
|
<ProfileEffectHeroPreview
|
||||||
|
item={item}
|
||||||
|
userId={previewUserId}
|
||||||
|
avatarUrl={previewAvatarUrl}
|
||||||
|
bannerUrl={previewBannerUrl}
|
||||||
|
displayName={previewDisplayName}
|
||||||
|
restartToken={restartToken}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VariantChip({
|
||||||
|
item,
|
||||||
|
kind,
|
||||||
|
selected,
|
||||||
|
previewAvatarUrl,
|
||||||
|
previewUserId,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
item: DiscordCollectibleItem;
|
||||||
|
kind: CollectibleKind;
|
||||||
|
selected: boolean;
|
||||||
|
previewAvatarUrl?: string;
|
||||||
|
previewUserId: string;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
const label = variantItemLabel(item);
|
||||||
|
|
||||||
|
if (kind === 'nameplate') {
|
||||||
|
const swatchBackground =
|
||||||
|
item.previewGradient || nameplatePaletteGradient(item.palette) || color.Surface.ContainerLine;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={classNames(css.VariantChip, selected && css.VariantChipSelected)}
|
||||||
|
onClick={onSelect}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
aria-pressed={selected}
|
||||||
|
>
|
||||||
|
<div className={css.VariantChipNameplate} style={{ background: swatchBackground }} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'avatar_decoration') {
|
||||||
|
const thumbUrl = pickCatalogStaticPreviewUrl(item);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={classNames(css.VariantChip, selected && css.VariantChipSelected)}
|
||||||
|
onClick={onSelect}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
aria-pressed={selected}
|
||||||
|
>
|
||||||
|
<div className={css.VariantChipDecorationStack}>
|
||||||
|
<div className={css.VariantChipDecorationAvatar}>
|
||||||
|
<UserAvatar
|
||||||
|
className={css.DecorationPreviewAvatar}
|
||||||
|
userId={previewUserId}
|
||||||
|
src={previewAvatarUrl}
|
||||||
|
alt=""
|
||||||
|
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{thumbUrl && (
|
||||||
|
<img
|
||||||
|
className={AvatarDecorationOverlay}
|
||||||
|
src={thumbUrl}
|
||||||
|
alt=""
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbUrl = pickCatalogStaticPreviewUrl(item);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={classNames(css.VariantChip, selected && css.VariantChipSelected)}
|
||||||
|
onClick={onSelect}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
aria-pressed={selected}
|
||||||
|
>
|
||||||
|
{thumbUrl ? (
|
||||||
|
<img className={css.VariantChipThumbnail} src={thumbUrl} alt="" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<div className={css.VariantChipThumbnail} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollectibleVariantGroupCard({
|
||||||
|
group,
|
||||||
|
kind,
|
||||||
|
selectedItem,
|
||||||
|
onSelectItem,
|
||||||
|
applyingId,
|
||||||
|
onApply,
|
||||||
|
previewAvatarUrl,
|
||||||
|
previewBannerUrl,
|
||||||
|
previewDisplayName,
|
||||||
|
previewUserId,
|
||||||
|
}: {
|
||||||
|
group: CollectibleVariantGroup;
|
||||||
|
kind: CollectibleKind;
|
||||||
|
selectedItem: DiscordCollectibleItem;
|
||||||
|
onSelectItem: (item: DiscordCollectibleItem) => void;
|
||||||
|
applyingId?: string;
|
||||||
|
onApply: (item: DiscordCollectibleItem) => void;
|
||||||
|
previewAvatarUrl?: string;
|
||||||
|
previewBannerUrl?: string;
|
||||||
|
previewDisplayName?: string;
|
||||||
|
previewUserId: string;
|
||||||
|
}) {
|
||||||
|
const aspectRatio =
|
||||||
|
kind === 'profile_effect'
|
||||||
|
? css.PROFILE_HERO_PREVIEW_ASPECT_RATIO
|
||||||
|
: String(getItemAspectRatio(selectedItem));
|
||||||
|
const meta = [variantItemLabel(selectedItem), group.category].filter(Boolean).join(' · ');
|
||||||
|
const [restartToken, setRestartToken] = useState(0);
|
||||||
|
|
||||||
|
const handlePreviewHover = () => {
|
||||||
|
setRestartToken((token) => token + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={css.CollectibleGroupCard}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={classNames(
|
||||||
|
css.CollectibleGroupPreview,
|
||||||
|
applyingId && css.CollectibleGroupPreviewDisabled,
|
||||||
|
applyingId && applyingId !== selectedItem.id && css.CollectibleGroupPreviewDimmed
|
||||||
|
)}
|
||||||
|
onMouseEnter={handlePreviewHover}
|
||||||
|
onClick={() => onApply(selectedItem)}
|
||||||
|
disabled={Boolean(applyingId)}
|
||||||
|
title={selectedItem.label}
|
||||||
|
aria-label={selectedItem.label}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
css.CollectiblePreviewFrame,
|
||||||
|
kind === 'avatar_decoration' && css.CollectiblePreviewFrameDecoration,
|
||||||
|
kind === 'profile_effect' && css.CollectiblePreviewFrameProfileEffect
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
aspectRatio: kind === 'nameplate' ? undefined : aspectRatio,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CollectiblePreview
|
||||||
|
item={selectedItem}
|
||||||
|
kind={kind}
|
||||||
|
previewAvatarUrl={previewAvatarUrl}
|
||||||
|
previewBannerUrl={previewBannerUrl}
|
||||||
|
previewDisplayName={previewDisplayName}
|
||||||
|
previewUserId={previewUserId}
|
||||||
|
restartToken={restartToken}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={css.CollectibleHoverCaption}>
|
||||||
|
<span className={css.CollectibleHoverLabel}>{selectedItem.label}</span>
|
||||||
|
<span className={css.CollectibleHoverMeta}>{meta}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{group.items.length > 1 && (
|
||||||
|
<div className={css.VariantRow}>
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<VariantChip
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
kind={kind}
|
||||||
|
selected={item.skuId === selectedItem.skuId}
|
||||||
|
previewAvatarUrl={previewAvatarUrl}
|
||||||
|
previewUserId={previewUserId}
|
||||||
|
onSelect={() => onSelectItem(item)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CollectiblesSection({ collectibles, onApply }: CollectiblesSectionProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const userId = mx.getUserId() ?? '';
|
||||||
|
const profile = useUserProfile(userId);
|
||||||
|
const [bannerMxc] = useUserBanner();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const previewAvatarUrl = useMemo(
|
||||||
|
() =>
|
||||||
|
profile.avatarUrl
|
||||||
|
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||||
|
: undefined,
|
||||||
|
[mx, profile.avatarUrl, useAuthentication]
|
||||||
|
);
|
||||||
|
const previewBannerUrl = useMemo(
|
||||||
|
() =>
|
||||||
|
bannerMxc ? mxcUrlToHttp(mx, bannerMxc, useAuthentication) ?? undefined : undefined,
|
||||||
|
[bannerMxc, mx, useAuthentication]
|
||||||
|
);
|
||||||
|
const previewDisplayName = profile.displayName;
|
||||||
|
const [kind, setKind] = useState<CollectibleKind>('profile_effect');
|
||||||
|
const [hasToken, setHasToken] = useState(false);
|
||||||
|
const [tokenInput, setTokenInput] = useState('');
|
||||||
|
const [tokenSaving, setTokenSaving] = useState(false);
|
||||||
|
const [catalogLoading, setCatalogLoading] = useState(false);
|
||||||
|
const [catalogError, setCatalogError] = useState<string>();
|
||||||
|
const [items, setItems] = useState<DiscordCollectibleItem[]>([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [applyingId, setApplyingId] = useState<string>();
|
||||||
|
const [applyProgress, setApplyProgress] = useState<string>();
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const [selectedByGroup, setSelectedByGroup] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const desktopAvailable = isElectronCollectiblesAvailable();
|
||||||
|
|
||||||
|
const refreshTokenState = useCallback(async () => {
|
||||||
|
if (!desktopAvailable) return;
|
||||||
|
const result = await window.electron!.discordCollectibles!.hasToken();
|
||||||
|
setHasToken(Boolean(result?.data));
|
||||||
|
}, [desktopAvailable]);
|
||||||
|
|
||||||
|
const loadCatalog = useCallback(async (force = false) => {
|
||||||
|
if (!desktopAvailable) return;
|
||||||
|
setCatalogLoading(true);
|
||||||
|
setCatalogError(undefined);
|
||||||
|
try {
|
||||||
|
const result = await window.electron!.discordCollectibles!.fetchCatalog(force);
|
||||||
|
if (!result?.success || !result.data?.items) {
|
||||||
|
throw new Error(result?.error || 'Failed to load Discord catalog.');
|
||||||
|
}
|
||||||
|
setItems(result.data.items as DiscordCollectibleItem[]);
|
||||||
|
} catch (e) {
|
||||||
|
setCatalogError(e instanceof Error ? e.message : 'Failed to load catalog.');
|
||||||
|
setItems([]);
|
||||||
|
}
|
||||||
|
setCatalogLoading(false);
|
||||||
|
}, [desktopAvailable]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshTokenState();
|
||||||
|
}, [refreshTokenState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasToken) {
|
||||||
|
loadCatalog();
|
||||||
|
}
|
||||||
|
}, [hasToken, loadCatalog]);
|
||||||
|
|
||||||
|
const current = collectibles[KIND_FIELD[kind]];
|
||||||
|
|
||||||
|
const filteredGroups = useMemo(() => {
|
||||||
|
const query = search.trim().toLowerCase();
|
||||||
|
const filtered = items
|
||||||
|
.filter((item) => item.type === kind)
|
||||||
|
.filter((item) => {
|
||||||
|
if (!query) return true;
|
||||||
|
return (
|
||||||
|
item.name.toLowerCase().includes(query) ||
|
||||||
|
item.label.toLowerCase().includes(query) ||
|
||||||
|
item.category.toLowerCase().includes(query) ||
|
||||||
|
variantItemLabel(item).toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return groupCollectibleItems(filtered);
|
||||||
|
}, [items, kind, search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedByGroup((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const group of filteredGroups) {
|
||||||
|
if (!next[group.id] || !group.items.some((item) => item.skuId === next[group.id])) {
|
||||||
|
next[group.id] = group.items[0]?.skuId ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [filteredGroups]);
|
||||||
|
|
||||||
|
const handleSaveToken = async () => {
|
||||||
|
if (!desktopAvailable) return;
|
||||||
|
setTokenSaving(true);
|
||||||
|
setCatalogError(undefined);
|
||||||
|
try {
|
||||||
|
await window.electron!.discordCollectibles!.setToken(tokenInput);
|
||||||
|
setTokenInput('');
|
||||||
|
await refreshTokenState();
|
||||||
|
} catch (e) {
|
||||||
|
setCatalogError(e instanceof Error ? e.message : 'Failed to save token.');
|
||||||
|
}
|
||||||
|
setTokenSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearToken = async () => {
|
||||||
|
if (!desktopAvailable) return;
|
||||||
|
await window.electron!.discordCollectibles!.clearToken();
|
||||||
|
setItems([]);
|
||||||
|
await refreshTokenState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = async (item: DiscordCollectibleItem) => {
|
||||||
|
setApplyingId(item.id);
|
||||||
|
setError(undefined);
|
||||||
|
setApplyProgress(undefined);
|
||||||
|
try {
|
||||||
|
const stored = await uploadCollectibleToMatrix(mx, item, setApplyProgress);
|
||||||
|
await onApply(item.type, stored);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to apply collectible.');
|
||||||
|
}
|
||||||
|
setApplyingId(undefined);
|
||||||
|
setApplyProgress(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async () => {
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
await onApply(kind, undefined);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to remove collectible.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const gridClassName = classNames(
|
||||||
|
css.CollectibleGroupGrid,
|
||||||
|
kind === 'nameplate' && css.CollectibleGroupGridNameplate,
|
||||||
|
kind === 'profile_effect' && css.CollectibleGroupGridEffects,
|
||||||
|
kind === 'avatar_decoration' && css.CollectibleGroupGridDecorations
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!desktopAvailable) {
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Text size="T200" style={{ opacity: 0.8 }}>
|
||||||
|
Discord profile overlays are available in the Paarrot desktop app. Assets are fetched live from
|
||||||
|
Discord and uploaded to your Matrix profile when you pick one.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="300">
|
||||||
|
<Text size="T200" style={{ opacity: 0.8 }}>
|
||||||
|
Browse Discord shop collectibles live, then download and upload only what you pick to Matrix.
|
||||||
|
Your Discord token stays on this device and is only used to list the shop catalog.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{!hasToken ? (
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Text size="T300">Discord token (required to list the shop)</Text>
|
||||||
|
<Input
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
type="password"
|
||||||
|
placeholder="User token or Bot xxxxx"
|
||||||
|
value={tokenInput}
|
||||||
|
onChange={(e) => setTokenInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Primary"
|
||||||
|
fill="Solid"
|
||||||
|
radii="300"
|
||||||
|
onClick={handleSaveToken}
|
||||||
|
disabled={tokenSaving || !tokenInput.trim()}
|
||||||
|
>
|
||||||
|
<Text size="B300">{tokenSaving ? 'Saving…' : 'Save token'}</Text>
|
||||||
|
</Button>
|
||||||
|
{catalogError && <Text size="T200" style={{ color: color.Critical.Main }}>{catalogError}</Text>}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box gap="200" alignItems="Center" wrap="Wrap">
|
||||||
|
<Text size="T200" style={{ opacity: 0.8 }}>Discord token saved on this device.</Text>
|
||||||
|
<Button size="300" variant="Secondary" fill="Soft" radii="300" onClick={() => loadCatalog(true)} disabled={catalogLoading}>
|
||||||
|
<Text size="B300">{catalogLoading ? 'Refreshing…' : 'Refresh catalog'}</Text>
|
||||||
|
</Button>
|
||||||
|
<Button size="300" variant="Critical" fill="Soft" radii="300" onClick={handleClearToken}>
|
||||||
|
<Text size="B300">Remove token</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasToken && (
|
||||||
|
<>
|
||||||
|
<Box gap="200" wrap="Wrap">
|
||||||
|
{KIND_TABS.map((tab) => (
|
||||||
|
<Button
|
||||||
|
key={tab}
|
||||||
|
size="300"
|
||||||
|
variant={tab === kind ? 'Primary' : 'Secondary'}
|
||||||
|
fill={tab === kind ? 'Solid' : 'Soft'}
|
||||||
|
radii="300"
|
||||||
|
onClick={() => setKind(tab)}
|
||||||
|
>
|
||||||
|
<Text size="B300">{collectibleKindLabel(tab)}</Text>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder={`Search ${collectibleKindLabel(kind).toLowerCase()}s…`}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{current && (
|
||||||
|
<Box gap="200" alignItems="Center" wrap="Wrap">
|
||||||
|
<Text size="T300">Active: {current.name}</Text>
|
||||||
|
<Button size="300" variant="Critical" fill="Soft" radii="300" onClick={handleRemove}>
|
||||||
|
<Text size="B300">Remove</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{catalogLoading && (
|
||||||
|
<Box gap="200" alignItems="Center">
|
||||||
|
<Spinner size="100" variant="Secondary" />
|
||||||
|
<Text size="T200">Loading catalog from Discord…</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{catalogError && !catalogLoading && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>{catalogError}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{applyProgress && (
|
||||||
|
<Box gap="200" alignItems="Center">
|
||||||
|
<Spinner size="100" variant="Secondary" />
|
||||||
|
<Text size="T200">{applyProgress}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <Text size="T200" style={{ color: color.Critical.Main }}>{error}</Text>}
|
||||||
|
|
||||||
|
<div className={gridClassName}>
|
||||||
|
{filteredGroups.map((group) => {
|
||||||
|
const selectedSku = selectedByGroup[group.id] ?? group.items[0]?.skuId;
|
||||||
|
const selectedItem =
|
||||||
|
group.items.find((item) => item.skuId === selectedSku) ?? group.items[0];
|
||||||
|
if (!selectedItem) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollectibleVariantGroupCard
|
||||||
|
key={group.id}
|
||||||
|
group={group}
|
||||||
|
kind={kind}
|
||||||
|
selectedItem={selectedItem}
|
||||||
|
onSelectItem={(item) =>
|
||||||
|
setSelectedByGroup((prev) => ({ ...prev, [group.id]: item.skuId }))
|
||||||
|
}
|
||||||
|
applyingId={applyingId}
|
||||||
|
onApply={handleApply}
|
||||||
|
previewAvatarUrl={previewAvatarUrl}
|
||||||
|
previewBannerUrl={previewBannerUrl}
|
||||||
|
previewDisplayName={previewDisplayName}
|
||||||
|
previewUserId={userId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!catalogLoading && filteredGroups.length === 0 && (
|
||||||
|
<Text size="T200" style={{ opacity: 0.7 }}>No items match your search.</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -37,6 +37,10 @@ import { AvatarPresence, PresenceBadge } from '../../../components/presence';
|
|||||||
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
|
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
|
||||||
import colorMXID, { getColorMXIDValue } from '../../../../util/colorMXID';
|
import colorMXID, { getColorMXIDValue } from '../../../../util/colorMXID';
|
||||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
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 {
|
import {
|
||||||
ColorPreference,
|
ColorPreference,
|
||||||
hasColorPreference,
|
hasColorPreference,
|
||||||
@@ -53,6 +57,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
const profile = useUserProfile(userId);
|
const profile = useUserProfile(userId);
|
||||||
const presence = useUserPresence(userId);
|
const presence = useUserPresence(userId);
|
||||||
const [userBanner, setUserBanner, loading] = useUserBanner();
|
const [userBanner, setUserBanner, loading] = useUserBanner();
|
||||||
|
const [collectibles, updateCollectible] = useUserCollectibles();
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const [hoveredArea, setHoveredArea] = useState<string | null>(null);
|
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
|
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||||
: 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
|
// Larger avatar URL for the blurred cover fallback
|
||||||
const avatarCoverUrl = profile.avatarUrl
|
const avatarCoverUrl = profile.avatarUrl
|
||||||
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication) ?? undefined
|
? 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);
|
resolveColorForTheme(previewPreference, theme.kind) || getColorMXIDValue(userId, theme.kind === ThemeKind.Dark);
|
||||||
const previewTextShadow = `0 1px 4px ${getTextShadowColor(previewProfileColor)}`;
|
const previewTextShadow = `0 1px 4px ${getTextShadowColor(previewProfileColor)}`;
|
||||||
const hasSavedColors = hasColorPreference(colorPreference);
|
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 (
|
return (
|
||||||
<Box direction="Column" gap="300">
|
<Box direction="Column" gap="300" style={{ width: '100%', alignItems: 'center' }}>
|
||||||
<Box
|
<Box
|
||||||
direction="Column"
|
direction="Column"
|
||||||
style={{
|
style={{
|
||||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||||
borderRadius: toRem(8),
|
borderRadius: toRem(8),
|
||||||
width: toRem(340),
|
width: '60%',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
@@ -521,14 +550,10 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
onMouseLeave={() => setHoveredArea(null)}
|
onMouseLeave={() => setHoveredArea(null)}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
as="button"
|
|
||||||
onClick={handleAvatarClick}
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: color.Surface.Container,
|
position: 'relative',
|
||||||
border: 'none',
|
width: toRem(76),
|
||||||
padding: 0,
|
height: toRem(76),
|
||||||
cursor: 'pointer',
|
|
||||||
borderRadius: '50%',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AvatarPresence
|
<AvatarPresence
|
||||||
@@ -538,21 +563,48 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Box
|
||||||
size="500"
|
as="button"
|
||||||
|
onClick={handleAvatarClick}
|
||||||
style={{
|
style={{
|
||||||
width: toRem(72),
|
position: 'relative',
|
||||||
height: toRem(72),
|
width: '100%',
|
||||||
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
|
height: '100%',
|
||||||
|
backgroundColor: avatarUrl ? 'transparent' : color.Surface.Container,
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '50%',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<UserAvatar
|
<Avatar
|
||||||
userId={userId}
|
size="500"
|
||||||
src={avatarUrl}
|
style={{
|
||||||
alt={userId}
|
width: toRem(76),
|
||||||
renderFallback={() => <Icon size="500" src={Icons.User} filled />}
|
height: toRem(76),
|
||||||
/>
|
...(avatarUrl
|
||||||
</Avatar>
|
? { 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>
|
</AvatarPresence>
|
||||||
</Box>
|
</Box>
|
||||||
{/* Avatar action icons - shown on hover */}
|
{/* Avatar action icons - shown on hover */}
|
||||||
@@ -845,67 +897,100 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
direction="Column"
|
direction="Column"
|
||||||
gap="300"
|
gap="300"
|
||||||
style={{
|
style={{
|
||||||
padding: config.space.S300,
|
padding: config.space.S400,
|
||||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||||
borderRadius: toRem(8),
|
borderRadius: toRem(8),
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="H6">Username colors</Text>
|
<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="Row" gap="400" wrap="Wrap" alignItems="Start">
|
||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200" style={{ flex: '1 1 0', minWidth: toRem(140) }}>
|
||||||
<Text size="T300">On dark themes</Text>
|
<Text size="T300">On dark themes</Text>
|
||||||
<Text size="T200" style={{ opacity: 0.7 }}>Bright colors work best</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
|
<Input
|
||||||
size="300"
|
size="300"
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
style={{ width: toRem(120) }}
|
style={{ width: '100%' }}
|
||||||
value={localOnDark}
|
value={localOnDark}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setLocalOnDark(e.target.value);
|
setLocalOnDark(e.target.value);
|
||||||
setColorError(undefined);
|
setColorError(undefined);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
width: toRem(48),
|
|
||||||
height: toRem(48),
|
|
||||||
borderRadius: toRem(8),
|
|
||||||
backgroundColor: localOnDark,
|
|
||||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
</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="T300">On light themes</Text>
|
||||||
<Text size="T200" style={{ opacity: 0.7 }}>Darker colors work best</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
|
<Input
|
||||||
size="300"
|
size="300"
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
style={{ width: toRem(120) }}
|
style={{ width: '100%' }}
|
||||||
value={localOnLight}
|
value={localOnLight}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setLocalOnLight(e.target.value);
|
setLocalOnLight(e.target.value);
|
||||||
setColorError(undefined);
|
setColorError(undefined);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
width: toRem(48),
|
|
||||||
height: toRem(48),
|
|
||||||
borderRadius: toRem(8),
|
|
||||||
backgroundColor: localOnLight,
|
|
||||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -939,6 +1024,21 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</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 && (
|
{uploadAtom && (
|
||||||
<Box gap="200" direction="Column" style={{ width: '100%' }}>
|
<Box gap="200" direction="Column" style={{ width: '100%' }}>
|
||||||
<CompactUploadCardRenderer
|
<CompactUploadCardRenderer
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Text } from 'folds';
|
||||||
|
import { UserAvatar } from '../../../components/user-avatar';
|
||||||
|
import { Icon, Icons } from '../../../components/icons';
|
||||||
|
import colorMXID from '../../../../util/colorMXID';
|
||||||
|
import { getMxIdLocalPart } from '../../../utils/matrix';
|
||||||
|
import { DiscordCollectibleItem } from '../../../utils/discordCollectibles';
|
||||||
|
import {
|
||||||
|
catalogProfileEffectIntroDurationMs,
|
||||||
|
pickCatalogProfileEffectIntroUrl,
|
||||||
|
pickCatalogProfileEffectLoopUrl,
|
||||||
|
} from '../../../utils/collectibleAssets';
|
||||||
|
import { ProfileEffectMedia } from '../../../components/user-profile/ProfileEffectMedia';
|
||||||
|
import * as css from './CollectiblesSection.css';
|
||||||
|
|
||||||
|
type ProfileEffectHeroPreviewProps = {
|
||||||
|
item: DiscordCollectibleItem;
|
||||||
|
userId: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
bannerUrl?: string;
|
||||||
|
displayName?: string;
|
||||||
|
profileColor?: string;
|
||||||
|
restartToken?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProfileEffectHeroPreview({
|
||||||
|
item,
|
||||||
|
userId,
|
||||||
|
avatarUrl,
|
||||||
|
bannerUrl,
|
||||||
|
displayName,
|
||||||
|
profileColor,
|
||||||
|
restartToken,
|
||||||
|
}: ProfileEffectHeroPreviewProps) {
|
||||||
|
const coverColor = colorMXID(userId);
|
||||||
|
const nameColor = profileColor ?? coverColor;
|
||||||
|
const username = getMxIdLocalPart(userId) ?? userId;
|
||||||
|
const resolvedName = displayName?.trim() || username;
|
||||||
|
const introUrl = pickCatalogProfileEffectIntroUrl(item);
|
||||||
|
const loopUrl = pickCatalogProfileEffectLoopUrl(item);
|
||||||
|
const introDurationMs = catalogProfileEffectIntroDurationMs(item);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={css.ProfileEffectHeroPreview} aria-hidden="true">
|
||||||
|
<div
|
||||||
|
className={css.ProfileEffectHeroCover}
|
||||||
|
style={{
|
||||||
|
backgroundColor: bannerUrl ? undefined : coverColor,
|
||||||
|
filter: bannerUrl || avatarUrl ? undefined : 'brightness(50%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bannerUrl ? (
|
||||||
|
<img className={css.ProfileEffectHeroBanner} src={bannerUrl} alt="" draggable={false} />
|
||||||
|
) : (
|
||||||
|
avatarUrl && (
|
||||||
|
<img className={css.ProfileEffectHeroCoverBlur} src={avatarUrl} alt="" draggable={false} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={css.ProfileEffectHeroAvatarSlot}>
|
||||||
|
<div className={css.ProfileEffectHeroAvatar}>
|
||||||
|
<UserAvatar
|
||||||
|
className={css.ProfileEffectHeroAvatarImg}
|
||||||
|
userId={userId}
|
||||||
|
src={avatarUrl}
|
||||||
|
alt=""
|
||||||
|
renderFallback={() => <Icon size="300" src={Icons.User} filled />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={css.ProfileEffectHeroInfo}>
|
||||||
|
<Text
|
||||||
|
size="T100"
|
||||||
|
className={css.ProfileEffectHeroName}
|
||||||
|
title={resolvedName}
|
||||||
|
style={{ color: nameColor }}
|
||||||
|
>
|
||||||
|
{resolvedName}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={css.ProfileEffectHeroEffectLayer}>
|
||||||
|
<ProfileEffectMedia
|
||||||
|
introUrl={introUrl}
|
||||||
|
loopUrl={loopUrl}
|
||||||
|
introDurationMs={introDurationMs}
|
||||||
|
restartToken={restartToken}
|
||||||
|
className={css.ProfileEffectHeroEffect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
src/app/hooks/useUserCollectibles.ts
Normal file
158
src/app/hooks/useUserCollectibles.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { useMediaAuthentication } from './useMediaAuthentication';
|
||||||
|
import { mxcUrlToHttp } from '../utils/matrix';
|
||||||
|
import { getCurrentAccessToken } from '../utils/auth';
|
||||||
|
import {
|
||||||
|
CollectibleProfileFields,
|
||||||
|
StoredCollectible,
|
||||||
|
loadUserCollectibles,
|
||||||
|
saveAvatarDecoration,
|
||||||
|
saveNameplate,
|
||||||
|
saveProfileEffect,
|
||||||
|
} from '../utils/profileFields';
|
||||||
|
import { CollectibleKind } from '../utils/discordCollectibles';
|
||||||
|
|
||||||
|
export function useUserCollectibles(): [
|
||||||
|
CollectibleProfileFields,
|
||||||
|
(kind: CollectibleKind, collectible: StoredCollectible | undefined) => Promise<void>,
|
||||||
|
boolean
|
||||||
|
] {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [collectibles, setCollectibles] = useState<CollectibleProfileFields>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const userId = mx.getUserId();
|
||||||
|
if (!userId) {
|
||||||
|
setLoading(false);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await loadUserCollectibles(mx, userId);
|
||||||
|
if (!cancelled) setCollectibles(data);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setCollectibles({});
|
||||||
|
}
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [mx]);
|
||||||
|
|
||||||
|
const updateCollectible = useCallback(
|
||||||
|
async (kind: CollectibleKind, collectible: StoredCollectible | undefined) => {
|
||||||
|
if (kind === 'profile_effect') {
|
||||||
|
await saveProfileEffect(mx, collectible);
|
||||||
|
setCollectibles((prev) => ({ ...prev, profile_effect: collectible }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kind === 'nameplate') {
|
||||||
|
await saveNameplate(mx, collectible);
|
||||||
|
setCollectibles((prev) => ({ ...prev, nameplate: collectible }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await saveAvatarDecoration(mx, collectible);
|
||||||
|
setCollectibles((prev) => ({ ...prev, avatar_decoration: collectible }));
|
||||||
|
},
|
||||||
|
[mx]
|
||||||
|
);
|
||||||
|
|
||||||
|
return [collectibles, updateCollectible, loading];
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectibleCache = new Map<string, { data: CollectibleProfileFields; timestamp: number }>();
|
||||||
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
async function fetchMxcBlobUrl(
|
||||||
|
mx: ReturnType<typeof useMatrixClient>,
|
||||||
|
mxc: string,
|
||||||
|
useAuthentication: boolean
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const httpUrl = mxcUrlToHttp(mx, mxc, useAuthentication);
|
||||||
|
if (!httpUrl) return undefined;
|
||||||
|
|
||||||
|
const accessToken = getCurrentAccessToken();
|
||||||
|
const headers: HeadersInit = {};
|
||||||
|
if (useAuthentication && accessToken) {
|
||||||
|
headers.Authorization = `Bearer ${accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(httpUrl, { headers });
|
||||||
|
if (!response.ok) return undefined;
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOtherUserCollectibles(userId: string): CollectibleProfileFields & {
|
||||||
|
assetUrls: Record<string, string | undefined>;
|
||||||
|
} {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const [collectibles, setCollectibles] = useState<CollectibleProfileFields>({});
|
||||||
|
const [assetUrls, setAssetUrls] = useState<Record<string, string | undefined>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userId) {
|
||||||
|
setCollectibles({});
|
||||||
|
setAssetUrls({});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const blobUrls: string[] = [];
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const cached = collectibleCache.get(userId);
|
||||||
|
let data: CollectibleProfileFields;
|
||||||
|
|
||||||
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
||||||
|
data = cached.data;
|
||||||
|
} else {
|
||||||
|
data = await loadUserCollectibles(mx, userId);
|
||||||
|
collectibleCache.set(userId, { data, timestamp: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
setCollectibles(data);
|
||||||
|
|
||||||
|
const urls: Record<string, string | undefined> = {};
|
||||||
|
const entries = [
|
||||||
|
['profile_effect', data.profile_effect],
|
||||||
|
['nameplate', data.nameplate],
|
||||||
|
['avatar_decoration', data.avatar_decoration],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const [kind, collectible] of entries) {
|
||||||
|
if (!collectible) continue;
|
||||||
|
for (const [role, mxc] of Object.entries(collectible.assets)) {
|
||||||
|
const blobUrl = await fetchMxcBlobUrl(mx, mxc, useAuthentication);
|
||||||
|
if (blobUrl) {
|
||||||
|
blobUrls.push(blobUrl);
|
||||||
|
urls[`${kind}:${role}`] = blobUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cancelled) setAssetUrls(urls);
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
blobUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
};
|
||||||
|
}, [mx, useAuthentication, userId]);
|
||||||
|
|
||||||
|
return { ...collectibles, assetUrls };
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@ import { nameInitials } from '../../../utils/common';
|
|||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { Settings } from '../../../features/settings';
|
import { Settings } from '../../../features/settings';
|
||||||
import { useUserProfile } from '../../../hooks/useUserProfile';
|
import { useUserProfile } from '../../../hooks/useUserProfile';
|
||||||
|
import { useOtherUserCollectibles } from '../../../hooks/useUserCollectibles';
|
||||||
|
import { pickAvatarDecorationUrl } from '../../../utils/collectibleAssets';
|
||||||
|
import * as avatarDecorationCss from '../../../styles/AvatarDecoration.css';
|
||||||
import { Modal500 } from '../../../components/Modal500';
|
import { Modal500 } from '../../../components/Modal500';
|
||||||
import { AccountSwitcher } from '../../../components/account-switcher';
|
import { AccountSwitcher } from '../../../components/account-switcher';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
@@ -34,6 +37,8 @@ export function SettingsTab() {
|
|||||||
const avatarUrl = profile.avatarUrl && userId
|
const avatarUrl = profile.avatarUrl && userId
|
||||||
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
|
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const { assetUrls } = useOtherUserCollectibles(userId ?? '');
|
||||||
|
const avatarDecorationUrl = pickAvatarDecorationUrl(assetUrls);
|
||||||
|
|
||||||
const openSettings = () => setSettings(true);
|
const openSettings = () => setSettings(true);
|
||||||
const closeSettings = () => setSettings(false);
|
const closeSettings = () => setSettings(false);
|
||||||
@@ -50,12 +55,23 @@ export function SettingsTab() {
|
|||||||
as="button"
|
as="button"
|
||||||
onClick={openSettings}
|
onClick={openSettings}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
|
style={{ overflow: 'visible' }}
|
||||||
>
|
>
|
||||||
<UserAvatar
|
<div className={avatarDecorationCss.SidebarAvatarStack}>
|
||||||
userId={userId ?? ''}
|
<UserAvatar
|
||||||
src={avatarUrl}
|
userId={userId ?? ''}
|
||||||
renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>}
|
src={avatarUrl}
|
||||||
/>
|
renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>}
|
||||||
|
/>
|
||||||
|
{avatarDecorationUrl && (
|
||||||
|
<img
|
||||||
|
className={avatarDecorationCss.AvatarDecorationOverlay}
|
||||||
|
src={avatarDecorationUrl}
|
||||||
|
alt=""
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</SidebarAvatar>
|
</SidebarAvatar>
|
||||||
|
|
||||||
<PopOut
|
<PopOut
|
||||||
|
|||||||
29
src/app/styles/AvatarDecoration.css.ts
Normal file
29
src/app/styles/AvatarDecoration.css.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
/** Discord presets are 128px canvases aligned to an ~80px avatar circle. */
|
||||||
|
export const AVATAR_DECORATION_SCALE_PERCENT = 119;
|
||||||
|
|
||||||
|
/** Inner avatar diameter as a fraction of the decoration canvas (80 / 128). */
|
||||||
|
export const AVATAR_DECORATION_INNER_PERCENT = 62.5;
|
||||||
|
|
||||||
|
export const AvatarDecorationOverlay = style({
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
width: `${AVATAR_DECORATION_SCALE_PERCENT}%`,
|
||||||
|
height: `${AVATAR_DECORATION_SCALE_PERCENT}%`,
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
objectFit: 'contain',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SidebarAvatarStack = style({
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
overflow: 'visible',
|
||||||
|
});
|
||||||
102
src/app/utils/collectibleAssets.ts
Normal file
102
src/app/utils/collectibleAssets.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { DiscordCollectibleItem } from './discordCollectibles';
|
||||||
|
|
||||||
|
function introDurationFromEffectMeta(effect: Record<string, unknown> | undefined): number | undefined {
|
||||||
|
if (!effect) return undefined;
|
||||||
|
const raw = effect.duration ?? effect.durationMs;
|
||||||
|
if (typeof raw !== 'number' || raw <= 0) return undefined;
|
||||||
|
return raw > 100 ? raw : raw * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function catalogAssetUrl(item: DiscordCollectibleItem, ...roles: string[]): string | undefined {
|
||||||
|
for (const role of roles) {
|
||||||
|
const asset = item.assets.find((entry) => entry.role === role);
|
||||||
|
if (asset?.url) return asset.url;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickCatalogStaticPreviewUrl(item: DiscordCollectibleItem): string | undefined {
|
||||||
|
switch (item.type) {
|
||||||
|
case 'nameplate':
|
||||||
|
return catalogAssetUrl(item, 'static') ?? item.thumbnailUrl;
|
||||||
|
case 'avatar_decoration':
|
||||||
|
return catalogAssetUrl(item, 'static') ?? item.thumbnailUrl;
|
||||||
|
case 'profile_effect':
|
||||||
|
return catalogAssetUrl(item, 'thumbnail', 'reduced_motion') ?? item.thumbnailUrl;
|
||||||
|
default:
|
||||||
|
return item.thumbnailUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickCatalogAnimatedPreviewUrl(item: DiscordCollectibleItem): string | undefined {
|
||||||
|
switch (item.type) {
|
||||||
|
case 'nameplate':
|
||||||
|
return catalogAssetUrl(item, 'animated');
|
||||||
|
case 'avatar_decoration':
|
||||||
|
return catalogAssetUrl(item, 'animated');
|
||||||
|
case 'profile_effect':
|
||||||
|
return pickCatalogProfileEffectLoopUrl(item);
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickCatalogProfileEffectIntroUrl(item: DiscordCollectibleItem): string | undefined {
|
||||||
|
return catalogAssetUrl(item, 'effect_0');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickCatalogProfileEffectLoopUrl(item: DiscordCollectibleItem): string | undefined {
|
||||||
|
return catalogAssetUrl(item, 'effect_1', 'effect_0', 'effect_2', 'effect_3');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function catalogProfileEffectIntroDurationMs(item: DiscordCollectibleItem): number | undefined {
|
||||||
|
const effect = item.effect?.effects?.[0] as Record<string, unknown> | undefined;
|
||||||
|
return introDurationFromEffectMeta(effect);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickStoredProfileEffectIntroUrl(
|
||||||
|
assetUrls: Record<string, string | undefined>
|
||||||
|
): string | undefined {
|
||||||
|
return assetUrls['profile_effect:effect_0'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickStoredProfileEffectLoopUrl(
|
||||||
|
assetUrls: Record<string, string | undefined>
|
||||||
|
): string | undefined {
|
||||||
|
return (
|
||||||
|
assetUrls['profile_effect:effect_1'] ||
|
||||||
|
assetUrls['profile_effect:effect_0'] ||
|
||||||
|
assetUrls['profile_effect:effect_2'] ||
|
||||||
|
assetUrls['profile_effect:effect_3']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCatalogVideoPreview(url?: string): boolean {
|
||||||
|
return Boolean(url && (url.includes('.webm') || url.includes('asset.webm')));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickNameplateUrl(
|
||||||
|
assetUrls: Record<string, string | undefined>
|
||||||
|
): string | undefined {
|
||||||
|
return (
|
||||||
|
assetUrls['nameplate:animated'] ||
|
||||||
|
assetUrls['nameplate:asset.webm'] ||
|
||||||
|
assetUrls['nameplate:static'] ||
|
||||||
|
assetUrls['nameplate:static.png']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickAvatarDecorationUrl(
|
||||||
|
assetUrls: Record<string, string | undefined>
|
||||||
|
): string | undefined {
|
||||||
|
return assetUrls['avatar_decoration:animated'] || assetUrls['avatar_decoration:animated.png'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickProfileEffectUrl(
|
||||||
|
assetUrls: Record<string, string | undefined>
|
||||||
|
): string | undefined {
|
||||||
|
return (
|
||||||
|
pickStoredProfileEffectLoopUrl(assetUrls) ||
|
||||||
|
assetUrls['profile_effect:reduced_motion']
|
||||||
|
);
|
||||||
|
}
|
||||||
236
src/app/utils/discordCollectibles.ts
Normal file
236
src/app/utils/discordCollectibles.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
import { StoredCollectible } from './profileFields';
|
||||||
|
|
||||||
|
export type CollectibleKind = 'profile_effect' | 'nameplate' | 'avatar_decoration';
|
||||||
|
|
||||||
|
export type DiscordCollectibleAsset = {
|
||||||
|
role: string;
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
mimeType: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DiscordCollectibleItem = {
|
||||||
|
id: string;
|
||||||
|
skuId: string;
|
||||||
|
name: string;
|
||||||
|
type: CollectibleKind;
|
||||||
|
category: string;
|
||||||
|
label: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
previewAspectRatio?: number;
|
||||||
|
previewColors?: number[];
|
||||||
|
previewGradient?: string;
|
||||||
|
palette?: string;
|
||||||
|
paletteLabel?: string;
|
||||||
|
assets: DiscordCollectibleAsset[];
|
||||||
|
effect?: {
|
||||||
|
animationType?: number;
|
||||||
|
thumbnailPreviewSrc?: string;
|
||||||
|
reducedMotionSrc?: string;
|
||||||
|
effects?: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DownloadedCollectibleAsset = DiscordCollectibleAsset & {
|
||||||
|
data: Uint8Array;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CDN_HOST_RE = /(^|\.)(discordapp\.com|discordapp\.net|discord\.com)$/i;
|
||||||
|
|
||||||
|
export function isElectronCollectiblesAvailable(): boolean {
|
||||||
|
return Boolean(window.electron?.discordCollectibles);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCdnUrl(value: unknown): value is string {
|
||||||
|
if (typeof value !== 'string' || !value.startsWith('http')) return false;
|
||||||
|
try {
|
||||||
|
return CDN_HOST_RE.test(new URL(value).hostname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceCdnUrls<T>(value: T, urlToMxc: Map<string, string>): T {
|
||||||
|
if (value == null) return value;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return (isCdnUrl(value) && urlToMxc.has(value) ? urlToMxc.get(value) : value) as T;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((entry) => replaceCdnUrls(entry, urlToMxc)) as T;
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
out[key] = replaceCdnUrls(entry, urlToMxc);
|
||||||
|
}
|
||||||
|
return out as T;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadCollectibleAssets(
|
||||||
|
assets: DiscordCollectibleAsset[]
|
||||||
|
): Promise<DownloadedCollectibleAsset[]> {
|
||||||
|
const api = window.electron?.discordCollectibles;
|
||||||
|
if (!api) {
|
||||||
|
throw new Error('Discord collectibles are only available in the Paarrot desktop app.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.downloadAssets(assets);
|
||||||
|
if (!result?.success || !result.data) {
|
||||||
|
throw new Error(result?.error || 'Failed to download collectible assets from Discord CDN.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data.map((asset) => ({
|
||||||
|
role: asset.role,
|
||||||
|
url: asset.url,
|
||||||
|
filename: asset.filename,
|
||||||
|
mimeType: asset.mimeType,
|
||||||
|
data: asset.data instanceof Uint8Array ? asset.data : new Uint8Array(asset.data),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadCollectibleToMatrix(
|
||||||
|
mx: MatrixClient,
|
||||||
|
item: DiscordCollectibleItem,
|
||||||
|
onProgress?: (message: string) => void
|
||||||
|
): Promise<StoredCollectible> {
|
||||||
|
onProgress?.('Downloading from Discord…');
|
||||||
|
const downloaded = await downloadCollectibleAssets(item.assets);
|
||||||
|
|
||||||
|
const urlToMxc = new Map<string, string>();
|
||||||
|
const assets: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const asset of downloaded) {
|
||||||
|
onProgress?.(`Uploading ${asset.filename}…`);
|
||||||
|
const blob = new Blob([asset.data], { type: asset.mimeType });
|
||||||
|
const file = new File([blob], asset.filename, { type: asset.mimeType });
|
||||||
|
const response = await mx.uploadContent(file, {
|
||||||
|
name: asset.filename,
|
||||||
|
type: asset.mimeType,
|
||||||
|
includeFilename: true,
|
||||||
|
});
|
||||||
|
if (!response.content_uri) {
|
||||||
|
throw new Error(`Failed to upload ${asset.filename} to Matrix.`);
|
||||||
|
}
|
||||||
|
urlToMxc.set(asset.url, response.content_uri);
|
||||||
|
assets[asset.role] = response.content_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored: StoredCollectible = {
|
||||||
|
sku_id: item.skuId,
|
||||||
|
name: item.name,
|
||||||
|
assets,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (item.effect) {
|
||||||
|
stored.effect = replaceCdnUrls(item.effect, urlToMxc);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectibleKindLabel(kind: CollectibleKind): string {
|
||||||
|
switch (kind) {
|
||||||
|
case 'profile_effect':
|
||||||
|
return 'Profile effect';
|
||||||
|
case 'nameplate':
|
||||||
|
return 'Nameplate';
|
||||||
|
case 'avatar_decoration':
|
||||||
|
return 'Avatar decoration';
|
||||||
|
default:
|
||||||
|
return kind;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CollectibleVariantGroup = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
items: DiscordCollectibleItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function stripBundleSuffix(name: string): string {
|
||||||
|
return name.replace(/\s+Bundle$/i, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripVariantSuffix(name: string): string {
|
||||||
|
return name.replace(/\s*\([^)]+\)\s*$/, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function variantGroupKey(item: DiscordCollectibleItem): string {
|
||||||
|
const baseName = stripVariantSuffix(stripBundleSuffix(item.name));
|
||||||
|
return `${item.type}:${baseName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function variantGroupDisplayName(item: DiscordCollectibleItem): string {
|
||||||
|
return stripVariantSuffix(stripBundleSuffix(item.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function variantItemLabel(item: DiscordCollectibleItem): string {
|
||||||
|
if (item.paletteLabel) return item.paletteLabel;
|
||||||
|
const nameMatch = item.name.match(/\(([^)]+)\)\s*$/);
|
||||||
|
if (nameMatch) return nameMatch[1];
|
||||||
|
const labelMatch = item.label.match(/\(([^)]+)\)\s*$/);
|
||||||
|
if (labelMatch) return labelMatch[1];
|
||||||
|
if (item.label && item.label !== item.name) return item.label;
|
||||||
|
return 'Default';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeVariantItems(items: DiscordCollectibleItem[]): DiscordCollectibleItem[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return items.filter((item) => {
|
||||||
|
if (seen.has(item.skuId)) return false;
|
||||||
|
seen.add(item.skuId);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupCollectibleItems(items: DiscordCollectibleItem[]): CollectibleVariantGroup[] {
|
||||||
|
const map = new Map<string, DiscordCollectibleItem[]>();
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const key = variantGroupKey(item);
|
||||||
|
const list = map.get(key);
|
||||||
|
if (list) list.push(item);
|
||||||
|
else map.set(key, [item]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...map.entries()]
|
||||||
|
.map(([id, groupItems]) => {
|
||||||
|
const items = dedupeVariantItems(groupItems).sort((a, b) =>
|
||||||
|
variantItemLabel(a).localeCompare(variantItemLabel(b))
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: variantGroupDisplayName(items[0]),
|
||||||
|
category: items[0].category,
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultPreviewAspectRatio(kind: CollectibleKind): number {
|
||||||
|
switch (kind) {
|
||||||
|
case 'profile_effect':
|
||||||
|
return 450 / 880;
|
||||||
|
case 'nameplate':
|
||||||
|
return 448 / 84;
|
||||||
|
case 'avatar_decoration':
|
||||||
|
return 1;
|
||||||
|
default:
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gradientFromPreviewColors(colors?: number[]): string | undefined {
|
||||||
|
if (!colors?.length) return undefined;
|
||||||
|
const toRgb = (value: number) => {
|
||||||
|
const n = value >>> 0;
|
||||||
|
return `rgb(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255})`;
|
||||||
|
};
|
||||||
|
if (colors.length === 1) return toRgb(colors[0]);
|
||||||
|
return `linear-gradient(135deg, ${toRgb(colors[0])}, ${toRgb(colors[1])})`;
|
||||||
|
}
|
||||||
28
src/app/utils/discordNameplatePalettes.ts
Normal file
28
src/app/utils/discordNameplatePalettes.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/** Discord nameplate palette IDs from the API — mapped to representative gradient colors. */
|
||||||
|
const NAMEPLATE_PALETTE_GRADIENTS: Record<string, string> = {
|
||||||
|
crimson: 'linear-gradient(135deg, #5c0a1c, #dc143c)',
|
||||||
|
berry: 'linear-gradient(135deg, #4a1030, #c42d78)',
|
||||||
|
sky: 'linear-gradient(135deg, #0a2a5c, #3b8eed)',
|
||||||
|
teal: 'linear-gradient(135deg, #0a3d3d, #2dd4bf)',
|
||||||
|
forest: 'linear-gradient(135deg, #0a2e1a, #22c55e)',
|
||||||
|
bubble_gum: 'linear-gradient(135deg, #4a1038, #f472b6)',
|
||||||
|
violet: 'linear-gradient(135deg, #2d1050, #8b5cf6)',
|
||||||
|
cobalt: 'linear-gradient(135deg, #0a1448, #3b5bdb)',
|
||||||
|
clover: 'linear-gradient(135deg, #0a3d20, #4ade80)',
|
||||||
|
lemon: 'linear-gradient(135deg, #4a3d0a, #fbbf24)',
|
||||||
|
white: 'linear-gradient(135deg, #888888, #f0f0f0)',
|
||||||
|
black: 'linear-gradient(135deg, #1a1a1a, #404040)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function nameplatePaletteGradient(palette?: string): string | undefined {
|
||||||
|
if (!palette) return undefined;
|
||||||
|
return NAMEPLATE_PALETTE_GRADIENTS[palette];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNameplatePalette(palette?: string): string | undefined {
|
||||||
|
if (!palette) return undefined;
|
||||||
|
return palette
|
||||||
|
.split('_')
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
@@ -13,6 +13,28 @@ export const PROFILE_KEY_BANNER_URL_UNSTABLE = 'chat.commet.profile_banner';
|
|||||||
/** MSC4427 stable profile field for banner */
|
/** MSC4427 stable profile field for banner */
|
||||||
export const PROFILE_KEY_BANNER_URL_STABLE = 'm.banner_url';
|
export const PROFILE_KEY_BANNER_URL_STABLE = 'm.banner_url';
|
||||||
|
|
||||||
|
/** Paarrot profile effect (animated profile background) */
|
||||||
|
export const PROFILE_KEY_PROFILE_EFFECT = 'im.paarrot.profile_effect';
|
||||||
|
|
||||||
|
/** Paarrot nameplate (username bar background) */
|
||||||
|
export const PROFILE_KEY_NAMEPLATE = 'im.paarrot.nameplate';
|
||||||
|
|
||||||
|
/** Paarrot avatar decoration (avatar frame) */
|
||||||
|
export const PROFILE_KEY_AVATAR_DECORATION = 'im.paarrot.avatar_decoration';
|
||||||
|
|
||||||
|
export type StoredCollectible = {
|
||||||
|
sku_id: string;
|
||||||
|
name: string;
|
||||||
|
assets: Record<string, string>;
|
||||||
|
effect?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CollectibleProfileFields = {
|
||||||
|
profile_effect?: StoredCollectible;
|
||||||
|
nameplate?: StoredCollectible;
|
||||||
|
avatar_decoration?: StoredCollectible;
|
||||||
|
};
|
||||||
|
|
||||||
export type ColorPreference = {
|
export type ColorPreference = {
|
||||||
on_dark?: string;
|
on_dark?: string;
|
||||||
on_light?: string;
|
on_light?: string;
|
||||||
@@ -66,6 +88,89 @@ export function extractBannerUrlFromProfile(profile: Record<string, unknown>): s
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseStoredCollectible(value: unknown): StoredCollectible | undefined {
|
||||||
|
if (!value || typeof value !== 'object') return undefined;
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
const sku_id = typeof obj.sku_id === 'string' ? obj.sku_id : undefined;
|
||||||
|
const name = typeof obj.name === 'string' ? obj.name : undefined;
|
||||||
|
const assets = obj.assets;
|
||||||
|
if (!sku_id || !name || !assets || typeof assets !== 'object') return undefined;
|
||||||
|
|
||||||
|
const parsedAssets: Record<string, string> = {};
|
||||||
|
for (const [key, mxc] of Object.entries(assets as Record<string, unknown>)) {
|
||||||
|
if (typeof mxc === 'string' && mxc.startsWith('mxc://')) {
|
||||||
|
parsedAssets[key] = mxc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(parsedAssets).length === 0) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
sku_id,
|
||||||
|
name,
|
||||||
|
assets: parsedAssets,
|
||||||
|
effect: obj.effect,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractCollectiblesFromProfile(profile: Record<string, unknown>): CollectibleProfileFields {
|
||||||
|
return {
|
||||||
|
profile_effect: parseStoredCollectible(profile[PROFILE_KEY_PROFILE_EFFECT]),
|
||||||
|
nameplate: parseStoredCollectible(profile[PROFILE_KEY_NAMEPLATE]),
|
||||||
|
avatar_decoration: parseStoredCollectible(profile[PROFILE_KEY_AVATAR_DECORATION]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCollectiblesFromProfile(mx: MatrixClient, userId: string): Promise<CollectibleProfileFields> {
|
||||||
|
if (await mx.doesServerSupportExtendedProfiles()) {
|
||||||
|
try {
|
||||||
|
const profile = await mx.getExtendedProfile(userId);
|
||||||
|
return extractCollectiblesFromProfile(profile);
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = (await mx.getProfileInfo(userId)) as Record<string, unknown>;
|
||||||
|
return extractCollectiblesFromProfile(profile);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadUserCollectibles(mx: MatrixClient, userId: string): Promise<CollectibleProfileFields> {
|
||||||
|
return loadCollectiblesFromProfile(mx, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveCollectible(
|
||||||
|
mx: MatrixClient,
|
||||||
|
key: string,
|
||||||
|
collectible: StoredCollectible | undefined
|
||||||
|
): Promise<void> {
|
||||||
|
if (!(await mx.doesServerSupportExtendedProfiles())) {
|
||||||
|
throw new Error('Server does not support extended profile fields (MSC4133)');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!collectible) {
|
||||||
|
await mx.deleteExtendedProfileProperty(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await mx.setExtendedProfileProperty(key, collectible);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveProfileEffect(mx: MatrixClient, collectible: StoredCollectible | undefined): Promise<void> {
|
||||||
|
await saveCollectible(mx, PROFILE_KEY_PROFILE_EFFECT, collectible);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveNameplate(mx: MatrixClient, collectible: StoredCollectible | undefined): Promise<void> {
|
||||||
|
await saveCollectible(mx, PROFILE_KEY_NAMEPLATE, collectible);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAvatarDecoration(mx: MatrixClient, collectible: StoredCollectible | undefined): Promise<void> {
|
||||||
|
await saveCollectible(mx, PROFILE_KEY_AVATAR_DECORATION, collectible);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getBannerUrlProfileKey(mx: MatrixClient): Promise<string> {
|
export async function getBannerUrlProfileKey(mx: MatrixClient): Promise<string> {
|
||||||
if (await mx.isVersionSupported('v1.16')) {
|
if (await mx.isVersionSupported('v1.16')) {
|
||||||
return PROFILE_KEY_BANNER_URL_STABLE;
|
return PROFILE_KEY_BANNER_URL_STABLE;
|
||||||
|
|||||||
21
src/ext.d.ts
vendored
21
src/ext.d.ts
vendored
@@ -113,6 +113,27 @@ interface ElectronAPI {
|
|||||||
releaseNotes?: unknown;
|
releaseNotes?: unknown;
|
||||||
}) => void) => void;
|
}) => void) => void;
|
||||||
};
|
};
|
||||||
|
discordCollectibles?: {
|
||||||
|
hasToken: () => Promise<{ success: boolean; data?: boolean; error?: string }>;
|
||||||
|
setToken: (token: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
clearToken: () => Promise<{ success: boolean; error?: string }>;
|
||||||
|
fetchCatalog: (force?: boolean) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: { items: unknown[]; fetchedAt: string };
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
downloadAssets: (assets: Array<{ role: string; url: string; filename: string; mimeType: string }>) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: Array<{
|
||||||
|
role: string;
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
mimeType: string;
|
||||||
|
data: Uint8Array;
|
||||||
|
}>;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
Reference in New Issue
Block a user