Compare commits
5 Commits
d59945eb4c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 47979718eb | |||
| 862307ee7d | |||
| b16263b481 | |||
| c34cfaf33f | |||
| 87c9f59826 |
@@ -7,6 +7,9 @@
|
||||
"LocalNotifications": {
|
||||
"smallIcon": "ic_stat_paarrot",
|
||||
"iconColor": "#FF8A00"
|
||||
},
|
||||
"CapacitorHttp": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
22
public/update/4.11.177.md
Normal file
22
public/update/4.11.177.md
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
version: 4.11.177
|
||||
title: Profile colors & banners
|
||||
date: 2026-08-23
|
||||
summary: Username colors now follow MSC4522 with separate dark and light theme pickers.
|
||||
---
|
||||
|
||||
## Profile colors (MSC4522)
|
||||
|
||||
Username colors are now stored on your Matrix profile instead of inside avatar images. In turn **will need to be updated in your Account settings**, this is now cross compatible with Element + Cinny
|
||||
|
||||
- Set **separate colors for dark and light themes** in Settings → Account → Profile.
|
||||
- Per-room username overrides are read from member profile data when available.
|
||||
|
||||

|
||||
|
||||
## Profile banners (MSC4133)
|
||||
|
||||
Profile banners now live on your Matrix profile via `m.banner_url` (MSC4133), not embedded in avatar metadata.
|
||||
|
||||
- Upload or remove a banner independently from your avatar.
|
||||
- Other clients that support MSC4133 can read your banner directly.
|
||||
@@ -1,22 +1,22 @@
|
||||
---
|
||||
version: 4.11.177
|
||||
title: Profile colors & banners
|
||||
date: 2026-08-23
|
||||
summary: Username colors now follow MSC4522 with separate dark and light theme pickers.
|
||||
version: 4.11.185
|
||||
title: Discord collectibles
|
||||
date: 2026-08-24
|
||||
summary: Add Discord-style profile effects, nameplates, and avatar decorations to Paarrot profiles.
|
||||
---
|
||||
|
||||
## Profile colors (MSC4522)
|
||||
## Discord collectibles
|
||||
|
||||
Username colors are now stored on your Matrix profile instead of inside avatar images. In turn **will need to be updated in your Account settings**, this is now cross compatible with Element + Cinny
|
||||
- Browse and apply profile effects, animated nameplates, and avatar decorations in Settings → Account → Profile.
|
||||
- Collectible assets are uploaded to your Matrix profile, so other Paarrot users can see them without Discord.
|
||||
- The catalog now comes from a public git, and no Discord token is required in Paarrot for the cosmetics.
|
||||
- Profile-effect previews play their intro and loop animations, with a mini profile preview for each effect.
|
||||
|
||||
- Set **separate colors for dark and light themes** in Settings → Account → Profile.
|
||||
- Per-room username overrides are read from member profile data when available.
|
||||
|
||||

|
||||
 
