Compare commits

2 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
d59945eb4c Add configurable sidebar nameplate background.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Render the equipped nameplate behind the larger settings avatar with blur-aware sidebar controls, adjustable opacity, and immediate collectible cache invalidation.
2026-08-24 18:48:48 +10:00
12 changed files with 224 additions and 100 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
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.
![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.
- 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

View File

@@ -9,6 +9,8 @@ export const Sidebar = style([
width: toRem(66),
backgroundColor: color.Background.Container,
borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
position: 'relative',
isolation: 'isolate',
display: 'flex',
flexDirection: 'column',
@@ -185,6 +187,46 @@ export const SidebarAvatar = recipe({
});
export type SidebarAvatarVariants = RecipeVariants<typeof SidebarAvatar>;
/** A vertical rendition of the user's nameplate behind the bottom settings avatar. */
export const SidebarAvatarNameplate = style({
position: 'absolute',
// The 50px avatar is centered in the 66px sidebar. Pin the rotated plate's
// bottom-right corner 8px left of it so the 66px cross-axis
// spans the whole sidebar and grows upward from its bottom edge.
right: toRem(58),
bottom: toRem(-16),
width: toRem(220),
height: toRem(66),
transformOrigin: 'right bottom',
transform: 'rotate(90deg)',
objectFit: 'cover',
objectPosition: 'right center',
pointerEvents: 'none',
zIndex: -1,
});
export const SidebarAvatarLarge = style({
width: toRem(50),
height: toRem(50),
borderRadius: '50%',
backgroundColor: `color-mix(in srgb, ${color.Background.Container} 68%, transparent)`,
backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)',
});
export const SidebarSearchAvatar = style({
position: 'relative',
zIndex: 1,
backgroundColor: `color-mix(in srgb, ${color.Background.Container} 68%, transparent)`,
backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)',
});
export const SidebarAvatarForeground = style({
position: 'relative',
zIndex: 1,
});
export const SidebarFolder = recipe({
base: [
ContainerColor({ variant: 'Background' }),

View File

@@ -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[]>([]);
@@ -352,12 +349,6 @@ export function CollectiblesSection({ collectibles, onApply }: CollectiblesSecti
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);
@@ -376,14 +367,8 @@ export function CollectiblesSection({ collectibles, onApply }: CollectiblesSecti
}, [desktopAvailable]);
useEffect(() => {
refreshTokenState();
}, [refreshTokenState]);
useEffect(() => {
if (hasToken) {
loadCatalog();
}
}, [hasToken, loadCatalog]);
loadCatalog();
}, [loadCatalog]);
const current = collectibles[KIND_FIELD[kind]];
@@ -416,27 +401,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);
@@ -481,48 +445,24 @@ export function CollectiblesSection({ collectibles, onApply }: CollectiblesSecti
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 +499,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 +546,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>
);
}

View File

