feat: Add profile images

This commit is contained in:
2026-04-17 03:06:44 +02:00
parent 35b616fb77
commit 17738ec484
49 changed files with 2622 additions and 89 deletions
@@ -0,0 +1,51 @@
import {
isAnimatedGif,
isAnimatedWebp
} from './profile-avatar-image.service';
describe('profile-avatar image animation detection', () => {
it('detects animated gifs with multiple frames', () => {
const animatedGif = new Uint8Array([
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00,
0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00,
0x3B
]).buffer;
expect(isAnimatedGif(animatedGif)).toBe(true);
});
it('does not mark single-frame gifs as animated', () => {
const staticGif = new Uint8Array([
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00,
0x3B
]).buffer;
expect(isAnimatedGif(staticGif)).toBe(false);
});
it('detects animated webp files from the VP8X animation flag', () => {
const animatedWebp = new Uint8Array([
0x52, 0x49, 0x46, 0x46, 0x16, 0x00, 0x00, 0x00,
0x57, 0x45, 0x42, 0x50,
0x56, 0x50, 0x38, 0x58, 0x0A, 0x00, 0x00, 0x00,
0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
]).buffer;
expect(isAnimatedWebp(animatedWebp)).toBe(true);
});
it('does not mark static webp files as animated', () => {
const staticWebp = new Uint8Array([
0x52, 0x49, 0x46, 0x46, 0x16, 0x00, 0x00, 0x00,
0x57, 0x45, 0x42, 0x50,
0x56, 0x50, 0x38, 0x58, 0x0A, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
]).buffer;
expect(isAnimatedWebp(staticWebp)).toBe(false);
});
});
@@ -0,0 +1,335 @@
import { Injectable } from '@angular/core';
import {
PROFILE_AVATAR_ALLOWED_MIME_TYPES,
PROFILE_AVATAR_OUTPUT_SIZE,
ProfileAvatarTransform,
EditableProfileAvatarSource,
ProcessedProfileAvatar,
clampProfileAvatarTransform,
PROFILE_AVATAR_EDITOR_FRAME_SIZE,
resolveProfileAvatarBaseScale
} from '../../domain/profile-avatar.models';
const PROFILE_AVATAR_OUTPUT_MIME = 'image/webp';
const PROFILE_AVATAR_OUTPUT_QUALITY = 0.92;
export function isAnimatedGif(buffer: ArrayBuffer): boolean {
const bytes = new Uint8Array(buffer);
if (bytes.length < 13 || readAscii(bytes, 0, 6) !== 'GIF87a' && readAscii(bytes, 0, 6) !== 'GIF89a') {
return false;
}
let offset = 13;
if ((bytes[10] & 0x80) !== 0) {
offset += 3 * (2 ** ((bytes[10] & 0x07) + 1));
}
let frameCount = 0;
while (offset < bytes.length) {
const blockType = bytes[offset];
if (blockType === 0x3B) {
return false;
}
if (blockType === 0x21) {
offset += 2;
while (offset < bytes.length) {
const blockSize = bytes[offset++];
if (blockSize === 0) {
break;
}
offset += blockSize;
}
continue;
}
if (blockType !== 0x2C || offset + 10 > bytes.length) {
return false;
}
frameCount++;
if (frameCount > 1) {
return true;
}
const packedFields = bytes[offset + 9];
offset += 10;
if ((packedFields & 0x80) !== 0) {
offset += 3 * (2 ** ((packedFields & 0x07) + 1));
}
offset += 1;
while (offset < bytes.length) {
const blockSize = bytes[offset++];
if (blockSize === 0) {
break;
}
offset += blockSize;
}
}
return false;
}
export function isAnimatedWebp(buffer: ArrayBuffer): boolean {
const bytes = new Uint8Array(buffer);
if (bytes.length < 16 || readAscii(bytes, 0, 4) !== 'RIFF' || readAscii(bytes, 8, 4) !== 'WEBP') {
return false;
}
let offset = 12;
while (offset + 8 <= bytes.length) {
const chunkType = readAscii(bytes, offset, 4);
const chunkSize = readUint32LittleEndian(bytes, offset + 4);
if (chunkType === 'ANIM' || chunkType === 'ANMF') {
return true;
}
if (chunkType === 'VP8X' && offset + 9 <= bytes.length) {
const featureFlags = bytes[offset + 8];
if ((featureFlags & 0x02) !== 0) {
return true;
}
}
offset += 8 + chunkSize + (chunkSize % 2);
}
return false;
}
@Injectable({ providedIn: 'root' })
export class ProfileAvatarImageService {
validateFile(file: File): string | null {
const mimeType = file.type.toLowerCase();
const normalizedName = file.name.toLowerCase();
const isAllowedMime = PROFILE_AVATAR_ALLOWED_MIME_TYPES.includes(mimeType as typeof PROFILE_AVATAR_ALLOWED_MIME_TYPES[number]);
const isAllowedExtension = normalizedName.endsWith('.webp')
|| normalizedName.endsWith('.gif')
|| normalizedName.endsWith('.jpg')
|| normalizedName.endsWith('.jpeg');
if (!isAllowedExtension || (mimeType && !isAllowedMime)) {
return 'Invalid file type. Use WebP, GIF, JPG, or JPEG.';
}
return null;
}
async prepareEditableSource(file: File): Promise<EditableProfileAvatarSource> {
const objectUrl = URL.createObjectURL(file);
const mime = this.resolveSourceMime(file);
try {
const [image, preservesAnimation] = await Promise.all([this.loadImage(objectUrl), this.detectAnimatedSource(file, mime)]);
return {
file,
objectUrl,
mime,
name: file.name,
width: image.naturalWidth,
height: image.naturalHeight,
preservesAnimation
};
} catch (error) {
URL.revokeObjectURL(objectUrl);
throw error;
}
}
releaseEditableSource(source: EditableProfileAvatarSource | null | undefined): void {
if (!source?.objectUrl) {
return;
}
URL.revokeObjectURL(source.objectUrl);
}
async processEditableSource(
source: EditableProfileAvatarSource,
transform: ProfileAvatarTransform
): Promise<ProcessedProfileAvatar> {
if (source.preservesAnimation) {
return this.processAnimatedSource(source);
}
const image = await this.loadImage(source.objectUrl);
const canvas = document.createElement('canvas');
canvas.width = PROFILE_AVATAR_OUTPUT_SIZE;
canvas.height = PROFILE_AVATAR_OUTPUT_SIZE;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Canvas not supported');
}
const clampedTransform = clampProfileAvatarTransform(source, transform);
const previewScale = resolveProfileAvatarBaseScale(source, PROFILE_AVATAR_EDITOR_FRAME_SIZE) * clampedTransform.zoom;
const renderRatio = PROFILE_AVATAR_OUTPUT_SIZE / PROFILE_AVATAR_EDITOR_FRAME_SIZE;
const drawWidth = image.naturalWidth * previewScale * renderRatio;
const drawHeight = image.naturalHeight * previewScale * renderRatio;
const drawX = (PROFILE_AVATAR_OUTPUT_SIZE - drawWidth) / 2 + clampedTransform.offsetX * renderRatio;
const drawY = (PROFILE_AVATAR_OUTPUT_SIZE - drawHeight) / 2 + clampedTransform.offsetY * renderRatio;
context.clearRect(0, 0, canvas.width, canvas.height);
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.drawImage(image, drawX, drawY, drawWidth, drawHeight);
const renderedBlob = await this.canvasToBlob(canvas, PROFILE_AVATAR_OUTPUT_MIME, PROFILE_AVATAR_OUTPUT_QUALITY);
const compressedBlob = renderedBlob;
const updatedAt = Date.now();
const dataUrl = await this.readBlobAsDataUrl(compressedBlob);
const hash = await this.computeHash(compressedBlob);
return {
blob: compressedBlob,
base64: dataUrl.split(',', 2)[1] ?? '',
avatarUrl: dataUrl,
avatarHash: hash,
avatarMime: compressedBlob.type || PROFILE_AVATAR_OUTPUT_MIME,
avatarUpdatedAt: updatedAt,
width: PROFILE_AVATAR_OUTPUT_SIZE,
height: PROFILE_AVATAR_OUTPUT_SIZE
};
}
private async processAnimatedSource(source: EditableProfileAvatarSource): Promise<ProcessedProfileAvatar> {
const updatedAt = Date.now();
const dataUrl = await this.readBlobAsDataUrl(source.file);
const hash = await this.computeHash(source.file);
return {
blob: source.file,
base64: dataUrl.split(',', 2)[1] ?? '',
avatarUrl: dataUrl,
avatarHash: hash,
avatarMime: source.mime || source.file.type || PROFILE_AVATAR_OUTPUT_MIME,
avatarUpdatedAt: updatedAt,
width: source.width,
height: source.height
};
}
private async detectAnimatedSource(file: File, mime: string): Promise<boolean> {
if (mime !== 'image/gif' && mime !== 'image/webp') {
return false;
}
const buffer = await file.arrayBuffer();
return mime === 'image/gif'
? isAnimatedGif(buffer)
: isAnimatedWebp(buffer);
}
private resolveSourceMime(file: File): string {
const mimeType = file.type.toLowerCase();
if (mimeType === 'image/jpg') {
return 'image/jpeg';
}
if (mimeType) {
return mimeType;
}
const normalizedName = file.name.toLowerCase();
if (normalizedName.endsWith('.gif')) {
return 'image/gif';
}
if (normalizedName.endsWith('.jpg') || normalizedName.endsWith('.jpeg')) {
return 'image/jpeg';
}
if (normalizedName.endsWith('.webp')) {
return 'image/webp';
}
return PROFILE_AVATAR_OUTPUT_MIME;
}
private async computeHash(blob: Blob): Promise<string> {
const buffer = await blob.arrayBuffer();
const digest = await crypto.subtle.digest('SHA-256', buffer);
return Array.from(new Uint8Array(digest))
.map((value) => value.toString(16).padStart(2, '0'))
.join('');
}
private canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
return;
}
reject(new Error('Failed to render avatar image'));
}, type, quality);
});
}
private readBlobAsDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
return;
}
reject(new Error('Failed to encode avatar image'));
};
reader.onerror = () => reject(reader.error ?? new Error('Failed to read avatar image'));
reader.readAsDataURL(blob);
});
}
private loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('Failed to load avatar image'));
image.src = url;
});
}
}
function readAscii(bytes: Uint8Array, offset: number, length: number): string {
return String.fromCharCode(...bytes.slice(offset, offset + length));
}
function readUint32LittleEndian(bytes: Uint8Array, offset: number): number {
return bytes[offset]
| (bytes[offset + 1] << 8)
| (bytes[offset + 2] << 16)
| (bytes[offset + 3] << 24);
}
@@ -0,0 +1,58 @@
import { Injectable, inject } from '@angular/core';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { User } from '../../../../shared-kernel';
import {
ProcessedProfileAvatar,
resolveProfileAvatarStorageFileName
} from '../../domain/profile-avatar.models';
const LEGACY_PROFILE_FILE_NAMES = [
'profile.webp',
'profile.gif',
'profile.jpg',
'profile.jpeg',
'profile.png'
];
@Injectable({ providedIn: 'root' })
export class ProfileAvatarStorageService {
private readonly electronBridge = inject(ElectronBridgeService);
async persistProcessedAvatar(
user: Pick<User, 'id' | 'username' | 'displayName'>,
avatar: Pick<ProcessedProfileAvatar, 'base64' | 'avatarMime'>
): Promise<void> {
const electronApi = this.electronBridge.getApi();
if (!electronApi) {
return;
}
const appDataPath = await electronApi.getAppDataPath();
const usernameSegment = this.sanitizePathSegment(user.username || user.displayName || user.id || 'user');
const directoryPath = `${appDataPath}/user/${usernameSegment}/profile`;
const targetFileName = resolveProfileAvatarStorageFileName(avatar.avatarMime);
await electronApi.ensureDir(directoryPath);
for (const fileName of LEGACY_PROFILE_FILE_NAMES) {
const filePath = `${directoryPath}/${fileName}`;
if (fileName !== targetFileName && await electronApi.fileExists(filePath)) {
await electronApi.deleteFile(filePath);
}
}
await electronApi.writeFile(`${directoryPath}/${targetFileName}`, avatar.base64);
}
private sanitizePathSegment(value: string): string {
const normalized = value
.trim()
.replace(/[^a-zA-Z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
return normalized || 'user';
}
}