Compare commits

2 Commits

Author SHA1 Message Date
d59945eb4c Add configurable sidebar nameplate background.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Render the equipped nameplate behind the larger settings avatar with blur-aware sidebar controls, adjustable opacity, and immediate collectible cache invalidation.
2026-08-24 18:48:48 +10:00
0cbc5d9a1c Add Discord collectibles to profiles, messages, and settings.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
Integrate profile effects, nameplates, and avatar decorations with live catalog browsing, Matrix profile storage, and rendering across user heroes, DMs, messages, and the sidebar avatar.
2026-08-24 00:54:48 +10:00
29 changed files with 2640 additions and 175 deletions

View File

@@ -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} />

View File

@@ -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>
) )
); );

View File

@@ -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,

View File

@@ -9,6 +9,8 @@ export const Sidebar = style([
width: toRem(66), width: toRem(66),
backgroundColor: color.Background.Container, backgroundColor: color.Background.Container,
borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`, borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
position: 'relative',
isolation: 'isolate',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@@ -185,6 +187,46 @@ export const SidebarAvatar = recipe({
}); });
export type SidebarAvatarVariants = RecipeVariants<typeof SidebarAvatar>; export type SidebarAvatarVariants = RecipeVariants<typeof SidebarAvatar>;
/** A vertical rendition of the user's nameplate behind the bottom settings avatar. */
export const SidebarAvatarNameplate = style({
position: 'absolute',
// The 50px avatar is centered in the 66px sidebar. Pin the rotated plate's
// bottom-right corner 8px left of it so the 66px cross-axis
// spans the whole sidebar and grows upward from its bottom edge.
right: toRem(58),
bottom: toRem(-16),
width: toRem(220),
height: toRem(66),
transformOrigin: 'right bottom',
transform: 'rotate(90deg)',
objectFit: 'cover',
objectPosition: 'right center',
pointerEvents: 'none',
zIndex: -1,
});
export const SidebarAvatarLarge = style({
width: toRem(50),
height: toRem(50),
borderRadius: '50%',
backgroundColor: `color-mix(in srgb, ${color.Background.Container} 68%, transparent)`,
backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)',
});
export const SidebarSearchAvatar = style({
position: 'relative',
zIndex: 1,
backgroundColor: `color-mix(in srgb, ${color.Background.Container} 68%, transparent)`,
backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)',
});
export const SidebarAvatarForeground = style({
position: 'relative',
zIndex: 1,
});
export const SidebarFolder = recipe({ export const SidebarFolder = recipe({
base: [ base: [
ContainerColor({ variant: 'Background' }), ContainerColor({ variant: 'Background' }),

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { ReactNode, useState } from 'react';
import { Avatar, Box, Overlay, Text, toRem } from 'folds'; import { 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,15 +61,22 @@ 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} />
} }
> >
<div className={css.UserHeroAvatarStack}>
<Avatar <Avatar
as={avatarUrl ? 'button' : 'div'} as={avatarUrl ? 'button' : 'div'}
onClick={avatarUrl ? () => setViewAvatar(avatarUrl) : undefined} onClick={avatarUrl ? () => setViewAvatar(avatarUrl) : undefined}
className={css.UserHeroAvatar} className={classNames(
css.UserHeroAvatar,
avatarUrl ? css.UserHeroAvatarWithImage : css.UserHeroAvatarBorder
)}
size="500" size="500"
style={{ style={{
width: toRem(72), width: toRem(72),
@@ -70,6 +91,15 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
renderFallback={() => <Icon size="500" src={Icons.User} filled />} renderFallback={() => <Icon size="500" src={Icons.User} filled />}
/> />
</Avatar> </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>
); );
} }

View File

@@ -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,7 +94,7 @@ 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 <Box
direction="Column" direction="Column"
gap="200" gap="200"
@@ -103,19 +103,19 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
padding: config.space.S400, padding: config.space.S400,
paddingTop: `calc(${config.space.S200} + ${toRem(36)})`, paddingTop: `calc(${config.space.S200} + ${toRem(36)})`,
marginTop: toRem(-36), marginTop: toRem(-36),
textAlign: 'center',
}} }}
> >
{/* Display Name */}
<Text <Text
size="H4" size="H4"
className={classNames(BreakWord, LineClamp3)} className={classNames(BreakWord, LineClamp3)}
style={{ color: getMemberDisplayName(room, userId) !== userId ? profileColor : undefined, textShadow: profileTextShadow }} style={{
color: getMemberDisplayName(room, userId) !== userId ? profileColor : undefined,
textShadow: profileTextShadow,
}}
> >
{getMemberDisplayName(room, userId)} {getMemberDisplayName(room, userId)}
</Text> </Text>
{/* Username */}
<Text <Text
size="T200" size="T200"
className={BreakWord} className={BreakWord}
@@ -124,7 +124,6 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
{userId} {userId}
</Text> </Text>
{/* Status Pill */}
{presence?.status && ( {presence?.status && (
<Box <Box
style={{ style={{
@@ -140,7 +139,10 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
</Text> </Text>
</Box> </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>
); );
} }

View File

@@ -1,4 +1,4 @@
import { style } from '@vanilla-extract/css'; import { globalStyle, style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds'; 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',
});

View File

@@ -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,

View File

@@ -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}

View File

@@ -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,7 +836,8 @@ 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}>
<Box shrink="No" gap="100" alignItems="Center">
{messageLayout === MessageLayout.Modern && hover && ( {messageLayout === MessageLayout.Modern && hover && (
<> <>
<Text as="span" size="T200" priority="300"> <Text as="span" size="T200" priority="300">
@@ -842,6 +855,23 @@ export const Message = as<'div', MessageProps>(
dateFormatString={dateFormatString} dateFormatString={dateFormatString}
/> />
</Box> </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>
)}
</Box>
</Box> </Box>
); );
@@ -857,8 +887,13 @@ export const Message = as<'div', MessageProps>(
<AvatarBase <AvatarBase
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined} className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
> >
<div className={css.MessageAvatarStack}>
<Avatar <Avatar
className={classNames(css.MessageAvatar, presenceClass)} className={classNames(
avatarDecorationUrl ? css.MessageAvatarCircular : css.MessageAvatar,
!avatarDecorationUrl && presenceClass
)}
radii={avatarDecorationUrl ? 'Pill' : undefined}
as="button" as="button"
size="300" size="300"
data-user-id={senderId} data-user-id={senderId}
@@ -875,6 +910,15 @@ export const Message = as<'div', MessageProps>(
renderFallback={() => <Icon size="200" src={Icons.User} filled />} renderFallback={() => <Icon size="200" src={Icons.User} filled />}
/> />
</Avatar> </Avatar>
{avatarDecorationUrl && (
<img
className={avatarDecorationCss.AvatarDecorationOverlay}
src={avatarDecorationUrl}
alt=""
draggable={false}
/>
)}
</div>
</AvatarBase> </AvatarBase>
); );
@@ -953,6 +997,10 @@ export const Message = as<'div', MessageProps>(
{...hoverProps} {...hoverProps}
{...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}>
@@ -1242,6 +1290,7 @@ export const Message = as<'div', MessageProps>(
{msgContentJSX} {msgContentJSX}
</ModernLayout> </ModernLayout>
)} )}
</div>
</MessageBase> </MessageBase>
); );
} }

View File

@@ -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({

View File

@@ -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}
/>
);
}

View 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',
});

View 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>
);
}

View File

@@ -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
@@ -537,13 +562,31 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
<PresenceBadge presence={presence.presence} status={presence.status} /> <PresenceBadge presence={presence.presence} status={presence.status} />
) )
} }
>
<Box
as="button"
onClick={handleAvatarClick}
style={{
position: 'relative',
width: '100%',
height: '100%',
backgroundColor: avatarUrl ? 'transparent' : color.Surface.Container,
border: 'none',
padding: 0,
cursor: 'pointer',
borderRadius: '50%',
}}
> >
<Avatar <Avatar
size="500" size="500"
style={{ style={{
width: toRem(72), width: toRem(76),
height: toRem(72), height: toRem(76),
...(avatarUrl
? { outline: 'none', border: 'none', boxShadow: 'none' }
: {
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`, outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
}),
}} }}
> >
<UserAvatar <UserAvatar
@@ -553,6 +596,15 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
renderFallback={() => <Icon size="500" src={Icons.User} filled />} renderFallback={() => <Icon size="500" src={Icons.User} filled />}
/> />
</Avatar> </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

