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, fetchPublishedCollectiblesCatalog, groupCollectibleItems, 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; }; const KIND_TABS: CollectibleKind[] = ['profile_effect', 'nameplate', 'avatar_decoration']; const KIND_FIELD: Record = { 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 ( ); } return (
); } if (kind === 'avatar_decoration') { return ( } /> ); } if (kind === 'profile_effect') { return ( ); } 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 ( ); } if (kind === 'avatar_decoration') { const thumbUrl = pickCatalogStaticPreviewUrl(item); return ( ); } const thumbUrl = pickCatalogStaticPreviewUrl(item); return ( ); } 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 (
{group.items.length > 1 && (
{group.items.map((item) => ( onSelectItem(item)} /> ))}
)}
); } 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('profile_effect'); const [catalogLoading, setCatalogLoading] = useState(false); const [catalogError, setCatalogError] = useState(); const [items, setItems] = useState([]); const [search, setSearch] = useState(''); const [applyingId, setApplyingId] = useState(); const [applyProgress, setApplyProgress] = useState(); const [error, setError] = useState(); const [selectedByGroup, setSelectedByGroup] = useState>({}); const loadCatalog = useCallback(async (force = false) => { setCatalogLoading(true); setCatalogError(undefined); try { const desktopCatalog = window.electron?.discordCollectibles; if (desktopCatalog) { const result = await desktopCatalog.fetchCatalog(force); if (!result?.success || !result.data?.items) { throw new Error(result?.error || 'Failed to load collectibles catalog.'); } setItems(result.data.items as DiscordCollectibleItem[]); } else { setItems(await fetchPublishedCollectiblesCatalog(force)); } } catch (e) { setCatalogError(e instanceof Error ? e.message : 'Failed to load catalog.'); setItems([]); } setCatalogLoading(false); }, []); useEffect(() => { loadCatalog(); }, [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 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 ); return ( Browse a community-published Discord collectibles catalog, then download and upload only what you pick to Matrix. <> {KIND_TABS.map((tab) => ( ))} setSearch(e.target.value)} /> {current && ( Active: {current.name} )} {catalogLoading && ( Loading collectibles catalog… )} {catalogError && !catalogLoading && ( {catalogError} )} {applyProgress && ( {applyProgress} )} {error && {error}}
{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 ( setSelectedByGroup((prev) => ({ ...prev, [group.id]: item.skuId })) } applyingId={applyingId} onApply={handleApply} previewAvatarUrl={previewAvatarUrl} previewBannerUrl={previewBannerUrl} previewDisplayName={previewDisplayName} previewUserId={userId} /> ); })}
{!catalogLoading && filteredGroups.length === 0 && ( No items match your search. )}
); }