Add Discord collectibles to profiles, messages, and settings.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s

Integrate profile effects, nameplates, and avatar decorations with live catalog browsing, Matrix profile storage, and rendering across user heroes, DMs, messages, and the sidebar avatar.
This commit is contained in:
2026-08-24 00:54:48 +10:00
parent 6cb1e14632
commit 0cbc5d9a1c
25 changed files with 2473 additions and 174 deletions

View File

@@ -0,0 +1,102 @@
import { DiscordCollectibleItem } from './discordCollectibles';
function introDurationFromEffectMeta(effect: Record<string, unknown> | undefined): number | undefined {
if (!effect) return undefined;
const raw = effect.duration ?? effect.durationMs;
if (typeof raw !== 'number' || raw <= 0) return undefined;
return raw > 100 ? raw : raw * 1000;
}
function catalogAssetUrl(item: DiscordCollectibleItem, ...roles: string[]): string | undefined {
for (const role of roles) {
const asset = item.assets.find((entry) => entry.role === role);
if (asset?.url) return asset.url;
}
return undefined;
}
export function pickCatalogStaticPreviewUrl(item: DiscordCollectibleItem): string | undefined {
switch (item.type) {
case 'nameplate':
return catalogAssetUrl(item, 'static') ?? item.thumbnailUrl;
case 'avatar_decoration':
return catalogAssetUrl(item, 'static') ?? item.thumbnailUrl;
case 'profile_effect':
return catalogAssetUrl(item, 'thumbnail', 'reduced_motion') ?? item.thumbnailUrl;
default:
return item.thumbnailUrl;
}
}
export function pickCatalogAnimatedPreviewUrl(item: DiscordCollectibleItem): string | undefined {
switch (item.type) {
case 'nameplate':
return catalogAssetUrl(item, 'animated');
case 'avatar_decoration':
return catalogAssetUrl(item, 'animated');
case 'profile_effect':
return pickCatalogProfileEffectLoopUrl(item);
default:
return undefined;
}
}
export function pickCatalogProfileEffectIntroUrl(item: DiscordCollectibleItem): string | undefined {
return catalogAssetUrl(item, 'effect_0');
}
export function pickCatalogProfileEffectLoopUrl(item: DiscordCollectibleItem): string | undefined {
return catalogAssetUrl(item, 'effect_1', 'effect_0', 'effect_2', 'effect_3');
}
export function catalogProfileEffectIntroDurationMs(item: DiscordCollectibleItem): number | undefined {
const effect = item.effect?.effects?.[0] as Record<string, unknown> | undefined;
return introDurationFromEffectMeta(effect);
}
export function pickStoredProfileEffectIntroUrl(
assetUrls: Record<string, string | undefined>
): string | undefined {
return assetUrls['profile_effect:effect_0'];
}
export function pickStoredProfileEffectLoopUrl(
assetUrls: Record<string, string | undefined>
): string | undefined {
return (
assetUrls['profile_effect:effect_1'] ||
assetUrls['profile_effect:effect_0'] ||
assetUrls['profile_effect:effect_2'] ||
assetUrls['profile_effect:effect_3']
);
}
export function isCatalogVideoPreview(url?: string): boolean {
return Boolean(url && (url.includes('.webm') || url.includes('asset.webm')));
}
export function pickNameplateUrl(
assetUrls: Record<string, string | undefined>
): string | undefined {
return (
assetUrls['nameplate:animated'] ||
assetUrls['nameplate:asset.webm'] ||
assetUrls['nameplate:static'] ||
assetUrls['nameplate:static.png']
);
}
export function pickAvatarDecorationUrl(
assetUrls: Record<string, string | undefined>
): string | undefined {
return assetUrls['avatar_decoration:animated'] || assetUrls['avatar_decoration:animated.png'];
}
export function pickProfileEffectUrl(
assetUrls: Record<string, string | undefined>
): string | undefined {
return (
pickStoredProfileEffectLoopUrl(assetUrls) ||
assetUrls['profile_effect:reduced_motion']
);
}

View File

