fix: Bug - User receiving direct call doesn't get notified (identity aliases)

Match incoming direct-call events against every local identity alias - home
id, entity id, peer id, and each provisioned signal-server actor id - instead
of only oderId||id. A caller who met the callee through a room on the
caller's signal server addresses the ring by the callee's provisioned actor
id, so the old admission check silently dropped it: the caller went "In
Voice" while the callee saw no modal, no ring audio, and no rail entry.
Incoming self aliases are normalized onto the canonical local id
(normalizeDirectCallPayloadSelfAliases) so they never appear as a phantom
third participant, and remoteParticipantIds / the DM-header peer lookup skip
all self aliases.

Adds a DM-header call ring e2e including the cross-signal topology (callee
homed on a secondary signal server) that fails on the old code.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-13 20:18:37 +02:00
co-authored by Cursor
parent 590e487250
commit 3e090933fd
7 changed files with 510 additions and 19 deletions
@@ -14,6 +14,6 @@ Direct calls coordinate private voice sessions started from people cards, direct
8. Joining, leaving, ending, participant additions, and call chat conversion updates are mirrored as `direct-call` events over the same P2P/signaling fallback path used by direct messages.
9. The server rail shows call icons only while at least one participant is joined. If a user is viewing a private call after the session ends, the route returns to the call's chat view.
Incoming `direct-call` events are ignored unless the current user is declared in the event's `participantIds` or participant profiles, so only invited PM/group-call participants can receive call audio, the in-app incoming-call modal, or a desktop ring notification.
Incoming `direct-call` events are ignored unless the current user is declared in the event's `participantIds` or participant profiles, so only invited PM/group-call participants can receive call audio, the in-app incoming-call modal, or a desktop ring notification. That declaration check — and every other self check (sender echo filter, `remoteParticipantIds`, the DM-header peer lookup) — must match **every local identity alias**: home id, entity id, peer id, and each provisioned signal-server actor id from `SignalServerCredentialStoreService`. A caller who met the callee on a foreign signal server addresses them by the provisioned actor id, not the home id; checking only `oderId || id` silently drops the ring while the caller's UI moves to "In Voice" (`normalizeDirectCallPayloadSelfAliases` in `direct-call-participant-identity.rules.ts` collapses those aliases onto the canonical local id before session state is built, so the alias never appears as a phantom third participant).
Two-person calls use the one-to-one direct-message conversation id as their call id. Converted group calls keep the original call id for media routing but point `conversationId` at the new group chat so active streams stay connected while the chat history boundary changes.
@@ -17,6 +17,7 @@ import {
import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing';
import { ViewportService } from '../../../../core/platform';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { SignalServerCredentialStoreService } from '../../../authentication/application/services/signal-server-credential-store.service';
import {
VoiceActivityService,
VoiceConnectionFacade,
@@ -110,6 +111,111 @@ describe('DirectCallService', () => {
expect(context.directMessages.createGroupConversation).not.toHaveBeenCalled();
});
it('notifies when a ring addresses the local user via a provisioned signal-server actor id', async () => {
// Bob's home identity is "bob", but on the caller's signal server he acts
// through the provisioned identity "bob-actor". The ring payload only
// carries the actor id, so admission must match every local alias.
const context = createServiceContext({
currentUser: bob,
allUsers: [alice, bob],
selfActorIds: ['bob-actor']
});
context.directCallEvents.next({
type: 'direct-call',
directCall: {
action: 'ring',
callId: 'dm-alice-bob-actor',
conversationId: 'dm-alice-bob-actor',
createdAt: 10,
sender: toParticipant(alice),
participantIds: ['alice', 'bob-actor'],
participants: [toParticipant(alice), { userId: 'bob-actor', username: 'bob', displayName: 'Bob' }]
}
});
await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob-actor'));
await vi.waitFor(() => expect(context.audio.playLoop).toHaveBeenCalledWith(AppSound.Call));
const session = context.service.sessionById('dm-alice-bob-actor');
// The actor alias must collapse onto the local user instead of appearing
// as a third participant (which would convert the call into a group chat).
expect(session?.participantIds.sort()).toEqual(['alice', 'bob']);
expect(context.directMessages.createGroupConversation).not.toHaveBeenCalled();
});
it('ignores rings echoed back to the sender through a provisioned actor alias', async () => {
const context = createServiceContext({
currentUser: bob,
allUsers: [alice, bob],
selfActorIds: ['bob-actor']
});
context.directCallEvents.next({
type: 'direct-call',
directCall: {
action: 'ring',
callId: 'dm-alice-bob',
conversationId: 'dm-alice-bob',
createdAt: 10,
sender: { userId: 'bob-actor', username: 'bob', displayName: 'Bob' },
participantIds: ['alice', 'bob-actor'],
participants: [toParticipant(alice), { userId: 'bob-actor', username: 'bob', displayName: 'Bob' }]
}
});
await Promise.resolve();
expect(context.service.sessionById('dm-alice-bob')).toBeNull();
expect(context.audio.playLoop).not.toHaveBeenCalled();
});
it('excludes provisioned actor aliases from remote participant ids', () => {
const context = createServiceContext({
currentUser: bob,
allUsers: [alice, bob],
selfActorIds: ['bob-actor']
});
expect(context.service.remoteParticipantIds({
...createSession('ringing', false),
participantIds: [
'alice',
'bob',
'bob-actor'
]
})).toEqual(['alice']);
});
it('starts a DM-header call to the peer even when the conversation stores the local user under an actor alias', async () => {
const context = createServiceContext({
currentUser: bob,
allUsers: [alice, bob],
selfActorIds: ['bob-actor']
});
const conversation: DirectMessageConversation = {
id: 'dm-alice-bob-actor',
kind: 'direct',
lastMessageAt: 10,
messages: [],
participantProfiles: {
'alice': toParticipant(alice),
'bob-actor': { userId: 'bob-actor', username: 'bob', displayName: 'Bob' }
},
participants: ['alice', 'bob-actor'],
unreadCount: 0
};
context.service.joinCall = vi.fn(async () => undefined);
await context.service.startConversationCall(conversation);
expect(context.delivery.sendCallEvent).toHaveBeenCalledWith('alice', expect.objectContaining({
directCall: expect.objectContaining({ action: 'ring' }),
type: 'direct-call'
}));
});
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');
@@ -429,6 +535,7 @@ describe('DirectCallService', () => {
interface ServiceContextOptions {
allUsers: User[];
currentUser: User | null;
selfActorIds?: string[];
}
interface ServiceContext {
@@ -536,6 +643,17 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
const voiceSession = {
endSession: vi.fn()
};
const credentialStore = {
listValidCredentials: vi.fn(() => (options.selfActorIds ?? []).map((userId) => ({
serverUrl: `https://signal.example/${userId}`,
userId,
username: userId,
displayName: userId,
token: 'token',
expiresAt: Date.now() + 60_000,
provisioned: true
})))
};
const injector = Injector.create({
providers: [
{
@@ -626,6 +744,10 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
requestVoiceClientTakeover: vi.fn()
}
},
{
provide: SignalServerCredentialStoreService,
useValue: credentialStore
},
...provideAppI18nForTests()
]
});
@@ -23,6 +23,7 @@ import {
} from '../../../voice-connection';
import { VoiceSessionFacade, isVoiceOnAnotherClient } from '../../../voice-session';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { SignalServerCredentialStoreService } from '../../../authentication/application/services/signal-server-credential-store.service';
import { DirectMessageService, PeerDeliveryService } from '../../../direct-message';
import type { DirectMessageConversation } from '../../../direct-message';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
@@ -34,9 +35,12 @@ import {
} from '../../../../shared-kernel';
import { DirectCallSession, participantToUser } from '../../domain/models/direct-call.model';
import {
collectDirectCallUserIdentityKeys,
directCallPayloadIncludesAnyId,
findDirectCallParticipantEntry,
findDirectCallParticipantEntryForUser,
isDirectCallParticipantJoined
isDirectCallParticipantJoined,
normalizeDirectCallPayloadSelfAliases
} from '../../domain/logic/direct-call-participant-identity.rules';
import { toDirectMessageParticipant } from '../../../direct-message';
@@ -56,6 +60,7 @@ export class DirectCallService {
private readonly mobileNotifications = inject(MobileNotificationsService);
private readonly mobileCallSession = inject(MobileCallSessionService);
private readonly mobileMedia = inject(MobileMediaService);
private readonly credentialStore = inject(SignalServerCredentialStoreService);
private readonly i18n = inject(AppI18nService);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private readonly users = this.store.selectSignal(selectAllUsers);
@@ -234,8 +239,8 @@ export class DirectCallService {
return await this.startGroupCall(conversation);
}
const meId = this.currentUserId();
const peerId = conversation.participants.find((participantId) => participantId !== meId);
const selfIds = this.selfIdentityIds();
const peerId = conversation.participants.find((participantId) => !selfIds.has(participantId));
if (!peerId) {
throw new Error(this.i18n.instant('call.errors.noRecipient'));
@@ -433,9 +438,9 @@ export class DirectCallService {
}
remoteParticipantIds(session: DirectCallSession): string[] {
const meId = this.currentUserId();
const selfIds = this.selfIdentityIds();
return session.participantIds.filter((participantId) => participantId !== meId);
return session.participantIds.filter((participantId) => !selfIds.has(participantId));
}
userForParticipant(participantId: string): User | null {
@@ -464,29 +469,35 @@ export class DirectCallService {
}
}
private async handleIncomingCallEvent(payload: DirectCallEventPayload): Promise<void> {
private async handleIncomingCallEvent(rawPayload: DirectCallEventPayload): Promise<void> {
const meId = this.currentUserId();
if (!meId) {
if (payload.action === 'ring') {
this.pendingIncomingCallPayloads.push(payload);
if (rawPayload.action === 'ring') {
this.pendingIncomingCallPayloads.push(rawPayload);
}
return;
}
if (payload.sender.userId === meId) {
// Callers on a foreign signal server address the local user through the
// provisioned actor identity, not the home id, so every self check and the
// stored participant state must work across all local identity aliases.
const selfIds = this.selfIdentityIds();
if (selfIds.has(rawPayload.sender.userId)) {
return;
}
if (!this.callPayloadIncludesParticipant(payload, meId)) {
if (!directCallPayloadIncludesAnyId(rawPayload, selfIds)) {
return;
}
if (payload.action === 'ring' && this.declinedCallIds.has(payload.callId)) {
if (rawPayload.action === 'ring' && this.declinedCallIds.has(rawPayload.callId)) {
return;
}
const payload = normalizeDirectCallPayloadSelfAliases(rawPayload, meId, selfIds);
const participants = this.callParticipantsFromPayload(payload);
const existing = this.sessionById(payload.callId);
const incomingSession = this.createSession({
@@ -826,11 +837,6 @@ export class DirectCallService {
]);
}
private callPayloadIncludesParticipant(payload: DirectCallEventPayload, participantId: string): boolean {
return payload.participantIds.includes(participantId)
|| (payload.participants ?? []).some((participant) => participant.userId === participantId);
}
private groupConversationTitle(session: DirectCallSession): string {
const names = Object.values(session.participants)
.map((participant) => participant.profile.displayName || participant.profile.username || participant.userId);
@@ -1056,6 +1062,19 @@ export class DirectCallService {
return user ? this.userKey(user) : null;
}
/** Every id that can address the local user, including provisioned signal-server actor ids. */
private selfIdentityIds(): ReadonlySet<string> {
const user = this.currentUser();
if (!user) {
return new Set();
}
const actorUserIds = this.credentialStore.listValidCredentials().map((credential) => credential.userId);
return new Set(collectDirectCallUserIdentityKeys(user, actorUserIds));
}
private requireCurrentUser(): User {
const user = this.currentUser();
@@ -1,8 +1,11 @@
import type { DirectCallEventPayload } from '../../../../shared-kernel';
import type { DirectCallSession } from '../models/direct-call.model';
import {
directCallPayloadIncludesAnyId,
findDirectCallParticipantEntry,
findDirectCallParticipantEntryForUser,
isDirectCallParticipantJoined
isDirectCallParticipantJoined,
normalizeDirectCallPayloadSelfAliases
} from './direct-call-participant-identity.rules';
function createSession(participants: DirectCallSession['participants']): DirectCallSession {
@@ -77,4 +80,58 @@ describe('direct-call-participant-identity.rules', () => {
oderId: 'bob-foreign'
}, ['bob-foreign'])).toBe(false);
});
it('directCallPayloadIncludesAnyId matches participant ids and participant profiles', () => {
const payload = createRingPayload();
expect(directCallPayloadIncludesAnyId(payload, new Set(['bob-actor']))).toBe(true);
expect(directCallPayloadIncludesAnyId(payload, new Set(['bob-profile-only']))).toBe(true);
expect(directCallPayloadIncludesAnyId(payload, new Set(['charlie']))).toBe(false);
});
it('normalizeDirectCallPayloadSelfAliases collapses provisioned aliases onto the canonical local id', () => {
const normalized = normalizeDirectCallPayloadSelfAliases(createRingPayload(), 'bob-home', new Set([
'bob-home',
'bob-actor',
'bob-profile-only'
]));
expect(normalized.participantIds).toEqual(['alice', 'bob-home']);
expect(normalized.participants?.map((participant) => participant.userId)).toEqual(['alice', 'bob-home']);
});
it('normalizeDirectCallPayloadSelfAliases leaves payloads without self aliases untouched', () => {
const payload = createRingPayload();
const normalized = normalizeDirectCallPayloadSelfAliases(payload, 'charlie', new Set(['charlie']));
expect(normalized.participantIds).toEqual(payload.participantIds);
expect(normalized.participants).toEqual(payload.participants);
});
});
function createRingPayload(): DirectCallEventPayload {
return {
action: 'ring',
callId: 'dm-alice--bob-actor',
conversationId: 'dm-alice--bob-actor',
createdAt: 1,
sender: {
userId: 'alice',
username: 'alice',
displayName: 'Alice'
},
participantIds: ['alice', 'bob-actor'],
participants: [
{
userId: 'alice',
username: 'alice',
displayName: 'Alice'
},
{
userId: 'bob-profile-only',
username: 'bob',
displayName: 'Bob'
}
]
};
}
@@ -1,4 +1,4 @@
import type { User } from '../../../../shared-kernel';
import type { DirectCallEventPayload, User } from '../../../../shared-kernel';
import type { DirectCallParticipant, DirectCallSession } from '../models/direct-call.model';
type UserIdentityFields = Pick<User, 'id' | 'oderId' | 'peerId'>;
@@ -86,3 +86,52 @@ export function isDirectCallParticipantJoined(
): boolean {
return !!findDirectCallParticipantEntryForUser(session, user, additionalIds)?.participant.joined;
}
/** True when any of the given ids is declared in the payload's participant ids or profiles. */
export function directCallPayloadIncludesAnyId(
payload: Pick<DirectCallEventPayload, 'participantIds' | 'participants'>,
ids: ReadonlySet<string>
): boolean {
return payload.participantIds.some((participantId) => ids.has(participantId))
|| (payload.participants ?? []).some((participant) => ids.has(participant.userId));
}
/**
* Rewrite every self alias (home id, entity id, provisioned signal-server
* actor ids) in an incoming call payload to the canonical local id. Callers
* on a foreign signal server address the local user by the provisioned actor
* identity; without collapsing it the alias shows up as an extra third
* participant and never matches the local user's session key.
*/
export function normalizeDirectCallPayloadSelfAliases(
payload: DirectCallEventPayload,
canonicalId: string,
selfIds: ReadonlySet<string>
): DirectCallEventPayload {
const participantIds = [
...new Set(payload.participantIds.map((participantId) =>
(selfIds.has(participantId) ? canonicalId : participantId)))
];
const seenParticipantIds = new Set<string>();
const participants = payload.participants
?.map((participant) => (selfIds.has(participant.userId)
? {
...participant,
userId: canonicalId
}
: participant))
.filter((participant) => {
if (seenParticipantIds.has(participant.userId)) {
return false;
}
seenParticipantIds.add(participant.userId);
return true;
});
return {
...payload,
participantIds,
participants
};
}