fix: Bug - In direct voice call the status is displayed as offline

Resolve direct-call participant join state and DM peer status across user identity aliases so call UI no longer shows participants as disconnected when they are in the call.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-13 21:31:03 +02:00
parent baa350e90a
commit 924d4bbb1d
6 changed files with 268 additions and 22 deletions

View File

@@ -16,6 +16,7 @@ import {
} from '../../../../infrastructure/mobile'; } from '../../../../infrastructure/mobile';
import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing'; import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing';
import { ViewportService } from '../../../../core/platform'; import { ViewportService } from '../../../../core/platform';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { import {
VoiceActivityService, VoiceActivityService,
VoiceConnectionFacade, VoiceConnectionFacade,
@@ -109,6 +110,47 @@ describe('DirectCallService', () => {
expect(context.directMessages.createGroupConversation).not.toHaveBeenCalled(); expect(context.directMessages.createGroupConversation).not.toHaveBeenCalled();
}); });
it('marks a remote join against the session participant alias stored locally', async () => {
const aliceForeign = createUser('alice-foreign', 'Alice');
const bobForeign = createUser('bob-foreign', 'Bob');
const bobHome = { ...bobForeign, id: 'bob-home', oderId: 'bob-home' };
const context = createServiceContext({ currentUser: aliceForeign, allUsers: [aliceForeign, bobForeign] });
context.directCallEvents.next({
type: 'direct-call',
directCall: {
action: 'ring',
callId: 'dm-alice-foreign--bob-foreign',
conversationId: 'dm-alice-foreign--bob-foreign',
createdAt: 10,
sender: toParticipant(bobForeign),
participantIds: ['alice-foreign', 'bob-foreign'],
participants: [toParticipant(aliceForeign), toParticipant(bobForeign)]
}
});
await vi.waitFor(() => expect(context.service.sessionById('dm-alice-foreign--bob-foreign')).not.toBeNull());
context.directCallEvents.next({
type: 'direct-call',
directCall: {
action: 'join',
callId: 'dm-alice-foreign--bob-foreign',
conversationId: 'dm-alice-foreign--bob-foreign',
createdAt: 10,
sender: toParticipant(bobHome),
participantIds: ['alice-foreign', 'bob-foreign'],
participants: [toParticipant(aliceForeign), toParticipant(bobForeign)]
}
});
await vi.waitFor(() => {
const session = context.service.sessionById('dm-alice-foreign--bob-foreign');
expect(session?.participants['bob-foreign']?.joined).toBe(true);
});
});
it('answers an incoming call from the modal action', async () => { it('answers an incoming call from the modal action', async () => {
const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] }); const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });
@@ -573,9 +615,17 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
{ {
provide: MobileMediaService, provide: MobileMediaService,
useValue: { useValue: {
ensureVoiceCapturePermissions: vi.fn(async () => true),
setSpeakerphoneEnabled: vi.fn(async () => undefined) setSpeakerphoneEnabled: vi.fn(async () => undefined)
} }
}, },
{
provide: RealtimeSessionFacade,
useValue: {
getClientInstanceId: vi.fn(() => 'test-client'),
requestVoiceClientTakeover: vi.fn()
}
},
...provideAppI18nForTests() ...provideAppI18nForTests()
] ]
}); });

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Injectable, Injectable,
computed, computed,
@@ -33,6 +33,11 @@ import {
User User
} from '../../../../shared-kernel'; } from '../../../../shared-kernel';
import { DirectCallSession, participantToUser } from '../../domain/models/direct-call.model'; import { DirectCallSession, participantToUser } from '../../domain/models/direct-call.model';
import {
findDirectCallParticipantEntry,
findDirectCallParticipantEntryForUser,
isDirectCallParticipantJoined
} from '../../domain/logic/direct-call-participant-identity.rules';
import { toDirectMessageParticipant } from '../../../direct-message'; import { toDirectMessageParticipant } from '../../../direct-message';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -772,13 +777,20 @@ export class DirectCallService {
private preserveJoinedParticipants(previousSession: DirectCallSession, nextSession: DirectCallSession): DirectCallSession { private preserveJoinedParticipants(previousSession: DirectCallSession, nextSession: DirectCallSession): DirectCallSession {
return { return {
...nextSession, ...nextSession,
participants: Object.fromEntries(Object.values(nextSession.participants).map((participant) => [ participants: Object.fromEntries(Object.values(nextSession.participants).map((participant) => {
participant.userId, const previousEntry = findDirectCallParticipantEntryForUser(previousSession, {
{ id: participant.userId,
...participant, oderId: participant.profile.userId
joined: previousSession.participants[participant.userId]?.joined ?? participant.joined });
}
])) return [
participant.userId,
{
...participant,
joined: previousEntry?.participant.joined ?? participant.joined
}
];
}))
}; };
} }
@@ -865,7 +877,14 @@ export class DirectCallService {
joined: boolean, joined: boolean,
status: DirectCallSession['status'] status: DirectCallSession['status']
): DirectCallSession { ): DirectCallSession {
const participant = session.participants[participantId]; const knownUser = this.users().find((user) =>
user.id === participantId || user.oderId === participantId || user.peerId === participantId
);
const entry = knownUser
? findDirectCallParticipantEntryForUser(session, knownUser, [participantId])
: findDirectCallParticipantEntry(session, participantId);
const key = entry?.key ?? participantId;
const participant = entry?.participant ?? session.participants[participantId];
return { return {
...session, ...session,
@@ -874,7 +893,7 @@ export class DirectCallService {
...session.participants, ...session.participants,
...(participant ...(participant
? { ? {
[participantId]: { [key]: {
...participant, ...participant,
joined joined
} }
@@ -916,9 +935,9 @@ export class DirectCallService {
} }
private isCurrentUserJoined(session: DirectCallSession): boolean { private isCurrentUserJoined(session: DirectCallSession): boolean {
const meId = this.currentUserId(); const user = this.currentUser();
return !!meId && !!session.participants[meId]?.joined; return !!user && isDirectCallParticipantJoined(session, user);
} }
private stopLocalMedia(session: DirectCallSession): void { private stopLocalMedia(session: DirectCallSession): void {
@@ -955,8 +974,13 @@ export class DirectCallService {
} }
private markRemoteVoiceState(userId: string, session: DirectCallSession, connected: boolean): void { private markRemoteVoiceState(userId: string, session: DirectCallSession, connected: boolean): void {
const knownUser = this.users().find((user) =>
user.id === userId || user.oderId === userId || user.peerId === userId
);
const resolvedUserId = knownUser?.id ?? userId;
this.store.dispatch(UsersActions.updateVoiceState({ this.store.dispatch(UsersActions.updateVoiceState({
userId, userId: resolvedUserId,
voiceState: { voiceState: {
isConnected: connected, isConnected: connected,
isMuted: false, isMuted: false,

View File

@@ -0,0 +1,80 @@
import type { DirectCallSession } from '../models/direct-call.model';
import {
findDirectCallParticipantEntry,
findDirectCallParticipantEntryForUser,
isDirectCallParticipantJoined
} from './direct-call-participant-identity.rules';
function createSession(participants: DirectCallSession['participants']): DirectCallSession {
return {
callId: 'dm-alice-home--bob-foreign',
conversationId: 'dm-alice-home--bob-foreign',
createdAt: 1,
initiatorId: 'alice-home',
participantIds: Object.keys(participants),
participants,
status: 'connected'
};
}
describe('direct-call-participant-identity.rules', () => {
it('findDirectCallParticipantEntryForUser resolves join state across provisioned participant aliases', () => {
const session = createSession({
'bob-foreign': {
userId: 'bob-foreign',
profile: {
userId: 'bob-foreign',
username: 'bob',
displayName: 'Bob'
},
joined: true
}
});
expect(isDirectCallParticipantJoined(session, {
id: 'bob-entity',
oderId: 'bob-home'
}, ['bob-foreign'])).toBe(true);
expect(findDirectCallParticipantEntry(session, 'bob-foreign')?.participant.joined).toBe(true);
});
it('findDirectCallParticipantEntryForUser matches store users keyed by a different entity id', () => {
const session = createSession({
'bob-foreign': {
userId: 'bob-foreign',
profile: {
userId: 'bob-foreign',
username: 'bob',
displayName: 'Bob'
},
joined: true
}
});
expect(findDirectCallParticipantEntryForUser(session, {
id: 'bob-entity',
oderId: 'bob-home',
peerId: 'bob-peer'
}, ['bob-foreign'])?.participant.joined).toBe(true);
});
it('isDirectCallParticipantJoined returns false when no alias matches a joined participant', () => {
const session = createSession({
'bob-home': {
userId: 'bob-home',
profile: {
userId: 'bob-home',
username: 'bob',
displayName: 'Bob'
},
joined: false
}
});
expect(isDirectCallParticipantJoined(session, {
id: 'bob-entity',
oderId: 'bob-foreign'
}, ['bob-foreign'])).toBe(false);
});
});

View File

@@ -0,0 +1,88 @@
import type { User } from '../../../../shared-kernel';
import type { DirectCallParticipant, DirectCallSession } from '../models/direct-call.model';
type UserIdentityFields = Pick<User, 'id' | 'oderId' | 'peerId'>;
/** Collect every id that can represent a user in direct-call participant state. */
export function collectDirectCallUserIdentityKeys(
user: UserIdentityFields,
additionalIds: readonly string[] = []
): string[] {
const keys: string[] = [];
for (const candidate of [
user.id,
user.oderId,
user.peerId,
...additionalIds
]) {
const normalized = candidate?.trim();
if (normalized && !keys.includes(normalized)) {
keys.push(normalized);
}
}
return keys;
}
export function findDirectCallParticipantEntry(
session: Pick<DirectCallSession, 'participants'>,
identity: string | undefined
): { key: string; participant: DirectCallParticipant } | undefined {
if (!identity?.trim()) {
return undefined;
}
const trimmed = identity.trim();
const direct = session.participants[trimmed];
if (direct) {
return { key: trimmed, participant: direct };
}
for (const [key, participant] of Object.entries(session.participants)) {
if (participant.userId === trimmed || participant.profile.userId === trimmed) {
return { key, participant };
}
}
return undefined;
}
export function findDirectCallParticipantEntryForUser(
session: Pick<DirectCallSession, 'participants'>,
user: UserIdentityFields,
additionalIds: readonly string[] = []
): { key: string; participant: DirectCallParticipant } | undefined {
for (const identity of collectDirectCallUserIdentityKeys(user, additionalIds)) {
const entry = findDirectCallParticipantEntry(session, identity);
if (entry) {
return entry;
}
}
const userKeys = new Set(collectDirectCallUserIdentityKeys(user, additionalIds));
for (const [key, participant] of Object.entries(session.participants)) {
const participantKeys = collectDirectCallUserIdentityKeys({
id: participant.userId,
oderId: participant.profile.userId
});
if (participantKeys.some((participantKey) => userKeys.has(participantKey))) {
return { key, participant };
}
}
return undefined;
}
export function isDirectCallParticipantJoined(
session: Pick<DirectCallSession, 'participants'>,
user: UserIdentityFields,
additionalIds: readonly string[] = []
): boolean {
return !!findDirectCallParticipantEntryForUser(session, user, additionalIds)?.participant.joined;
}

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
computed, computed,
@@ -28,6 +28,7 @@ import { ThemeNodeDirective } from '../../../theme';
import { DirectMessageService } from '../../application/services/direct-message.service'; import { DirectMessageService } from '../../application/services/direct-message.service';
import { isConversationBound } from './dm-chat.rules'; import { isConversationBound } from './dm-chat.rules';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors'; import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import { buildUserIdentityLookup, resolveUserByIdentity } from '../../../../store/users/user-identity-lookup.rules';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePhone, lucidePhoneCall } from '@ng-icons/lucide'; import { lucidePhone, lucidePhoneCall } from '@ng-icons/lucide';
import { import {
@@ -137,13 +138,14 @@ export class DmChatComponent {
readonly participantUsers = computed<User[]>(() => { readonly participantUsers = computed<User[]>(() => {
const conversation = this.conversation(); const conversation = this.conversation();
const knownUsers = this.allUsers(); const knownUsers = this.allUsers();
const userLookup = buildUserIdentityLookup(knownUsers);
if (!conversation) { if (!conversation) {
return []; return [];
} }
return conversation.participants.map((participantId) => { return conversation.participants.map((participantId) => {
const knownUser = knownUsers.find((user) => user.id === participantId || user.oderId === participantId); const knownUser = resolveUserByIdentity(userLookup, participantId);
const participant = conversation.participantProfiles[participantId]; const participant = conversation.participantProfiles[participantId];
return ( return (
@@ -651,7 +653,9 @@ export class DmChatComponent {
return null; return null;
} }
return this.participantUsers().find((user) => user.id === peerId || user.oderId === peerId) ?? null; return this.participantUsers().find((user) =>
user.id === peerId || user.oderId === peerId || user.peerId === peerId
) ?? resolveUserByIdentity(buildUserIdentityLookup(this.allUsers()), peerId) ?? null;
} }
private groupConversationTitle(conversation: NonNullable<ReturnType<typeof this.conversation>>): string { private groupConversationTitle(conversation: NonNullable<ReturnType<typeof this.conversation>>): string {

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
Component, Component,
@@ -30,6 +30,7 @@ import {
participantToUser, participantToUser,
type DirectCallSession type DirectCallSession
} from '../../domains/direct-call'; } from '../../domains/direct-call';
import { isDirectCallParticipantJoined } from '../../domains/direct-call/domain/logic/direct-call-participant-identity.rules';
import { DmChatComponent } from '../../domains/direct-message/feature/dm-chat/dm-chat.component'; import { DmChatComponent } from '../../domains/direct-message/feature/dm-chat/dm-chat.component';
import { import {
VoiceActivityService, VoiceActivityService,
@@ -123,9 +124,9 @@ export class PrivateCallComponent {
}); });
readonly isConnected = computed(() => { readonly isConnected = computed(() => {
const session = this.session(); const session = this.session();
const currentUserId = this.currentUserKey(); const currentUser = this.currentUser();
return !!session && !!currentUserId && !!session.participants[currentUserId]?.joined; return !!session && !!currentUser && isDirectCallParticipantJoined(session, currentUser);
}); });
readonly isMuted = this.voice.isMuted; readonly isMuted = this.voice.isMuted;
readonly isDeafened = this.voice.isDeafened; readonly isDeafened = this.voice.isDeafened;
@@ -439,7 +440,6 @@ export class PrivateCallComponent {
isParticipantConnected(user: User): boolean { isParticipantConnected(user: User): boolean {
const session = this.session(); const session = this.session();
const userId = this.userKey(user);
const current = this.currentUser(); const current = this.currentUser();
if (!session) { if (!session) {
@@ -453,11 +453,11 @@ export class PrivateCallComponent {
); );
const isSelf = !!current && (user.id === current.id || user.oderId === current.oderId); const isSelf = !!current && (user.id === current.id || user.oderId === current.oderId);
if (isSelf && inCallVoice) { if (isSelf && (isDirectCallParticipantJoined(session, user) || inCallVoice)) {
return isLocalVoiceOwner(user.voiceState, this.realtime.getClientInstanceId()); return isLocalVoiceOwner(user.voiceState, this.realtime.getClientInstanceId());
} }
return !!session.participants[userId]?.joined || inCallVoice; return isDirectCallParticipantJoined(session, user) || inCallVoice;
} }
isPassiveCallParticipant(user: User): boolean { isPassiveCallParticipant(user: User): boolean {