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.
This commit is contained in:
2026-08-24 00:54:48 +10:00
parent 6cb1e14632
commit 0cbc5d9a1c
25 changed files with 2473 additions and 174 deletions

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