5 Commits

Author SHA1 Message Date
47979718eb Fix responsive message images
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Constrain image attachments to their parent while preserving their aspect ratio.
2026-08-25 00:02:45 +10:00
862307ee7d Add TikTok player iframe
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Use TikTok's supported player endpoint rather than injecting its oEmbed script.
2026-08-24 23:58:31 +10:00
b16263b481 Enable native HTTP for Capacitor.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
Route mobile fetch requests through Capacitor's native network bridge to support redirected collectible catalog downloads.
2026-08-24 20:06:27 +10:00
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
12 changed files with 229 additions and 157 deletions

View File

@@ -7,6 +7,9 @@
"LocalNotifications": {
"smallIcon": "ic_stat_paarrot",
"iconColor": "#FF8A00"
},
"CapacitorHttp": {
"enabled": true
}
}
}

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

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

View File

@@ -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',
},

View File

@@ -112,5 +112,7 @@ export const TikTokEmbedContainer = style([
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#000',
border: 'none',
display: 'block',
},
]);

View File

@@ -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,6 +59,7 @@ 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)}`;
@@ -67,6 +74,7 @@ async function fetchTikTokOEmbed(url: string): Promise<{
authorUrl: data.author_url || url,
thumbnailUrl: data.thumbnail_url || '',
embedHtml: data.html || '',
videoId: extractTikTokVideoId(url, data.html || ''),
};
} catch {
return null;
@@ -75,7 +83,15 @@ async function fetchTikTokOEmbed(url: string): Promise<{
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(() => {
@@ -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' });
@@ -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 && (
{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>
{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>
);
});

View File

@@ -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);
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 Discord catalog.');
throw new Error(result?.error || 'Failed to load collectibles catalog.');
}
setItems(result.data.items as DiscordCollectibleItem[]);
} else {
setItems(await fetchPublishedCollectiblesCatalog(force));
}
} 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]);
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,61 +433,26 @@ 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)}
/>
<Box gap="200" alignItems="Center" wrap="Wrap">
<Button
size="300"
variant="Primary"
fill="Solid"
variant="Secondary"
fill="Soft"
radii="300"
onClick={handleSaveToken}
disabled={tokenSaving || !tokenInput.trim()}
onClick={() => loadCatalog(true)}
disabled={catalogLoading}
>
<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">
{KIND_TABS.map((tab) => (
@@ -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>
)}
@@ -607,7 +538,6 @@ export function CollectiblesSection({ collectibles, onApply }: CollectiblesSecti
<Text size="T200" style={{ opacity: 0.7 }}>No items match your search.</Text>
)}
</>
)}
</Box>
);
}

View File

@@ -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,15 +151,11 @@ 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,
@@ -91,6 +165,19 @@ export async function downloadCollectibleAssets(
}));
}
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(
mx: MatrixClient,
item: DiscordCollectibleItem,

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