feat: enhance user profile customization with avatar border color and gradient options

- Added support for setting avatar border color and gradient in user profile settings.
- Introduced new hooks for managing user profile styles and fetching metadata from avatars.
- Implemented an AngleSelector component for selecting gradient direction.
- Updated RoomNavItem to display parent space information for direct messages.
- Improved caching mechanism for user banners and profile styles.
- Refactored image metadata handling to include new profile style attributes.
This commit is contained in:
2026-03-12 20:50:00 +11:00
parent fc30d81f8f
commit 834de012b4
13 changed files with 911 additions and 90 deletions

View File

@@ -18,9 +18,28 @@ import { embedColorInWebP, extractColorFromWebP, removeColorFromWebP } from './w
import { embedColorInJPEG, extractColorFromJPEG, removeColorFromJPEG } from './jpegMetadata';
import { embedColorInGIF, extractColorFromGIF, removeColorFromGIF } from './gifMetadata';
/**
* Gradient configuration for profile styling
* All colors support RGBA format (e.g., #FF550080 for 50% opacity)
*/
export type ProfileGradient = {
/** CSS gradient direction (e.g., "to bottom", "45deg", "to bottom right") */
direction: string;
/** Start color in hex format with optional alpha (#RRGGBB or #RRGGBBAA) */
startColor: string;
/** End color in hex format with optional alpha (#RRGGBB or #RRGGBBAA) */
stopColor: string;
};
export type ImageMetadata = {
/** Profile name color */
color?: string;
/** Banner MXC URL */
banner?: string;
/** Avatar border color with optional transparency (#RRGGBB or #RRGGBBAA) */
avatarBorderColor?: string;
/** Profile card gradient */
gradient?: ProfileGradient;
};
/** PNG signature bytes */

View File

@@ -17,6 +17,12 @@ export const PAARROT_COLOR_KEY = 'paarrot:color';
/** Key used to store banner URL in PNG metadata */
export const PAARROT_BANNER_KEY = 'paarrot:banner';
/** Key used to store avatar border color in PNG metadata */
export const PAARROT_BORDER_COLOR_KEY = 'paarrot:borderColor';
/** Key used to store profile gradient (JSON) in PNG metadata */
export const PAARROT_GRADIENT_KEY = 'paarrot:gradient';
let crc32Table: Uint32Array | null = null;
function getCRC32Table(): Uint32Array {
if (crc32Table) return crc32Table;
@@ -194,14 +200,16 @@ export function extractBannerFromPNG(imageData: ArrayBuffer | Uint8Array): strin
return undefined;
}
import type { ImageMetadata, ProfileGradient } from './imageMetadata';
/**
* Extract all paarrot metadata from PNG image data
* @param imageData - Raw PNG image data as ArrayBuffer or Uint8Array
* @returns Object with color and banner if found
* @returns Object with all metadata fields if found
*/
export function extractMetadataFromPNG(imageData: ArrayBuffer | Uint8Array): { color?: string; banner?: string } {
export function extractMetadataFromPNG(imageData: ArrayBuffer | Uint8Array): ImageMetadata {
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
const metadata: { color?: string; banner?: string } = {};
const metadata: ImageMetadata = {};
// Verify PNG signature
for (let i = 0; i < 8; i++) {
@@ -220,6 +228,14 @@ export function extractMetadataFromPNG(imageData: ArrayBuffer | Uint8Array): { c
metadata.color = text.value;
} else if (text.key === PAARROT_BANNER_KEY) {
metadata.banner = text.value;
} else if (text.key === PAARROT_BORDER_COLOR_KEY) {
metadata.avatarBorderColor = text.value;
} else if (text.key === PAARROT_GRADIENT_KEY) {
try {
metadata.gradient = JSON.parse(text.value) as ProfileGradient;
} catch {
// Invalid JSON, skip
}
}
}
}
@@ -287,16 +303,19 @@ export function embedColorInPNG(imageData: ArrayBuffer | Uint8Array, color: stri
return newData;
}
/** All paarrot metadata keys */
const PAARROT_KEYS = [PAARROT_COLOR_KEY, PAARROT_BANNER_KEY, PAARROT_BORDER_COLOR_KEY, PAARROT_GRADIENT_KEY];
/**
* Embed paarrot metadata (color and/or banner) into PNG image data
* Embed paarrot metadata into PNG image data
* Preserves existing metadata that is not being updated
* @param imageData - Raw PNG image data as ArrayBuffer or Uint8Array
* @param metadata - Object with color and/or banner to embed
* @param metadata - Object with metadata fields to embed
* @returns New PNG data with embedded metadata, or null if not a valid PNG
*/
export function embedMetadataInPNG(
imageData: ArrayBuffer | Uint8Array,
metadata: { color?: string; banner?: string }
metadata: ImageMetadata
): Uint8Array | null {
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
@@ -313,9 +332,11 @@ export function embedMetadataInPNG(
const existing = extractMetadataFromPNG(data);
// Merge with new metadata
const finalMetadata = {
const finalMetadata: ImageMetadata = {
color: metadata.color !== undefined ? metadata.color : existing.color,
banner: metadata.banner !== undefined ? metadata.banner : existing.banner,
avatarBorderColor: metadata.avatarBorderColor !== undefined ? metadata.avatarBorderColor : existing.avatarBorderColor,
gradient: metadata.gradient !== undefined ? metadata.gradient : existing.gradient,
};
// Find IHDR chunk and existing paarrot chunks
@@ -327,7 +348,7 @@ export function embedMetadataInPNG(
insertOffset = chunk.offset + chunk.length;
} else if (chunk.type === 'tEXt') {
const text = parseTextChunk(chunk.data);
if (text && (text.key === PAARROT_COLOR_KEY || text.key === PAARROT_BANNER_KEY)) {
if (text && PAARROT_KEYS.includes(text.key)) {
existingChunks.push({ key: text.key, offset: chunk.offset, length: chunk.length });
}
}
@@ -341,6 +362,12 @@ export function embedMetadataInPNG(
if (finalMetadata.banner) {
newChunks.push(createTextChunk(PAARROT_BANNER_KEY, finalMetadata.banner));
}
if (finalMetadata.avatarBorderColor) {
newChunks.push(createTextChunk(PAARROT_BORDER_COLOR_KEY, finalMetadata.avatarBorderColor));
}
if (finalMetadata.gradient) {
newChunks.push(createTextChunk(PAARROT_GRADIENT_KEY, JSON.stringify(finalMetadata.gradient)));
}
// Calculate new size
const removedSize = existingChunks.reduce((sum, chunk) => sum + chunk.length, 0);