feat(presence): live stream badges, honest ring delivery, cross-signal DMs
- Camera and screen-share badges are decided by `shouldShowStreamIndicator`, so a live stream is visible from outside the voice channel and clicking the badge joins the streamer's channel before focusing the stream. - `ringParticipants` reports whether a `direct-call` ring reached anyone; a call that reached nobody shows `call.errors.ringUndelivered` instead of sitting in "calling" as if it were live. - Direct messages resolve every local identity alias, so a conversation opened from a foreign roster entry lands in the same thread.
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* P4 coverage: one human must own one DM thread, and a call that reached
|
||||
* nobody must not look live.
|
||||
*
|
||||
* The fork this guards against: a peer on another signal server addresses the
|
||||
* local user by their provisioned actor id, so the inbound conversation id does
|
||||
* not match the id the local user builds from their home identity. Before the
|
||||
* canonicalization the recipient ended up with two threads for the same human -
|
||||
* one holding the peer's messages, one empty - and clicking the peer opened the
|
||||
* empty one.
|
||||
*/
|
||||
const USER_PASSWORD = 'TestPass123!';
|
||||
const PRIMARY_SIGNAL_ID = 'e2e-dm-identity-primary';
|
||||
const SECONDARY_SIGNAL_ID = 'e2e-dm-identity-secondary';
|
||||
|
||||
test.describe('Cross-signal direct message identity', () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test('keeps one DM thread when the peer addresses the local user by a provisioned actor id', async ({
|
||||
createClient,
|
||||
testServer
|
||||
}) => {
|
||||
const secondaryServer = await startTestServer();
|
||||
|
||||
try {
|
||||
const suffix = uniqueName('xsig-dm');
|
||||
const serverName = `Cross Signal DM ${suffix}`;
|
||||
const message = `cross signal hello ${suffix}`;
|
||||
const alice = await createClient();
|
||||
const bob = await createClient();
|
||||
const endpoints = [
|
||||
{
|
||||
id: PRIMARY_SIGNAL_ID,
|
||||
name: 'E2E DM Signal A',
|
||||
url: testServer.url,
|
||||
isActive: true,
|
||||
status: 'online'
|
||||
},
|
||||
{
|
||||
id: SECONDARY_SIGNAL_ID,
|
||||
name: 'E2E DM Signal B',
|
||||
url: secondaryServer.url,
|
||||
isActive: true,
|
||||
status: 'online'
|
||||
}
|
||||
];
|
||||
|
||||
await installTestServerEndpoints(alice.context, endpoints);
|
||||
await installTestServerEndpoints(bob.context, endpoints);
|
||||
|
||||
await test.step('Alice is home on signal A, Bob on signal B', async () => {
|
||||
await registerOn(alice.page, PRIMARY_SIGNAL_ID, `alice_${suffix}`, 'Alice');
|
||||
await registerOn(bob.page, SECONDARY_SIGNAL_ID, `bob_${suffix}`, 'Bob');
|
||||
});
|
||||
|
||||
await test.step('They meet in a room on signal A, so Bob acts through a provisioned identity', async () => {
|
||||
await new ServerSearchPage(alice.page).createServer(serverName, {
|
||||
description: 'Cross-signal DM identity coverage',
|
||||
sourceId: PRIMARY_SIGNAL_ID
|
||||
});
|
||||
|
||||
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
await new ChatMessagesPage(alice.page).waitForReady();
|
||||
|
||||
await new ServerSearchPage(bob.page).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 sends Bob a DM addressed to his provisioned actor id', async () => {
|
||||
await openDmFromRoomUserCard(alice.page, 'Bob');
|
||||
await alice.page.getByTestId('dm-input').fill(message);
|
||||
await alice.page.getByTestId('dm-input').press('Enter');
|
||||
|
||||
// Bob stores the thread before he ever opens the DM view, so the
|
||||
// inbound conversation id is the only id his device knows.
|
||||
await expect
|
||||
.poll(async () => await countStoredConversations(bob.page), { timeout: 30_000 })
|
||||
.toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await test.step('Bob opens Alice and finds one thread holding her message', async () => {
|
||||
await openDmFromRoomUserCard(bob.page, 'Alice');
|
||||
|
||||
await expect(bob.page.locator('app-dm-chat').getByText(message)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(bob.page.locator('app-dm-conversation-item')).toHaveCount(1, { timeout: 20_000 });
|
||||
expect(await countStoredConversations(bob.page)).toBe(1);
|
||||
});
|
||||
} finally {
|
||||
await secondaryServer.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('surfaces an undelivered ring instead of a call that looks live', async ({ createClient }) => {
|
||||
const suffix = uniqueName('undelivered-ring');
|
||||
const serverName = `Undelivered Ring ${suffix}`;
|
||||
const alice = await createClient();
|
||||
const bob = await createClient();
|
||||
|
||||
await test.step('Alice and Bob meet in a room', async () => {
|
||||
await registerOn(alice.page, null, `alice_${suffix}`, 'Alice');
|
||||
await registerOn(bob.page, null, `bob_${suffix}`, 'Bob');
|
||||
|
||||
await new ServerSearchPage(alice.page).createServer(serverName, {
|
||||
description: 'Undelivered call ring coverage'
|
||||
});
|
||||
|
||||
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
await new ChatMessagesPage(alice.page).waitForReady();
|
||||
|
||||
await new ServerSearchPage(bob.page).joinServerFromSearch(serverName);
|
||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
await new ChatMessagesPage(bob.page).waitForReady();
|
||||
});
|
||||
|
||||
await test.step('Alice calls Bob with no transport that can carry the ring', async () => {
|
||||
await openDmFromRoomUserCard(alice.page, 'Bob');
|
||||
await alice.page.evaluate(() => window.simulateOffline?.());
|
||||
|
||||
const callButton = alice.page.locator('app-dm-chat header').getByRole('button', { name: 'Call Bob' });
|
||||
|
||||
await expect(callButton).toBeEnabled({ timeout: 20_000 });
|
||||
await callButton.click();
|
||||
await expect(alice.page).toHaveURL(/\/call\//, { timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('The call view says the ring reached nobody', async () => {
|
||||
await expect(alice.page.getByTestId('private-call-error')).toContainText('Could not reach anyone', {
|
||||
timeout: 20_000
|
||||
});
|
||||
|
||||
await expect(bob.page.getByRole('dialog', { name: /is calling/ })).toBeHidden();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function registerOn(
|
||||
page: Page,
|
||||
signalServerId: string | null,
|
||||
username: string,
|
||||
displayName: string
|
||||
): Promise<void> {
|
||||
const registerPage = new RegisterPage(page);
|
||||
|
||||
await registerPage.goto();
|
||||
|
||||
if (signalServerId) {
|
||||
await registerPage.serverSelect.selectOption(signalServerId);
|
||||
}
|
||||
|
||||
await registerPage.register(username, displayName, USER_PASSWORD);
|
||||
await expect(page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function openDmFromRoomUserCard(page: Page, displayName: string): Promise<void> {
|
||||
const userCard = page.locator('[data-testid^="room-user-card-"]', { hasText: displayName }).first();
|
||||
|
||||
await expect(userCard).toBeVisible({ timeout: 20_000 });
|
||||
await userCard.getByRole('button', { name: `Message ${displayName}` }).click();
|
||||
await expect(page).toHaveURL(/\/dm\//, { timeout: 20_000 });
|
||||
await expect(page.getByRole('heading', { name: displayName })).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
/** Count stored DM threads for whichever user owns this browser profile. */
|
||||
async function countStoredConversations(page: Page): Promise<number> {
|
||||
return await page.evaluate(() => {
|
||||
const prefix = 'metoyou_direct_message_conversations:';
|
||||
|
||||
let total = 0;
|
||||
|
||||
for (let index = 0; index < localStorage.length; index++) {
|
||||
const key = localStorage.key(index);
|
||||
|
||||
if (!key?.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(key) ?? '[]') as unknown[];
|
||||
|
||||
total += Array.isArray(parsed) ? parsed.length : 0;
|
||||
} catch {
|
||||
// A half-written entry is not a thread.
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
});
|
||||
}
|
||||
|
||||
function uniqueName(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
||||
.slice(2, 8)}`;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
import { test, type Client } from '../../fixtures/multi-client';
|
||||
import { installDeterministicVoiceSettings } from '../../helpers/voice-session';
|
||||
import {
|
||||
dumpRtcDiagnostics,
|
||||
installAutoResumeAudioContext,
|
||||
installWebRTCTracking,
|
||||
waitForAudioStatsPresent,
|
||||
waitForConnectedPeerCount,
|
||||
waitForInboundVideoFlow,
|
||||
waitForOutboundVideoFlow
|
||||
} from '../../helpers/webrtc-helpers';
|
||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
|
||||
const USER_PASSWORD = 'TestPass123!';
|
||||
const VOICE_CHANNEL = 'General';
|
||||
|
||||
/**
|
||||
* Screen share is pull-based: the sharer only attaches tracks to peers that asked
|
||||
* for them. This covers the case the request model has to get right - a viewer who
|
||||
* arrives after the share already started.
|
||||
*/
|
||||
test.describe('Late joiner screen share', () => {
|
||||
test('a user who joins voice mid-share still receives the screen', async ({ createClient }) => {
|
||||
test.setTimeout(240_000);
|
||||
|
||||
const serverName = `Late Share ${Date.now()}`;
|
||||
const sharer = await createVoiceClient(createClient, 'sharer');
|
||||
const viewer = await createVoiceClient(createClient, 'viewer');
|
||||
|
||||
await test.step('Both users register and join the server', async () => {
|
||||
await new ServerSearchPage(sharer.page).createServer(serverName, {
|
||||
description: 'Late joiner screen share test'
|
||||
});
|
||||
|
||||
await expect(sharer.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
|
||||
await new ServerSearchPage(viewer.page).joinServerFromSearch(serverName);
|
||||
await expect(viewer.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('The sharer starts sharing while alone in voice', async () => {
|
||||
const room = new ChatRoomPage(sharer.page);
|
||||
|
||||
await room.ensureVoiceChannelExists(VOICE_CHANNEL);
|
||||
await room.joinVoiceChannel(VOICE_CHANNEL);
|
||||
await expect(room.voiceControls).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await openVoiceWorkspace(sharer.page);
|
||||
await room.startScreenShare();
|
||||
await expect(room.isScreenShareActive).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
await test.step('The viewer joins voice after the share is already running', async () => {
|
||||
const room = new ChatRoomPage(viewer.page);
|
||||
|
||||
await room.joinVoiceChannel(VOICE_CHANNEL);
|
||||
await expect(room.voiceControls).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await waitForConnectedPeerCount(viewer.page, 1, 90_000);
|
||||
await waitForConnectedPeerCount(sharer.page, 1, 90_000);
|
||||
await waitForAudioStatsPresent(viewer.page, 30_000);
|
||||
|
||||
await openVoiceWorkspace(viewer.page);
|
||||
});
|
||||
|
||||
await test.step('The in-progress screen reaches the late joiner', async () => {
|
||||
try {
|
||||
const outbound = await waitForOutboundVideoFlow(sharer.page, 60_000);
|
||||
const inbound = await waitForInboundVideoFlow(viewer.page, 60_000);
|
||||
|
||||
expect(
|
||||
outbound.outboundBytesDelta > 0 || outbound.outboundPacketsDelta > 0,
|
||||
'The sharer never sent screen video to the late joiner'
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
inbound.inboundBytesDelta > 0 || inbound.inboundPacketsDelta > 0,
|
||||
'The late joiner never received the in-progress screen share'
|
||||
).toBe(true);
|
||||
} catch (error) {
|
||||
console.log(`[sharer RTC]\n${await dumpRtcDiagnostics(sharer.page)}`);
|
||||
console.log(`[viewer RTC]\n${await dumpRtcDiagnostics(viewer.page)}`);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('The late joiner renders a remote screen tile', async () => {
|
||||
await expect(viewer.page.locator('app-voice-workspace-stream-tile').first())
|
||||
.toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** Expand the voice workspace, which is what turns on remote screen-share requests. */
|
||||
async function openVoiceWorkspace(page: Page): Promise<void> {
|
||||
const viewButton = page.locator('app-rooms-side-panel')
|
||||
.getByRole('button', { name: /view/i })
|
||||
.first();
|
||||
|
||||
await expect(viewButton).toBeVisible({ timeout: 20_000 });
|
||||
await viewButton.click();
|
||||
await expect(page.locator('app-voice-workspace')).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function createVoiceClient(
|
||||
createClient: () => Promise<Client>,
|
||||
role: string
|
||||
): Promise<Client> {
|
||||
const client = await createClient();
|
||||
|
||||
await installDeterministicVoiceSettings(client.page);
|
||||
await installWebRTCTracking(client.page);
|
||||
await installAutoResumeAudioContext(client.page);
|
||||
|
||||
const registerPage = new RegisterPage(client.page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(
|
||||
`late_share_${role}_${Date.now()}`,
|
||||
`Late Share ${role}`,
|
||||
USER_PASSWORD
|
||||
);
|
||||
|
||||
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
||||
|
||||
return client;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { test, type Client } from '../../fixtures/multi-client';
|
||||
import { installDeterministicVoiceSettings } from '../../helpers/voice-session';
|
||||
import { installAutoResumeAudioContext, installWebRTCTracking } from '../../helpers/webrtc-helpers';
|
||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
|
||||
const USER_PASSWORD = 'TestPass123!';
|
||||
const VOICE_CHANNEL = 'General';
|
||||
|
||||
/**
|
||||
* A user sharing alone in a voice channel used to look idle to everyone else,
|
||||
* because the LIVE badge was gated on the observer's own voice connection. The
|
||||
* observer here never joins voice, so the badge can only appear if sharing
|
||||
* presence reaches a non-participant.
|
||||
*/
|
||||
test.describe('Screen share visibility from outside the channel', () => {
|
||||
test('a user who is not in voice sees the LIVE badge of someone sharing alone', async ({ createClient }) => {
|
||||
test.setTimeout(240_000);
|
||||
|
||||
const serverName = `Outside Share ${Date.now()}`;
|
||||
const sharer = await createVoiceClient(createClient, 'sharer');
|
||||
const observer = await createVoiceClient(createClient, 'observer');
|
||||
const sharerRoom = new ChatRoomPage(sharer.page);
|
||||
const observerRoom = new ChatRoomPage(observer.page);
|
||||
|
||||
await test.step('Both users register and join the server', async () => {
|
||||
await new ServerSearchPage(sharer.page).createServer(serverName, {
|
||||
description: 'Live badge visibility test'
|
||||
});
|
||||
|
||||
await expect(sharer.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
|
||||
await new ServerSearchPage(observer.page).joinServerFromSearch(serverName);
|
||||
await expect(observer.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('The sharer shares while alone in the voice channel', async () => {
|
||||
await sharerRoom.ensureVoiceChannelExists(VOICE_CHANNEL);
|
||||
await sharerRoom.joinVoiceChannel(VOICE_CHANNEL);
|
||||
await expect(sharerRoom.voiceControls).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await sharerRoom.startScreenShare();
|
||||
await expect(sharerRoom.isScreenShareActive).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
await test.step('The observer stays out of voice and still sees the badge', async () => {
|
||||
await expect(observerRoom.channelsSidePanel).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Proves the observer never joined: the disconnect control only shows in voice.
|
||||
await expect(observerRoom.disconnectButton).toBeHidden();
|
||||
|
||||
await expect(liveBadge(observerRoom))
|
||||
.toBeVisible({ timeout: 60_000 });
|
||||
});
|
||||
|
||||
await test.step('The badge disappears when the share stops', async () => {
|
||||
await sharerRoom.stopScreenShare();
|
||||
|
||||
await expect(liveBadge(observerRoom))
|
||||
.toBeHidden({ timeout: 60_000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function liveBadge(room: ChatRoomPage) {
|
||||
return room.channelsSidePanel
|
||||
.locator('[data-testid="voice-user-live"]')
|
||||
.first();
|
||||
}
|
||||
|
||||
async function createVoiceClient(
|
||||
createClient: () => Promise<Client>,
|
||||
role: string
|
||||
): Promise<Client> {
|
||||
const client = await createClient();
|
||||
|
||||
await installDeterministicVoiceSettings(client.page);
|
||||
await installWebRTCTracking(client.page);
|
||||
await installAutoResumeAudioContext(client.page);
|
||||
|
||||
const registerPage = new RegisterPage(client.page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(
|
||||
`outside_share_${role}_${Date.now()}`,
|
||||
`Outside Share ${role}`,
|
||||
USER_PASSWORD
|
||||
);
|
||||
|
||||
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
||||
|
||||
return client;
|
||||
}
|
||||
@@ -49,7 +49,8 @@
|
||||
"microphonePermissionDenied": "Microphone access is blocked. Allow the microphone permission in system settings to join calls.",
|
||||
"microphoneUnavailable": "Could not start the microphone. Close other apps that use it and try again.",
|
||||
"cameraPermissionDenied": "Camera access is blocked. Allow the camera permission in system settings to share video.",
|
||||
"cameraUnavailable": "Could not start the camera. Close other apps that use it and try again."
|
||||
"cameraUnavailable": "Could not start the camera. Close other apps that use it and try again.",
|
||||
"ringUndelivered": "Could not reach anyone in this call. They may be offline or on another server."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ Direct calls coordinate private voice sessions started from people cards, direct
|
||||
## Flow
|
||||
|
||||
1. `DirectCallService.startCall()` creates or reuses the direct-message conversation for a peer, while `startConversationCall()` starts from an existing one-to-one or group conversation. Both paths reuse a live call for the same peer or group before creating a new session.
|
||||
2. The caller joins a call-scoped voice session and sends a `direct-call` ring event through `PeerDeliveryService`. Joining a direct call first leaves any other joined call or server voice channel.
|
||||
2. The caller rings first, then joins a call-scoped voice session; joining a direct call first leaves any other joined call or server voice channel. `ringParticipants` reports the result of every `direct-call` ring sent through `PeerDeliveryService`: when no recipient could be reached over a peer data channel or a signaling route, `deliveryError` is set (`call.errors.ringUndelivered`) and the private-call view shows it next to join errors. A call that reached nobody must not sit in "calling" as if it were live.
|
||||
3. The caller and recipient both record a direct-message `call-started` system entry for the call's conversation, so the chat history shows who started the call without creating a normal text message.
|
||||
4. The recipient stores the incoming session, loops `assets/audio/call.wav`, shows an in-app answer/decline modal, and shows a desktop notification when permission allows. If the recipient is set to Do Not Disturb (`status: "busy"`), the session is stored silently without call audio, the in-app modal, or a desktop notification. Ring events received before the current user identity is hydrated are queued and replayed once identity is available. The ring stops when the recipient joins, declines, leaves, or the call ends; stale duplicate ring events for a locally ended call are ignored.
|
||||
5. Opening `/call/:callId` shows the private call surface with portraits, voice indicators, media controls (mute, deafen, camera, screen share), screen/camera tiles, add-user control, and a narrow DM chat panel. Deafen mutes incoming audio and also mutes the local mic, matching voice-channel behavior.
|
||||
|
||||
@@ -527,6 +527,47 @@ describe('DirectCallService', () => {
|
||||
expect(context.service.sessionById(session.callId)?.participants.alice.joined).toBe(true);
|
||||
});
|
||||
|
||||
it('rings the peer before joining local voice', async () => {
|
||||
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
|
||||
const joinCall = vi.fn(async () => undefined);
|
||||
|
||||
context.service.joinCall = joinCall;
|
||||
await context.service.startCall(bob);
|
||||
|
||||
expect(context.delivery.sendCallEvent).toHaveBeenCalled();
|
||||
expect(context.delivery.sendCallEvent.mock.invocationCallOrder[0])
|
||||
.toBeLessThan(joinCall.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('surfaces an undelivered ring instead of looking like a live call', async () => {
|
||||
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
|
||||
|
||||
context.service.joinCall = vi.fn(async () => undefined);
|
||||
context.delivery.sendCallEvent.mockReturnValue(false);
|
||||
|
||||
await context.service.startCall(bob);
|
||||
|
||||
expect(context.service.deliveryError()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('clears the delivery error once a ring reaches the peer', async () => {
|
||||
const context = createServiceContext({ currentUser: alice, allUsers: [
|
||||
alice,
|
||||
bob,
|
||||
charlie
|
||||
] });
|
||||
|
||||
context.service.joinCall = vi.fn(async () => undefined);
|
||||
context.delivery.sendCallEvent.mockReturnValueOnce(false);
|
||||
|
||||
await context.service.startCall(bob);
|
||||
expect(context.service.deliveryError()).not.toBeNull();
|
||||
|
||||
await context.service.startCall(charlie);
|
||||
|
||||
expect(context.service.deliveryError()).toBeNull();
|
||||
});
|
||||
|
||||
it('starts group calls by keeping the rail-visible call session and ringing every other participant', async () => {
|
||||
const context = createServiceContext({ currentUser: alice, allUsers: [
|
||||
alice,
|
||||
|
||||
@@ -21,7 +21,14 @@ import {
|
||||
VoiceConnectionFacade,
|
||||
VoicePlaybackService
|
||||
} from '../../../voice-connection';
|
||||
import { VoiceSessionFacade, isVoiceOnAnotherClient } from '../../../voice-session';
|
||||
import {
|
||||
SYSTEM_DEFAULT_AUDIO_DEVICE_ID,
|
||||
VoiceSessionFacade,
|
||||
buildMicrophoneConstraints,
|
||||
isDeviceUnavailableError,
|
||||
isVoiceOnAnotherClient,
|
||||
loadVoiceSettingsFromStorage
|
||||
} 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';
|
||||
@@ -93,6 +100,8 @@ export class DirectCallService {
|
||||
readonly currentSession = signal<DirectCallSession | null>(null);
|
||||
/** User-facing reason the last joinCall attempt failed; null after a successful join. */
|
||||
readonly joinError = signal<string | null>(null);
|
||||
/** User-facing reason the last outgoing ring reached nobody; null once a ring is delivered. */
|
||||
readonly deliveryError = signal<string | null>(null);
|
||||
readonly hasActiveCall = computed(() => this.visibleActiveSessions().length > 0);
|
||||
readonly mobileOverlaySession = computed(() => {
|
||||
const callId = this.mobileOverlayCallId();
|
||||
@@ -230,8 +239,8 @@ export class DirectCallService {
|
||||
session.createdAt
|
||||
);
|
||||
|
||||
this.ringParticipants(session, [peerParticipant.userId]);
|
||||
await this.joinCall(session.callId, false);
|
||||
this.sendCallEvent(peerParticipant.userId, 'ring', session);
|
||||
await this.openCallView(session.callId);
|
||||
return session;
|
||||
}
|
||||
@@ -367,12 +376,7 @@ export class DirectCallService {
|
||||
let stream: MediaStream;
|
||||
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: false
|
||||
}
|
||||
});
|
||||
stream = await this.captureCallMicrophone();
|
||||
} catch {
|
||||
this.joinError.set(this.i18n.instant('call.errors.microphoneUnavailable'));
|
||||
return;
|
||||
@@ -419,6 +423,38 @@ export class DirectCallService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the mic for a call, honouring the saved device.
|
||||
*
|
||||
* The device is requested exactly, so a saved id that is gone or already
|
||||
* claimed rejects instead of quietly opening a different mic. That must not
|
||||
* cost the user the call, so retry once on the system default.
|
||||
*/
|
||||
private async captureCallMicrophone(): Promise<MediaStream> {
|
||||
const voiceSettings = loadVoiceSettingsFromStorage();
|
||||
const browserNoiseSuppression = !voiceSettings.noiseReduction;
|
||||
|
||||
try {
|
||||
return await navigator.mediaDevices.getUserMedia(
|
||||
buildMicrophoneConstraints({
|
||||
browserNoiseSuppression,
|
||||
deviceId: voiceSettings.inputDevice
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
if (!voiceSettings.inputDevice || !isDeviceUnavailableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return await navigator.mediaDevices.getUserMedia(
|
||||
buildMicrophoneConstraints({
|
||||
browserNoiseSuppression,
|
||||
deviceId: SYSTEM_DEFAULT_AUDIO_DEVICE_ID
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private leaveJoinedSession(session: DirectCallSession, endForEveryone = false): void {
|
||||
const action = endForEveryone ? 'end' : 'leave';
|
||||
const nextSession = this.markCurrentUserLeft(session, endForEveryone);
|
||||
@@ -451,7 +487,7 @@ export class DirectCallService {
|
||||
this.upsertSession(convertedSession);
|
||||
this.currentSession.set(convertedSession);
|
||||
this.broadcastCallEvent('update', convertedSession, [participant.userId]);
|
||||
this.sendCallEvent(participant.userId, 'ring', convertedSession);
|
||||
this.ringParticipants(convertedSession, [participant.userId]);
|
||||
}
|
||||
|
||||
remoteParticipantIds(session: DirectCallSession): string[] {
|
||||
@@ -611,8 +647,8 @@ export class DirectCallService {
|
||||
|
||||
this.upsertSession(session);
|
||||
this.currentSession.set(session);
|
||||
this.ringParticipants(session, this.remoteParticipantIds(session));
|
||||
await this.joinCall(session.callId, false);
|
||||
this.broadcastCallEvent('ring', this.sessionById(session.callId) ?? session);
|
||||
await this.router.navigate(['/call', session.callId]);
|
||||
return this.sessionById(session.callId) ?? session;
|
||||
}
|
||||
@@ -756,10 +792,25 @@ export class DirectCallService {
|
||||
return session;
|
||||
}
|
||||
|
||||
private sendCallEvent(recipientId: string, action: DirectCallEventPayload['action'], session: DirectCallSession): void {
|
||||
/**
|
||||
* Ring every recipient and report the outcome. A call whose ring reached
|
||||
* nobody must not look live, so an undelivered ring surfaces as an error
|
||||
* instead of a session that waits forever.
|
||||
*/
|
||||
private ringParticipants(session: DirectCallSession, recipientIds: readonly string[]): void {
|
||||
const delivered = recipientIds
|
||||
.map((recipientId) => this.sendCallEvent(recipientId, 'ring', session))
|
||||
.filter((wasDelivered) => wasDelivered);
|
||||
|
||||
this.deliveryError.set(delivered.length > 0
|
||||
? null
|
||||
: this.i18n.instant('call.errors.ringUndelivered'));
|
||||
}
|
||||
|
||||
private sendCallEvent(recipientId: string, action: DirectCallEventPayload['action'], session: DirectCallSession): boolean {
|
||||
const me = this.requireCurrentUser();
|
||||
|
||||
this.delivery.sendCallEvent(recipientId, {
|
||||
return this.delivery.sendCallEvent(recipientId, {
|
||||
type: 'direct-call',
|
||||
directCall: {
|
||||
action,
|
||||
|
||||
@@ -32,6 +32,14 @@ Unread counts are idempotent by message id: re-receiving or syncing a message th
|
||||
|
||||
Incoming PM and group-chat events are ignored unless the current user is declared in the message recipients, participant profiles, or existing local conversation. Sync requests are only answered for conversation participants, so a stray peer route cannot create unread state or expose private history.
|
||||
|
||||
## One human, one thread
|
||||
|
||||
A one-to-one conversation id is derived from participant ids, so an id must never be built from an actor alias. `direct-message-identity.rules.ts` owns the canonicalization: `buildDirectParticipantAliasIndex` maps every alias of a human (home id, entity id, peer id, and each provisioned signal-server actor id from `SignalServerCredentialStoreService`) to the id their threads are stored under, and `getCanonicalDirectConversationId` / `canonicalizeDirectConversationId` resolve outbound and inbound ids through it. Group ids and any id that is not a plain participant pair pass through untouched.
|
||||
|
||||
A peer who met the local user on a foreign signal server addresses them by the provisioned actor id, so the inbound `conversationId` differs from the one the local user builds from their home identity. `DirectMessageService.collapseAliasConversations` therefore merges every stored thread that resolves to the same canonical id on first touch (create, inbound message, inbound sync, call-started record) with `mergeAliasDirectConversations`, deletes the alias copies, and re-points the current selection. Message `senderId`, `recipientId`, and `recipientIds` are canonicalized as well, so acknowledgements and read receipts route to one identity. Without this the recipient kept two threads for one human — one holding the peer's messages, one empty — and clicking the peer opened the empty one (`e2e/tests/chat/cross-signal-dm-identity.spec.ts`).
|
||||
|
||||
The index can only collapse identities the client knows: all of the local user's own aliases, plus the alias ids carried on stored user entities. Two roster entries for one remote human on different signal servers still read as two people.
|
||||
|
||||
Status transitions are monotonic, so a stale `SENT` event cannot overwrite `DELIVERED` or `ACKNOWLEDGED`.
|
||||
|
||||
## Chat View
|
||||
|
||||
+158
-39
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
@@ -24,12 +24,20 @@ import {
|
||||
directMessageConversationIncludesUser,
|
||||
directMessageEventIncludesUser,
|
||||
directMessageSyncIncludesUser,
|
||||
getDirectConversationId,
|
||||
isGroupDirectConversation,
|
||||
updateMessageStatusInConversation,
|
||||
upsertDirectMessage
|
||||
} from '../../domain/logic/direct-message.logic';
|
||||
import { collectDirectMessageSelfUserIds, isSelfDirectMessageSender } from '../../domain/logic/direct-message-identity.rules';
|
||||
import {
|
||||
buildDirectParticipantAliasIndex,
|
||||
canonicalizeDirectConversationId,
|
||||
canonicalizeDirectParticipantId,
|
||||
collectDirectMessageSelfUserIds,
|
||||
getCanonicalDirectConversationId,
|
||||
isSelfDirectMessageSender,
|
||||
mergeAliasDirectConversations,
|
||||
type DirectParticipantAliasIndex
|
||||
} from '../../domain/logic/direct-message-identity.rules';
|
||||
import {
|
||||
DirectMessage,
|
||||
DirectMessageConversation,
|
||||
@@ -48,7 +56,7 @@ import type {
|
||||
Reaction,
|
||||
User
|
||||
} from '../../../../shared-kernel';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
|
||||
const DIRECT_MESSAGE_SYNC_LIMIT = 1000;
|
||||
const DIRECT_MESSAGE_SYNC_REQUEST_COOLDOWN_MS = 5000;
|
||||
@@ -75,6 +83,7 @@ export class DirectMessageService {
|
||||
private readonly router = inject(Router);
|
||||
private readonly notifications = inject(NotificationsFacade);
|
||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
private readonly users = this.store.selectSignal(selectAllUsers);
|
||||
private readonly conversationsSignal = signal<DirectMessageConversation[]>([]);
|
||||
private readonly selectedConversationIdSignal = signal<string | null>(null);
|
||||
private readonly typingEntriesSignal = signal<DirectMessageTypingEntry[]>([]);
|
||||
@@ -128,10 +137,11 @@ export class DirectMessageService {
|
||||
|
||||
await this.loadForOwner(ownerId);
|
||||
|
||||
const currentParticipant = toDirectMessageParticipant(currentUser);
|
||||
const peerParticipant = toDirectMessageParticipant(user);
|
||||
const conversationId = getDirectConversationId(currentParticipant.userId, peerParticipant.userId);
|
||||
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId);
|
||||
const aliasIndex = this.participantAliasIndex();
|
||||
const currentParticipant = this.canonicalParticipant(aliasIndex, toDirectMessageParticipant(currentUser));
|
||||
const peerParticipant = this.canonicalParticipant(aliasIndex, toDirectMessageParticipant(user));
|
||||
const conversationId = getCanonicalDirectConversationId(aliasIndex, currentParticipant.userId, peerParticipant.userId);
|
||||
const existingConversation = await this.collapseAliasConversations(ownerId, conversationId);
|
||||
|
||||
if (existingConversation) {
|
||||
this.selectedConversationIdSignal.set(existingConversation.id);
|
||||
@@ -258,30 +268,32 @@ export class DirectMessageService {
|
||||
}
|
||||
|
||||
async recordCallStarted(
|
||||
conversationId: string,
|
||||
rawConversationId: string,
|
||||
caller: DirectMessageParticipant,
|
||||
participants: DirectMessageParticipant[],
|
||||
timestamp = Date.now()
|
||||
): Promise<void> {
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
const currentUser = this.requireCurrentUser();
|
||||
const currentParticipant = toDirectMessageParticipant(currentUser);
|
||||
const aliasIndex = this.participantAliasIndex();
|
||||
const conversationId = canonicalizeDirectConversationId(aliasIndex, rawConversationId);
|
||||
const currentParticipant = this.canonicalParticipant(aliasIndex, toDirectMessageParticipant(currentUser));
|
||||
const canonicalCaller = this.canonicalParticipant(aliasIndex, caller);
|
||||
const allParticipants = this.uniqueParticipants([
|
||||
currentParticipant,
|
||||
caller,
|
||||
...participants
|
||||
canonicalCaller,
|
||||
...participants.map((participant) => this.canonicalParticipant(aliasIndex, participant))
|
||||
]);
|
||||
|
||||
await this.loadForOwner(ownerId);
|
||||
|
||||
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId)
|
||||
?? await this.repository.getConversation(ownerId, conversationId)
|
||||
?? this.createConversationForSystemEvent(conversationId, currentParticipant, caller, allParticipants, timestamp);
|
||||
const existingConversation = await this.collapseAliasConversations(ownerId, conversationId)
|
||||
?? this.createConversationForSystemEvent(conversationId, currentParticipant, canonicalCaller, allParticipants, timestamp);
|
||||
const conversation = this.mergeConversationParticipants(existingConversation, allParticipants);
|
||||
const message = createDirectCallStartedMessage(
|
||||
conversation.id,
|
||||
caller,
|
||||
conversation.participants.filter((participantId) => participantId !== caller.userId),
|
||||
canonicalCaller,
|
||||
conversation.participants.filter((participantId) => participantId !== canonicalCaller.userId),
|
||||
timestamp
|
||||
);
|
||||
|
||||
@@ -517,23 +529,32 @@ export class DirectMessageService {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentParticipant = toDirectMessageParticipant(currentUser);
|
||||
const sender = payload.sender;
|
||||
const aliasIndex = this.participantAliasIndex();
|
||||
const currentParticipant = this.canonicalParticipant(aliasIndex, toDirectMessageParticipant(currentUser));
|
||||
const sender = this.canonicalParticipant(aliasIndex, payload.sender);
|
||||
const conversationId = payload.message.conversationId
|
||||
|| getDirectConversationId(currentParticipant.userId, sender.userId);
|
||||
? canonicalizeDirectConversationId(aliasIndex, payload.message.conversationId)
|
||||
: getCanonicalDirectConversationId(aliasIndex, currentParticipant.userId, sender.userId);
|
||||
const participants = this.uniqueParticipants([
|
||||
currentParticipant,
|
||||
sender,
|
||||
...(payload.participants ?? [])
|
||||
...(payload.participants ?? []).map((participant) => this.canonicalParticipant(aliasIndex, participant))
|
||||
]);
|
||||
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId)
|
||||
const existingConversation = await this.collapseAliasConversations(ownerId, conversationId)
|
||||
?? (payload.conversationKind === 'group' || participants.length > 2
|
||||
? createGroupConversation(conversationId, participants, payload.message.timestamp, payload.conversationTitle)
|
||||
: createDirectConversation(currentParticipant, sender, payload.message.timestamp));
|
||||
: {
|
||||
...createDirectConversation(currentParticipant, sender, payload.message.timestamp),
|
||||
id: conversationId
|
||||
});
|
||||
const conversationWithParticipants = this.mergeConversationParticipants(existingConversation, participants);
|
||||
const incomingMessage: DirectMessage = {
|
||||
...payload.message,
|
||||
conversationId,
|
||||
senderId: canonicalizeDirectParticipantId(aliasIndex, payload.message.senderId),
|
||||
recipientId: canonicalizeDirectParticipantId(aliasIndex, payload.message.recipientId),
|
||||
recipientIds: payload.message.recipientIds?.map((recipientId) =>
|
||||
canonicalizeDirectParticipantId(aliasIndex, recipientId)),
|
||||
status: advanceDirectMessageStatus(payload.message.status, 'DELIVERED')
|
||||
};
|
||||
const shouldIncrementUnread = !this.isConversationVisible(conversationId);
|
||||
@@ -590,7 +611,10 @@ export class DirectMessageService {
|
||||
|
||||
private async handleIncomingMutation(payload: DirectMessageMutationEventPayload): Promise<void> {
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
const conversation = await this.findConversation(ownerId, payload.conversationId);
|
||||
const conversation = await this.findConversation(
|
||||
ownerId,
|
||||
canonicalizeDirectConversationId(this.participantAliasIndex(), payload.conversationId)
|
||||
);
|
||||
const selfUserIds = this.getSelfUserIds();
|
||||
|
||||
if (!conversation || !directMessageConversationIncludesUser(conversation, selfUserIds)) {
|
||||
@@ -607,25 +631,28 @@ export class DirectMessageService {
|
||||
return;
|
||||
}
|
||||
|
||||
const conversation = this.conversationsSignal().find((entry) => entry.id === payload.conversationId);
|
||||
const aliasIndex = this.participantAliasIndex();
|
||||
const conversationId = canonicalizeDirectConversationId(aliasIndex, payload.conversationId);
|
||||
const senderId = canonicalizeDirectParticipantId(aliasIndex, payload.sender.userId);
|
||||
const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId);
|
||||
|
||||
if (!conversation
|
||||
|| !directMessageConversationIncludesUser(conversation, selfUserIds)
|
||||
|| !directMessageConversationIncludesUser(conversation, payload.sender.userId)) {
|
||||
|| !directMessageConversationIncludesUser(conversation, senderId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload.isTyping) {
|
||||
this.typingEntriesSignal.update((entries) => entries.filter((entry) =>
|
||||
!(entry.conversationId === payload.conversationId && entry.userId === payload.sender.userId)
|
||||
!(entry.conversationId === conversationId && entry.userId === senderId)
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const nextEntry: DirectMessageTypingEntry = {
|
||||
conversationId: payload.conversationId,
|
||||
userId: payload.sender.userId,
|
||||
conversationId,
|
||||
userId: senderId,
|
||||
displayName: payload.sender.displayName,
|
||||
expiresAt: Date.now() + DIRECT_MESSAGE_TYPING_TTL_MS
|
||||
};
|
||||
@@ -641,7 +668,10 @@ export class DirectMessageService {
|
||||
private async handleIncomingSyncRequest(payload: DirectMessageSyncRequestEventPayload): Promise<void> {
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
const currentUser = this.requireCurrentUser();
|
||||
const conversation = await this.findConversation(ownerId, payload.conversationId);
|
||||
const conversation = await this.findConversation(
|
||||
ownerId,
|
||||
canonicalizeDirectConversationId(this.participantAliasIndex(), payload.conversationId)
|
||||
);
|
||||
const selfUserIds = this.getSelfUserIds();
|
||||
|
||||
if (!conversation
|
||||
@@ -668,7 +698,9 @@ export class DirectMessageService {
|
||||
private async handleIncomingSync(payload: DirectMessageSyncEventPayload): Promise<void> {
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
const currentUser = this.requireCurrentUser();
|
||||
const currentParticipant = toDirectMessageParticipant(currentUser);
|
||||
const aliasIndex = this.participantAliasIndex();
|
||||
const currentParticipant = this.canonicalParticipant(aliasIndex, toDirectMessageParticipant(currentUser));
|
||||
const sender = this.canonicalParticipant(aliasIndex, payload.sender);
|
||||
const selfUserIds = this.getSelfUserIds();
|
||||
|
||||
if (selfUserIds.has(payload.sender.userId)) {
|
||||
@@ -679,14 +711,18 @@ export class DirectMessageService {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === payload.conversationId)
|
||||
?? await this.repository.getConversation(ownerId, payload.conversationId)
|
||||
const conversationId = canonicalizeDirectConversationId(aliasIndex, payload.conversationId);
|
||||
const syncParticipants = payload.participants.map((participant) => this.canonicalParticipant(aliasIndex, participant));
|
||||
const existingConversation = await this.collapseAliasConversations(ownerId, conversationId)
|
||||
?? (payload.conversationKind === 'group' || payload.participants.length > 2
|
||||
? createGroupConversation(payload.conversationId, [currentParticipant, ...payload.participants], payload.syncedAt, payload.conversationTitle)
|
||||
: createDirectConversation(currentParticipant, payload.sender, payload.syncedAt));
|
||||
? createGroupConversation(conversationId, [currentParticipant, ...syncParticipants], payload.syncedAt, payload.conversationTitle)
|
||||
: {
|
||||
...createDirectConversation(currentParticipant, sender, payload.syncedAt),
|
||||
id: conversationId
|
||||
});
|
||||
const participantProfiles = {
|
||||
...existingConversation.participantProfiles,
|
||||
...Object.fromEntries(payload.participants.map((participant) => [participant.userId, participant])),
|
||||
...Object.fromEntries(syncParticipants.map((participant) => [participant.userId, participant])),
|
||||
[currentParticipant.userId]: currentParticipant
|
||||
};
|
||||
const syncBaseConversation: DirectMessageConversation = {
|
||||
@@ -697,14 +733,20 @@ export class DirectMessageService {
|
||||
participantProfiles
|
||||
};
|
||||
const mergedConversation = payload.messages.reduce<DirectMessageConversation>(
|
||||
(conversation, message) => upsertDirectMessage(conversation, message, false),
|
||||
(conversation, message) => upsertDirectMessage(conversation, {
|
||||
...message,
|
||||
conversationId,
|
||||
senderId: canonicalizeDirectParticipantId(aliasIndex, message.senderId),
|
||||
recipientId: canonicalizeDirectParticipantId(aliasIndex, message.recipientId),
|
||||
recipientIds: message.recipientIds?.map((recipientId) => canonicalizeDirectParticipantId(aliasIndex, recipientId))
|
||||
}, false),
|
||||
syncBaseConversation
|
||||
);
|
||||
|
||||
await this.persistConversation(ownerId, mergedConversation);
|
||||
|
||||
if (this.selectedConversationIdSignal() === payload.conversationId) {
|
||||
await this.markRead(payload.conversationId);
|
||||
if (this.selectedConversationIdSignal() === conversationId) {
|
||||
await this.markRead(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -912,6 +954,83 @@ export class DirectMessageService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One human can be addressed by several ids: their home id and one
|
||||
* provisioned actor id per foreign signal server. Threads are stored under
|
||||
* the home id, so every inbound id is resolved through this index first.
|
||||
*/
|
||||
private participantAliasIndex(): DirectParticipantAliasIndex {
|
||||
const currentUser = this.currentUser();
|
||||
const peerGroups = this.users().map((user) => ({
|
||||
canonicalId: user.oderId || user.id,
|
||||
aliasIds: [
|
||||
user.id,
|
||||
user.oderId,
|
||||
user.peerId
|
||||
].filter((aliasId): aliasId is string => !!aliasId)
|
||||
}));
|
||||
const selfGroups = currentUser
|
||||
? [
|
||||
{
|
||||
canonicalId: currentUser.oderId || currentUser.id,
|
||||
aliasIds: [...this.getSelfUserIds()]
|
||||
}
|
||||
]
|
||||
: [];
|
||||
|
||||
return buildDirectParticipantAliasIndex([...peerGroups, ...selfGroups]);
|
||||
}
|
||||
|
||||
private canonicalParticipant(
|
||||
aliasIndex: DirectParticipantAliasIndex,
|
||||
participant: DirectMessageParticipant
|
||||
): DirectMessageParticipant {
|
||||
const canonicalId = canonicalizeDirectParticipantId(aliasIndex, participant.userId);
|
||||
|
||||
return canonicalId === participant.userId ? participant : { ...participant, userId: canonicalId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold any thread that resolves to `canonicalId` into a single stored
|
||||
* conversation. Returns null when this human pair has no thread yet.
|
||||
*/
|
||||
private async collapseAliasConversations(
|
||||
ownerId: string,
|
||||
canonicalId: string
|
||||
): Promise<DirectMessageConversation | null> {
|
||||
await this.loadForOwner(ownerId);
|
||||
|
||||
const aliasIndex = this.participantAliasIndex();
|
||||
const matching = this.conversationsSignal().filter((conversation) =>
|
||||
canonicalizeDirectConversationId(aliasIndex, conversation.id) === canonicalId);
|
||||
|
||||
if (matching.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (matching.length === 1 && matching[0].id === canonicalId) {
|
||||
return matching[0];
|
||||
}
|
||||
|
||||
const merged = mergeAliasDirectConversations(aliasIndex, matching);
|
||||
const staleIds = matching.map((conversation) => conversation.id).filter((id) => id !== merged.id);
|
||||
|
||||
await this.persistConversation(ownerId, merged);
|
||||
|
||||
for (const staleId of staleIds) {
|
||||
await this.repository.deleteConversation(ownerId, staleId);
|
||||
}
|
||||
|
||||
this.conversationsSignal.update((conversations) => conversations.filter((conversation) =>
|
||||
!staleIds.includes(conversation.id)));
|
||||
|
||||
if (staleIds.includes(this.selectedConversationIdSignal() ?? '')) {
|
||||
this.selectedConversationIdSignal.set(merged.id);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private mergeConversationParticipants(
|
||||
conversation: DirectMessageConversation,
|
||||
participants: DirectMessageParticipant[]
|
||||
|
||||
+111
-2
@@ -1,10 +1,21 @@
|
||||
import {
|
||||
buildDirectParticipantAliasIndex,
|
||||
canonicalizeDirectConversation,
|
||||
canonicalizeDirectConversationId,
|
||||
canonicalizeDirectParticipantId,
|
||||
collectDirectMessageSelfUserIds,
|
||||
directMessageConversationIncludesAnyUser,
|
||||
directMessageEventIncludesAnyUser,
|
||||
isSelfDirectMessageSender
|
||||
getCanonicalDirectConversationId,
|
||||
isSelfDirectMessageSender,
|
||||
mergeAliasDirectConversations
|
||||
} from './direct-message-identity.rules';
|
||||
import type { DirectMessageConversation, DirectMessageParticipant } from '../models/direct-message.model';
|
||||
import { getDirectConversationId } from './direct-message.logic';
|
||||
import type {
|
||||
DirectMessage,
|
||||
DirectMessageConversation,
|
||||
DirectMessageParticipant
|
||||
} from '../models/direct-message.model';
|
||||
|
||||
const aliceHome: DirectMessageParticipant = {
|
||||
userId: 'alice-home',
|
||||
@@ -112,3 +123,101 @@ describe('direct-message-identity.rules', () => {
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('direct conversation canonicalization', () => {
|
||||
const bobAliases = { canonicalId: 'bob-home', aliasIds: ['bob-home', 'bob-foreign'] };
|
||||
const aliceAliases = { canonicalId: 'alice-home', aliasIds: ['alice-home', 'alice-foreign'] };
|
||||
const aliasIndex = buildDirectParticipantAliasIndex([bobAliases, aliceAliases]);
|
||||
|
||||
it('resolves every alias of one human to the same participant id', () => {
|
||||
expect(canonicalizeDirectParticipantId(aliasIndex, 'alice-foreign')).toBe('alice-home');
|
||||
expect(canonicalizeDirectParticipantId(aliasIndex, 'alice-home')).toBe('alice-home');
|
||||
expect(canonicalizeDirectParticipantId(aliasIndex, 'charlie')).toBe('charlie');
|
||||
});
|
||||
|
||||
it('gives two devices of the same human one conversation id', () => {
|
||||
const fromHomeDevice = getCanonicalDirectConversationId(aliasIndex, 'bob-home', 'alice-home');
|
||||
const fromForeignDevice = getCanonicalDirectConversationId(aliasIndex, 'bob-foreign', 'alice-foreign');
|
||||
|
||||
expect(fromForeignDevice).toBe(fromHomeDevice);
|
||||
expect(fromHomeDevice).toBe(getDirectConversationId('bob-home', 'alice-home'));
|
||||
});
|
||||
|
||||
it('rewrites an inbound conversation id built from actor aliases', () => {
|
||||
const inboundId = getDirectConversationId('alice-foreign', 'bob-foreign');
|
||||
|
||||
expect(canonicalizeDirectConversationId(aliasIndex, inboundId))
|
||||
.toBe(getDirectConversationId('alice-home', 'bob-home'));
|
||||
});
|
||||
|
||||
it('leaves group and unparseable conversation ids untouched', () => {
|
||||
expect(canonicalizeDirectConversationId(aliasIndex, 'dm-group-1234')).toBe('dm-group-1234');
|
||||
expect(canonicalizeDirectConversationId(aliasIndex, 'call-42')).toBe('call-42');
|
||||
});
|
||||
|
||||
it('rewrites conversation participants and message ids onto canonical identities', () => {
|
||||
const canonical = canonicalizeDirectConversation(aliasIndex, aliasConversation());
|
||||
|
||||
expect(canonical.id).toBe(getDirectConversationId('alice-home', 'bob-home'));
|
||||
expect(canonical.participants).toEqual(['alice-home', 'bob-home']);
|
||||
expect(Object.keys(canonical.participantProfiles).sort()).toEqual(['alice-home', 'bob-home']);
|
||||
expect(canonical.messages[0].senderId).toBe('alice-home');
|
||||
expect(canonical.messages[0].recipientId).toBe('bob-home');
|
||||
expect(canonical.messages[0].recipientIds).toEqual(['bob-home']);
|
||||
expect(canonical.messages[0].conversationId).toBe(canonical.id);
|
||||
});
|
||||
|
||||
it('merges an alias thread into the canonical thread without losing messages or unread count', () => {
|
||||
const merged = mergeAliasDirectConversations(aliasIndex, [canonicalConversation(), aliasConversation()]);
|
||||
|
||||
expect(merged.id).toBe(getDirectConversationId('alice-home', 'bob-home'));
|
||||
expect(merged.messages.map((message) => message.id)).toEqual(['message-home', 'message-foreign']);
|
||||
expect(merged.messages.every((message) => message.conversationId === merged.id)).toBe(true);
|
||||
expect(merged.unreadCount).toBe(3);
|
||||
expect(merged.lastMessageAt).toBe(20);
|
||||
expect(merged.participants).toEqual(['alice-home', 'bob-home']);
|
||||
});
|
||||
});
|
||||
|
||||
function canonicalConversation(): DirectMessageConversation {
|
||||
return {
|
||||
id: getDirectConversationId('alice-home', 'bob-home'),
|
||||
kind: 'direct',
|
||||
participants: ['alice-home', 'bob-home'],
|
||||
participantProfiles: {
|
||||
'alice-home': aliceHome,
|
||||
'bob-home': bobHome
|
||||
},
|
||||
messages: [createMessage('message-home', 'alice-home', 'bob-home', 10)],
|
||||
lastMessageAt: 10,
|
||||
unreadCount: 1
|
||||
};
|
||||
}
|
||||
|
||||
function aliasConversation(): DirectMessageConversation {
|
||||
return {
|
||||
id: getDirectConversationId('alice-foreign', 'bob-foreign'),
|
||||
kind: 'direct',
|
||||
participants: ['alice-foreign', 'bob-foreign'],
|
||||
participantProfiles: {
|
||||
'alice-foreign': { ...aliceHome, userId: 'alice-foreign' },
|
||||
'bob-foreign': { ...bobHome, userId: 'bob-foreign' }
|
||||
},
|
||||
messages: [createMessage('message-foreign', 'alice-foreign', 'bob-foreign', 20)],
|
||||
lastMessageAt: 20,
|
||||
unreadCount: 2
|
||||
};
|
||||
}
|
||||
|
||||
function createMessage(id: string, senderId: string, recipientId: string, timestamp: number): DirectMessage {
|
||||
return {
|
||||
id,
|
||||
conversationId: getDirectConversationId(senderId, recipientId),
|
||||
senderId,
|
||||
recipientId,
|
||||
recipientIds: [recipientId],
|
||||
content: 'hello',
|
||||
timestamp,
|
||||
status: 'DELIVERED'
|
||||
};
|
||||
}
|
||||
|
||||
+192
-2
@@ -1,9 +1,29 @@
|
||||
import type { User } from '../../../../shared-kernel';
|
||||
import { directMessageConversationIncludesUser, directMessageEventIncludesUser } from './direct-message.logic';
|
||||
import type { DirectMessageConversation, DirectMessageEventPayload } from '../models/direct-message.model';
|
||||
import {
|
||||
directMessageConversationIncludesUser,
|
||||
directMessageEventIncludesUser,
|
||||
getDirectConversationId,
|
||||
upsertDirectMessage
|
||||
} from './direct-message.logic';
|
||||
import type {
|
||||
DirectMessage,
|
||||
DirectMessageConversation,
|
||||
DirectMessageEventPayload
|
||||
} from '../models/direct-message.model';
|
||||
|
||||
type UserIdentityFields = Pick<User, 'id' | 'oderId' | 'peerId'>;
|
||||
|
||||
const DIRECT_CONVERSATION_ID_PREFIX = 'dm-';
|
||||
const DIRECT_CONVERSATION_ID_SEPARATOR = '--';
|
||||
|
||||
export interface DirectParticipantAliasGroup {
|
||||
canonicalId: string;
|
||||
aliasIds: readonly string[];
|
||||
}
|
||||
|
||||
/** Maps every known alias id of a human to the one id their threads are stored under. */
|
||||
export type DirectParticipantAliasIndex = ReadonlyMap<string, string>;
|
||||
|
||||
/** Collect every id that can represent the local user in direct-message traffic. */
|
||||
export function collectDirectMessageSelfUserIds(
|
||||
user: UserIdentityFields,
|
||||
@@ -47,3 +67,173 @@ export function directMessageConversationIncludesAnyUser(
|
||||
): boolean {
|
||||
return directMessageConversationIncludesUser(conversation, userIds);
|
||||
}
|
||||
|
||||
export function buildDirectParticipantAliasIndex(
|
||||
groups: readonly DirectParticipantAliasGroup[]
|
||||
): DirectParticipantAliasIndex {
|
||||
const index = new Map<string, string>();
|
||||
|
||||
for (const group of groups) {
|
||||
const canonicalId = group.canonicalId.trim();
|
||||
|
||||
if (!canonicalId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const aliasId of group.aliasIds) {
|
||||
const alias = aliasId?.trim();
|
||||
|
||||
if (alias) {
|
||||
index.set(alias, canonicalId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
export function canonicalizeDirectParticipantId(
|
||||
aliasIndex: DirectParticipantAliasIndex,
|
||||
participantId: string
|
||||
): string {
|
||||
const normalized = participantId?.trim();
|
||||
|
||||
if (!normalized) {
|
||||
return participantId;
|
||||
}
|
||||
|
||||
return aliasIndex.get(normalized) ?? normalized;
|
||||
}
|
||||
|
||||
export function getCanonicalDirectConversationId(
|
||||
aliasIndex: DirectParticipantAliasIndex,
|
||||
firstParticipantId: string,
|
||||
secondParticipantId: string
|
||||
): string {
|
||||
return getDirectConversationId(
|
||||
canonicalizeDirectParticipantId(aliasIndex, firstParticipantId),
|
||||
canonicalizeDirectParticipantId(aliasIndex, secondParticipantId)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a two-party conversation id whose ids are actor aliases onto the
|
||||
* canonical pair id. Ids that are not a plain participant pair (group threads,
|
||||
* ids carrying the separator inside a participant id) are returned unchanged.
|
||||
*/
|
||||
export function canonicalizeDirectConversationId(
|
||||
aliasIndex: DirectParticipantAliasIndex,
|
||||
conversationId: string
|
||||
): string {
|
||||
const participantIds = parseDirectConversationParticipantIds(conversationId);
|
||||
|
||||
if (!participantIds) {
|
||||
return conversationId;
|
||||
}
|
||||
|
||||
return getCanonicalDirectConversationId(aliasIndex, participantIds[0], participantIds[1]);
|
||||
}
|
||||
|
||||
export function canonicalizeDirectConversation(
|
||||
aliasIndex: DirectParticipantAliasIndex,
|
||||
conversation: DirectMessageConversation
|
||||
): DirectMessageConversation {
|
||||
const canonicalId = canonicalizeDirectConversationId(aliasIndex, conversation.id);
|
||||
const participantProfiles = Object.fromEntries(
|
||||
Object.entries(conversation.participantProfiles).map(([participantId, profile]) => {
|
||||
const canonicalParticipantId = canonicalizeDirectParticipantId(aliasIndex, participantId);
|
||||
|
||||
return [
|
||||
canonicalParticipantId,
|
||||
{
|
||||
...profile,
|
||||
userId: canonicalizeDirectParticipantId(aliasIndex, profile.userId)
|
||||
}
|
||||
];
|
||||
})
|
||||
);
|
||||
const canonicalParticipantIds = conversation.participants.map((participantId) =>
|
||||
canonicalizeDirectParticipantId(aliasIndex, participantId));
|
||||
const participants = [...new Set([...canonicalParticipantIds, ...Object.keys(participantProfiles)])].sort();
|
||||
|
||||
return {
|
||||
...conversation,
|
||||
id: canonicalId,
|
||||
participants,
|
||||
participantProfiles,
|
||||
messages: conversation.messages.map((message) => canonicalizeDirectMessage(aliasIndex, message, canonicalId))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold alias threads of the same two humans into one canonical thread. Unread
|
||||
* counts add up because each thread held messages the user has not seen yet.
|
||||
*/
|
||||
export function mergeAliasDirectConversations(
|
||||
aliasIndex: DirectParticipantAliasIndex,
|
||||
conversations: readonly DirectMessageConversation[]
|
||||
): DirectMessageConversation {
|
||||
const canonicalConversations = conversations.map((conversation) =>
|
||||
canonicalizeDirectConversation(aliasIndex, conversation));
|
||||
const [first, ...rest] = canonicalConversations;
|
||||
|
||||
if (!first) {
|
||||
throw new Error('Cannot merge an empty list of direct conversations.');
|
||||
}
|
||||
|
||||
return rest.reduce((merged, conversation) => {
|
||||
const withParticipants: DirectMessageConversation = {
|
||||
...merged,
|
||||
kind: merged.kind === 'group' || conversation.kind === 'group' ? 'group' : merged.kind,
|
||||
title: merged.title ?? conversation.title,
|
||||
participants: [...new Set([...merged.participants, ...conversation.participants])].sort(),
|
||||
participantProfiles: {
|
||||
...conversation.participantProfiles,
|
||||
...merged.participantProfiles
|
||||
},
|
||||
lastMessageAt: Math.max(merged.lastMessageAt, conversation.lastMessageAt),
|
||||
unreadCount: merged.unreadCount + conversation.unreadCount
|
||||
};
|
||||
|
||||
return conversation.messages.reduce(
|
||||
(target, message) => upsertDirectMessage(target, message, false),
|
||||
withParticipants
|
||||
);
|
||||
}, first);
|
||||
}
|
||||
|
||||
function canonicalizeDirectMessage(
|
||||
aliasIndex: DirectParticipantAliasIndex,
|
||||
message: DirectMessage,
|
||||
conversationId: string
|
||||
): DirectMessage {
|
||||
return {
|
||||
...message,
|
||||
conversationId,
|
||||
senderId: canonicalizeDirectParticipantId(aliasIndex, message.senderId),
|
||||
recipientId: canonicalizeDirectParticipantId(aliasIndex, message.recipientId),
|
||||
recipientIds: message.recipientIds
|
||||
? [...new Set(message.recipientIds.map((recipientId) => canonicalizeDirectParticipantId(aliasIndex, recipientId)))]
|
||||
: message.recipientIds
|
||||
};
|
||||
}
|
||||
|
||||
function parseDirectConversationParticipantIds(conversationId: string): [string, string] | null {
|
||||
if (!conversationId?.startsWith(DIRECT_CONVERSATION_ID_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segments = conversationId
|
||||
.slice(DIRECT_CONVERSATION_ID_PREFIX.length)
|
||||
.split(DIRECT_CONVERSATION_ID_SEPARATOR);
|
||||
|
||||
if (segments.length !== 2 || !segments[0] || !segments[1]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return [decodeURIComponent(segments[0]), decodeURIComponent(segments[1])];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { shouldShowStreamIndicator, type StreamIndicatorInput } from './stream-indicator.rules';
|
||||
|
||||
function buildInput(overrides: Partial<StreamIndicatorInput> = {}): StreamIndicatorInput {
|
||||
return {
|
||||
announcedState: undefined,
|
||||
hasLiveRemoteTrack: false,
|
||||
isSelf: false,
|
||||
localStreamActive: false,
|
||||
subjectInVoice: true,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('shouldShowStreamIndicator', () => {
|
||||
it('shows an announced share even though the observer never joined voice', () => {
|
||||
const isVisible = shouldShowStreamIndicator(buildInput({
|
||||
announcedState: true,
|
||||
hasLiveRemoteTrack: false
|
||||
}));
|
||||
|
||||
expect(isVisible).toBe(true);
|
||||
});
|
||||
|
||||
it('hides the indicator for a user who is not in a voice channel', () => {
|
||||
const isVisible = shouldShowStreamIndicator(buildInput({
|
||||
announcedState: true,
|
||||
subjectInVoice: false
|
||||
}));
|
||||
|
||||
expect(isVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('trusts a stop announcement over a track that has not torn down yet', () => {
|
||||
const isVisible = shouldShowStreamIndicator(buildInput({
|
||||
announcedState: false,
|
||||
hasLiveRemoteTrack: true
|
||||
}));
|
||||
|
||||
expect(isVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to a live track when the peer never announced', () => {
|
||||
const isVisible = shouldShowStreamIndicator(buildInput({
|
||||
announcedState: undefined,
|
||||
hasLiveRemoteTrack: true
|
||||
}));
|
||||
|
||||
expect(isVisible).toBe(true);
|
||||
});
|
||||
|
||||
it('reads the local stream for the local user and ignores peer announcements', () => {
|
||||
const isVisible = shouldShowStreamIndicator(buildInput({
|
||||
announcedState: false,
|
||||
isSelf: true,
|
||||
localStreamActive: true
|
||||
}));
|
||||
|
||||
expect(isVisible).toBe(true);
|
||||
});
|
||||
|
||||
it('hides the local indicator when the local user streams nothing', () => {
|
||||
const isVisible = shouldShowStreamIndicator(buildInput({
|
||||
announcedState: true,
|
||||
isSelf: true,
|
||||
localStreamActive: false
|
||||
}));
|
||||
|
||||
expect(isVisible).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface StreamIndicatorInput {
|
||||
/** The observed user is connected to a voice channel. Nobody can stream from outside one. */
|
||||
subjectInVoice: boolean;
|
||||
/** The observed user is the local user, whose stream state is known locally. */
|
||||
isSelf: boolean;
|
||||
localStreamActive: boolean;
|
||||
/** What the observed user announced over its peer channel, `undefined` when it never did. */
|
||||
announcedState: boolean | undefined;
|
||||
hasLiveRemoteTrack: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a remote user's live indicator belongs on screen.
|
||||
*
|
||||
* The observer's own voice state is deliberately absent from the input: gating
|
||||
* on it hid the indicator from everyone outside the channel, so a user sharing
|
||||
* alone looked idle and nobody could tell there was anything to watch. An
|
||||
* announcement arrives over the peer channel whether or not the observer joined
|
||||
* voice, so it is authoritative once present, and a live track only ever backs
|
||||
* up a peer that never announced.
|
||||
*/
|
||||
export function shouldShowStreamIndicator(input: StreamIndicatorInput): boolean {
|
||||
if (!input.subjectInVoice) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (input.isSelf) {
|
||||
return input.localStreamActive;
|
||||
}
|
||||
|
||||
if (input.announcedState !== undefined) {
|
||||
return input.announcedState;
|
||||
}
|
||||
|
||||
return input.hasLiveRemoteTrack;
|
||||
}
|
||||
@@ -133,8 +133,9 @@ export class PrivateCallComponent {
|
||||
readonly isCameraEnabled = this.voice.isCameraEnabled;
|
||||
readonly isScreenSharing = this.screenShare.isScreenSharing;
|
||||
readonly joinError = this.calls.joinError;
|
||||
readonly deliveryError = this.calls.deliveryError;
|
||||
readonly cameraError = signal<string | null>(null);
|
||||
readonly callErrorMessage = computed(() => this.joinError() ?? this.cameraError());
|
||||
readonly callErrorMessage = computed(() => this.joinError() ?? this.deliveryError() ?? this.cameraError());
|
||||
readonly showScreenShareButton = computed(() => !this.isMobile() && !this.mobilePlatform.isNativeMobile());
|
||||
readonly remoteStreamRevision = signal(0);
|
||||
readonly includeSystemAudio = signal(false);
|
||||
|
||||
@@ -262,6 +262,7 @@
|
||||
<button
|
||||
(click)="viewStream(u.oderId || u.id); $event.stopPropagation()"
|
||||
class="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-bold bg-red-500 text-white rounded animate-pulse hover:bg-red-600 transition-colors"
|
||||
data-testid="voice-user-live"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="getUserLiveIconName(u.oderId || u.id)"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
@@ -58,7 +57,8 @@ import {
|
||||
VoiceSessionFacade,
|
||||
VoiceWorkspaceService,
|
||||
isLocalVoiceOwner,
|
||||
isVoiceOnAnotherClient
|
||||
isVoiceOnAnotherClient,
|
||||
shouldShowStreamIndicator
|
||||
} from '../../../domains/voice-session';
|
||||
import { DirectMessageService } from '../../../domains/direct-message';
|
||||
import { DirectCallService } from '../../../domains/direct-call';
|
||||
@@ -162,6 +162,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
private profileCardOpenTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private skeletonRevealTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Stream to open once a join started from a LIVE badge finishes connecting. */
|
||||
private pendingStreamFocus: string | null = null;
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly panelMode = input<PanelMode>('channels');
|
||||
@@ -840,11 +842,25 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
this.startVoiceHeartbeat(roomId, room);
|
||||
this.broadcastVoiceConnected(roomId, room, current);
|
||||
this.startVoiceSession(roomId, room);
|
||||
this.applyPendingStreamFocus();
|
||||
}
|
||||
|
||||
private applyPendingStreamFocus(): void {
|
||||
const focusTarget = this.pendingStreamFocus;
|
||||
|
||||
this.pendingStreamFocus = null;
|
||||
|
||||
if (!focusTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.voiceWorkspace.focusStream(focusTarget, { connectRemoteShares: true });
|
||||
}
|
||||
|
||||
private handleVoiceJoinFailure(error: unknown): void {
|
||||
const message = error instanceof Error ? error.message : 'room.voiceJoin.failed';
|
||||
|
||||
this.pendingStreamFocus = null;
|
||||
this.voiceConnection.reportConnectionError(message);
|
||||
}
|
||||
|
||||
@@ -988,7 +1004,22 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
viewStream(userId: string) {
|
||||
const focusTarget = this.isUserSharing(userId) ? `screen:${userId}` : `camera:${userId}`;
|
||||
|
||||
if (this.voiceSessionService.voiceSession()) {
|
||||
this.voiceWorkspace.focusStream(focusTarget, { connectRemoteShares: true });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const streamerVoiceState = this.findKnownUser(userId)?.voiceState;
|
||||
|
||||
// Only the streamer's own channel can be joined on their behalf, and only on
|
||||
// the server being viewed, since `joinVoice` resolves the server from the view.
|
||||
if (!streamerVoiceState?.roomId || streamerVoiceState.serverId !== this.currentRoom()?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingStreamFocus = focusTarget;
|
||||
this.joinVoice(streamerVoiceState.roomId);
|
||||
}
|
||||
|
||||
canMoveVoiceUsers(): boolean {
|
||||
@@ -1109,54 +1140,27 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
isUserOnCamera(userId: string): boolean {
|
||||
const user = this.findKnownUser(userId);
|
||||
|
||||
if (!this.isUserInCurrentVoiceRoom(userId, user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const current = this.currentUser();
|
||||
|
||||
if (current && (current.id === userId || current.oderId === userId)) {
|
||||
return this.voiceConnection.isCameraEnabled();
|
||||
}
|
||||
|
||||
if (user?.cameraState?.isEnabled === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (user?.cameraState?.isEnabled === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.getPeerKeysForUser(user, userId).some((peerKey) => this.hasActiveVideoStream(this.voiceConnection.getRemoteCameraStream(peerKey)));
|
||||
return shouldShowStreamIndicator({
|
||||
announcedState: user?.cameraState?.isEnabled,
|
||||
hasLiveRemoteTrack: this.getPeerKeysForUser(user, userId)
|
||||
.some((peerKey) => this.hasActiveVideoStream(this.voiceConnection.getRemoteCameraStream(peerKey))),
|
||||
isSelf: this.isCurrentUserId(userId),
|
||||
localStreamActive: this.voiceConnection.isCameraEnabled(),
|
||||
subjectInVoice: this.isSubjectInVoice(userId, user)
|
||||
});
|
||||
}
|
||||
|
||||
isUserSharing(userId: string): boolean {
|
||||
const user = this.findKnownUser(userId);
|
||||
|
||||
if (!this.isUserInCurrentVoiceRoom(userId, user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const current = this.currentUser();
|
||||
|
||||
if (current && (current.id === userId || current.oderId === userId)) {
|
||||
return this.screenShare.isScreenSharing();
|
||||
}
|
||||
|
||||
if (user?.screenShareState?.isSharing === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (user?.screenShareState?.isSharing === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const stream =
|
||||
this.getPeerKeysForUser(user, userId)
|
||||
.map((peerKey) => this.screenShare.getRemoteScreenShareStream(peerKey))
|
||||
.find((candidate) => this.hasActiveVideoStream(candidate)) || null;
|
||||
|
||||
return this.hasActiveVideoStream(stream);
|
||||
return shouldShowStreamIndicator({
|
||||
announcedState: user?.screenShareState?.isSharing,
|
||||
hasLiveRemoteTrack: this.getPeerKeysForUser(user, userId)
|
||||
.some((peerKey) => this.hasActiveVideoStream(this.screenShare.getRemoteScreenShareStream(peerKey))),
|
||||
isSelf: this.isCurrentUserId(userId),
|
||||
localStreamActive: this.screenShare.isScreenSharing(),
|
||||
subjectInVoice: this.isSubjectInVoice(userId, user)
|
||||
});
|
||||
}
|
||||
|
||||
isUserStreaming(userId: string): boolean {
|
||||
@@ -1315,23 +1319,16 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
return this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId) ?? null;
|
||||
}
|
||||
|
||||
private isUserInCurrentVoiceRoom(userId: string, user: User | null): boolean {
|
||||
const currentVoiceState = this.currentUser()?.voiceState;
|
||||
private isCurrentUserId(userId: string): boolean {
|
||||
const current = this.currentUser();
|
||||
|
||||
if (!currentVoiceState?.isConnected || !currentVoiceState.roomId || !currentVoiceState.serverId) {
|
||||
return false;
|
||||
return !!current && (current.id === userId || current.oderId === userId);
|
||||
}
|
||||
|
||||
if (current && (current.id === userId || current.oderId === userId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
!!user?.voiceState?.isConnected &&
|
||||
user.voiceState.roomId === currentVoiceState.roomId &&
|
||||
user.voiceState.serverId === currentVoiceState.serverId
|
||||
);
|
||||
private isSubjectInVoice(userId: string, user: User | null): boolean {
|
||||
return this.isCurrentUserId(userId)
|
||||
? !!this.currentUser()?.voiceState?.isConnected
|
||||
: !!user?.voiceState?.isConnected;
|
||||
}
|
||||
|
||||
private getPeerKeysForUser(user: User | null, userId: string): string[] {
|
||||
|
||||
Reference in New Issue
Block a user