Compare commits

2 Commits

Author SHA1 Message Date
c34cfaf33f Enable collectibles on mobile clients.
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.
2026-08-24 19:52:50 +10:00
87c9f59826 Use public Discord collectibles catalog.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Remove local token setup in favor of the cached published catalog and document the collectible profile experience in the latest update notes.
2026-08-24 19:29:48 +10:00
7 changed files with 169 additions and 133 deletions

22
public/update/4.11.177.md Normal file
View 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.
![Paarrot](./images/usernamecolors.png)
## 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.

View File

@@ -1,22 +1,22 @@
--- ---
version: 4.11.177 version: 4.11.185
title: Profile colors & banners title: Discord collectibles
date: 2026-08-23 date: 2026-08-24
summary: Username colors now follow MSC4522 with separate dark and light theme pickers. 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.
![Paarrot](./images/usernamecolors.png) ![Discord Cosmetics 1](./images/discordcosmetics1.png) ![Discord Cosmetics 2](./images/discordcosmetics2.png)
## Profile banners (MSC4133) ## Profile presentation
Profile banners now live on your Matrix profile via `m.banner_url` (MSC4133), not embedded in avatar metadata. - 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.
- Upload or remove a banner independently from your avatar. - Customize the sidebar nameplate opacity in Settings → General → Collectibles.
- Other clients that support MSC4133 can read your banner directly.

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

View File

@@ -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';
@@ -338,9 +338,6 @@ export function CollectiblesSection({ collectibles, onApply }: CollectiblesSecti
); );
const previewDisplayName = profile.displayName; const previewDisplayName = profile.displayName;
const [kind, setKind] = useState<CollectibleKind>('profile_effect'); 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 [catalogLoading, setCatalogLoading] = useState(false);
const [catalogError, setCatalogError] = useState<string>(); const [catalogError, setCatalogError] = useState<string>();
const [items, setItems] = useState<DiscordCollectibleItem[]>([]); const [items, setItems] = useState<DiscordCollectibleItem[]>([]);
@@ -350,40 +347,30 @@ 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 refreshTokenState = useCallback(async () => {
if (!desktopAvailable) return;
const result = await window.electron!.discordCollectibles!.hasToken();
setHasToken(Boolean(result?.data));
}, [desktopAvailable]);
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 (!result?.success || !result.data?.items) { if (desktopCatalog) {
throw new Error(result?.error || 'Failed to load Discord catalog.'); 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) { } 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(() => {
refreshTokenState(); loadCatalog();
}, [refreshTokenState]); }, [loadCatalog]);
useEffect(() => {
if (hasToken) {
loadCatalog();
}
}, [hasToken, loadCatalog]);
const current = collectibles[KIND_FIELD[kind]]; const current = collectibles[KIND_FIELD[kind]];
@@ -416,27 +403,6 @@ export function CollectiblesSection({ collectibles, onApply }: CollectiblesSecti
}); });
}, [filteredGroups]); }, [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) => { const handleApply = async (item: DiscordCollectibleItem) => {
setApplyingId(item.id); setApplyingId(item.id);
setError(undefined); setError(undefined);
@@ -467,62 +433,27 @@ 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 }}>
Browse Discord shop collectibles live, then download and upload only what you pick to Matrix. Browse a community-published Discord collectibles catalog, then download and upload only what
Your Discord token stays on this device and is only used to list the shop catalog. you pick to Matrix.
</Text> </Text>
{!hasToken ? ( <Box gap="200" alignItems="Center" wrap="Wrap">
<Box direction="Column" gap="200"> <Button
<Text size="T300">Discord token (required to list the shop)</Text> size="300"
<Input variant="Secondary"
size="300" fill="Soft"
variant="Secondary" radii="300"
style={{ width: '100%' }} onClick={() => loadCatalog(true)}
type="password" disabled={catalogLoading}
placeholder="User token or Bot xxxxx" >
value={tokenInput} <Text size="B300">{catalogLoading ? 'Refreshing…' : 'Refresh catalog'}</Text>
onChange={(e) => setTokenInput(e.target.value)} </Button>
/> </Box>
<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"> <Box gap="200" wrap="Wrap">
{KIND_TABS.map((tab) => ( {KIND_TABS.map((tab) => (
<Button <Button
@@ -559,7 +490,7 @@ export function CollectiblesSection({ collectibles, onApply }: CollectiblesSecti
{catalogLoading && ( {catalogLoading && (
<Box gap="200" alignItems="Center"> <Box gap="200" alignItems="Center">
<Spinner size="100" variant="Secondary" /> <Spinner size="100" variant="Secondary" />
<Text size="T200">Loading catalog from Discord</Text> <Text size="T200">Loading collectibles catalog</Text>
</Box> </Box>
)} )}
@@ -606,8 +537,7 @@ export function CollectiblesSection({ collectibles, onApply }: CollectiblesSecti
{!catalogLoading && filteredGroups.length === 0 && ( {!catalogLoading && filteredGroups.length === 0 && (
<Text size="T200" style={{ opacity: 0.7 }}>No items match your search.</Text> <Text size="T200" style={{ opacity: 0.7 }}>No items match your search.</Text>
)} )}
</> </>
)}
</Box> </Box>
); );
} }

View File

@@ -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,22 +151,31 @@ 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);
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); return Promise.all(
if (!result?.success || !result.data) { assets.map(async (asset) => {
throw new Error(result?.error || 'Failed to download collectible assets from Discord CDN.'); 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 result.data.map((asset) => ({ return {
role: asset.role, ...asset,
url: asset.url, data: new Uint8Array(await response.arrayBuffer()),
filename: asset.filename, };
mimeType: asset.mimeType, })
data: asset.data instanceof Uint8Array ? asset.data : new Uint8Array(asset.data), );
}));
} }
export async function uploadCollectibleToMatrix( export async function uploadCollectibleToMatrix(

3
src/ext.d.ts vendored
View File

@@ -114,9 +114,6 @@ interface ElectronAPI {
}) => void) => void; }) => void) => void;
}; };
discordCollectibles?: { 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<{ fetchCatalog: (force?: boolean) => Promise<{
success: boolean; success: boolean;
data?: { items: unknown[]; fetchedAt: string }; data?: { items: unknown[]; fetchedAt: string };