@@ -0,0 +1,236 @@
import { MatrixClient } from 'matrix-js-sdk';
import { StoredCollectible } from './profileFields';
export type CollectibleKind = 'profile_effect' | 'nameplate' | 'avatar_decoration';
export type DiscordCollectibleAsset = {
role: string;
url: string;
filename: string;
mimeType: string;
};
export type DiscordCollectibleItem = {
id: string;
skuId: string;
name: string;
type: CollectibleKind;
category: string;
label: string;
thumbnailUrl?: string;
previewAspectRatio?: number;
previewColors?: number[];
previewGradient?: string;
palette?: string;
paletteLabel?: string;
assets: DiscordCollectibleAsset[];
effect?: {
animationType?: number;
thumbnailPreviewSrc?: string;
reducedMotionSrc?: string;
effects?: Array<Record<string, unknown>>;
};
};
export type DownloadedCollectibleAsset = DiscordCollectibleAsset & {
data: Uint8Array;
};
const CDN_HOST_RE = /(^|\.)(discordapp\.com|discordapp\.net|discord\.com)$/i;
export function isElectronCollectiblesAvailable(): boolean {
return Boolean(window.electron?.discordCollectibles);
}
function isCdnUrl(value: unknown): value is string {
if (typeof value !== 'string' || !value.startsWith('http')) return false;
try {
return CDN_HOST_RE.test(new URL(value).hostname);
} catch {
return false;
}
}
function replaceCdnUrls<T>(value: T, urlToMxc: Map<string, string>): T {
if (value == null) return value;
if (typeof value === 'string') {
return (isCdnUrl(value) && urlToMxc.has(value) ? urlToMxc.get(value) : value) as T;
}
if (Array.isArray(value)) {
return value.map((entry) => replaceCdnUrls(entry, urlToMxc)) as T;
}
if (typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
out[key] = replaceCdnUrls(entry, urlToMxc);
}
return out as T;
}
return value;
}
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.');
}
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),
}));
}
export async function uploadCollectibleToMatrix(
mx: MatrixClient,
item: DiscordCollectibleItem,
onProgress?: (message: string) => void
): Promise<StoredCollectible> {
onProgress?.('Downloading from Discord…');
const downloaded = await downloadCollectibleAssets(item.assets);
const urlToMxc = new Map<string, string>();
const assets: Record<string, string> = {};
for (const asset of downloaded) {
onProgress?.(`Uploading ${asset.filename}`);
const blob = new Blob([asset.data], { type: asset.mimeType });
const file = new File([blob], asset.filename, { type: asset.mimeType });
const response = await mx.uploadContent(file, {
name: asset.filename,
type: asset.mimeType,
includeFilename: true,
});
if (!response.content_uri) {
throw new Error(`Failed to upload ${asset.filename} to Matrix.`);
}
urlToMxc.set(asset.url, response.content_uri);
assets[asset.role] = response.content_uri;
}
const stored: StoredCollectible = {
sku_id: item.skuId,
name: item.name,
assets,
};
if (item.effect) {
stored.effect = replaceCdnUrls(item.effect, urlToMxc);
}
return stored;
}
export function collectibleKindLabel(kind: CollectibleKind): string {
switch (kind) {
case 'profile_effect':
return 'Profile effect';
case 'nameplate':
return 'Nameplate';
case 'avatar_decoration':
return 'Avatar decoration';
default:
return kind;
}
}
export type CollectibleVariantGroup = {
id: string;
name: string;
category: string;
items: DiscordCollectibleItem[];
};
function stripBundleSuffix(name: string): string {
return name.replace(/\s+Bundle$/i, '').trim();
}
function stripVariantSuffix(name: string): string {
return name.replace(/\s*\([^)]+\)\s*$/, '').trim();
}
export function variantGroupKey(item: DiscordCollectibleItem): string {
const baseName = stripVariantSuffix(stripBundleSuffix(item.name));
return `${item.type}:${baseName}`;
}
export function variantGroupDisplayName(item: DiscordCollectibleItem): string {
return stripVariantSuffix(stripBundleSuffix(item.name));
}
export function variantItemLabel(item: DiscordCollectibleItem): string {
if (item.paletteLabel) return item.paletteLabel;
const nameMatch = item.name.match(/\(([^)]+)\)\s*$/);
if (nameMatch) return nameMatch[1];
const labelMatch = item.label.match(/\(([^)]+)\)\s*$/);
if (labelMatch) return labelMatch[1];
if (item.label && item.label !== item.name) return item.label;
return 'Default';
}
function dedupeVariantItems(items: DiscordCollectibleItem[]): DiscordCollectibleItem[] {
const seen = new Set<string>();
return items.filter((item) => {
if (seen.has(item.skuId)) return false;
seen.add(item.skuId);
return true;
});
}
export function groupCollectibleItems(items: DiscordCollectibleItem[]): CollectibleVariantGroup[] {
const map = new Map<string, DiscordCollectibleItem[]>();
for (const item of items) {
const key = variantGroupKey(item);
const list = map.get(key);
if (list) list.push(item);
else map.set(key, [item]);
}
return [...map.entries()]
.map(([id, groupItems]) => {
const items = dedupeVariantItems(groupItems).sort((a, b) =>
variantItemLabel(a).localeCompare(variantItemLabel(b))
);
return {
id,
name: variantGroupDisplayName(items[0]),
category: items[0].category,
items,
};
})
.sort((a, b) => a.name.localeCompare(b.name));
}
export function defaultPreviewAspectRatio(kind: CollectibleKind): number {
switch (kind) {
case 'profile_effect':
return 450 / 880;
case 'nameplate':
return 448 / 84;
case 'avatar_decoration':
return 1;
default:
return 1;
}
}
export function gradientFromPreviewColors(colors?: number[]): string | undefined {
if (!colors?.length) return undefined;
const toRgb = (value: number) => {
const n = value >>> 0;
return `rgb(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255})`;
};
if (colors.length === 1) return toRgb(colors[0]);
return `linear-gradient(135deg, ${toRgb(colors[0])}, ${toRgb(colors[1])})`;
}

