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

@@ -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 {

View File

@@ -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',

View File

@@ -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', () => {

View File

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

View File

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

View File

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