From 924d4bbb1d71be76d9a024b684053df7405597a3 Mon Sep 17 00:00:00 2001 From: Myx Date: Sat, 13 Jun 2026 21:31:03 +0200 Subject: [PATCH] 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 --- .../services/direct-call.service.spec.ts | 50 +++++++++++ .../services/direct-call.service.ts | 50 ++++++++--- ...ct-call-participant-identity.rules.spec.ts | 80 +++++++++++++++++ .../direct-call-participant-identity.rules.ts | 88 +++++++++++++++++++ .../feature/dm-chat/dm-chat.component.ts | 10 ++- .../direct-call/private-call.component.ts | 12 +-- 6 files changed, 268 insertions(+), 22 deletions(-) create mode 100644 toju-app/src/app/domains/direct-call/domain/logic/direct-call-participant-identity.rules.spec.ts create mode 100644 toju-app/src/app/domains/direct-call/domain/logic/direct-call-participant-identity.rules.ts diff --git a/toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts b/toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts index e7b0037..ea9ca6b 100644 --- a/toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts +++ b/toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts @@ -16,6 +16,7 @@ import { } from '../../../../infrastructure/mobile'; import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing'; import { ViewportService } from '../../../../core/platform'; +import { RealtimeSessionFacade } from '../../../../core/realtime'; import { VoiceActivityService, VoiceConnectionFacade, @@ -109,6 +110,47 @@ describe('DirectCallService', () => { 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 () => { const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] }); @@ -573,9 +615,17 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext { { provide: MobileMediaService, useValue: { + ensureVoiceCapturePermissions: vi.fn(async () => true), setSpeakerphoneEnabled: vi.fn(async () => undefined) } }, + { + provide: RealtimeSessionFacade, + useValue: { + getClientInstanceId: vi.fn(() => 'test-client'), + requestVoiceClientTakeover: vi.fn() + } + }, ...provideAppI18nForTests() ] }); diff --git a/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts b/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts index ba05d78..7c853d0 100644 --- a/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts +++ b/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/member-ordering */ + import { Injectable, computed, @@ -33,6 +33,11 @@ import { User } from '../../../../shared-kernel'; 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'; @Injectable({ providedIn: 'root' }) @@ -772,13 +777,20 @@ export class DirectCallService { private preserveJoinedParticipants(previousSession: DirectCallSession, nextSession: DirectCallSession): DirectCallSession { return { ...nextSession, - participants: Object.fromEntries(Object.values(nextSession.participants).map((participant) => [ - participant.userId, - { - ...participant, - joined: previousSession.participants[participant.userId]?.joined ?? participant.joined - } - ])) + participants: Object.fromEntries(Object.values(nextSession.participants).map((participant) => { + const previousEntry = findDirectCallParticipantEntryForUser(previousSession, { + id: participant.userId, + oderId: participant.profile.userId + }); + + return [ + participant.userId, + { + ...participant, + joined: previousEntry?.participant.joined ?? participant.joined + } + ]; + })) }; } @@ -865,7 +877,14 @@ export class DirectCallService { joined: boolean, status: DirectCallSession['status'] ): 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 { ...session, @@ -874,7 +893,7 @@ export class DirectCallService { ...session.participants, ...(participant ? { - [participantId]: { + [key]: { ...participant, joined } @@ -916,9 +935,9 @@ export class DirectCallService { } 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 { @@ -955,8 +974,13 @@ export class DirectCallService { } 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({ - userId, + userId: resolvedUserId, voiceState: { isConnected: connected, isMuted: false, diff --git a/toju-app/src/app/domains/direct-call/domain/logic/direct-call-participant-identity.rules.spec.ts b/toju-app/src/app/domains/direct-call/domain/logic/direct-call-participant-identity.rules.spec.ts new file mode 100644 index 0000000..0589d6b --- /dev/null +++ b/toju-app/src/app/domains/direct-call/domain/logic/direct-call-participant-identity.rules.spec.ts @@ -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); + }); +}); diff --git a/toju-app/src/app/domains/direct-call/domain/logic/direct-call-participant-identity.rules.ts b/toju-app/src/app/domains/direct-call/domain/logic/direct-call-participant-identity.rules.ts new file mode 100644 index 0000000..f8a05b9 --- /dev/null +++ b/toju-app/src/app/domains/direct-call/domain/logic/direct-call-participant-identity.rules.ts @@ -0,0 +1,88 @@ +import type { User } from '../../../../shared-kernel'; +import type { DirectCallParticipant, DirectCallSession } from '../models/direct-call.model'; + +type UserIdentityFields = Pick; + +/** 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, + 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, + 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, + user: UserIdentityFields, + additionalIds: readonly string[] = [] +): boolean { + return !!findDirectCallParticipantEntryForUser(session, user, additionalIds)?.participant.joined; +} diff --git a/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.ts b/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.ts index ca6b9e6..ac3700d 100644 --- a/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.ts +++ b/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/member-ordering */ + import { Component, computed, @@ -28,6 +28,7 @@ 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 { buildUserIdentityLookup, resolveUserByIdentity } from '../../../../store/users/user-identity-lookup.rules'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucidePhone, lucidePhoneCall } from '@ng-icons/lucide'; import { @@ -137,13 +138,14 @@ export class DmChatComponent { readonly participantUsers = computed(() => { const conversation = this.conversation(); const knownUsers = this.allUsers(); + const userLookup = buildUserIdentityLookup(knownUsers); if (!conversation) { return []; } 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]; return ( @@ -651,7 +653,9 @@ export class DmChatComponent { 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>): string { diff --git a/toju-app/src/app/features/direct-call/private-call.component.ts b/toju-app/src/app/features/direct-call/private-call.component.ts index 8db019a..5b4379c 100644 --- a/toju-app/src/app/features/direct-call/private-call.component.ts +++ b/toju-app/src/app/features/direct-call/private-call.component.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/member-ordering */ + import { CUSTOM_ELEMENTS_SCHEMA, Component, @@ -30,6 +30,7 @@ import { participantToUser, type DirectCallSession } 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 { VoiceActivityService, @@ -123,9 +124,9 @@ export class PrivateCallComponent { }); readonly isConnected = computed(() => { 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 isDeafened = this.voice.isDeafened; @@ -439,7 +440,6 @@ export class PrivateCallComponent { isParticipantConnected(user: User): boolean { const session = this.session(); - const userId = this.userKey(user); const current = this.currentUser(); if (!session) { @@ -453,11 +453,11 @@ export class PrivateCallComponent { ); 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 !!session.participants[userId]?.joined || inCallVoice; + return isDirectCallParticipantJoined(session, user) || inCallVoice; } isPassiveCallParticipant(user: User): boolean {