Compare commits

1 Commits

Author SHA1 Message Date
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
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 };