Enable collectibles on mobile clients.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
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.
This commit is contained in:
@@ -14,8 +14,8 @@ import {
|
|||||||
DiscordCollectibleItem,
|
DiscordCollectibleItem,
|
||||||
collectibleKindLabel,
|
collectibleKindLabel,
|
||||||
defaultPreviewAspectRatio,
|
defaultPreviewAspectRatio,
|
||||||
|
fetchPublishedCollectiblesCatalog,
|
||||||
groupCollectibleItems,
|
groupCollectibleItems,
|
||||||
isElectronCollectiblesAvailable,
|
|
||||||
uploadCollectibleToMatrix,
|
uploadCollectibleToMatrix,
|
||||||
variantItemLabel,
|
variantItemLabel,
|
||||||
} from '../../../utils/discordCollectibles';
|
} from '../../../utils/discordCollectibles';
|
||||||
@@ -347,24 +347,26 @@ export function CollectiblesSection({ collectibles, onApply }: CollectiblesSecti
|
|||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const [selectedByGroup, setSelectedByGroup] = useState<Record<string, string>>({});
|
const [selectedByGroup, setSelectedByGroup] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
const desktopAvailable = isElectronCollectiblesAvailable();
|
|
||||||
|
|
||||||
const loadCatalog = useCallback(async (force = false) => {
|
const loadCatalog = useCallback(async (force = false) => {
|
||||||
if (!desktopAvailable) return;
|
|
||||||
setCatalogLoading(true);
|
setCatalogLoading(true);
|
||||||
setCatalogError(undefined);
|
setCatalogError(undefined);
|
||||||
try {
|
try {
|
||||||
const result = await window.electron!.discordCollectibles!.fetchCatalog(force);
|
const desktopCatalog = window.electron?.discordCollectibles;
|
||||||
|
if (desktopCatalog) {
|
||||||
|
const result = await desktopCatalog.fetchCatalog(force);
|
||||||
if (!result?.success || !result.data?.items) {
|
if (!result?.success || !result.data?.items) {
|
||||||
throw new Error(result?.error || 'Failed to load Discord catalog.');
|
throw new Error(result?.error || 'Failed to load collectibles catalog.');
|
||||||
}
|
}
|
||||||
setItems(result.data.items as DiscordCollectibleItem[]);
|
setItems(result.data.items as DiscordCollectibleItem[]);
|
||||||
|
} else {
|
||||||
|
setItems(await fetchPublishedCollectiblesCatalog(force));
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setCatalogError(e instanceof Error ? e.message : 'Failed to load catalog.');
|
setCatalogError(e instanceof Error ? e.message : 'Failed to load catalog.');
|
||||||
setItems([]);
|
setItems([]);
|
||||||
}
|
}
|
||||||
setCatalogLoading(false);
|
setCatalogLoading(false);
|
||||||
}, [desktopAvailable]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadCatalog();
|
loadCatalog();
|
||||||
@@ -431,17 +433,6 @@ export function CollectiblesSection({ collectibles, onApply }: CollectiblesSecti
|
|||||||
kind === 'avatar_decoration' && css.CollectibleGroupGridDecorations
|
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 (
|
return (
|
||||||
<Box direction="Column" gap="300">
|
<Box direction="Column" gap="300">
|
||||||
<Text size="T200" style={{ opacity: 0.8 }}>
|
<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 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 {
|
type PublishedCatalog = {
|
||||||
return Boolean(window.electron?.discordCollectibles);
|
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 {
|
function isCdnUrl(value: unknown): value is string {
|
||||||
@@ -73,15 +151,11 @@ export async function downloadCollectibleAssets(
|
|||||||
assets: DiscordCollectibleAsset[]
|
assets: DiscordCollectibleAsset[]
|
||||||
): Promise<DownloadedCollectibleAsset[]> {
|
): Promise<DownloadedCollectibleAsset[]> {
|
||||||
const api = window.electron?.discordCollectibles;
|
const api = window.electron?.discordCollectibles;
|
||||||
if (!api) {
|
if (api) {
|
||||||
throw new Error('Discord collectibles are only available in the Paarrot desktop app.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await api.downloadAssets(assets);
|
const result = await api.downloadAssets(assets);
|
||||||
if (!result?.success || !result.data) {
|
if (!result?.success || !result.data) {
|
||||||
throw new Error(result?.error || 'Failed to download collectible assets from Discord CDN.');
|
throw new Error(result?.error || 'Failed to download collectible assets from Discord CDN.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.data.map((asset) => ({
|
return result.data.map((asset) => ({
|
||||||
role: asset.role,
|
role: asset.role,
|
||||||
url: asset.url,
|
url: asset.url,
|
||||||
@@ -91,6 +165,19 @@ export async function downloadCollectibleAssets(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
export async function uploadCollectibleToMatrix(
|
||||||
mx: MatrixClient,
|
mx: MatrixClient,
|
||||||
item: DiscordCollectibleItem,
|
item: DiscordCollectibleItem,
|
||||||
|
|||||||
Reference in New Issue
Block a user