From c34cfaf33fde852e6f2350c43e590f0527eb555c Mon Sep 17 00:00:00 2001 From: litruv Date: Mon, 24 Aug 2026 19:52:50 +1000 Subject: [PATCH] Enable collectibles on mobile clients. Fetch and cache the published catalog in the renderer when Electron is unavailable, then download selected CDN assets directly for Matrix upload. --- .../settings/account/CollectiblesSection.tsx | 31 ++--- src/app/utils/discordCollectibles.ts | 119 +++++++++++++++--- 2 files changed, 114 insertions(+), 36 deletions(-) diff --git a/src/app/features/settings/account/CollectiblesSection.tsx b/src/app/features/settings/account/CollectiblesSection.tsx index 28684b3..34fe66e 100644 --- a/src/app/features/settings/account/CollectiblesSection.tsx +++ b/src/app/features/settings/account/CollectiblesSection.tsx @@ -14,8 +14,8 @@ import { DiscordCollectibleItem, collectibleKindLabel, defaultPreviewAspectRatio, + fetchPublishedCollectiblesCatalog, groupCollectibleItems, - isElectronCollectiblesAvailable, uploadCollectibleToMatrix, variantItemLabel, } from '../../../utils/discordCollectibles'; @@ -347,24 +347,26 @@ export function CollectiblesSection({ collectibles, onApply }: CollectiblesSecti const [error, setError] = useState(); const [selectedByGroup, setSelectedByGroup] = useState>({}); - const desktopAvailable = isElectronCollectiblesAvailable(); - 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.'); + 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)); } - setItems(result.data.items as DiscordCollectibleItem[]); } catch (e) { setCatalogError(e instanceof Error ? e.message : 'Failed to load catalog.'); setItems([]); } setCatalogLoading(false); - }, [desktopAvailable]); + }, []); useEffect(() => { loadCatalog(); @@ -431,17 +433,6 @@ export function CollectiblesSection({ collectibles, onApply }: CollectiblesSecti kind === 'avatar_decoration' && css.CollectibleGroupGridDecorations ); - if (!desktopAvailable) { - return ( - - - 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. - - - ); - } - return ( diff --git a/src/app/utils/discordCollectibles.ts b/src/app/utils/discordCollectibles.ts index ac9d5ca..fe57547 100644 --- a/src/app/utils/discordCollectibles.ts +++ b/src/app/utils/discordCollectibles.ts @@ -37,9 +37,87 @@ export type DownloadedCollectibleAsset = DiscordCollectibleAsset & { }; const CDN_HOST_RE = /(^|\.)(discordapp\.com|discordapp\.net|discord\.com)$/i; +const PUBLISHED_CATALOG_URLS = [ + 'https://github.com/litruv/discord-collectibles/releases/download/latest/profileeffects.json', + 'https://github.com/litruv/discord-collectibles/releases/download/latest/nameplate.json', + 'https://github.com/litruv/discord-collectibles/releases/download/latest/avatardecorations.json', +]; +const CATALOG_CACHE_KEY = 'paarrot.discordCollectiblesCatalog'; +const CATALOG_CACHE_TTL_MS = 6 * 60 * 60 * 1000; -export function isElectronCollectiblesAvailable(): boolean { - return Boolean(window.electron?.discordCollectibles); +type PublishedCatalog = { + schema_version: number; + fetchedAt: string; + items: DiscordCollectibleItem[]; +}; + +function isPublishedCatalog(value: unknown): value is { schema_version: number; items: DiscordCollectibleItem[] } { + if (!value || typeof value !== 'object') return false; + const catalog = value as Record; + return ( + catalog.schema_version === 1 && + Array.isArray(catalog.items) && + catalog.items.every( + (item) => + item && + typeof item === 'object' && + typeof (item as DiscordCollectibleItem).id === 'string' && + Array.isArray((item as DiscordCollectibleItem).assets) + ) + ); +} + +function loadCachedCatalog(): PublishedCatalog | undefined { + try { + const raw = localStorage.getItem(CATALOG_CACHE_KEY); + if (!raw) return undefined; + const cached = JSON.parse(raw) as PublishedCatalog; + if ( + !isPublishedCatalog(cached) || + typeof cached.fetchedAt !== 'string' || + Date.now() - Date.parse(cached.fetchedAt) >= CATALOG_CACHE_TTL_MS + ) { + return undefined; + } + return cached; + } catch { + return undefined; + } +} + +function saveCachedCatalog(catalog: PublishedCatalog): void { + try { + localStorage.setItem(CATALOG_CACHE_KEY, JSON.stringify(catalog)); + } catch { + // A full or unavailable storage backend should not prevent catalog use. + } +} + +export async function fetchPublishedCollectiblesCatalog( + force = false +): Promise { + const cached = loadCachedCatalog(); + if (!force && cached) return cached.items; + + try { + const documents = await Promise.all( + PUBLISHED_CATALOG_URLS.map(async (url) => { + const response = await fetch(url); + if (!response.ok) throw new Error(`Catalog request failed with HTTP ${response.status}.`); + const catalog = await response.json(); + if (!isPublishedCatalog(catalog)) throw new Error('Catalog has an unsupported schema.'); + return catalog; + }) + ); + const items = documents.flatMap((catalog) => catalog.items); + if (items.length === 0) throw new Error('Catalog is empty.'); + + saveCachedCatalog({ schema_version: 1, fetchedAt: new Date().toISOString(), items }); + return items; + } catch (error) { + if (cached) return cached.items; + throw error; + } } function isCdnUrl(value: unknown): value is string { @@ -73,22 +151,31 @@ export async function downloadCollectibleAssets( assets: DiscordCollectibleAsset[] ): Promise { const api = window.electron?.discordCollectibles; - if (!api) { - throw new Error('Discord collectibles are only available in the Paarrot desktop app.'); + if (api) { + 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), + })); } - 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), - })); + return Promise.all( + assets.map(async (asset) => { + if (!isCdnUrl(asset.url)) throw new Error('Collectible asset URL is not a Discord CDN URL.'); + const response = await fetch(asset.url); + if (!response.ok) throw new Error(`Failed to download ${asset.filename}: HTTP ${response.status}.`); + return { + ...asset, + data: new Uint8Array(await response.arrayBuffer()), + }; + }) + ); } export async function uploadCollectibleToMatrix(