View File

@@ -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>
);
}

View File

@@ -9,6 +9,7 @@ import React, {
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { as, Box, Button, Chip, color, config, Header, IconButton, Input, Menu, MenuItem, PopOut, RectCords, Scroll, Switch, Text, toRem } from 'folds'; import { as, Box, Button, Chip, color, config, Header, IconButton, Input, Menu, MenuItem, PopOut, RectCords, Scroll, Switch, Text, toRem } from 'folds';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { Range } from 'react-range';
import { Icon, Icons } from '../../../components/icons'; import { Icon, Icons } from '../../../components/icons';
import { HexColorPicker } from 'react-colorful'; import { HexColorPicker } from 'react-colorful';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
@@ -334,6 +335,66 @@ const emojiStyleNames: Record<EmojiStyle, string> = {
[EmojiStyle.Twemoji]: 'Twemoji', [EmojiStyle.Twemoji]: 'Twemoji',
}; };
function SidebarNameplate() {
const [sidebarNameplateOpacity, setSidebarNameplateOpacity] = useSetting(
settingsAtom,
'sidebarNameplateOpacity'
);
return (
<Box direction="Column" gap="100">
<Text size="L400">Collectibles</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Sidebar Nameplate Opacity"
description="Set to 0% to hide your equipped nameplate behind the bottom sidebar avatar."
after={
<Box gap="200" alignItems="Center" style={{ width: toRem(180) }}>
<Range
step={1}
min={0}
max={100}
values={[sidebarNameplateOpacity]}
onChange={(values) => setSidebarNameplateOpacity(values[0])}
renderTrack={(params) => (
<div
{...params.props}
style={{
...params.props.style,
width: '100%',
height: toRem(8),
borderRadius: toRem(4),
background: `linear-gradient(to right, ${color.Primary.Main} ${sidebarNameplateOpacity}%, ${color.Surface.ContainerLine} ${sidebarNameplateOpacity}%)`,
}}
>
{params.children}
</div>
)}
renderThumb={(params) => (
<div
{...params.props}
style={{
...params.props.style,
width: toRem(16),
height: toRem(16),
borderRadius: '50%',
backgroundColor: color.Primary.Main,
border: `${toRem(2)} solid ${color.Surface.Container}`,
}}
/>
)}
/>
<Text size="T300" style={{ minWidth: toRem(32), textAlign: 'right' }}>
{sidebarNameplateOpacity}%
</Text>
</Box>
}
/>
</SequenceCard>
</Box>
);
}
type EmojiStyleSelectorProps = { type EmojiStyleSelectorProps = {
selected: EmojiStyle; selected: EmojiStyle;
onSelect: (style: EmojiStyle) => void; onSelect: (style: EmojiStyle) => void;
@@ -1222,6 +1283,7 @@ export function General({ requestClose }: GeneralProps) {
<PageContent> <PageContent>
<Box direction="Column" gap="700"> <Box direction="Column" gap="700">
<Appearance /> <Appearance />
<SidebarNameplate />
<DateAndTime /> <DateAndTime />
<Editor /> <Editor />
<Spaces /> <Spaces />

View File

@@ -0,0 +1,179 @@
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);
clearUserCollectiblesCache(mx.getUserId());
setCollectibles((prev) => ({ ...prev, profile_effect: collectible }));
return;
}
if (kind === 'nameplate') {
await saveNameplate(mx, collectible);
clearUserCollectiblesCache(mx.getUserId());
setCollectibles((prev) => ({ ...prev, nameplate: collectible }));
return;
}
await saveAvatarDecoration(mx, collectible);
clearUserCollectiblesCache(mx.getUserId());
setCollectibles((prev) => ({ ...prev, avatar_decoration: collectible }));
},
[mx]
);
return [collectibles, updateCollectible, loading];
}
const collectibleCache = new Map<string, { data: CollectibleProfileFields; timestamp: number }>();
const collectibleCacheListeners = new Set<(userId: string) => void>();
const CACHE_TTL_MS = 5 * 60 * 1000;
export function clearUserCollectiblesCache(userId?: string): void {
if (!userId) return;
collectibleCache.delete(userId);
collectibleCacheListeners.forEach((listener) => listener(userId));
}
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>>({});
const [cacheVersion, setCacheVersion] = useState(0);
useEffect(() => {
const handleCacheClear = (clearedUserId: string) => {
if (clearedUserId === userId) {
setCacheVersion((version) => version + 1);
}
};
collectibleCacheListeners.add(handleCacheClear);
return () => collectibleCacheListeners.delete(handleCacheClear);
}, [userId]);
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));
};
}, [cacheVersion, mx, useAuthentication, userId]);
return { ...collectibles, assetUrls };
}

