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:
@@ -8,6 +8,7 @@
|
||||
* Extracted from the monolithic MessagesEffects to keep each
|
||||
* class focused on a single concern.
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {
|
||||
Actions,
|
||||
@@ -48,6 +49,17 @@ import {
|
||||
|
||||
@Injectable()
|
||||
export class MessagesSyncEffects {
|
||||
private readonly actions$ = inject(Actions);
|
||||
private readonly store = inject(Store);
|
||||
private readonly db = inject(DatabaseService);
|
||||
private readonly debugging = inject(DebuggingService);
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
|
||||
/** Tracks whether the last sync cycle found no new messages. */
|
||||
private lastSyncClean = false;
|
||||
|
||||
/** Subject to reset the periodic sync timer. */
|
||||
private readonly syncReset$ = new Subject<void>();
|
||||
|
||||
/**
|
||||
* When a new peer connects, sends our dataset summary and an
|
||||
@@ -232,21 +244,4 @@ export class MessagesSyncEffects {
|
||||
),
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
private readonly actions$ = inject(Actions);
|
||||
|
||||
private readonly store = inject(Store);
|
||||
|
||||
private readonly db = inject(DatabaseService);
|
||||
|
||||
private readonly debugging = inject(DebuggingService);
|
||||
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
|
||||
/** Tracks whether the last sync cycle found no new messages. */
|
||||
private lastSyncClean = false;
|
||||
|
||||
/** Subject to reset the periodic sync timer. */
|
||||
private readonly syncReset$ = new Subject<void>();
|
||||
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* The giant `incomingMessages$` switch-case has been replaced by a
|
||||
* handler registry in `messages-incoming.handlers.ts`.
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {
|
||||
Actions,
|
||||
@@ -63,6 +64,18 @@ const PREFETCH_CONCURRENCY = 3;
|
||||
|
||||
@Injectable()
|
||||
export class MessagesEffects {
|
||||
private readonly actions$ = inject(Actions);
|
||||
private readonly store = inject(Store);
|
||||
private readonly db = inject(DatabaseService);
|
||||
private readonly debugging = inject(DebuggingService);
|
||||
private readonly attachments = inject(AttachmentFacade);
|
||||
private readonly customEmoji = inject(CustomEmojiService);
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
private readonly timeSync = inject(TimeSyncService);
|
||||
private readonly linkMetadata = inject(LinkMetadataService);
|
||||
private readonly platform = inject(PlatformService);
|
||||
private readonly i18n = inject(AppI18nService);
|
||||
private readonly messageRevisions = inject(MessageRevisionService);
|
||||
|
||||
/** Loads messages for a room from the local database, hydrating reactions. */
|
||||
loadMessages$ = createEffect(() =>
|
||||
@@ -133,6 +146,29 @@ export class MessagesEffects {
|
||||
)
|
||||
);
|
||||
|
||||
private async fetchRoomMessagesForPrefetch(roomId: string, targetRoom: Room | null) {
|
||||
try {
|
||||
const messages = await this.loadInitialMessages(roomId, targetRoom);
|
||||
const hydrated = await hydrateMessages(messages, this.db);
|
||||
|
||||
for (const message of hydrated) {
|
||||
this.attachments.rememberMessageRoom(message.id, message.roomId);
|
||||
}
|
||||
|
||||
return MessagesActions.prefetchRoomMessagesSuccess({ messages: hydrated });
|
||||
} catch (error) {
|
||||
reportDebuggingError(
|
||||
this.debugging,
|
||||
'MessagesEffects.prefetchRoomMessages',
|
||||
'Failed to prefetch room messages',
|
||||
{ roomId },
|
||||
error
|
||||
);
|
||||
|
||||
return MessagesActions.prefetchRoomMessagesSuccess({ messages: [] });
|
||||
}
|
||||
}
|
||||
|
||||
/** Paginates older messages from the local DB for scroll-up history loading. */
|
||||
loadOlderMessages$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
@@ -162,6 +198,29 @@ export class MessagesEffects {
|
||||
)
|
||||
);
|
||||
|
||||
private async loadInitialMessages(roomId: string, targetRoom: Room | null): Promise<Message[]> {
|
||||
const textChannels = targetRoom?.id === roomId
|
||||
? (targetRoom.channels ?? []).filter((channel) => channel.type === 'text')
|
||||
: [];
|
||||
|
||||
if (textChannels.length <= 1) {
|
||||
return this.db.getMessages(roomId, INITIAL_ROOM_MESSAGE_LIMIT, 0, textChannels[0]?.id);
|
||||
}
|
||||
|
||||
const channelMessageSets = await Promise.all(
|
||||
textChannels.map((channel) => this.db.getMessages(roomId, INITIAL_ROOM_MESSAGE_LIMIT, 0, channel.id))
|
||||
);
|
||||
const messagesById = new Map<string, Message>();
|
||||
|
||||
for (const messages of channelMessageSets) {
|
||||
for (const message of messages) {
|
||||
messagesById.set(message.id, message);
|
||||
}
|
||||
}
|
||||
|
||||
return [...messagesById.values()].sort((first, second) => first.timestamp - second.timestamp);
|
||||
}
|
||||
|
||||
/** Constructs a new message, persists it locally, and broadcasts to all peers. */
|
||||
sendMessage$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
@@ -687,78 +746,7 @@ export class MessagesEffects {
|
||||
)
|
||||
);
|
||||
|
||||
private readonly actions$ = inject(Actions);
|
||||
|
||||
private readonly store = inject(Store);
|
||||
|
||||
private readonly db = inject(DatabaseService);
|
||||
|
||||
private readonly debugging = inject(DebuggingService);
|
||||
|
||||
private readonly attachments = inject(AttachmentFacade);
|
||||
|
||||
private readonly customEmoji = inject(CustomEmojiService);
|
||||
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
|
||||
private readonly timeSync = inject(TimeSyncService);
|
||||
|
||||
private readonly linkMetadata = inject(LinkMetadataService);
|
||||
|
||||
private readonly platform = inject(PlatformService);
|
||||
|
||||
private readonly i18n = inject(AppI18nService);
|
||||
|
||||
private readonly messageRevisions = inject(MessageRevisionService);
|
||||
|
||||
private async fetchRoomMessagesForPrefetch(roomId: string, targetRoom: Room | null) {
|
||||
try {
|
||||
const messages = await this.loadInitialMessages(roomId, targetRoom);
|
||||
const hydrated = await hydrateMessages(messages, this.db);
|
||||
|
||||
for (const message of hydrated) {
|
||||
this.attachments.rememberMessageRoom(message.id, message.roomId);
|
||||
}
|
||||
|
||||
return MessagesActions.prefetchRoomMessagesSuccess({ messages: hydrated });
|
||||
} catch (error) {
|
||||
reportDebuggingError(
|
||||
this.debugging,
|
||||
'MessagesEffects.prefetchRoomMessages',
|
||||
'Failed to prefetch room messages',
|
||||
{ roomId },
|
||||
error
|
||||
);
|
||||
|
||||
return MessagesActions.prefetchRoomMessagesSuccess({ messages: [] });
|
||||
}
|
||||
}
|
||||
|
||||
private async loadInitialMessages(roomId: string, targetRoom: Room | null): Promise<Message[]> {
|
||||
const textChannels = targetRoom?.id === roomId
|
||||
? (targetRoom.channels ?? []).filter((channel) => channel.type === 'text')
|
||||
: [];
|
||||
|
||||
if (textChannels.length <= 1) {
|
||||
return this.db.getMessages(roomId, INITIAL_ROOM_MESSAGE_LIMIT, 0, textChannels[0]?.id);
|
||||
}
|
||||
|
||||
const channelMessageSets = await Promise.all(
|
||||
textChannels.map((channel) => this.db.getMessages(roomId, INITIAL_ROOM_MESSAGE_LIMIT, 0, channel.id))
|
||||
);
|
||||
const messagesById = new Map<string, Message>();
|
||||
|
||||
for (const messages of channelMessageSets) {
|
||||
for (const message of messages) {
|
||||
messagesById.set(message.id, message);
|
||||
}
|
||||
}
|
||||
|
||||
return [...messagesById.values()].sort((first, second) => first.timestamp - second.timestamp);
|
||||
}
|
||||
|
||||
private trackBackgroundOperation(task: Promise<unknown> | unknown, message: string, payload: Record<string, unknown>): void {
|
||||
trackDebuggingTaskFailure(task, this.debugging, 'messages', message, payload);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {
|
||||
Actions,
|
||||
@@ -40,6 +41,10 @@ import { isSelfPresenceUserId } from '../../domains/authentication/domain/logic/
|
||||
|
||||
@Injectable()
|
||||
export class RoomMembersSyncEffects {
|
||||
private readonly actions$ = inject(Actions);
|
||||
private readonly store = inject(Store);
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
private readonly signalServerAuth = inject(SignalServerAuthService);
|
||||
|
||||
/** Ensure the local user is recorded in a room as soon as it becomes active. */
|
||||
ensureCurrentMemberOnRoomEntry$ = createEffect(() =>
|
||||
@@ -323,14 +328,6 @@ export class RoomMembersSyncEffects {
|
||||
)
|
||||
);
|
||||
|
||||
private readonly actions$ = inject(Actions);
|
||||
|
||||
private readonly store = inject(Store);
|
||||
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
|
||||
private readonly signalServerAuth = inject(SignalServerAuthService);
|
||||
|
||||
private resolveRoom(roomId: string | undefined, currentRoom: Room | null, savedRooms: Room[]): Room | null {
|
||||
if (!roomId)
|
||||
return null;
|
||||
@@ -669,5 +666,4 @@ export class RoomMembersSyncEffects {
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {
|
||||
Actions,
|
||||
@@ -36,6 +37,11 @@ import { defaultChannels } from './room-channels.defaults';
|
||||
*/
|
||||
@Injectable()
|
||||
export class RoomSettingsEffects {
|
||||
private actions$ = inject(Actions);
|
||||
private store = inject(Store);
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
private db = inject(DatabaseService);
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
|
||||
/** Updates room settings (host/admin-only) and broadcasts changes to all peers. */
|
||||
updateRoomSettings$ = createEffect(() =>
|
||||
@@ -362,15 +368,4 @@ export class RoomSettingsEffects {
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
private actions$ = inject(Actions);
|
||||
|
||||
private store = inject(Store);
|
||||
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
|
||||
private db = inject(DatabaseService);
|
||||
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {
|
||||
Actions,
|
||||
@@ -72,6 +73,29 @@ const SERVER_ICON_SYNC_REQUEST_DELAYS_MS = [
|
||||
*/
|
||||
@Injectable()
|
||||
export class RoomStateSyncEffects {
|
||||
private actions$ = inject(Actions);
|
||||
private store = inject(Store);
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
private db = inject(DatabaseService);
|
||||
private audioService = inject(NotificationAudioService);
|
||||
private voiceSessionService = inject(VoiceSessionFacade);
|
||||
private voiceClientTakeoverService = inject(VoiceClientTakeoverService);
|
||||
private signalServerAuth = inject(SignalServerAuthService);
|
||||
private clientInstanceService = inject(ClientInstanceService);
|
||||
|
||||
/**
|
||||
* Tracks user IDs we already know are in voice. Lives outside the
|
||||
* NgRx store so it survives room switches and presence re-syncs,
|
||||
* preventing false join/leave sounds during state refreshes.
|
||||
*/
|
||||
private knownVoiceUsers = new Set<string>();
|
||||
private pendingServerIconRequestsByPeer = new Map<string, Set<string>>();
|
||||
/**
|
||||
* When a user leaves (e.g. socket drops), record the timestamp so
|
||||
* that a rapid re-join (reconnect) does not trigger a false
|
||||
* join/leave sound within {@link RECONNECT_SOUND_GRACE_MS}.
|
||||
*/
|
||||
private recentlyLeftVoiceTimestamps = new Map<string, number>();
|
||||
|
||||
// ── Signaling presence ─────────────────────────────────────────
|
||||
|
||||
@@ -454,40 +478,6 @@ export class RoomStateSyncEffects {
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
private actions$ = inject(Actions);
|
||||
|
||||
private store = inject(Store);
|
||||
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
|
||||
private db = inject(DatabaseService);
|
||||
|
||||
private audioService = inject(NotificationAudioService);
|
||||
|
||||
private voiceSessionService = inject(VoiceSessionFacade);
|
||||
|
||||
private voiceClientTakeoverService = inject(VoiceClientTakeoverService);
|
||||
|
||||
private signalServerAuth = inject(SignalServerAuthService);
|
||||
|
||||
private clientInstanceService = inject(ClientInstanceService);
|
||||
|
||||
/**
|
||||
* Tracks user IDs we already know are in voice. Lives outside the
|
||||
* NgRx store so it survives room switches and presence re-syncs,
|
||||
* preventing false join/leave sounds during state refreshes.
|
||||
*/
|
||||
private knownVoiceUsers = new Set<string>();
|
||||
|
||||
private pendingServerIconRequestsByPeer = new Map<string, Set<string>>();
|
||||
|
||||
/**
|
||||
* When a user leaves (e.g. socket drops), record the timestamp so
|
||||
* that a rapid re-join (reconnect) does not trigger a false
|
||||
* join/leave sound within {@link RECONNECT_SOUND_GRACE_MS}.
|
||||
*/
|
||||
private recentlyLeftVoiceTimestamps = new Map<string, number>();
|
||||
|
||||
// ── Voice / Screen / Camera handlers ───────────────────────────
|
||||
|
||||
private handleVoiceOrScreenState(event: ChatEvent, allUsers: User[], currentUser: User | null, kind: 'voice' | 'screen' | 'camera') {
|
||||
@@ -1012,5 +1002,4 @@ export class RoomStateSyncEffects {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import {
|
||||
@@ -68,6 +70,23 @@ const VIEW_SERVER_LOAD_DELAY_MS = 0;
|
||||
|
||||
@Injectable()
|
||||
export class RoomsEffects {
|
||||
private actions$ = inject(Actions);
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
private db = inject(DatabaseService);
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private readonly i18n = inject(AppI18nService);
|
||||
private readonly signalServerAuthorize = inject(SignalServerAuthorizeService);
|
||||
private readonly signalServerAuth = inject(SignalServerAuthService);
|
||||
|
||||
private readonly signalingConnection = new RoomSignalingConnection(
|
||||
this.webrtc,
|
||||
this.serverDirectory,
|
||||
this.store,
|
||||
this.signalServerAuthorize,
|
||||
this.signalServerAuth
|
||||
);
|
||||
|
||||
/** Loads all saved rooms from the local database. */
|
||||
loadRooms$ = createEffect(() =>
|
||||
@@ -228,26 +247,45 @@ export class RoomsEffects {
|
||||
sourceName: endpoint?.name,
|
||||
sourceUrl: endpoint?.url
|
||||
};
|
||||
const ownerActorId = endpoint?.url
|
||||
? this.signalServerAuth.resolveActorUserIdForServer(endpoint.url, currentUser.oderId || currentUser.id)
|
||||
: currentUser.oderId || currentUser.id;
|
||||
const registrationPayload = buildServerRegistrationPayload(
|
||||
room,
|
||||
currentUser,
|
||||
normalizedPassword,
|
||||
ownerActorId
|
||||
);
|
||||
const registrationSelector = endpoint
|
||||
? { sourceId: endpoint.id, sourceUrl: endpoint.url }
|
||||
: undefined;
|
||||
|
||||
return from(this.db.saveRoom(room)).pipe(
|
||||
map(() => {
|
||||
// Register with central server (using the same room ID for discoverability)
|
||||
this.serverDirectory
|
||||
.registerServer(
|
||||
buildServerRegistrationPayload(room, currentUser, normalizedPassword),
|
||||
endpoint ? {
|
||||
sourceId: endpoint.id,
|
||||
sourceUrl: endpoint.url
|
||||
} : undefined
|
||||
)
|
||||
.subscribe({
|
||||
error: (error) => {
|
||||
// Registration is best-effort, but never swallow the failure
|
||||
// silently: otherwise the creator lands in a room view for a
|
||||
// server that was never persisted (invites/search 404).
|
||||
console.error('Failed to register created server with directory:', error);
|
||||
const registerCreatedServer = () => {
|
||||
this.serverDirectory
|
||||
.registerServer(registrationPayload, registrationSelector)
|
||||
.subscribe({
|
||||
error: (error) => {
|
||||
// Registration is best-effort, but never swallow the failure
|
||||
// silently: otherwise the creator lands in a room view for a
|
||||
// server that was never persisted (invites/search 404).
|
||||
console.error('Failed to register created server with directory:', error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (endpoint?.url) {
|
||||
void this.signalServerAuthorize.ensureCredentialForServerUrl(endpoint.url).then((hasCredential) => {
|
||||
if (hasCredential) {
|
||||
registerCreatedServer();
|
||||
} else {
|
||||
console.error('Failed to provision credentials before registering created server:', endpoint.url);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
registerCreatedServer();
|
||||
}
|
||||
|
||||
return RoomsActions.createRoomSuccess({ room });
|
||||
})
|
||||
@@ -863,32 +901,6 @@ export class RoomsEffects {
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
private actions$ = inject(Actions);
|
||||
|
||||
private store = inject(Store);
|
||||
|
||||
private router = inject(Router);
|
||||
|
||||
private db = inject(DatabaseService);
|
||||
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
|
||||
private readonly i18n = inject(AppI18nService);
|
||||
|
||||
private readonly signalServerAuthorize = inject(SignalServerAuthorizeService);
|
||||
|
||||
private readonly signalServerAuth = inject(SignalServerAuthService);
|
||||
|
||||
private readonly signalingConnection = new RoomSignalingConnection(
|
||||
this.webrtc,
|
||||
this.serverDirectory,
|
||||
this.store,
|
||||
this.signalServerAuthorize,
|
||||
this.signalServerAuth
|
||||
);
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────
|
||||
|
||||
private async getBlockedRoomAccessActions(
|
||||
@@ -914,5 +926,4 @@ export class RoomsEffects {
|
||||
private getPersistedCurrentUserId(): string | null {
|
||||
return localStorage.getItem('metoyou_currentUserId');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -64,6 +64,18 @@ describe('buildServerRegistrationPayload', () => {
|
||||
expect(withoutPw.hasPassword).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the actor owner id on foreign signal servers when provided', () => {
|
||||
const payload = buildServerRegistrationPayload(
|
||||
makeRoom(),
|
||||
{ id: 'home-user-1', oderId: 'home-oder-1', displayName: 'Alice' },
|
||||
'',
|
||||
'foreign-actor-9'
|
||||
);
|
||||
|
||||
expect(payload.ownerId).toBe('foreign-actor-9');
|
||||
expect(payload.ownerPublicKey).toBe('home-oder-1');
|
||||
});
|
||||
|
||||
it('carries the room channels and identity through to the payload', () => {
|
||||
const payload = buildServerRegistrationPayload(
|
||||
makeRoom({ id: 'room-9', name: 'Beta' }),
|
||||
|
||||
@@ -37,13 +37,14 @@ export interface ServerRegistrationPayload {
|
||||
export function buildServerRegistrationPayload(
|
||||
room: Room,
|
||||
owner: ServerRegistrationOwner,
|
||||
normalizedPassword: string
|
||||
normalizedPassword: string,
|
||||
ownerActorId?: string
|
||||
): ServerRegistrationPayload {
|
||||
return {
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
description: room.description,
|
||||
ownerId: owner.id,
|
||||
ownerId: ownerActorId ?? owner.id,
|
||||
ownerPublicKey: owner.oderId || owner.id,
|
||||
hostName: owner.displayName,
|
||||
password: normalizedPassword || null,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user