View File

@@ -0,0 +1,28 @@
/** Discord nameplate palette IDs from the API — mapped to representative gradient colors. */
const NAMEPLATE_PALETTE_GRADIENTS: Record<string, string> = {
crimson: 'linear-gradient(135deg, #5c0a1c, #dc143c)',
berry: 'linear-gradient(135deg, #4a1030, #c42d78)',
sky: 'linear-gradient(135deg, #0a2a5c, #3b8eed)',
teal: 'linear-gradient(135deg, #0a3d3d, #2dd4bf)',
forest: 'linear-gradient(135deg, #0a2e1a, #22c55e)',
bubble_gum: 'linear-gradient(135deg, #4a1038, #f472b6)',
violet: 'linear-gradient(135deg, #2d1050, #8b5cf6)',
cobalt: 'linear-gradient(135deg, #0a1448, #3b5bdb)',
clover: 'linear-gradient(135deg, #0a3d20, #4ade80)',
lemon: 'linear-gradient(135deg, #4a3d0a, #fbbf24)',
white: 'linear-gradient(135deg, #888888, #f0f0f0)',
black: 'linear-gradient(135deg, #1a1a1a, #404040)',
};
export function nameplatePaletteGradient(palette?: string): string | undefined {
if (!palette) return undefined;
return NAMEPLATE_PALETTE_GRADIENTS[palette];
}
export function formatNameplatePalette(palette?: string): string | undefined {
if (!palette) return undefined;
return palette
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}

View File

