feat: add user color customization and metadata handling

- Implemented `useUserColor` hook to manage user profile color stored in avatar PNG metadata.
- Added `useOtherUserColor` hook to fetch and cache colors from other users' avatars.
- Created utility functions for reading and writing PNG metadata, enabling color embedding and extraction.
- Refactored `SearchResultGroup`, `RoomInput`, `Message`, `RoomPinMenu`, and `Notifications` components to utilize user color for display.
- Introduced `UserColorPicker` component in settings for users to select and save their profile color.
- Enhanced notification and message rendering to prioritize custom user colors over legacy colors.
This commit is contained in:
2026-02-06 05:53:37 +11:00
parent 4ca4af0e8b
commit 516000a25f
11 changed files with 1061 additions and 222 deletions

View File

@@ -0,0 +1,306 @@
/**
* Utilities for reading and writing PNG metadata (tEXt chunks)
* Used to embed user color in avatar images
*/
/* eslint-disable no-bitwise */
/* eslint-disable no-plusplus */
/* eslint-disable no-restricted-syntax */
/* eslint-disable no-console */
/** PNG signature bytes */
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';
let crc32Table: Uint32Array | null = null;
function getCRC32Table(): Uint32Array {
if (crc32Table) return crc32Table;
crc32Table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) {
c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
}
crc32Table[i] = c;
}
return crc32Table;
}
/**
* Calculate CRC32 for a chunk
*/
function crc32(data: Uint8Array): number {
let crc = 0xffffffff;
const table = getCRC32Table();
for (let i = 0; i < data.length; i++) {
crc = (crc >>> 8) ^ table[(crc ^ data[i]) & 0xff];
}
return (crc ^ 0xffffffff) >>> 0;
}
/**
* Read a 32-bit big-endian integer from a buffer
*/
function readUint32BE(data: Uint8Array, offset: number): number {
return (data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3];
}
/**
* Write a 32-bit big-endian integer to a buffer
*/
function writeUint32BE(value: number): Uint8Array {
return new Uint8Array([
(value >>> 24) & 0xff,
(value >>> 16) & 0xff,
(value >>> 8) & 0xff,
value & 0xff,
]);
}
/**
* Create a tEXt chunk with the given key and value
*/
function createTextChunk(key: string, value: string): Uint8Array {
const keyBytes = new TextEncoder().encode(key);
const valueBytes = new TextEncoder().encode(value);
// tEXt chunk data: key + null byte + value
const chunkData = new Uint8Array(keyBytes.length + 1 + valueBytes.length);
chunkData.set(keyBytes, 0);
chunkData[keyBytes.length] = 0; // null separator
chunkData.set(valueBytes, keyBytes.length + 1);
const chunkType = new TextEncoder().encode('tEXt');
// Calculate CRC over type + data
const crcData = new Uint8Array(4 + chunkData.length);
crcData.set(chunkType, 0);
crcData.set(chunkData, 4);
const crc = crc32(crcData);
// Full chunk: length (4) + type (4) + data + crc (4)
const chunk = new Uint8Array(4 + 4 + chunkData.length + 4);
chunk.set(writeUint32BE(chunkData.length), 0);
chunk.set(chunkType, 4);
chunk.set(chunkData, 8);
chunk.set(writeUint32BE(crc), 8 + chunkData.length);
return chunk;
}
/**
* Parse PNG chunks from image data
*/
function parseChunks(data: Uint8Array): Array<{ type: string; data: Uint8Array; offset: number; length: number }> {
const chunks: Array<{ type: string; data: Uint8Array; offset: number; length: number }> = [];
let offset = 8; // Skip PNG signature
while (offset < data.length) {
const length = readUint32BE(data, offset);
const type = new TextDecoder().decode(data.slice(offset + 4, offset + 8));
const chunkData = data.slice(offset + 8, offset + 8 + length);
chunks.push({
type,
data: chunkData,
offset,
length: 12 + length, // length field + type + data + crc
});
offset += 12 + length;
if (type === 'IEND') break;
}
return chunks;
}
/**
* Read a tEXt chunk value
*/
function parseTextChunk(data: Uint8Array): { key: string; value: string } | null {
const nullIndex = data.indexOf(0);
if (nullIndex === -1) return null;
const key = new TextDecoder().decode(data.slice(0, nullIndex));
const value = new TextDecoder().decode(data.slice(nullIndex + 1));
return { key, value };
}
/**
* Extract paarrot color from PNG image data
* @param imageData - Raw PNG image data as ArrayBuffer or Uint8Array
* @returns The color string if found, or undefined
*/
export function extractColorFromPNG(imageData: ArrayBuffer | Uint8Array): string | undefined {
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
console.log('[extractColorFromPNG] Input size:', data.length, 'bytes');
// Verify PNG signature
for (let i = 0; i < 8; i++) {
if (data[i] !== PNG_SIGNATURE[i]) {
console.error('[extractColorFromPNG] Not a PNG! Byte', i, 'is', data[i], 'expected', PNG_SIGNATURE[i]);
return undefined; // Not a PNG
}
}
const chunks = parseChunks(data);
console.log('[extractColorFromPNG] Found', chunks.length, 'chunks:', chunks.map(c => c.type).join(', '));
for (const chunk of chunks) {
if (chunk.type === 'tEXt') {
const text = parseTextChunk(chunk.data);
console.log('[extractColorFromPNG] tEXt chunk found, key:', text?.key);
if (text && text.key === PAARROT_COLOR_KEY) {
console.log('[extractColorFromPNG] Found color:', text.value);
return text.value;
}
}
}
console.log('[extractColorFromPNG] No color found');
return undefined;
}
/**
* Embed paarrot color into PNG image data
* @param imageData - Raw PNG image data as ArrayBuffer or Uint8Array
* @param color - The color string to embed (e.g., '#ff5500')
* @returns New PNG data with embedded color, or null if not a valid PNG
*/
export function embedColorInPNG(imageData: ArrayBuffer | Uint8Array, color: string): Uint8Array | null {
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
console.log('[embedColorInPNG] Input size:', data.length, 'bytes');
console.log('[embedColorInPNG] First 8 bytes:', Array.from(data.slice(0, 8)));
console.log('[embedColorInPNG] PNG signature expected:', Array.from(PNG_SIGNATURE));
// Verify PNG signature
for (let i = 0; i < 8; i++) {
if (data[i] !== PNG_SIGNATURE[i]) {
console.error('[embedColorInPNG] Not a PNG! Byte', i, 'is', data[i], 'expected', PNG_SIGNATURE[i]);
return null; // Not a PNG
}
}
console.log('[embedColorInPNG] PNG signature verified OK');
const chunks = parseChunks(data);
console.log('[embedColorInPNG] Found', chunks.length, 'chunks:', chunks.map(c => c.type).join(', '));
// Find IHDR chunk (always first) and any existing paarrot tEXt chunk
let insertOffset = 8; // After signature
let existingPaarrotChunk: { offset: number; length: number } | null = null;
for (const chunk of chunks) {
if (chunk.type === 'IHDR') {
insertOffset = chunk.offset + chunk.length; // Insert after IHDR
} else if (chunk.type === 'tEXt') {
const text = parseTextChunk(chunk.data);
if (text && text.key === PAARROT_COLOR_KEY) {
existingPaarrotChunk = { offset: chunk.offset, length: chunk.length };
}
}
}
// Create new tEXt chunk with color
const colorChunk = createTextChunk(PAARROT_COLOR_KEY, color);
console.log('[embedColorInPNG] Created color chunk, size:', colorChunk.length, 'bytes');
console.log('[embedColorInPNG] Color being embedded:', color);
// Build new PNG data
let newData: Uint8Array;
if (existingPaarrotChunk) {
// Replace existing chunk
console.log('[embedColorInPNG] Replacing existing chunk at offset', existingPaarrotChunk.offset);
newData = new Uint8Array(data.length - existingPaarrotChunk.length + colorChunk.length);
newData.set(data.slice(0, existingPaarrotChunk.offset), 0);
newData.set(colorChunk, existingPaarrotChunk.offset);
newData.set(
data.slice(existingPaarrotChunk.offset + existingPaarrotChunk.length),
existingPaarrotChunk.offset + colorChunk.length
);
} else {
// Insert new chunk after IHDR
console.log('[embedColorInPNG] Inserting new chunk at offset', insertOffset);
newData = new Uint8Array(data.length + colorChunk.length);
newData.set(data.slice(0, insertOffset), 0);
newData.set(colorChunk, insertOffset);
newData.set(data.slice(insertOffset), insertOffset + colorChunk.length);
}
console.log('[embedColorInPNG] Output size:', newData.length, 'bytes');
return newData;
}
/**
* Remove paarrot color from PNG image data
* @param imageData - Raw PNG image data as ArrayBuffer or Uint8Array
* @returns New PNG data without the color chunk, or null if not a valid PNG
*/
export function removeColorFromPNG(imageData: ArrayBuffer | Uint8Array): 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);
// Find existing paarrot tEXt chunk
let existingPaarrotChunk: { offset: number; length: number } | null = null;
for (const chunk of chunks) {
if (chunk.type === 'tEXt') {
const text = parseTextChunk(chunk.data);
if (text && text.key === PAARROT_COLOR_KEY) {
existingPaarrotChunk = { offset: chunk.offset, length: chunk.length };
break;
}
}
}
if (!existingPaarrotChunk) {
// No color to remove
return data;
}
// Build new PNG data without the color chunk
const newData = new Uint8Array(data.length - existingPaarrotChunk.length);
newData.set(data.slice(0, existingPaarrotChunk.offset), 0);
newData.set(
data.slice(existingPaarrotChunk.offset + existingPaarrotChunk.length),
existingPaarrotChunk.offset
);
return newData;
}
/**
* Fetch an image and extract the paarrot color from it
* @param url - The URL of the image to fetch
* @returns The color string if found, or undefined
*/
export async function fetchAndExtractColor(url: string): Promise<string | undefined> {
try {
const response = await fetch(url);
if (!response.ok) return undefined;
const arrayBuffer = await response.arrayBuffer();
return extractColorFromPNG(arrayBuffer);
} catch {
return undefined;
}
}