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:
+122
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user