Fix wonkyness #18
@@ -25,6 +25,13 @@ Durable rules for AI agents working on this project. Read this file at session s
|
||||
|
||||
## 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]
|
||||
|
||||
- **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.
|
||||
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.
|
||||
|
||||
+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();
|
||||
|
||||
|
||||
+58
-1
@@ -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'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
+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';
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user