feat: Enhance user color and banner management

- Updated `useUserColor` hook to support fetching and embedding user color from avatar metadata with authentication.
- Introduced `useUserBanner` hook for managing user banners stored in avatar metadata, including fetching and embedding functionality.
- Added `fetchAndExtractMetadata` utility to retrieve both color and banner from images.
- Implemented `CustomStatusDialog` component for users to set and clear custom status messages.
- Modified `SettingsTab` to include custom status functionality and improved user experience.
- Enhanced image metadata utilities to support banner extraction and embedding in PNG format.
- Updated sidebar settings to handle user profile changes more gracefully.
This commit is contained in:
2026-03-12 10:58:38 +11:00
parent 2c2560f5a2
commit fc30d81f8f
12 changed files with 1804 additions and 419 deletions

View File

@@ -1,16 +1,28 @@
/**
* Unified image metadata utilities for embedding/extracting user color
* Unified image metadata utilities for embedding/extracting user color and banner
* Supports PNG, WebP, JPEG, and GIF formats
*/
/* eslint-disable no-plusplus */
/* eslint-disable no-console */
import { embedColorInPNG, extractColorFromPNG, removeColorFromPNG } from './pngMetadata';
import {
embedColorInPNG,
extractColorFromPNG,
removeColorFromPNG,
embedMetadataInPNG,
extractMetadataFromPNG,
extractBannerFromPNG,
} from './pngMetadata';
import { embedColorInWebP, extractColorFromWebP, removeColorFromWebP } from './webpMetadata';
import { embedColorInJPEG, extractColorFromJPEG, removeColorFromJPEG } from './jpegMetadata';
import { embedColorInGIF, extractColorFromGIF, removeColorFromGIF } from './gifMetadata';
export type ImageMetadata = {
color?: string;
banner?: string;
};
/** PNG signature bytes */
const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
@@ -114,6 +126,48 @@ export function extractColorFromImage(imageData: ArrayBuffer | Uint8Array): stri
}
}
/**
* Extract paarrot banner URL from image data (any supported format)
* @param imageData - Raw image data as ArrayBuffer or Uint8Array
* @returns The banner MXC URL if found, or undefined
*/
export function extractBannerFromImage(imageData: ArrayBuffer | Uint8Array): string | undefined {
const format = detectImageFormat(imageData);
switch (format) {
case 'png':
return extractBannerFromPNG(imageData);
// For now, only PNG supports banner. Add other formats later
default:
console.warn('[extractBannerFromImage] Format not yet supported for banner');
return undefined;
}
}
/**
* Extract all paarrot metadata from image data (any supported format)
* @param imageData - Raw image data as ArrayBuffer or Uint8Array
* @returns Object with color and banner if found
*/
export function extractMetadataFromImage(imageData: ArrayBuffer | Uint8Array): ImageMetadata {
const format = detectImageFormat(imageData);
switch (format) {
case 'png':
return extractMetadataFromPNG(imageData);
// For other formats, extract color only for now
case 'webp':
case 'jpeg':
case 'gif': {
const color = extractColorFromImage(imageData);
return color ? { color } : {};
}
default:
console.warn('[extractMetadataFromImage] Unknown image format');
return {};
}
}
/**
* Embed paarrot color into image data (PNG, WebP, JPEG, or GIF)
* @param imageData - Raw image data as ArrayBuffer or Uint8Array
@@ -138,6 +192,36 @@ export function embedColorInImage(imageData: ArrayBuffer | Uint8Array, color: st
}
}
/**
* Embed paarrot metadata (color and/or banner) into image data
* Preserves existing metadata that is not being updated
* @param imageData - Raw image data as ArrayBuffer or Uint8Array
* @param metadata - Object with color and/or banner to embed
* @returns New image data with embedded metadata, or null if not a supported format
*/
export function embedMetadataInImage(
imageData: ArrayBuffer | Uint8Array,
metadata: ImageMetadata
): Uint8Array | null {
const format = detectImageFormat(imageData);
switch (format) {
case 'png':
return embedMetadataInPNG(imageData, metadata);
// For other formats, only embed color for now
case 'webp':
case 'jpeg':
case 'gif':
if (metadata.color) {
return embedColorInImage(imageData, metadata.color);
}
return null;
default:
console.warn('[embedMetadataInImage] Unknown image format, cannot embed metadata');
return null;
}
}
/**
* Remove paarrot color from image data (PNG, WebP, JPEG, or GIF)
* @param imageData - Raw image data as ArrayBuffer or Uint8Array
@@ -198,13 +282,17 @@ export function getExtension(format: ImageFormat): string {
}
/**
* Fetch and extract color from an image URL (supports PNG and WebP)
* Fetch and extract color from an image URL (supports all formats)
* @param url - HTTP URL to fetch the image from
* @param accessToken - Optional access token for authenticated media
* @returns The color string if found, or undefined
*/
export async function fetchAndExtractColor(url: string): Promise<string | undefined> {
export async function fetchAndExtractColor(url: string, accessToken?: string | null): Promise<string | undefined> {
try {
const response = await fetch(url);
const response = await fetch(url, {
method: 'GET',
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
if (!response.ok) return undefined;
const data = await response.arrayBuffer();
@@ -213,3 +301,24 @@ export async function fetchAndExtractColor(url: string): Promise<string | undefi
return undefined;
}
}
/**
* Fetch and extract all metadata from an image URL
* @param url - HTTP URL to fetch the image from
* @param accessToken - Optional access token for authenticated media
* @returns Object with color and banner if found
*/
export async function fetchAndExtractMetadata(url: string, accessToken?: string | null): Promise<ImageMetadata> {
try {
const response = await fetch(url, {
method: 'GET',
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
if (!response.ok) return {};
const data = await response.arrayBuffer();
return extractMetadataFromImage(data);
} catch {
return {};
}
}

View File

@@ -14,6 +14,9 @@ const PNG_SIGNATURE = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
/** Key used to store user color in PNG metadata */
export const PAARROT_COLOR_KEY = 'paarrot:color';
/** Key used to store banner URL in PNG metadata */
export const PAARROT_BANNER_KEY = 'paarrot:banner';
let crc32Table: Uint32Array | null = null;
function getCRC32Table(): Uint32Array {
if (crc32Table) return crc32Table;
@@ -162,6 +165,69 @@ export function extractColorFromPNG(imageData: ArrayBuffer | Uint8Array): string
return undefined;
}
/**
* Extract paarrot banner URL from PNG image data
* @param imageData - Raw PNG image data as ArrayBuffer or Uint8Array
* @returns The banner MXC URL if found, or undefined
*/
export function extractBannerFromPNG(imageData: ArrayBuffer | Uint8Array): string | undefined {
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
// Verify PNG signature
for (let i = 0; i < 8; i++) {
if (data[i] !== PNG_SIGNATURE[i]) {
return undefined; // Not a PNG
}
}
const chunks = parseChunks(data);
for (const chunk of chunks) {
if (chunk.type === 'tEXt') {
const text = parseTextChunk(chunk.data);
if (text && text.key === PAARROT_BANNER_KEY) {
return text.value;
}
}
}
return undefined;
}
/**
* 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
*/
export function extractMetadataFromPNG(imageData: ArrayBuffer | Uint8Array): { color?: string; banner?: string } {
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
const metadata: { color?: string; banner?: string } = {};
// Verify PNG signature
for (let i = 0; i < 8; i++) {
if (data[i] !== PNG_SIGNATURE[i]) {
return metadata; // Not a PNG
}
}
const chunks = parseChunks(data);
for (const chunk of chunks) {
if (chunk.type === 'tEXt') {
const text = parseTextChunk(chunk.data);
if (text) {
if (text.key === PAARROT_COLOR_KEY) {
metadata.color = text.value;
} else if (text.key === PAARROT_BANNER_KEY) {
metadata.banner = text.value;
}
}
}
}
return metadata;
}
/**
* Embed paarrot color into PNG image data
* @param imageData - Raw PNG image data as ArrayBuffer or Uint8Array
@@ -221,6 +287,103 @@ export function embedColorInPNG(imageData: ArrayBuffer | Uint8Array, color: stri
return newData;
}
/**
* Embed paarrot metadata (color and/or banner) 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
* @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 }
): Uint8Array | null {
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
// Verify PNG signature
for (let i = 0; i < 8; i++) {
if (data[i] !== PNG_SIGNATURE[i]) {
return null; // Not a PNG
}
}
const chunks = parseChunks(data);
// Get existing metadata
const existing = extractMetadataFromPNG(data);
// Merge with new metadata
const finalMetadata = {
color: metadata.color !== undefined ? metadata.color : existing.color,
banner: metadata.banner !== undefined ? metadata.banner : existing.banner,
};
// Find IHDR chunk and existing paarrot chunks
let insertOffset = 8;
const existingChunks: { key: string; offset: number; length: number }[] = [];
for (const chunk of chunks) {
if (chunk.type === 'IHDR') {
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)) {
existingChunks.push({ key: text.key, offset: chunk.offset, length: chunk.length });
}
}
}
// Create new chunks
const newChunks: Uint8Array[] = [];
if (finalMetadata.color) {
newChunks.push(createTextChunk(PAARROT_COLOR_KEY, finalMetadata.color));
}
if (finalMetadata.banner) {
newChunks.push(createTextChunk(PAARROT_BANNER_KEY, finalMetadata.banner));
}
// Calculate new size
const removedSize = existingChunks.reduce((sum, chunk) => sum + chunk.length, 0);
const addedSize = newChunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
const newSize = data.length - removedSize + addedSize;
// Build new PNG
const newData = new Uint8Array(newSize);
let writeOffset = 0;
let readOffset = 0;
// Sort existing chunks by offset
existingChunks.sort((a, b) => a.offset - b.offset);
// Copy data, skipping old chunks and inserting new ones
for (const existingChunk of existingChunks) {
// Copy data before this chunk
newData.set(data.slice(readOffset, existingChunk.offset), writeOffset);
writeOffset += existingChunk.offset - readOffset;
// Skip the old chunk
readOffset = existingChunk.offset + existingChunk.length;
}
// If no existing chunks, insert at insertOffset
if (existingChunks.length === 0) {
newData.set(data.slice(0, insertOffset), 0);
writeOffset = insertOffset;
readOffset = insertOffset;
}
// Insert new chunks
for (const newChunk of newChunks) {
newData.set(newChunk, writeOffset);
writeOffset += newChunk.byteLength;
}
// Copy remaining data
newData.set(data.slice(readOffset), writeOffset);
return newData;
}
/**
* Remove paarrot color from PNG image data
* @param imageData - Raw PNG image data as ArrayBuffer or Uint8Array