diff --git a/agents-docs/LESSONS.md b/agents-docs/LESSONS.md index d0e62fb..9dfe6ae 100644 --- a/agents-docs/LESSONS.md +++ b/agents-docs/LESSONS.md @@ -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. diff --git a/e2e/tests/voice/dm-header-call-ring.spec.ts b/e2e/tests/voice/dm-header-call-ring.spec.ts new file mode 100644 index 0000000..6314429 --- /dev/null +++ b/e2e/tests/voice/dm-header-call-ring.spec.ts @@ -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 { + 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 { + 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 { + 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)}`; +} diff --git a/toju-app/src/app/domains/direct-call/README.md b/toju-app/src/app/domains/direct-call/README.md index 90df848..c86d828 100644 --- a/toju-app/src/app/domains/direct-call/README.md +++ b/toju-app/src/app/domains/direct-call/README.md @@ -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. diff --git a/toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts b/toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts index ea9ca6b..e3f4408 100644 --- a/toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts +++ b/toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts @@ -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() ] }); diff --git a/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts b/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts index 7c853d0..9c7b3dc 100644 --- a/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts +++ b/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts @@ -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 { + private async handleIncomingCallEvent(rawPayload: DirectCallEventPayload): Promise { 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 { + 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(); diff --git a/toju-app/src/app/domains/direct-call/domain/logic/direct-call-participant-identity.rules.spec.ts b/toju-app/src/app/domains/direct-call/domain/logic/direct-call-participant-identity.rules.spec.ts index 0589d6b..06d9222 100644 --- a/toju-app/src/app/domains/direct-call/domain/logic/direct-call-participant-identity.rules.spec.ts +++ b/toju-app/src/app/domains/direct-call/domain/logic/direct-call-participant-identity.rules.spec.ts @@ -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' + } + ] + }; +} diff --git a/toju-app/src/app/domains/direct-call/domain/logic/direct-call-participant-identity.rules.ts b/toju-app/src/app/domains/direct-call/domain/logic/direct-call-participant-identity.rules.ts index f8a05b9..0d356cc 100644 --- a/toju-app/src/app/domains/direct-call/domain/logic/direct-call-participant-identity.rules.ts +++ b/toju-app/src/app/domains/direct-call/domain/logic/direct-call-participant-identity.rules.ts @@ -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; @@ -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, + ids: ReadonlySet +): 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 +): DirectCallEventPayload { + const participantIds = [ + ...new Set(payload.participantIds.map((participantId) => + (selfIds.has(participantId) ? canonicalId : participantId))) + ]; + const seenParticipantIds = new Set(); + 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 + }; +}