View File

@@ -4,6 +4,7 @@ import React from 'react';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar'; import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar';
import { searchModalAtom } from '../../../state/searchModal'; import { searchModalAtom } from '../../../state/searchModal';
import * as sidebarCss from '../../../components/sidebar/Sidebar.css';
export function SearchTab() { export function SearchTab() {
const [opened, setOpen] = useAtom(searchModalAtom); const [opened, setOpen] = useAtom(searchModalAtom);
@@ -14,7 +15,13 @@ export function SearchTab() {
<SidebarItem active={opened}> <SidebarItem active={opened}>
<SidebarItemTooltip tooltip="Search"> <SidebarItemTooltip tooltip="Search">
{(triggerRef) => ( {(triggerRef) => (
<SidebarAvatar as="button" ref={triggerRef} outlined onClick={open}> <SidebarAvatar
as="button"
ref={triggerRef}
className={sidebarCss.SidebarSearchAvatar}
outlined
onClick={open}
>
<Icon src={Icons.Search} filled={opened} /> <Icon src={Icons.Search} filled={opened} />
</SidebarAvatar> </SidebarAvatar>
)} )}

View File

@@ -11,6 +11,12 @@ 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 { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { pickAvatarDecorationUrl, pickNameplateUrl } from '../../../utils/collectibleAssets';
import * as avatarDecorationCss from '../../../styles/AvatarDecoration.css';
import * as sidebarCss from '../../../components/sidebar/Sidebar.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 +40,13 @@ 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 nameplateUrl = pickNameplateUrl(assetUrls);
const [sidebarNameplateOpacity] = useSetting(settingsAtom, 'sidebarNameplateOpacity');
const nameplateIsVideo = Boolean(
assetUrls['nameplate:animated'] || assetUrls['nameplate:asset.webm']
);
const openSettings = () => setSettings(true); const openSettings = () => setSettings(true);
const closeSettings = () => setSettings(false); const closeSettings = () => setSettings(false);
@@ -48,14 +61,49 @@ export function SettingsTab() {
<SidebarItem active={settings}> <SidebarItem active={settings}>
<SidebarAvatar <SidebarAvatar
as="button" as="button"
className={sidebarCss.SidebarAvatarLarge}
onClick={openSettings} onClick={openSettings}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
style={{ overflow: 'visible' }}
> >
<div className={avatarDecorationCss.SidebarAvatarStack}>
{sidebarNameplateOpacity > 0 &&
nameplateUrl &&
(nameplateIsVideo ? (
<video
className={sidebarCss.SidebarAvatarNameplate}
src={nameplateUrl}
style={{ opacity: sidebarNameplateOpacity / 100 }}
autoPlay
loop
muted
playsInline
aria-hidden="true"
/>
) : (
<img
className={sidebarCss.SidebarAvatarNameplate}
src={nameplateUrl}
style={{ opacity: sidebarNameplateOpacity / 100 }}
alt=""
draggable={false}
/>
))}
<UserAvatar <UserAvatar
className={sidebarCss.SidebarAvatarForeground}
userId={userId ?? ''} userId={userId ?? ''}
src={avatarUrl} src={avatarUrl}
renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>} renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>}
/> />
{avatarDecorationUrl && (
<img
className={avatarDecorationCss.AvatarDecorationOverlay}
src={avatarDecorationUrl}
alt=""
draggable={false}
/>
)}
</div>
</SidebarAvatar> </SidebarAvatar>
<PopOut <PopOut

View File

@@ -50,6 +50,7 @@ export interface Settings {
encUrlPreview: boolean; encUrlPreview: boolean;
showHiddenEvents: boolean; showHiddenEvents: boolean;
legacyUsernameColor: boolean; legacyUsernameColor: boolean;
sidebarNameplateOpacity: number;
showNotifications: boolean; showNotifications: boolean;
isNotificationSounds: boolean; isNotificationSounds: boolean;
@@ -93,6 +94,7 @@ const defaultSettings: Settings = {
encUrlPreview: false, encUrlPreview: false,
showHiddenEvents: false, showHiddenEvents: false,
legacyUsernameColor: false, legacyUsernameColor: false,
sidebarNameplateOpacity: 60,
showNotifications: true, showNotifications: true,
isNotificationSounds: true, isNotificationSounds: true,

View 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',
});

View 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']
);
}

View 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])})`;
}

View 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(' ');
}

View File

@@ -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
View File

@@ -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 {