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:
@@ -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()
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
const previousEntry = findDirectCallParticipantEntryForUser(previousSession, {
|
||||||
|
id: participant.userId,
|
||||||
|
oderId: participant.profile.userId
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
participant.userId,
|
participant.userId,
|
||||||
{
|
{
|
||||||
...participant,
|
...participant,
|
||||||
joined: previousSession.participants[participant.userId]?.joined ?? participant.joined
|
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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user