Fix wonkyness #18

Merged
myxelium merged 11 commits from fix-bug into main 2026-07-14 09:28:25 +00:00
7 changed files with 510 additions and 19 deletions
Showing only changes of commit 3e090933fd - Show all commits
+7
View File
@@ -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 1050 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.
+237
View File
@@ -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.
@@ -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
};
}