All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
Fetch and cache the published catalog in the renderer when Electron is unavailable, then download selected CDN assets directly for Matrix upload.
544 lines
17 KiB
TypeScript
544 lines
17 KiB
TypeScript
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<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 [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 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 (
|
|
<Box direction="Column" gap="300">
|
|
<Text size="T200" style={{ opacity: 0.8 }}>
|
|
Browse a community-published Discord collectibles catalog, then download and upload only what
|
|
you pick to Matrix.
|
|
</Text>
|
|
|
|
<Box gap="200" alignItems="Center" wrap="Wrap">
|
|
<Button
|
|
size="300"
|
|
variant="Secondary"
|
|
fill="Soft"
|
|
radii="300"
|
|
onClick={() => loadCatalog(true)}
|
|
disabled={catalogLoading}
|
|
>
|
|
<Text size="B300">{catalogLoading ? 'Refreshing…' : 'Refresh catalog'}</Text>
|
|
</Button>
|
|
</Box>
|
|
|
|
<>
|
|
<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 collectibles catalog…</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>
|
|
);
|
|
}
|