Compare commits
1 Commits
87c9f59826
...
c34cfaf33f
| Author | SHA1 | Date | |
|---|---|---|---|
| c34cfaf33f |
@@ -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<string>();
|
||||
const [selectedByGroup, setSelectedByGroup] = useState<Record<string, string>>({});
|
||||
|
||||
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 (
|
||||
<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 }}>
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<DiscordCollectibleItem[]> {
|
||||
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<DownloadedCollectibleAsset[]> {
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user