fix: restore build and stabilize E2E cross-signal behavior

Revert the automated member-ordering pass that broke Angular field init
(TS2729) and disable that rule until a safe reorder strategy exists.
Fix modal/confirm dialog i18n defaults via template fallbacks, search all
active endpoints (including offline), register foreign rooms with actor
owner IDs, sync profile display names from avatar summaries, and guard
dm-chat when a private call converts to a group conversation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 12:16:40 +02:00
parent 79c6f91cd6
commit 31962aeb1a
131 changed files with 2483 additions and 3896 deletions

View File

@@ -1,5 +1,11 @@
import { User } from '../../shared-kernel';
import { shouldApplyAvatarTransfer, shouldRequestAvatarData } from './user-avatar.effects';
import {
buildProfileUpsertFromAvatarSummary,
buildUserAvatarSummary,
shouldApplyAvatarTransfer,
shouldRequestAvatarData
} from './user-avatar.effects';
import { UsersActions } from './users.actions';
function createUser(overrides: Partial<User> = {}): User {
return {
@@ -101,6 +107,50 @@ describe('user avatar sync helpers', () => {
})).toBe(false);
});
it('includes profile text in avatar summary broadcasts', () => {
const summary = buildUserAvatarSummary(createUser({
displayName: 'Alice Two',
description: 'Updated bio',
profileUpdatedAt: 300
}));
expect(summary).toMatchObject({
type: 'user-avatar-summary',
displayName: 'Alice Two',
description: 'Updated bio',
profileUpdatedAt: 300
});
});
it('builds a profile upsert action from a newer avatar summary', () => {
const existingUser = createUser({
displayName: 'Alice',
profileUpdatedAt: 100
});
const action = buildProfileUpsertFromAvatarSummary({
oderId: 'oder-1',
displayName: 'Alice Two',
description: 'Updated bio',
profileUpdatedAt: 200,
avatarUpdatedAt: 0
}, existingUser);
expect(action).toEqual(UsersActions.upsertRemoteUserAvatar({
user: {
id: 'user-1',
oderId: 'oder-1',
username: 'alice',
displayName: 'Alice Two',
description: 'Updated bio',
profileUpdatedAt: 200,
avatarHash: undefined,
avatarMime: undefined,
avatarUpdatedAt: undefined,
avatarUrl: undefined
}
}));
});
it('applies profile-only transfers when the remote profile is newer', () => {
const existingUser = createUser({
displayName: 'Alice',

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core';
import {
Actions,
@@ -106,8 +107,87 @@ export function shouldApplyAvatarTransfer(
|| shouldAcceptProfilePayload(existingUser, transfer.profileUpdatedAt);
}
export function buildUserAvatarSummary(
user: Pick<User,
| 'oderId'
| 'id'
| 'username'
| 'displayName'
| 'description'
| 'avatarHash'
| 'avatarUpdatedAt'
| 'profileUpdatedAt'
>
): ChatEvent {
return {
type: 'user-avatar-summary',
oderId: user.oderId || user.id,
username: user.username,
displayName: user.displayName,
description: user.description,
avatarHash: user.avatarHash,
avatarUpdatedAt: user.avatarUpdatedAt || 0,
profileUpdatedAt: user.profileUpdatedAt || 0
};
}
export function buildProfileUpsertFromAvatarSummary(
event: Pick<ChatEvent,
| 'oderId'
| 'username'
| 'displayName'
| 'description'
| 'profileUpdatedAt'
| 'avatarHash'
| 'avatarMime'
| 'avatarUpdatedAt'
>,
existingUser?: Pick<User,
| 'id'
| 'oderId'
| 'username'
| 'displayName'
| 'description'
| 'profileUpdatedAt'
| 'avatarHash'
| 'avatarMime'
| 'avatarUpdatedAt'
| 'avatarUrl'
>
): ReturnType<typeof UsersActions.upsertRemoteUserAvatar> | null {
if (!event.oderId || !shouldAcceptProfilePayload(existingUser, event.profileUpdatedAt)) {
return null;
}
if (!event.displayName && event.description === undefined) {
return null;
}
return UsersActions.upsertRemoteUserAvatar({
user: {
id: existingUser?.id || event.oderId,
oderId: existingUser?.oderId || event.oderId,
username: existingUser?.username || event.username || event.displayName || 'User',
displayName: event.displayName || existingUser?.displayName || 'User',
description: event.description ?? existingUser?.description,
profileUpdatedAt: event.profileUpdatedAt,
avatarHash: event.avatarHash ?? existingUser?.avatarHash,
avatarMime: event.avatarMime ?? existingUser?.avatarMime,
avatarUpdatedAt: existingUser?.avatarUpdatedAt,
avatarUrl: existingUser?.avatarUrl
}
});
}
@Injectable()
export class UserAvatarEffects {
private readonly actions$ = inject(Actions);
private readonly store = inject(Store);
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly db = inject(DatabaseService);
private readonly avatars = inject(ProfileAvatarFacade);
private readonly pendingTransfers = new Map<string, PendingAvatarTransfer>();
persistCurrentAvatar$ = createEffect(
() =>
@@ -259,26 +339,19 @@ export class UserAvatarEffects {
)
);
private readonly actions$ = inject(Actions);
private readonly store = inject(Store);
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly db = inject(DatabaseService);
private readonly avatars = inject(ProfileAvatarFacade);
private readonly pendingTransfers = new Map<string, PendingAvatarTransfer>();
private buildAvatarSummary(user: Pick<User, 'oderId' | 'id' | 'avatarHash' | 'avatarUpdatedAt' | 'profileUpdatedAt'>): ChatEvent {
return {
type: 'user-avatar-summary',
oderId: user.oderId || user.id,
avatarHash: user.avatarHash,
avatarUpdatedAt: user.avatarUpdatedAt || 0,
profileUpdatedAt: user.profileUpdatedAt || 0
};
private buildAvatarSummary(
user: Pick<User,
| 'oderId'
| 'id'
| 'username'
| 'displayName'
| 'description'
| 'avatarHash'
| 'avatarUpdatedAt'
| 'profileUpdatedAt'
>
): ChatEvent {
return buildUserAvatarSummary(user);
}
private handleAvatarSummary(event: ChatEvent, allUsers: User[], currentUser: User | null) {
@@ -293,17 +366,16 @@ export class UserAvatarEffects {
}
const existingUser = allUsers.find((user) => user.id === event.oderId || user.oderId === event.oderId);
const profileAction = buildProfileUpsertFromAvatarSummary(event, existingUser);
if (!shouldRequestAvatarData(existingUser, event)) {
return EMPTY;
if (shouldRequestAvatarData(existingUser, event)) {
this.webrtc.sendToPeer(event.fromPeerId, {
type: 'user-avatar-request',
oderId: event.oderId
});
}
this.webrtc.sendToPeer(event.fromPeerId, {
type: 'user-avatar-request',
oderId: event.oderId
});
return EMPTY;
return profileAction ? of(profileAction) : EMPTY;
}
private handleAvatarRequest(event: ChatEvent, currentUser: User | null) {
@@ -539,5 +611,4 @@ export class UserAvatarEffects {
reader.readAsDataURL(blob);
});
}
}

View File

@@ -1,6 +1,7 @@
/**
* Users store effects (load, kick, ban, host election, profile persistence).
*/
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core';
import { Router } from '@angular/router';
import {
@@ -67,6 +68,16 @@ type IncomingModerationAction =
@Injectable()
export class UsersEffects {
private actions$ = inject(Actions);
private store = inject(Store);
private db = inject(DatabaseService);
private serverDirectory = inject(ServerDirectoryFacade);
private webrtc = inject(RealtimeSessionFacade);
private readonly i18n = inject(AppI18nService);
private readonly authTokenStore = inject(AuthTokenStoreService);
private readonly signalServerAuthRetries = new Map<string, { count: number; windowStart: number }>();
private readonly signalServerAuth = inject(SignalServerAuthService);
private readonly router = inject(Router);
/** Prepares persisted state for a successful login before exposing the user in-memory. */
authenticateUser$ = createEffect(() =>
@@ -244,6 +255,54 @@ export class UsersEffects {
)
);
private clearStartupVoiceConnection(user: User): User {
const voiceState = user.voiceState;
if (!voiceState)
return user;
const hasStaleConnectionState =
voiceState.isConnected ||
voiceState.isSpeaking ||
voiceState.roomId !== undefined ||
voiceState.serverId !== undefined;
if (!hasStaleConnectionState)
return user;
return {
...user,
voiceState: {
...voiceState,
isConnected: false,
isSpeaking: false,
roomId: undefined,
serverId: undefined
}
};
}
private async prepareAuthenticatedUserStorage(
user: User,
loginResponse?: {
id: string;
username: string;
displayName: string;
token: string;
expiresAt: number;
}
): Promise<void> {
setStoredCurrentUserId(user.id);
await this.db.initialize();
await this.db.setCurrentUserId(user.id);
await this.db.saveUser(user);
if (user.homeSignalServerUrl && loginResponse) {
this.signalServerAuth.upsertCredentialFromLogin(user.homeSignalServerUrl, loginResponse, { provisioned: false });
await this.signalServerAuth.ensureHomeProvisionSecret(user);
}
}
/** Loads all users associated with a specific room from the local database. */
loadRoomUsers$ = createEffect(() =>
this.actions$.pipe(
@@ -605,74 +664,6 @@ export class UsersEffects {
{ dispatch: false }
);
private actions$ = inject(Actions);
private store = inject(Store);
private db = inject(DatabaseService);
private serverDirectory = inject(ServerDirectoryFacade);
private webrtc = inject(RealtimeSessionFacade);
private readonly i18n = inject(AppI18nService);
private readonly authTokenStore = inject(AuthTokenStoreService);
private readonly signalServerAuthRetries = new Map<string, { count: number; windowStart: number }>();
private readonly signalServerAuth = inject(SignalServerAuthService);
private readonly router = inject(Router);
private clearStartupVoiceConnection(user: User): User {
const voiceState = user.voiceState;
if (!voiceState)
return user;
const hasStaleConnectionState =
voiceState.isConnected ||
voiceState.isSpeaking ||
voiceState.roomId !== undefined ||
voiceState.serverId !== undefined;
if (!hasStaleConnectionState)
return user;
return {
...user,
voiceState: {
...voiceState,
isConnected: false,
isSpeaking: false,
roomId: undefined,
serverId: undefined
}
};
}
private async prepareAuthenticatedUserStorage(
user: User,
loginResponse?: {
id: string;
username: string;
displayName: string;
token: string;
expiresAt: number;
}
): Promise<void> {
setStoredCurrentUserId(user.id);
await this.db.initialize();
await this.db.setCurrentUserId(user.id);
await this.db.saveUser(user);
if (user.homeSignalServerUrl && loginResponse) {
this.signalServerAuth.upsertCredentialFromLogin(user.homeSignalServerUrl, loginResponse, { provisioned: false });
await this.signalServerAuth.ensureHomeProvisionSecret(user);
}
}
private resolveRoom(roomId: string | undefined, currentRoom: Room | null, savedRooms: Room[]): Room | null {
if (!roomId)
return currentRoom;
@@ -905,5 +896,4 @@ export class UsersEffects {
catchError(() => EMPTY)
);
}
}