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.
This commit is contained in:
2026-08-24 19:29:48 +10:00
parent d59945eb4c
commit 87c9f59826
6 changed files with 55 additions and 97 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

@@ -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[]>([]);
@@ -352,12 +349,6 @@ export function CollectiblesSection({ collectibles, onApply }: CollectiblesSecti
const desktopAvailable = isElectronCollectiblesAvailable(); 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; if (!desktopAvailable) return;
setCatalogLoading(true); setCatalogLoading(true);
@@ -376,14 +367,8 @@ export function CollectiblesSection({ collectibles, onApply }: CollectiblesSecti
}, [desktopAvailable]); }, [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 +401,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);
@@ -481,48 +445,24 @@ export function CollectiblesSection({ collectibles, onApply }: CollectiblesSecti
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 +499,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 +546,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>
); );
} }

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 };