Compare commits

..

1 Commits

Author SHA1 Message Date
Myx
bd21568726 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
2026-04-17 22:55:50 +02:00
21 changed files with 242 additions and 303 deletions

View File

@@ -1,7 +1,4 @@
import { import { mkdtemp, rm } from 'node:fs/promises';
mkdtemp,
rm
} from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { import {
@@ -9,10 +6,7 @@ import {
type BrowserContext, type BrowserContext,
type Page type Page
} from '@playwright/test'; } from '@playwright/test';
import { import { test, expect } from '../../fixtures/multi-client';
test,
expect
} from '../../fixtures/multi-client';
import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint'; import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint';
import { installWebRTCTracking } from '../../helpers/webrtc-helpers'; import { installWebRTCTracking } from '../../helpers/webrtc-helpers';
import { LoginPage } from '../../pages/login.page'; import { LoginPage } from '../../pages/login.page';
@@ -47,16 +41,33 @@ interface ProfileMetadata {
} }
const STATIC_GIF_BASE64 = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=='; const STATIC_GIF_BASE64 = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
const GIF_FRAME_MARKER = Buffer.from([0x21, 0xF9, 0x04]); const GIF_FRAME_MARKER = Buffer.from([
const NETSCAPE_LOOP_EXTENSION = Buffer.from([ 0x21,
0x21, 0xFF, 0x0B, 0xF9,
0x4E, 0x45, 0x54, 0x53, 0x43, 0x41, 0x50, 0x45, 0x32, 0x2E, 0x30, 0x04
0x03, 0x01, 0x00, 0x00, 0x00
]); ]);
const CLIENT_LAUNCH_ARGS = [ const NETSCAPE_LOOP_EXTENSION = Buffer.from([
'--use-fake-device-for-media-stream', 0x21,
'--use-fake-ui-for-media-stream' 0xFF,
]; 0x0B,
0x4E,
0x45,
0x54,
0x53,
0x43,
0x41,
0x50,
0x45,
0x32,
0x2E,
0x30,
0x03,
0x01,
0x00,
0x00,
0x00
]);
const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'];
const VOICE_CHANNEL = 'General'; const VOICE_CHANNEL = 'General';
test.describe('Profile avatar sync', () => { test.describe('Profile avatar sync', () => {
@@ -556,7 +567,8 @@ function getUserRow(page: Page, displayName: string) {
return usersSidePanel.locator('[role="button"]').filter({ return usersSidePanel.locator('[role="button"]').filter({
has: page.getByText(displayName, { exact: true }) has: page.getByText(displayName, { exact: true })
}).first(); })
.first();
} }
async function expectUserRowVisible(page: Page, displayName: string): Promise<void> { async function expectUserRowVisible(page: Page, displayName: string): Promise<void> {
@@ -670,7 +682,11 @@ function buildAnimatedGifUpload(label: string): AvatarUploadPayload {
const frame = baseGif.subarray(frameStart, baseGif.length - 1); const frame = baseGif.subarray(frameStart, baseGif.length - 1);
const commentData = Buffer.from(label, 'ascii'); const commentData = Buffer.from(label, 'ascii');
const commentExtension = Buffer.concat([ const commentExtension = Buffer.concat([
Buffer.from([0x21, 0xFE, commentData.length]), Buffer.from([
0x21,
0xFE,
commentData.length
]),
commentData, commentData,
Buffer.from([0x00]) Buffer.from([0x00])
]); ]);
@@ -693,5 +709,6 @@ function buildAnimatedGifUpload(label: string): AvatarUploadPayload {
} }
function uniqueName(prefix: string): string { function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
} }

View File

@@ -346,7 +346,6 @@ function normalizeRoomMember(rawMember: Record<string, unknown>, now: number): R
const avatarHash = trimmedString(rawMember, 'avatarHash'); const avatarHash = trimmedString(rawMember, 'avatarHash');
const avatarMime = trimmedString(rawMember, 'avatarMime'); const avatarMime = trimmedString(rawMember, 'avatarMime');
const avatarUpdatedAt = isFiniteNumber(rawMember['avatarUpdatedAt']) ? rawMember['avatarUpdatedAt'] : undefined; const avatarUpdatedAt = isFiniteNumber(rawMember['avatarUpdatedAt']) ? rawMember['avatarUpdatedAt'] : undefined;
const member: RoomMemberRecord = { const member: RoomMemberRecord = {
id: normalizedId || normalizedKey, id: normalizedId || normalizedKey,
oderId: normalizedOderId || undefined, oderId: normalizedOderId || undefined,

View File

@@ -208,6 +208,36 @@ describe('server websocket handler - profile metadata in presence messages', ()
connectedUsers.clear(); connectedUsers.clear();
}); });
it('broadcasts updated profile metadata when an identified user changes it', async () => {
const alice = createConnectedUser('conn-1', 'user-1', {
displayName: 'Alice',
viewedServerId: 'server-1'
});
const bob = createConnectedUser('conn-2', 'user-2', {
viewedServerId: 'server-1'
});
alice.serverIds.add('server-1');
bob.serverIds.add('server-1');
getSentMessagesStore(bob).sentMessages.length = 0;
await handleWebSocketMessage('conn-1', {
type: 'identify',
oderId: 'user-1',
displayName: 'Alice Updated',
description: 'Updated bio',
profileUpdatedAt: 789
});
const messages = getSentMessagesStore(bob).sentMessages.map((messageText: string) => JSON.parse(messageText));
const joinMsg = messages.find((message: { type: string }) => message.type === 'user_joined');
expect(joinMsg?.displayName).toBe('Alice Updated');
expect(joinMsg?.description).toBe('Updated bio');
expect(joinMsg?.profileUpdatedAt).toBe(789);
expect(joinMsg?.serverId).toBe('server-1');
});
it('includes description and profileUpdatedAt in server_users responses', async () => { it('includes description and profileUpdatedAt in server_users responses', async () => {
const alice = createConnectedUser('conn-1', 'user-1'); const alice = createConnectedUser('conn-1', 'user-1');
const bob = createConnectedUser('conn-2', 'user-2'); const bob = createConnectedUser('conn-2', 'user-2');

View File

@@ -67,6 +67,9 @@ function sendServerUsers(user: ConnectedUser, serverId: string): void {
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void { function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
const newOderId = readMessageId(message['oderId']) ?? connectionId; const newOderId = readMessageId(message['oderId']) ?? connectionId;
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined; const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
const previousDisplayName = normalizeDisplayName(user.displayName);
const previousDescription = user.description;
const previousProfileUpdatedAt = user.profileUpdatedAt;
// Close stale connections from the same identity AND the same connection // Close stale connections from the same identity AND the same connection
// scope so offer routing always targets the freshest socket (e.g. after // scope so offer routing always targets the freshest socket (e.g. after
@@ -89,15 +92,38 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
user.oderId = newOderId; user.oderId = newOderId;
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName)); user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
if (Object.prototype.hasOwnProperty.call(message, 'description')) { if (Object.prototype.hasOwnProperty.call(message, 'description')) {
user.description = normalizeDescription(message['description']); user.description = normalizeDescription(message['description']);
} }
if (Object.prototype.hasOwnProperty.call(message, 'profileUpdatedAt')) { if (Object.prototype.hasOwnProperty.call(message, 'profileUpdatedAt')) {
user.profileUpdatedAt = normalizeProfileUpdatedAt(message['profileUpdatedAt']); user.profileUpdatedAt = normalizeProfileUpdatedAt(message['profileUpdatedAt']);
} }
user.connectionScope = newScope; user.connectionScope = newScope;
connectedUsers.set(connectionId, user); connectedUsers.set(connectionId, user);
console.log(`User identified: ${user.displayName} (${user.oderId})`); console.log(`User identified: ${user.displayName} (${user.oderId})`);
if (
user.displayName === previousDisplayName
&& user.description === previousDescription
&& user.profileUpdatedAt === previousProfileUpdatedAt
) {
return;
}
for (const serverId of user.serverIds) {
broadcastToServer(serverId, {
type: 'user_joined',
oderId: user.oderId,
displayName: normalizeDisplayName(user.displayName),
description: user.description,
profileUpdatedAt: user.profileUpdatedAt,
status: user.status ?? 'online',
serverId
}, user.oderId);
}
} }
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> { async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {

View File

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

View File

@@ -1,8 +1,8 @@
<div <div
class="fixed inset-0 z-[112] bg-black/70 backdrop-blur-sm" class="fixed inset-0 z-[112] bg-black/70 backdrop-blur-sm"
(click)="cancelled.emit()" (click)="cancelled.emit(undefined)"
(keydown.enter)="cancelled.emit()" (keydown.enter)="cancelled.emit(undefined)"
(keydown.space)="cancelled.emit()" (keydown.space)="cancelled.emit(undefined)"
role="button" role="button"
tabindex="0" tabindex="0"
aria-label="Close profile image editor" 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="fixed inset-0 z-[113] flex items-center justify-center p-4 pointer-events-none">
<div <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" 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" role="dialog"
aria-modal="true" aria-modal="true"
tabindex="-1" tabindex="-1"
@@ -135,7 +134,7 @@
<button <button
type="button" type="button"
class="rounded-lg bg-secondary px-4 py-2 text-sm text-foreground transition-colors hover:bg-secondary/80" 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()" [disabled]="processing()"
> >
Cancel Cancel

View File

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

View File

@@ -1,13 +1,7 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { import { Overlay, OverlayRef } from '@angular/cdk/overlay';
Overlay,
OverlayRef
} from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal'; import { ComponentPortal } from '@angular/cdk/portal';
import { import { EditableProfileAvatarSource, ProcessedProfileAvatar } from '../../domain/profile-avatar.models';
EditableProfileAvatarSource,
ProcessedProfileAvatar
} from '../../domain/profile-avatar.models';
import { ProfileAvatarEditorComponent } from './profile-avatar-editor.component'; import { ProfileAvatarEditorComponent } from './profile-avatar-editor.component';
export const PROFILE_AVATAR_EDITOR_OVERLAY_CLASS = 'profile-avatar-editor-overlay-pane'; export const PROFILE_AVATAR_EDITOR_OVERLAY_CLASS = 'profile-avatar-editor-overlay-pane';
@@ -25,7 +19,9 @@ export class ProfileAvatarEditorService {
const overlayRef = this.overlay.create({ const overlayRef = this.overlay.create({
disposeOnNavigation: true, disposeOnNavigation: true,
panelClass: PROFILE_AVATAR_EDITOR_OVERLAY_CLASS, 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() scrollStrategy: this.overlay.scrollStrategies.block()
}); });
@@ -55,7 +51,6 @@ export class ProfileAvatarEditorService {
overlayRef.dispose(); overlayRef.dispose();
resolve(result); resolve(result);
}; };
const cancelSub = componentRef.instance.cancelled.subscribe(() => finish(null)); const cancelSub = componentRef.instance.cancelled.subscribe(() => finish(null));
const confirmSub = componentRef.instance.confirmed.subscribe((avatar) => finish(avatar)); const confirmSub = componentRef.instance.confirmed.subscribe((avatar) => finish(avatar));
const detachSub = overlayRef.detachments().subscribe(() => finish(null)); 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 { ProfileAvatarFacade } from './application/services/profile-avatar.facade';
export { ProfileAvatarEditorComponent } from './feature/profile-avatar-editor/profile-avatar-editor.component'; export { ProfileAvatarEditorComponent } from './feature/profile-avatar-editor/profile-avatar-editor.component';
export { export {
PROFILE_AVATAR_EDITOR_OVERLAY_CLASS, PROFILE_AVATAR_EDITOR_OVERLAY_CLASS,
ProfileAvatarEditorService ProfileAvatarEditorService
} from './feature/profile-avatar-editor/profile-avatar-editor.service'; } from './feature/profile-avatar-editor/profile-avatar-editor.service';

View File

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

View File

@@ -1,10 +1,7 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { User } from '../../../../shared-kernel'; import type { User } from '../../../../shared-kernel';
import { import { resolveProfileAvatarStorageFileName, type ProcessedProfileAvatar } from '../../domain/profile-avatar.models';
ProcessedProfileAvatar,
resolveProfileAvatarStorageFileName
} from '../../domain/profile-avatar.models';
const LEGACY_PROFILE_FILE_NAMES = [ const LEGACY_PROFILE_FILE_NAMES = [
'profile.webp', 'profile.webp',

View File

@@ -174,7 +174,9 @@ export class SignalingTransportHandler<TMessage> {
const normalizedDescription = typeof profile?.description === 'string' const normalizedDescription = typeof profile?.description === 'string'
? (profile.description.trim() || undefined) ? (profile.description.trim() || undefined)
: undefined; : undefined;
const normalizedProfileUpdatedAt = typeof profile?.profileUpdatedAt === 'number' && Number.isFinite(profile.profileUpdatedAt) && profile.profileUpdatedAt > 0 const normalizedProfileUpdatedAt = typeof profile?.profileUpdatedAt === 'number'
&& Number.isFinite(profile.profileUpdatedAt)
&& profile.profileUpdatedAt > 0
? profile.profileUpdatedAt ? profile.profileUpdatedAt
: undefined; : undefined;

View File

@@ -44,7 +44,7 @@
<input <input
type="text" 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" 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"
autofocus
[value]="displayNameDraft()" [value]="displayNameDraft()"
(input)="onDisplayNameInput($event)" (input)="onDisplayNameInput($event)"
(blur)="finishEdit('displayName')" (blur)="finishEdit('displayName')"
@@ -67,7 +67,7 @@
<textarea <textarea
rows="3" 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" 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"
autofocus
[value]="descriptionDraft()" [value]="descriptionDraft()"
placeholder="Add a description" placeholder="Add a description"
(input)="onDescriptionInput($event)" (input)="onDescriptionInput($event)"

View File

@@ -1,103 +0,0 @@
import { RoomMember, User } from '../../shared-kernel';
import { UsersActions } from '../users/users.actions';
import { buildRosterAvatarBackfillActions } from './room-members-sync.effects';
function createMember(overrides: Partial<RoomMember> = {}): RoomMember {
return {
id: 'user-1',
oderId: 'oder-1',
username: 'alice',
displayName: 'Alice',
role: 'member',
joinedAt: Date.now(),
lastSeenAt: Date.now(),
...overrides
};
}
function createCurrentUser(overrides: Partial<User> = {}): User {
return {
id: 'current-user',
oderId: 'current-oder',
username: 'current',
displayName: 'Current User',
status: 'online',
role: 'member',
joinedAt: Date.now(),
...overrides
};
}
describe('room member roster avatar backfill', () => {
it('creates avatar upsert actions from roster members with avatar data', () => {
const actions = buildRosterAvatarBackfillActions([
createMember({
avatarUrl: 'data:image/gif;base64,abc',
avatarHash: 'hash-1',
avatarMime: 'image/gif',
avatarUpdatedAt: 123
})
], null);
expect(actions).toEqual([
UsersActions.upsertRemoteUserAvatar({
user: {
id: 'user-1',
oderId: 'oder-1',
username: 'alice',
displayName: 'Alice',
description: undefined,
profileUpdatedAt: undefined,
avatarUrl: 'data:image/gif;base64,abc',
avatarHash: 'hash-1',
avatarMime: 'image/gif',
avatarUpdatedAt: 123
}
})
]);
});
it('creates avatar upsert actions from roster members with only profile metadata', () => {
const actions = buildRosterAvatarBackfillActions([
createMember({
description: 'Synced from roster',
profileUpdatedAt: 456
})
], null);
expect(actions).toEqual([
UsersActions.upsertRemoteUserAvatar({
user: {
id: 'user-1',
oderId: 'oder-1',
username: 'alice',
displayName: 'Alice',
description: 'Synced from roster',
profileUpdatedAt: 456,
avatarUrl: undefined,
avatarHash: undefined,
avatarMime: undefined,
avatarUpdatedAt: undefined
}
})
]);
});
it('skips the current user and members without syncable data', () => {
const currentUser = createCurrentUser();
const actions = buildRosterAvatarBackfillActions([
createMember({
id: currentUser.id,
oderId: currentUser.oderId
}),
createMember({
id: 'user-2',
oderId: 'oder-2',
username: 'bob',
displayName: 'Bob'
})
], currentUser);
expect(actions).toEqual([]);
});
});

View File

@@ -5,15 +5,14 @@ import {
createEffect, createEffect,
ofType ofType
} from '@ngrx/effects'; } from '@ngrx/effects';
import { Action } from '@ngrx/store'; import { Store, type Action } from '@ngrx/store';
import { Store } from '@ngrx/store';
import { EMPTY } from 'rxjs'; import { EMPTY } from 'rxjs';
import { import {
mergeMap, mergeMap,
tap, tap,
withLatestFrom withLatestFrom
} from 'rxjs/operators'; } from 'rxjs/operators';
import { import type {
ChatEvent, ChatEvent,
Room, Room,
RoomMember, RoomMember,
@@ -37,41 +36,6 @@ import {
upsertRoomMember upsertRoomMember
} from './room-members.helpers'; } from './room-members.helpers';
export function buildRosterAvatarBackfillActions(
members: RoomMember[],
currentUser: Pick<User, 'id' | 'oderId'> | null | undefined
): ReturnType<typeof UsersActions.upsertRemoteUserAvatar>[] {
const currentUserId = currentUser?.oderId || currentUser?.id;
return members.flatMap((member) => {
const memberId = member.oderId || member.id;
const hasProfileData = !!member.avatarUrl
|| !!member.avatarHash
|| !!member.avatarUpdatedAt
|| !!member.profileUpdatedAt
|| typeof member.description === 'string';
if (!memberId || memberId === currentUserId || !hasProfileData) {
return [];
}
return [UsersActions.upsertRemoteUserAvatar({
user: {
id: member.id,
oderId: memberId,
username: member.username,
displayName: member.displayName,
description: member.description,
profileUpdatedAt: member.profileUpdatedAt,
avatarUrl: member.avatarUrl,
avatarHash: member.avatarHash,
avatarMime: member.avatarMime,
avatarUpdatedAt: member.avatarUpdatedAt
}
})];
});
}
@Injectable() @Injectable()
export class RoomMembersSyncEffects { export class RoomMembersSyncEffects {
private readonly actions$ = inject(Actions); private readonly actions$ = inject(Actions);
@@ -429,10 +393,28 @@ export class RoomMembersSyncEffects {
); );
} }
return [ const actions = this.createRoomMemberUpdateActions(room, members);
...this.createRoomMemberUpdateActions(room, members), const currentUserId = currentUser?.oderId || currentUser?.id;
...buildRosterAvatarBackfillActions(members, currentUser)
]; 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( private handleMemberLeave(

View File

@@ -1,6 +1,6 @@
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { Room, User } from '../../shared-kernel'; import type { Room, User } from '../../shared-kernel';
import { import {
type RoomSignalSource, type RoomSignalSource,
type ServerSourceSelector, type ServerSourceSelector,

View File

@@ -5,7 +5,7 @@ import {
createEffect, createEffect,
ofType ofType
} from '@ngrx/effects'; } from '@ngrx/effects';
import { Action, Store } from '@ngrx/store'; import { Store, type Action } from '@ngrx/store';
import { import {
of, of,
from, from,
@@ -30,7 +30,7 @@ import {
import { RealtimeSessionFacade } from '../../core/realtime'; import { RealtimeSessionFacade } from '../../core/realtime';
import { DatabaseService } from '../../infrastructure/persistence'; import { DatabaseService } from '../../infrastructure/persistence';
import { resolveRoomPermission } from '../../domains/access-control'; import { resolveRoomPermission } from '../../domains/access-control';
import { import type {
ChatEvent, ChatEvent,
Room, Room,
RoomSettings, RoomSettings,
@@ -50,9 +50,9 @@ import {
resolveRoom, resolveRoom,
sanitizeRoomSnapshot, sanitizeRoomSnapshot,
normalizeIncomingBans, normalizeIncomingBans,
getPersistedCurrentUserId, getPersistedCurrentUserId
RoomPresenceSignalingMessage
} from './rooms.helpers'; } from './rooms.helpers';
import type { RoomPresenceSignalingMessage } from './rooms.helpers';
/** /**
* NgRx effects for real-time state synchronisation: signaling presence * NgRx effects for real-time state synchronisation: signaling presence

View File

@@ -1,8 +1,5 @@
import { User } from '../../shared-kernel'; import { User } from '../../shared-kernel';
import { import { shouldApplyAvatarTransfer, shouldRequestAvatarData } from './user-avatar.effects';
shouldApplyAvatarTransfer,
shouldRequestAvatarData
} from './user-avatar.effects';
function createUser(overrides: Partial<User> = {}): User { function createUser(overrides: Partial<User> = {}): User {
return { return {
@@ -58,17 +55,6 @@ describe('user avatar sync helpers', () => {
})).toBe(false); })).toBe(false);
}); });
it('requests profile data when the remote profile version is newer', () => {
const existingUser = createUser({
profileUpdatedAt: 100
});
expect(shouldRequestAvatarData(existingUser, {
avatarUpdatedAt: 0,
profileUpdatedAt: 200
})).toBe(true);
});
it('applies equal-version transfers when the local payload is missing', () => { it('applies equal-version transfers when the local payload is missing', () => {
const existingUser = createUser({ const existingUser = createUser({
avatarHash: 'hash-1', avatarHash: 'hash-1',
@@ -81,19 +67,6 @@ describe('user avatar sync helpers', () => {
})).toBe(true); })).toBe(true);
}); });
it('applies transfers when the remote profile version is newer', () => {
const existingUser = createUser({
displayName: 'Alice',
profileUpdatedAt: 100
});
expect(shouldApplyAvatarTransfer(existingUser, {
hash: undefined,
profileUpdatedAt: 250,
updatedAt: 0
})).toBe(true);
});
it('rejects older avatar transfers', () => { it('rejects older avatar transfers', () => {
const existingUser = createUser({ const existingUser = createUser({
avatarUrl: 'data:image/gif;base64,current', avatarUrl: 'data:image/gif;base64,current',

View File

@@ -1,10 +1,11 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { import {
Actions, Actions,
createEffect, createEffect,
ofType ofType
} from '@ngrx/effects'; } from '@ngrx/effects';
import { Action, Store } from '@ngrx/store'; import { Store, type Action } from '@ngrx/store';
import { import {
EMPTY, EMPTY,
from, from,
@@ -17,29 +18,23 @@ import {
} from 'rxjs/operators'; } from 'rxjs/operators';
import { ProfileAvatarFacade } from '../../domains/profile-avatar'; import { ProfileAvatarFacade } from '../../domains/profile-avatar';
import { import {
ChatEvent,
P2P_BASE64_CHUNK_SIZE_BYTES, P2P_BASE64_CHUNK_SIZE_BYTES,
User,
decodeBase64, decodeBase64,
iterateBlobChunks iterateBlobChunks
} from '../../shared-kernel'; } from '../../shared-kernel';
import type { ChatEvent, User } from '../../shared-kernel';
import { RealtimeSessionFacade } from '../../core/realtime'; import { RealtimeSessionFacade } from '../../core/realtime';
import { DatabaseService } from '../../infrastructure/persistence'; import { DatabaseService } from '../../infrastructure/persistence';
import { UsersActions } from './users.actions'; import { UsersActions } from './users.actions';
import { import { selectAllUsers, selectCurrentUser } from './users.selectors';
selectAllUsers,
selectCurrentUser
} from './users.selectors';
import { selectCurrentRoom, selectSavedRooms } from '../rooms/rooms.selectors'; import { selectCurrentRoom, selectSavedRooms } from '../rooms/rooms.selectors';
import { RoomsActions } from '../rooms/rooms.actions'; import { RoomsActions } from '../rooms/rooms.actions';
import { findRoomMember } from '../rooms/room-members.helpers'; import { findRoomMember } from '../rooms/room-members.helpers';
interface PendingAvatarTransfer { interface PendingAvatarTransfer {
displayName: string; displayName: string;
description?: string;
mime?: string; mime?: string;
oderId: string; oderId: string;
profileUpdatedAt?: number;
total: number; total: number;
updatedAt: number; updatedAt: number;
username: string; username: string;
@@ -47,7 +42,18 @@ interface PendingAvatarTransfer {
hash?: string; hash?: string;
} }
type AvatarVersionState = Pick<User, 'avatarUrl' | 'avatarHash' | 'avatarUpdatedAt' | 'displayName' | 'profileUpdatedAt'> | undefined; type AvatarVersionState = Pick<User, 'avatarUrl' | 'avatarHash' | 'avatarUpdatedAt'> | undefined;
type RoomProfileState = Pick<User,
| 'id'
| 'oderId'
| 'displayName'
| 'description'
| 'profileUpdatedAt'
| 'avatarUrl'
| 'avatarHash'
| 'avatarMime'
| 'avatarUpdatedAt'
>;
function shouldAcceptAvatarPayload( function shouldAcceptAvatarPayload(
existingUser: AvatarVersionState, existingUser: AvatarVersionState,
@@ -71,51 +77,22 @@ function shouldAcceptAvatarPayload(
return !!incomingHash && incomingHash !== existingUser.avatarHash; return !!incomingHash && incomingHash !== existingUser.avatarHash;
} }
function shouldAcceptProfilePayload( function hasSyncableUserData(user: Pick<User, 'avatarUpdatedAt' | 'profileUpdatedAt'> | null | undefined): boolean {
existingUser: AvatarVersionState, return (user?.avatarUpdatedAt ?? 0) > 0;
incomingUpdatedAt: number
): boolean {
const localUpdatedAt = existingUser?.profileUpdatedAt ?? 0;
if (incomingUpdatedAt > localUpdatedAt) {
return true;
}
if (incomingUpdatedAt < localUpdatedAt || incomingUpdatedAt === 0) {
return false;
}
return !existingUser?.displayName?.trim();
}
function shouldAcceptUserPayload(
existingUser: AvatarVersionState,
incoming: Pick<ChatEvent, 'avatarHash' | 'avatarUpdatedAt' | 'profileUpdatedAt'>
): boolean {
return shouldAcceptAvatarPayload(existingUser, incoming.avatarUpdatedAt ?? 0, incoming.avatarHash)
|| shouldAcceptProfilePayload(existingUser, incoming.profileUpdatedAt ?? 0);
}
function hasSyncableProfileData(user: Pick<User, 'avatarUpdatedAt' | 'profileUpdatedAt'> | null | undefined): boolean {
return (user?.avatarUpdatedAt ?? 0) > 0 || (user?.profileUpdatedAt ?? 0) > 0;
} }
export function shouldRequestAvatarData( export function shouldRequestAvatarData(
existingUser: AvatarVersionState, existingUser: AvatarVersionState,
incomingAvatar: Pick<ChatEvent, 'avatarHash' | 'avatarUpdatedAt' | 'profileUpdatedAt'> incomingAvatar: Pick<ChatEvent, 'avatarHash' | 'avatarUpdatedAt' | 'profileUpdatedAt'>
): boolean { ): boolean {
return shouldAcceptUserPayload(existingUser, incomingAvatar); return shouldAcceptAvatarPayload(existingUser, incomingAvatar.avatarUpdatedAt ?? 0, incomingAvatar.avatarHash);
} }
export function shouldApplyAvatarTransfer( export function shouldApplyAvatarTransfer(
existingUser: AvatarVersionState, existingUser: AvatarVersionState,
transfer: Pick<PendingAvatarTransfer, 'hash' | 'profileUpdatedAt' | 'updatedAt'> transfer: Pick<PendingAvatarTransfer, 'hash' | 'updatedAt'>
): boolean { ): boolean {
return shouldAcceptUserPayload(existingUser, { return shouldAcceptAvatarPayload(existingUser, transfer.updatedAt, transfer.hash);
avatarHash: transfer.hash,
avatarUpdatedAt: transfer.updatedAt,
profileUpdatedAt: transfer.profileUpdatedAt
});
} }
@Injectable() @Injectable()
@@ -197,7 +174,15 @@ export class UserAvatarEffects {
]) => { ]) => {
const avatarOwner = action.type === UsersActions.upsertRemoteUserAvatar.type const avatarOwner = action.type === UsersActions.upsertRemoteUserAvatar.type
? action.user ? action.user
: currentUser; : action.type === UsersActions.updateCurrentUserProfile.type
? (currentUser ? {
...currentUser,
...action.profile
} : null)
: (currentUser ? {
...currentUser,
...action.avatar
} : null);
if (!avatarOwner) { if (!avatarOwner) {
return EMPTY; return EMPTY;
@@ -216,7 +201,7 @@ export class UserAvatarEffects {
ofType(UsersActions.updateCurrentUserAvatar, UsersActions.updateCurrentUserProfile), ofType(UsersActions.updateCurrentUserAvatar, UsersActions.updateCurrentUserProfile),
withLatestFrom(this.store.select(selectCurrentUser)), withLatestFrom(this.store.select(selectCurrentUser)),
tap(([, currentUser]) => { tap(([, currentUser]) => {
if (!currentUser || !hasSyncableProfileData(currentUser)) { if (!currentUser || !hasSyncableUserData(currentUser)) {
return; return;
} }
@@ -231,7 +216,7 @@ export class UserAvatarEffects {
this.webrtc.onPeerConnected.pipe( this.webrtc.onPeerConnected.pipe(
withLatestFrom(this.store.select(selectCurrentUser)), withLatestFrom(this.store.select(selectCurrentUser)),
tap(([peerId, currentUser]) => { tap(([peerId, currentUser]) => {
if (!currentUser || !hasSyncableProfileData(currentUser)) { if (!currentUser || !hasSyncableUserData(currentUser)) {
return; return;
} }
@@ -269,22 +254,17 @@ export class UserAvatarEffects {
) )
); );
private buildAvatarSummary(user: Pick<User, 'oderId' | 'id' | 'username' | 'displayName' | 'description' | 'profileUpdatedAt' | 'avatarHash' | 'avatarMime' | 'avatarUpdatedAt'>): ChatEvent { private buildAvatarSummary(user: Pick<User, 'oderId' | 'id' | 'avatarHash' | 'avatarUpdatedAt'>): ChatEvent {
return { return {
type: 'user-avatar-summary', type: 'user-avatar-summary',
oderId: user.oderId || user.id, oderId: user.oderId || user.id,
username: user.username,
displayName: user.displayName,
description: user.description,
profileUpdatedAt: user.profileUpdatedAt,
avatarHash: user.avatarHash, avatarHash: user.avatarHash,
avatarMime: user.avatarMime,
avatarUpdatedAt: user.avatarUpdatedAt || 0 avatarUpdatedAt: user.avatarUpdatedAt || 0
}; };
} }
private handleAvatarSummary(event: ChatEvent, allUsers: User[]) { private handleAvatarSummary(event: ChatEvent, allUsers: User[]) {
if (!event.fromPeerId || !event.oderId || (!event.avatarUpdatedAt && !event.profileUpdatedAt)) { if (!event.fromPeerId || !event.oderId || !event.avatarUpdatedAt) {
return EMPTY; return EMPTY;
} }
@@ -305,7 +285,7 @@ export class UserAvatarEffects {
private handleAvatarRequest(event: ChatEvent, currentUser: User | null) { private handleAvatarRequest(event: ChatEvent, currentUser: User | null) {
const currentUserKey = currentUser?.oderId || currentUser?.id; const currentUserKey = currentUser?.oderId || currentUser?.id;
if (!event.fromPeerId || !currentUser || !currentUserKey || event.oderId !== currentUserKey || !hasSyncableProfileData(currentUser)) { if (!event.fromPeerId || !currentUser || !currentUserKey || event.oderId !== currentUserKey || !hasSyncableUserData(currentUser)) {
return EMPTY; return EMPTY;
} }
@@ -320,11 +300,9 @@ export class UserAvatarEffects {
if (event.total === 0) { if (event.total === 0) {
return from(this.buildRemoteAvatarAction({ return from(this.buildRemoteAvatarAction({
chunks: [], chunks: [],
description: event.description,
displayName: event.displayName || 'User', displayName: event.displayName || 'User',
mime: event.avatarMime, mime: event.avatarMime,
oderId: event.oderId, oderId: event.oderId,
profileUpdatedAt: event.profileUpdatedAt,
total: 0, total: 0,
updatedAt: event.avatarUpdatedAt || 0, updatedAt: event.avatarUpdatedAt || 0,
username: event.username || (event.displayName || 'User').toLowerCase().replace(/\s+/g, '_'), username: event.username || (event.displayName || 'User').toLowerCase().replace(/\s+/g, '_'),
@@ -340,11 +318,9 @@ export class UserAvatarEffects {
this.pendingTransfers.set(event.oderId, { this.pendingTransfers.set(event.oderId, {
chunks: new Array<string | undefined>(event.total), chunks: new Array<string | undefined>(event.total),
description: event.description,
displayName: event.displayName || 'User', displayName: event.displayName || 'User',
mime: event.avatarMime, mime: event.avatarMime,
oderId: event.oderId, oderId: event.oderId,
profileUpdatedAt: event.profileUpdatedAt,
total: event.total, total: event.total,
updatedAt: event.avatarUpdatedAt || Date.now(), updatedAt: event.avatarUpdatedAt || Date.now(),
username: event.username || (event.displayName || 'User').toLowerCase().replace(/\s+/g, '_'), username: event.username || (event.displayName || 'User').toLowerCase().replace(/\s+/g, '_'),
@@ -378,16 +354,29 @@ export class UserAvatarEffects {
); );
} }
private async buildRemoteAvatarAction(transfer: PendingAvatarTransfer, allUsers: User[]): Promise<Action | null> { private async buildRemoteAvatarAction(
const existingUser = allUsers.find((user) => user.id === transfer.oderId || user.oderId === transfer.oderId); transfer: PendingAvatarTransfer,
allUsers: User[]
): Promise<Action | null> {
const existingUser = allUsers.find(
(user) => user.id === transfer.oderId || user.oderId === transfer.oderId
);
if (!shouldApplyAvatarTransfer(existingUser, transfer)) { if (!shouldApplyAvatarTransfer(existingUser, transfer)) {
return null; return null;
} }
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 const dataUrl = transfer.total > 0
? await this.readBlobAsDataUrl(new Blob( ? await this.readBlobAsDataUrl(new Blob(
transfer.chunks.map((chunk) => this.decodeBase64ToArrayBuffer(chunk!)), base64Chunks.map((chunk) => this.decodeBase64ToArrayBuffer(chunk)),
{ type: transfer.mime || 'image/webp' } { type: transfer.mime || 'image/webp' }
)) ))
: undefined; : undefined;
@@ -398,8 +387,6 @@ export class UserAvatarEffects {
oderId: existingUser?.oderId || transfer.oderId, oderId: existingUser?.oderId || transfer.oderId,
username: existingUser?.username || transfer.username, username: existingUser?.username || transfer.username,
displayName: transfer.displayName || existingUser?.displayName || 'User', displayName: transfer.displayName || existingUser?.displayName || 'User',
description: transfer.description,
profileUpdatedAt: transfer.profileUpdatedAt,
avatarUrl: dataUrl, avatarUrl: dataUrl,
avatarHash: transfer.hash, avatarHash: transfer.hash,
avatarMime: transfer.mime, avatarMime: transfer.mime,
@@ -409,11 +396,13 @@ export class UserAvatarEffects {
} }
private buildRoomProfileActions( private buildRoomProfileActions(
avatarOwner: Pick<User, 'id' | 'oderId' | 'displayName' | 'description' | 'profileUpdatedAt' | 'avatarUrl' | 'avatarHash' | 'avatarMime' | 'avatarUpdatedAt'>, avatarOwner: RoomProfileState,
currentRoom: ReturnType<typeof selectCurrentRoom['projector']> | null, currentRoom: ReturnType<typeof selectCurrentRoom['projector']> | null,
savedRooms: ReturnType<typeof selectSavedRooms['projector']> savedRooms: ReturnType<typeof selectSavedRooms['projector']>
): Action[] { ): 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 roomActions: Action[] = [];
const avatarOwnerId = avatarOwner.oderId || avatarOwner.id; const avatarOwnerId = avatarOwner.oderId || avatarOwner.id;
@@ -462,8 +451,6 @@ export class UserAvatarEffects {
oderId: userKey, oderId: userKey,
username: user.username, username: user.username,
displayName: user.displayName, displayName: user.displayName,
description: user.description,
profileUpdatedAt: user.profileUpdatedAt,
avatarHash: user.avatarHash, avatarHash: user.avatarHash,
avatarMime: blob ? (user.avatarMime || blob.type || 'image/webp') : undefined, avatarMime: blob ? (user.avatarMime || blob.type || 'image/webp') : undefined,
avatarUpdatedAt: user.avatarUpdatedAt || 0, avatarUpdatedAt: user.avatarUpdatedAt || 0,

View File

@@ -39,7 +39,13 @@ export const UsersActions = createActionGroup({
'Kick User': props<{ userId: string; roomId?: string }>(), 'Kick User': props<{ userId: string; roomId?: string }>(),
'Kick User Success': 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 }>(), 'Ban User Success': props<{ userId: string; roomId: string; ban: BanEntry }>(),
'Unban User': props<{ roomId: string; oderId: string }>(), 'Unban User': props<{ roomId: string; oderId: string }>(),
'Unban User Success': props<{ oderId: string }>(), 'Unban User Success': props<{ oderId: string }>(),
@@ -61,8 +67,34 @@ export const UsersActions = createActionGroup({
'Set Manual Status': props<{ status: UserStatus | null }>(), 'Set Manual Status': props<{ status: UserStatus | null }>(),
'Update Remote User Status': props<{ userId: string; status: UserStatus }>(), 'Update Remote User Status': props<{ userId: string; status: UserStatus }>(),
'Update Current User Profile': props<{ profile: { displayName: string; description?: string; profileUpdatedAt: number } }>(), 'Update Current User Profile': props<{
'Update Current User Avatar': props<{ avatar: { avatarUrl: string; avatarHash: string; avatarMime: string; avatarUpdatedAt: number } }>(), profile: {
'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 } }>() 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

@@ -3,6 +3,7 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"rootDir": "./src",
"outDir": "./out-tsc/spec", "outDir": "./out-tsc/spec",
"types": [ "types": [
"vitest/globals" "vitest/globals"