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:
@@ -25,6 +25,13 @@ Durable rules for AI agents working on this project. Read this file at session s
|
|||||||
|
|
||||||
## Lessons
|
## Lessons
|
||||||
|
|
||||||
|
### Match direct-call recipients against every local identity alias, exactly like DMs already do [direct-call] [identity]
|
||||||
|
|
||||||
|
- **Trigger:** "User receiving direct call doesn't get notified" — a caller who met the callee through a room on the caller's signal server addressed the ring by the callee's *provisioned actor id*; `handleIncomingCallEvent` admitted only `payload.participantIds.includes(oderId || id)`, so the ring was silently dropped, the caller sat "In Voice", and the callee saw nothing. DMs had the identical bug fixed earlier (`baa350e`), but the fix stopped at `DirectMessageService` and never reached `DirectCallService`.
|
||||||
|
- **Rule:** every self check on a cross-user event (admission, sender-echo filter, remote-participant filtering, DM-header peer lookup) must span all local aliases — home id, entity id, peer id, plus each `SignalServerCredentialStoreService.listValidCredentials()` actor id — and incoming aliases must be normalized onto the canonical local id before session state is keyed (`normalizeDirectCallPayloadSelfAliases`).
|
||||||
|
- **Why:** the failure only reproduces when caller and callee have different home signal servers, which no same-server e2e covers; and when one identity-alias bug is fixed in a domain, grep for the same `=== currentUserId` pattern in sibling domains that share the transport — the direct-call domain reused `PeerDeliveryService` but kept the naive check for another month.
|
||||||
|
- **Example:** `direct-call-participant-identity.rules.ts#directCallPayloadIncludesAnyId` / `normalizeDirectCallPayloadSelfAliases`; regression e2e `e2e/tests/voice/dm-header-call-ring.spec.ts` registers Bob on a secondary signal server, meets in a primary-signal room, and asserts the DM-header call rings Bob's incoming-call modal (fails on old code, passes after).
|
||||||
|
|
||||||
### Decide attachment receive admission once at request time; never re-gate size in the chunk handler [attachments]
|
### Decide attachment receive admission once at request time; never re-gate size in the chunk handler [attachments]
|
||||||
|
|
||||||
- **Trigger:** "Sending files between users doesn't really work" — a browser user clicked Request on a 10–50 MB generic file, the request gate (`canReceiveAttachment`) admitted it for in-memory receive, the sender streamed chunks, but `handleFileChunk` still had a leftover hard `size > MAX_AUTO_SAVE_SIZE_BYTES` rejection on the in-memory path, so every chunk was dropped, no ack was ever sent, the sender's `waitForAck` timed out, and the GUI never changed.
|
- **Trigger:** "Sending files between users doesn't really work" — a browser user clicked Request on a 10–50 MB generic file, the request gate (`canReceiveAttachment`) admitted it for in-memory receive, the sender streamed chunks, but `handleFileChunk` still had a leftover hard `size > MAX_AUTO_SAVE_SIZE_BYTES` rejection on the in-memory path, so every chunk was dropped, no ack was ever sent, the sender's `waitForAck` timed out, and the GUI never changed.
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
import { test } from '../../fixtures/multi-client';
|
||||||
|
import { installTestServerEndpoints } from '../../helpers/seed-test-endpoint';
|
||||||
|
import { startTestServer } from '../../helpers/test-server';
|
||||||
|
import { readSignalServerCredentialFromPage } from '../../helpers/auth-api';
|
||||||
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
|
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression coverage for "User receiving direct call doesn't get notified":
|
||||||
|
* starting a call from the DM chat header (steps: open DM of a user, click
|
||||||
|
* call) must ring the recipient - incoming-call modal, ring audio, and a
|
||||||
|
* server-rail call entry. Includes the cross-signal topology where the callee
|
||||||
|
* is addressed by a provisioned actor id instead of their home identity.
|
||||||
|
*/
|
||||||
|
const USER_PASSWORD = 'TestPass123!';
|
||||||
|
const PRIMARY_SIGNAL_ID = 'e2e-dm-ring-primary';
|
||||||
|
const SECONDARY_SIGNAL_ID = 'e2e-dm-ring-secondary';
|
||||||
|
|
||||||
|
test.describe('DM header call ring', () => {
|
||||||
|
test.describe.configure({ timeout: 240_000 });
|
||||||
|
|
||||||
|
test('callee is notified when the caller starts the call from the DM chat header', async ({ createClient }) => {
|
||||||
|
const suffix = uniqueName('dm-ring');
|
||||||
|
const serverName = `DM Ring Server ${suffix}`;
|
||||||
|
const alice = await createClient();
|
||||||
|
const bob = await createClient();
|
||||||
|
|
||||||
|
await installRingInstrumentation(bob.page);
|
||||||
|
|
||||||
|
await test.step('Alice and Bob register and meet in a server', async () => {
|
||||||
|
await registerUser(alice.page, `alice_${suffix}`, 'Alice');
|
||||||
|
await registerUser(bob.page, `bob_${suffix}`, 'Bob');
|
||||||
|
|
||||||
|
const aliceSearch = new ServerSearchPage(alice.page);
|
||||||
|
|
||||||
|
await aliceSearch.createServer(serverName, { description: 'DM header call ring regression coverage' });
|
||||||
|
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
await new ChatMessagesPage(alice.page).waitForReady();
|
||||||
|
|
||||||
|
const bobSearch = new ServerSearchPage(bob.page);
|
||||||
|
|
||||||
|
await bobSearch.joinServerFromSearch(serverName);
|
||||||
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
await new ChatMessagesPage(bob.page).waitForReady();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Both users open the DM view; live DM delivery confirms the transport works', async () => {
|
||||||
|
const bobUserCard = alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first();
|
||||||
|
|
||||||
|
await expect(bobUserCard).toBeVisible({ timeout: 20_000 });
|
||||||
|
await bobUserCard.getByRole('button', { name: 'Message Bob' }).click();
|
||||||
|
await expect(alice.page).toHaveURL(/\/dm\//, { timeout: 15_000 });
|
||||||
|
|
||||||
|
const aliceUserCard = bob.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Alice' }).first();
|
||||||
|
|
||||||
|
await expect(aliceUserCard).toBeVisible({ timeout: 20_000 });
|
||||||
|
await aliceUserCard.getByRole('button', { name: 'Message Alice' }).click();
|
||||||
|
await expect(bob.page).toHaveURL(/\/dm\//, { timeout: 15_000 });
|
||||||
|
|
||||||
|
// Mirrors the bug report: the users are in the DM view (not a server
|
||||||
|
// room) when the call starts. The message must arrive live so a broken
|
||||||
|
// ring cannot be blamed on a dead transport.
|
||||||
|
await alice.page.getByTestId('dm-input').fill(`hello before call ${suffix}`);
|
||||||
|
await alice.page.getByTestId('dm-input').press('Enter');
|
||||||
|
await expect(bob.page.locator('app-dm-chat').getByText(`hello before call ${suffix}`)).toBeVisible({ timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Alice starts the call from the DM chat header', async () => {
|
||||||
|
const callButton = alice.page.locator('app-dm-chat header').getByRole('button', { name: 'Call Bob' });
|
||||||
|
|
||||||
|
await expect(callButton).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect(callButton).toBeEnabled({ timeout: 20_000 });
|
||||||
|
await callButton.click();
|
||||||
|
await expect(alice.page).toHaveURL(/\/call\//, { timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Bob gets the incoming-call modal, ring audio, and rail entry', async () => {
|
||||||
|
await expect(bob.page.getByRole('dialog', { name: /is calling/ })).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect(bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await getCallAudioPlayCount(bob.page), {
|
||||||
|
timeout: 20_000,
|
||||||
|
intervals: [500, 1_000]
|
||||||
|
})
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callee homed on another signal server is notified when called via their provisioned actor id', async ({ createClient, testServer }) => {
|
||||||
|
const secondaryServer = await startTestServer();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const suffix = uniqueName('xsig-ring');
|
||||||
|
const serverName = `Cross Signal Ring ${suffix}`;
|
||||||
|
const alice = await createClient();
|
||||||
|
const bob = await createClient();
|
||||||
|
const endpoints = [
|
||||||
|
{
|
||||||
|
id: PRIMARY_SIGNAL_ID,
|
||||||
|
name: 'E2E Ring Signal A',
|
||||||
|
url: testServer.url,
|
||||||
|
isActive: true,
|
||||||
|
status: 'online'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: SECONDARY_SIGNAL_ID,
|
||||||
|
name: 'E2E Ring Signal B',
|
||||||
|
url: secondaryServer.url,
|
||||||
|
isActive: true,
|
||||||
|
status: 'online'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
await installTestServerEndpoints(alice.context, endpoints);
|
||||||
|
await installTestServerEndpoints(bob.context, endpoints);
|
||||||
|
await installRingInstrumentation(bob.page);
|
||||||
|
|
||||||
|
await test.step('Alice registers on the primary signal, Bob on the secondary', async () => {
|
||||||
|
const aliceRegister = new RegisterPage(alice.page);
|
||||||
|
|
||||||
|
await aliceRegister.goto();
|
||||||
|
await aliceRegister.serverSelect.selectOption(PRIMARY_SIGNAL_ID);
|
||||||
|
await aliceRegister.register(`alice_${suffix}`, 'Alice', USER_PASSWORD);
|
||||||
|
await expect(alice.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
||||||
|
|
||||||
|
const bobRegister = new RegisterPage(bob.page);
|
||||||
|
|
||||||
|
await bobRegister.goto();
|
||||||
|
await bobRegister.serverSelect.selectOption(SECONDARY_SIGNAL_ID);
|
||||||
|
await bobRegister.register(`bob_${suffix}`, 'Bob', USER_PASSWORD);
|
||||||
|
await expect(bob.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('They meet in a room on the primary signal; Bob gets a provisioned actor identity', async () => {
|
||||||
|
const aliceSearch = new ServerSearchPage(alice.page);
|
||||||
|
|
||||||
|
await aliceSearch.createServer(serverName, {
|
||||||
|
description: 'Cross-signal DM call ring coverage',
|
||||||
|
sourceId: PRIMARY_SIGNAL_ID
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
await new ChatMessagesPage(alice.page).waitForReady();
|
||||||
|
|
||||||
|
const bobSearch = new ServerSearchPage(bob.page);
|
||||||
|
|
||||||
|
await bobSearch.joinServerFromSearch(serverName);
|
||||||
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
await new ChatMessagesPage(bob.page).waitForReady();
|
||||||
|
|
||||||
|
await expect.poll(async () =>
|
||||||
|
await readSignalServerCredentialFromPage(bob.page, testServer.url),
|
||||||
|
{ timeout: 30_000 }
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Alice opens the DM with Bob and calls from the DM chat header', async () => {
|
||||||
|
const bobUserCard = alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first();
|
||||||
|
|
||||||
|
await expect(bobUserCard).toBeVisible({ timeout: 20_000 });
|
||||||
|
await bobUserCard.getByRole('button', { name: 'Message Bob' }).click();
|
||||||
|
await expect(alice.page).toHaveURL(/\/dm\//, { timeout: 15_000 });
|
||||||
|
|
||||||
|
const callButton = alice.page.locator('app-dm-chat header').getByRole('button', { name: 'Call Bob' });
|
||||||
|
|
||||||
|
await expect(callButton).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect(callButton).toBeEnabled({ timeout: 20_000 });
|
||||||
|
await callButton.click();
|
||||||
|
await expect(alice.page).toHaveURL(/\/call\//, { timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Bob gets the incoming-call modal and ring audio', async () => {
|
||||||
|
await expect(bob.page.getByRole('dialog', { name: /is calling/ })).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await getCallAudioPlayCount(bob.page), {
|
||||||
|
timeout: 20_000,
|
||||||
|
intervals: [500, 1_000]
|
||||||
|
})
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await secondaryServer.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function registerUser(page: Page, username: string, displayName: string): Promise<void> {
|
||||||
|
const registerPage = new RegisterPage(page);
|
||||||
|
|
||||||
|
await registerPage.goto();
|
||||||
|
await registerPage.register(username, displayName, USER_PASSWORD);
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installRingInstrumentation(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const OriginalAudio = window.Audio;
|
||||||
|
const callAudioState = { playCount: 0 };
|
||||||
|
|
||||||
|
(window as Window & { __callAudioState?: typeof callAudioState }).__callAudioState = callAudioState;
|
||||||
|
|
||||||
|
function isCallAudio(audio: HTMLAudioElement): boolean {
|
||||||
|
return audio.src.includes('/assets/audio/call.wav') || audio.src.endsWith('assets/audio/call.wav');
|
||||||
|
}
|
||||||
|
|
||||||
|
(window as unknown as { Audio: typeof Audio }).Audio = function(this: HTMLAudioElement, src?: string) {
|
||||||
|
const audio = new OriginalAudio(src);
|
||||||
|
const originalPlay = audio.play.bind(audio);
|
||||||
|
|
||||||
|
audio.play = () => {
|
||||||
|
if (isCallAudio(audio)) {
|
||||||
|
callAudioState.playCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalPlay();
|
||||||
|
};
|
||||||
|
|
||||||
|
return audio;
|
||||||
|
} as typeof Audio;
|
||||||
|
|
||||||
|
window.Audio.prototype = OriginalAudio.prototype;
|
||||||
|
Object.setPrototypeOf(window.Audio, OriginalAudio);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCallAudioPlayCount(page: Page): Promise<number> {
|
||||||
|
return await page.evaluate(() => (window as Window & { __callAudioState?: { playCount: number } }).__callAudioState?.playCount ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueName(prefix: string): string {
|
||||||
|
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
||||||
|
.slice(2, 8)}`;
|
||||||
|
}
|
||||||
@@ -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.
|
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.
|
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.
|
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.
|
||||||
|
|||||||
+122
@@ -17,6 +17,7 @@ import {
|
|||||||
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 { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||||
|
import { SignalServerCredentialStoreService } from '../../../authentication/application/services/signal-server-credential-store.service';
|
||||||
import {
|
import {
|
||||||
VoiceActivityService,
|
VoiceActivityService,
|
||||||
VoiceConnectionFacade,
|
VoiceConnectionFacade,
|
||||||
@@ -110,6 +111,111 @@ describe('DirectCallService', () => {
|
|||||||
expect(context.directMessages.createGroupConversation).not.toHaveBeenCalled();
|
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 () => {
|
it('marks a remote join against the session participant alias stored locally', async () => {
|
||||||
const aliceForeign = createUser('alice-foreign', 'Alice');
|
const aliceForeign = createUser('alice-foreign', 'Alice');
|
||||||
const bobForeign = createUser('bob-foreign', 'Bob');
|
const bobForeign = createUser('bob-foreign', 'Bob');
|
||||||
@@ -429,6 +535,7 @@ describe('DirectCallService', () => {
|
|||||||
interface ServiceContextOptions {
|
interface ServiceContextOptions {
|
||||||
allUsers: User[];
|
allUsers: User[];
|
||||||
currentUser: User | null;
|
currentUser: User | null;
|
||||||
|
selfActorIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ServiceContext {
|
interface ServiceContext {
|
||||||
@@ -536,6 +643,17 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
|
|||||||
const voiceSession = {
|
const voiceSession = {
|
||||||
endSession: vi.fn()
|
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({
|
const injector = Injector.create({
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
@@ -626,6 +744,10 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
|
|||||||
requestVoiceClientTakeover: vi.fn()
|
requestVoiceClientTakeover: vi.fn()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: SignalServerCredentialStoreService,
|
||||||
|
useValue: credentialStore
|
||||||
|
},
|
||||||
...provideAppI18nForTests()
|
...provideAppI18nForTests()
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from '../../../voice-connection';
|
} from '../../../voice-connection';
|
||||||
import { VoiceSessionFacade, isVoiceOnAnotherClient } from '../../../voice-session';
|
import { VoiceSessionFacade, isVoiceOnAnotherClient } from '../../../voice-session';
|
||||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||||
|
import { SignalServerCredentialStoreService } from '../../../authentication/application/services/signal-server-credential-store.service';
|
||||||
import { DirectMessageService, PeerDeliveryService } from '../../../direct-message';
|
import { DirectMessageService, PeerDeliveryService } from '../../../direct-message';
|
||||||
import type { DirectMessageConversation } from '../../../direct-message';
|
import type { DirectMessageConversation } from '../../../direct-message';
|
||||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||||
@@ -34,9 +35,12 @@ import {
|
|||||||
} 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 {
|
import {
|
||||||
|
collectDirectCallUserIdentityKeys,
|
||||||
|
directCallPayloadIncludesAnyId,
|
||||||
findDirectCallParticipantEntry,
|
findDirectCallParticipantEntry,
|
||||||
findDirectCallParticipantEntryForUser,
|
findDirectCallParticipantEntryForUser,
|
||||||
isDirectCallParticipantJoined
|
isDirectCallParticipantJoined,
|
||||||
|
normalizeDirectCallPayloadSelfAliases
|
||||||
} from '../../domain/logic/direct-call-participant-identity.rules';
|
} from '../../domain/logic/direct-call-participant-identity.rules';
|
||||||
import { toDirectMessageParticipant } from '../../../direct-message';
|
import { toDirectMessageParticipant } from '../../../direct-message';
|
||||||
|
|
||||||
@@ -56,6 +60,7 @@ export class DirectCallService {
|
|||||||
private readonly mobileNotifications = inject(MobileNotificationsService);
|
private readonly mobileNotifications = inject(MobileNotificationsService);
|
||||||
private readonly mobileCallSession = inject(MobileCallSessionService);
|
private readonly mobileCallSession = inject(MobileCallSessionService);
|
||||||
private readonly mobileMedia = inject(MobileMediaService);
|
private readonly mobileMedia = inject(MobileMediaService);
|
||||||
|
private readonly credentialStore = inject(SignalServerCredentialStoreService);
|
||||||
private readonly i18n = inject(AppI18nService);
|
private readonly i18n = inject(AppI18nService);
|
||||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
private readonly users = this.store.selectSignal(selectAllUsers);
|
private readonly users = this.store.selectSignal(selectAllUsers);
|
||||||
@@ -234,8 +239,8 @@ export class DirectCallService {
|
|||||||
return await this.startGroupCall(conversation);
|
return await this.startGroupCall(conversation);
|
||||||
}
|
}
|
||||||
|
|
||||||
const meId = this.currentUserId();
|
const selfIds = this.selfIdentityIds();
|
||||||
const peerId = conversation.participants.find((participantId) => participantId !== meId);
|
const peerId = conversation.participants.find((participantId) => !selfIds.has(participantId));
|
||||||
|
|
||||||
if (!peerId) {
|
if (!peerId) {
|
||||||
throw new Error(this.i18n.instant('call.errors.noRecipient'));
|
throw new Error(this.i18n.instant('call.errors.noRecipient'));
|
||||||
@@ -433,9 +438,9 @@ export class DirectCallService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
remoteParticipantIds(session: DirectCallSession): string[] {
|
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 {
|
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();
|
const meId = this.currentUserId();
|
||||||
|
|
||||||
if (!meId) {
|
if (!meId) {
|
||||||
if (payload.action === 'ring') {
|
if (rawPayload.action === 'ring') {
|
||||||
this.pendingIncomingCallPayloads.push(payload);
|
this.pendingIncomingCallPayloads.push(rawPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.callPayloadIncludesParticipant(payload, meId)) {
|
if (!directCallPayloadIncludesAnyId(rawPayload, selfIds)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.action === 'ring' && this.declinedCallIds.has(payload.callId)) {
|
if (rawPayload.action === 'ring' && this.declinedCallIds.has(rawPayload.callId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const payload = normalizeDirectCallPayloadSelfAliases(rawPayload, meId, selfIds);
|
||||||
const participants = this.callParticipantsFromPayload(payload);
|
const participants = this.callParticipantsFromPayload(payload);
|
||||||
const existing = this.sessionById(payload.callId);
|
const existing = this.sessionById(payload.callId);
|
||||||
const incomingSession = this.createSession({
|
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 {
|
private groupConversationTitle(session: DirectCallSession): string {
|
||||||
const names = Object.values(session.participants)
|
const names = Object.values(session.participants)
|
||||||
.map((participant) => participant.profile.displayName || participant.profile.username || participant.userId);
|
.map((participant) => participant.profile.displayName || participant.profile.username || participant.userId);
|
||||||
@@ -1056,6 +1062,19 @@ export class DirectCallService {
|
|||||||
return user ? this.userKey(user) : null;
|
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 {
|
private requireCurrentUser(): User {
|
||||||
const user = this.currentUser();
|
const user = this.currentUser();
|
||||||
|
|
||||||
|
|||||||
+58
-1
@@ -1,8 +1,11 @@
|
|||||||
|
import type { DirectCallEventPayload } from '../../../../shared-kernel';
|
||||||
import type { DirectCallSession } from '../models/direct-call.model';
|
import type { DirectCallSession } from '../models/direct-call.model';
|
||||||
import {
|
import {
|
||||||
|
directCallPayloadIncludesAnyId,
|
||||||
findDirectCallParticipantEntry,
|
findDirectCallParticipantEntry,
|
||||||
findDirectCallParticipantEntryForUser,
|
findDirectCallParticipantEntryForUser,
|
||||||
isDirectCallParticipantJoined
|
isDirectCallParticipantJoined,
|
||||||
|
normalizeDirectCallPayloadSelfAliases
|
||||||
} from './direct-call-participant-identity.rules';
|
} from './direct-call-participant-identity.rules';
|
||||||
|
|
||||||
function createSession(participants: DirectCallSession['participants']): DirectCallSession {
|
function createSession(participants: DirectCallSession['participants']): DirectCallSession {
|
||||||
@@ -77,4 +80,58 @@ describe('direct-call-participant-identity.rules', () => {
|
|||||||
oderId: 'bob-foreign'
|
oderId: 'bob-foreign'
|
||||||
}, ['bob-foreign'])).toBe(false);
|
}, ['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'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
+50
-1
@@ -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';
|
import type { DirectCallParticipant, DirectCallSession } from '../models/direct-call.model';
|
||||||
|
|
||||||
type UserIdentityFields = Pick<User, 'id' | 'oderId' | 'peerId'>;
|
type UserIdentityFields = Pick<User, 'id' | 'oderId' | 'peerId'>;
|
||||||
@@ -86,3 +86,52 @@ export function isDirectCallParticipantJoined(
|
|||||||
): boolean {
|
): boolean {
|
||||||
return !!findDirectCallParticipantEntryForUser(session, user, additionalIds)?.participant.joined;
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user