@@ -9,6 +9,7 @@ import React, {
import dayjs from 'dayjs';
import { as, Box, Button, Chip, color, config, Header, IconButton, Input, Menu, MenuItem, PopOut, RectCords, Scroll, Switch, Text, toRem } from 'folds';
import { useSetAtom } from 'jotai';
import { Range } from 'react-range';
import { Icon, Icons } from '../../../components/icons';
import { HexColorPicker } from 'react-colorful';
import { isKeyHotkey } from 'is-hotkey';
@@ -334,6 +335,66 @@ const emojiStyleNames: Record<EmojiStyle, string> = {
[EmojiStyle.Twemoji]: 'Twemoji',
};
function SidebarNameplate() {
const [sidebarNameplateOpacity, setSidebarNameplateOpacity] = useSetting(
settingsAtom,
'sidebarNameplateOpacity'
);
return (
<Box direction="Column" gap="100">
<Text size="L400">Collectibles</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Sidebar Nameplate Opacity"
description="Set to 0% to hide your equipped nameplate behind the bottom sidebar avatar."
after={
<Box gap="200" alignItems="Center" style={{ width: toRem(180) }}>
<Range
step={1}
min={0}
max={100}
values={[sidebarNameplateOpacity]}
onChange={(values) => setSidebarNameplateOpacity(values[0])}
renderTrack={(params) => (
<div
{...params.props}
style={{
...params.props.style,
width: '100%',
height: toRem(8),
borderRadius: toRem(4),
background: `linear-gradient(to right, ${color.Primary.Main} ${sidebarNameplateOpacity}%, ${color.Surface.ContainerLine} ${sidebarNameplateOpacity}%)`,
}}
>
{params.children}
</div>
)}
renderThumb={(params) => (
<div
{...params.props}
style={{
...params.props.style,
width: toRem(16),
height: toRem(16),
borderRadius: '50%',
backgroundColor: color.Primary.Main,
border: `${toRem(2)} solid ${color.Surface.Container}`,
}}
/>
)}
/>
<Text size="T300" style={{ minWidth: toRem(32), textAlign: 'right' }}>
{sidebarNameplateOpacity}%
</Text>
</Box>
}
/>
</SequenceCard>
</Box>
);
}
type EmojiStyleSelectorProps = {
selected: EmojiStyle;
onSelect: (style: EmojiStyle) => void;
@@ -1222,6 +1283,7 @@ export function General({ requestClose }: GeneralProps) {
<PageContent>
<Box direction="Column" gap="700">
<Appearance />
<SidebarNameplate />
<DateAndTime />
<Editor />
<Spaces />

View File

@@ -52,15 +52,18 @@ export function useUserCollectibles(): [
async (kind: CollectibleKind, collectible: StoredCollectible | undefined) => {
if (kind === 'profile_effect') {
await saveProfileEffect(mx, collectible);
clearUserCollectiblesCache(mx.getUserId());
setCollectibles((prev) => ({ ...prev, profile_effect: collectible }));
return;
}
if (kind === 'nameplate') {
await saveNameplate(mx, collectible);
clearUserCollectiblesCache(mx.getUserId());
setCollectibles((prev) => ({ ...prev, nameplate: collectible }));
return;
}
await saveAvatarDecoration(mx, collectible);
clearUserCollectiblesCache(mx.getUserId());
setCollectibles((prev) => ({ ...prev, avatar_decoration: collectible }));
},
[mx]
@@ -70,8 +73,15 @@ export function useUserCollectibles(): [
}
const collectibleCache = new Map<string, { data: CollectibleProfileFields; timestamp: number }>();
const collectibleCacheListeners = new Set<(userId: string) => void>();
const CACHE_TTL_MS = 5 * 60 * 1000;
export function clearUserCollectiblesCache(userId?: string): void {
if (!userId) return;
collectibleCache.delete(userId);
collectibleCacheListeners.forEach((listener) => listener(userId));
}
async function fetchMxcBlobUrl(
mx: ReturnType<typeof useMatrixClient>,
mxc: string,
@@ -100,6 +110,17 @@ export function useOtherUserCollectibles(userId: string): CollectibleProfileFiel
const useAuthentication = useMediaAuthentication();
const [collectibles, setCollectibles] = useState<CollectibleProfileFields>({});
const [assetUrls, setAssetUrls] = useState<Record<string, string | undefined>>({});
const [cacheVersion, setCacheVersion] = useState(0);
useEffect(() => {
const handleCacheClear = (clearedUserId: string) => {
if (clearedUserId === userId) {
setCacheVersion((version) => version + 1);
}
};
collectibleCacheListeners.add(handleCacheClear);
return () => collectibleCacheListeners.delete(handleCacheClear);
}, [userId]);
useEffect(() => {
if (!userId) {
@@ -152,7 +173,7 @@ export function useOtherUserCollectibles(userId: string): CollectibleProfileFiel
cancelled = true;
blobUrls.forEach((url) => URL.revokeObjectURL(url));
};
}, [mx, useAuthentication, userId]);
}, [cacheVersion, mx, useAuthentication, userId]);
return { ...collectibles, assetUrls };
}

View File

@@ -4,6 +4,7 @@ import React from 'react';
import { useAtom } from 'jotai';
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar';
import { searchModalAtom } from '../../../state/searchModal';
import * as sidebarCss from '../../../components/sidebar/Sidebar.css';
export function SearchTab() {
const [opened, setOpen] = useAtom(searchModalAtom);
@@ -14,7 +15,13 @@ export function SearchTab() {
<SidebarItem active={opened}>
<SidebarItemTooltip tooltip="Search">
{(triggerRef) => (
<SidebarAvatar as="button" ref={triggerRef} outlined onClick={open}>
<SidebarAvatar
as="button"
ref={triggerRef}
className={sidebarCss.SidebarSearchAvatar}
outlined
onClick={open}
>
<Icon src={Icons.Search} filled={opened} />
</SidebarAvatar>
)}

View File

@@ -12,8 +12,11 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { Settings } from '../../../features/settings';
import { useUserProfile } from '../../../hooks/useUserProfile';
import { useOtherUserCollectibles } from '../../../hooks/useUserCollectibles';
import { pickAvatarDecorationUrl } from '../../../utils/collectibleAssets';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { pickAvatarDecorationUrl, pickNameplateUrl } from '../../../utils/collectibleAssets';
import * as avatarDecorationCss from '../../../styles/AvatarDecoration.css';
import * as sidebarCss from '../../../components/sidebar/Sidebar.css';
import { Modal500 } from '../../../components/Modal500';
import { AccountSwitcher } from '../../../components/account-switcher';
import { stopPropagation } from '../../../utils/keyboard';
@@ -39,6 +42,11 @@ export function SettingsTab() {
: undefined;
const { assetUrls } = useOtherUserCollectibles(userId ?? '');
const avatarDecorationUrl = pickAvatarDecorationUrl(assetUrls);
const nameplateUrl = pickNameplateUrl(assetUrls);
const [sidebarNameplateOpacity] = useSetting(settingsAtom, 'sidebarNameplateOpacity');
const nameplateIsVideo = Boolean(
assetUrls['nameplate:animated'] || assetUrls['nameplate:asset.webm']
);
const openSettings = () => setSettings(true);
const closeSettings = () => setSettings(false);
@@ -53,12 +61,36 @@ export function SettingsTab() {
<SidebarItem active={settings}>
<SidebarAvatar
as="button"
className={sidebarCss.SidebarAvatarLarge}
onClick={openSettings}
onContextMenu={handleContextMenu}
style={{ overflow: 'visible' }}
>
<div className={avatarDecorationCss.SidebarAvatarStack}>
{sidebarNameplateOpacity > 0 &&
nameplateUrl &&
(nameplateIsVideo ? (
<video
className={sidebarCss.SidebarAvatarNameplate}
src={nameplateUrl}
style={{ opacity: sidebarNameplateOpacity / 100 }}
autoPlay
loop
muted
playsInline
aria-hidden="true"
/>
) : (
<img
className={sidebarCss.SidebarAvatarNameplate}
src={nameplateUrl}
style={{ opacity: sidebarNameplateOpacity / 100 }}
alt=""
draggable={false}
/>
))}
<UserAvatar
className={sidebarCss.SidebarAvatarForeground}
userId={userId ?? ''}
src={avatarUrl}
renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>}

View File

@@ -50,6 +50,7 @@ export interface Settings {
encUrlPreview: boolean;
showHiddenEvents: boolean;
legacyUsernameColor: boolean;
sidebarNameplateOpacity: number;
showNotifications: boolean;
isNotificationSounds: boolean;
@@ -93,6 +94,7 @@ const defaultSettings: Settings = {
encUrlPreview: false,
showHiddenEvents: false,
legacyUsernameColor: false,
sidebarNameplateOpacity: 60,
showNotifications: true,
isNotificationSounds: true,

3
src/ext.d.ts vendored
View File

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