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