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
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:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -199,6 +199,8 @@ export class RoomsSidePanelComponent {
|
||||
oderId: member.oderId || member.id,
|
||||
username: member.username,
|
||||
displayName: member.displayName,
|
||||
description: member.description,
|
||||
profileUpdatedAt: member.profileUpdatedAt,
|
||||
avatarUrl: member.avatarUrl,
|
||||
status: 'disconnected',
|
||||
role: member.role,
|
||||
|
||||
@@ -232,7 +232,7 @@ A single ordered data channel carries all peer-to-peer messages: chat events, at
|
||||
|
||||
Back-pressure is handled with a high-water mark (4 MB) and low-water mark (1 MB). `sendToPeerBuffered()` waits for the buffer to drain before sending, which matters during file transfers.
|
||||
|
||||
Profile avatar sync follows attachment-style chunk transport plus server-icon-style version handshakes: sender announces `avatarUpdatedAt`, receiver requests only when remote version is newer, then sender streams ordered base64 chunks over buffered sends.
|
||||
Profile avatar sync follows attachment-style chunk transport plus server-icon-style version handshakes: sender announces avatar/profile versions, receiver requests only when either remote version is newer, then sender streams ordered base64 chunks when avatar bytes are needed and still uses the same full-event path for profile-only updates.
|
||||
|
||||
Every 5 seconds a PING message is sent to each peer. The peer responds with PONG carrying the original timestamp, and the round-trip latency is stored in a signal.
|
||||
|
||||
|
||||
@@ -328,8 +328,13 @@ export class WebRTCService implements OnDestroy {
|
||||
* @param oderId - The user's unique order/peer ID.
|
||||
* @param displayName - The user's display name.
|
||||
*/
|
||||
identify(oderId: string, displayName: string, signalUrl?: string): void {
|
||||
this.signalingTransportHandler.identify(oderId, displayName, signalUrl);
|
||||
identify(
|
||||
oderId: string,
|
||||
displayName: string,
|
||||
signalUrl?: string,
|
||||
profile?: { description?: string; profileUpdatedAt?: number }
|
||||
): void {
|
||||
this.signalingTransportHandler.identify(oderId, displayName, signalUrl, profile);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,6 +36,10 @@ export interface IdentifyCredentials {
|
||||
oderId: string;
|
||||
/** The user's display name shown to other peers. */
|
||||
displayName: string;
|
||||
/** Optional profile description advertised via signaling identity. */
|
||||
description?: string;
|
||||
/** Monotonic profile version for late-join reconciliation. */
|
||||
profileUpdatedAt?: number;
|
||||
}
|
||||
|
||||
/** Last-joined server info, used for reconnection. */
|
||||
|
||||
@@ -30,6 +30,10 @@ export class SignalingTransportHandler<TMessage> {
|
||||
return this.lastIdentifyCredentials?.displayName || DEFAULT_DISPLAY_NAME;
|
||||
}
|
||||
|
||||
getIdentifyDescription(): string | undefined {
|
||||
return this.lastIdentifyCredentials?.description;
|
||||
}
|
||||
|
||||
getConnectedSignalingManagers(): ConnectedSignalingManager[] {
|
||||
return this.dependencies.signalingCoordinator.getConnectedSignalingManagers();
|
||||
}
|
||||
@@ -160,12 +164,27 @@ export class SignalingTransportHandler<TMessage> {
|
||||
return true;
|
||||
}
|
||||
|
||||
identify(oderId: string, displayName: string, signalUrl?: string): void {
|
||||
identify(
|
||||
oderId: string,
|
||||
displayName: string,
|
||||
signalUrl?: string,
|
||||
profile?: Pick<IdentifyCredentials, 'description' | 'profileUpdatedAt'>
|
||||
): void {
|
||||
const normalizedDisplayName = displayName.trim() || DEFAULT_DISPLAY_NAME;
|
||||
const normalizedDescription = typeof profile?.description === 'string'
|
||||
? (profile.description.trim() || undefined)
|
||||
: undefined;
|
||||
const normalizedProfileUpdatedAt = typeof profile?.profileUpdatedAt === 'number'
|
||||
&& Number.isFinite(profile.profileUpdatedAt)
|
||||
&& profile.profileUpdatedAt > 0
|
||||
? profile.profileUpdatedAt
|
||||
: undefined;
|
||||
|
||||
this.lastIdentifyCredentials = {
|
||||
oderId,
|
||||
displayName: normalizedDisplayName
|
||||
displayName: normalizedDisplayName,
|
||||
description: normalizedDescription,
|
||||
profileUpdatedAt: normalizedProfileUpdatedAt
|
||||
};
|
||||
|
||||
if (signalUrl) {
|
||||
@@ -173,6 +192,8 @@ export class SignalingTransportHandler<TMessage> {
|
||||
type: SIGNALING_TYPE_IDENTIFY,
|
||||
oderId,
|
||||
displayName: normalizedDisplayName,
|
||||
description: normalizedDescription,
|
||||
profileUpdatedAt: normalizedProfileUpdatedAt,
|
||||
connectionScope: signalUrl
|
||||
});
|
||||
|
||||
@@ -190,6 +211,8 @@ export class SignalingTransportHandler<TMessage> {
|
||||
type: SIGNALING_TYPE_IDENTIFY,
|
||||
oderId,
|
||||
displayName: normalizedDisplayName,
|
||||
description: normalizedDescription,
|
||||
profileUpdatedAt: normalizedProfileUpdatedAt,
|
||||
connectionScope: managerSignalUrl
|
||||
});
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface ChatEventBase {
|
||||
deletedBy?: string;
|
||||
oderId?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
profileUpdatedAt?: number;
|
||||
emoji?: string;
|
||||
reason?: string;
|
||||
settings?: Partial<RoomSettings>;
|
||||
@@ -273,6 +275,8 @@ export interface UserAvatarSummaryEvent extends ChatEventBase {
|
||||
oderId: string;
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
profileUpdatedAt?: number;
|
||||
avatarHash?: string;
|
||||
avatarMime?: string;
|
||||
avatarUpdatedAt: number;
|
||||
@@ -288,8 +292,10 @@ export interface UserAvatarFullEvent extends ChatEventBase {
|
||||
oderId: string;
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
profileUpdatedAt?: number;
|
||||
avatarHash?: string;
|
||||
avatarMime: string;
|
||||
avatarMime?: string;
|
||||
avatarUpdatedAt: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface User {
|
||||
oderId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
profileUpdatedAt?: number;
|
||||
avatarUrl?: string;
|
||||
avatarHash?: string;
|
||||
avatarMime?: string;
|
||||
@@ -35,6 +37,8 @@ export interface RoomMember {
|
||||
oderId?: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
profileUpdatedAt?: number;
|
||||
avatarUrl?: string;
|
||||
avatarHash?: string;
|
||||
avatarMime?: string;
|
||||
|
||||
@@ -2,63 +2,109 @@
|
||||
class="w-72 rounded-lg border border-border bg-card shadow-xl"
|
||||
style="animation: profile-card-in 120ms cubic-bezier(0.2, 0, 0, 1) both"
|
||||
>
|
||||
<div class="h-24 rounded-t-lg bg-gradient-to-r from-primary/30 to-primary/10"></div>
|
||||
@let profileUser = user();
|
||||
@let isEditable = editable();
|
||||
@let activeField = editingField();
|
||||
@let statusColor = currentStatusColor();
|
||||
@let statusLabel = currentStatusLabel();
|
||||
|
||||
<div class="h-20 rounded-t-lg bg-gradient-to-r from-primary/30 to-primary/10"></div>
|
||||
|
||||
<div class="relative px-4">
|
||||
<div class="-mt-9">
|
||||
@if (editable()) {
|
||||
<button
|
||||
#avatarInputButton
|
||||
type="button"
|
||||
class="group relative rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
(click)="pickAvatar(avatarInput)"
|
||||
>
|
||||
<app-user-avatar
|
||||
[name]="user().displayName"
|
||||
[avatarUrl]="user().avatarUrl"
|
||||
size="xl"
|
||||
[status]="user().status"
|
||||
[showStatusBadge]="true"
|
||||
ringClass="ring-4 ring-card"
|
||||
/>
|
||||
<span class="pointer-events-none absolute inset-0 rounded-full bg-black/0 transition-colors group-hover:bg-black/15"></span>
|
||||
</button>
|
||||
<input
|
||||
#avatarInput
|
||||
type="file"
|
||||
class="hidden"
|
||||
[accept]="avatarAccept"
|
||||
(change)="onAvatarSelected($event)"
|
||||
/>
|
||||
} @else {
|
||||
<div class="-mt-8">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full"
|
||||
(click)="pickAvatar(avatarInput)"
|
||||
>
|
||||
<app-user-avatar
|
||||
[name]="user().displayName"
|
||||
[avatarUrl]="user().avatarUrl"
|
||||
[name]="profileUser.displayName"
|
||||
[avatarUrl]="profileUser.avatarUrl"
|
||||
size="xl"
|
||||
[status]="user().status"
|
||||
[status]="profileUser.status"
|
||||
[showStatusBadge]="true"
|
||||
ringClass="ring-4 ring-card"
|
||||
/>
|
||||
}
|
||||
</button>
|
||||
<input
|
||||
#avatarInput
|
||||
type="file"
|
||||
class="hidden"
|
||||
[accept]="avatarAccept"
|
||||
(change)="onAvatarSelected($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 pb-4 pt-3">
|
||||
<p class="truncate text-base font-semibold text-foreground">{{ user().displayName }}</p>
|
||||
<p class="truncate text-sm text-muted-foreground">{{ user().username }}</p>
|
||||
<div class="px-4 pb-3 pt-2.5">
|
||||
@if (isEditable) {
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
@if (activeField === 'displayName') {
|
||||
<input
|
||||
type="text"
|
||||
class="w-full rounded-md border border-border bg-background/70 px-2 py-1.5 text-base font-semibold text-foreground outline-none focus:border-primary/70"
|
||||
|
||||
[value]="displayNameDraft()"
|
||||
(input)="onDisplayNameInput($event)"
|
||||
(blur)="finishEdit('displayName')"
|
||||
/>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full py-0.5 text-left text-base font-semibold text-foreground"
|
||||
(click)="startEdit('displayName')"
|
||||
>
|
||||
{{ profileUser.displayName }}
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (editable()) {
|
||||
<p class="mt-2 text-xs text-muted-foreground">Click avatar to upload and crop a profile picture.</p>
|
||||
<p class="truncate text-sm text-muted-foreground">{{ profileUser.username }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@if (activeField === 'description') {
|
||||
<textarea
|
||||
rows="3"
|
||||
class="w-full resize-none rounded-md border border-border bg-background/70 px-2 py-2 text-sm leading-5 text-foreground outline-none focus:border-primary/70"
|
||||
|
||||
[value]="descriptionDraft()"
|
||||
placeholder="Add a description"
|
||||
(input)="onDescriptionInput($event)"
|
||||
(blur)="finishEdit('description')"
|
||||
></textarea>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full py-1 text-left text-sm leading-5"
|
||||
(click)="startEdit('description')"
|
||||
>
|
||||
@if (profileUser.description) {
|
||||
<span class="whitespace-pre-line text-muted-foreground">{{ profileUser.description }}</span>
|
||||
} @else {
|
||||
<span class="text-muted-foreground/70">Add a description</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="truncate text-base font-semibold text-foreground">{{ profileUser.displayName }}</p>
|
||||
<p class="truncate text-sm text-muted-foreground">{{ profileUser.username }}</p>
|
||||
|
||||
@if (profileUser.description) {
|
||||
<p class="mt-2 whitespace-pre-line text-sm leading-5 text-muted-foreground">{{ profileUser.description }}</p>
|
||||
}
|
||||
}
|
||||
|
||||
@if (avatarError()) {
|
||||
<div class="mt-3 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-red-200">
|
||||
<div class="mt-2.5 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-red-200">
|
||||
{{ avatarError() }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (editable()) {
|
||||
<div class="relative mt-3">
|
||||
@if (isEditable) {
|
||||
<div class="relative mt-2.5">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-md border border-border px-2.5 py-1.5 text-xs transition-colors hover:bg-secondary/60"
|
||||
@@ -66,9 +112,9 @@
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full"
|
||||
[class]="currentStatusColor()"
|
||||
[class]="statusColor"
|
||||
></span>
|
||||
<span class="flex-1 text-left text-foreground">{{ currentStatusLabel() }}</span>
|
||||
<span class="flex-1 text-left text-foreground">{{ statusLabel }}</span>
|
||||
<ng-icon
|
||||
name="lucideChevronDown"
|
||||
class="h-3 w-3 text-muted-foreground"
|
||||
@@ -97,9 +143,9 @@
|
||||
<div class="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span
|
||||
class="h-2 w-2 rounded-full"
|
||||
[class]="currentStatusColor()"
|
||||
[class]="statusColor"
|
||||
></span>
|
||||
<span>{{ currentStatusLabel() }}</span>
|
||||
<span>{{ statusLabel }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Component,
|
||||
effect,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
@@ -37,6 +38,9 @@ export class ProfileCardComponent {
|
||||
readonly avatarAccept = PROFILE_AVATAR_ACCEPT_ATTRIBUTE;
|
||||
readonly avatarError = signal<string | null>(null);
|
||||
readonly avatarSaving = signal(false);
|
||||
readonly editingField = signal<'displayName' | 'description' | null>(null);
|
||||
readonly displayNameDraft = signal('');
|
||||
readonly descriptionDraft = signal('');
|
||||
|
||||
readonly statusOptions: { value: UserStatus | null; label: string; color: string }[] = [
|
||||
{ value: null, label: 'Online', color: 'bg-green-500' },
|
||||
@@ -49,6 +53,19 @@ export class ProfileCardComponent {
|
||||
private readonly store = inject(Store);
|
||||
private readonly profileAvatar = inject(ProfileAvatarFacade);
|
||||
private readonly profileAvatarEditor = inject(ProfileAvatarEditorService);
|
||||
private readonly syncProfileDrafts = effect(() => {
|
||||
const user = this.user();
|
||||
const editingField = this.editingField();
|
||||
|
||||
if (editingField !== 'displayName') {
|
||||
this.displayNameDraft.set(user.displayName || '');
|
||||
}
|
||||
|
||||
if (editingField !== 'description') {
|
||||
this.descriptionDraft.set(user.description || '');
|
||||
}
|
||||
|
||||
}, { allowSignalWrites: true });
|
||||
|
||||
currentStatusColor(): string {
|
||||
switch (this.user().status) {
|
||||
@@ -81,6 +98,31 @@ export class ProfileCardComponent {
|
||||
this.showStatusMenu.set(false);
|
||||
}
|
||||
|
||||
onDisplayNameInput(event: Event): void {
|
||||
this.displayNameDraft.set((event.target as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
onDescriptionInput(event: Event): void {
|
||||
this.descriptionDraft.set((event.target as HTMLTextAreaElement).value);
|
||||
}
|
||||
|
||||
startEdit(field: 'displayName' | 'description'): void {
|
||||
if (!this.editable() || this.editingField() === field) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.editingField.set(field);
|
||||
}
|
||||
|
||||
finishEdit(field: 'displayName' | 'description'): void {
|
||||
if (this.editingField() !== field) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.commitProfileDrafts();
|
||||
this.editingField.set(null);
|
||||
}
|
||||
|
||||
pickAvatar(fileInput: HTMLInputElement): void {
|
||||
if (!this.editable() || this.avatarSaving()) {
|
||||
return;
|
||||
@@ -147,4 +189,49 @@ export class ProfileCardComponent {
|
||||
this.avatarSaving.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private commitProfileDrafts(): void {
|
||||
if (!this.editable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const displayName = this.normalizeDisplayName(this.displayNameDraft());
|
||||
|
||||
if (!displayName) {
|
||||
this.displayNameDraft.set(this.user().displayName || '');
|
||||
return;
|
||||
}
|
||||
|
||||
const user = this.user();
|
||||
const description = this.normalizeDescription(this.descriptionDraft());
|
||||
|
||||
if (
|
||||
displayName === this.normalizeDisplayName(user.displayName)
|
||||
&& description === this.normalizeDescription(user.description)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = {
|
||||
displayName,
|
||||
description,
|
||||
profileUpdatedAt: Date.now()
|
||||
};
|
||||
|
||||
this.store.dispatch(UsersActions.updateCurrentUserProfile({ profile }));
|
||||
this.user.update((user) => ({
|
||||
...user,
|
||||
...profile
|
||||
}));
|
||||
}
|
||||
|
||||
private normalizeDisplayName(value: string | undefined): string {
|
||||
return value?.trim().replace(/\s+/g, ' ') || '';
|
||||
}
|
||||
|
||||
private normalizeDescription(value: string | undefined): string | undefined {
|
||||
const normalized = value?.trim();
|
||||
|
||||
return normalized || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,14 @@ import {
|
||||
createEffect,
|
||||
ofType
|
||||
} from '@ngrx/effects';
|
||||
import { Action } from '@ngrx/store';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Store, type Action } from '@ngrx/store';
|
||||
import { EMPTY } from 'rxjs';
|
||||
import {
|
||||
mergeMap,
|
||||
tap,
|
||||
withLatestFrom
|
||||
} from 'rxjs/operators';
|
||||
import {
|
||||
import type {
|
||||
ChatEvent,
|
||||
Room,
|
||||
RoomMember,
|
||||
@@ -394,7 +393,28 @@ export class RoomMembersSyncEffects {
|
||||
);
|
||||
}
|
||||
|
||||
return this.createRoomMemberUpdateActions(room, members);
|
||||
const actions = this.createRoomMemberUpdateActions(room, members);
|
||||
const currentUserId = currentUser?.oderId || currentUser?.id;
|
||||
|
||||
for (const member of members) {
|
||||
const memberId = member.oderId || member.id;
|
||||
|
||||
if (!member.avatarUrl || !memberId || memberId === currentUserId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
actions.push(UsersActions.upsertRemoteUserAvatar({
|
||||
user: {
|
||||
id: member.id,
|
||||
oderId: memberId,
|
||||
username: member.username,
|
||||
displayName: member.displayName,
|
||||
avatarUrl: member.avatarUrl
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
private handleMemberLeave(
|
||||
|
||||
@@ -36,6 +36,51 @@ function normalizeAvatarUpdatedAt(value: unknown): number | undefined {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeProfileUpdatedAt(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeDescription(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function hasOwnProperty(object: object, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(object, key);
|
||||
}
|
||||
|
||||
function mergeProfileFields(
|
||||
existingMember: Pick<RoomMember, 'displayName' | 'description' | 'profileUpdatedAt'>,
|
||||
incomingMember: Pick<RoomMember, 'displayName' | 'description' | 'profileUpdatedAt'>,
|
||||
preferIncomingFallback: boolean
|
||||
): Pick<RoomMember, 'displayName' | 'description' | 'profileUpdatedAt'> {
|
||||
const existingUpdatedAt = existingMember.profileUpdatedAt ?? 0;
|
||||
const incomingUpdatedAt = incomingMember.profileUpdatedAt ?? 0;
|
||||
const preferIncoming = incomingUpdatedAt === existingUpdatedAt
|
||||
? preferIncomingFallback
|
||||
: incomingUpdatedAt > existingUpdatedAt;
|
||||
const incomingHasDescription = hasOwnProperty(incomingMember, 'description');
|
||||
const incomingDescription = normalizeDescription(incomingMember.description);
|
||||
const existingDescription = normalizeDescription(existingMember.description);
|
||||
|
||||
return {
|
||||
displayName: preferIncoming
|
||||
? (incomingMember.displayName || existingMember.displayName)
|
||||
: (existingMember.displayName || incomingMember.displayName),
|
||||
description: preferIncoming
|
||||
? (incomingHasDescription ? incomingDescription : existingDescription)
|
||||
: existingDescription,
|
||||
profileUpdatedAt: Math.max(existingUpdatedAt, incomingUpdatedAt) || undefined
|
||||
};
|
||||
}
|
||||
|
||||
function mergeAvatarFields(
|
||||
existingMember: Pick<RoomMember, 'avatarUrl' | 'avatarHash' | 'avatarMime' | 'avatarUpdatedAt'>,
|
||||
incomingMember: Pick<RoomMember, 'avatarUrl' | 'avatarHash' | 'avatarMime' | 'avatarUpdatedAt'>,
|
||||
@@ -73,12 +118,12 @@ function normalizeMember(member: RoomMember, now = Date.now()): RoomMember {
|
||||
typeof member.joinedAt === 'number' && Number.isFinite(member.joinedAt)
|
||||
? member.joinedAt
|
||||
: lastSeenAt;
|
||||
|
||||
return {
|
||||
const nextMember: RoomMember = {
|
||||
id: member.id || key,
|
||||
oderId: member.oderId || undefined,
|
||||
username: member.username || fallbackUsername(member),
|
||||
displayName: fallbackDisplayName(member),
|
||||
profileUpdatedAt: normalizeProfileUpdatedAt(member.profileUpdatedAt),
|
||||
avatarUrl: member.avatarUrl || undefined,
|
||||
avatarHash: member.avatarHash || undefined,
|
||||
avatarMime: member.avatarMime || undefined,
|
||||
@@ -88,6 +133,12 @@ function normalizeMember(member: RoomMember, now = Date.now()): RoomMember {
|
||||
joinedAt,
|
||||
lastSeenAt
|
||||
};
|
||||
|
||||
if (hasOwnProperty(member, 'description')) {
|
||||
nextMember.description = normalizeDescription(member.description);
|
||||
}
|
||||
|
||||
return nextMember;
|
||||
}
|
||||
|
||||
function compareMembers(firstMember: RoomMember, secondMember: RoomMember): number {
|
||||
@@ -128,6 +179,7 @@ function mergeMembers(
|
||||
|
||||
const normalizedExisting = normalizeMember(existingMember, now);
|
||||
const preferIncoming = normalizedIncoming.lastSeenAt >= normalizedExisting.lastSeenAt;
|
||||
const profileFields = mergeProfileFields(normalizedExisting, normalizedIncoming, preferIncoming);
|
||||
const avatarFields = mergeAvatarFields(normalizedExisting, normalizedIncoming, preferIncoming);
|
||||
|
||||
return {
|
||||
@@ -136,9 +188,7 @@ function mergeMembers(
|
||||
username: preferIncoming
|
||||
? (normalizedIncoming.username || normalizedExisting.username)
|
||||
: (normalizedExisting.username || normalizedIncoming.username),
|
||||
displayName: preferIncoming
|
||||
? (normalizedIncoming.displayName || normalizedExisting.displayName)
|
||||
: (normalizedExisting.displayName || normalizedIncoming.displayName),
|
||||
...profileFields,
|
||||
...avatarFields,
|
||||
role: mergeRole(normalizedExisting.role, normalizedIncoming.role, preferIncoming),
|
||||
roleIds: preferIncoming
|
||||
@@ -177,6 +227,8 @@ export function roomMemberFromUser(
|
||||
oderId: user.oderId || undefined,
|
||||
username: user.username || '',
|
||||
displayName: user.displayName || user.username || 'User',
|
||||
description: user.description,
|
||||
profileUpdatedAt: user.profileUpdatedAt,
|
||||
avatarUrl: user.avatarUrl,
|
||||
avatarHash: user.avatarHash,
|
||||
avatarMime: user.avatarMime,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Store } from '@ngrx/store';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { Room, User } from '../../shared-kernel';
|
||||
import type { Room, User } from '../../shared-kernel';
|
||||
import {
|
||||
type RoomSignalSource,
|
||||
type ServerSourceSelector,
|
||||
@@ -353,6 +353,8 @@ export class RoomSignalingConnection {
|
||||
const wsUrl = this.serverDirectory.getWebSocketUrl(selector);
|
||||
const oderId = resolvedOderId || user?.oderId || this.webrtc.peerId();
|
||||
const displayName = resolveUserDisplayName(user);
|
||||
const description = user?.description;
|
||||
const profileUpdatedAt = user?.profileUpdatedAt;
|
||||
const sameSignalRooms = this.getRoomsForSignalingUrl(this.includeRoom(savedRooms, room), wsUrl);
|
||||
const backgroundRooms = sameSignalRooms.filter((candidate) => candidate.id !== room.id);
|
||||
const joinCurrentEndpointRooms = () => {
|
||||
@@ -361,7 +363,10 @@ export class RoomSignalingConnection {
|
||||
}
|
||||
|
||||
this.webrtc.setCurrentServer(room.id);
|
||||
this.webrtc.identify(oderId, displayName, wsUrl);
|
||||
this.webrtc.identify(oderId, displayName, wsUrl, {
|
||||
description,
|
||||
profileUpdatedAt
|
||||
});
|
||||
|
||||
for (const backgroundRoom of backgroundRooms) {
|
||||
this.webrtc.joinRoom(backgroundRoom.id, oderId, wsUrl);
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
createEffect,
|
||||
ofType
|
||||
} from '@ngrx/effects';
|
||||
import { Action, Store } from '@ngrx/store';
|
||||
import { Store, type Action } from '@ngrx/store';
|
||||
import {
|
||||
of,
|
||||
from,
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
import { RealtimeSessionFacade } from '../../core/realtime';
|
||||
import { DatabaseService } from '../../infrastructure/persistence';
|
||||
import { resolveRoomPermission } from '../../domains/access-control';
|
||||
import {
|
||||
import type {
|
||||
ChatEvent,
|
||||
Room,
|
||||
RoomSettings,
|
||||
@@ -50,9 +50,9 @@ import {
|
||||
resolveRoom,
|
||||
sanitizeRoomSnapshot,
|
||||
normalizeIncomingBans,
|
||||
getPersistedCurrentUserId,
|
||||
RoomPresenceSignalingMessage
|
||||
getPersistedCurrentUserId
|
||||
} from './rooms.helpers';
|
||||
import type { RoomPresenceSignalingMessage } from './rooms.helpers';
|
||||
|
||||
/**
|
||||
* NgRx effects for real-time state synchronisation: signaling presence
|
||||
@@ -113,6 +113,8 @@ export class RoomStateSyncEffects {
|
||||
.map((user) =>
|
||||
buildSignalingUser(user, {
|
||||
...buildKnownUserExtras(room, user.oderId),
|
||||
description: user.description,
|
||||
profileUpdatedAt: user.profileUpdatedAt,
|
||||
presenceServerIds: [signalingMessage.serverId],
|
||||
...(user.status ? { status: user.status } : {})
|
||||
})
|
||||
@@ -141,12 +143,16 @@ export class RoomStateSyncEffects {
|
||||
const joinedUser = {
|
||||
oderId: signalingMessage.oderId,
|
||||
displayName: signalingMessage.displayName,
|
||||
description: signalingMessage.description,
|
||||
profileUpdatedAt: signalingMessage.profileUpdatedAt,
|
||||
status: signalingMessage.status
|
||||
};
|
||||
const actions: Action[] = [
|
||||
UsersActions.userJoined({
|
||||
user: buildSignalingUser(joinedUser, {
|
||||
...buildKnownUserExtras(room, joinedUser.oderId),
|
||||
description: joinedUser.description,
|
||||
profileUpdatedAt: joinedUser.profileUpdatedAt,
|
||||
presenceServerIds: [signalingMessage.serverId]
|
||||
})
|
||||
})
|
||||
|
||||
@@ -48,6 +48,8 @@ export function buildKnownUserExtras(room: Room | null, identifier: string): Rec
|
||||
|
||||
return {
|
||||
username: knownMember.username,
|
||||
description: knownMember.description,
|
||||
profileUpdatedAt: knownMember.profileUpdatedAt,
|
||||
avatarUrl: knownMember.avatarUrl,
|
||||
avatarHash: knownMember.avatarHash,
|
||||
avatarMime: knownMember.avatarMime,
|
||||
@@ -194,8 +196,10 @@ export interface RoomPresenceSignalingMessage {
|
||||
reason?: string;
|
||||
serverId?: string;
|
||||
serverIds?: string[];
|
||||
users?: { oderId: string; displayName: string; status?: string }[];
|
||||
users?: { oderId: string; displayName: string; description?: string; profileUpdatedAt?: number; status?: string }[];
|
||||
oderId?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
profileUpdatedAt?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { User } from '../../shared-kernel';
|
||||
import {
|
||||
shouldApplyAvatarTransfer,
|
||||
shouldRequestAvatarData
|
||||
} from './user-avatar.effects';
|
||||
import { shouldApplyAvatarTransfer, shouldRequestAvatarData } from './user-avatar.effects';
|
||||
|
||||
function createUser(overrides: Partial<User> = {}): User {
|
||||
return {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {
|
||||
Actions,
|
||||
createEffect,
|
||||
ofType
|
||||
} from '@ngrx/effects';
|
||||
import { Action, Store } from '@ngrx/store';
|
||||
import { Store, type Action } from '@ngrx/store';
|
||||
import {
|
||||
EMPTY,
|
||||
from,
|
||||
@@ -17,26 +18,22 @@ import {
|
||||
} from 'rxjs/operators';
|
||||
import { ProfileAvatarFacade } from '../../domains/profile-avatar';
|
||||
import {
|
||||
ChatEvent,
|
||||
P2P_BASE64_CHUNK_SIZE_BYTES,
|
||||
User,
|
||||
decodeBase64,
|
||||
iterateBlobChunks
|
||||
} from '../../shared-kernel';
|
||||
import type { ChatEvent, User } from '../../shared-kernel';
|
||||
import { RealtimeSessionFacade } from '../../core/realtime';
|
||||
import { DatabaseService } from '../../infrastructure/persistence';
|
||||
import { UsersActions } from './users.actions';
|
||||
import {
|
||||
selectAllUsers,
|
||||
selectCurrentUser
|
||||
} from './users.selectors';
|
||||
import { selectAllUsers, selectCurrentUser } from './users.selectors';
|
||||
import { selectCurrentRoom, selectSavedRooms } from '../rooms/rooms.selectors';
|
||||
import { RoomsActions } from '../rooms/rooms.actions';
|
||||
import { findRoomMember } from '../rooms/room-members.helpers';
|
||||
|
||||
interface PendingAvatarTransfer {
|
||||
displayName: string;
|
||||
mime: string;
|
||||
mime?: string;
|
||||
oderId: string;
|
||||
total: number;
|
||||
updatedAt: number;
|
||||
@@ -46,6 +43,17 @@ interface PendingAvatarTransfer {
|
||||
}
|
||||
|
||||
type AvatarVersionState = Pick<User, 'avatarUrl' | 'avatarHash' | 'avatarUpdatedAt'> | undefined;
|
||||
type RoomProfileState = Pick<User,
|
||||
| 'id'
|
||||
| 'oderId'
|
||||
| 'displayName'
|
||||
| 'description'
|
||||
| 'profileUpdatedAt'
|
||||
| 'avatarUrl'
|
||||
| 'avatarHash'
|
||||
| 'avatarMime'
|
||||
| 'avatarUpdatedAt'
|
||||
>;
|
||||
|
||||
function shouldAcceptAvatarPayload(
|
||||
existingUser: AvatarVersionState,
|
||||
@@ -69,9 +77,13 @@ function shouldAcceptAvatarPayload(
|
||||
return !!incomingHash && incomingHash !== existingUser.avatarHash;
|
||||
}
|
||||
|
||||
function hasSyncableUserData(user: Pick<User, 'avatarUpdatedAt' | 'profileUpdatedAt'> | null | undefined): boolean {
|
||||
return (user?.avatarUpdatedAt ?? 0) > 0;
|
||||
}
|
||||
|
||||
export function shouldRequestAvatarData(
|
||||
existingUser: AvatarVersionState,
|
||||
incomingAvatar: Pick<ChatEvent, 'avatarHash' | 'avatarUpdatedAt'>
|
||||
incomingAvatar: Pick<ChatEvent, 'avatarHash' | 'avatarUpdatedAt' | 'profileUpdatedAt'>
|
||||
): boolean {
|
||||
return shouldAcceptAvatarPayload(existingUser, incomingAvatar.avatarUpdatedAt ?? 0, incomingAvatar.avatarHash);
|
||||
}
|
||||
@@ -114,29 +126,41 @@ export class UserAvatarEffects {
|
||||
withLatestFrom(this.store.select(selectAllUsers)),
|
||||
tap(([{ user }, allUsers]) => {
|
||||
const mergedUser = allUsers.find((entry) => entry.id === user.id || entry.oderId === user.oderId);
|
||||
const avatarUrl = mergedUser?.avatarUrl ?? user.avatarUrl;
|
||||
const userToPersist = mergedUser ?? {
|
||||
id: user.id,
|
||||
oderId: user.oderId,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
description: user.description,
|
||||
profileUpdatedAt: user.profileUpdatedAt,
|
||||
avatarUrl: user.avatarUrl,
|
||||
avatarHash: user.avatarHash,
|
||||
avatarMime: user.avatarMime,
|
||||
avatarUpdatedAt: user.avatarUpdatedAt,
|
||||
status: 'offline' as const,
|
||||
role: 'member' as const,
|
||||
joinedAt: Date.now()
|
||||
};
|
||||
|
||||
if (!avatarUrl) {
|
||||
this.db.saveUser(userToPersist);
|
||||
|
||||
if (!user.avatarUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mergedUser) {
|
||||
this.db.saveUser(mergedUser);
|
||||
}
|
||||
|
||||
void this.avatars.persistAvatarDataUrl({
|
||||
id: mergedUser?.id || user.id,
|
||||
username: mergedUser?.username || user.username,
|
||||
displayName: mergedUser?.displayName || user.displayName
|
||||
}, avatarUrl);
|
||||
id: userToPersist.id,
|
||||
username: userToPersist.username,
|
||||
displayName: userToPersist.displayName
|
||||
}, user.avatarUrl);
|
||||
})
|
||||
),
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
syncRoomMemberAvatars$ = createEffect(() =>
|
||||
syncRoomMemberProfiles$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(UsersActions.updateCurrentUserAvatar, UsersActions.upsertRemoteUserAvatar),
|
||||
ofType(UsersActions.updateCurrentUserAvatar, UsersActions.updateCurrentUserProfile, UsersActions.upsertRemoteUserAvatar),
|
||||
withLatestFrom(
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom),
|
||||
@@ -148,28 +172,36 @@ export class UserAvatarEffects {
|
||||
currentRoom,
|
||||
savedRooms
|
||||
]) => {
|
||||
const avatarOwner = action.type === UsersActions.updateCurrentUserAvatar.type
|
||||
? currentUser
|
||||
: ('user' in action ? action.user : null);
|
||||
const avatarOwner = action.type === UsersActions.upsertRemoteUserAvatar.type
|
||||
? action.user
|
||||
: action.type === UsersActions.updateCurrentUserProfile.type
|
||||
? (currentUser ? {
|
||||
...currentUser,
|
||||
...action.profile
|
||||
} : null)
|
||||
: (currentUser ? {
|
||||
...currentUser,
|
||||
...action.avatar
|
||||
} : null);
|
||||
|
||||
if (!avatarOwner) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
const actions = this.buildRoomAvatarActions(avatarOwner, currentRoom, savedRooms);
|
||||
const actions = this.buildRoomProfileActions(avatarOwner, currentRoom, savedRooms);
|
||||
|
||||
return actions.length > 0 ? actions : EMPTY;
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
broadcastCurrentAvatarSummary$ = createEffect(
|
||||
broadcastCurrentProfileSummary$ = createEffect(
|
||||
() =>
|
||||
this.actions$.pipe(
|
||||
ofType(UsersActions.updateCurrentUserAvatar),
|
||||
ofType(UsersActions.updateCurrentUserAvatar, UsersActions.updateCurrentUserProfile),
|
||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||
tap(([, currentUser]) => {
|
||||
if (!currentUser?.avatarUpdatedAt) {
|
||||
if (!currentUser || !hasSyncableUserData(currentUser)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -184,7 +216,7 @@ export class UserAvatarEffects {
|
||||
this.webrtc.onPeerConnected.pipe(
|
||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||
tap(([peerId, currentUser]) => {
|
||||
if (!currentUser?.avatarUpdatedAt) {
|
||||
if (!currentUser || !hasSyncableUserData(currentUser)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -210,7 +242,7 @@ export class UserAvatarEffects {
|
||||
return this.handleAvatarRequest(event, currentUser ?? null);
|
||||
|
||||
case 'user-avatar-full':
|
||||
return this.handleAvatarFull(event);
|
||||
return this.handleAvatarFull(event, allUsers);
|
||||
|
||||
case 'user-avatar-chunk':
|
||||
return this.handleAvatarChunk(event, allUsers);
|
||||
@@ -222,14 +254,11 @@ export class UserAvatarEffects {
|
||||
)
|
||||
);
|
||||
|
||||
private buildAvatarSummary(user: Pick<User, 'oderId' | 'id' | 'username' | 'displayName' | 'avatarHash' | 'avatarMime' | 'avatarUpdatedAt'>): ChatEvent {
|
||||
private buildAvatarSummary(user: Pick<User, 'oderId' | 'id' | 'avatarHash' | 'avatarUpdatedAt'>): ChatEvent {
|
||||
return {
|
||||
type: 'user-avatar-summary',
|
||||
oderId: user.oderId || user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarHash: user.avatarHash,
|
||||
avatarMime: user.avatarMime,
|
||||
avatarUpdatedAt: user.avatarUpdatedAt || 0
|
||||
};
|
||||
}
|
||||
@@ -256,15 +285,34 @@ export class UserAvatarEffects {
|
||||
private handleAvatarRequest(event: ChatEvent, currentUser: User | null) {
|
||||
const currentUserKey = currentUser?.oderId || currentUser?.id;
|
||||
|
||||
if (!event.fromPeerId || !currentUser || !currentUserKey || event.oderId !== currentUserKey || !currentUser.avatarUrl) {
|
||||
if (!event.fromPeerId || !currentUser || !currentUserKey || event.oderId !== currentUserKey || !hasSyncableUserData(currentUser)) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
return from(this.sendAvatarToPeer(event.fromPeerId, currentUser)).pipe(mergeMap(() => EMPTY));
|
||||
}
|
||||
|
||||
private handleAvatarFull(event: ChatEvent) {
|
||||
if (!event.oderId || !event.avatarMime || typeof event.total !== 'number' || event.total < 1) {
|
||||
private handleAvatarFull(event: ChatEvent, allUsers: User[]) {
|
||||
if (!event.oderId || typeof event.total !== 'number' || event.total < 0) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
if (event.total === 0) {
|
||||
return from(this.buildRemoteAvatarAction({
|
||||
chunks: [],
|
||||
displayName: event.displayName || 'User',
|
||||
mime: event.avatarMime,
|
||||
oderId: event.oderId,
|
||||
total: 0,
|
||||
updatedAt: event.avatarUpdatedAt || 0,
|
||||
username: event.username || (event.displayName || 'User').toLowerCase().replace(/\s+/g, '_'),
|
||||
hash: event.avatarHash
|
||||
}, allUsers)).pipe(
|
||||
mergeMap((action) => action ? of(action) : EMPTY)
|
||||
);
|
||||
}
|
||||
|
||||
if (!event.avatarMime) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
@@ -306,36 +354,55 @@ export class UserAvatarEffects {
|
||||
);
|
||||
}
|
||||
|
||||
private async buildRemoteAvatarAction(transfer: PendingAvatarTransfer, allUsers: User[]): Promise<Action | null> {
|
||||
const existingUser = allUsers.find((user) => user.id === transfer.oderId || user.oderId === transfer.oderId);
|
||||
private async buildRemoteAvatarAction(
|
||||
transfer: PendingAvatarTransfer,
|
||||
allUsers: User[]
|
||||
): Promise<Action | null> {
|
||||
const existingUser = allUsers.find(
|
||||
(user) => user.id === transfer.oderId || user.oderId === transfer.oderId
|
||||
);
|
||||
|
||||
if (!shouldApplyAvatarTransfer(existingUser, transfer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const blob = new Blob(transfer.chunks.map((chunk) => this.decodeBase64ToArrayBuffer(chunk!)), { type: transfer.mime });
|
||||
const dataUrl = await this.readBlobAsDataUrl(blob);
|
||||
const base64Chunks = transfer.chunks.filter(
|
||||
(chunk): chunk is string => typeof chunk === 'string'
|
||||
);
|
||||
|
||||
if (transfer.total > 0 && base64Chunks.length !== transfer.total) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dataUrl = transfer.total > 0
|
||||
? await this.readBlobAsDataUrl(new Blob(
|
||||
base64Chunks.map((chunk) => this.decodeBase64ToArrayBuffer(chunk)),
|
||||
{ type: transfer.mime || 'image/webp' }
|
||||
))
|
||||
: undefined;
|
||||
|
||||
return UsersActions.upsertRemoteUserAvatar({
|
||||
user: {
|
||||
id: existingUser?.id || transfer.oderId,
|
||||
oderId: existingUser?.oderId || transfer.oderId,
|
||||
username: existingUser?.username || transfer.username,
|
||||
displayName: existingUser?.displayName || transfer.displayName,
|
||||
displayName: transfer.displayName || existingUser?.displayName || 'User',
|
||||
avatarUrl: dataUrl,
|
||||
avatarHash: transfer.hash,
|
||||
avatarMime: transfer.mime,
|
||||
avatarUpdatedAt: transfer.updatedAt
|
||||
avatarUpdatedAt: transfer.updatedAt || undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private buildRoomAvatarActions(
|
||||
avatarOwner: Pick<User, 'id' | 'oderId' | 'avatarUrl' | 'avatarHash' | 'avatarMime' | 'avatarUpdatedAt'>,
|
||||
private buildRoomProfileActions(
|
||||
avatarOwner: RoomProfileState,
|
||||
currentRoom: ReturnType<typeof selectCurrentRoom['projector']> | null,
|
||||
savedRooms: ReturnType<typeof selectSavedRooms['projector']>
|
||||
): Action[] {
|
||||
const rooms = [currentRoom, ...savedRooms.filter((room) => room.id !== currentRoom?.id)].filter((room): room is NonNullable<typeof currentRoom> => !!room);
|
||||
const rooms = [currentRoom, ...savedRooms.filter((room) => room.id !== currentRoom?.id)].filter(
|
||||
(room): room is NonNullable<typeof currentRoom> => !!room
|
||||
);
|
||||
const roomActions: Action[] = [];
|
||||
const avatarOwnerId = avatarOwner.oderId || avatarOwner.id;
|
||||
|
||||
@@ -353,6 +420,9 @@ export class UserAvatarEffects {
|
||||
|
||||
return {
|
||||
...roomMember,
|
||||
displayName: avatarOwner.displayName,
|
||||
description: avatarOwner.description,
|
||||
profileUpdatedAt: avatarOwner.profileUpdatedAt,
|
||||
avatarUrl: avatarOwner.avatarUrl,
|
||||
avatarHash: avatarOwner.avatarHash,
|
||||
avatarMime: avatarOwner.avatarMime,
|
||||
@@ -370,13 +440,11 @@ export class UserAvatarEffects {
|
||||
}
|
||||
|
||||
private async sendAvatarToPeer(targetPeerId: string, user: User): Promise<void> {
|
||||
if (!user.avatarUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await this.dataUrlToBlob(user.avatarUrl, user.avatarMime || 'image/webp');
|
||||
const total = Math.ceil(blob.size / P2P_BASE64_CHUNK_SIZE_BYTES);
|
||||
const userKey = user.oderId || user.id;
|
||||
const blob = user.avatarUrl
|
||||
? await this.dataUrlToBlob(user.avatarUrl, user.avatarMime || 'image/webp')
|
||||
: null;
|
||||
const total = blob ? Math.ceil(blob.size / P2P_BASE64_CHUNK_SIZE_BYTES) : 0;
|
||||
|
||||
this.webrtc.sendToPeer(targetPeerId, {
|
||||
type: 'user-avatar-full',
|
||||
@@ -384,11 +452,15 @@ export class UserAvatarEffects {
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarHash: user.avatarHash,
|
||||
avatarMime: user.avatarMime || blob.type || 'image/webp',
|
||||
avatarUpdatedAt: user.avatarUpdatedAt || Date.now(),
|
||||
avatarMime: blob ? (user.avatarMime || blob.type || 'image/webp') : undefined,
|
||||
avatarUpdatedAt: user.avatarUpdatedAt || 0,
|
||||
total
|
||||
});
|
||||
|
||||
if (!blob) {
|
||||
return;
|
||||
}
|
||||
|
||||
for await (const chunk of iterateBlobChunks(blob, P2P_BASE64_CHUNK_SIZE_BYTES)) {
|
||||
await this.webrtc.sendToPeerBuffered(targetPeerId, {
|
||||
type: 'user-avatar-chunk',
|
||||
|
||||
@@ -140,6 +140,74 @@ describe('users reducer - status', () => {
|
||||
expect(state.entities['remote-1']?.avatarHash).toBe('hash-newer');
|
||||
expect(state.entities['remote-1']?.avatarUpdatedAt).toBe(200);
|
||||
});
|
||||
|
||||
it('updates the current user profile metadata', () => {
|
||||
const state = usersReducer(baseState, UsersActions.updateCurrentUserProfile({
|
||||
profile: {
|
||||
displayName: 'Updated User',
|
||||
description: 'New description',
|
||||
profileUpdatedAt: 4567
|
||||
}
|
||||
}));
|
||||
|
||||
expect(state.entities['user-1']?.displayName).toBe('Updated User');
|
||||
expect(state.entities['user-1']?.description).toBe('New description');
|
||||
expect(state.entities['user-1']?.profileUpdatedAt).toBe(4567);
|
||||
});
|
||||
|
||||
it('keeps newer remote profile text when stale profile data arrives later', () => {
|
||||
const withRemote = usersReducer(
|
||||
baseState,
|
||||
UsersActions.upsertRemoteUserAvatar({
|
||||
user: {
|
||||
id: 'remote-1',
|
||||
oderId: 'oder-remote-1',
|
||||
username: 'remote',
|
||||
displayName: 'Remote Newer',
|
||||
description: 'Newest bio',
|
||||
profileUpdatedAt: 300
|
||||
}
|
||||
})
|
||||
);
|
||||
const state = usersReducer(
|
||||
withRemote,
|
||||
UsersActions.upsertRemoteUserAvatar({
|
||||
user: {
|
||||
id: 'remote-1',
|
||||
oderId: 'oder-remote-1',
|
||||
username: 'remote',
|
||||
displayName: 'Remote Older',
|
||||
description: 'Old bio',
|
||||
profileUpdatedAt: 100
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
expect(state.entities['remote-1']?.displayName).toBe('Remote Newer');
|
||||
expect(state.entities['remote-1']?.description).toBe('Newest bio');
|
||||
expect(state.entities['remote-1']?.profileUpdatedAt).toBe(300);
|
||||
});
|
||||
|
||||
it('allows remote profile-only sync updates without avatar bytes', () => {
|
||||
const state = usersReducer(
|
||||
baseState,
|
||||
UsersActions.upsertRemoteUserAvatar({
|
||||
user: {
|
||||
id: 'remote-2',
|
||||
oderId: 'oder-remote-2',
|
||||
username: 'remote2',
|
||||
displayName: 'Remote Profile',
|
||||
description: 'Profile only sync',
|
||||
profileUpdatedAt: 700
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
expect(state.entities['remote-2']?.displayName).toBe('Remote Profile');
|
||||
expect(state.entities['remote-2']?.description).toBe('Profile only sync');
|
||||
expect(state.entities['remote-2']?.profileUpdatedAt).toBe(700);
|
||||
expect(state.entities['remote-2']?.avatarUrl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('presence-aware user with status', () => {
|
||||
|
||||
@@ -39,7 +39,13 @@ export const UsersActions = createActionGroup({
|
||||
'Kick User': props<{ userId: string; roomId?: string }>(),
|
||||
'Kick User Success': props<{ userId: string; roomId: string }>(),
|
||||
|
||||
'Ban User': props<{ userId: string; roomId?: string; displayName?: string; reason?: string; expiresAt?: number }>(),
|
||||
'Ban User': props<{
|
||||
userId: string;
|
||||
roomId?: string;
|
||||
displayName?: string;
|
||||
reason?: string;
|
||||
expiresAt?: number;
|
||||
}>(),
|
||||
'Ban User Success': props<{ userId: string; roomId: string; ban: BanEntry }>(),
|
||||
'Unban User': props<{ roomId: string; oderId: string }>(),
|
||||
'Unban User Success': props<{ oderId: string }>(),
|
||||
@@ -61,7 +67,34 @@ export const UsersActions = createActionGroup({
|
||||
'Set Manual Status': props<{ status: UserStatus | null }>(),
|
||||
'Update Remote User Status': props<{ userId: string; status: UserStatus }>(),
|
||||
|
||||
'Update Current User Avatar': props<{ avatar: { avatarUrl: string; avatarHash: string; avatarMime: string; avatarUpdatedAt: number } }>(),
|
||||
'Upsert Remote User Avatar': props<{ user: { id: string; oderId: string; username: string; displayName: string; avatarUrl: string; avatarHash?: string; avatarMime?: string; avatarUpdatedAt?: number } }>()
|
||||
'Update Current User Profile': props<{
|
||||
profile: {
|
||||
displayName: string;
|
||||
description?: string;
|
||||
profileUpdatedAt: number;
|
||||
};
|
||||
}>(),
|
||||
'Update Current User Avatar': props<{
|
||||
avatar: {
|
||||
avatarUrl: string;
|
||||
avatarHash: string;
|
||||
avatarMime: string;
|
||||
avatarUpdatedAt: number;
|
||||
};
|
||||
}>(),
|
||||
'Upsert Remote User Avatar': props<{
|
||||
user: {
|
||||
id: string;
|
||||
oderId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
profileUpdatedAt?: number;
|
||||
avatarUrl?: string;
|
||||
avatarHash?: string;
|
||||
avatarMime?: string;
|
||||
avatarUpdatedAt?: number;
|
||||
};
|
||||
}>()
|
||||
}
|
||||
});
|
||||
|
||||
@@ -429,7 +429,8 @@ export class UsersEffects {
|
||||
ofType(
|
||||
UsersActions.setCurrentUser,
|
||||
UsersActions.loadCurrentUserSuccess,
|
||||
UsersActions.updateCurrentUser
|
||||
UsersActions.updateCurrentUser,
|
||||
UsersActions.updateCurrentUserProfile
|
||||
),
|
||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||
tap(([, user]) => {
|
||||
@@ -449,14 +450,18 @@ export class UsersEffects {
|
||||
this.actions$.pipe(
|
||||
ofType(
|
||||
UsersActions.setCurrentUser,
|
||||
UsersActions.loadCurrentUserSuccess
|
||||
UsersActions.loadCurrentUserSuccess,
|
||||
UsersActions.updateCurrentUserProfile
|
||||
),
|
||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||
tap(([, user]) => {
|
||||
if (!user)
|
||||
return;
|
||||
|
||||
this.webrtc.identify(user.oderId || user.id, this.resolveDisplayName(user));
|
||||
this.webrtc.identify(user.oderId || user.id, this.resolveDisplayName(user), undefined, {
|
||||
description: user.description,
|
||||
profileUpdatedAt: user.profileUpdatedAt
|
||||
});
|
||||
})
|
||||
),
|
||||
{ dispatch: false }
|
||||
|
||||
@@ -37,6 +37,69 @@ interface AvatarFields {
|
||||
avatarUpdatedAt?: number;
|
||||
}
|
||||
|
||||
interface ProfileFields {
|
||||
displayName: string;
|
||||
description?: string;
|
||||
profileUpdatedAt?: number;
|
||||
}
|
||||
|
||||
function hasOwnProperty(object: object, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(object, key);
|
||||
}
|
||||
|
||||
function normalizeProfileUpdatedAt(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeDisplayName(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = value.trim().replace(/\s+/g, ' ');
|
||||
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function normalizeDescription(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function mergeProfileFields(
|
||||
existingValue: Partial<ProfileFields> | undefined,
|
||||
incomingValue: Partial<ProfileFields>,
|
||||
preferIncomingFallback = true
|
||||
): ProfileFields {
|
||||
const existingUpdatedAt = normalizeProfileUpdatedAt(existingValue?.profileUpdatedAt) ?? 0;
|
||||
const incomingUpdatedAt = normalizeProfileUpdatedAt(incomingValue.profileUpdatedAt) ?? 0;
|
||||
const preferIncoming = incomingUpdatedAt === existingUpdatedAt
|
||||
? preferIncomingFallback
|
||||
: incomingUpdatedAt > existingUpdatedAt;
|
||||
const existingDisplayName = normalizeDisplayName(existingValue?.displayName);
|
||||
const incomingDisplayName = normalizeDisplayName(incomingValue.displayName);
|
||||
const existingDescription = normalizeDescription(existingValue?.description);
|
||||
const incomingHasDescription = hasOwnProperty(incomingValue, 'description');
|
||||
const incomingDescription = normalizeDescription(incomingValue.description);
|
||||
|
||||
return {
|
||||
displayName: preferIncoming
|
||||
? (incomingDisplayName || existingDisplayName || 'User')
|
||||
: (existingDisplayName || incomingDisplayName || 'User'),
|
||||
description: preferIncoming
|
||||
? (incomingHasDescription ? incomingDescription : existingDescription)
|
||||
: existingDescription,
|
||||
profileUpdatedAt: Math.max(existingUpdatedAt, incomingUpdatedAt) || undefined
|
||||
};
|
||||
}
|
||||
|
||||
function mergeAvatarFields(
|
||||
existingValue: AvatarFields | undefined,
|
||||
incomingValue: AvatarFields,
|
||||
@@ -112,10 +175,12 @@ function buildPresenceAwareUser(existingUser: User | undefined, incomingUser: Us
|
||||
? incomingUser.status
|
||||
: (existingUser?.status && existingUser.status !== 'offline' ? existingUser.status : 'online'))
|
||||
: 'offline';
|
||||
const profileFields = mergeProfileFields(existingUser, incomingUser, true);
|
||||
|
||||
return {
|
||||
...existingUser,
|
||||
...incomingUser,
|
||||
...profileFields,
|
||||
...mergeAvatarFields(existingUser, incomingUser, true),
|
||||
presenceServerIds,
|
||||
isOnline,
|
||||
@@ -128,17 +193,21 @@ function buildAvatarUser(existingUser: User | undefined, incomingUser: {
|
||||
oderId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
description?: string;
|
||||
profileUpdatedAt?: number;
|
||||
avatarUrl?: string;
|
||||
avatarHash?: string;
|
||||
avatarMime?: string;
|
||||
avatarUpdatedAt?: number;
|
||||
}): User {
|
||||
const profileFields = mergeProfileFields(existingUser, incomingUser, true);
|
||||
|
||||
return {
|
||||
...existingUser,
|
||||
id: incomingUser.id,
|
||||
oderId: incomingUser.oderId,
|
||||
username: incomingUser.username || existingUser?.username || 'user',
|
||||
displayName: incomingUser.displayName || existingUser?.displayName || 'User',
|
||||
...profileFields,
|
||||
status: existingUser?.status ?? 'offline',
|
||||
role: existingUser?.role ?? 'member',
|
||||
joinedAt: existingUser?.joinedAt ?? Date.now(),
|
||||
@@ -230,6 +299,18 @@ export const usersReducer = createReducer(
|
||||
state
|
||||
);
|
||||
}),
|
||||
on(UsersActions.updateCurrentUserProfile, (state, { profile }) => {
|
||||
if (!state.currentUserId)
|
||||
return state;
|
||||
|
||||
return usersAdapter.updateOne(
|
||||
{
|
||||
id: state.currentUserId,
|
||||
changes: mergeProfileFields(state.entities[state.currentUserId], profile, true)
|
||||
},
|
||||
state
|
||||
);
|
||||
}),
|
||||
on(UsersActions.updateCurrentUserAvatar, (state, { avatar }) => {
|
||||
if (!state.currentUserId)
|
||||
return state;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": [
|
||||
"vitest/globals"
|
||||
|
||||
Reference in New Issue
Block a user