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:
306
src/app/utils/pngMetadata.ts
Normal file
306
src/app/utils/pngMetadata.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user