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,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
@@ -25,6 +26,7 @@ import { DirectCallService } from '../../../direct-call';
import { Attachment, AttachmentFacade } from '../../../attachment';
import { ThemeNodeDirective } from '../../../theme';
import { DirectMessageService } from '../../application/services/direct-message.service';
import { isConversationBound } from './dm-chat.rules';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePhone, lucidePhoneCall } from '@ng-icons/lucide';
@@ -83,68 +85,55 @@ interface DmStatusLabel {
export class DmChatComponent {
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
private readonly route = inject(ActivatedRoute);
private readonly store = inject(Store);
private readonly electronBridge = inject(ElectronBridgeService);
private readonly attachments = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService);
private readonly linkMetadata = inject(LinkMetadataService);
private readonly profileCard = inject(ProfileCardService);
private readonly viewport = inject(ViewportService);
private readonly metadataRequestKeys = new Set<string>();
private openedConversationId: string | null = null;
private readonly i18n = inject(AppI18nService);
readonly isMobile = this.viewport.isMobile;
readonly directCalls = inject(DirectCallService);
readonly directMessages = inject(DirectMessageService);
readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly allUsers = this.store.selectSignal(selectAllUsers);
readonly showGifPicker = signal(false);
readonly conversationId = input<string | null>(null);
readonly showCallButton = input(true);
readonly composerBottomPadding = signal(140);
readonly gifPickerAnchorRight = signal(16);
readonly linkMetadataByMessageId = signal<Record<string, LinkMetadata[]>>({});
readonly replyTo = signal<Message | null>(null);
readonly lightboxState = signal<ChatLightboxState | null>(null);
readonly galleryAttachments = signal<Attachment[] | null>(null);
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
initialValue: this.route.snapshot.paramMap.get('conversationId')
});
readonly effectiveConversationId = computed(() => this.conversationId() ?? this.routeConversationId());
readonly currentUserId = computed(() => this.currentUser()?.oderId || this.currentUser()?.id || '');
readonly conversation = this.directMessages.selectedConversation;
readonly klipyEnabled = computed(() => this.klipy.isEnabled(null));
readonly conversationKey = computed(() => this.conversation()?.id ?? 'dm:none');
readonly typingUsers = computed(() => {
void this.directMessages.typingEntries();
return this.directMessages.typingUsers(this.conversation()?.id);
});
readonly peerUser = computed(() => {
const conversation = this.conversation();
return conversation ? this.peerUserFor(conversation) : null;
});
readonly isGroupConversation = computed(() => {
const conversation = this.conversation();
return !!conversation && (conversation.kind === 'group' || conversation.participants.length > 2);
});
readonly participantUsers = computed<User[]>(() => {
const conversation = this.conversation();
const knownUsers = this.allUsers();
@@ -176,7 +165,6 @@ export class DmChatComponent {
);
});
});
readonly messageStatuses = computed<DmStatusLabel[]>(() => {
const conversation = this.conversation();
const currentUserId = this.currentUserId();
@@ -192,12 +180,12 @@ export class DmChatComponent {
status: message.status
}));
});
readonly chatMessages = computed<Message[]>(() => {
const conversation = this.conversation();
const metadataByMessageId = this.linkMetadataByMessageId();
const boundConversationId = this.effectiveConversationId();
if (!conversation) {
if (!conversation || !isConversationBound(conversation.id, boundConversationId)) {
return [];
}
@@ -227,7 +215,6 @@ export class DmChatComponent {
};
});
});
readonly peerName = computed(() => {
const conversation = this.conversation();
const currentUserId = this.currentUserId();
@@ -240,7 +227,6 @@ export class DmChatComponent {
return peerId ? conversation?.participantProfiles[peerId]?.displayName || peerId : this.i18n.instant('dm.chat.defaultTitle');
});
readonly peerCallIcon = computed(() => {
const conversation = this.conversation();
@@ -252,7 +238,6 @@ export class DmChatComponent {
return peer && this.directCalls.isCallingUser(peer) ? 'lucidePhoneCall' : 'lucidePhone';
});
readonly canCallConversation = computed(() => {
const conversation = this.conversation();
@@ -267,28 +252,6 @@ export class DmChatComponent {
return !!this.peerUser();
});
private readonly route = inject(ActivatedRoute);
private readonly store = inject(Store);
private readonly electronBridge = inject(ElectronBridgeService);
private readonly attachments = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService);
private readonly linkMetadata = inject(LinkMetadataService);
private readonly profileCard = inject(ProfileCardService);
private readonly viewport = inject(ViewportService);
private readonly metadataRequestKeys = new Set<string>();
private openedConversationId: string | null = null;
private readonly i18n = inject(AppI18nService);
constructor() {
effect(() => {
const conversationId = this.effectiveConversationId();
@@ -702,5 +665,4 @@ export class DmChatComponent {
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
}
}

View File

@@ -0,0 +1,21 @@
import {
describe,
it,
expect
} from 'vitest';
import { isConversationBound } from './dm-chat.rules';
describe('isConversationBound', () => {
it('returns false when either id is missing', () => {
expect(isConversationBound(null, 'conv-1')).toBe(false);
expect(isConversationBound('conv-1', null)).toBe(false);
});
it('returns false when the selected conversation lags behind navigation', () => {
expect(isConversationBound('old-conv', 'new-conv')).toBe(false);
});
it('returns true when the store conversation matches the bound route/input id', () => {
expect(isConversationBound('conv-1', 'conv-1')).toBe(true);
});
});

View File

@@ -0,0 +1,8 @@
export function isConversationBound(
conversationId: string | null | undefined,
selectedConversationId: string | null | undefined
): boolean {
return !!conversationId
&& !!selectedConversationId
&& conversationId === selectedConversationId;
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
@@ -59,16 +60,15 @@ const EXIT_ANIMATION_MS = 160;
styleUrl: './dm-rail.component.scss'
})
export class DmRailComponent implements OnDestroy {
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly exitTimers = new Map<string, ReturnType<typeof setTimeout>>();
private readonly i18n = inject(AppI18nService);
readonly directMessages = inject(DirectMessageService);
readonly friends = inject(FriendService);
readonly users = this.store.selectSignal(selectAllUsers);
readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly currentUserId = computed(() => this.currentUser()?.oderId || this.currentUser()?.id || '');
readonly activeConversationId = toSignal(
this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
@@ -76,15 +76,11 @@ export class DmRailComponent implements OnDestroy {
),
{ initialValue: this.getConversationIdFromUrl(this.router.url) }
);
readonly friendUsers = computed(() => this.users().filter((user) =>
this.friends.isFriend(user.oderId || user.id) && (user.oderId || user.id) !== this.currentUserId()
));
readonly railItems = signal<DmRailItem[]>([]);
readonly contextMenu = signal<DmRailContextMenuState | null>(null);
readonly unreadRailItems = computed<DmRailItem[]>(() => {
const currentUserId = this.currentUserId();
const items = new Map<string, DmRailItem>();
@@ -147,7 +143,6 @@ export class DmRailComponent implements OnDestroy {
return Array.from(items.values()).filter((item) => item.conversation && item.unreadCount > 0);
});
readonly isOnDirectMessages = toSignal(
this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
@@ -156,14 +151,6 @@ export class DmRailComponent implements OnDestroy {
{ initialValue: this.router.url.startsWith('/dm') }
);
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly exitTimers = new Map<string, ReturnType<typeof setTimeout>>();
private readonly i18n = inject(AppI18nService);
constructor() {
effect(() => {
const unreadItems = this.unreadRailItems();
@@ -347,5 +334,4 @@ export class DmRailComponent implements OnDestroy {
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
}
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
@@ -43,42 +44,26 @@ import { DirectMessageService } from '../../application/services/direct-message.
templateUrl: './dm-conversation-item.component.html'
})
export class DmConversationItemComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly attachments = inject(AttachmentFacade);
private readonly directMessages = inject(DirectMessageService);
private readonly directCalls = inject(DirectCallService);
private readonly i18n = inject(AppI18nService);
readonly conversation = input.required<DirectMessageConversation>();
readonly conversationOpened = output<string>();
readonly users = this.store.selectSignal(selectAllUsers);
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
initialValue: this.route.snapshot.paramMap.get('conversationId')
});
readonly isSelected = computed(() => this.routeConversationId() === this.conversation().id);
readonly peerName = computed(() => this.resolvePeerName(this.conversation()));
readonly peerAvatarUrl = computed(() => this.resolvePeerAvatarUrl(this.conversation()));
readonly lastMessagePreview = computed(() => this.resolveLastMessagePreview(this.conversation()));
readonly canCall = computed(() => this.canCallConversation(this.conversation()));
readonly callIcon = computed(() => this.conversationCallIcon(this.conversation()));
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly attachments = inject(AttachmentFacade);
private readonly directMessages = inject(DirectMessageService);
private readonly directCalls = inject(DirectCallService);
private readonly i18n = inject(AppI18nService);
constructor() {
effect(() => {
const conversation = this.conversation();
@@ -133,8 +118,9 @@ export class DmConversationItemComponent {
const peerId = this.peerId(conversation);
const knownUser = this.peerUser(conversation);
if (!peerId)
if (!peerId) {
return this.i18n.instant('dm.chat.defaultTitle');
}
return knownUser?.displayName
|| conversation.participantProfiles[peerId]?.displayName
@@ -249,5 +235,4 @@ export class DmConversationItemComponent {
? this.i18n.instant('dm.previews.oneAttachment')
: this.i18n.instant('dm.previews.manyAttachments');
}
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
@@ -32,16 +33,12 @@ import { DmConversationItemComponent } from './dm-conversation-item.component';
templateUrl: './dm-conversations-panel.component.html'
})
export class DmConversationsPanelComponent {
readonly directMessages = inject(DirectMessageService);
readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel'));
readonly conversationSelected = output<string>();
private readonly theme = inject(ThemeService);
readonly directMessages = inject(DirectMessageService);
readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel'));
readonly conversationSelected = output<string>();
trackConversationId(index: number, conversation: DirectMessageConversation): string {
return conversation.id;
}
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
CUSTOM_ELEMENTS_SCHEMA,
Component,
@@ -54,18 +55,21 @@ interface SwiperElement extends HTMLElement {
templateUrl: './dm-workspace.component.html'
})
export class DmWorkspaceComponent implements OnDestroy {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly theme = inject(ThemeService);
private readonly viewport = inject(ViewportService);
private readonly zone = inject(NgZone);
private readonly directCalls = inject(DirectCallService);
private lastSeenConversationId: string | null = null;
private swiperListenerAttached: SwiperElement | null = null;
readonly directMessages = inject(DirectMessageService);
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
initialValue: this.route.snapshot.paramMap.get('conversationId')
});
readonly layoutStyles = computed(() => this.theme.getLayoutContainerStyles('dmLayout'));
readonly isMobile = this.viewport.isMobile;
readonly swiperRef = viewChild<ElementRef<SwiperElement>>('swiperEl');
readonly activeCall = computed(() => {
const currentSession = this.directCalls.currentSession();
const visibleSessions = this.directCalls.visibleActiveSessions();
@@ -76,22 +80,6 @@ export class DmWorkspaceComponent implements OnDestroy {
/** Active page within the mobile single-pane navigation flow. Ignored on desktop. */
readonly mobilePage = signal<DmWorkspaceMobilePage>('conversations');
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly theme = inject(ThemeService);
private readonly viewport = inject(ViewportService);
private readonly zone = inject(NgZone);
private readonly directCalls = inject(DirectCallService);
private lastSeenConversationId: string | null = null;
private swiperListenerAttached: SwiperElement | null = null;
constructor() {
effect(() => {
const conversationId = this.routeConversationId();
@@ -183,6 +171,5 @@ export class DmWorkspaceComponent implements OnDestroy {
ngOnDestroy(): void {
this.directMessages.closeConversationView(this.routeConversationId());
}
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
@@ -23,26 +24,20 @@ import type { User } from '../../../../shared-kernel';
templateUrl: './friend-button.component.html'
})
export class FriendButtonComponent {
private readonly friends = inject(FriendService);
private readonly i18n = inject(AppI18nService);
readonly user = input.required<User>();
readonly userId = computed(() => this.user().oderId || this.user().id);
readonly isFriend = computed(() => this.friends.isFriend(this.userId()));
readonly ariaLabel = computed(() =>
this.isFriend()
? this.i18n.instant('dm.friend.remove')
: this.i18n.instant('dm.friend.add')
);
private readonly friends = inject(FriendService);
private readonly i18n = inject(AppI18nService);
toggle(event: Event): void {
event.stopPropagation();
void this.friends.toggleFriend(this.userId());
}
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
@@ -38,18 +39,16 @@ import type { User } from '../../../../shared-kernel';
templateUrl: './user-search-list.component.html'
})
export class UserSearchListComponent {
private readonly store = inject(Store);
private readonly router = inject(Router);
private readonly directMessages = inject(DirectMessageService);
readonly directCalls = inject(DirectCallService);
readonly friends = inject(FriendService);
private readonly i18n = inject(AppI18nService);
readonly searchQuery = input('');
readonly users = this.store.selectSignal(selectAllUsers);
readonly savedRooms = this.store.selectSignal(selectSavedRooms);
readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly discoveredUsers = computed(() => {
const usersById = new Map<string, User>();
@@ -82,7 +81,6 @@ export class UserSearchListComponent {
return Array.from(usersById.values());
});
readonly matchingUsers = computed(() => {
const query = this.normalizedSearchQuery();
const currentUserId = this.currentUserKey();
@@ -92,23 +90,13 @@ export class UserSearchListComponent {
.filter((user) => this.matchesQuery(user, query))
.slice(0, 24);
});
readonly friendResults = computed(() => this.matchingUsers().filter((user) => this.friends.isFriend(this.userKey(user))));
readonly results = computed(() => {
const friendIds = this.friends.friendIds();
return this.matchingUsers().filter((user) => !friendIds.has(this.userKey(user)));
});
private readonly store = inject(Store);
private readonly router = inject(Router);
private readonly directMessages = inject(DirectMessageService);
private readonly i18n = inject(AppI18nService);
async messageUser(user: User): Promise<void> {
const conversation = await this.directMessages.createConversation(user);
@@ -163,5 +151,4 @@ export class UserSearchListComponent {
.filter((value): value is string => !!value)
.some((value) => value.toLowerCase().includes(query));
}
}