|
||||
|
||||
## Profile banners (MSC4133)
|
||||
## Profile presentation
|
||||
|
||||
Profile banners now live on your Matrix profile via `m.banner_url` (MSC4133), not embedded in avatar metadata.
|
||||
|
||||
- Upload or remove a banner independently from your avatar.
|
||||
- Other clients that support MSC4133 can read your banner directly.
|
||||
- Avatar decorations render in messages, profile cards, and the bottom sidebar avatar.
|
||||
- Nameplates to the right of messages, beneath timestamps and can be displayed behind the bottom sidebar avatar with opacity settings.
|
||||
- Customize the sidebar nameplate opacity in Settings → General → Collectibles.
|
||||
|
||||
BIN
public/update/images/discordcosmetics1.png
Normal file
BIN
public/update/images/discordcosmetics1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
public/update/images/discordcosmetics2.png
Normal file
BIN
public/update/images/discordcosmetics2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
@@ -262,7 +262,7 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
||||
<AttachmentBox
|
||||
style={{
|
||||
width: toRem(width),
|
||||
height: toRem(height),
|
||||
aspectRatio: `${width} / ${height}`,
|
||||
['--media-h' as string]: `${height}px`,
|
||||
}}
|
||||
data-paarrot-media-height={height}
|
||||
|
||||
@@ -8,7 +8,7 @@ export const Attachment = recipe({
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
borderRadius: config.radii.R400,
|
||||
overflow: 'hidden',
|
||||
maxWidth: toRem(400),
|
||||
maxWidth: `min(100%, ${toRem(400)})`,
|
||||
},
|
||||
variants: {
|
||||
outlined: {
|
||||
@@ -33,7 +33,7 @@ export const AttachmentHeader = style({
|
||||
export const AttachmentBox = style([
|
||||
DefaultReset,
|
||||
{
|
||||
maxWidth: toRem(400),
|
||||
maxWidth: `min(100%, ${toRem(400)})`,
|
||||
maxHeight: toRem(400),
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
@@ -112,5 +112,7 @@ export const TikTokEmbedContainer = style([
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#000',
|
||||
border: 'none',
|
||||
display: 'block',
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -42,6 +42,12 @@ export function isTikTokUrl(url: string): boolean {
|
||||
return TIKTOK_URL_PATTERNS.some((pattern) => pattern.test(url));
|
||||
}
|
||||
|
||||
function extractTikTokVideoId(url: string, embedHtml = ''): string | null {
|
||||
const urlMatch = url.match(/\/video\/(\d+)/i) ?? url.match(/\/v\/(\d+)/i);
|
||||
const embedMatch = embedHtml.match(/data-video-id=["'](\d+)["']/i);
|
||||
return urlMatch?.[1] ?? embedMatch?.[1] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches TikTok video metadata using oEmbed API
|
||||
* @param url The TikTok video URL
|
||||
@@ -53,29 +59,39 @@ async function fetchTikTokOEmbed(url: string): Promise<{
|
||||
authorUrl: string;
|
||||
thumbnailUrl: string;
|
||||
embedHtml: string;
|
||||
videoId: string | null;
|
||||
} | null> {
|
||||
try {
|
||||
const oembedUrl = `${TIKTOK_OEMBED_API}?url=${encodeURIComponent(url)}`;
|
||||
const response = await fetch(oembedUrl);
|
||||
if (!response.ok) return null;
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
return {
|
||||
title: data.title || 'TikTok Video',
|
||||
authorName: data.author_name || 'Unknown',
|
||||
authorUrl: data.author_url || url,
|
||||
thumbnailUrl: data.thumbnail_url || '',
|
||||
embedHtml: data.html || '',
|
||||
videoId: extractTikTokVideoId(url, data.html || ''),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type TikTokEmbedState =
|
||||
type TikTokEmbedState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'loaded'; title: string; authorName: string; authorUrl: string; thumbnailUrl: string; embedHtml: string }
|
||||
| {
|
||||
status: 'loaded';
|
||||
title: string;
|
||||
authorName: string;
|
||||
authorUrl: string;
|
||||
thumbnailUrl: string;
|
||||
embedHtml: string;
|
||||
videoId: string | null;
|
||||
}
|
||||
| { status: 'error' };
|
||||
|
||||
type TikTokEmbedProps = {
|
||||
@@ -88,7 +104,7 @@ type TikTokEmbedProps = {
|
||||
*/
|
||||
export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref) => {
|
||||
const [state, setState] = useState<TikTokEmbedState>({ status: 'loading' });
|
||||
const [showEmbed, setShowEmbed] = useState(false);
|
||||
const [showPlayer, setShowPlayer] = useState(false);
|
||||
|
||||
// Fetch video info on mount
|
||||
useEffect(() => {
|
||||
@@ -96,7 +112,7 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
|
||||
|
||||
fetchTikTokOEmbed(url).then((info) => {
|
||||
if (cancelled) return;
|
||||
|
||||
|
||||
if (info) {
|
||||
setState({
|
||||
status: 'loaded',
|
||||
@@ -105,6 +121,7 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
|
||||
authorUrl: info.authorUrl,
|
||||
thumbnailUrl: info.thumbnailUrl,
|
||||
embedHtml: info.embedHtml,
|
||||
videoId: info.videoId,
|
||||
});
|
||||
} else {
|
||||
setState({ status: 'error' });
|
||||
@@ -134,9 +151,9 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
|
||||
TikTok - Loading...
|
||||
</Text>
|
||||
</div>
|
||||
<Box
|
||||
className={css.TikTokThumbnailContainer}
|
||||
alignItems="Center"
|
||||
<Box
|
||||
className={css.TikTokThumbnailContainer}
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
style={{ background: '#000', display: 'flex' }}
|
||||
>
|
||||
@@ -169,7 +186,7 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
|
||||
}
|
||||
|
||||
// Loaded state - show thumbnail or embed
|
||||
if (!showEmbed) {
|
||||
if (!showPlayer) {
|
||||
return (
|
||||
<Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}>
|
||||
<div className={css.TikTokEmbedHeader}>
|
||||
@@ -203,23 +220,27 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
|
||||
<button
|
||||
type="button"
|
||||
className={css.TikTokThumbnailButton}
|
||||
onClick={() => setShowEmbed(true)}
|
||||
aria-label="Load TikTok video"
|
||||
onClick={() => setShowPlayer(true)}
|
||||
aria-label="Play TikTok video"
|
||||
>
|
||||
{state.thumbnailUrl && (
|
||||
<img
|
||||
src={state.thumbnailUrl}
|
||||
{state.thumbnailUrl ? (
|
||||
<img
|
||||
src={state.thumbnailUrl}
|
||||
alt={state.title}
|
||||
className={css.TikTokThumbnail}
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Show embed (using dangerouslySetInnerHTML for TikTok's oEmbed HTML)
|
||||
const playerUrl = state.videoId
|
||||
? `https://www.tiktok.com/player/v1/${state.videoId}?autoplay=1`
|
||||
: null;
|
||||
|
||||
// Use TikTok's documented player instead of injecting the oEmbed script.
|
||||
return (
|
||||
<Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}>
|
||||
<div className={css.TikTokEmbedHeader}>
|
||||
@@ -236,10 +257,20 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
|
||||
{state.title}
|
||||
</Text>
|
||||
</div>
|
||||
<div
|
||||
className={css.TikTokEmbedContainer}
|
||||
dangerouslySetInnerHTML={{ __html: state.embedHtml }}
|
||||
/>
|
||||
{playerUrl ? (
|
||||
<iframe
|
||||
className={css.TikTokEmbedContainer}
|
||||
src={playerUrl}
|
||||
title="TikTok video player"
|
||||
allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={css.TikTokEmbedContainer}
|
||||
dangerouslySetInnerHTML={{ __html: state.embedHtml }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
DiscordCollectibleItem,
|
||||
collectibleKindLabel,
|
||||
defaultPreviewAspectRatio,
|
||||
fetchPublishedCollectiblesCatalog,
|
||||
groupCollectibleItems,
|
||||
isElectronCollectiblesAvailable,
|
||||
uploadCollectibleToMatrix,
|
||||
variantItemLabel,
|
||||
} from '../../../utils/discordCollectibles';
|
||||
@@ -338,9 +338,6 @@ export function CollectiblesSection({ collectibles, onApply }: CollectiblesSecti
|
||||
);
|
||||
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[]>([]);
|
||||
@@ -350,40 +347,30 @@ export function CollectiblesSection({ collectibles, onApply }: CollectiblesSecti
|
||||
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.');
|
||||
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(() => {
|
||||
refreshTokenState();
|
||||
}, [refreshTokenState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasToken) {
|
||||
loadCatalog();
|
||||
}
|
||||
}, [hasToken, loadCatalog]);
|
||||
loadCatalog();
|
||||
}, [loadCatalog]);
|
||||
|
||||
const current = collectibles[KIND_FIELD[kind]];
|
||||
|
||||
@@ -416,27 +403,6 @@ export function CollectiblesSection({ collectibles, onApply }: CollectiblesSecti
|
||||
});
|
||||
}, [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);
|
||||
@@ -467,62 +433,27 @@ 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 }}>
|
||||
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.
|
||||
Browse a community-published Discord collectibles catalog, then download and upload only what
|
||||
you pick to Matrix.
|
||||
</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>
|
||||
)}
|
||||
<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>
|
||||
|
||||
{hasToken && (
|
||||
<>
|
||||
<>
|
||||
<Box gap="200" wrap="Wrap">
|
||||
{KIND_TABS.map((tab) => (
|
||||
<Button
|
||||
@@ -559,7 +490,7 @@ export function CollectiblesSection({ collectibles, onApply }: CollectiblesSecti
|
||||
{catalogLoading && (
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Spinner size="100" variant="Secondary" />
|
||||
<Text size="T200">Loading catalog from Discord…</Text>
|
||||
<Text size="T200">Loading collectibles catalog…</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -606,8 +537,7 @@ export function CollectiblesSection({ collectibles, onApply }: CollectiblesSecti
|
||||
{!catalogLoading && filteredGroups.length === 0 && (
|
||||
<Text size="T200" style={{ opacity: 0.7 }}>No items match your search.</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
3
src/ext.d.ts
vendored
3
src/ext.d.ts
vendored
@@ -114,9 +114,6 @@ interface ElectronAPI {
|
||||
}) => void) => void;
|
||||
};
|
||||
discordCollectibles?: {
|
||||
hasToken: () => Promise<{ success: boolean; data?: boolean; error?: string }>;
|
||||
setToken: (token: string) => Promise<{ success: boolean; error?: string }>;
|
||||
clearToken: () => Promise<{ success: boolean; error?: string }>;
|
||||
fetchCatalog: (force?: boolean) => Promise<{
|
||||
success: boolean;
|
||||
data?: { items: unknown[]; fetchedAt: string };
|
||||
|
||||
Reference in New Issue
Block a user