@@ -13,6 +13,28 @@ export const PROFILE_KEY_BANNER_URL_UNSTABLE = 'chat.commet.profile_banner';
/** MSC4427 stable profile field for banner */
export const PROFILE_KEY_BANNER_URL_STABLE = 'm.banner_url';
/** Paarrot profile effect (animated profile background) */
export const PROFILE_KEY_PROFILE_EFFECT = 'im.paarrot.profile_effect';
/** Paarrot nameplate (username bar background) */
export const PROFILE_KEY_NAMEPLATE = 'im.paarrot.nameplate';
/** Paarrot avatar decoration (avatar frame) */
export const PROFILE_KEY_AVATAR_DECORATION = 'im.paarrot.avatar_decoration';
export type StoredCollectible = {
sku_id: string;
name: string;
assets: Record<string, string>;
effect?: unknown;
};
export type CollectibleProfileFields = {
profile_effect?: StoredCollectible;
nameplate?: StoredCollectible;
avatar_decoration?: StoredCollectible;
};
export type ColorPreference = {
on_dark?: string;
on_light?: string;
@@ -66,6 +88,89 @@ export function extractBannerUrlFromProfile(profile: Record<string, unknown>): s
return undefined;
}
function parseStoredCollectible(value: unknown): StoredCollectible | undefined {
if (!value || typeof value !== 'object') return undefined;
const obj = value as Record<string, unknown>;
const sku_id = typeof obj.sku_id === 'string' ? obj.sku_id : undefined;
const name = typeof obj.name === 'string' ? obj.name : undefined;
const assets = obj.assets;
if (!sku_id || !name || !assets || typeof assets !== 'object') return undefined;
const parsedAssets: Record<string, string> = {};
for (const [key, mxc] of Object.entries(assets as Record<string, unknown>)) {
if (typeof mxc === 'string' && mxc.startsWith('mxc://')) {
parsedAssets[key] = mxc;
}
}
if (Object.keys(parsedAssets).length === 0) return undefined;
return {
sku_id,
name,
assets: parsedAssets,
effect: obj.effect,
};
}
export function extractCollectiblesFromProfile(profile: Record<string, unknown>): CollectibleProfileFields {
return {
profile_effect: parseStoredCollectible(profile[PROFILE_KEY_PROFILE_EFFECT]),
nameplate: parseStoredCollectible(profile[PROFILE_KEY_NAMEPLATE]),
avatar_decoration: parseStoredCollectible(profile[PROFILE_KEY_AVATAR_DECORATION]),
};
}
async function loadCollectiblesFromProfile(mx: MatrixClient, userId: string): Promise<CollectibleProfileFields> {
if (await mx.doesServerSupportExtendedProfiles()) {
try {
const profile = await mx.getExtendedProfile(userId);
return extractCollectiblesFromProfile(profile);
} catch {
// fall through
}
}
try {
const profile = (await mx.getProfileInfo(userId)) as Record<string, unknown>;
return extractCollectiblesFromProfile(profile);
} catch {
return {};
}
}
export async function loadUserCollectibles(mx: MatrixClient, userId: string): Promise<CollectibleProfileFields> {
return loadCollectiblesFromProfile(mx, userId);
}
export async function saveCollectible(
mx: MatrixClient,
key: string,
collectible: StoredCollectible | undefined
): Promise<void> {
if (!(await mx.doesServerSupportExtendedProfiles())) {
throw new Error('Server does not support extended profile fields (MSC4133)');
}
if (!collectible) {
await mx.deleteExtendedProfileProperty(key);
return;
}
await mx.setExtendedProfileProperty(key, collectible);
}
export async function saveProfileEffect(mx: MatrixClient, collectible: StoredCollectible | undefined): Promise<void> {
await saveCollectible(mx, PROFILE_KEY_PROFILE_EFFECT, collectible);
}
export async function saveNameplate(mx: MatrixClient, collectible: StoredCollectible | undefined): Promise<void> {
await saveCollectible(mx, PROFILE_KEY_NAMEPLATE, collectible);
}
export async function saveAvatarDecoration(mx: MatrixClient, collectible: StoredCollectible | undefined): Promise<void> {
await saveCollectible(mx, PROFILE_KEY_AVATAR_DECORATION, collectible);
}
export async function getBannerUrlProfileKey(mx: MatrixClient): Promise<string> {
if (await mx.isVersionSupported('v1.16')) {
return PROFILE_KEY_BANNER_URL_STABLE;