feat: Add profile images
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
44
toju-app/src/app/domains/profile-avatar/README.md
Normal file
44
toju-app/src/app/domains/profile-avatar/README.md
Normal 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`.
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
7
toju-app/src/app/domains/profile-avatar/index.ts
Normal file
7
toju-app/src/app/domains/profile-avatar/index.ts
Normal 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';
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
>
|
||||
<app-user-avatar
|
||||
[name]="currentUser()?.displayName || '?'"
|
||||
[avatarUrl]="currentUser()?.avatarUrl"
|
||||
size="sm"
|
||||
[status]="currentUser()?.status"
|
||||
[showStatusBadge]="true"
|
||||
|
||||
Reference in New Issue
Block a user