feat: Add profile images

This commit is contained in:
2026-04-17 03:05:47 +02:00
parent 35b616fb77
commit 17738ec484
49 changed files with 2622 additions and 89 deletions

View File

@@ -13,6 +13,7 @@ infrastructure adapters and UI.
| **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` |
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
| **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` |
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
| **theme** | JSON-driven theming, element registry, layout syncing, picker tooling, and Electron saved-theme library management | `ThemeService` |
@@ -28,6 +29,7 @@ The larger domains also keep longer design notes in their own folders:
- [authentication/README.md](authentication/README.md)
- [chat/README.md](chat/README.md)
- [notifications/README.md](notifications/README.md)
- [profile-avatar/README.md](profile-avatar/README.md)
- [screen-share/README.md](screen-share/README.md)
- [server-directory/README.md](server-directory/README.md)
- [voice-connection/README.md](voice-connection/README.md)

View File

@@ -3,6 +3,11 @@ import { RealtimeSessionFacade } from '../../../../core/realtime';
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
import { FILE_CHUNK_SIZE_BYTES } from '../../domain/constants/attachment-transfer.constants';
import { FileChunkEvent } from '../../domain/models/attachment-transfer.model';
import {
arrayBufferToBase64,
decodeBase64,
iterateBlobChunks
} from '../../../../shared-kernel';
@Injectable({ providedIn: 'root' })
export class AttachmentTransferTransportService {
@@ -10,14 +15,7 @@ export class AttachmentTransferTransportService {
private readonly attachmentStorage = inject(AttachmentStorageService);
decodeBase64(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
return decodeBase64(base64);
}
async streamFileToPeer(
@@ -27,31 +25,20 @@ export class AttachmentTransferTransportService {
file: File,
isCancelled: () => boolean
): Promise<void> {
const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE_BYTES);
let offset = 0;
let chunkIndex = 0;
while (offset < file.size) {
for await (const chunk of iterateBlobChunks(file, FILE_CHUNK_SIZE_BYTES)) {
if (isCancelled())
break;
const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES);
const arrayBuffer = await slice.arrayBuffer();
const base64 = this.arrayBufferToBase64(arrayBuffer);
const fileChunkEvent: FileChunkEvent = {
type: 'file-chunk',
messageId,
fileId,
index: chunkIndex,
total: totalChunks,
data: base64
index: chunk.index,
total: chunk.total,
data: chunk.base64
};
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
offset += FILE_CHUNK_SIZE_BYTES;
chunkIndex++;
}
}
@@ -67,7 +54,7 @@ export class AttachmentTransferTransportService {
if (!base64Full)
return;
const fileBytes = this.decodeBase64(base64Full);
const fileBytes = decodeBase64(base64Full);
const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES);
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
@@ -81,7 +68,7 @@ export class AttachmentTransferTransportService {
slice.byteOffset,
slice.byteOffset + slice.byteLength
);
const base64Chunk = this.arrayBufferToBase64(sliceBuffer);
const base64Chunk = arrayBufferToBase64(sliceBuffer);
const fileChunkEvent: FileChunkEvent = {
type: 'file-chunk',
messageId,
@@ -94,16 +81,4 @@ export class AttachmentTransferTransportService {
this.webrtc.sendToPeer(targetPeerId, fileChunkEvent);
}
}
private arrayBufferToBase64(buffer: ArrayBuffer): string {
let binary = '';
const bytes = new Uint8Array(buffer);
for (let index = 0; index < bytes.byteLength; index++) {
binary += String.fromCharCode(bytes[index]);
}
return btoa(binary);
}
}

View File

@@ -1,5 +1,4 @@
/** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */
export const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB
export { P2P_BASE64_CHUNK_SIZE_BYTES as FILE_CHUNK_SIZE_BYTES } from '../../../../shared-kernel/p2p-transfer.constants';
/**
* EWMA smoothing weight for the previous speed estimate.

View File

@@ -4,14 +4,16 @@
<button
#avatarBtn
type="button"
class="relative flex items-center justify-center w-8 h-8 rounded-full bg-secondary text-foreground text-sm font-medium hover:bg-secondary/80 transition-colors"
class="rounded-full transition-opacity hover:opacity-90"
(click)="toggleProfileCard(avatarBtn)"
>
{{ user()!.displayName.charAt(0).toUpperCase() || '?' }}
<span
class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-card"
[class]="currentStatusColor()"
></span>
<app-user-avatar
[name]="user()!.displayName"
[avatarUrl]="user()!.avatarUrl"
size="sm"
[status]="user()!.status"
[showStatusBadge]="true"
/>
</button>
</div>
} @else {

View File

@@ -6,11 +6,12 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideLogIn, lucideUserPlus } from '@ng-icons/lucide';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { ProfileCardService } from '../../../../shared/components/profile-card/profile-card.service';
import { UserAvatarComponent } from '../../../../shared';
@Component({
selector: 'app-user-bar',
standalone: true,
imports: [CommonModule, NgIcon],
imports: [CommonModule, NgIcon, UserAvatarComponent],
viewProviders: [
provideIcons({
lucideLogIn,
@@ -28,19 +29,6 @@ export class UserBarComponent {
private router = inject(Router);
private profileCard = inject(ProfileCardService);
currentStatusColor(): string {
const status = this.user()?.status;
switch (status) {
case 'online': return 'bg-green-500';
case 'away': return 'bg-yellow-500';
case 'busy': return 'bg-red-500';
case 'offline': return 'bg-gray-500';
case 'disconnected': return 'bg-gray-500';
default: return 'bg-green-500';
}
}
toggleProfileCard(origin: HTMLElement): void {
const user = this.user();

View File

@@ -11,7 +11,8 @@
(click)="openSenderProfileCard($event); $event.stopPropagation()"
>
<app-user-avatar
[name]="msg.senderName"
[name]="senderUser().displayName || msg.senderName"
[avatarUrl]="senderUser().avatarUrl"
size="md"
/>
</div>

View File

@@ -146,17 +146,11 @@ export class ChatMessageItemComponent {
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
readonly isEditing = signal(false);
readonly showEmojiPicker = signal(false);
editContent = '';
openSenderProfileCard(event: MouseEvent): void {
event.stopPropagation();
const el = event.currentTarget as HTMLElement;
readonly senderUser = computed<User>(() => {
const msg = this.message();
// Look up full user from store
const users = this.allUsers();
const found = users.find((userEntry) => userEntry.id === msg.senderId || userEntry.oderId === msg.senderId);
const user: User = found ?? {
const found = this.allUsers().find((userEntry) => userEntry.id === msg.senderId || userEntry.oderId === msg.senderId);
return found ?? {
id: msg.senderId,
oderId: msg.senderId,
username: msg.senderName,
@@ -165,6 +159,14 @@ export class ChatMessageItemComponent {
role: 'member',
joinedAt: 0
};
});
editContent = '';
openSenderProfileCard(event: MouseEvent): void {
event.stopPropagation();
const el = event.currentTarget as HTMLElement;
const user = this.senderUser();
const editable = user.id === this.currentUserId();
this.profileCard.open(el, user, { editable });

View File

@@ -0,0 +1,44 @@
# Profile Avatar Domain
Owns local profile picture workflow: source validation, crop/zoom editor state, static 256x256 WebP rendering, animated avatar preservation, desktop file persistence, and P2P avatar sync metadata.
## Responsibilities
- Accept `.webp`, `.gif`, `.jpg`, `.jpeg` profile image sources.
- Let user drag and zoom source inside fixed preview frame before saving.
- Render static avatars to `256x256` WebP with client-side compression.
- Preserve animated `.gif` and animated `.webp` uploads without flattening frames.
- Persist desktop copy at `user/<username>/profile/profile.<ext>` under app data.
- Expose helpers used by store effects to keep avatar metadata (`avatarHash`, `avatarMime`, `avatarUpdatedAt`) consistent.
## Module map
```mermaid
graph TD
PC[ProfileCardComponent] --> PAE[ProfileAvatarEditorComponent]
PAE --> PAF[ProfileAvatarFacade]
PAF --> PAI[ProfileAvatarImageService]
PAF --> PAS[ProfileAvatarStorageService]
PAF --> Store[UsersActions.updateCurrentUserAvatar]
Store --> UAV[UserAvatarEffects]
UAV --> RTC[WebRTC data channel]
UAV --> DB[DatabaseService]
click PAE "feature/profile-avatar-editor/" "Crop and zoom editor UI" _blank
click PAF "application/services/profile-avatar.facade.ts" "Facade used by UI and effects" _blank
click PAI "infrastructure/services/profile-avatar-image.service.ts" "Canvas render and compression" _blank
click PAS "infrastructure/services/profile-avatar-storage.service.ts" "Electron file persistence" _blank
```
## Flow
1. `ProfileCardComponent` opens file picker from editable avatar button.
2. `ProfileAvatarEditorComponent` previews exact crop using drag + zoom.
3. `ProfileAvatarImageService` renders static uploads to `256x256` WebP, but keeps animated GIF and WebP sources intact.
4. `ProfileAvatarStorageService` writes desktop copy when Electron is available.
5. `UserAvatarEffects` broadcasts avatar summary, answers requests, streams chunks, and persists received avatars locally.
## Notes
- Static uploads are normalized to WebP. Animated GIF and animated WebP uploads keep their original animation, mime type, and full-frame presentation.
- `avatarUrl` stays local display data. Version conflict resolution uses `avatarUpdatedAt` and `avatarHash`.

View File

@@ -0,0 +1,69 @@
import { Injectable, inject } from '@angular/core';
import { User } from '../../../../shared-kernel';
import {
EditableProfileAvatarSource,
ProcessedProfileAvatar,
ProfileAvatarTransform,
ProfileAvatarUpdates
} from '../../domain/profile-avatar.models';
import { ProfileAvatarImageService } from '../../infrastructure/services/profile-avatar-image.service';
import { ProfileAvatarStorageService } from '../../infrastructure/services/profile-avatar-storage.service';
@Injectable({ providedIn: 'root' })
export class ProfileAvatarFacade {
private readonly image = inject(ProfileAvatarImageService);
private readonly storage = inject(ProfileAvatarStorageService);
validateFile(file: File): string | null {
return this.image.validateFile(file);
}
prepareEditableSource(file: File): Promise<EditableProfileAvatarSource> {
return this.image.prepareEditableSource(file);
}
releaseEditableSource(source: EditableProfileAvatarSource | null | undefined): void {
this.image.releaseEditableSource(source);
}
processEditableSource(
source: EditableProfileAvatarSource,
transform: ProfileAvatarTransform
): Promise<ProcessedProfileAvatar> {
return this.image.processEditableSource(source, transform);
}
persistProcessedAvatar(
user: Pick<User, 'id' | 'username' | 'displayName'>,
avatar: ProcessedProfileAvatar
): Promise<void> {
return this.storage.persistProcessedAvatar(user, avatar);
}
persistAvatarDataUrl(
user: Pick<User, 'id' | 'username' | 'displayName'>,
avatarUrl: string | null | undefined
): Promise<void> {
const mimeMatch = avatarUrl?.match(/^data:([^;]+);base64,/i);
const base64 = avatarUrl?.split(',', 2)[1] ?? '';
const avatarMime = mimeMatch?.[1]?.toLowerCase() ?? 'image/webp';
if (!base64) {
return Promise.resolve();
}
return this.storage.persistProcessedAvatar(user, {
base64,
avatarMime
});
}
buildAvatarUpdates(avatar: ProcessedProfileAvatar): ProfileAvatarUpdates {
return {
avatarUrl: avatar.avatarUrl,
avatarHash: avatar.avatarHash,
avatarMime: avatar.avatarMime,
avatarUpdatedAt: avatar.avatarUpdatedAt
};
}
}

View File

@@ -0,0 +1,38 @@
import {
PROFILE_AVATAR_MAX_ZOOM,
PROFILE_AVATAR_MIN_ZOOM,
clampProfileAvatarTransform,
clampProfileAvatarZoom,
resolveProfileAvatarStorageFileName,
resolveProfileAvatarBaseScale
} from './profile-avatar.models';
describe('profile-avatar models', () => {
it('clamps zoom inside allowed range', () => {
expect(clampProfileAvatarZoom(0.1)).toBe(PROFILE_AVATAR_MIN_ZOOM);
expect(clampProfileAvatarZoom(9)).toBe(PROFILE_AVATAR_MAX_ZOOM);
expect(clampProfileAvatarZoom(2.5)).toBe(2.5);
});
it('resolves cover scale for portrait images', () => {
expect(resolveProfileAvatarBaseScale({ width: 200, height: 400 }, 224)).toBeCloseTo(1.12);
});
it('clamps transform offsets so image still covers crop frame', () => {
const transform = clampProfileAvatarTransform(
{ width: 320, height: 240 },
{ zoom: 1, offsetX: 500, offsetY: -500 },
224
);
expect(transform.offsetX).toBeCloseTo(37.333333, 4);
expect(transform.offsetY).toBe(0);
});
it('maps avatar mime types to storage file names', () => {
expect(resolveProfileAvatarStorageFileName('image/gif')).toBe('profile.gif');
expect(resolveProfileAvatarStorageFileName('image/jpeg')).toBe('profile.jpg');
expect(resolveProfileAvatarStorageFileName('image/webp')).toBe('profile.webp');
expect(resolveProfileAvatarStorageFileName(undefined)).toBe('profile.webp');
});
});

View File

@@ -0,0 +1,99 @@
export const PROFILE_AVATAR_ALLOWED_MIME_TYPES = [
'image/webp',
'image/gif',
'image/jpeg'
] as const;
export const PROFILE_AVATAR_ACCEPT_ATTRIBUTE = '.webp,.gif,.jpg,.jpeg,image/webp,image/gif,image/jpeg';
export const PROFILE_AVATAR_OUTPUT_SIZE = 256;
export const PROFILE_AVATAR_EDITOR_FRAME_SIZE = 224;
export const PROFILE_AVATAR_MIN_ZOOM = 1;
export const PROFILE_AVATAR_MAX_ZOOM = 4;
export interface ProfileAvatarDimensions {
width: number;
height: number;
}
export interface EditableProfileAvatarSource extends ProfileAvatarDimensions {
file: File;
objectUrl: string;
mime: string;
name: string;
preservesAnimation: boolean;
}
export interface ProfileAvatarTransform {
zoom: number;
offsetX: number;
offsetY: number;
}
export interface ProfileAvatarUpdates {
avatarUrl: string;
avatarHash: string;
avatarMime: string;
avatarUpdatedAt: number;
}
export interface ProcessedProfileAvatar extends ProfileAvatarUpdates, ProfileAvatarDimensions {
base64: string;
blob: Blob;
}
export function resolveProfileAvatarStorageFileName(mime: string | null | undefined): string {
switch (mime?.toLowerCase()) {
case 'image/gif':
return 'profile.gif';
case 'image/jpeg':
case 'image/jpg':
return 'profile.jpg';
default:
return 'profile.webp';
}
}
export function clampProfileAvatarZoom(zoom: number): number {
if (!Number.isFinite(zoom)) {
return PROFILE_AVATAR_MIN_ZOOM;
}
return Math.min(Math.max(zoom, PROFILE_AVATAR_MIN_ZOOM), PROFILE_AVATAR_MAX_ZOOM);
}
export function resolveProfileAvatarBaseScale(
source: ProfileAvatarDimensions,
frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE
): number {
return Math.max(frameSize / source.width, frameSize / source.height);
}
export function clampProfileAvatarTransform(
source: ProfileAvatarDimensions,
transform: ProfileAvatarTransform,
frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE
): ProfileAvatarTransform {
const zoom = clampProfileAvatarZoom(transform.zoom);
const renderedWidth = source.width * resolveProfileAvatarBaseScale(source, frameSize) * zoom;
const renderedHeight = source.height * resolveProfileAvatarBaseScale(source, frameSize) * zoom;
const maxOffsetX = Math.max(0, (renderedWidth - frameSize) / 2);
const maxOffsetY = Math.max(0, (renderedHeight - frameSize) / 2);
return {
zoom,
offsetX: clampOffset(transform.offsetX, maxOffsetX),
offsetY: clampOffset(transform.offsetY, maxOffsetY)
};
}
function clampOffset(value: number, maxMagnitude: number): number {
if (!Number.isFinite(value)) {
return 0;
}
const nextValue = Math.min(Math.max(value, -maxMagnitude), maxMagnitude);
return Object.is(nextValue, -0) ? 0 : nextValue;
}

View File

@@ -0,0 +1,153 @@
<div
class="fixed inset-0 z-[112] bg-black/70 backdrop-blur-sm"
(click)="cancelled.emit()"
(keydown.enter)="cancelled.emit()"
(keydown.space)="cancelled.emit()"
role="button"
tabindex="0"
aria-label="Close profile image editor"
></div>
<div class="fixed inset-0 z-[113] flex items-center justify-center p-4 pointer-events-none">
<div
class="pointer-events-auto flex max-h-[calc(100vh-2rem)] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-2xl"
(click)="$event.stopPropagation()"
role="dialog"
aria-modal="true"
tabindex="-1"
>
<div class="border-b border-border p-5">
<h3 class="text-lg font-semibold text-foreground">Adjust profile picture</h3>
<p class="mt-1 text-sm text-muted-foreground">
@if (preservesAnimation()) {
Animated GIF and WebP avatars keep their original animation and framing.
} @else {
Drag image to frame subject. Zoom until preview looks right. Final image saves as 256x256 WebP.
}
</p>
</div>
<div class="grid gap-6 overflow-y-auto p-5 sm:grid-cols-[minmax(0,1fr)_280px]">
<div class="flex flex-col items-center gap-4">
<div
class="relative overflow-hidden rounded-[32px] border border-border bg-secondary/40 shadow-inner touch-none"
[style.width.px]="frameSize"
[style.height.px]="frameSize"
(pointerdown)="onPointerDown($event)"
(pointermove)="onPointerMove($event)"
(pointerup)="onPointerUp($event)"
(pointercancel)="onPointerUp($event)"
(wheel)="onWheel($event)"
>
<img
[src]="source().objectUrl"
[alt]="source().name"
class="pointer-events-none absolute left-1/2 top-1/2 max-w-none select-none"
[style.transform]="imageTransform()"
draggable="false"
/>
<div class="pointer-events-none absolute inset-0 rounded-[32px] ring-1 ring-white/10"></div>
<div class="pointer-events-none absolute inset-4 rounded-full border border-white/45 shadow-[0_0_0_999px_rgba(4,8,15,0.58)]"></div>
</div>
<p class="text-xs text-muted-foreground">
@if (preservesAnimation()) {
Animation and original framing are preserved.
} @else {
Preview matches saved crop.
}
</p>
</div>
<div class="space-y-5">
<div class="rounded-xl border border-border bg-secondary/20 p-4">
<p class="text-xs uppercase tracking-[0.18em] text-muted-foreground">Source</p>
<p class="mt-2 truncate text-sm font-medium text-foreground">{{ source().name }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ source().width }} x {{ source().height }}</p>
</div>
<div
class="rounded-xl border border-border bg-secondary/20 p-4"
[class.opacity-60]="preservesAnimation()"
>
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-foreground">Zoom</p>
<p class="text-xs text-muted-foreground">
@if (preservesAnimation()) {
Animated avatars keep the original frame sequence.
} @else {
Use wheel or slider.
}
</p>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-lg border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-secondary"
(click)="zoomBy(-0.12)"
[disabled]="preservesAnimation()"
>
-
</button>
<button
type="button"
class="rounded-lg border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-secondary"
(click)="zoomBy(0.12)"
[disabled]="preservesAnimation()"
>
+
</button>
</div>
</div>
<input
type="range"
min="1"
max="4"
step="0.01"
class="mt-4 w-full accent-primary"
[value]="clampedTransform().zoom"
(input)="onZoomInput($event)"
[disabled]="preservesAnimation()"
/>
<p class="mt-2 text-xs text-muted-foreground">
@if (preservesAnimation()) {
Animated upload detected.
} @else {
{{ (clampedTransform().zoom * 100).toFixed(0) }}% zoom
}
</p>
</div>
@if (errorMessage()) {
<div class="rounded-xl border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-200">
{{ errorMessage() }}
</div>
}
</div>
</div>
<div class="flex items-center justify-end gap-2 border-t border-border p-4">
<button
type="button"
class="rounded-lg bg-secondary px-4 py-2 text-sm text-foreground transition-colors hover:bg-secondary/80"
(click)="cancelled.emit()"
[disabled]="processing()"
>
Cancel
</button>
<button
type="button"
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
(click)="confirm()"
[disabled]="processing()"
>
{{ processing() ? 'Saving...' : 'Apply picture' }}
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,157 @@
import {
Component,
HostListener,
computed,
inject,
input,
output,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ProfileAvatarFacade } from '../../application/services/profile-avatar.facade';
import {
EditableProfileAvatarSource,
ProcessedProfileAvatar,
ProfileAvatarTransform,
PROFILE_AVATAR_EDITOR_FRAME_SIZE,
clampProfileAvatarTransform,
resolveProfileAvatarBaseScale
} from '../../domain/profile-avatar.models';
@Component({
selector: 'app-profile-avatar-editor',
standalone: true,
imports: [CommonModule],
templateUrl: './profile-avatar-editor.component.html'
})
export class ProfileAvatarEditorComponent {
readonly source = input.required<EditableProfileAvatarSource>();
readonly cancelled = output<void>();
readonly confirmed = output<ProcessedProfileAvatar>();
readonly frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE;
readonly processing = signal(false);
readonly errorMessage = signal<string | null>(null);
readonly preservesAnimation = computed(() => this.source().preservesAnimation);
readonly transform = signal<ProfileAvatarTransform>({ zoom: 1,
offsetX: 0,
offsetY: 0 });
readonly clampedTransform = computed(() => clampProfileAvatarTransform(this.source(), this.transform()));
readonly imageTransform = computed(() => {
const source = this.source();
const transform = this.clampedTransform();
const scale = resolveProfileAvatarBaseScale(source, this.frameSize) * transform.zoom;
return `translate(-50%, -50%) translate(${transform.offsetX}px, ${transform.offsetY}px) scale(${scale})`;
});
private readonly avatar = inject(ProfileAvatarFacade);
private dragPointerId: number | null = null;
private dragOrigin: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
@HostListener('document:keydown.escape')
onEscape(): void {
if (!this.processing()) {
this.cancelled.emit();
}
}
onZoomChange(value: string): void {
if (this.preservesAnimation()) {
return;
}
const zoom = Number(value);
this.transform.update((current) => ({
...current,
zoom
}));
}
onZoomInput(event: Event): void {
this.onZoomChange((event.target as HTMLInputElement).value);
}
zoomBy(delta: number): void {
if (this.preservesAnimation()) {
return;
}
this.transform.update((current) => ({
...current,
zoom: current.zoom + delta
}));
}
onWheel(event: WheelEvent): void {
if (this.preservesAnimation()) {
return;
}
event.preventDefault();
this.zoomBy(event.deltaY < 0 ? 0.08 : -0.08);
}
onPointerDown(event: PointerEvent): void {
if (this.processing() || this.preservesAnimation()) {
return;
}
const currentTarget = event.currentTarget as HTMLElement | null;
const currentTransform = this.clampedTransform();
currentTarget?.setPointerCapture(event.pointerId);
this.dragPointerId = event.pointerId;
this.dragOrigin = {
x: event.clientX,
y: event.clientY,
offsetX: currentTransform.offsetX,
offsetY: currentTransform.offsetY
};
}
onPointerMove(event: PointerEvent): void {
if (this.dragPointerId !== event.pointerId || !this.dragOrigin || this.processing() || this.preservesAnimation()) {
return;
}
this.transform.set(clampProfileAvatarTransform(this.source(), {
zoom: this.clampedTransform().zoom,
offsetX: this.dragOrigin.offsetX + (event.clientX - this.dragOrigin.x),
offsetY: this.dragOrigin.offsetY + (event.clientY - this.dragOrigin.y)
}));
}
onPointerUp(event: PointerEvent): void {
if (this.dragPointerId !== event.pointerId) {
return;
}
const currentTarget = event.currentTarget as HTMLElement | null;
currentTarget?.releasePointerCapture(event.pointerId);
this.dragPointerId = null;
this.dragOrigin = null;
}
async confirm(): Promise<void> {
if (this.processing()) {
return;
}
this.processing.set(true);
this.errorMessage.set(null);
try {
const avatar = await this.avatar.processEditableSource(this.source(), this.clampedTransform());
this.confirmed.emit(avatar);
} catch {
this.errorMessage.set('Failed to process profile image.');
} finally {
this.processing.set(false);
}
}
}

View File

@@ -0,0 +1,90 @@
import { Injectable, inject } from '@angular/core';
import {
Overlay,
OverlayRef
} from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import {
EditableProfileAvatarSource,
ProcessedProfileAvatar
} from '../../domain/profile-avatar.models';
import { ProfileAvatarEditorComponent } from './profile-avatar-editor.component';
export const PROFILE_AVATAR_EDITOR_OVERLAY_CLASS = 'profile-avatar-editor-overlay-pane';
@Injectable({ providedIn: 'root' })
export class ProfileAvatarEditorService {
private readonly overlay = inject(Overlay);
private overlayRef: OverlayRef | null = null;
open(source: EditableProfileAvatarSource): Promise<ProcessedProfileAvatar | null> {
this.close();
this.syncThemeVars();
const overlayRef = this.overlay.create({
disposeOnNavigation: true,
panelClass: PROFILE_AVATAR_EDITOR_OVERLAY_CLASS,
positionStrategy: this.overlay.position().global().centerHorizontally().centerVertically(),
scrollStrategy: this.overlay.scrollStrategies.block()
});
this.overlayRef = overlayRef;
const componentRef = overlayRef.attach(new ComponentPortal(ProfileAvatarEditorComponent));
componentRef.setInput('source', source);
return new Promise((resolve) => {
let settled = false;
const finish = (result: ProcessedProfileAvatar | null): void => {
if (settled) {
return;
}
settled = true;
cancelSub.unsubscribe();
confirmSub.unsubscribe();
detachSub.unsubscribe();
if (this.overlayRef === overlayRef) {
this.overlayRef = null;
}
overlayRef.dispose();
resolve(result);
};
const cancelSub = componentRef.instance.cancelled.subscribe(() => finish(null));
const confirmSub = componentRef.instance.confirmed.subscribe((avatar) => finish(avatar));
const detachSub = overlayRef.detachments().subscribe(() => finish(null));
});
}
close(): void {
if (!this.overlayRef) {
return;
}
const overlayRef = this.overlayRef;
this.overlayRef = null;
overlayRef.dispose();
}
private syncThemeVars(): void {
const appRoot = document.querySelector<HTMLElement>('[data-theme-key="appRoot"]');
const container = document.querySelector<HTMLElement>('.cdk-overlay-container');
if (!appRoot || !container) {
return;
}
for (const prop of Array.from(appRoot.style)) {
if (prop.startsWith('--')) {
container.style.setProperty(prop, appRoot.style.getPropertyValue(prop));
}
}
}
}

View File

@@ -0,0 +1,7 @@
export * from './domain/profile-avatar.models';
export { ProfileAvatarFacade } from './application/services/profile-avatar.facade';
export { ProfileAvatarEditorComponent } from './feature/profile-avatar-editor/profile-avatar-editor.component';
export {
PROFILE_AVATAR_EDITOR_OVERLAY_CLASS,
ProfileAvatarEditorService
} from './feature/profile-avatar-editor/profile-avatar-editor.service';

View File

@@ -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);
});
});

View File

@@ -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);
}

View File

@@ -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';
}
}

View File

@@ -23,6 +23,7 @@
>
<app-user-avatar
[name]="currentUser()?.displayName || '?'"
[avatarUrl]="currentUser()?.avatarUrl"
size="sm"
[status]="currentUser()?.status"
[showStatusBadge]="true"