From 0cbc5d9a1cfa9d4525f27bb46e86bbeb37c0f8c0 Mon Sep 17 00:00:00 2001 From: litruv Date: Mon, 24 Aug 2026 00:54:48 +1000 Subject: [PATCH] Add Discord collectibles to profiles, messages, and settings. 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. --- .../components/UserRoomProfileRenderer.tsx | 2 +- src/app/components/presence/Presence.tsx | 2 +- src/app/components/presence/styles.css.ts | 2 +- .../ProfileCollectibleOverlays.tsx | 28 + .../user-profile/ProfileEffectMedia.tsx | 77 +++ src/app/components/user-profile/UserHero.tsx | 86 ++- .../user-profile/UserRoomProfile.tsx | 94 +-- src/app/components/user-profile/styles.css.ts | 103 ++- src/app/features/room/RoomTimeline.css.ts | 21 +- src/app/features/room/RoomTimeline.tsx | 37 +- src/app/features/room/message/Message.tsx | 117 +++- src/app/features/room/message/styles.css.ts | 75 ++- .../account/CollectiblePreviewMedia.tsx | 50 ++ .../account/CollectiblesSection.css.ts | 332 ++++++++++ .../settings/account/CollectiblesSection.tsx | 613 ++++++++++++++++++ src/app/features/settings/account/Profile.tsx | 204 ++++-- .../account/ProfileEffectHeroPreview.tsx | 95 +++ src/app/hooks/useUserCollectibles.ts | 158 +++++ src/app/pages/client/sidebar/SettingsTab.tsx | 30 +- src/app/styles/AvatarDecoration.css.ts | 29 + src/app/utils/collectibleAssets.ts | 102 +++ src/app/utils/discordCollectibles.ts | 236 +++++++ src/app/utils/discordNameplatePalettes.ts | 28 + src/app/utils/profileFields.ts | 105 +++ src/ext.d.ts | 21 + 25 files changed, 2473 insertions(+), 174 deletions(-) create mode 100644 src/app/components/user-profile/ProfileCollectibleOverlays.tsx create mode 100644 src/app/components/user-profile/ProfileEffectMedia.tsx create mode 100644 src/app/features/settings/account/CollectiblePreviewMedia.tsx create mode 100644 src/app/features/settings/account/CollectiblesSection.css.ts create mode 100644 src/app/features/settings/account/CollectiblesSection.tsx create mode 100644 src/app/features/settings/account/ProfileEffectHeroPreview.tsx create mode 100644 src/app/hooks/useUserCollectibles.ts create mode 100644 src/app/styles/AvatarDecoration.css.ts create mode 100644 src/app/utils/collectibleAssets.ts create mode 100644 src/app/utils/discordCollectibles.ts create mode 100644 src/app/utils/discordNameplatePalettes.ts diff --git a/src/app/components/UserRoomProfileRenderer.tsx b/src/app/components/UserRoomProfileRenderer.tsx index ca7aa83..81188ea 100644 --- a/src/app/components/UserRoomProfileRenderer.tsx +++ b/src/app/components/UserRoomProfileRenderer.tsx @@ -34,7 +34,7 @@ function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState }) escapeDeactivates: stopPropagation, }} > - + diff --git a/src/app/components/presence/Presence.tsx b/src/app/components/presence/Presence.tsx index b5a7222..fde4439 100644 --- a/src/app/components/presence/Presence.tsx +++ b/src/app/components/presence/Presence.tsx @@ -67,6 +67,7 @@ type AvatarPresenceProps = { export const AvatarPresence = as<'div', AvatarPresenceProps>( ({ as: AsAvatarPresence, badge, variant = 'Surface', badgeBackgroundColor, children, ...props }, ref) => ( + {children} {badge && (
( {badge}
)} - {children}
) ); diff --git a/src/app/components/presence/styles.css.ts b/src/app/components/presence/styles.css.ts index 12ea7f1..50ec779 100644 --- a/src/app/components/presence/styles.css.ts +++ b/src/app/components/presence/styles.css.ts @@ -12,7 +12,7 @@ export const AvatarPresenceBadge = style({ bottom: 0, right: 0, transform: 'translate(25%, 25%)', - zIndex: 1, + zIndex: 10, display: 'flex', padding: config.borderWidth.B600, diff --git a/src/app/components/user-profile/ProfileCollectibleOverlays.tsx b/src/app/components/user-profile/ProfileCollectibleOverlays.tsx new file mode 100644 index 0000000..87f01aa --- /dev/null +++ b/src/app/components/user-profile/ProfileCollectibleOverlays.tsx @@ -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; +}; + +export function ProfileCollectibleOverlays({ assetUrls }: ProfileCollectibleOverlaysProps) { + const introUrl = pickStoredProfileEffectIntroUrl(assetUrls); + const loopUrl = pickStoredProfileEffectLoopUrl(assetUrls); + + if (!introUrl && !loopUrl) return null; + + return ( + + ); +} diff --git a/src/app/components/user-profile/ProfileEffectMedia.tsx b/src/app/components/user-profile/ProfileEffectMedia.tsx new file mode 100644 index 0000000..e136294 --- /dev/null +++ b/src/app/components/user-profile/ProfileEffectMedia.tsx @@ -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 ( +