feat: Add user metadata changing display name and description with sync
All checks were successful
Queue Release Build / prepare (push) Successful in 28s
Deploy Web Apps / deploy (push) Successful in 5m2s
Queue Release Build / build-windows (push) Successful in 16m44s
Queue Release Build / build-linux (push) Successful in 27m12s
Queue Release Build / finalize (push) Successful in 22s

This commit is contained in:
2026-04-17 22:04:18 +02:00
parent 3ba8a2c9eb
commit bd21568726
41 changed files with 1176 additions and 191 deletions

View File

@@ -11,7 +11,11 @@ import { UserAvatarComponent } from '../../../../shared';
@Component({
selector: 'app-user-bar',
standalone: true,
imports: [CommonModule, NgIcon, UserAvatarComponent],
imports: [
CommonModule,
NgIcon,
UserAvatarComponent
],
viewProviders: [
provideIcons({
lucideLogIn,

View File

@@ -1,6 +1,6 @@
# 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.
Owns local profile picture workflow plus peer-synced profile-card metadata: source validation, crop/zoom editor state, static 256x256 WebP rendering, animated avatar preservation, desktop file persistence, and P2P avatar/profile sync metadata.
## Responsibilities
@@ -9,7 +9,9 @@ Owns local profile picture workflow: source validation, crop/zoom editor state,
- 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.
- Let the local user edit their profile-card display name and description.
- Expose helpers used by store effects to keep avatar metadata (`avatarHash`, `avatarMime`, `avatarUpdatedAt`) consistent.
- Reuse the avatar summary/request/full handshake to sync profile text (`displayName`, `description`, `profileUpdatedAt`) alongside avatar state.
## Module map
@@ -33,12 +35,14 @@ graph TD
## 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.
2. `ProfileCardComponent` saves display-name and description edits through the users store.
3. `ProfileAvatarEditorComponent` previews exact crop using drag + zoom.
4. `ProfileAvatarImageService` renders static uploads to `256x256` WebP, but keeps animated GIF and WebP sources intact.
5. `ProfileAvatarStorageService` writes desktop copy when Electron is available.
6. `UserAvatarEffects` broadcasts avatar/profile summaries, answers requests, streams chunks when needed, and persists received profile state 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`.
- Profile text uses its own `profileUpdatedAt` version so display-name and description changes can sync without replacing a newer avatar.

View File

@@ -1,8 +1,8 @@
<div
class="fixed inset-0 z-[112] bg-black/70 backdrop-blur-sm"
(click)="cancelled.emit()"
(keydown.enter)="cancelled.emit()"
(keydown.space)="cancelled.emit()"
(click)="cancelled.emit(undefined)"
(keydown.enter)="cancelled.emit(undefined)"
(keydown.space)="cancelled.emit(undefined)"
role="button"
tabindex="0"
aria-label="Close profile image editor"
@@ -11,7 +11,6 @@
<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"
@@ -135,7 +134,7 @@
<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()"
(click)="cancelled.emit(undefined)"
[disabled]="processing()"
>
Cancel

View File

@@ -27,7 +27,7 @@ import {
export class ProfileAvatarEditorComponent {
readonly source = input.required<EditableProfileAvatarSource>();
readonly cancelled = output<void>();
readonly cancelled = output<undefined>();
readonly confirmed = output<ProcessedProfileAvatar>();
readonly frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE;
@@ -53,7 +53,7 @@ export class ProfileAvatarEditorComponent {
@HostListener('document:keydown.escape')
onEscape(): void {
if (!this.processing()) {
this.cancelled.emit();
this.cancelled.emit(undefined);
}
}

View File

@@ -1,13 +1,7 @@
import { Injectable, inject } from '@angular/core';
import {
Overlay,
OverlayRef
} from '@angular/cdk/overlay';
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import {
EditableProfileAvatarSource,
ProcessedProfileAvatar
} from '../../domain/profile-avatar.models';
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';
@@ -25,7 +19,9 @@ export class ProfileAvatarEditorService {
const overlayRef = this.overlay.create({
disposeOnNavigation: true,
panelClass: PROFILE_AVATAR_EDITOR_OVERLAY_CLASS,
positionStrategy: this.overlay.position().global().centerHorizontally().centerVertically(),
positionStrategy: this.overlay.position().global()
.centerHorizontally()
.centerVertically(),
scrollStrategy: this.overlay.scrollStrategies.block()
});
@@ -55,7 +51,6 @@ export class ProfileAvatarEditorService {
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));

View File

@@ -2,6 +2,6 @@ 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
PROFILE_AVATAR_EDITOR_OVERLAY_CLASS,
ProfileAvatarEditorService
} from './feature/profile-avatar-editor/profile-avatar-editor.service';

View File

@@ -1,7 +1,5 @@
import {
isAnimatedGif,
isAnimatedWebp
} from './profile-avatar-image.service';
/* eslint-disable @stylistic/js/array-element-newline */
import { isAnimatedGif, isAnimatedWebp } from './profile-avatar-image.service';
describe('profile-avatar image animation detection', () => {
it('detects animated gifs with multiple frames', () => {

View File

@@ -1,10 +1,7 @@
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';
import type { User } from '../../../../shared-kernel';
import { resolveProfileAvatarStorageFileName, type ProcessedProfileAvatar } from '../../domain/profile-avatar.models';
const LEGACY_PROFILE_FILE_NAMES = [
'profile.webp',