Fix private calls

This commit is contained in:
2026-05-17 15:14:52 +02:00
parent 0f6cb3ee77
commit e769a6ee4a
71 changed files with 5821 additions and 349 deletions

View File

@@ -0,0 +1,712 @@
import { expect, type Page } from '@playwright/test';
import { test, type Client } from '../../fixtures/multi-client';
import {
dumpRtcDiagnostics,
installAutoResumeAudioContext,
installWebRTCTracking,
waitForAllPeerAudioFlow,
waitForAudioFlow,
waitForAudioStatsPresent,
waitForConnectedPeerCount,
waitForInboundVideoFlow,
waitForOutboundVideoFlow,
waitForPeerConnected
} from '../../helpers/webrtc-helpers';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatMessagesPage } from '../../pages/chat-messages.page';
import { disableLastViewedChatResume } from '../../helpers/seed-test-endpoint';
interface DirectCallScenario {
alice: Client;
bob: Client;
charlie?: Client;
aliceUserId: string;
bobUserId: string;
charlieUserId?: string;
}
interface AudioFlowDelta {
outboundBytesDelta: number;
inboundBytesDelta: number;
outboundPacketsDelta: number;
inboundPacketsDelta: number;
}
interface VideoFlowDelta {
outboundBytesDelta: number;
inboundBytesDelta: number;
outboundPacketsDelta: number;
inboundPacketsDelta: number;
}
const USER_PASSWORD = 'TestPass123!';
test.describe('Direct private calls', () => {
test.describe.configure({ timeout: 240_000 });
test('two users can ring, answer, chat, see self voice indicators, and exchange audio', async ({ createClient }) => {
const scenario = await createDirectCallScenario(createClient, { includeCharlie: true });
const callMessage = `Call chat ${uniqueName('msg')}`;
const privateOnlyMessage = `Private before group ${uniqueName('msg')}`;
const groupMessage = `Group call chat ${uniqueName('msg')}`;
await test.step('Alice starts a call from the search people card', async () => {
await disableLastViewedChatResume(scenario.alice.page);
await scenario.alice.page.goto('/search', { waitUntil: 'domcontentloaded' });
await expect(scenario.alice.page.locator('app-user-search-list')).toBeVisible({ timeout: 20_000 });
const bobPeopleCard = scenario.alice.page.locator(`[data-testid="user-card-${scenario.bobUserId}"]`, { hasText: 'Bob' }).first();
await expect(bobPeopleCard).toBeVisible({ timeout: 20_000 });
await bobPeopleCard.hover();
await bobPeopleCard.getByRole('button', { name: 'Call Bob' }).click();
await expect(scenario.alice.page).toHaveURL(/\/call\//, { timeout: 20_000 });
await expect(scenario.alice.page.locator('app-private-call')).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
});
await test.step('Alice starts sharing before Bob joins', async () => {
await scenario.alice.page.getByRole('button', { name: 'Share screen' }).click();
await expect(scenario.alice.page.getByRole('button', { name: 'Stop sharing screen' })).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.getByTestId('private-call-focused-stream')).toBeVisible({ timeout: 20_000 });
});
await test.step('Bob receives a ringing call and the ring stops when he answers', async () => {
await expect
.poll(async () => await getCallAudioPlayCount(scenario.bob.page), {
timeout: 20_000,
intervals: [500, 1_000]
})
.toBeGreaterThan(0);
await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
await expect
.poll(async () => await getCallNotificationCount(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBeGreaterThan(0);
await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click();
await expect(scenario.bob.page).toHaveURL(/\/call\//, { timeout: 20_000 });
await scenario.bob.page.getByRole('button', { name: 'Join call' }).click();
await expect(scenario.bob.page.getByRole('button', { name: 'Leave call' })).toBeVisible({ timeout: 20_000 });
await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
await expect
.poll(async () => await getActiveCallAudioLoops(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(0);
await expect
.poll(async () => await getCallAudioPauseCount(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBeGreaterThan(0);
});
await test.step('WebRTC connects and late-join screen share is visible', async () => {
await waitForPeerConnected(scenario.alice.page, 45_000);
await waitForPeerConnected(scenario.bob.page, 45_000);
await expectParticipantConnected(scenario.alice.page, scenario.aliceUserId);
await expectParticipantConnected(scenario.alice.page, scenario.bobUserId);
await expectParticipantConnected(scenario.bob.page, scenario.aliceUserId);
await expectParticipantConnected(scenario.bob.page, scenario.bobUserId);
const aliceVideo = await waitForOutboundVideoFlow(scenario.alice.page, 30_000);
const bobVideo = await waitForInboundVideoFlow(scenario.bob.page, 30_000);
if (!isOutboundVideoFlowing(aliceVideo) || !isInboundVideoFlowing(bobVideo)) {
console.log('[Alice direct-call RTC]\n' + (await dumpRtcDiagnostics(scenario.alice.page)));
console.log('[Bob direct-call RTC]\n' + (await dumpRtcDiagnostics(scenario.bob.page)));
}
expectOutboundVideoFlow(aliceVideo, 'Alice late-join direct call screen share');
expectInboundVideoFlow(bobVideo, 'Bob late-join direct call screen share');
await expect(scenario.bob.page.getByTestId('private-call-focused-stream')).toBeVisible({ timeout: 20_000 });
});
await test.step('Audio flows in both directions', async () => {
await waitForAudioStatsPresent(scenario.alice.page, 30_000);
await waitForAudioStatsPresent(scenario.bob.page, 30_000);
const aliceDelta = await waitForAudioFlow(scenario.alice.page, 45_000);
const bobDelta = await waitForAudioFlow(scenario.bob.page, 45_000);
if (!isAudioFlowing(aliceDelta) || !isAudioFlowing(bobDelta)) {
console.log('[Alice direct-call RTC]\n' + (await dumpRtcDiagnostics(scenario.alice.page)));
console.log('[Bob direct-call RTC]\n' + (await dumpRtcDiagnostics(scenario.bob.page)));
}
expectAudioFlow(aliceDelta, 'Alice direct call');
expectAudioFlow(bobDelta, 'Bob direct call');
});
await test.step('Adding a third participant converts the call chat to an empty group chat', async () => {
if (!scenario.charlie || !scenario.charlieUserId) {
throw new Error('Expected direct-call scenario to include Charlie.');
}
const charlie = scenario.charlie;
await scenario.alice.page.getByTestId('dm-input').fill(privateOnlyMessage);
await scenario.alice.page.getByTestId('dm-input').press('Enter');
await expect(scenario.bob.page.locator('app-private-call aside app-dm-chat').getByText(privateOnlyMessage)).toBeVisible({ timeout: 20_000 });
await scenario.alice.page.getByLabel('Add user to call').selectOption(scenario.charlieUserId);
await scenario.alice.page.getByRole('button', { name: 'Add user' }).click();
await expect(scenario.alice.page.locator('app-private-call aside app-dm-chat').getByText(privateOnlyMessage)).toHaveCount(0, {
timeout: 20_000
});
await expect(scenario.alice.page.locator('[data-testid^="dm-rail-item-dm-group-"]')).toHaveCount(0, { timeout: 20_000 });
await expect(scenario.alice.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
await expect
.poll(async () => await getCallAudioPlayCount(charlie.page), {
timeout: 20_000,
intervals: [500, 1_000]
})
.toBeGreaterThan(0);
await charlie.page.getByRole('button', { name: 'Open private call' }).click();
await expect(charlie.page).toHaveURL(/\/call\//, { timeout: 20_000 });
await charlie.page.getByRole('button', { name: 'Join call' }).click();
await expect(charlie.page.getByRole('button', { name: 'Leave call' })).toBeVisible({ timeout: 20_000 });
await expect(charlie.page.locator('app-private-call aside app-dm-chat').getByText(privateOnlyMessage)).toHaveCount(0);
await waitForConnectedPeerCount(scenario.alice.page, 2, 45_000);
await waitForConnectedPeerCount(scenario.bob.page, 2, 45_000);
await waitForConnectedPeerCount(charlie.page, 2, 45_000);
await Promise.all([
waitForAllPeerAudioFlow(scenario.alice.page, 2, 45_000),
waitForAllPeerAudioFlow(scenario.bob.page, 2, 45_000),
waitForAllPeerAudioFlow(charlie.page, 2, 45_000)
]);
await expectParticipantConnected(scenario.alice.page, scenario.charlieUserId);
await expectParticipantConnected(scenario.bob.page, scenario.charlieUserId);
await expectParticipantConnected(charlie.page, scenario.aliceUserId);
await expectParticipantConnected(charlie.page, scenario.bobUserId);
await expectParticipantConnected(charlie.page, scenario.charlieUserId);
await scenario.alice.page.getByTestId('dm-input').fill(groupMessage);
await scenario.alice.page.getByTestId('dm-input').press('Enter');
await expect(scenario.bob.page.locator('app-private-call aside app-dm-chat').getByText(groupMessage)).toBeVisible({ timeout: 20_000 });
await expect(charlie.page.locator('app-private-call aside app-dm-chat').getByText(groupMessage)).toBeVisible({ timeout: 20_000 });
});
await test.step('Private call streams can switch between all-stream and focused viewing', async () => {
await scenario.bob.page.getByRole('button', { name: 'Turn camera on' }).click();
await expect(scenario.bob.page.getByRole('button', { name: 'Turn camera off' })).toBeVisible({ timeout: 20_000 });
await expect
.poll(async () => await privateCallGridStreamCount(scenario.bob.page), {
timeout: 30_000,
intervals: [500, 1_000]
})
.toBeGreaterThanOrEqual(2);
await scenario.bob.page
.getByTestId('private-call-stream-grid')
.locator('app-voice-workspace-stream-tile')
.first()
.click();
await expect(scenario.bob.page.getByTestId('private-call-focused-stream')).toBeVisible({ timeout: 20_000 });
await expect(scenario.bob.page.getByTestId('private-call-show-all-streams')).toBeVisible({ timeout: 20_000 });
await assertSustainedMediaFlow(scenario.alice.page, scenario.bob.page, 'direct call screen share and camera');
await scenario.bob.page.getByTestId('private-call-focused-stream').dblclick();
await expect
.poll(async () => await hasFullscreenElement(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(true);
await exitFullscreen(scenario.bob.page);
await expect
.poll(async () => await hasFullscreenElement(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(false);
await scenario.bob.page.getByTestId('private-call-show-all-streams').click();
await expect(scenario.bob.page.getByTestId('private-call-stream-grid')).toBeVisible({ timeout: 20_000 });
});
await test.step('Both clients show their own speaking indicator', async () => {
await expect(scenario.alice.page.getByTestId(`call-participant-${scenario.aliceUserId}`)).toHaveClass(/ring-emerald-400/, {
timeout: 20_000
});
await expect(scenario.bob.page.getByTestId(`call-participant-${scenario.bobUserId}`)).toHaveClass(/ring-emerald-400/, {
timeout: 20_000
});
});
await test.step('Private call layout does not require vertical scrolling', async () => {
await expect
.poll(async () => await privateCallMainHasNoVerticalOverflow(scenario.alice.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(true);
await expect
.poll(async () => await privateCallMainHasNoVerticalOverflow(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(true);
await expect(scenario.alice.page.locator('app-private-call article').filter({ hasText: /Connected|Speaking/ })).toHaveCount(0);
await expect(scenario.bob.page.locator('app-private-call article').filter({ hasText: /Connected|Speaking/ })).toHaveCount(0);
await expect(scenario.alice.page.getByText('No live streams yet')).toHaveCount(0);
await expect(scenario.bob.page.getByText('No live streams yet')).toHaveCount(0);
const originalWidth = await privateCallChatWidth(scenario.alice.page);
const resizer = scenario.alice.page.getByTestId('private-call-chat-resizer');
const box = await resizer.boundingBox();
expect(box, 'private call chat resizer should be measurable').not.toBeNull();
if (box) {
await scenario.alice.page.mouse.move(box.x + box.width / 2, box.y + 20);
await scenario.alice.page.mouse.down();
await scenario.alice.page.mouse.move(box.x - 96, box.y + 20);
await scenario.alice.page.mouse.up();
}
await expect
.poll(async () => await privateCallChatWidth(scenario.alice.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBeGreaterThan(originalWidth + 40);
});
await test.step('Embedded call chat syncs and does not expose another call button', async () => {
await expect(scenario.alice.page.locator('app-private-call aside').getByRole('button', { name: /Call/i })).toHaveCount(0);
await expect(scenario.bob.page.locator('app-private-call aside').getByRole('button', { name: /Call/i })).toHaveCount(0);
await scenario.bob.page.getByTestId('dm-input').fill('typing from Bob');
await expect(scenario.alice.page.getByTestId('dm-typing-indicator')).toContainText('Bob is typing', { timeout: 20_000 });
await scenario.alice.page.getByTestId('dm-input').fill(callMessage);
await scenario.alice.page.getByTestId('dm-input').press('Enter');
await expect(scenario.bob.page.locator('app-private-call aside app-dm-chat').getByText(callMessage)).toBeVisible({ timeout: 20_000 });
});
await test.step('Group chat call button rings every other participant', async () => {
if (!scenario.charlie) {
throw new Error('Expected direct-call scenario to include Charlie.');
}
await scenario.alice.page.getByRole('button', { name: 'Leave call' }).click();
await expect(scenario.alice.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
await scenario.bob.page.getByRole('button', { name: 'Leave call' }).click();
await expect(scenario.bob.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
await scenario.charlie.page.getByRole('button', { name: 'Leave call' }).click();
await expect(scenario.charlie.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
const bobPlayCountBeforeGroupCall = await getCallAudioPlayCount(scenario.bob.page);
const charliePlayCountBeforeGroupCall = await getCallAudioPlayCount(scenario.charlie.page);
await scenario.alice.page
.locator('app-dm-chat header')
.getByRole('button', { name: /Call/i })
.click();
await expect(scenario.alice.page).toHaveURL(/\/call\/dm-group-/, { timeout: 20_000 });
await expect
.poll(async () => await getCallAudioPlayCount(scenario.bob.page), {
timeout: 20_000,
intervals: [500, 1_000]
})
.toBeGreaterThan(bobPlayCountBeforeGroupCall);
await expect
.poll(async () => await getCallAudioPlayCount(scenario.charlie.page), {
timeout: 20_000,
intervals: [500, 1_000]
})
.toBeGreaterThan(charliePlayCountBeforeGroupCall);
await scenario.bob.page
.getByRole('button', { name: 'Open private call' })
.last()
.click();
await scenario.bob.page.getByRole('button', { name: 'Join call' }).click();
await expect
.poll(async () => await getActiveCallAudioLoops(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(0);
await scenario.charlie.page
.getByRole('button', { name: 'Open private call' })
.last()
.click();
await scenario.charlie.page.getByRole('button', { name: 'Join call' }).click();
await expect
.poll(async () => await getActiveCallAudioLoops(scenario.charlie.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(0);
});
});
test('missing and ended private calls do not leave stale call controls behind', async ({ createClient }) => {
const scenario = await createDirectCallScenario(createClient);
await test.step('Unknown call routes render an inert empty state', async () => {
await scenario.alice.page.goto('/call/not-a-real-call', { waitUntil: 'domcontentloaded' });
await expect(scenario.alice.page.getByText('No active call for this route.')).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.getByRole('button', { name: 'Join call' })).toHaveCount(0);
});
await test.step('Caller leaving before answer clears recipient call route and rail icon', async () => {
await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob');
await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click();
await expect(scenario.bob.page).toHaveURL(/\/call\//, { timeout: 20_000 });
await scenario.alice.page.getByRole('button', { name: 'Leave call' }).click();
await expect(scenario.alice.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
await expect(scenario.bob.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
await expect(scenario.bob.page.getByRole('button', { name: 'Open private call' })).toHaveCount(0);
await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(0);
await expect
.poll(async () => await getActiveCallAudioLoops(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(0);
});
await test.step('Leaving an answered call clears local ringing and returns to DM', async () => {
await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob');
await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click();
await scenario.bob.page.getByRole('button', { name: 'Join call' }).click();
await expect(scenario.bob.page.getByRole('button', { name: 'Leave call' })).toBeVisible({ timeout: 20_000 });
await scenario.bob.page.getByRole('button', { name: 'Leave call' }).click();
await expect(scenario.bob.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
await expect
.poll(async () => await getActiveCallAudioLoops(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(0);
});
});
});
async function createDirectCallScenario(
createClient: () => Promise<Client>,
options: { includeCharlie?: boolean } = {}
): Promise<DirectCallScenario> {
const suffix = uniqueName('direct-call');
const serverName = `Direct Call Server ${suffix}`;
const alice = await createClient();
const bob = await createClient();
const charlie = options.includeCharlie ? await createClient() : undefined;
await installDirectCallInstrumentation(alice.page);
await installDirectCallInstrumentation(bob.page);
if (charlie) {
await installDirectCallInstrumentation(charlie.page);
}
await registerUser(alice.page, `alice_${suffix}`, 'Alice');
await registerUser(bob.page, `bob_${suffix}`, 'Bob');
if (charlie) {
await registerUser(charlie.page, `charlie_${suffix}`, 'Charlie');
}
const aliceUserId = await getCurrentUserId(alice.page);
const aliceSearch = new ServerSearchPage(alice.page);
await aliceSearch.createServer(serverName, { description: 'E2E direct call discovery server' });
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();
if (charlie) {
const charlieSearch = new ServerSearchPage(charlie.page);
await charlieSearch.joinServerFromSearch(serverName);
await expect(charlie.page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ChatMessagesPage(charlie.page).waitForReady();
}
const bobRoomCard = alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first();
const charlieRoomCard = charlie ? alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Charlie' }).first() : null;
await expect(bobRoomCard).toBeVisible({ timeout: 20_000 });
if (charlieRoomCard) {
await expect(charlieRoomCard).toBeVisible({ timeout: 20_000 });
}
const bobUserCardTestId = await bobRoomCard.getAttribute('data-testid');
const bobUserId = bobUserCardTestId?.replace('room-user-card-', '');
const charlieUserCardTestId = charlieRoomCard ? await charlieRoomCard.getAttribute('data-testid') : null;
const charlieUserId = charlieUserCardTestId?.replace('room-user-card-', '');
if (!aliceUserId || !bobUserId || (charlie && !charlieUserId)) {
throw new Error('Expected direct-call scenario users to expose stable ids.');
}
return {
alice,
bob,
charlie,
aliceUserId,
bobUserId,
charlieUserId
};
}
async function installDirectCallInstrumentation(page: Page): Promise<void> {
await installWebRTCTracking(page);
await installAutoResumeAudioContext(page);
await page.addInitScript(() => {
localStorage.setItem(
'metoyou_voice_settings',
JSON.stringify({
inputVolume: 100,
outputVolume: 100,
audioBitrate: 96,
latencyProfile: 'balanced',
includeSystemAudio: false,
noiseReduction: false,
screenShareQuality: 'balanced',
askScreenShareQuality: false
})
);
const OriginalAudio = window.Audio;
const callAudioState = {
activeLoops: 0,
pauseCount: 0,
playCount: 0
};
const callNotificationState = {
count: 0,
bodies: [] as string[],
titles: [] as string[]
};
(window as Window & { __callAudioState?: typeof callAudioState }).__callAudioState = callAudioState;
(window as Window & { __callNotificationState?: typeof callNotificationState }).__callNotificationState = callNotificationState;
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);
const originalPause = audio.pause.bind(audio);
audio.play = () => {
if (isCallAudio(audio)) {
callAudioState.playCount += 1;
if (audio.loop) {
callAudioState.activeLoops += 1;
}
}
return originalPlay();
};
audio.pause = () => {
if (isCallAudio(audio)) {
callAudioState.pauseCount += 1;
if (audio.loop && callAudioState.activeLoops > 0) {
callAudioState.activeLoops -= 1;
}
}
return originalPause();
};
return audio;
} as typeof Audio;
window.Audio.prototype = OriginalAudio.prototype;
Object.setPrototypeOf(window.Audio, OriginalAudio);
class MockNotification {
static permission: NotificationPermission = 'granted';
onclick: ((this: Notification, ev: Event) => unknown) | null = null;
constructor(title: string, options?: NotificationOptions) {
callNotificationState.count += 1;
callNotificationState.titles.push(title);
callNotificationState.bodies.push(options?.body ?? '');
}
static async requestPermission(): Promise<NotificationPermission> {
return 'granted';
}
close(): void {}
}
(window as unknown as { Notification: typeof Notification }).Notification = MockNotification as unknown as typeof Notification;
});
}
async function registerUser(page: Page, username: string, displayName: string): Promise<void> {
const registerPage = new RegisterPage(page);
await registerPage.goto();
await registerPage.register(username, displayName, USER_PASSWORD);
await expect(page).toHaveURL(/\/search/, { timeout: 20_000 });
}
async function startCallFromSearch(page: Page, userId: string, displayName: string): Promise<void> {
await disableLastViewedChatResume(page);
await page.goto('/search', { waitUntil: 'domcontentloaded' });
const peopleCard = page.locator(`[data-testid="user-card-${userId}"]`, { hasText: displayName }).first();
await expect(peopleCard).toBeVisible({ timeout: 20_000 });
await peopleCard.hover();
await peopleCard.getByRole('button', { name: `Call ${displayName}` }).click();
await expect(page).toHaveURL(/\/call\//, { timeout: 20_000 });
}
async function getCurrentUserId(page: Page): Promise<string> {
return await page.evaluate(() => localStorage.getItem('metoyou_currentUserId') ?? '');
}
async function getCallAudioPlayCount(page: Page): Promise<number> {
return await page.evaluate(() => (window as Window & { __callAudioState?: { playCount: number } }).__callAudioState?.playCount ?? 0);
}
async function getCallAudioPauseCount(page: Page): Promise<number> {
return await page.evaluate(() => (window as Window & { __callAudioState?: { pauseCount: number } }).__callAudioState?.pauseCount ?? 0);
}
async function getCallNotificationCount(page: Page): Promise<number> {
return await page.evaluate(() => (
window as Window & { __callNotificationState?: { count: number } }
).__callNotificationState?.count ?? 0);
}
async function getActiveCallAudioLoops(page: Page): Promise<number> {
return await page.evaluate(() => (window as Window & { __callAudioState?: { activeLoops: number } }).__callAudioState?.activeLoops ?? 0);
}
async function expectParticipantConnected(page: Page, userId: string | undefined): Promise<void> {
if (!userId) {
throw new Error('Expected a stable participant id.');
}
await expect(page.getByTestId(`call-participant-${userId}`)).not.toHaveClass(/opacity-55/, { timeout: 20_000 });
}
async function assertSustainedMediaFlow(senderPage: Page, receiverPage: Page, label: string): Promise<void> {
for (let sample = 0; sample < 3; sample++) {
const [
senderAudio,
receiverAudio,
outboundVideo,
inboundVideo
] = await Promise.all([
waitForAudioFlow(senderPage, 30_000),
waitForAudioFlow(receiverPage, 30_000),
waitForOutboundVideoFlow(senderPage, 30_000),
waitForInboundVideoFlow(receiverPage, 30_000)
]);
expectAudioFlow(senderAudio, `${label} sender sample ${sample + 1}`);
expectAudioFlow(receiverAudio, `${label} receiver sample ${sample + 1}`);
expectOutboundVideoFlow(outboundVideo, `${label} outbound sample ${sample + 1}`);
expectInboundVideoFlow(inboundVideo, `${label} inbound sample ${sample + 1}`);
}
}
async function privateCallMainHasNoVerticalOverflow(page: Page): Promise<boolean> {
return await page.locator('app-private-call > section > main').evaluate((main) => main.scrollHeight <= main.clientHeight + 1);
}
async function privateCallGridStreamCount(page: Page): Promise<number> {
return await page
.getByTestId('private-call-stream-grid')
.locator('app-voice-workspace-stream-tile')
.count();
}
async function privateCallChatWidth(page: Page): Promise<number> {
return await page.locator('app-private-call aside').evaluate((aside) => aside.getBoundingClientRect().width);
}
async function hasFullscreenElement(page: Page): Promise<boolean> {
return await page.evaluate(() => document.fullscreenElement !== null);
}
async function exitFullscreen(page: Page): Promise<void> {
await page.evaluate(async () => {
if (document.fullscreenElement) {
await document.exitFullscreen();
}
});
}
function expectAudioFlow(delta: AudioFlowDelta, label: string): void {
expect(delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0, `${label} should send audio`).toBe(true);
expect(delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0, `${label} should receive audio`).toBe(true);
}
function expectOutboundVideoFlow(delta: VideoFlowDelta, label: string): void {
expect(isOutboundVideoFlowing(delta), `${label} should send video`).toBe(true);
}
function expectInboundVideoFlow(delta: VideoFlowDelta, label: string): void {
expect(isInboundVideoFlowing(delta), `${label} should receive video`).toBe(true);
}
function isAudioFlowing(delta: AudioFlowDelta): boolean {
return (delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0)
&& (delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0);
}
function isOutboundVideoFlowing(delta: VideoFlowDelta): boolean {
return delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0;
}
function isInboundVideoFlowing(delta: VideoFlowDelta): boolean {
return delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0;
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
}

View File

@@ -12,7 +12,7 @@ import {
import * as fs from 'fs';
import * as fsp from 'fs/promises';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { fileURLToPath, pathToFileURL } from 'url';
import {
getDesktopSettingsSnapshot,
updateDesktopSettings,
@@ -519,12 +519,46 @@ export function setupSystemHandlers(): void {
}
});
ipcMain.handle('get-file-url', async (_event, filePath: string) => {
if (typeof filePath !== 'string' || !filePath.trim()) {
return null;
}
try {
await fsp.access(filePath, fs.constants.F_OK);
return pathToFileURL(filePath).toString();
} catch {
return null;
}
});
ipcMain.handle('read-file', async (_event, filePath: string) => {
const data = await fsp.readFile(filePath);
return data.toString('base64');
});
ipcMain.handle('read-file-chunk', async (_event, filePath: string, start: number, end: number) => {
const fileHandle = await fsp.open(filePath, 'r');
try {
const safeStart = Math.max(0, Math.trunc(start));
const safeEnd = Math.max(safeStart, Math.trunc(end));
const buffer = Buffer.alloc(safeEnd - safeStart);
const result = await fileHandle.read(buffer, 0, buffer.length, safeStart);
return buffer.subarray(0, result.bytesRead).toString('base64');
} finally {
await fileHandle.close();
}
});
ipcMain.handle('get-file-size', async (_event, filePath: string) => {
const stats = await fsp.stat(filePath);
return stats.size;
});
ipcMain.handle('read-clipboard-files', async () => {
return await readClipboardFiles();
});
@@ -536,6 +570,13 @@ export function setupSystemHandlers(): void {
return true;
});
ipcMain.handle('append-file', async (_event, filePath: string, base64Data: string) => {
const buffer = Buffer.from(base64Data, 'base64');
await fsp.appendFile(filePath, buffer);
return true;
});
ipcMain.handle('delete-file', async (_event, filePath: string) => {
try {
await fsp.unlink(filePath);
@@ -567,6 +608,60 @@ export function setupSystemHandlers(): void {
cancelled: false };
});
ipcMain.handle('save-existing-file-as', async (_event, sourceFilePath: string, defaultFileName: string) => {
if (typeof sourceFilePath !== 'string' || !sourceFilePath.trim()) {
return { saved: false,
cancelled: false };
}
const stats = await fsp.stat(sourceFilePath);
if (!stats.isFile()) {
return { saved: false,
cancelled: false };
}
const result = await dialog.showSaveDialog({
defaultPath: defaultFileName || path.basename(sourceFilePath)
});
if (result.canceled || !result.filePath) {
return { saved: false,
cancelled: true };
}
await fsp.copyFile(sourceFilePath, result.filePath);
return { saved: true,
cancelled: false };
});
ipcMain.handle('open-file-path', async (_event, filePath: string) => {
if (typeof filePath !== 'string' || !filePath.trim()) {
return { opened: false,
reason: 'missing-path' };
}
try {
const stats = await fsp.stat(filePath);
if (!stats.isFile()) {
return { opened: false,
reason: 'not-a-file' };
}
const error = await shell.openPath(filePath);
return error
? { opened: false,
reason: error }
: { opened: true };
} catch (error) {
return { opened: false,
reason: error instanceof Error ? error.message : 'open-failed' };
}
});
ipcMain.handle('ensure-dir', async (_event, dirPath: string) => {
await fsp.mkdir(dirPath, { recursive: true });
return true;

View File

@@ -282,9 +282,15 @@ export interface ElectronAPI {
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
readFile: (filePath: string) => Promise<string>;
readFileChunk: (filePath: string, start: number, end: number) => Promise<string>;
getFileSize: (filePath: string) => Promise<number>;
writeFile: (filePath: string, data: string) => Promise<boolean>;
appendFile: (filePath: string, data: string) => Promise<boolean>;
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
saveExistingFileAs: (sourceFilePath: string, defaultFileName: string) => Promise<{ saved: boolean; cancelled: boolean }>;
openFilePath: (filePath: string) => Promise<{ opened: boolean; reason?: string }>;
fileExists: (filePath: string) => Promise<boolean>;
getFileUrl: (filePath: string) => Promise<string | null>;
deleteFile: (filePath: string) => Promise<boolean>;
ensureDir: (dirPath: string) => Promise<boolean>;
@@ -404,9 +410,15 @@ const electronAPI: ElectronAPI = {
},
readClipboardFiles: () => ipcRenderer.invoke('read-clipboard-files'),
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
readFileChunk: (filePath, start, end) => ipcRenderer.invoke('read-file-chunk', filePath, start, end),
getFileSize: (filePath) => ipcRenderer.invoke('get-file-size', filePath),
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
appendFile: (filePath, data) => ipcRenderer.invoke('append-file', filePath, data),
saveFileAs: (defaultFileName, data) => ipcRenderer.invoke('save-file-as', defaultFileName, data),
saveExistingFileAs: (sourceFilePath, defaultFileName) => ipcRenderer.invoke('save-existing-file-as', sourceFilePath, defaultFileName),
openFilePath: (filePath) => ipcRenderer.invoke('open-file-path', filePath),
fileExists: (filePath) => ipcRenderer.invoke('file-exists', filePath),
getFileUrl: (filePath) => ipcRenderer.invoke('get-file-url', filePath),
deleteFile: (filePath) => ipcRenderer.invoke('delete-file', filePath),
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath),

View File

@@ -196,9 +196,8 @@ router.get('/link-metadata', async (req, res) => {
const cached = metadataCache.get(url);
if (cached) {
const { cachedAt, ...metadata } = cached;
const { cachedAt: _cachedAt, ...metadata } = cached;
console.log(`[Link Metadata] Cache hit for ${url} (cached at ${new Date(cachedAt).toISOString()})`);
return res.json(metadata);
}

View File

@@ -484,6 +484,10 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
case 'direct-message':
case 'direct-message-status':
case 'direct-message-mutation':
case 'direct-message-typing':
case 'direct-message-sync-request':
case 'direct-message-sync':
case 'direct-call':
case 'server_icon_peer_request':
case 'server_icon_peer_data':
forwardRtcMessage(user, message);

View File

@@ -0,0 +1,8 @@
(function registerMetoYouVlcPlaceholder(globalScope) {
globalScope.MetoYouVlcJs = {
isPlaceholder: true,
createPlayer() {
throw new Error('Experimental VLC.js playback is enabled, but no VLC.js runtime is bundled. Replace /vlcjs/metoyou-vlc-player.js with a runtime adapter to enable playback.');
}
};
})(window);

View File

@@ -44,6 +44,21 @@ export const routes: Routes = [
loadComponent: () =>
import('./domains/direct-message/feature/dm-workspace/dm-workspace.component').then((module) => module.DmWorkspaceComponent)
},
{
path: 'pm',
loadComponent: () =>
import('./domains/direct-message/feature/dm-workspace/dm-workspace.component').then((module) => module.DmWorkspaceComponent)
},
{
path: 'pm/:conversationId',
loadComponent: () =>
import('./domains/direct-message/feature/dm-workspace/dm-workspace.component').then((module) => module.DmWorkspaceComponent)
},
{
path: 'call/:callId',
loadComponent: () =>
import('./features/direct-call/private-call.component').then((module) => module.PrivateCallComponent)
},
{
path: 'settings',
loadComponent: () =>

View File

@@ -36,6 +36,7 @@ import { ElectronBridgeService } from './core/platform/electron/electron-bridge.
import { UserStatusService } from './core/services/user-status.service';
import { GameActivityService } from './domains/game-activity';
import { PluginBootstrapService } from './domains/plugins';
import { DirectCallService } from './domains/direct-call';
import { ServersRailComponent } from './features/servers/servers-rail/servers-rail.component';
import { TitleBarComponent } from './features/shell/title-bar/title-bar.component';
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
@@ -99,6 +100,7 @@ export class App implements OnInit, OnDestroy {
readonly electronBridge = inject(ElectronBridgeService);
readonly userStatus = inject(UserStatusService);
readonly gameActivity = inject(GameActivityService);
readonly directCalls = inject(DirectCallService);
readonly dismissedDesktopUpdateNoticeKey = signal<string | null>(null);
readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null);
readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null);
@@ -117,7 +119,11 @@ export class App implements OnInit, OnDestroy {
return this.settingsModal.activePage() === 'theme'
&& this.settingsModal.themeStudioMinimized();
});
readonly isDirectMessageRoute = computed(() => this.getRoutePath(this.currentRouteUrl()).startsWith('/dm'));
readonly isDirectMessageRoute = computed(() => {
const routePath = this.getRoutePath(this.currentRouteUrl());
return routePath.startsWith('/dm') || routePath.startsWith('/pm') || routePath.startsWith('/call');
});
readonly desktopUpdateNoticeKey = computed(() => {
const updateState = this.desktopUpdateState();

View File

@@ -262,9 +262,15 @@ export interface ElectronApi {
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
readFile: (filePath: string) => Promise<string>;
readFileChunk: (filePath: string, start: number, end: number) => Promise<string>;
getFileSize: (filePath: string) => Promise<number>;
writeFile: (filePath: string, data: string) => Promise<boolean>;
appendFile: (filePath: string, data: string) => Promise<boolean>;
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
saveExistingFileAs?: (sourceFilePath: string, defaultFileName: string) => Promise<{ saved: boolean; cancelled: boolean }>;
openFilePath?: (filePath: string) => Promise<{ opened: boolean; reason?: string }>;
fileExists: (filePath: string) => Promise<boolean>;
getFileUrl: (filePath: string) => Promise<string | null>;
deleteFile: (filePath: string) => Promise<boolean>;
ensureDir: (dirPath: string) => Promise<boolean>;
onContextMenu: (listener: (params: ContextMenuParams) => void) => () => void;

View File

@@ -7,6 +7,7 @@ import { Injectable, signal } from '@angular/core';
* Each key maps to a file in `src/assets/audio/`.
*/
export enum AppSound {
Call = 'call',
Joining = 'joining',
Leave = 'leave',
Notification = 'notification'
@@ -38,6 +39,8 @@ export class NotificationAudioService {
private readonly sources = new Map<AppSound, string>();
private readonly activeLoops = new Map<AppSound, HTMLAudioElement>();
/** Reactive notification volume (0 - 1), persisted to localStorage. */
readonly notificationVolume = signal(this.loadVolume());
@@ -142,4 +145,37 @@ export class NotificationAudioService {
});
});
}
playLoop(sound: AppSound, volumeOverride?: number): void {
if (this.dndMuted() || this.activeLoops.has(sound))
return;
const src = this.sources.get(sound) ?? this.resolveAudioUrl(sound);
const vol = volumeOverride ?? this.notificationVolume();
if (vol === 0)
return;
const audio = new Audio(src);
audio.loop = true;
audio.preload = 'auto';
audio.volume = Math.max(0, Math.min(1, vol));
this.activeLoops.set(sound, audio);
audio.play().catch(() => {
this.activeLoops.delete(sound);
});
}
stop(sound: AppSound): void {
const audio = this.activeLoops.get(sound);
if (!audio)
return;
audio.pause();
audio.currentTime = 0;
audio.remove();
this.activeLoops.delete(sound);
}
}

View File

@@ -13,6 +13,8 @@ infrastructure adapters and UI.
| **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` |
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
| **direct-message** | One-to-one WebRTC messages, offline queueing, delivery state, and friends | `DirectMessageService`, `FriendService` |
| **direct-call** | Direct and small-group private calls initiated from people cards and direct messages | `DirectCallService` |
| **experimental-media** | Optional media playback experiments kept isolated from the default attachment path | `ExperimentalMediaSettingsService` |
| **game-activity** | Local game detection, server metadata matching, P2P now-playing sync, and elapsed playtime formatting | `GameActivityService`, `formatGameActivityElapsed()` |
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
| **plugins** | Client-only plugin manifests, load ordering, registry state, and signal-server support metadata | `PluginHostService`, `PluginRegistryService` |
@@ -32,6 +34,8 @@ The larger domains also keep longer design notes in their own folders:
- [authentication/README.md](authentication/README.md)
- [chat/README.md](chat/README.md)
- [direct-message/README.md](direct-message/README.md)
- [direct-call/README.md](direct-call/README.md)
- [experimental-media/README.md](experimental-media/README.md)
- [notifications/README.md](notifications/README.md)
- [plugins/README.md](plugins/README.md)
- [profile-avatar/README.md](profile-avatar/README.md)

View File

@@ -76,6 +76,8 @@ graph TD
Files move between peers using a request/response pattern over the WebRTC data channel. The sender announces a file, the receiver requests it, and chunks flow back one by one.
When Electron serves a file from disk, the sender reads one chunk at a time and uses the buffered data-channel send path so large saved media does not get loaded into renderer memory or flood the receiver.
```mermaid
sequenceDiagram
participant S as Sender
@@ -90,12 +92,12 @@ sequenceDiagram
loop Every 64 KB chunk
S->>R: file-chunk (attachmentId, index, data, progress, speed)
Note over R: Append to chunk buffer
Note over R: Append to chunk buffer, or append media directly to disk on Electron
Note over R: Update progress + EWMA speed
end
Note over R: All chunks received
Note over R: Reassemble blob
Note over R: Reassemble blob, or open completed Electron media from disk
Note over R: shouldPersistDownloadedAttachment? Save to disk
```
@@ -131,17 +133,27 @@ When the user navigates to a room, the manager watches the route and decides whi
The decision lives in `shouldAutoRequestWhenWatched()` which calls `isAttachmentMedia()` and checks against `MAX_AUTO_SAVE_SIZE_BYTES`.
Browser chat views render audio/video larger than 50 MB with the same generic file interface as other downloads, even after the bytes are available. Attachments with audio/video MIME types that Chromium reports as unsupported also use the generic file interface instead of a broken native player.
An optional experimental VLC.js adapter can be enabled from General settings. When enabled, unsupported downloaded audio/video files show a manual Play action that lazy-loads `/vlcjs/metoyou-vlc-player.js`. The runtime is intentionally isolated in the experimental media domain and is not part of the default attachment path.
## Persistence
On Electron, completed downloads are written to the app-data directory. The storage path is resolved per room and bucket:
On Electron, local audio/video uploads are played through the original filesystem path when Electron exposes one, and received audio/video downloads are appended to an app-data file as chunks arrive. Completed audio/video downloads are then played through a file-backed media URL instead of being reloaded into a renderer `Blob`, which avoids full-file renderer memory pressure during download, startup restore, and playback. The storage path for downloaded server-room files is resolved per room and bucket:
```
{appDataPath}/{serverId}/{roomName}/{bucket}/{attachmentId}.{ext?}
{appDataPath}/server/{roomName}/{bucket}/{attachmentId}.{ext?}
```
Room names are sanitised to remove filesystem-unsafe characters. The bucket is either `attachments` or `media` depending on the attachment type. The original filename is kept in attachment metadata for display and downloads, but the stored file uses the attachment ID plus the original extension so two uploads with the same visible name do not overwrite each other.
Direct-message attachments use the conversation id instead of the server-room path:
`AttachmentPersistenceService` handles startup migration from an older localStorage-based format into the database, and restores attachment metadata from the DB on init. On browser builds, files stay in memory only.
```
{appDataPath}/direct-messages/{conversationId}/{bucket}/{attachmentId}.{ext?}
```
Room and conversation names are sanitised to remove filesystem-unsafe characters. The bucket is `video`, `audio`, `image`, or `files` depending on the attachment type. The original filename is kept in attachment metadata for display and downloads, but the stored file uses the attachment ID plus the original extension so two uploads with the same visible name do not overwrite each other.
`AttachmentPersistenceService` handles startup migration from an older localStorage-based format into the database, and restores attachment metadata from the DB on init. On Electron, saved audio/video records are restored as file-backed URLs; other restored files still need their bytes loaded when a `Blob` URL is required. On browser builds, files stay in memory only.
## Runtime store

View File

@@ -70,17 +70,20 @@ export class AttachmentPersistenceService {
} catch { /* persistence is best-effort */ }
}
async saveFileToDisk(attachment: Attachment, blob: Blob): Promise<void> {
async saveFileToDisk(attachment: Attachment, blob: Blob): Promise<string | null> {
try {
const roomName = await this.resolveCurrentRoomName();
const diskPath = await this.attachmentStorage.saveBlob(attachment, blob, roomName);
const storageContainer = await this.resolveStorageContainerName(attachment);
const diskPath = await this.attachmentStorage.saveBlob(attachment, blob, storageContainer);
if (!diskPath)
return;
return null;
attachment.savedPath = diskPath;
void this.persistAttachmentMeta(attachment);
return diskPath;
} catch { /* disk save is best-effort */ }
return null;
}
async initFromDatabase(): Promise<void> {
@@ -120,6 +123,10 @@ export class AttachmentPersistenceService {
});
}
async resolveStorageContainerName(attachment: Pick<Attachment, 'messageId'>): Promise<string> {
return this.runtimeStore.getMessageRoomId(attachment.messageId) ?? await this.resolveCurrentRoomName();
}
private async loadFromDatabase(): Promise<void> {
try {
const allRecords: AttachmentMeta[] = await this.database.getAllAttachments();
@@ -176,6 +183,11 @@ export class AttachmentPersistenceService {
continue;
if (attachment.savedPath) {
if (await this.restoreMediaAttachmentFromFileUrl(attachment, attachment.savedPath)) {
hasChanges = true;
continue;
}
const savedBase64 = await this.attachmentStorage.readFile(attachment.savedPath);
if (savedBase64) {
@@ -186,6 +198,11 @@ export class AttachmentPersistenceService {
}
if (attachment.filePath) {
if (await this.restoreMediaAttachmentFromFileUrl(attachment, attachment.filePath)) {
hasChanges = true;
continue;
}
const originalBase64 = await this.attachmentStorage.readFile(attachment.filePath);
if (originalBase64) {
@@ -222,6 +239,26 @@ export class AttachmentPersistenceService {
);
}
private async restoreMediaAttachmentFromFileUrl(attachment: Attachment, filePath: string): Promise<boolean> {
if (!this.isPlayableMedia(attachment)) {
return false;
}
const fileUrl = await this.attachmentStorage.getFileUrl(filePath);
if (!fileUrl) {
return false;
}
attachment.objectUrl = fileUrl;
attachment.available = true;
return true;
}
private isPlayableMedia(attachment: Pick<Attachment, 'mime'>): boolean {
return attachment.mime.startsWith('video/') || attachment.mime.startsWith('audio/');
}
private async getRetainedSavedPathsForOtherMessages(messageId: string): Promise<Set<string>> {
const retainedSavedPaths = new Set<string>();

View File

@@ -49,6 +49,11 @@ export class AttachmentTransferTransportService {
diskPath: string,
isCancelled: () => boolean
): Promise<void> {
if (this.attachmentStorage.canReadFileChunks()) {
await this.streamFileFromDiskChunksToPeer(targetPeerId, messageId, fileId, diskPath, isCancelled);
return;
}
const base64Full = await this.attachmentStorage.readFile(diskPath);
if (!base64Full)
@@ -78,7 +83,45 @@ export class AttachmentTransferTransportService {
data: base64Chunk
};
this.webrtc.sendToPeer(targetPeerId, fileChunkEvent);
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
}
}
private async streamFileFromDiskChunksToPeer(
targetPeerId: string,
messageId: string,
fileId: string,
diskPath: string,
isCancelled: () => boolean
): Promise<void> {
const fileSize = await this.attachmentStorage.getFileSize(diskPath);
if (fileSize === null)
return;
const totalChunks = Math.ceil(fileSize / FILE_CHUNK_SIZE_BYTES);
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
if (isCancelled())
break;
const start = chunkIndex * FILE_CHUNK_SIZE_BYTES;
const end = Math.min(fileSize, start + FILE_CHUNK_SIZE_BYTES);
const base64Chunk = await this.attachmentStorage.readFileChunk(diskPath, start, end);
if (base64Chunk === null)
return;
const fileChunkEvent: FileChunkEvent = {
type: 'file-chunk',
messageId,
fileId,
index: chunkIndex,
total: totalChunks,
data: base64Chunk
};
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
}
}
}

View File

@@ -28,6 +28,22 @@ import { AttachmentPersistenceService } from './attachment-persistence.service';
import { AttachmentRuntimeStore } from './attachment-runtime.store';
import { AttachmentTransferTransportService } from './attachment-transfer-transport.service';
interface DiskReceiveAssembly {
path: string;
receivedCount: number;
receivedIndexes: Set<number>;
total: number;
}
interface ValidFileChunkPayload {
data: string;
fileId: string;
fromPeerId?: string;
index: number;
messageId: string;
total: number;
}
@Injectable({ providedIn: 'root' })
export class AttachmentTransferService {
private readonly webrtc = inject(RealtimeSessionFacade);
@@ -36,6 +52,9 @@ export class AttachmentTransferService {
private readonly persistence = inject(AttachmentPersistenceService);
private readonly transport = inject(AttachmentTransferTransportService);
private readonly diskReceiveAssemblies = new Map<string, DiskReceiveAssembly>();
private readonly diskReceiveChains = new Map<string, Promise<void>>();
getAttachmentMetasForMessages(messageIds: string[]): Record<string, AttachmentMeta[]> {
const result: Record<string, AttachmentMeta[]> = {};
@@ -174,10 +193,19 @@ export class AttachmentTransferService {
attachments.push(attachment);
this.runtimeStore.setOriginalFile(`${messageId}:${fileId}`, file);
const fileUrl = attachment.filePath && this.isPlayableMedia(attachment)
? await this.attachmentStorage.getFileUrl(attachment.filePath)
: null;
if (fileUrl) {
attachment.objectUrl = fileUrl;
attachment.available = true;
} else {
try {
attachment.objectUrl = URL.createObjectURL(file);
attachment.available = true;
} catch { /* non-critical */ }
}
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
void this.persistence.saveFileToDisk(attachment, file);
@@ -257,6 +285,19 @@ export class AttachmentTransferService {
if (!attachment)
return;
if (this.shouldReceiveToDisk(attachment)) {
this.enqueueDiskFileChunk(attachment, {
data,
fileId,
fromPeerId,
index,
messageId,
total
});
return;
}
const decodedBytes = this.transport.decodeBase64(data);
const assemblyKey = `${messageId}:${fileId}`;
const requestKey = this.buildRequestKey(messageId, fileId);
@@ -274,7 +315,7 @@ export class AttachmentTransferService {
this.updateTransferProgress(attachment, decodedBytes, fromPeerId);
this.runtimeStore.touch();
this.finalizeTransferIfComplete(attachment, assemblyKey, total);
void this.finalizeTransferIfComplete(attachment, assemblyKey, total);
}
async handleFileRequest(payload: FileRequestPayload): Promise<void> {
@@ -375,6 +416,7 @@ export class AttachmentTransferService {
this.runtimeStore.deleteChunkBuffer(assemblyKey);
this.runtimeStore.deleteChunkCount(assemblyKey);
void this.deleteDiskReceiveAssembly(assemblyKey);
attachment.receivedBytes = 0;
attachment.speedBps = 0;
@@ -533,11 +575,11 @@ export class AttachmentTransferService {
attachment.lastUpdateMs = now;
}
private finalizeTransferIfComplete(
private async finalizeTransferIfComplete(
attachment: Attachment,
assemblyKey: string,
total: number
): void {
): Promise<void> {
const receivedChunkCount = this.runtimeStore.getChunkCount(assemblyKey) ?? 0;
const completeBuffer = this.runtimeStore.getChunkBuffer(assemblyKey);
@@ -551,16 +593,167 @@ export class AttachmentTransferService {
const blob = new Blob(completeBuffer, { type: attachment.mime });
attachment.available = true;
attachment.objectUrl = URL.createObjectURL(blob);
if (shouldPersistDownloadedAttachment(attachment)) {
void this.persistence.saveFileToDisk(attachment, blob);
}
this.runtimeStore.deleteChunkBuffer(assemblyKey);
this.runtimeStore.deleteChunkCount(assemblyKey);
if (shouldPersistDownloadedAttachment(attachment)) {
const diskPath = await this.persistence.saveFileToDisk(attachment, blob);
const fileUrl = diskPath && this.isPlayableMedia(attachment)
? await this.attachmentStorage.getFileUrl(diskPath)
: null;
if (fileUrl) {
attachment.objectUrl = fileUrl;
} else {
attachment.objectUrl = URL.createObjectURL(blob);
}
} else {
attachment.objectUrl = URL.createObjectURL(blob);
}
attachment.available = true;
this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(attachment);
}
private isPlayableMedia(attachment: Pick<Attachment, 'mime'>): boolean {
return attachment.mime.startsWith('video/') || attachment.mime.startsWith('audio/');
}
private shouldReceiveToDisk(attachment: Attachment): boolean {
return this.isPlayableMedia(attachment) && !attachment.filePath && this.attachmentStorage.canWriteFiles();
}
private enqueueDiskFileChunk(
attachment: Attachment,
payload: ValidFileChunkPayload
): void {
const assemblyKey = `${payload.messageId}:${payload.fileId}`;
const previous = this.diskReceiveChains.get(assemblyKey) ?? Promise.resolve();
const next = previous
.catch(() => undefined)
.then(() => this.handleDiskFileChunk(attachment, assemblyKey, payload))
.catch((error: unknown) => this.handleDiskReceiveFailure(attachment, assemblyKey, error));
this.diskReceiveChains.set(assemblyKey, next);
void next.finally(() => {
if (this.diskReceiveChains.get(assemblyKey) === next) {
this.diskReceiveChains.delete(assemblyKey);
}
});
}
private async handleDiskFileChunk(
attachment: Attachment,
assemblyKey: string,
payload: ValidFileChunkPayload
): Promise<void> {
const decodedBytes = this.transport.decodeBase64(payload.data);
const requestKey = this.buildRequestKey(payload.messageId, payload.fileId);
this.runtimeStore.deletePendingRequest(requestKey);
this.clearAttachmentRequestError(attachment);
const assembly = await this.getOrCreateDiskReceiveAssembly(attachment, assemblyKey, payload.total);
if (!assembly) {
throw new Error('Could not prepare media download on disk.');
}
if (assembly.receivedIndexes.has(payload.index)) {
return;
}
if (payload.index !== assembly.receivedCount) {
throw new Error('Received media chunks out of order. Retry the download.');
}
const didAppend = await this.attachmentStorage.appendBase64(assembly.path, payload.data);
if (!didAppend) {
throw new Error('Could not write media download to disk.');
}
assembly.receivedIndexes.add(payload.index);
assembly.receivedCount += 1;
this.updateTransferProgress(attachment, decodedBytes, payload.fromPeerId);
this.runtimeStore.touch();
if (assembly.receivedCount < assembly.total && (attachment.receivedBytes ?? 0) < attachment.size) {
return;
}
const fileUrl = await this.attachmentStorage.getFileUrl(assembly.path);
if (!fileUrl) {
throw new Error('Could not open completed media download from disk.');
}
attachment.savedPath = assembly.path;
attachment.objectUrl = fileUrl;
attachment.available = true;
this.diskReceiveAssemblies.delete(assemblyKey);
this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(attachment);
}
private async getOrCreateDiskReceiveAssembly(
attachment: Attachment,
assemblyKey: string,
total: number
): Promise<DiskReceiveAssembly | null> {
const existing = this.diskReceiveAssemblies.get(assemblyKey);
if (existing) {
return existing;
}
const storageContainer = await this.persistence.resolveStorageContainerName(attachment);
const path = await this.attachmentStorage.createWritableFile(attachment, storageContainer);
if (!path) {
return null;
}
const assembly: DiskReceiveAssembly = {
path,
receivedCount: 0,
receivedIndexes: new Set<number>(),
total
};
this.diskReceiveAssemblies.set(assemblyKey, assembly);
return assembly;
}
private async handleDiskReceiveFailure(
attachment: Attachment,
assemblyKey: string,
error: unknown
): Promise<void> {
await this.deleteDiskReceiveAssembly(assemblyKey);
attachment.available = false;
attachment.objectUrl = undefined;
attachment.receivedBytes = 0;
attachment.speedBps = 0;
attachment.startedAtMs = undefined;
attachment.lastUpdateMs = undefined;
attachment.requestError = error instanceof Error && error.message
? error.message
: 'Media download failed. Retry the download.';
this.runtimeStore.touch();
}
private async deleteDiskReceiveAssembly(assemblyKey: string): Promise<void> {
const assembly = this.diskReceiveAssemblies.get(assemblyKey);
this.diskReceiveAssemblies.delete(assemblyKey);
if (assembly?.path) {
await this.attachmentStorage.deleteFile(assembly.path);
}
}
}

View File

@@ -1,2 +1,5 @@
/** Maximum file size (bytes) that is automatically saved or pushed for inline previews. */
export const MAX_AUTO_SAVE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
/** Maximum browser-only audio/video size that renders with an inline media player. */
export const MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB

View File

@@ -7,10 +7,24 @@ import {
sanitizeAttachmentRoomName
} from '../util/attachment-storage.util';
const DIRECT_MESSAGE_STORAGE_PREFIX = 'direct-message:';
@Injectable({ providedIn: 'root' })
export class AttachmentStorageService {
private readonly electronBridge = inject(ElectronBridgeService);
canWriteFiles(): boolean {
const electronApi = this.electronBridge.getApi();
return !!electronApi?.appendFile && !!electronApi.writeFile && !!electronApi.getAppDataPath;
}
canReadFileChunks(): boolean {
const electronApi = this.electronBridge.getApi();
return !!electronApi?.readFileChunk && !!electronApi.getFileSize;
}
async resolveExistingPath(
attachment: Pick<Attachment, 'filePath' | 'savedPath'>
): Promise<string | null> {
@@ -41,10 +55,73 @@ export class AttachmentStorageService {
}
}
async getFileSize(filePath: string): Promise<number | null> {
const electronApi = this.electronBridge.getApi();
if (!electronApi?.getFileSize || !filePath) {
return null;
}
try {
return await electronApi.getFileSize(filePath);
} catch {
return null;
}
}
async readFileChunk(filePath: string, start: number, end: number): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
if (!electronApi?.readFileChunk || !filePath) {
return null;
}
try {
return await electronApi.readFileChunk(filePath, start, end);
} catch {
return null;
}
}
async getFileUrl(filePath: string): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
if (!electronApi?.getFileUrl || !filePath) {
return null;
}
try {
return await electronApi.getFileUrl(filePath);
} catch {
return null;
}
}
async saveBlob(
attachment: Pick<Attachment, 'id' | 'filename' | 'mime'>,
blob: Blob,
roomName: string
): Promise<string | null> {
const diskPath = await this.createWritableFile(attachment, roomName);
if (!diskPath) {
return null;
}
try {
const arrayBuffer = await blob.arrayBuffer();
await this.writeBase64(diskPath, this.arrayBufferToBase64(arrayBuffer));
return diskPath;
} catch {
return null;
}
}
async createWritableFile(
attachment: Pick<Attachment, 'id' | 'filename' | 'mime'>,
roomName: string
): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
const appDataPath = await this.resolveAppDataPath();
@@ -54,14 +131,12 @@ export class AttachmentStorageService {
}
try {
const directoryPath = `${appDataPath}/server/${sanitizeAttachmentRoomName(roomName)}/${resolveAttachmentStorageBucket(attachment.mime)}`;
const directoryPath = this.resolveStorageDirectoryPath(appDataPath, roomName, attachment.mime);
await electronApi.ensureDir(directoryPath);
const arrayBuffer = await blob.arrayBuffer();
const diskPath = `${directoryPath}/${resolveAttachmentStoredFilename(attachment.id, attachment.filename)}`;
await electronApi.writeFile(diskPath, this.arrayBufferToBase64(arrayBuffer));
await this.writeBase64(diskPath, '');
return diskPath;
} catch {
@@ -69,6 +144,20 @@ export class AttachmentStorageService {
}
}
async appendBase64(filePath: string, base64Data: string): Promise<boolean> {
const electronApi = this.electronBridge.getApi();
if (!electronApi?.appendFile || !filePath) {
return false;
}
try {
return await electronApi.appendFile(filePath, base64Data);
} catch {
return false;
}
}
async deleteFile(filePath: string): Promise<void> {
const electronApi = this.electronBridge.getApi();
@@ -95,6 +184,18 @@ export class AttachmentStorageService {
}
}
private resolveStorageDirectoryPath(appDataPath: string, containerName: string, mime: string): string {
const bucket = resolveAttachmentStorageBucket(mime);
if (containerName.startsWith(DIRECT_MESSAGE_STORAGE_PREFIX)) {
const conversationId = containerName.slice(DIRECT_MESSAGE_STORAGE_PREFIX.length);
return `${appDataPath}/direct-messages/${sanitizeAttachmentRoomName(conversationId)}/${bucket}`;
}
return `${appDataPath}/server/${sanitizeAttachmentRoomName(containerName)}/${bucket}`;
}
private async findExistingPath(candidates: (string | null | undefined)[]): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
@@ -117,6 +218,16 @@ export class AttachmentStorageService {
return null;
}
private async writeBase64(filePath: string, base64Data: string): Promise<boolean> {
const electronApi = this.electronBridge.getApi();
if (!electronApi || !filePath) {
return false;
}
return await electronApi.writeFile(filePath, base64Data);
}
private arrayBufferToBase64(buffer: ArrayBuffer): string {
let binary = '';

View File

@@ -278,6 +278,19 @@ export class ChatMessagesComponent {
const electronApi = this.electronBridge.getApi();
if (electronApi) {
const diskPath = this.getAttachmentDiskPath(attachment);
if (diskPath && electronApi.saveExistingFileAs) {
try {
const result = await electronApi.saveExistingFileAs(diskPath, attachment.filename);
if (result.saved || result.cancelled)
return;
} catch {
/* fall back to blob/browser download */
}
}
const blob = await this.getAttachmentBlob(attachment);
if (blob) {
@@ -326,6 +339,9 @@ export class ChatMessagesComponent {
if (!attachment.objectUrl)
return null;
if (attachment.objectUrl.startsWith('file:'))
return null;
try {
const response = await fetch(attachment.objectUrl);
@@ -335,6 +351,10 @@ export class ChatMessagesComponent {
}
}
private getAttachmentDiskPath(attachment: Attachment): string | null {
return attachment.savedPath || attachment.filePath || null;
}
private blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();

View File

@@ -112,7 +112,8 @@
type="button"
class="font-semibold text-primary underline-offset-4 hover:underline"
(click)="openMissingPluginStore(missingEmbed)"
>store</button
>
store</button
>.
</article>
}
@@ -359,6 +360,30 @@
</button>
}
} @else {
@if (att.canOpenExternally) {
<button
class="inline-flex items-center gap-1.5 rounded bg-secondary px-2 py-1 text-xs text-foreground transition-colors hover:bg-secondary/80"
(click)="openAttachmentExternally(att)"
>
<ng-icon
name="lucideExternalLink"
class="h-3.5 w-3.5"
/>
Open
</button>
}
@if (att.canUseExperimentalPlayer) {
<button
class="inline-flex items-center gap-1.5 rounded bg-secondary px-2 py-1 text-xs text-foreground transition-colors hover:bg-secondary/80"
(click)="openExperimentalPlayer(att)"
>
<ng-icon
name="lucidePlay"
class="h-3.5 w-3.5"
/>
Play
</button>
}
<button
class="rounded bg-primary px-2 py-1 text-xs text-primary-foreground"
(click)="downloadAttachment(att)"
@@ -368,6 +393,30 @@
}
} @else {
<div class="text-xs text-muted-foreground">Shared from your device</div>
@if (att.canOpenExternally) {
<button
class="inline-flex items-center gap-1.5 rounded bg-secondary px-2 py-1 text-xs text-foreground transition-colors hover:bg-secondary/80"
(click)="openAttachmentExternally(att)"
>
<ng-icon
name="lucideExternalLink"
class="h-3.5 w-3.5"
/>
Open
</button>
}
@if (att.canUseExperimentalPlayer) {
<button
class="inline-flex items-center gap-1.5 rounded bg-secondary px-2 py-1 text-xs text-foreground transition-colors hover:bg-secondary/80"
(click)="openExperimentalPlayer(att)"
>
<ng-icon
name="lucidePlay"
class="h-3.5 w-3.5"
/>
Play
</button>
}
}
</div>
</div>
@@ -379,6 +428,22 @@
</div>
}
</div>
@if (att.experimentalPlayerActive && att.objectUrl) {
@defer {
<app-experimental-vlc-player
[src]="att.objectUrl"
[filename]="att.filename"
[mime]="att.mime"
[sizeLabel]="formatBytes(att.size)"
(closed)="closeExperimentalPlayer()"
(downloadRequested)="downloadAttachment(att)"
/>
} @loading {
<div class="mt-2 max-w-xl rounded-md border border-border bg-secondary/20 p-3 text-xs text-muted-foreground">
Loading experimental player...
</div>
}
}
}
}
</div>

View File

@@ -20,7 +20,9 @@ import {
lucideDownload,
lucideEdit,
lucideExpand,
lucideExternalLink,
lucideImage,
lucidePlay,
lucideReply,
lucideSmile,
lucideTrash2,
@@ -29,8 +31,15 @@ import {
import {
Attachment,
AttachmentFacade,
MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES,
MAX_AUTO_SAVE_SIZE_BYTES
} from '../../../../../attachment';
import { PlatformService } from '../../../../../../core/platform';
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
import {
ExperimentalMediaSettingsService
} from '../../../../../experimental-media';
import { ExperimentalVlcPlayerComponent } from '../../../../../experimental-media/feature/experimental-vlc-player/experimental-vlc-player.component';
import { KlipyService } from '../../../../application/services/klipy.service';
import { hasDedicatedChatEmbed } from '../../../../domain/rules/link-embed.rules';
import {
@@ -81,6 +90,9 @@ const RICH_MARKDOWN_PATTERNS = [
];
interface ChatMessageAttachmentViewModel extends Attachment {
canOpenExternally: boolean;
canUseExperimentalPlayer: boolean;
experimentalPlayerActive: boolean;
isAudio: boolean;
isUploader: boolean;
isVideo: boolean;
@@ -112,6 +124,7 @@ interface MissingPluginEmbedFallback {
ChatLinkEmbedComponent,
UserAvatarComponent,
PluginRenderHostComponent,
ExperimentalVlcPlayerComponent,
ThemeNodeDirective
],
viewProviders: [
@@ -120,7 +133,9 @@ interface MissingPluginEmbedFallback {
lucideDownload,
lucideEdit,
lucideExpand,
lucideExternalLink,
lucideImage,
lucidePlay,
lucideReply,
lucideSmile,
lucideTrash2,
@@ -140,9 +155,14 @@ export class ChatMessageItemComponent {
private readonly klipy = inject(KlipyService);
private readonly pluginRequirements = inject(PluginRequirementStateService);
private readonly pluginUi = inject(PluginUiRegistryService);
private readonly electronBridge = inject(ElectronBridgeService);
private readonly platform = inject(PlatformService);
private readonly experimentalMedia = inject(ExperimentalMediaSettingsService);
private readonly profileCard = inject(ProfileCardService);
private readonly router = inject(Router);
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
private readonly experimentalPlayerAttachmentId = signal<string | null>(null);
private readonly mediaSupportCache = new Map<string, boolean>();
readonly message = input.required<Message>();
readonly repliedMessage = input<Message | undefined>();
@@ -539,13 +559,51 @@ export class ChatMessageItemComponent {
this.downloadRequested.emit(attachment);
}
openExperimentalPlayer(attachment: Attachment): void {
if (!attachment.available || !attachment.objectUrl) {
return;
}
this.experimentalPlayerAttachmentId.set(attachment.id);
}
async openAttachmentExternally(attachment: Attachment): Promise<void> {
const diskPath = this.getAttachmentDiskPath(attachment);
const electronApi = this.electronBridge.getApi();
if (!diskPath || !electronApi?.openFilePath) {
return;
}
await electronApi.openFilePath(diskPath);
}
closeExperimentalPlayer(): void {
this.experimentalPlayerAttachmentId.set(null);
}
private buildAttachmentViewModel(attachment: Attachment): ChatMessageAttachmentViewModel {
const isVideo = this.isVideoAttachment(attachment);
const isAudio = this.isAudioAttachment(attachment);
const isRawVideo = this.isVideoAttachment(attachment);
const isRawAudio = this.isAudioAttachment(attachment);
const isRawPlayableMedia = isRawVideo || isRawAudio;
const isNativePlayableMedia = this.canPlayMediaType(attachment.mime);
const shouldUseDefaultFileInterface = isRawPlayableMedia &&
(!isNativePlayableMedia ||
(this.platform.isBrowser && attachment.size > MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES));
const isVideo = isRawVideo && !shouldUseDefaultFileInterface;
const isAudio = isRawAudio && !shouldUseDefaultFileInterface;
const requiresMediaDownloadAcceptance = (isVideo || isAudio) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
const canUseExperimentalPlayer = this.experimentalMedia.vlcJsPlaybackEnabled() &&
shouldUseDefaultFileInterface &&
isRawPlayableMedia &&
attachment.available &&
!!attachment.objectUrl;
return {
...attachment,
canOpenExternally: this.platform.isElectron && attachment.available && !!this.getAttachmentDiskPath(attachment),
canUseExperimentalPlayer,
experimentalPlayerActive: canUseExperimentalPlayer && this.experimentalPlayerAttachmentId() === attachment.id,
isAudio,
isUploader: this.isUploader(attachment),
isVideo,
@@ -572,6 +630,30 @@ export class ChatMessageItemComponent {
private getLiveAttachment(attachmentId: string): Attachment | undefined {
return this.attachmentsSvc.getForMessage(this.message().id).find((attachment) => attachment.id === attachmentId);
}
private getAttachmentDiskPath(attachment: Attachment): string | null {
return attachment.savedPath || attachment.filePath || null;
}
private canPlayMediaType(mime: string): boolean {
if (!mime.startsWith('video/') && !mime.startsWith('audio/')) {
return false;
}
const cached = this.mediaSupportCache.get(mime);
if (cached !== undefined) {
return cached;
}
const element = document.createElement(mime.startsWith('video/') ? 'video' : 'audio');
const canPlay = element.canPlayType(mime) !== '';
this.mediaSupportCache.set(mime, canPlay);
return canPlay;
}
}
function parsePluginEmbedToken(content: string): PluginEmbedToken | null {

View File

@@ -0,0 +1,16 @@
# Direct Call Domain
Direct calls coordinate private voice sessions started from people cards, direct-message headers, or active-call rail icons. The domain owns call session state and call-control events; media capture, camera, screen sharing, playback, and voice activity stay in the existing voice and screen-share domains.
## 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.
3. The recipient stores the incoming session, loops `assets/audio/call.wav`, and shows a desktop notification when permission allows. The ring stops when the recipient joins, leaves, or the call ends.
4. Opening `/call/:callId` shows the private call surface with portraits, voice indicators, media controls, screen/camera tiles, add-user control, and a narrow DM chat panel.
5. If a third participant is invited, the call creates a fresh empty group conversation and switches the call chat panel to it. Existing one-to-one messages stay in the original PM and are not copied into the group chat.
6. Starting a call from a group chat uses the group conversation id as the call id and rings every other participant.
7. Joining, leaving, ending, participant additions, and call chat conversion updates are mirrored as `direct-call` events over the same P2P/signaling fallback path used by direct messages.
8. 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.
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.

View File

@@ -0,0 +1,522 @@
import {
Injector,
runInInjectionContext,
signal,
ɵChangeDetectionScheduler as ChangeDetectionScheduler,
ɵEffectScheduler as EffectScheduler
} from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { Subject } from 'rxjs';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import {
VoiceActivityService,
VoiceConnectionFacade,
VoicePlaybackService
} from '../../../voice-connection';
import { VoiceSessionFacade } from '../../../voice-session';
import { DirectMessageService, PeerDeliveryService } from '../../../direct-message';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import type {
ChatEvent,
DirectMessageParticipant,
User
} from '../../../../shared-kernel';
import type { DirectMessageConversation } from '../../../direct-message';
import type { DirectCallSession } from '../../domain/models/direct-call.model';
import { DirectCallService } from './direct-call.service';
const alice = createUser('alice', 'Alice');
const bob = createUser('bob', 'Bob');
const charlie = createUser('charlie', 'Charlie');
describe('DirectCallService', () => {
it('only keeps sessions visible while a participant is joined', () => {
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
expect(context.service.hasOngoingActivity(createSession('calling', false))).toBe(false);
expect(context.service.hasOngoingActivity(createSession('ringing', false))).toBe(false);
expect(context.service.hasOngoingActivity(createSession('connected', true))).toBe(true);
expect(context.service.hasOngoingActivity(createSession('connected', false))).toBe(false);
expect(context.service.hasOngoingActivity(createSession('ended', true))).toBe(false);
});
it('keeps a locally left call visible only until the last peer leaves', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
const session = createSession('connected', true);
session.participants.bob.joined = true;
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
expect(context.service.visibleActiveSessions()).toHaveLength(1);
context.service.leaveCall(session.callId);
expect(context.service.visibleActiveSessions()).toHaveLength(1);
context.directCallEvents.next(createCallEvent('leave', bob, ['alice', 'bob']));
await vi.waitFor(() => expect(context.service.visibleActiveSessions()).toHaveLength(0));
});
it('hides an incoming call after the last joined participant leaves before answer', async () => {
const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
await vi.waitFor(() => expect(context.service.visibleActiveSessions()).toHaveLength(1));
await vi.waitFor(() => expect(context.audio.playLoop).toHaveBeenCalledWith(AppSound.Call));
context.directCallEvents.next(createCallEvent('leave', alice, ['alice', 'bob']));
await vi.waitFor(() => expect(context.service.visibleActiveSessions()).toHaveLength(0));
expect(context.audio.stop).toHaveBeenCalledWith(AppSound.Call);
});
it('rejoins an existing direct call instead of ringing a duplicate after leaving locally', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
const session = createSession('connected', true);
session.participants.alice.joined = false;
session.participants.bob.joined = true;
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
context.service.joinCall = vi.fn(async () => undefined);
await context.service.startCall(bob);
expect(context.service.visibleActiveSessions()).toHaveLength(1);
expect(context.service.joinCall).toHaveBeenCalledWith('dm-alice-bob');
expect(context.delivery.sendCallEvent).not.toHaveBeenCalled();
expect(context.router.navigate).toHaveBeenCalledWith(['/call', 'dm-alice-bob']);
});
it('reuses an existing group call by conversation id instead of creating a duplicate call', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob, charlie] });
const session = createGroupSession('dm-original-call', 'dm-group-live', [alice, bob, charlie]);
const conversation = createGroupConversation('dm-group-live', [alice, bob, charlie]);
session.participants.alice.joined = false;
session.participants.bob.joined = true;
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
context.service.joinCall = vi.fn(async () => undefined);
await context.service.startConversationCall(conversation);
expect(context.service.visibleActiveSessions()).toHaveLength(1);
expect(context.service.joinCall).toHaveBeenCalledWith('dm-original-call');
expect(context.directMessages.createGroupConversation).not.toHaveBeenCalled();
expect(context.delivery.sendCallEvent).not.toHaveBeenCalled();
expect(context.router.navigate).toHaveBeenCalledWith(['/call', 'dm-original-call']);
});
it('leaves a joined call before joining a different call', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob, charlie] });
const firstSession = createSession('connected', true);
const nextSession = createDirectSession('dm-alice-charlie', alice, charlie, 'connected', false);
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(firstSession);
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(nextSession);
await context.service.joinCall(nextSession.callId);
expect(context.service.sessionById(firstSession.callId)?.participants.alice.joined).toBe(false);
expect(context.delivery.sendCallEvent).toHaveBeenCalledWith('bob', expect.objectContaining({
directCall: expect.objectContaining({
action: 'leave',
callId: firstSession.callId
}),
type: 'direct-call'
}));
});
it('disconnects the current voice channel before joining a call', async () => {
const voiceConnectedAlice: User = {
...alice,
voiceState: {
isConnected: true,
isMuted: false,
isDeafened: false,
roomId: 'voice-room-1',
serverId: 'server-1'
}
};
const context = createServiceContext({ currentUser: voiceConnectedAlice, allUsers: [voiceConnectedAlice, bob] });
const session = createSession('connected', false);
session.participants.bob.joined = true;
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
await context.service.joinCall(session.callId);
expect(context.voice.stopVoiceHeartbeat).toHaveBeenCalled();
expect(context.voice.disableVoice).toHaveBeenCalled();
expect(context.voice.broadcastMessage).toHaveBeenCalledWith(expect.objectContaining({
type: 'voice-state',
voiceState: expect.objectContaining({
isConnected: false,
roomId: 'voice-room-1',
serverId: 'server-1'
})
}));
expect(context.voiceSession.endSession).toHaveBeenCalled();
});
it('starts group calls by keeping the rail-visible call session and ringing every other participant', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [
alice,
bob,
charlie
] });
const conversation = createGroupConversation('dm-group-test', [
alice,
bob,
charlie
]);
context.service.joinCall = vi.fn(async (callId: string) => {
const session = context.service.sessionById(callId);
if (!session) {
return;
}
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession({
...session,
status: 'connected',
participants: {
...session.participants,
alice: {
...session.participants.alice,
joined: true
}
}
});
});
await context.service.startConversationCall(conversation);
expect(context.service.visibleActiveSessions()).toHaveLength(1);
expect(context.delivery.sendCallEvent).toHaveBeenCalledWith('bob', expect.objectContaining({
directCall: expect.objectContaining({
action: 'ring',
callId: 'dm-group-test'
}),
type: 'direct-call'
}));
expect(context.delivery.sendCallEvent).toHaveBeenCalledWith('charlie', expect.objectContaining({
directCall: expect.objectContaining({
action: 'ring',
callId: 'dm-group-test'
}),
type: 'direct-call'
}));
expect(context.router.navigate).toHaveBeenCalledWith(['/call', 'dm-group-test']);
});
});
interface ServiceContextOptions {
allUsers: User[];
currentUser: User;
}
interface ServiceContext {
audio: {
playLoop: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
};
delivery: {
sendCallEvent: ReturnType<typeof vi.fn>;
};
directCallEvents: Subject<ChatEvent>;
directMessages: {
createConversation: ReturnType<typeof vi.fn>;
createGroupConversation: ReturnType<typeof vi.fn>;
openConversation: ReturnType<typeof vi.fn>;
};
router: {
navigate: ReturnType<typeof vi.fn>;
};
service: DirectCallService;
voice: {
broadcastMessage: ReturnType<typeof vi.fn>;
disableVoice: ReturnType<typeof vi.fn>;
stopVoiceHeartbeat: ReturnType<typeof vi.fn>;
};
voiceSession: {
endSession: ReturnType<typeof vi.fn>;
};
}
function createServiceContext(options: ServiceContextOptions): ServiceContext {
const currentUser = signal<User | null>(options.currentUser);
const allUsers = signal<User[]>(options.allUsers);
const directCallEvents = new Subject<ChatEvent>();
const router = {
navigate: vi.fn(async () => true)
};
const store = {
dispatch: vi.fn(),
selectSignal: vi.fn((selector: unknown) => {
if (selector === selectCurrentUser) {
return currentUser;
}
if (selector === selectAllUsers) {
return allUsers;
}
throw new Error('Unexpected selector requested by DirectCallService test.');
})
};
const directMessages = {
createConversation: vi.fn(async (user: User) => createDirectConversation(options.currentUser, user)),
createGroupConversation: vi.fn(async (participants: DirectMessageParticipant[], title?: string, conversationId = 'dm-group-test') => ({
...createGroupConversation(conversationId, participants.map(participantToUser)),
title
})),
openConversation: vi.fn(async () => undefined)
};
const delivery = {
directCallEvents$: directCallEvents.asObservable(),
sendCallEvent: vi.fn(() => true)
};
const audio = {
playLoop: vi.fn(),
stop: vi.fn()
};
const voice = {
broadcastMessage: vi.fn(),
disableVoice: vi.fn(),
ensureSignalingConnected: vi.fn(async () => true),
isDeafened: vi.fn(() => false),
isMuted: vi.fn(() => false),
setLocalStream: vi.fn(async () => undefined),
startVoiceHeartbeat: vi.fn(),
stopVoiceHeartbeat: vi.fn(),
syncOutgoingVoiceRouting: vi.fn(),
toggleMute: vi.fn()
};
const voiceSession = {
endSession: vi.fn()
};
const injector = Injector.create({
providers: [
{
provide: ChangeDetectionScheduler,
useValue: {
notify: vi.fn()
}
},
{
provide: EffectScheduler,
useValue: {
add: vi.fn(),
flush: vi.fn(),
remove: vi.fn(),
schedule: vi.fn()
}
},
{
provide: DirectMessageService,
useValue: directMessages
},
{
provide: NotificationAudioService,
useValue: audio
},
{
provide: PeerDeliveryService,
useValue: delivery
},
{
provide: Router,
useValue: router
},
{
provide: Store,
useValue: store
},
{
provide: VoiceActivityService,
useValue: {
trackLocalMic: vi.fn(),
untrackLocalMic: vi.fn()
}
},
{
provide: VoiceConnectionFacade,
useValue: voice
},
{
provide: VoiceSessionFacade,
useValue: voiceSession
},
{
provide: VoicePlaybackService,
useValue: {
playPendingStreams: vi.fn(),
teardownAll: vi.fn()
}
}
]
});
return {
audio,
delivery,
directCallEvents,
directMessages,
router,
service: runInInjectionContext(injector, () => new DirectCallService()),
voice,
voiceSession
};
}
function createCallEvent(action: 'leave' | 'ring', sender: User, participantIds: string[]): ChatEvent {
return {
type: 'direct-call',
directCall: {
action,
callId: 'dm-alice-bob',
conversationId: 'dm-alice-bob',
createdAt: 10,
sender: toParticipant(sender),
participantIds,
participants: [alice, bob].map(toParticipant)
}
};
}
function createSession(status: DirectCallSession['status'], joined: boolean): DirectCallSession {
return {
callId: 'dm-alice-bob',
conversationId: 'dm-alice-bob',
createdAt: 10,
initiatorId: 'alice',
participantIds: ['alice', 'bob'],
participants: {
alice: {
userId: 'alice',
profile: toParticipant(alice),
joined
},
bob: {
userId: 'bob',
profile: toParticipant(bob),
joined: false
}
},
status
};
}
function createDirectSession(
callId: string,
currentUser: User,
peer: User,
status: DirectCallSession['status'],
joined: boolean
): DirectCallSession {
const currentParticipant = toParticipant(currentUser);
const peerParticipant = toParticipant(peer);
return {
callId,
conversationId: callId,
createdAt: 10,
initiatorId: currentParticipant.userId,
participantIds: [currentParticipant.userId, peerParticipant.userId],
participants: {
[currentParticipant.userId]: {
userId: currentParticipant.userId,
profile: currentParticipant,
joined
},
[peerParticipant.userId]: {
userId: peerParticipant.userId,
profile: peerParticipant,
joined: false
}
},
status
};
}
function createGroupSession(callId: string, conversationId: string, users: User[]): DirectCallSession {
const participants = users.map(toParticipant);
return {
callId,
conversationId,
createdAt: 10,
initiatorId: participants[0].userId,
participantIds: participants.map((participant) => participant.userId),
participants: Object.fromEntries(participants.map((participant) => [
participant.userId,
{
userId: participant.userId,
profile: participant,
joined: false
}
])),
status: 'connected'
};
}
function createDirectConversation(currentUser: User, peer: User): DirectMessageConversation {
const participants = [toParticipant(currentUser), toParticipant(peer)];
const participantIds = participants.map((participant) => participant.userId).sort();
return {
id: `dm-${participantIds.join('-')}`,
kind: 'direct',
lastMessageAt: 10,
messages: [],
participantProfiles: Object.fromEntries(participants.map((participant) => [participant.userId, participant])),
participants: participantIds,
unreadCount: 0
};
}
function createGroupConversation(conversationId: string, users: User[]): DirectMessageConversation {
const participants = users.map(toParticipant);
const participantIds = participants.map((participant) => participant.userId).sort();
return {
id: conversationId,
kind: 'group',
lastMessageAt: 10,
messages: [],
participantProfiles: Object.fromEntries(participants.map((participant) => [participant.userId, participant])),
participants: participantIds,
title: participants.map((participant) => participant.displayName).join(', '),
unreadCount: 0
};
}
function participantToUser(participant: DirectMessageParticipant): User {
return createUser(participant.userId, participant.displayName);
}
function toParticipant(user: User): DirectMessageParticipant {
return {
userId: user.oderId || user.id,
username: user.username,
displayName: user.displayName
};
}
function createUser(id: string, displayName: string): User {
return {
id,
oderId: id,
username: displayName.toLowerCase(),
displayName,
status: 'online',
role: 'member',
joinedAt: 1
};
}

View File

@@ -0,0 +1,809 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Injectable,
computed,
effect,
inject,
signal
} from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import {
VoiceActivityService,
VoiceConnectionFacade,
VoicePlaybackService
} from '../../../voice-connection';
import { VoiceSessionFacade } from '../../../voice-session';
import { DirectMessageService, PeerDeliveryService } from '../../../direct-message';
import type { DirectMessageConversation } from '../../../direct-message';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import { UsersActions } from '../../../../store/users/users.actions';
import {
DirectCallEventPayload,
DirectMessageParticipant,
User
} from '../../../../shared-kernel';
import { DirectCallSession, participantToUser } from '../../domain/models/direct-call.model';
import { toDirectMessageParticipant } from '../../../direct-message';
@Injectable({ providedIn: 'root' })
export class DirectCallService {
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly delivery = inject(PeerDeliveryService);
private readonly directMessages = inject(DirectMessageService);
private readonly audio = inject(NotificationAudioService);
private readonly voice = inject(VoiceConnectionFacade);
private readonly voiceSession = inject(VoiceSessionFacade);
private readonly voiceActivity = inject(VoiceActivityService);
private readonly playback = inject(VoicePlaybackService);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private readonly users = this.store.selectSignal(selectAllUsers);
private readonly sessionsSignal = signal<DirectCallSession[]>([]);
readonly sessions = computed(() => this.sessionsSignal());
readonly activeSessions = computed(() => this.sessions().filter((session) => session.status !== 'ended'));
readonly visibleActiveSessions = computed(() => this.activeSessions().filter((session) => this.hasOngoingActivity(session)));
readonly currentSession = signal<DirectCallSession | null>(null);
readonly hasActiveCall = computed(() => this.visibleActiveSessions().length > 0);
constructor() {
this.delivery.directCallEvents$.subscribe((event) => {
if (event.directCall) {
void this.handleIncomingCallEvent(event.directCall);
}
});
effect(() => {
const session = this.currentSession();
if (!session || session.status === 'ended') {
return;
}
const peerIds = this.remoteParticipantIds(session);
this.voice.syncOutgoingVoiceRouting(peerIds);
});
}
sessionById(callId: string | null | undefined): DirectCallSession | null {
if (!callId) {
return null;
}
return this.sessionsSignal().find((session) => session.callId === callId) ?? null;
}
isCallingUser(user: User): boolean {
const userId = this.userKey(user);
return this.visibleActiveSessions().some((session) => session.participantIds.includes(userId));
}
isCallingConversation(conversationId: string | null | undefined): boolean {
if (!conversationId) {
return false;
}
return this.visibleActiveSessions().some((session) => session.callId === conversationId || session.conversationId === conversationId);
}
hasConnectedParticipant(session: DirectCallSession | null | undefined): boolean {
if (!session || session.status === 'ended') {
return false;
}
return Object.values(session.participants).some((participant) => participant.joined);
}
hasOngoingActivity(session: DirectCallSession | null | undefined): boolean {
return this.hasConnectedParticipant(session);
}
async startCall(user: User): Promise<DirectCallSession> {
const conversation = await this.directMessages.createConversation(user);
const me = this.requireCurrentUser();
const meParticipant = toDirectMessageParticipant(me);
const peerParticipant = toDirectMessageParticipant(user);
const participantIds = this.uniqueIds([meParticipant.userId, peerParticipant.userId]);
const activeSession = this.findLiveSessionForParticipants(participantIds, conversation.id);
if (activeSession) {
return await this.rejoinLiveSession(activeSession);
}
const existing = this.sessionById(conversation.id);
const session = existing ?? this.createSession({
callId: conversation.id,
conversationId: conversation.id,
createdAt: Date.now(),
initiatorId: meParticipant.userId,
participantIds,
participants: [meParticipant, peerParticipant],
status: 'calling'
});
this.upsertSession(session);
this.currentSession.set(session);
await this.joinCall(session.callId, false);
this.sendCallEvent(peerParticipant.userId, 'ring', session);
await this.router.navigate(['/call', session.callId]);
return session;
}
async startConversationCall(conversation: DirectMessageConversation): Promise<DirectCallSession> {
if (this.isGroupConversation(conversation)) {
return await this.startGroupCall(conversation);
}
const meId = this.currentUserId();
const peerId = conversation.participants.find((participantId) => participantId !== meId);
if (!peerId) {
throw new Error('Direct message conversation has no recipient to call.');
}
const peer = this.userForParticipant(peerId) ?? participantToUser(this.participantFromConversation(conversation, peerId));
return await this.startCall(peer);
}
async openCall(callId: string): Promise<void> {
const session = this.sessionById(callId);
if (session?.conversationId) {
await this.directMessages.openConversation(session.conversationId);
}
this.currentSession.set(session);
}
async joinCall(callId: string, notifyPeers = true): Promise<void> {
const session = this.sessionById(callId);
const me = this.requireCurrentUser();
const meId = this.userKey(me);
if (!session) {
return;
}
this.leaveOtherJoinedCalls(callId);
this.leaveCurrentVoiceTargetForCall(callId);
this.audio.stop(AppSound.Call);
const ok = await this.voice.ensureSignalingConnected();
if (!ok || !navigator.mediaDevices?.getUserMedia) {
return;
}
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: false
}
});
await this.voice.setLocalStream(stream);
this.voiceActivity.trackLocalMic(meId, stream);
this.voice.startVoiceHeartbeat(session.callId, session.callId);
this.updateLocalVoiceState(session, true);
this.playback.playPendingStreams({
isConnected: true,
outputVolume: 1,
isDeafened: this.voice.isDeafened()
});
const nextSession = this.markParticipantJoined(session, meId, true, 'connected');
this.upsertSession(nextSession);
this.currentSession.set(nextSession);
if (notifyPeers) {
this.broadcastCallEvent('join', nextSession);
}
}
leaveCall(callId: string, endForEveryone = false): void {
const session = this.sessionById(callId);
if (!session) {
return;
}
this.leaveJoinedSession(session, endForEveryone);
}
leaveCurrentJoinedCall(exceptCallId?: string): void {
for (const session of this.sessionsSignal()) {
if (session.callId === exceptCallId || !this.isCurrentUserJoined(session)) {
continue;
}
this.leaveJoinedSession(session);
}
}
private leaveJoinedSession(session: DirectCallSession, endForEveryone = false): void {
const action = endForEveryone ? 'end' : 'leave';
const nextSession = this.markCurrentUserLeft(session, endForEveryone);
this.audio.stop(AppSound.Call);
this.broadcastCallEvent(action, nextSession);
this.stopLocalMedia(nextSession);
this.upsertSession(nextSession);
this.currentSession.set(null);
}
async inviteUser(callId: string, user: User): Promise<void> {
const session = this.sessionById(callId);
if (!session) {
return;
}
const participant = toDirectMessageParticipant(user);
const nextSession = this.createSession({
...session,
participantIds: this.uniqueIds([...session.participantIds, participant.userId]),
participants: [...Object.values(session.participants).map((entry) => entry.profile), participant],
status: session.status
});
const convertedSession = await this.convertToGroupConversationIfNeeded(this.preserveJoinedParticipants(session, nextSession));
this.upsertSession(convertedSession);
this.currentSession.set(convertedSession);
this.broadcastCallEvent('update', convertedSession, [participant.userId]);
this.sendCallEvent(participant.userId, 'ring', convertedSession);
}
remoteParticipantIds(session: DirectCallSession): string[] {
const meId = this.currentUserId();
return session.participantIds.filter((participantId) => participantId !== meId);
}
userForParticipant(participantId: string): User | null {
const known = this.users().find((user) => user.id === participantId || user.oderId === participantId || user.peerId === participantId);
if (known) {
return known;
}
const participant = this.currentSession()?.participants[participantId]?.profile;
return participant ? participantToUser(participant) : null;
}
private async handleIncomingCallEvent(payload: DirectCallEventPayload): Promise<void> {
const meId = this.currentUserId();
if (!meId || payload.sender.userId === meId) {
return;
}
const participants = this.callParticipantsFromPayload(payload);
const existing = this.sessionById(payload.callId);
const incomingSession = this.createSession({
callId: payload.callId,
conversationId: payload.conversationId,
createdAt: payload.createdAt,
initiatorId: existing?.initiatorId ?? payload.sender.userId,
participantIds: this.uniqueIds([
...payload.participantIds,
meId,
payload.sender.userId
]),
participants,
status: this.resolveIncomingStatus(payload.action, existing?.status)
});
const preservedSession = existing ? this.preserveJoinedParticipants(existing, incomingSession) : incomingSession;
const session = this.applyIncomingParticipantState(preservedSession, payload);
this.upsertSession(session);
this.currentSession.set(this.currentSession()?.callId === session.callId ? session : this.currentSession());
this.markRemoteVoiceState(payload.sender.userId, session, payload.action === 'join');
if (payload.action === 'update') {
await this.ensureCallConversation(session);
return;
}
if (payload.action === 'ring') {
await this.ensureCallConversation(session);
if (session.status !== 'connected') {
this.audio.playLoop(AppSound.Call);
} else {
this.audio.stop(AppSound.Call);
}
await this.showIncomingNotification(payload.sender.displayName, payload.callId);
return;
}
this.audio.stop(AppSound.Call);
if (payload.action === 'end') {
if (this.currentSession()?.callId === payload.callId) {
this.stopLocalMedia(session);
this.currentSession.set(null);
}
}
}
private async startGroupCall(conversation: DirectMessageConversation): Promise<DirectCallSession> {
const me = this.requireCurrentUser();
const meParticipant = toDirectMessageParticipant(me);
const participantIds = this.uniqueIds([...conversation.participants, meParticipant.userId]);
const conversationParticipants = participantIds.map((participantId) => this.participantFromConversation(conversation, participantId));
const participants = this.uniqueParticipants([meParticipant, ...conversationParticipants]);
const activeSession = this.findLiveSessionForParticipants(participantIds, conversation.id);
if (activeSession) {
return await this.rejoinLiveSession(activeSession);
}
const existing = this.sessionById(conversation.id);
const session = existing && existing.status !== 'ended'
? existing
: this.createSession({
callId: conversation.id,
conversationId: conversation.id,
createdAt: Date.now(),
initiatorId: meParticipant.userId,
participantIds,
participants,
status: 'calling'
});
this.upsertSession(session);
this.currentSession.set(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;
}
private async rejoinLiveSession(session: DirectCallSession): Promise<DirectCallSession> {
this.upsertSession(session);
this.currentSession.set(session);
if (!this.isCurrentUserJoined(session)) {
await this.joinCall(session.callId);
}
const nextSession = this.sessionById(session.callId) ?? session;
await this.router.navigate(['/call', nextSession.callId]);
return nextSession;
}
private leaveOtherJoinedCalls(callId: string): void {
this.leaveCurrentJoinedCall(callId);
}
private leaveCurrentVoiceTargetForCall(callId: string): void {
const user = this.currentUser();
const voiceState = user?.voiceState;
if (!voiceState?.isConnected || (voiceState.roomId === callId && voiceState.serverId === callId)) {
return;
}
const userId = user?.id;
const userKey = user ? this.userKey(user) : undefined;
this.voice.stopVoiceHeartbeat();
if (userKey) {
this.voiceActivity.untrackLocalMic(userKey);
}
this.voice.disableVoice();
this.playback.teardownAll();
this.voiceSession.endSession();
if (userId) {
this.store.dispatch(UsersActions.updateVoiceState({
userId,
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: undefined,
serverId: undefined
}
}));
}
this.voice.broadcastMessage({
type: 'voice-state',
oderId: userKey,
displayName: user?.displayName || 'User',
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: voiceState.roomId,
serverId: voiceState.serverId
}
});
}
private async ensureConversation(sender: DirectMessageParticipant): Promise<void> {
await this.directMessages.createConversation(participantToUser(sender));
}
private isGroupConversation(conversation: DirectMessageConversation): boolean {
return conversation.kind === 'group' || conversation.participants.length > 2;
}
private participantFromConversation(conversation: DirectMessageConversation, participantId: string): DirectMessageParticipant {
const knownUser = this.userForParticipant(participantId);
const profile = conversation.participantProfiles[participantId];
if (knownUser) {
return toDirectMessageParticipant(knownUser);
}
return profile ?? {
userId: participantId,
username: participantId,
displayName: participantId
};
}
private resolveIncomingStatus(action: DirectCallEventPayload['action'], currentStatus?: DirectCallSession['status']): DirectCallSession['status'] {
if (action === 'ring') {
return currentStatus === 'connected' ? 'connected' : 'ringing';
}
if (action === 'join') {
return 'connected';
}
if (action === 'end') {
return 'ended';
}
if (action === 'update') {
return currentStatus ?? 'ringing';
}
if (action === 'leave') {
return currentStatus === 'ringing' ? 'ringing' : 'connected';
}
return currentStatus ?? 'ringing';
}
private applyIncomingParticipantState(session: DirectCallSession, payload: DirectCallEventPayload): DirectCallSession {
if (payload.action === 'ring' || payload.action === 'join') {
return this.markParticipantJoined(session, payload.sender.userId, true, payload.action === 'join' ? 'connected' : session.status);
}
if (payload.action === 'leave') {
const nextSession = this.markParticipantJoined(session, payload.sender.userId, false, session.status);
return this.hasConnectedParticipant(nextSession)
? nextSession
: {
...nextSession,
status: 'ended'
};
}
if (payload.action === 'end') {
return {
...session,
status: 'ended'
};
}
return session;
}
private sendCallEvent(recipientId: string, action: DirectCallEventPayload['action'], session: DirectCallSession): void {
const me = this.requireCurrentUser();
this.delivery.sendCallEvent(recipientId, {
type: 'direct-call',
directCall: {
action,
callId: session.callId,
conversationId: session.conversationId,
createdAt: session.createdAt,
sender: toDirectMessageParticipant(me),
participantIds: session.participantIds,
participants: Object.values(session.participants).map((participant) => participant.profile)
}
});
}
private broadcastCallEvent(action: DirectCallEventPayload['action'], session: DirectCallSession, excludedParticipantIds: string[] = []): void {
const excluded = new Set(excludedParticipantIds);
for (const participantId of this.remoteParticipantIds(session)) {
if (excluded.has(participantId)) {
continue;
}
this.sendCallEvent(participantId, action, session);
}
}
private async convertToGroupConversationIfNeeded(session: DirectCallSession): Promise<DirectCallSession> {
if (session.participantIds.length <= 2) {
return session;
}
const conversation = await this.directMessages.createGroupConversation(
Object.values(session.participants).map((participant) => participant.profile),
this.groupConversationTitle(session),
session.conversationId.startsWith('dm-group-') ? session.conversationId : undefined
);
return {
...session,
conversationId: conversation.id
};
}
private preserveJoinedParticipants(previousSession: DirectCallSession, nextSession: DirectCallSession): DirectCallSession {
return {
...nextSession,
participants: Object.fromEntries(Object.values(nextSession.participants).map((participant) => [
participant.userId,
{
...participant,
joined: previousSession.participants[participant.userId]?.joined ?? participant.joined
}
]))
};
}
private async ensureCallConversation(session: DirectCallSession): Promise<void> {
if (session.participantIds.length > 2) {
await this.directMessages.createGroupConversation(
Object.values(session.participants).map((participant) => participant.profile),
this.groupConversationTitle(session),
session.conversationId
);
return;
}
const sender = Object.values(session.participants)
.map((participant) => participant.profile)
.find((participant) => participant.userId !== this.currentUserId());
if (sender) {
await this.ensureConversation(sender);
}
}
private callParticipantsFromPayload(payload: DirectCallEventPayload): DirectMessageParticipant[] {
return this.uniqueParticipants([
payload.sender,
toDirectMessageParticipant(this.requireCurrentUser()),
...(payload.participants ?? []),
...payload.participantIds
.map((participantId) => this.userForParticipant(participantId))
.filter((user): user is User => !!user)
.map((user) => toDirectMessageParticipant(user))
]);
}
private groupConversationTitle(session: DirectCallSession): string {
const names = Object.values(session.participants)
.map((participant) => participant.profile.displayName || participant.profile.username || participant.userId);
if (names.length <= 3) {
return names.join(', ');
}
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
}
private createSession(input: {
callId: string;
conversationId: string;
createdAt: number;
initiatorId: string;
participantIds: string[];
participants: DirectMessageParticipant[];
status: DirectCallSession['status'];
}): DirectCallSession {
const participants = Object.fromEntries(this.uniqueParticipants(input.participants).map((participant) => [
participant.userId,
{
userId: participant.userId,
profile: participant,
joined: input.status === 'connected' && participant.userId === this.currentUserId()
}
]));
return {
callId: input.callId,
conversationId: input.conversationId,
createdAt: input.createdAt,
initiatorId: input.initiatorId,
participantIds: this.uniqueIds(input.participantIds),
participants,
status: input.status
};
}
private markParticipantJoined(
session: DirectCallSession,
participantId: string,
joined: boolean,
status: DirectCallSession['status']
): DirectCallSession {
const participant = session.participants[participantId];
return {
...session,
status,
participants: {
...session.participants,
...(participant
? {
[participantId]: {
...participant,
joined
}
}
: {})
}
};
}
private upsertSession(session: DirectCallSession): void {
this.sessionsSignal.update((sessions) => [...sessions.filter((entry) => entry.callId !== session.callId), session]);
}
private markCurrentUserLeft(session: DirectCallSession, endForEveryone: boolean): DirectCallSession {
const meId = this.currentUserId();
const locallyLeftSession = meId
? this.markParticipantJoined(session, meId, false, session.status)
: session;
return {
...locallyLeftSession,
status: endForEveryone || !this.hasConnectedParticipant(locallyLeftSession) ? 'ended' as const : 'connected' as const
};
}
private findLiveSessionForParticipants(participantIds: string[], conversationId?: string | null): DirectCallSession | null {
const normalizedParticipantIds = this.uniqueIds(participantIds).sort();
return this.visibleActiveSessions().find((session) => {
if (conversationId && (session.callId === conversationId || session.conversationId === conversationId)) {
return true;
}
const sessionParticipantIds = this.uniqueIds(session.participantIds).sort();
return sessionParticipantIds.length === normalizedParticipantIds.length
&& sessionParticipantIds.every((participantId, index) => participantId === normalizedParticipantIds[index]);
}) ?? null;
}
private isCurrentUserJoined(session: DirectCallSession): boolean {
const meId = this.currentUserId();
return !!meId && !!session.participants[meId]?.joined;
}
private stopLocalMedia(session: DirectCallSession): void {
const meId = this.currentUserId();
if (meId) {
this.voiceActivity.untrackLocalMic(meId);
}
this.voice.stopVoiceHeartbeat();
this.voice.disableVoice();
this.playback.teardownAll();
this.updateLocalVoiceState(session, false);
}
private updateLocalVoiceState(session: DirectCallSession, connected: boolean): void {
const user = this.currentUser();
if (!user?.id) {
return;
}
this.store.dispatch(UsersActions.updateVoiceState({
userId: user.id,
voiceState: {
isConnected: connected,
isMuted: connected ? this.voice.isMuted() : false,
isDeafened: connected ? this.voice.isDeafened() : false,
roomId: connected ? session.callId : undefined,
serverId: connected ? session.callId : undefined
}
}));
}
private markRemoteVoiceState(userId: string, session: DirectCallSession, connected: boolean): void {
this.store.dispatch(UsersActions.updateVoiceState({
userId,
voiceState: {
isConnected: connected,
isMuted: false,
isDeafened: false,
roomId: connected ? session.callId : undefined,
serverId: connected ? session.callId : undefined
}
}));
}
private async showIncomingNotification(displayName: string, callId: string): Promise<void> {
if (typeof Notification === 'undefined') {
return;
}
let permission = Notification.permission;
if (permission === 'default') {
permission = await Notification.requestPermission();
}
if (permission !== 'granted') {
return;
}
const notification = new Notification('Incoming call', {
body: `${displayName} is calling you`
});
notification.onclick = () => {
window.focus();
void this.router.navigate(['/call', callId]);
};
}
private uniqueParticipants(participants: DirectMessageParticipant[]): DirectMessageParticipant[] {
const seen = new Set<string>();
return participants.filter((participant) => {
if (seen.has(participant.userId)) {
return false;
}
seen.add(participant.userId);
return true;
});
}
private uniqueIds(ids: string[]): string[] {
return ids.filter((id, index) => !!id && ids.indexOf(id) === index);
}
private userKey(user: User): string {
return user.oderId || user.id;
}
private currentUserId(): string | null {
const user = this.currentUser();
return user ? this.userKey(user) : null;
}
private requireCurrentUser(): User {
const user = this.currentUser();
if (!user) {
throw new Error('Cannot use calls without a current user.');
}
return user;
}
}

View File

@@ -0,0 +1,37 @@
import type { DirectMessageParticipant, User } from '../../../../shared-kernel';
export type DirectCallStatus = 'calling' | 'ringing' | 'connected' | 'ended';
export interface DirectCallParticipant {
userId: string;
profile: DirectMessageParticipant;
joined: boolean;
}
export interface DirectCallSession {
callId: string;
conversationId: string;
createdAt: number;
initiatorId: string;
participantIds: string[];
participants: Record<string, DirectCallParticipant>;
status: DirectCallStatus;
}
export function participantToUser(participant: DirectMessageParticipant): User {
return {
id: participant.userId,
oderId: participant.userId,
username: participant.username,
displayName: participant.displayName,
description: participant.description,
avatarUrl: participant.avatarUrl,
avatarHash: participant.avatarHash,
avatarMime: participant.avatarMime,
avatarUpdatedAt: participant.avatarUpdatedAt,
profileUpdatedAt: participant.profileUpdatedAt,
status: 'online',
role: 'member',
joinedAt: Date.now()
};
}

View File

@@ -0,0 +1,2 @@
export * from './application/services/direct-call.service';
export * from './domain/models/direct-call.model';

View File

@@ -1,6 +1,8 @@
# Direct Message Domain
Direct messages provide local, offline-safe one-to-one messaging over the existing WebRTC data channel, with a signaling relay fallback when no peer data channel is available but a route to the recipient is known.
Direct messages provide local, offline-safe one-to-one and small-group messaging over the existing WebRTC data channel, with a signaling relay fallback when no peer data channel is available but a route to the recipient is known.
The same `PeerDeliveryService` also exposes direct-call events for the `direct-call` domain so private calls can ring through either an open peer data channel or a known signaling route without adding a second recipient lookup path.
## Structure
@@ -15,8 +17,8 @@ direct-message/
## Flow
1. `DirectMessageService.sendMessage()` stores the message locally with `QUEUED`.
2. `PeerDeliveryService` tries to send a `direct-message` P2P event to the recipient's current peer id.
3. If no data channel is connected, `PeerDeliveryService` tries the recipient's known signaling route before leaving the message queued.
2. `PeerDeliveryService` tries to send a `direct-message` P2P event to every other participant's current peer id.
3. If no data channel is connected, `PeerDeliveryService` tries each participant's known signaling route before leaving the message queued.
4. If either transport sends, the sender advances to `SENT`; otherwise the message id remains in `OfflineMessageQueueService`.
5. The recipient persists the message as `DELIVERED` and sends a `direct-message-status` event back.
6. Opening the conversation marks incoming messages as `ACKNOWLEDGED` and emits a status event.
@@ -29,6 +31,14 @@ The DM view reuses the chat domain's shared message list, composer, overlays, ma
Message edits, deletions, and reaction changes are stored locally and mirrored to the peer with `direct-message-mutation` events. Delivery state remains direct-message-owned and is exposed separately from the visible shared chat row UI.
When a private call grows beyond two participants, the direct-call domain creates a new empty `group` conversation and points the call chat panel at it. The previous one-to-one conversation remains untouched, so private history is not copied into the group chat. Group conversations reuse the same composer, message list, attachment, GIF, markdown, link-embed, typing, mutation, and sync paths as one-to-one DMs; delivery simply fans out to every participant except the sender.
The DM header and conversation list can start calls from both one-to-one and group conversations. Group calls reuse the group conversation id as the call id and send the same ring notification to every other participant.
Typing state is DM-owned as well. The composer emits `direct-message-typing` events, and the chat view renders the active peer names with a short TTL so the embedded private-call chat has the same typing feedback as a standalone PM.
When a conversation opens, a peer reconnects, or network service is restored, the selected conversation requests a bounded `direct-message-sync` snapshot from the peer. Incoming snapshots merge the newest messages by id instead of replacing local history, which lets clients backfill older PMs when their local stores drift.
## GIFs
The DM composer reuses the chat domain's KLIPY integration. Availability and GIF search go through the configured signal server API, and selected GIFs are sent as markdown image messages so the same proxy-fallback image rendering path is used in DMs and server chat.

View File

@@ -1,7 +1,9 @@
import {
advanceDirectMessageStatus,
createDirectConversation,
createGroupConversation,
getDirectConversationId,
isGroupDirectConversation,
updateMessageStatusInConversation,
upsertDirectMessage
} from '../../domain/logic/direct-message.logic';
@@ -17,6 +19,11 @@ const bob: DirectMessageParticipant = {
username: 'bob',
displayName: 'Bob'
};
const charlie: DirectMessageParticipant = {
userId: 'charlie',
username: 'charlie',
displayName: 'Charlie'
};
describe('DirectMessageService domain flow', () => {
it('should create conversation', () => {
@@ -44,6 +51,41 @@ describe('DirectMessageService domain flow', () => {
expect(updatedConversation.messages[0].status).toBe('QUEUED');
});
it('should create empty group conversation without direct-message history', () => {
const directConversation = upsertDirectMessage(createDirectConversation(alice, bob, 10), createMessage('message-1', 'SENT'), false);
const groupConversation = createGroupConversation('dm-group-test', [
alice,
bob,
charlie
], 30, 'Alice, Bob, Charlie');
expect(isGroupDirectConversation(groupConversation)).toBe(true);
expect(groupConversation.id).toBe('dm-group-test');
expect(groupConversation.title).toBe('Alice, Bob, Charlie');
expect(groupConversation.participants).toEqual([
'alice',
'bob',
'charlie'
]);
expect(groupConversation.messages).toEqual([]);
expect(directConversation.messages).toHaveLength(1);
});
it('should preserve group message recipient metadata', () => {
const conversation = createGroupConversation('dm-group-test', [
alice,
bob,
charlie
], 10);
const recipientIds = ['bob', 'charlie'];
const message = createMessage('message-1', 'QUEUED', conversation.id, recipientIds);
const updatedConversation = upsertDirectMessage(conversation, message, false);
expect(updatedConversation.messages[0].recipientId).toBe('bob');
expect(updatedConversation.messages[0].recipientIds).toEqual(recipientIds);
});
it('should update status correctly', () => {
expect(advanceDirectMessageStatus('QUEUED', 'SENT')).toBe('SENT');
expect(advanceDirectMessageStatus('SENT', 'DELIVERED')).toBe('DELIVERED');
@@ -52,12 +94,18 @@ describe('DirectMessageService domain flow', () => {
});
});
function createMessage(id: string, status: DirectMessage['status']): DirectMessage {
function createMessage(
id: string,
status: DirectMessage['status'],
conversationId = getDirectConversationId('alice', 'bob'),
recipientIds = ['bob']
): DirectMessage {
return {
id,
conversationId: getDirectConversationId('alice', 'bob'),
conversationId,
senderId: 'alice',
recipientId: 'bob',
recipientId: recipientIds[0],
recipientIds,
content: 'Hello',
timestamp: 20,
status

View File

@@ -12,10 +12,13 @@ import { v4 as uuidv4 } from 'uuid';
import { DirectMessageRepository } from '../../infrastructure/direct-message.repository';
import { OfflineMessageQueueService } from './offline-message-queue.service';
import { PeerDeliveryService } from './peer-delivery.service';
import { AttachmentFacade } from '../../../attachment';
import {
advanceDirectMessageStatus,
createDirectConversation,
createGroupConversation,
getDirectConversationId,
isGroupDirectConversation,
updateMessageStatusInConversation,
upsertDirectMessage
} from '../../domain/logic/direct-message.logic';
@@ -24,8 +27,12 @@ import {
DirectMessageConversation,
DirectMessageEventPayload,
DirectMessageMutationEventPayload,
DirectMessageParticipant,
DirectMessageSyncEventPayload,
DirectMessageSyncRequestEventPayload,
DirectMessageStatus,
DirectMessageStatusEventPayload,
DirectMessageTypingEventPayload,
toDirectMessageParticipant
} from '../../domain/models/direct-message.model';
import type {
@@ -35,16 +42,32 @@ import type {
} from '../../../../shared-kernel';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
const DIRECT_MESSAGE_SYNC_LIMIT = 1000;
const DIRECT_MESSAGE_SYNC_REQUEST_COOLDOWN_MS = 5000;
const DIRECT_MESSAGE_TYPING_TTL_MS = 3000;
const DIRECT_MESSAGE_TYPING_PURGE_MS = 1000;
const DIRECT_MESSAGE_ATTACHMENT_STORAGE_PREFIX = 'direct-message:';
interface DirectMessageTypingEntry {
conversationId: string;
userId: string;
displayName: string;
expiresAt: number;
}
@Injectable({ providedIn: 'root' })
export class DirectMessageService {
private readonly repository = inject(DirectMessageRepository);
private readonly offlineQueue = inject(OfflineMessageQueueService);
private readonly delivery = inject(PeerDeliveryService);
private readonly attachments = inject(AttachmentFacade);
private readonly store = inject(Store);
private readonly router = inject(Router);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private readonly conversationsSignal = signal<DirectMessageConversation[]>([]);
private readonly selectedConversationIdSignal = signal<string | null>(null);
private readonly typingEntriesSignal = signal<DirectMessageTypingEntry[]>([]);
private readonly lastSyncRequestAt = new Map<string, number>();
private loadedOwnerId: string | null = null;
readonly conversations = computed(() => [...this.conversationsSignal()].sort(
@@ -62,6 +85,7 @@ export class DirectMessageService {
(total, conversation) => total + conversation.unreadCount,
0
));
readonly typingEntries = this.typingEntriesSignal.asReadonly();
constructor() {
effect(() => {
@@ -76,11 +100,15 @@ export class DirectMessageService {
this.delivery.peerConnected$.subscribe(() => {
void this.retryPending();
void this.requestOpenConversationSync();
});
this.delivery.networkRestored$.subscribe(() => {
void this.retryPending();
void this.requestOpenConversationSync();
});
window.setInterval(() => this.purgeExpiredTypingEntries(), DIRECT_MESSAGE_TYPING_PURGE_MS);
}
async createConversation(user: User): Promise<DirectMessageConversation> {
@@ -106,12 +134,47 @@ export class DirectMessageService {
return conversation;
}
async createGroupConversation(
participants: DirectMessageParticipant[],
title?: string,
conversationId = `dm-group-${uuidv4()}`
): Promise<DirectMessageConversation> {
const currentUser = this.requireCurrentUser();
const ownerId = this.getCurrentUserIdOrThrow();
const currentParticipant = toDirectMessageParticipant(currentUser);
const allParticipants = this.uniqueParticipants([currentParticipant, ...participants]);
await this.loadForOwner(ownerId);
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId)
?? await this.repository.getConversation(ownerId, conversationId);
if (existingConversation) {
const mergedConversation = this.mergeConversationParticipants({
...existingConversation,
kind: 'group',
title: existingConversation.title || title
}, allParticipants);
await this.persistConversation(ownerId, mergedConversation);
this.selectedConversationIdSignal.set(mergedConversation.id);
return mergedConversation;
}
const conversation = createGroupConversation(conversationId, allParticipants, Date.now(), title);
await this.persistConversation(ownerId, conversation);
this.selectedConversationIdSignal.set(conversation.id);
return conversation;
}
async openConversation(conversationId: string): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow();
await this.loadForOwner(ownerId);
this.selectedConversationIdSignal.set(conversationId);
await this.markRead(conversationId);
this.requestConversationSync(conversationId);
}
closeConversationView(conversationId?: string | null): void {
@@ -152,10 +215,11 @@ export class DirectMessageService {
const ownerId = this.getCurrentUserIdOrThrow();
const conversation = await this.requireConversation(ownerId, conversationId);
const senderId = currentUser.oderId || currentUser.id;
const recipientId = conversation.participants.find((participantId) => participantId !== senderId);
const recipientIds = this.recipientIdsFor(conversation, senderId);
const recipientId = recipientIds[0];
if (!recipientId) {
throw new Error('Direct message conversation has no recipient.');
throw new Error('Direct message conversation has no recipients.');
}
const message: DirectMessage = {
@@ -163,6 +227,7 @@ export class DirectMessageService {
conversationId,
senderId,
recipientId,
recipientIds,
content: normalizedContent,
timestamp: Date.now(),
status: 'QUEUED',
@@ -172,7 +237,7 @@ export class DirectMessageService {
};
await this.persistConversation(ownerId, upsertDirectMessage(conversation, message, false));
await this.attemptDelivery(ownerId, message);
await this.attemptDelivery(ownerId, message, conversation);
return message;
}
@@ -249,9 +314,8 @@ export class DirectMessageService {
requestPeerAvatarSync(conversationId: string): void {
const currentUserId = this.getCurrentUserId();
const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId);
const peerId = conversation?.participants.find((participantId) => participantId !== currentUserId);
if (peerId) {
for (const peerId of this.recipientIdsFor(conversation, currentUserId)) {
this.delivery.requestUserAvatar(peerId);
}
}
@@ -321,13 +385,51 @@ export class DirectMessageService {
for (const messageId of pendingMessageIds) {
const message = messages.find((entry) => entry.id === messageId);
const conversation = message
? this.conversationsSignal().find((entry) => entry.id === message.conversationId)
: null;
if (message) {
await this.attemptDelivery(ownerId, message);
if (message && conversation) {
await this.attemptDelivery(ownerId, message, conversation);
}
}
}
typingUsers(conversationId: string | null | undefined): string[] {
if (!conversationId) {
return [];
}
const now = Date.now();
return this.typingEntriesSignal()
.filter((entry) => entry.conversationId === conversationId && entry.expiresAt > now)
.map((entry) => entry.displayName);
}
sendTyping(conversationId: string, isTyping = true): void {
const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId);
const currentUser = this.currentUser();
const currentUserId = this.getCurrentUserId();
const recipientIds = this.recipientIdsFor(conversation, currentUserId);
if (!conversation || !currentUser || recipientIds.length === 0) {
return;
}
for (const recipientId of recipientIds) {
this.delivery.sendViaWebRTC(recipientId, {
type: 'direct-message-typing',
directMessageTyping: {
conversationId,
sender: toDirectMessageParticipant(currentUser),
isTyping,
updatedAt: Date.now()
}
});
}
}
private async handlePeerEvent(event: ChatEvent): Promise<void> {
if (event.type === 'direct-message' && event.directMessage) {
await this.handleIncomingMessage(event.directMessage);
@@ -341,6 +443,21 @@ export class DirectMessageService {
if (event.type === 'direct-message-mutation' && event.directMessageMutation) {
await this.handleIncomingMutation(event.directMessageMutation);
return;
}
if (event.type === 'direct-message-typing' && event.directMessageTyping) {
this.handleIncomingTyping(event.directMessageTyping);
return;
}
if (event.type === 'direct-message-sync-request' && event.directMessageSyncRequest) {
await this.handleIncomingSyncRequest(event.directMessageSyncRequest);
return;
}
if (event.type === 'direct-message-sync' && event.directMessageSync) {
await this.handleIncomingSync(event.directMessageSync);
}
}
@@ -351,8 +468,16 @@ export class DirectMessageService {
const sender = payload.sender;
const conversationId = payload.message.conversationId
|| getDirectConversationId(currentParticipant.userId, sender.userId);
const participants = this.uniqueParticipants([
currentParticipant,
sender,
...(payload.participants ?? [])
]);
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId)
?? createDirectConversation(currentParticipant, sender, payload.message.timestamp);
?? (payload.conversationKind === 'group' || participants.length > 2
? createGroupConversation(conversationId, participants, payload.message.timestamp, payload.conversationTitle)
: createDirectConversation(currentParticipant, sender, payload.message.timestamp));
const conversationWithParticipants = this.mergeConversationParticipants(existingConversation, participants);
const incomingMessage: DirectMessage = {
...payload.message,
conversationId,
@@ -360,7 +485,7 @@ export class DirectMessageService {
};
const shouldIncrementUnread = !this.isConversationVisible(conversationId);
await this.persistConversation(ownerId, upsertDirectMessage(existingConversation, incomingMessage, shouldIncrementUnread));
await this.persistConversation(ownerId, upsertDirectMessage(conversationWithParticipants, incomingMessage, shouldIncrementUnread));
this.sendStatusUpdate(incomingMessage.senderId, {
conversationId,
messageId: incomingMessage.id,
@@ -384,14 +509,20 @@ export class DirectMessageService {
private isConversationVisible(conversationId: string): boolean {
const currentUrl = this.router.url.split(/[?#]/, 1)[0];
if (!currentUrl.startsWith('/dm/')) {
if (!currentUrl.startsWith('/dm/') && !currentUrl.startsWith('/pm/')) {
if (currentUrl.startsWith('/call/')) {
return this.selectedConversationIdSignal() === conversationId;
}
return false;
}
const prefix = currentUrl.startsWith('/pm/') ? '/pm/' : '/dm/';
try {
return decodeURIComponent(currentUrl.slice('/dm/'.length)) === conversationId;
return decodeURIComponent(currentUrl.slice(prefix.length)) === conversationId;
} catch {
return currentUrl.slice('/dm/'.length) === conversationId;
return currentUrl.slice(prefix.length) === conversationId;
}
}
@@ -402,6 +533,98 @@ export class DirectMessageService {
await this.persistConversation(ownerId, this.applyMutation(conversation, payload));
}
private handleIncomingTyping(payload: DirectMessageTypingEventPayload): void {
const currentUserId = this.getCurrentUserId();
if (!currentUserId || payload.sender.userId === currentUserId) {
return;
}
if (!payload.isTyping) {
this.typingEntriesSignal.update((entries) => entries.filter((entry) =>
!(entry.conversationId === payload.conversationId && entry.userId === payload.sender.userId)
));
return;
}
const nextEntry: DirectMessageTypingEntry = {
conversationId: payload.conversationId,
userId: payload.sender.userId,
displayName: payload.sender.displayName,
expiresAt: Date.now() + DIRECT_MESSAGE_TYPING_TTL_MS
};
this.typingEntriesSignal.update((entries) => [
...entries.filter((entry) =>
!(entry.conversationId === nextEntry.conversationId && entry.userId === nextEntry.userId)
),
nextEntry
]);
}
private async handleIncomingSyncRequest(payload: DirectMessageSyncRequestEventPayload): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow();
const currentUser = this.requireCurrentUser();
const conversation = this.conversationsSignal().find((entry) => entry.id === payload.conversationId)
?? await this.repository.getConversation(ownerId, payload.conversationId);
if (!conversation || payload.sender.userId === ownerId) {
return;
}
this.delivery.sendViaWebRTC(payload.sender.userId, {
type: 'direct-message-sync',
directMessageSync: {
conversationId: conversation.id,
sender: toDirectMessageParticipant(currentUser),
participants: Object.values(conversation.participantProfiles),
conversationKind: this.conversationKind(conversation),
conversationTitle: conversation.title,
messages: conversation.messages.slice(-DIRECT_MESSAGE_SYNC_LIMIT),
syncedAt: Date.now()
}
});
}
private async handleIncomingSync(payload: DirectMessageSyncEventPayload): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow();
const currentUser = this.requireCurrentUser();
const currentParticipant = toDirectMessageParticipant(currentUser);
if (payload.sender.userId === ownerId) {
return;
}
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === payload.conversationId)
?? await this.repository.getConversation(ownerId, payload.conversationId)
?? (payload.conversationKind === 'group' || payload.participants.length > 2
? createGroupConversation(payload.conversationId, [currentParticipant, ...payload.participants], payload.syncedAt, payload.conversationTitle)
: createDirectConversation(currentParticipant, payload.sender, payload.syncedAt));
const participantProfiles = {
...existingConversation.participantProfiles,
...Object.fromEntries(payload.participants.map((participant) => [participant.userId, participant])),
[currentParticipant.userId]: currentParticipant
};
const syncBaseConversation: DirectMessageConversation = {
...existingConversation,
kind: payload.conversationKind ?? existingConversation.kind,
title: payload.conversationTitle ?? existingConversation.title,
participants: Object.keys(participantProfiles).sort(),
participantProfiles
};
const mergedConversation = payload.messages.reduce<DirectMessageConversation>(
(conversation, message) => upsertDirectMessage(conversation, message, false),
syncBaseConversation
);
await this.persistConversation(ownerId, mergedConversation);
if (this.selectedConversationIdSignal() === payload.conversationId) {
await this.markRead(payload.conversationId);
}
}
private async applyAndSendMutation(
conversationId: string,
payload: DirectMessageMutationEventPayload
@@ -409,11 +632,11 @@ export class DirectMessageService {
const ownerId = this.getCurrentUserIdOrThrow();
const conversation = await this.requireConversation(ownerId, conversationId);
const updatedConversation = this.applyMutation(conversation, payload);
const recipientId = conversation.participants.find((participantId) => participantId !== ownerId);
const recipientIds = this.recipientIdsFor(conversation, ownerId);
await this.persistConversation(ownerId, updatedConversation);
if (recipientId) {
for (const recipientId of recipientIds) {
this.delivery.sendViaWebRTC(recipientId, {
type: 'direct-message-mutation',
directMessageMutation: payload
@@ -474,25 +697,40 @@ export class DirectMessageService {
return { ...conversation, messages };
}
private async attemptDelivery(ownerId: string, message: DirectMessage): Promise<void> {
private async attemptDelivery(ownerId: string, message: DirectMessage, conversation: DirectMessageConversation): Promise<void> {
const currentUser = this.requireCurrentUser();
const sent = this.delivery.sendViaWebRTC(message.recipientId, {
const recipientIds = this.recipientIdsFor(conversation, ownerId);
let sentCount = 0;
for (const recipientId of recipientIds) {
if (this.delivery.sendViaWebRTC(recipientId, {
type: 'direct-message',
directMessage: {
message,
sender: toDirectMessageParticipant(currentUser)
sender: toDirectMessageParticipant(currentUser),
participants: Object.values(conversation.participantProfiles),
conversationKind: this.conversationKind(conversation),
conversationTitle: conversation.title
}
})) {
sentCount += 1;
}
}
});
if (!sent) {
if (sentCount < recipientIds.length) {
await this.offlineQueue.enqueue(ownerId, message.id);
return;
}
await this.offlineQueue.markDelivered(ownerId, message.id);
if (sentCount > 0) {
await this.updateStatus(message.id, 'SENT');
}
if (sentCount === recipientIds.length) {
await this.offlineQueue.markDelivered(ownerId, message.id);
}
}
private sendStatusUpdate(recipientId: string, payload: DirectMessageStatusEventPayload): void {
this.delivery.handleAck(recipientId, {
type: 'direct-message-status',
@@ -500,6 +738,52 @@ export class DirectMessageService {
});
}
private requestOpenConversationSync(): void {
const conversationId = this.selectedConversationIdSignal();
if (conversationId) {
this.requestConversationSync(conversationId);
}
}
private requestConversationSync(conversationId: string): void {
const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId);
const currentUser = this.currentUser();
const currentUserId = this.getCurrentUserId();
const recipientIds = this.recipientIdsFor(conversation, currentUserId);
if (!conversation || !currentUser || recipientIds.length === 0) {
return;
}
const now = Date.now();
for (const recipientId of recipientIds) {
const syncKey = `${conversationId}:${recipientId}`;
if (now - (this.lastSyncRequestAt.get(syncKey) ?? 0) < DIRECT_MESSAGE_SYNC_REQUEST_COOLDOWN_MS) {
continue;
}
this.lastSyncRequestAt.set(syncKey, now);
this.delivery.sendViaWebRTC(recipientId, {
type: 'direct-message-sync-request',
directMessageSyncRequest: {
conversationId,
sender: toDirectMessageParticipant(currentUser),
requestedAt: Date.now()
}
});
}
}
private purgeExpiredTypingEntries(): void {
const now = Date.now();
this.typingEntriesSignal.update((entries) => entries.filter((entry) => entry.expiresAt > now));
}
private async loadForOwner(ownerId: string | null): Promise<void> {
if (!ownerId) {
this.loadedOwnerId = null;
@@ -512,10 +796,14 @@ export class DirectMessageService {
}
this.loadedOwnerId = ownerId;
this.conversationsSignal.set(await this.repository.loadConversations(ownerId));
const conversations = await this.repository.loadConversations(ownerId);
conversations.forEach((conversation) => this.rememberConversationAttachmentStorage(conversation));
this.conversationsSignal.set(conversations);
}
private async persistConversation(ownerId: string, conversation: DirectMessageConversation): Promise<void> {
this.rememberConversationAttachmentStorage(conversation);
await this.repository.saveConversation(ownerId, conversation);
this.conversationsSignal.update((conversations) => {
const nextConversations = conversations.filter((entry) => entry.id !== conversation.id);
@@ -525,6 +813,55 @@ export class DirectMessageService {
});
}
private rememberConversationAttachmentStorage(conversation: DirectMessageConversation): void {
const storageContainer = `${DIRECT_MESSAGE_ATTACHMENT_STORAGE_PREFIX}${conversation.id}`;
for (const message of conversation.messages) {
this.attachments.rememberMessageRoom(message.id, storageContainer);
}
}
private mergeConversationParticipants(
conversation: DirectMessageConversation,
participants: DirectMessageParticipant[]
): DirectMessageConversation {
const participantProfiles = {
...conversation.participantProfiles,
...Object.fromEntries(participants.map((participant) => [participant.userId, participant]))
};
return {
...conversation,
participants: Object.keys(participantProfiles).sort(),
participantProfiles
};
}
private recipientIdsFor(conversation: DirectMessageConversation | null | undefined, currentUserId: string | null | undefined): string[] {
if (!conversation || !currentUserId) {
return [];
}
return conversation.participants.filter((participantId) => participantId !== currentUserId);
}
private conversationKind(conversation: DirectMessageConversation): 'direct' | 'group' {
return isGroupDirectConversation(conversation) ? 'group' : 'direct';
}
private uniqueParticipants(participants: DirectMessageParticipant[]): DirectMessageParticipant[] {
const seen = new Set<string>();
return participants.filter((participant) => {
if (!participant.userId || seen.has(participant.userId)) {
return false;
}
seen.add(participant.userId);
return true;
});
}
private async requireConversation(ownerId: string, conversationId: string): Promise<DirectMessageConversation> {
await this.loadForOwner(ownerId);

View File

@@ -22,7 +22,19 @@ export class PeerDeliveryService {
this.webrtc.onMessageReceived,
this.webrtc.onSignalingMessage as Observable<ChatEvent>
).pipe(
filter((event) => event.type === 'direct-message' || event.type === 'direct-message-status' || event.type === 'direct-message-mutation')
filter((event) => event.type === 'direct-message'
|| event.type === 'direct-message-status'
|| event.type === 'direct-message-mutation'
|| event.type === 'direct-message-typing'
|| event.type === 'direct-message-sync-request'
|| event.type === 'direct-message-sync')
);
readonly directCallEvents$: Observable<ChatEvent> = merge(
this.webrtc.onMessageReceived,
this.webrtc.onSignalingMessage as Observable<ChatEvent>
).pipe(
filter((event) => event.type === 'direct-call')
);
readonly peerConnected$ = this.webrtc.onPeerConnected;
@@ -60,6 +72,10 @@ export class PeerDeliveryService {
});
}
sendCallEvent(recipientId: string, event: ChatEvent): boolean {
return this.sendViaWebRTC(recipientId, event);
}
syncOnReconnect(onReconnect: () => void): void {
this.peerConnected$.subscribe(() => onReconnect());
}
@@ -84,7 +100,15 @@ export class PeerDeliveryService {
}
private sendViaSignaling(recipientId: string, event: ChatEvent): boolean {
if (event.type !== 'direct-message' && event.type !== 'direct-message-status' && event.type !== 'direct-message-mutation') {
if (
event.type !== 'direct-message'
&& event.type !== 'direct-message-status'
&& event.type !== 'direct-message-mutation'
&& event.type !== 'direct-message-typing'
&& event.type !== 'direct-message-sync-request'
&& event.type !== 'direct-message-sync'
&& event.type !== 'direct-call'
) {
return false;
}

View File

@@ -37,6 +37,7 @@ export function createDirectConversation(
return {
id: getDirectConversationId(currentUser.userId, peer.userId),
kind: 'direct',
participants,
participantProfiles: {
[currentUser.userId]: currentUser,
@@ -48,6 +49,31 @@ export function createDirectConversation(
};
}
export function createGroupConversation(
conversationId: string,
participants: DirectMessageParticipant[],
now: number,
title?: string
): DirectMessageConversation {
const uniqueParticipants = uniqueDirectMessageParticipants(participants);
const participantIds = uniqueParticipants.map((participant) => participant.userId).sort();
return {
id: conversationId,
kind: 'group',
title: title || buildGroupConversationTitle(uniqueParticipants),
participants: participantIds,
participantProfiles: Object.fromEntries(uniqueParticipants.map((participant) => [participant.userId, participant])),
messages: [],
lastMessageAt: now,
unreadCount: 0
};
}
export function isGroupDirectConversation(conversation: DirectMessageConversation): boolean {
return conversation.kind === 'group' || conversation.participants.length > 2;
}
export function upsertDirectMessage(
conversation: DirectMessageConversation,
message: DirectMessage,
@@ -89,3 +115,26 @@ export function updateMessageStatusInConversation(
return { ...conversation, messages };
}
function uniqueDirectMessageParticipants(participants: DirectMessageParticipant[]): DirectMessageParticipant[] {
const seen = new Set<string>();
return participants.filter((participant) => {
if (!participant.userId || seen.has(participant.userId)) {
return false;
}
seen.add(participant.userId);
return true;
});
}
function buildGroupConversationTitle(participants: DirectMessageParticipant[]): string {
const names = participants.map((participant) => participant.displayName || participant.username || participant.userId);
if (names.length <= 3) {
return names.join(', ');
}
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
}

View File

@@ -1,17 +1,24 @@
import type { User } from '../../../../shared-kernel';
import type { DirectMessage, DirectMessageParticipant } from '../../../../shared-kernel';
export type DirectMessageConversationKind = 'direct' | 'group';
export type {
DirectMessage,
DirectMessageEventPayload,
DirectMessageMutationEventPayload,
DirectMessageParticipant,
DirectMessageSyncEventPayload,
DirectMessageSyncRequestEventPayload,
DirectMessageStatus,
DirectMessageStatusEventPayload
DirectMessageStatusEventPayload,
DirectMessageTypingEventPayload
} from '../../../../shared-kernel';
export interface DirectMessageConversation {
id: string;
kind?: DirectMessageConversationKind;
title?: string;
participants: string[];
participantProfiles: Record<string, DirectMessageParticipant>;
messages: DirectMessage[];

View File

@@ -13,10 +13,25 @@
[showStatusBadge]="true"
size="md"
/>
<div class="min-w-0">
<div class="min-w-0 flex-1">
<h1 class="truncate text-base font-semibold text-foreground">{{ peerName() }}</h1>
<p class="text-xs text-muted-foreground">Direct Message</p>
<p class="text-xs text-muted-foreground">{{ isGroupConversation() ? 'Group Chat' : 'Direct Message' }}</p>
</div>
@if (showCallButton() && conversation()) {
<button
type="button"
class="grid h-9 w-9 place-items-center rounded-md bg-emerald-500 text-white transition-colors hover:bg-emerald-600 disabled:opacity-50"
[disabled]="!canCallConversation()"
[attr.aria-label]="'Call ' + peerName()"
[title]="'Call ' + peerName()"
(click)="callConversation()"
>
<ng-icon
[name]="peerCallIcon()"
class="h-4 w-4"
/>
</button>
}
</header>
@if (conversation()) {
@@ -58,6 +73,15 @@
appThemeNode="chatComposerBar"
class="chat-bottom-bar absolute bottom-0 left-0 right-2 z-10 bg-background/85 backdrop-blur-md"
>
@if (typingUsers().length > 0) {
<div
data-testid="dm-typing-indicator"
class="px-4 pb-1 text-xs text-muted-foreground"
>
{{ typingUsers().join(', ') }} {{ typingUsers().length === 1 ? 'is' : 'are' }} typing...
</div>
}
<app-chat-message-composer
[replyTo]="replyTo()"
[showKlipyGifPicker]="showGifPicker()"
@@ -65,6 +89,7 @@
[klipySignalSource]="null"
[textareaTestId]="'dm-input'"
(messageSubmitted)="handleMessageSubmitted($event)"
(typingStarted)="handleTypingStarted()"
(replyCleared)="clearReply()"
(heightChanged)="composerBottomPadding.set($event + 20)"
(klipyGifPickerToggleRequested)="toggleGifPicker()"

View File

@@ -5,6 +5,7 @@ import {
effect,
HostListener,
inject,
input,
signal,
ViewChild
} from '@angular/core';
@@ -15,10 +16,13 @@ import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { UserAvatarComponent } from '../../../../shared';
import { DirectCallService } from '../../../direct-call';
import { Attachment, AttachmentFacade } from '../../../attachment';
import { ThemeNodeDirective } from '../../../theme';
import { DirectMessageService } from '../../application/services/direct-message.service';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePhone, lucidePhoneCall } from '@ng-icons/lucide';
import {
ChatMessageComposerSubmitEvent,
ChatMessageComposerComponent,
@@ -57,9 +61,11 @@ interface DmStatusLabel {
ChatMessageListComponent,
ChatMessageOverlaysComponent,
KlipyGifPickerComponent,
NgIcon,
ThemeNodeDirective,
UserAvatarComponent
],
viewProviders: [provideIcons({ lucidePhone, lucidePhoneCall })],
templateUrl: './dm-chat.component.html',
host: {
class: 'block h-full'
@@ -74,10 +80,15 @@ export class DmChatComponent {
private readonly attachments = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService);
private readonly linkMetadata = inject(LinkMetadataService);
private readonly metadataRequestKeys = new Set<string>();
private openedConversationId: string | null = null;
readonly directCalls = inject(DirectCallService);
readonly directMessages = inject(DirectMessageService);
readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly allUsers = this.store.selectSignal(selectAllUsers);
readonly showGifPicker = signal(false);
readonly conversationId = input<string | null>(null);
readonly showCallButton = input(true);
readonly composerBottomPadding = signal(140);
readonly gifPickerAnchorRight = signal(16);
readonly linkMetadataByMessageId = signal<Record<string, LinkMetadata[]>>({});
@@ -87,15 +98,26 @@ export class DmChatComponent {
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
initialValue: this.route.snapshot.paramMap.get('conversationId')
});
readonly effectiveConversationId = computed(() => this.conversationId() ?? this.routeConversationId());
readonly currentUserId = computed(() => this.currentUser()?.oderId || this.currentUser()?.id || '');
readonly conversation = this.directMessages.selectedConversation;
readonly klipyEnabled = computed(() => this.klipy.isEnabled(null));
readonly conversationKey = computed(() => this.conversation()?.id ?? 'dm:none');
readonly typingUsers = computed(() => {
void this.directMessages.typingEntries();
return this.directMessages.typingUsers(this.conversation()?.id);
});
readonly peerUser = computed(() => {
const conversation = this.conversation();
return conversation ? this.peerUserFor(conversation) : null;
});
readonly isGroupConversation = computed(() => {
const conversation = this.conversation();
return !!conversation && (conversation.kind === 'group' || conversation.participants.length > 2);
});
readonly participantUsers = computed<User[]>(() => {
const conversation = this.conversation();
const knownUsers = this.allUsers();
@@ -173,22 +195,57 @@ export class DmChatComponent {
readonly peerName = computed(() => {
const conversation = this.conversation();
const currentUserId = this.currentUserId();
if (conversation && this.isGroupConversation()) {
return conversation.title || this.groupConversationTitle(conversation);
}
const peerId = conversation?.participants.find((participantId) => participantId !== currentUserId);
return peerId ? conversation?.participantProfiles[peerId]?.displayName || peerId : 'Direct Message';
});
readonly peerCallIcon = computed(() => {
const conversation = this.conversation();
if (conversation && this.isGroupConversation()) {
return this.directCalls.isCallingConversation(conversation.id) ? 'lucidePhoneCall' : 'lucidePhone';
}
const peer = this.peerUser();
return peer && this.directCalls.isCallingUser(peer) ? 'lucidePhoneCall' : 'lucidePhone';
});
readonly canCallConversation = computed(() => {
const conversation = this.conversation();
if (!conversation) {
return false;
}
if (this.isGroupConversation()) {
return conversation.participants.some((participantId) => participantId !== this.currentUserId());
}
return !!this.peerUser();
});
constructor() {
effect(() => {
const conversationId = this.routeConversationId();
const conversationId = this.effectiveConversationId();
if (conversationId) {
if (!conversationId) {
this.openedConversationId = null;
return;
}
if (conversationId !== this.openedConversationId) {
this.openedConversationId = conversationId;
void this.directMessages.openConversation(conversationId);
}
});
effect(() => {
void this.routeConversationId();
void this.effectiveConversationId();
void this.klipy.refreshAvailability(null);
});
@@ -226,11 +283,28 @@ export class DmChatComponent {
this.replyTo.set(null);
if (event.pendingFiles.length > 0) {
this.attachments.rememberMessageRoom(message.id, `direct-message:${conversation.id}`);
this.attachments.publishAttachments(message.id, event.pendingFiles, this.currentUserId() || undefined);
}
});
}
handleTypingStarted(): void {
const conversationId = this.conversation()?.id;
if (conversationId) {
this.directMessages.sendTyping(conversationId, true);
}
}
async callConversation(): Promise<void> {
const conversation = this.conversation();
if (conversation && this.canCallConversation()) {
await this.directCalls.startConversationCall(conversation);
}
}
setReplyTo(message: ChatMessageReplyEvent): void {
this.replyTo.set(message);
}
@@ -325,6 +399,20 @@ export class DmChatComponent {
const electronApi = this.electronBridge.getApi();
if (electronApi) {
const diskPath = this.getAttachmentDiskPath(attachment);
if (diskPath && electronApi.saveExistingFileAs) {
try {
const result = await electronApi.saveExistingFileAs(diskPath, attachment.filename);
if (result.saved || result.cancelled) {
return;
}
} catch {
/* fall back to blob/browser download */
}
}
const blob = await this.getAttachmentBlob(attachment);
if (blob) {
@@ -391,12 +479,16 @@ export class DmChatComponent {
continue;
}
const urls = this.linkMetadata.extractUrls(message.content).filter((url) => !hasDedicatedChatEmbed(url));
const urls = this.linkMetadata.extractUrls(message.content)
.filter((url) => !hasDedicatedChatEmbed(url))
.filter((url) => !this.metadataRequestKeys.has(this.metadataRequestKey(message.id, url)));
if (urls.length === 0) {
continue;
}
urls.forEach((url) => this.metadataRequestKeys.add(this.metadataRequestKey(message.id, url)));
const metadata = (await this.linkMetadata.fetchAllMetadata(urls)).filter((entry) => !entry.failed);
if (metadata.length === 0) {
@@ -410,11 +502,19 @@ export class DmChatComponent {
}
}
private metadataRequestKey(messageId: string, url: string): string {
return `${messageId}:${url}`;
}
private async getAttachmentBlob(attachment: Attachment): Promise<Blob | null> {
if (!attachment.objectUrl) {
return null;
}
if (attachment.objectUrl.startsWith('file:')) {
return null;
}
try {
const response = await fetch(attachment.objectUrl);
@@ -424,6 +524,10 @@ export class DmChatComponent {
}
}
private getAttachmentDiskPath(attachment: Attachment): string | null {
return attachment.savedPath || attachment.filePath || null;
}
private blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -445,6 +549,10 @@ export class DmChatComponent {
}
private peerUserFor(conversation: NonNullable<ReturnType<typeof this.conversation>>): User | null {
if (conversation.kind === 'group' || conversation.participants.length > 2) {
return null;
}
const currentUserId = this.currentUserId();
const peerId = conversation.participants.find((participantId) => participantId !== currentUserId);
@@ -454,4 +562,16 @@ export class DmChatComponent {
return this.participantUsers().find((user) => user.id === peerId || user.oderId === peerId) ?? null;
}
private groupConversationTitle(conversation: NonNullable<ReturnType<typeof this.conversation>>): string {
const names = conversation.participants
.filter((participantId) => participantId !== this.currentUserId())
.map((participantId) => conversation.participantProfiles[participantId]?.displayName || participantId);
if (names.length <= 3) {
return names.join(', ');
}
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
}
}

View File

@@ -29,9 +29,11 @@
[class.dm-rail-slide-out]="item.isExiting"
[class.pointer-events-none]="item.isExiting"
[ngClass]="isSelectedItem(item) ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10' : 'rounded-xl bg-card'"
[attr.data-testid]="'dm-rail-item-' + item.id"
[title]="item.label"
[attr.aria-current]="isSelectedItem(item) ? 'page' : null"
(click)="openItem(item)"
(contextmenu)="openContextMenu($event, item)"
>
<div class="h-full w-full overflow-hidden rounded-[inherit]">
@if (item.avatarUrl) {
@@ -58,7 +60,7 @@
class="absolute -bottom-1 -right-1 grid h-4 w-4 place-items-center rounded-full bg-secondary text-muted-foreground shadow-sm ring-2 ring-card"
>
<ng-icon
name="lucideUser"
[name]="iconFor(item)"
class="h-2.5 w-2.5"
/>
</span>
@@ -72,3 +74,24 @@
</div>
}
</div>
@if (contextMenu(); as menu) {
<app-context-menu
[x]="menu.x"
[y]="menu.y"
width="w-44"
(closed)="closeContextMenu()"
>
<button
type="button"
class="context-menu-item-icon-danger"
(click)="forgetContextItem()"
>
<ng-icon
[name]="forgetContextIcon(menu.item)"
class="h-4 w-4"
/>
{{ forgetContextLabel(menu.item) }}
</button>
</app-context-menu>
}

View File

@@ -12,8 +12,15 @@ import { CommonModule } from '@angular/common';
import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideMessageCircle, lucideUser } from '@ng-icons/lucide';
import {
lucideLogOut,
lucideMessageCircle,
lucideTrash2,
lucideUser,
lucideUsers
} from '@ng-icons/lucide';
import { filter, map } from 'rxjs';
import { ContextMenuComponent } from '../../../../shared';
import { DirectMessageService } from '../../application/services/direct-message.service';
import { FriendService } from '../../application/services/friend.service';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
@@ -30,13 +37,23 @@ interface DmRailItem {
unreadCount: number;
}
interface DmRailContextMenuState {
x: number;
y: number;
item: DmRailItem;
}
const EXIT_ANIMATION_MS = 160;
@Component({
selector: 'app-dm-rail',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [provideIcons({ lucideMessageCircle, lucideUser })],
imports: [
CommonModule,
ContextMenuComponent,
NgIcon
],
viewProviders: [provideIcons({ lucideLogOut, lucideMessageCircle, lucideTrash2, lucideUser, lucideUsers })],
templateUrl: './dm-rail.component.html',
styleUrl: './dm-rail.component.scss'
})
@@ -60,11 +77,29 @@ export class DmRailComponent implements OnDestroy {
this.friends.isFriend(user.oderId || user.id) && (user.oderId || user.id) !== this.currentUserId()
));
readonly railItems = signal<DmRailItem[]>([]);
readonly contextMenu = signal<DmRailContextMenuState | null>(null);
readonly unreadRailItems = computed<DmRailItem[]>(() => {
const currentUserId = this.currentUserId();
const items = new Map<string, DmRailItem>();
for (const conversation of this.directMessages.conversations()) {
if (conversation.unreadCount === 0) {
continue;
}
if (this.isGroupConversation(conversation)) {
items.set(conversation.id, {
id: conversation.id,
label: this.titleFor(conversation),
conversation,
isExiting: false,
user: null,
unreadCount: conversation.unreadCount
});
continue;
}
const peerId = conversation.participants.find((participantId) => participantId !== currentUserId);
if (!peerId) {
@@ -103,7 +138,7 @@ export class DmRailComponent implements OnDestroy {
});
}
return Array.from(items.values()).filter((item) => item.unreadCount > 0);
return Array.from(items.values()).filter((item) => item.conversation && item.unreadCount > 0);
});
readonly isOnDirectMessages = toSignal(
this.router.events.pipe(
@@ -140,6 +175,8 @@ export class DmRailComponent implements OnDestroy {
}
async openItem(item: DmRailItem): Promise<void> {
this.closeContextMenu();
if (item.conversation) {
await this.openConversation(item.conversation);
return;
@@ -155,6 +192,10 @@ export class DmRailComponent implements OnDestroy {
}
titleFor(conversation: DirectMessageConversation): string {
if (this.isGroupConversation(conversation)) {
return conversation.title || this.groupConversationTitle(conversation);
}
const peerId = conversation.participants.find((participantId) => participantId !== this.currentUserId());
return peerId ? conversation.participantProfiles[peerId]?.displayName || peerId : 'DM';
@@ -184,6 +225,51 @@ export class DmRailComponent implements OnDestroy {
return !!item.conversation && this.isSelectedConversation(item.conversation);
}
iconFor(item: DmRailItem): string {
return item.conversation && this.isGroupConversation(item.conversation) ? 'lucideUsers' : 'lucideUser';
}
openContextMenu(event: MouseEvent, item: DmRailItem): void {
if (!item.conversation) {
return;
}
event.preventDefault();
event.stopPropagation();
this.contextMenu.set({
x: event.clientX,
y: event.clientY,
item
});
}
closeContextMenu(): void {
this.contextMenu.set(null);
}
async forgetContextItem(): Promise<void> {
const item = this.contextMenu()?.item;
if (!item?.conversation) {
return;
}
await this.directMessages.forgetConversation(item.conversation.id);
this.closeContextMenu();
if (this.isSelectedConversation(item.conversation)) {
await this.router.navigate(['/dm']);
}
}
forgetContextLabel(item: DmRailItem): string {
return item.conversation && this.isGroupConversation(item.conversation) ? 'Leave chat' : 'Forget chat';
}
forgetContextIcon(item: DmRailItem): string {
return item.conversation && this.isGroupConversation(item.conversation) ? 'lucideLogOut' : 'lucideTrash2';
}
formatUnreadCount(count: number): string {
return count > 99 ? '99+' : String(count);
}
@@ -227,4 +313,20 @@ export class DmRailComponent implements OnDestroy {
this.railItems.set(nextItems);
}
private isGroupConversation(conversation: DirectMessageConversation): boolean {
return conversation.kind === 'group' || conversation.participants.length > 2;
}
private groupConversationTitle(conversation: DirectMessageConversation): string {
const names = conversation.participants
.filter((participantId) => participantId !== this.currentUserId())
.map((participantId) => conversation.participantProfiles[participantId]?.displayName || participantId);
if (names.length <= 3) {
return names.join(', ');
}
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
}
}

View File

@@ -0,0 +1,7 @@
<main
appThemeNode="dmChatPanel"
class="relative min-h-0 min-w-0 overflow-hidden bg-background"
[ngStyle]="chatPanelStyles()"
>
<app-dm-chat />
</main>

View File

@@ -0,0 +1,21 @@
import { Component, computed, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ThemeNodeDirective, ThemeService } from '../../../theme';
import { DmChatComponent } from '../dm-chat/dm-chat.component';
@Component({
selector: 'app-dm-chat-panel',
standalone: true,
imports: [
CommonModule,
ThemeNodeDirective,
DmChatComponent
],
host: { class: 'contents' },
templateUrl: './dm-chat-panel.component.html'
})
export class DmChatPanelComponent {
private readonly theme = inject(ThemeService);
readonly chatPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmChatPanel'));
}

View File

@@ -0,0 +1,54 @@
<div
appThemeNode="dmConversationItem"
class="group flex w-full items-center gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-secondary/60"
[class.bg-primary/10]="isSelected()"
[class.text-foreground]="isSelected()"
[attr.aria-current]="isSelected() ? 'page' : null"
(click)="openConversation()"
(keydown.enter)="openConversation()"
(keydown.space)="openConversation()"
role="button"
tabindex="0"
>
<app-user-avatar
[name]="peerName()"
[avatarUrl]="peerAvatarUrl()"
size="sm"
/>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-2">
<p class="truncate text-sm font-medium text-foreground">{{ peerName() }}</p>
@if (conversation().unreadCount > 0) {
<span class="rounded-full bg-amber-400 px-1.5 py-0.5 text-[10px] font-semibold text-black">
{{ formatUnreadCount(conversation().unreadCount) }}
</span>
}
</div>
<p class="truncate text-xs text-muted-foreground">{{ lastMessagePreview() }}</p>
</div>
<button
type="button"
class="invisible grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground opacity-0 transition hover:bg-emerald-500/10 hover:text-emerald-600 focus:visible focus:opacity-100 group-focus-within:visible group-focus-within:opacity-100 group-hover:visible group-hover:opacity-100 disabled:group-focus-within:opacity-30 disabled:group-hover:opacity-30"
[disabled]="!canCall()"
[attr.aria-label]="'Call ' + peerName()"
[title]="'Call ' + peerName()"
(click)="callConversationPeer($event)"
>
<ng-icon
[name]="callIcon()"
class="h-3.5 w-3.5"
/>
</button>
<button
type="button"
class="grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground opacity-0 transition hover:bg-destructive/10 hover:text-destructive focus:opacity-100 group-hover:opacity-100"
[attr.aria-label]="'Forget ' + peerName()"
[title]="'Forget ' + peerName()"
(click)="forgetConversation($event)"
>
<ng-icon
name="lucideTrash2"
class="h-3.5 w-3.5"
/>
</button>
</div>

View File

@@ -0,0 +1,216 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
effect,
inject,
input
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucidePhone,
lucidePhoneCall,
lucideTrash2
} from '@ng-icons/lucide';
import { map } from 'rxjs';
import { UserAvatarComponent } from '../../../../shared';
import { ThemeNodeDirective } from '../../../theme';
import { AttachmentFacade } from '../../../attachment';
import { DirectCallService } from '../../../direct-call';
import { selectAllUsers } from '../../../../store/users/users.selectors';
import type { DirectMessageConversation } from '../../domain/models/direct-message.model';
import type { Attachment } from '../../../attachment';
import type { User } from '../../../../shared-kernel';
import { DirectMessageService } from '../../application/services/direct-message.service';
@Component({
selector: 'app-dm-conversation-item',
standalone: true,
imports: [
CommonModule,
NgIcon,
UserAvatarComponent,
ThemeNodeDirective
],
viewProviders: [provideIcons({ lucidePhone, lucidePhoneCall, lucideTrash2 })],
host: { class: 'block' },
templateUrl: './dm-conversation-item.component.html'
})
export class DmConversationItemComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly attachments = inject(AttachmentFacade);
private readonly directMessages = inject(DirectMessageService);
private readonly directCalls = inject(DirectCallService);
readonly conversation = input.required<DirectMessageConversation>();
readonly users = this.store.selectSignal(selectAllUsers);
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
initialValue: this.route.snapshot.paramMap.get('conversationId')
});
readonly isSelected = computed(() => this.routeConversationId() === this.conversation().id);
readonly peerName = computed(() => this.resolvePeerName(this.conversation()));
readonly peerAvatarUrl = computed(() => this.resolvePeerAvatarUrl(this.conversation()));
readonly lastMessagePreview = computed(() => this.resolveLastMessagePreview(this.conversation()));
readonly canCall = computed(() => this.canCallConversation(this.conversation()));
readonly callIcon = computed(() => this.conversationCallIcon(this.conversation()));
constructor() {
effect(() => {
const conversation = this.conversation();
const peer = this.peerUser(conversation, this.users());
if (!peer?.avatarUrl) {
this.directMessages.requestPeerAvatarSync(conversation.id);
}
});
}
openConversation(): void {
void this.router.navigate(['/dm', this.conversation().id]);
}
async forgetConversation(event: Event): Promise<void> {
event.stopPropagation();
const conversation = this.conversation();
const conversations = this.directMessages.conversations();
const nextConversation = conversations.find((entry) => entry.id !== conversation.id) ?? null;
await this.directMessages.forgetConversation(conversation.id);
if (this.routeConversationId() === conversation.id) {
await this.router.navigate(nextConversation ? ['/dm', nextConversation.id] : ['/dm']);
}
}
async callConversationPeer(event: Event): Promise<void> {
event.stopPropagation();
await this.directCalls.startConversationCall(this.conversation());
}
formatUnreadCount(count: number): string {
return count > 99 ? '99+' : String(count);
}
private resolvePeerName(conversation: DirectMessageConversation): string {
if (this.isGroupConversation(conversation)) {
return conversation.title || this.groupConversationTitle(conversation);
}
const peerId = this.peerId(conversation);
const knownUser = this.peerUser(conversation);
return peerId ? knownUser?.displayName || conversation.participantProfiles[peerId]?.displayName || peerId : 'Direct Message';
}
private resolvePeerAvatarUrl(conversation: DirectMessageConversation): string | undefined {
if (this.isGroupConversation(conversation)) {
return undefined;
}
const peerId = this.peerId(conversation);
const knownUser = this.peerUser(conversation);
return peerId ? knownUser?.avatarUrl || conversation.participantProfiles[peerId]?.avatarUrl : undefined;
}
private resolveLastMessagePreview(conversation: DirectMessageConversation): string {
const lastMessage = conversation.messages.at(-1);
if (!lastMessage) {
return 'No messages yet';
}
if (lastMessage.isDeleted) {
return 'Message deleted';
}
if (this.isKlipyGif(lastMessage.content)) {
return 'Sent a GIF';
}
this.attachments.updated();
const attachments = this.attachments.getForMessage(lastMessage.id);
if (attachments.length > 0) {
return this.attachmentPreview(attachments);
}
return lastMessage.content || 'Attachment';
}
private conversationCallIcon(conversation: DirectMessageConversation): string {
if (this.isGroupConversation(conversation)) {
return this.directCalls.isCallingConversation(conversation.id) ? 'lucidePhoneCall' : 'lucidePhone';
}
const peer = this.peerUser(conversation);
return peer && this.directCalls.isCallingUser(peer) ? 'lucidePhoneCall' : 'lucidePhone';
}
private canCallConversation(conversation: DirectMessageConversation): boolean {
if (this.isGroupConversation(conversation)) {
return conversation.participants.some((participantId) => participantId !== this.directMessages.currentUserId());
}
return !!this.peerUser(conversation);
}
private peerId(conversation: DirectMessageConversation): string | undefined {
const currentUserId = this.directMessages.currentUserId();
return conversation.participants.find((participantId) => participantId !== currentUserId);
}
private peerUser(conversation: DirectMessageConversation, users = this.users()): User | undefined {
if (this.isGroupConversation(conversation)) {
return undefined;
}
const peerId = this.peerId(conversation);
return peerId ? users.find((user) => user.id === peerId || user.oderId === peerId) : undefined;
}
private isGroupConversation(conversation: DirectMessageConversation): boolean {
return conversation.kind === 'group' || conversation.participants.length > 2;
}
private groupConversationTitle(conversation: DirectMessageConversation): string {
const currentUserId = this.directMessages.currentUserId();
const names = conversation.participants
.filter((participantId) => participantId !== currentUserId)
.map((participantId) => conversation.participantProfiles[participantId]?.displayName || participantId);
if (names.length <= 3) {
return names.join(', ');
}
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
}
private isKlipyGif(content: string): boolean {
return /!\[KLIPY GIF\]\([^)]*static\.klipy\.com[^)]*\)/i.test(content.trim());
}
private attachmentPreview(attachments: Attachment[]): string {
if (attachments.some((attachment) => attachment.mime.startsWith('image/'))) {
return 'Sent an image';
}
if (attachments.some((attachment) => attachment.mime.startsWith('video/'))) {
return 'Sent a video';
}
if (attachments.some((attachment) => attachment.mime.startsWith('audio/'))) {
return 'Sent audio';
}
return attachments.length === 1 ? 'Sent an attachment' : 'Sent attachments';
}
}

View File

@@ -0,0 +1,46 @@
<aside
appThemeNode="dmConversationsPanel"
class="flex min-h-0 overflow-hidden border-r border-border bg-card"
[ngStyle]="listPanelStyles()"
>
<section class="flex h-full w-full min-w-0 flex-col">
<header
appThemeNode="dmConversationsHeader"
class="flex h-14 shrink-0 items-center gap-2 border-b border-border px-3"
>
<div class="grid h-8 w-8 place-items-center rounded-lg bg-secondary text-muted-foreground">
<ng-icon
name="lucideMessageCircle"
class="h-4 w-4"
/>
</div>
<div class="min-w-0">
<h1 class="truncate text-sm font-semibold text-foreground">Direct Messages</h1>
<p class="text-xs text-muted-foreground">{{ directMessages.conversations().length }} chats</p>
</div>
</header>
<div
appThemeNode="dmConversationList"
class="min-h-0 flex-1 overflow-y-auto p-2"
>
@if (directMessages.conversations().length === 0) {
<div class="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">No direct messages yet.</div>
} @else {
<div class="space-y-1">
<app-dm-conversation-item
*ngFor="let conversation of directMessages.conversations(); trackBy: trackConversationId"
[conversation]="conversation"
></app-dm-conversation-item>
</div>
}
</div>
<div
appThemeNode="dmVoiceControlsArea"
class="border-t border-border px-2 py-3"
>
<app-voice-controls />
</div>
</section>
</aside>

View File

@@ -0,0 +1,38 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
inject
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideMessageCircle } from '@ng-icons/lucide';
import { ThemeNodeDirective, ThemeService } from '../../../theme';
import { VoiceControlsComponent } from '../../../voice-session';
import type { DirectMessageConversation } from '../../domain/models/direct-message.model';
import { DirectMessageService } from '../../application/services/direct-message.service';
import { DmConversationItemComponent } from './dm-conversation-item.component';
@Component({
selector: 'app-dm-conversations-panel',
standalone: true,
imports: [
CommonModule,
DmConversationItemComponent,
NgIcon,
ThemeNodeDirective,
VoiceControlsComponent
],
viewProviders: [provideIcons({ lucideMessageCircle })],
host: { class: 'contents' },
templateUrl: './dm-conversations-panel.component.html'
})
export class DmConversationsPanelComponent {
private readonly theme = inject(ThemeService);
readonly directMessages = inject(DirectMessageService);
readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel'));
trackConversationId(index: number, conversation: DirectMessageConversation): string {
return conversation.id;
}
}

View File

@@ -2,97 +2,6 @@
class="grid h-full min-h-0 overflow-hidden bg-background"
[ngStyle]="layoutStyles()"
>
<aside
appThemeNode="dmConversationsPanel"
class="flex min-h-0 overflow-hidden border-r border-border bg-card"
[ngStyle]="listPanelStyles()"
>
<section class="flex h-full w-full min-w-0 flex-col">
<header
appThemeNode="dmConversationsHeader"
class="flex h-14 shrink-0 items-center gap-2 border-b border-border px-3"
>
<div class="grid h-8 w-8 place-items-center rounded-lg bg-secondary text-muted-foreground">
<ng-icon
name="lucideMessageCircle"
class="h-4 w-4"
/>
</div>
<div class="min-w-0">
<h1 class="truncate text-sm font-semibold text-foreground">Direct Messages</h1>
<p class="text-xs text-muted-foreground">{{ directMessages.conversations().length }} chats</p>
</div>
</header>
<div
appThemeNode="dmConversationList"
class="min-h-0 flex-1 overflow-y-auto p-2"
>
@if (directMessages.conversations().length === 0) {
<div class="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">No direct messages yet.</div>
} @else {
<div class="space-y-1">
@for (conversation of directMessages.conversations(); track conversation.id) {
<div
appThemeNode="dmConversationItem"
class="group flex w-full items-center gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-secondary/60"
[class.bg-primary/10]="isSelectedConversation(conversation)"
[class.text-foreground]="isSelectedConversation(conversation)"
[attr.aria-current]="isSelectedConversation(conversation) ? 'page' : null"
(click)="openConversation(conversation)"
(keydown.enter)="openConversation(conversation)"
(keydown.space)="openConversation(conversation)"
role="button"
tabindex="0"
>
<app-user-avatar
[name]="peerName(conversation)"
[avatarUrl]="peerAvatarUrl(conversation)"
size="sm"
/>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-2">
<p class="truncate text-sm font-medium text-foreground">{{ peerName(conversation) }}</p>
@if (conversation.unreadCount > 0) {
<span class="rounded-full bg-amber-400 px-1.5 py-0.5 text-[10px] font-semibold text-black">
{{ formatUnreadCount(conversation.unreadCount) }}
</span>
}
</div>
<p class="truncate text-xs text-muted-foreground">{{ lastMessagePreview(conversation) }}</p>
</div>
<button
type="button"
class="grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground opacity-0 transition hover:bg-destructive/10 hover:text-destructive focus:opacity-100 group-hover:opacity-100"
[attr.aria-label]="'Forget ' + peerName(conversation)"
[title]="'Forget ' + peerName(conversation)"
(click)="forgetConversation($event, conversation)"
>
<ng-icon
name="lucideTrash2"
class="h-3.5 w-3.5"
/>
</button>
</div>
}
</div>
}
</div>
<div
appThemeNode="dmVoiceControlsArea"
class="border-t border-border px-2 py-3"
>
<app-voice-controls />
</div>
</section>
</aside>
<main
appThemeNode="dmChatPanel"
class="relative min-h-0 min-w-0 overflow-hidden bg-background"
[ngStyle]="chatPanelStyles()"
>
<app-dm-chat />
</main>
<app-dm-conversations-panel />
<app-dm-chat-panel />
</div>

View File

@@ -9,50 +9,31 @@ import {
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideMessageCircle, lucideTrash2 } from '@ng-icons/lucide';
import { map } from 'rxjs';
import { UserAvatarComponent } from '../../../../shared';
import { ThemeNodeDirective, ThemeService } from '../../../theme';
import { AttachmentFacade } from '../../../attachment';
import { VoiceControlsComponent } from '../../../voice-session';
import { ThemeService } from '../../../theme';
import { DirectMessageService } from '../../application/services/direct-message.service';
import { DmChatComponent } from '../dm-chat/dm-chat.component';
import { selectAllUsers } from '../../../../store/users/users.selectors';
import type { DirectMessageConversation } from '../../domain/models/direct-message.model';
import type { Attachment } from '../../../attachment';
import type { User } from '../../../../shared-kernel';
import { DmChatPanelComponent } from './dm-chat-panel.component';
import { DmConversationsPanelComponent } from './dm-conversations-panel.component';
@Component({
selector: 'app-dm-workspace',
standalone: true,
imports: [
CommonModule,
NgIcon,
UserAvatarComponent,
ThemeNodeDirective,
DmChatComponent,
VoiceControlsComponent
DmChatPanelComponent,
DmConversationsPanelComponent
],
viewProviders: [provideIcons({ lucideMessageCircle, lucideTrash2 })],
templateUrl: './dm-workspace.component.html'
})
export class DmWorkspaceComponent implements OnDestroy {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly theme = inject(ThemeService);
private readonly store = inject(Store);
private readonly attachments = inject(AttachmentFacade);
readonly directMessages = inject(DirectMessageService);
readonly users = this.store.selectSignal(selectAllUsers);
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
initialValue: this.route.snapshot.paramMap.get('conversationId')
});
readonly layoutStyles = computed(() => this.theme.getLayoutContainerStyles('dmLayout'));
readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel'));
readonly chatPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmChatPanel'));
constructor() {
effect(() => {
@@ -69,116 +50,9 @@ export class DmWorkspaceComponent implements OnDestroy {
void this.router.navigate(['/dm', firstConversation.id], { replaceUrl: true });
}
});
effect(() => {
const users = this.users();
for (const conversation of this.directMessages.conversations()) {
const peer = this.peerUser(conversation, users);
if (!peer?.avatarUrl) {
this.directMessages.requestPeerAvatarSync(conversation.id);
}
}
});
}
openConversation(conversation: DirectMessageConversation): void {
void this.router.navigate(['/dm', conversation.id]);
}
ngOnDestroy(): void {
this.directMessages.closeConversationView(this.routeConversationId());
}
isSelectedConversation(conversation: DirectMessageConversation): boolean {
return this.routeConversationId() === conversation.id;
}
peerName(conversation: DirectMessageConversation): string {
const peerId = this.peerId(conversation);
const knownUser = this.peerUser(conversation);
return peerId ? knownUser?.displayName || conversation.participantProfiles[peerId]?.displayName || peerId : 'Direct Message';
}
peerAvatarUrl(conversation: DirectMessageConversation): string | undefined {
const peerId = this.peerId(conversation);
const knownUser = this.peerUser(conversation);
return peerId ? knownUser?.avatarUrl || conversation.participantProfiles[peerId]?.avatarUrl : undefined;
}
lastMessagePreview(conversation: DirectMessageConversation): string {
const lastMessage = conversation.messages.at(-1);
if (!lastMessage) {
return 'No messages yet';
}
if (lastMessage.isDeleted) {
return 'Message deleted';
}
if (this.isKlipyGif(lastMessage.content)) {
return 'Sent a GIF';
}
this.attachments.updated();
const attachments = this.attachments.getForMessage(lastMessage.id);
if (attachments.length > 0) {
return this.attachmentPreview(attachments);
}
return lastMessage.content || 'Attachment';
}
async forgetConversation(event: Event, conversation: DirectMessageConversation): Promise<void> {
event.stopPropagation();
const conversations = this.directMessages.conversations();
const nextConversation = conversations.find((entry) => entry.id !== conversation.id) ?? null;
await this.directMessages.forgetConversation(conversation.id);
if (this.routeConversationId() === conversation.id) {
await this.router.navigate(nextConversation ? ['/dm', nextConversation.id] : ['/dm']);
}
}
formatUnreadCount(count: number): string {
return count > 99 ? '99+' : String(count);
}
private peerId(conversation: DirectMessageConversation): string | undefined {
const currentUserId = this.directMessages.currentUserId();
return conversation.participants.find((participantId) => participantId !== currentUserId);
}
private peerUser(conversation: DirectMessageConversation, users = this.users()): User | undefined {
const peerId = this.peerId(conversation);
return peerId ? users.find((user) => user.id === peerId || user.oderId === peerId) : undefined;
}
private isKlipyGif(content: string): boolean {
return /!\[KLIPY GIF\]\([^)]*static\.klipy\.com[^)]*\)/i.test(content.trim());
}
private attachmentPreview(attachments: Attachment[]): string {
if (attachments.some((attachment) => attachment.mime.startsWith('image/'))) {
return 'Sent an image';
}
if (attachments.some((attachment) => attachment.mime.startsWith('video/'))) {
return 'Sent a video';
}
if (attachments.some((attachment) => attachment.mime.startsWith('audio/'))) {
return 'Sent audio';
}
return attachments.length === 1 ? 'Sent an attachment' : 'Sent attachments';
}
}

View File

@@ -34,6 +34,19 @@
class="pointer-events-none flex scale-95 shrink-0 items-center gap-2 opacity-0 transition-[opacity,transform] duration-75 ease-out group-hover:pointer-events-auto group-hover:scale-100 group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:scale-100 group-focus-within:opacity-100"
>
<app-friend-button [user]="user" />
<button
type="button"
[attr.data-testid]="'call-friend-' + userKey(user)"
class="grid h-8 w-8 place-items-center rounded-md bg-emerald-500 text-white transition-colors hover:bg-emerald-600"
[attr.aria-label]="'Call ' + user.displayName"
[title]="'Call ' + user.displayName"
(click)="callUser(user)"
>
<ng-icon
[name]="callIcon(user)"
class="h-4 w-4"
/>
</button>
<button
type="button"
[attr.data-testid]="'message-friend-' + userKey(user)"
@@ -98,6 +111,19 @@
class="pointer-events-none flex scale-95 shrink-0 items-center gap-2 opacity-0 transition-[opacity,transform] duration-75 ease-out group-hover:pointer-events-auto group-hover:scale-100 group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:scale-100 group-focus-within:opacity-100"
>
<app-friend-button [user]="user" />
<button
type="button"
[attr.data-testid]="'call-user-' + userKey(user)"
class="grid h-8 w-8 place-items-center rounded-md bg-emerald-500 text-white transition-colors hover:bg-emerald-600"
[attr.aria-label]="'Call ' + user.displayName"
[title]="'Call ' + user.displayName"
(click)="callUser(user)"
>
<ng-icon
[name]="callIcon(user)"
class="h-4 w-4"
/>
</button>
<button
type="button"
[attr.data-testid]="'message-user-' + userKey(user)"

View File

@@ -9,11 +9,17 @@ import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideMessageCircle, lucideSearch } from '@ng-icons/lucide';
import {
lucideMessageCircle,
lucidePhone,
lucidePhoneCall,
lucideSearch
} from '@ng-icons/lucide';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
import { UserAvatarComponent } from '../../../../shared';
import { DirectMessageService } from '../../application/services/direct-message.service';
import { DirectCallService } from '../../../direct-call';
import { FriendService } from '../../application/services/friend.service';
import { FriendButtonComponent } from '../friend-button/friend-button.component';
import type { User } from '../../../../shared-kernel';
@@ -27,13 +33,14 @@ import type { User } from '../../../../shared-kernel';
UserAvatarComponent,
FriendButtonComponent
],
viewProviders: [provideIcons({ lucideMessageCircle, lucideSearch })],
viewProviders: [provideIcons({ lucideMessageCircle, lucidePhone, lucidePhoneCall, lucideSearch })],
templateUrl: './user-search-list.component.html'
})
export class UserSearchListComponent {
private readonly store = inject(Store);
private readonly router = inject(Router);
private readonly directMessages = inject(DirectMessageService);
readonly directCalls = inject(DirectCallService);
readonly friends = inject(FriendService);
readonly searchQuery = input('');
readonly users = this.store.selectSignal(selectAllUsers);
@@ -93,6 +100,14 @@ export class UserSearchListComponent {
await this.router.navigate(['/dm', conversation.id]);
}
async callUser(user: User): Promise<void> {
await this.directCalls.startCall(user);
}
callIcon(user: User): string {
return this.directCalls.isCallingUser(user) ? 'lucidePhoneCall' : 'lucidePhone';
}
userKey(user: User): string {
return user.oderId || user.id;
}

View File

@@ -0,0 +1,38 @@
# Experimental Media Domain
Optional media experiments live here so they can be removed without disturbing the attachment transfer domain or chat rendering.
## VLC.js Playback
The VLC.js player is off by default and is only offered for audio/video attachments that do not use the native Chromium player path. Chat does not instantiate VLC.js while scrolling; the runtime is loaded only after the user chooses the experimental player on a downloaded attachment.
The app does not bundle VideoLAN's proof-of-concept build directly. Instead, `ExperimentalVlcRuntimeService` loads this browser adapter script:
```text
/vlcjs/metoyou-vlc-player.js
```
That script must register this adapter on `window`:
```ts
window.MetoYouVlcJs = {
createPlayer({ container, sourceUrl, filename, mime }) {
// Mount VLC.js/WebAssembly UI into container and return an optional cleanup handle.
return { destroy() {} };
}
};
```
The repository includes a small placeholder at `toju-app/public/vlcjs/metoyou-vlc-player.js` so dev/prod servers return JavaScript instead of the Angular HTML fallback. The placeholder sets `isPlaceholder: true`; the settings toggle stays disabled and chat does not show the experimental Play action while only the placeholder is present. To enable real playback, replace that file with an adapter that mounts the chosen VLC.js/WebAssembly runtime, removes `isPlaceholder`, and returns an optional cleanup handle.
On Electron, downloaded file-backed attachments also expose an Open action in the generic file interface. Use that for MKV/AVI or other unsupported formats until a real VLC.js adapter is bundled; Electron opens the saved file with the operating system's default player.
## Removal
To remove the experiment later:
1. Delete this domain folder.
2. Remove `ExperimentalMediaSettingsService` and `ExperimentalVlcPlayerComponent` imports/usages from the chat message item and general settings.
3. Delete any bundled `public/vlcjs/` runtime files.
The attachment transport, persistence, and default file UI do not depend on this runtime.

View File

@@ -0,0 +1,89 @@
import { Injectable, inject, signal } from '@angular/core';
import { ExperimentalVlcRuntimeService } from '../../infrastructure/services/experimental-vlc-runtime.service';
const STORAGE_KEY_EXPERIMENTAL_MEDIA_SETTINGS = 'metoyou_experimental_media_settings';
export interface ExperimentalMediaSettings {
vlcJsPlaybackEnabled: boolean;
}
const DEFAULT_EXPERIMENTAL_MEDIA_SETTINGS: ExperimentalMediaSettings = {
vlcJsPlaybackEnabled: false
};
@Injectable({ providedIn: 'root' })
export class ExperimentalMediaSettingsService {
private readonly vlcRuntime = inject(ExperimentalVlcRuntimeService);
private readonly storedSettings = loadExperimentalMediaSettings();
readonly vlcJsPlaybackEnabled = signal(false);
readonly vlcJsRuntimeAvailable = signal(false);
readonly vlcJsRuntimeStatus = signal<'checking' | 'available' | 'missing'>('checking');
constructor() {
void this.refreshVlcRuntimeStatus();
}
setVlcJsPlaybackEnabled(enabled: boolean): void {
if (enabled && !this.vlcJsRuntimeAvailable()) {
this.vlcJsPlaybackEnabled.set(false);
saveExperimentalMediaSettings({ vlcJsPlaybackEnabled: false });
return;
}
const settings = saveExperimentalMediaSettings({
vlcJsPlaybackEnabled: enabled
});
this.vlcJsPlaybackEnabled.set(settings.vlcJsPlaybackEnabled);
}
async refreshVlcRuntimeStatus(): Promise<void> {
this.vlcJsRuntimeStatus.set('checking');
const available = await this.vlcRuntime.hasBundledRuntime();
this.vlcJsRuntimeAvailable.set(available);
this.vlcJsRuntimeStatus.set(available ? 'available' : 'missing');
this.vlcJsPlaybackEnabled.set(available && this.storedSettings.vlcJsPlaybackEnabled);
if (!available && this.storedSettings.vlcJsPlaybackEnabled) {
saveExperimentalMediaSettings({ vlcJsPlaybackEnabled: false });
}
}
}
function loadExperimentalMediaSettings(): ExperimentalMediaSettings {
try {
const raw = localStorage.getItem(STORAGE_KEY_EXPERIMENTAL_MEDIA_SETTINGS);
if (!raw) {
return { ...DEFAULT_EXPERIMENTAL_MEDIA_SETTINGS };
}
return normaliseExperimentalMediaSettings(JSON.parse(raw) as Partial<ExperimentalMediaSettings>);
} catch {
return { ...DEFAULT_EXPERIMENTAL_MEDIA_SETTINGS };
}
}
function saveExperimentalMediaSettings(patch: Partial<ExperimentalMediaSettings>): ExperimentalMediaSettings {
const nextSettings = normaliseExperimentalMediaSettings({
...loadExperimentalMediaSettings(),
...patch
});
try {
localStorage.setItem(STORAGE_KEY_EXPERIMENTAL_MEDIA_SETTINGS, JSON.stringify(nextSettings));
} catch {}
return nextSettings;
}
function normaliseExperimentalMediaSettings(raw: Partial<ExperimentalMediaSettings>): ExperimentalMediaSettings {
return {
vlcJsPlaybackEnabled: typeof raw.vlcJsPlaybackEnabled === 'boolean'
? raw.vlcJsPlaybackEnabled
: DEFAULT_EXPERIMENTAL_MEDIA_SETTINGS.vlcJsPlaybackEnabled
};
}

View File

@@ -0,0 +1,77 @@
<section class="mt-2 max-w-xl overflow-hidden rounded-md border border-border bg-card shadow-sm">
<div class="flex items-center justify-between gap-3 border-b border-border px-3 py-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium text-foreground">{{ filename() }}</p>
<p class="text-xs text-muted-foreground">{{ sizeLabel() }}</p>
</div>
<div class="flex shrink-0 items-center gap-1">
<button
type="button"
class="grid h-8 w-8 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Download"
aria-label="Download"
(click)="requestDownload()"
>
<ng-icon
name="lucideDownload"
class="h-4 w-4"
/>
</button>
<button
type="button"
class="grid h-8 w-8 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Close"
aria-label="Close"
(click)="close()"
>
<ng-icon
name="lucideX"
class="h-4 w-4"
/>
</button>
</div>
</div>
<div class="relative min-h-72 bg-black">
<div
#playerMount
class="min-h-72 w-full"
></div>
@if (status() === 'loading') {
<div class="absolute inset-0 grid place-items-center bg-black/80 px-4 text-sm text-white/80">Loading experimental player...</div>
}
@if (status() === 'error') {
<div class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-black/85 px-6 text-center">
<p class="max-w-md text-sm text-white/80">{{ errorMessage() }}</p>
<div class="flex flex-wrap justify-center gap-2">
<button
type="button"
class="inline-flex items-center gap-2 rounded-md bg-white/10 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-white/20"
(click)="retry()"
>
<ng-icon
name="lucideRefreshCw"
class="h-3.5 w-3.5"
/>
Retry
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-md bg-white px-3 py-1.5 text-xs font-medium text-black transition-colors hover:bg-white/90"
(click)="requestDownload()"
>
<ng-icon
name="lucideDownload"
class="h-3.5 w-3.5"
/>
Download
</button>
</div>
</div>
}
</div>
</section>

View File

@@ -0,0 +1,109 @@
import {
AfterViewInit,
Component,
ElementRef,
OnDestroy,
ViewChild,
inject,
input,
output,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideDownload,
lucideRefreshCw,
lucideX
} from '@ng-icons/lucide';
import {
ExperimentalVlcPlayerHandle,
ExperimentalVlcRuntimeService
} from '../../infrastructure/services/experimental-vlc-runtime.service';
@Component({
selector: 'app-experimental-vlc-player',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [
provideIcons({
lucideDownload,
lucideRefreshCw,
lucideX
})
],
templateUrl: './experimental-vlc-player.component.html'
})
export class ExperimentalVlcPlayerComponent implements AfterViewInit, OnDestroy {
@ViewChild('playerMount') playerMount?: ElementRef<HTMLDivElement>;
src = input.required<string>();
filename = input.required<string>();
mime = input.required<string>();
sizeLabel = input<string>('');
closed = output<void>();
downloadRequested = output<void>();
private readonly runtime = inject(ExperimentalVlcRuntimeService);
private playerHandle: ExperimentalVlcPlayerHandle | null = null;
readonly status = signal<'loading' | 'ready' | 'error'>('loading');
readonly errorMessage = signal('');
ngAfterViewInit(): void {
void this.loadPlayer();
}
ngOnDestroy(): void {
this.destroyPlayer();
}
retry(): void {
this.destroyPlayer();
void this.loadPlayer();
}
close(): void {
this.closed.emit();
}
requestDownload(): void {
this.downloadRequested.emit();
}
private async loadPlayer(): Promise<void> {
const container = this.playerMount?.nativeElement;
if (!container) {
return;
}
this.status.set('loading');
this.errorMessage.set('');
container.replaceChildren();
try {
this.playerHandle = await this.runtime.createPlayer({
container,
sourceUrl: this.src(),
filename: this.filename(),
mime: this.mime()
});
this.status.set('ready');
} catch (error) {
this.status.set('error');
this.errorMessage.set(error instanceof Error ? error.message : 'Experimental VLC.js playback failed to start.');
}
}
private destroyPlayer(): void {
try {
this.playerHandle?.destroy?.();
} catch {}
this.playerHandle = null;
this.playerMount?.nativeElement.replaceChildren();
}
}

View File

@@ -0,0 +1,2 @@
export { ExperimentalMediaSettingsService } from './application/services/experimental-media-settings.service';
export { ExperimentalVlcPlayerComponent } from './feature/experimental-vlc-player/experimental-vlc-player.component';

View File

@@ -0,0 +1,80 @@
import { DOCUMENT } from '@angular/common';
import { Injectable, inject } from '@angular/core';
export interface ExperimentalVlcPlayerOptions {
container: HTMLElement;
sourceUrl: string;
filename: string;
mime: string;
}
export interface ExperimentalVlcPlayerHandle {
destroy?: () => void;
}
export interface ExperimentalVlcRuntime {
createPlayer: (options: ExperimentalVlcPlayerOptions) => ExperimentalVlcPlayerHandle | Promise<ExperimentalVlcPlayerHandle>;
isPlaceholder?: boolean;
}
declare global {
interface Window {
MetoYouVlcJs?: ExperimentalVlcRuntime;
}
}
const VLC_RUNTIME_SCRIPT_URL = '/vlcjs/metoyou-vlc-player.js';
@Injectable({ providedIn: 'root' })
export class ExperimentalVlcRuntimeService {
private readonly document = inject(DOCUMENT);
private runtimeLoadPromise: Promise<ExperimentalVlcRuntime> | null = null;
async createPlayer(options: ExperimentalVlcPlayerOptions): Promise<ExperimentalVlcPlayerHandle> {
const runtime = await this.loadRuntime();
if (runtime.isPlaceholder) {
throw new Error('No VLC.js runtime is bundled. Use Open in Electron, or replace /vlcjs/metoyou-vlc-player.js with a real runtime adapter.');
}
return runtime.createPlayer(options);
}
async hasBundledRuntime(): Promise<boolean> {
try {
const runtime = await this.loadRuntime();
return !!runtime.createPlayer && !runtime.isPlaceholder;
} catch {
return false;
}
}
private loadRuntime(): Promise<ExperimentalVlcRuntime> {
if (this.document.defaultView?.MetoYouVlcJs?.createPlayer) {
return Promise.resolve(this.document.defaultView.MetoYouVlcJs);
}
this.runtimeLoadPromise ??= new Promise<ExperimentalVlcRuntime>((resolve, reject) => {
const script = this.document.createElement('script');
script.src = VLC_RUNTIME_SCRIPT_URL;
script.async = true;
script.onload = () => {
const runtime = this.document.defaultView?.MetoYouVlcJs;
if (!runtime?.createPlayer) {
reject(new Error('The experimental VLC.js runtime did not register a MetoYouVlcJs.createPlayer adapter.'));
return;
}
resolve(runtime);
};
script.onerror = () => reject(new Error(`The experimental VLC.js runtime was not found at ${VLC_RUNTIME_SCRIPT_URL}.`));
this.document.head.appendChild(script);
});
return this.runtimeLoadPromise;
}
}

View File

@@ -80,6 +80,8 @@ stateDiagram-v2
When a voice session is active and the user navigates away from the voice-connected server, `showFloatingControls` becomes `true` and the floating overlay appears. Clicking the overlay dispatches `RoomsActions.viewServer` to navigate back.
Joining a new voice target is exclusive: entering another voice channel or private call first disconnects the current call/channel, clears local voice state, and broadcasts the leave for the previous target. Users never need to manually leave one voice target before joining another.
Remote voice playback is scoped to the active voice channel, not the whole server. Users stay connected to the shared peer mesh for text, presence, and screen-share control, but voice transport and playback only stay active for peers whose `voiceState.roomId` and `voiceState.serverId` match the local user's current voice session.
Owners and admins can also move connected users between voice channels from the room sidebar by dragging a user onto a different voice channel. The moved client updates its local heartbeat and voice-session metadata to the new channel, so routing, floating controls, and occupancy stay in sync after the move.

View File

@@ -0,0 +1,71 @@
<div class="flex w-full flex-wrap items-center justify-center gap-3 rounded-2xl bg-background/75 px-4 py-3 backdrop-blur">
@if (!connected()) {
<button
type="button"
class="inline-flex h-12 items-center gap-2 rounded-full bg-emerald-500 px-6 text-sm font-semibold text-white transition-colors hover:bg-emerald-600"
(click)="joinRequested.emit()"
>
<ng-icon
name="lucidePhone"
class="h-5 w-5"
/>
Join call
</button>
}
<button
type="button"
class="grid h-12 w-12 place-items-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-45"
[disabled]="!connected()"
(click)="muteToggled.emit()"
[attr.aria-label]="muted() ? 'Unmute' : 'Mute'"
[title]="muted() ? 'Unmute' : 'Mute'"
>
<ng-icon
[name]="muted() ? 'lucideMicOff' : 'lucideMic'"
class="h-5 w-5"
/>
</button>
<button
type="button"
class="grid h-12 w-12 place-items-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-45"
[disabled]="!connected()"
(click)="cameraToggled.emit()"
[attr.aria-label]="cameraEnabled() ? 'Turn camera off' : 'Turn camera on'"
[title]="cameraEnabled() ? 'Turn camera off' : 'Turn camera on'"
>
<ng-icon
[name]="cameraEnabled() ? 'lucideVideoOff' : 'lucideVideo'"
class="h-5 w-5"
/>
</button>
<button
type="button"
class="grid h-12 w-12 place-items-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-45"
[disabled]="!connected()"
(click)="screenShareToggled.emit()"
[attr.aria-label]="screenSharing() ? 'Stop sharing screen' : 'Share screen'"
[title]="screenSharing() ? 'Stop sharing screen' : 'Share screen'"
>
<ng-icon
[name]="screenSharing() ? 'lucideMonitorOff' : 'lucideMonitor'"
class="h-5 w-5"
/>
</button>
<button
type="button"
class="grid h-12 w-12 place-items-center rounded-full bg-destructive/10 text-destructive transition-colors hover:bg-destructive/15 disabled:opacity-45"
[disabled]="!connected()"
(click)="leaveRequested.emit()"
aria-label="Leave call"
title="Leave call"
>
<ng-icon
name="lucidePhoneOff"
class="h-5 w-5"
/>
</button>
</div>

View File

@@ -0,0 +1,43 @@
import { Component, input, output } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideMic,
lucideMicOff,
lucideMonitor,
lucideMonitorOff,
lucidePhone,
lucidePhoneOff,
lucideVideo,
lucideVideoOff
} from '@ng-icons/lucide';
@Component({
selector: 'app-private-call-controls',
standalone: true,
imports: [NgIcon],
viewProviders: [
provideIcons({
lucideMic,
lucideMicOff,
lucideMonitor,
lucideMonitorOff,
lucidePhone,
lucidePhoneOff,
lucideVideo,
lucideVideoOff
})
],
templateUrl: './private-call-controls.component.html'
})
export class PrivateCallControlsComponent {
readonly connected = input.required<boolean>();
readonly muted = input.required<boolean>();
readonly cameraEnabled = input.required<boolean>();
readonly screenSharing = input.required<boolean>();
readonly joinRequested = output<void>();
readonly muteToggled = output<void>();
readonly cameraToggled = output<void>();
readonly screenShareToggled = output<void>();
readonly leaveRequested = output<void>();
}

View File

@@ -0,0 +1,102 @@
<article
class="flex aspect-square min-w-0 flex-col items-center justify-center overflow-hidden rounded-2xl border border-border/80 bg-card/80 text-center shadow-sm backdrop-blur"
[class.w-[11rem]]="compact()"
[class.shrink-0]="compact()"
[class.p-4]="compact()"
[class.sm:w-[12.5rem]]="compact()"
[class.w-full]="!compact()"
[class.p-[clamp(1rem,4vw,1.5rem)]]="!compact()"
>
<div
class="relative h-[var(--participant-avatar-size)] w-[var(--participant-avatar-size)] rounded-full ring-2 transition-all duration-150 sm:h-[var(--participant-avatar-size-sm)] sm:w-[var(--participant-avatar-size-sm)]"
[attr.data-testid]="'call-participant-' + (user().oderId || user().id)"
[style.--participant-avatar-size]="avatarSize()"
[style.--participant-avatar-size-sm]="avatarSizeSm()"
[class.p-1.5]="compact()"
[class.p-2]="!compact()"
[class.ring-emerald-400]="speaking()"
[class.shadow-[0_0_0_6px_rgba(16,185,129,0.12)]]="speaking() && compact()"
[class.shadow-[0_0_0_8px_rgba(16,185,129,0.12)]]="speaking() && !compact()"
[class.ring-border]="!speaking()"
[class.opacity-55]="!connected()"
>
@if (user().avatarUrl) {
<img
[src]="user().avatarUrl"
[alt]="user().displayName"
[width]="compact() ? 96 : 160"
[height]="compact() ? 96 : 160"
decoding="async"
loading="lazy"
class="block h-full w-full rounded-full object-cover"
/>
} @else {
<div
class="grid h-full w-full place-items-center rounded-full bg-primary/15 font-semibold text-primary"
[class.text-3xl]="compact()"
[class.text-[clamp(1.75rem,8vw,3.5rem)]]="!compact()"
>
{{ participantInitial() }}
</div>
}
@if (!connected()) {
<div
class="absolute grid place-items-center rounded-full bg-background/72 backdrop-blur-[1px]"
[class.inset-1.5]="compact()"
[class.inset-2]="!compact()"
>
<div
class="grid place-items-center rounded-full border border-border bg-card text-muted-foreground shadow-sm"
[class.h-10]="compact()"
[class.w-10]="compact()"
[class.h-14]="!compact()"
[class.w-14]="!compact()"
>
<ng-icon
name="lucideWifiOff"
[class.h-5]="compact()"
[class.w-5]="compact()"
[class.h-7]="!compact()"
[class.w-7]="!compact()"
/>
</div>
</div>
}
@if (connected()) {
<span
class="absolute rounded-full border-card"
[class.bottom-3]="compact()"
[class.right-3]="compact()"
[class.h-4]="compact()"
[class.w-4]="compact()"
[class.border-[3px]]="compact()"
[class.bottom-5]="!compact()"
[class.right-5]="!compact()"
[class.h-5]="!compact()"
[class.w-5]="!compact()"
[class.border-4]="!compact()"
[class.bg-emerald-400]="speaking()"
[class.bg-muted-foreground]="!speaking()"
></span>
}
</div>
<div
class="min-w-0 max-w-full"
[class.mt-3]="compact()"
[class.mt-5]="!compact()"
>
<h2
class="truncate font-semibold text-foreground"
[class.text-sm]="compact()"
[class.text-[clamp(1rem,4vw,1.25rem)]]="!compact()"
>
{{ user().displayName }}
</h2>
@if (issueLabel(); as label) {
<p class="mt-1 text-xs font-semibold text-muted-foreground">{{ label }}</p>
}
</div>
</article>

View File

@@ -0,0 +1,33 @@
import { Component, input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideWifiOff } from '@ng-icons/lucide';
import type { User } from '../../shared-kernel';
@Component({
selector: 'app-private-call-participant-card',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [provideIcons({ lucideWifiOff })],
host: { class: 'block min-w-0' },
templateUrl: './private-call-participant-card.component.html'
})
export class PrivateCallParticipantCardComponent {
readonly user = input.required<User>();
readonly connected = input.required<boolean>();
readonly speaking = input.required<boolean>();
readonly issueLabel = input<string | null>(null);
readonly compact = input(false);
avatarSize(): string {
return this.compact() ? '5rem' : 'clamp(4.25rem, 22vw, 10rem)';
}
avatarSizeSm(): string {
return this.compact() ? '6rem' : this.avatarSize();
}
participantInitial(): string {
return this.user().displayName.charAt(0).toUpperCase() || '?';
}
}

View File

@@ -0,0 +1,202 @@
<section
class="grid h-full min-h-0 bg-background lg:grid-cols-[minmax(0,1fr)_var(--private-call-chat-width)]"
[style.--private-call-chat-width]="chatWidthPx() + 'px'"
>
<main class="flex min-h-0 min-w-0 flex-col overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.10),transparent_34rem)]">
<header class="flex min-h-16 shrink-0 items-center justify-between gap-3 border-b border-border/70 bg-background/80 px-5 backdrop-blur">
<div class="flex min-w-0 items-center gap-3">
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-emerald-500/10 text-emerald-500">
<ng-icon
name="lucidePhone"
class="h-5 w-5"
/>
</div>
<div class="min-w-0">
<h1 class="truncate text-base font-semibold text-foreground">Private Call</h1>
<p class="truncate text-xs text-muted-foreground">
@if (session()) {
{{ participantUsers().length }} participants
} @else {
Call not found
}
</p>
</div>
</div>
@if (session()) {
<div class="flex items-center gap-2">
<select
class="h-9 max-w-44 rounded-md border border-border bg-secondary px-2 text-sm text-foreground"
[ngModel]="inviteUserId()"
(ngModelChange)="inviteUserId.set($event)"
aria-label="Add user to call"
>
<option value="">Add user</option>
@for (user of inviteCandidates(); track userKey(user)) {
<option [value]="userKey(user)">{{ user.displayName }}</option>
}
</select>
<button
type="button"
class="grid h-9 w-9 place-items-center rounded-md bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-50"
[disabled]="!inviteUserId()"
(click)="inviteSelectedUser()"
aria-label="Add user"
title="Add user"
>
<ng-icon
name="lucideUserPlus"
class="h-4 w-4"
/>
</button>
</div>
}
</header>
@if (session()) {
<div class="flex min-h-0 flex-1 flex-col overflow-hidden px-4 py-4 sm:px-5">
<div class="relative min-h-0 flex-1 overflow-hidden rounded-2xl border border-border/80 bg-card/45 shadow-sm">
@if (activeShares().length > 0) {
@if (focusedShare()) {
@if (hasMultipleShares()) {
<div class="absolute right-3 top-3 z-10 sm:right-4 sm:top-4">
<button
type="button"
data-testid="private-call-show-all-streams"
class="inline-flex h-10 items-center gap-2 rounded-full border border-white/10 bg-black/45 px-3 text-xs font-medium text-white/80 backdrop-blur transition hover:bg-black/65 hover:text-white"
title="Show all streams"
(click)="showAllStreams()"
>
<ng-icon
name="lucideUsers"
class="h-3.5 w-3.5"
/>
All streams
</button>
</div>
}
<app-voice-workspace-stream-tile
[item]="focusedShare()!"
[featured]="true"
[focused]="true"
data-testid="private-call-focused-stream"
[immersive]="true"
(focusRequested)="focusShare($event)"
/>
} @else if (hasMultipleShares()) {
<div
class="grid h-full min-h-0 auto-rows-[minmax(12rem,1fr)] grid-cols-1 gap-3 p-3 sm:grid-cols-2 sm:gap-4 sm:p-4"
[ngClass]="{ '2xl:grid-cols-3': activeShares().length > 2 }"
data-testid="private-call-stream-grid"
>
@for (share of activeShares(); track share.id) {
<div class="min-h-0 overflow-hidden rounded-2xl bg-black">
<app-voice-workspace-stream-tile
[item]="share"
[focused]="false"
(focusRequested)="focusShare($event)"
/>
</div>
}
</div>
}
} @else {
<div class="flex h-full min-h-0 items-center justify-center p-4 sm:p-6">
<div
class="grid w-full max-w-5xl grid-cols-[repeat(auto-fit,minmax(min(10rem,100%),1fr))] items-stretch justify-center gap-3 sm:grid-cols-[repeat(auto-fit,minmax(min(13rem,100%),1fr))] sm:gap-5 lg:gap-7"
>
<app-private-call-participant-card
*ngFor="let user of participantUsers(); trackBy: trackUserKey"
[user]="user"
[connected]="isParticipantConnected(user)"
[speaking]="isSpeaking(user)"
[issueLabel]="participantIssueLabel(user)"
></app-private-call-participant-card>
</div>
</div>
}
</div>
@if (activeShares().length > 0) {
<div class="shrink-0 pt-4">
<div class="flex w-full items-stretch gap-3 overflow-x-auto pb-1">
<app-private-call-participant-card
*ngFor="let user of participantUsers(); trackBy: trackUserKey"
[user]="user"
[connected]="isParticipantConnected(user)"
[speaking]="isSpeaking(user)"
[issueLabel]="participantIssueLabel(user)"
[compact]="true"
></app-private-call-participant-card>
@if (hasMultipleShares()) {
@for (share of focusedShare() ? thumbnailShares() : activeShares(); track share.id) {
<article
class="flex min-h-[8.75rem] w-[11rem] shrink-0 flex-col overflow-hidden rounded-2xl border border-border/80 bg-black shadow-sm sm:w-[12.5rem]"
>
<div class="min-h-0 flex-1">
<app-voice-workspace-stream-tile
[item]="share"
[mini]="true"
[focused]="false"
(focusRequested)="focusShare($event)"
/>
</div>
<div class="shrink-0 bg-black/80 px-3 py-2 text-xs font-semibold text-white/75">
{{ streamLabel(share) }}
</div>
</article>
}
}
</div>
</div>
}
<div class="shrink-0 pt-3">
<app-private-call-controls
class="mx-auto block w-full max-w-5xl"
[connected]="isConnected()"
[muted]="isMuted()"
[cameraEnabled]="isCameraEnabled()"
[screenSharing]="isScreenSharing()"
(joinRequested)="join()"
(muteToggled)="toggleMute()"
(cameraToggled)="toggleCamera()"
(screenShareToggled)="toggleScreenShare()"
(leaveRequested)="leave()"
></app-private-call-controls>
</div>
</div>
} @else {
<div class="flex flex-1 items-center justify-center px-6 text-sm text-muted-foreground">No active call for this route.</div>
}
</main>
<aside class="relative hidden min-h-0 border-l border-border bg-card lg:block">
<div
class="group absolute inset-y-0 left-0 z-10 w-3 -translate-x-1/2 cursor-col-resize bg-transparent"
role="separator"
aria-orientation="vertical"
title="Resize chat"
data-testid="private-call-chat-resizer"
(mousedown)="startChatResize($event)"
>
<div class="mx-auto h-full w-px bg-border transition group-hover:bg-primary"></div>
</div>
<app-dm-chat
[conversationId]="session()?.conversationId ?? null"
[showCallButton]="false"
/>
</aside>
</section>
@if (showScreenShareQualityDialog()) {
<app-screen-share-quality-dialog
[selectedQuality]="screenShareQuality()"
[includeSystemAudio]="includeSystemAudio()"
(cancelled)="onScreenShareQualityCancelled()"
(confirmed)="onScreenShareQualityConfirmed($event)"
/>
}

View File

@@ -0,0 +1,564 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
DestroyRef,
HostListener,
computed,
effect,
inject,
signal,
untracked
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucidePhone,
lucideUsers,
lucideUserPlus
} from '@ng-icons/lucide';
import { map } from 'rxjs';
import {
DirectCallService,
participantToUser,
type DirectCallSession
} from '../../domains/direct-call';
import { DmChatComponent } from '../../domains/direct-message/feature/dm-chat/dm-chat.component';
import {
VoiceActivityService,
VoiceConnectionFacade,
VoicePlaybackService
} from '../../domains/voice-connection';
import {
ScreenShareFacade,
ScreenShareQuality,
ScreenShareStartOptions
} from '../../domains/screen-share';
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../domains/voice-session';
import { ScreenShareQualityDialogComponent } from '../../shared';
import { selectAllUsers, selectCurrentUser } from '../../store/users/users.selectors';
import { UsersActions } from '../../store/users/users.actions';
import { User } from '../../shared-kernel';
import { VoiceWorkspaceStreamItem } from '../room/voice-workspace/voice-workspace.models';
import { VoiceWorkspaceStreamTileComponent } from '../room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-tile.component';
import { PrivateCallControlsComponent } from './private-call-controls.component';
import { PrivateCallParticipantCardComponent } from './private-call-participant-card.component';
@Component({
selector: 'app-private-call',
standalone: true,
imports: [
CommonModule,
DmChatComponent,
FormsModule,
NgIcon,
PrivateCallControlsComponent,
PrivateCallParticipantCardComponent,
ScreenShareQualityDialogComponent,
VoiceWorkspaceStreamTileComponent
],
viewProviders: [
provideIcons({
lucidePhone,
lucideUsers,
lucideUserPlus
})
],
templateUrl: './private-call.component.html'
})
export class PrivateCallComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
private readonly store = inject(Store);
private readonly calls = inject(DirectCallService);
private readonly voice = inject(VoiceConnectionFacade);
private readonly voiceActivity = inject(VoiceActivityService);
private readonly playback = inject(VoicePlaybackService);
private readonly screenShare = inject(ScreenShareFacade);
private chatResizing = false;
readonly allUsers = this.store.selectSignal(selectAllUsers);
readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly callId = toSignal(this.route.paramMap.pipe(map((params) => params.get('callId'))), {
initialValue: this.route.snapshot.paramMap.get('callId')
});
readonly session = computed(() => this.calls.sessionById(this.callId()));
readonly participantUsers = computed(() => {
const session = this.session();
if (!session) {
return [] as User[];
}
return session.participantIds
.map((participantId) => this.userForSessionParticipant(session, participantId))
.filter((user): user is User => !!user);
});
readonly isConnected = computed(() => {
const session = this.session();
const currentUserId = this.currentUserKey();
return !!session && !!currentUserId && !!session.participants[currentUserId]?.joined;
});
readonly isMuted = this.voice.isMuted;
readonly isDeafened = this.voice.isDeafened;
readonly isCameraEnabled = this.voice.isCameraEnabled;
readonly isScreenSharing = this.screenShare.isScreenSharing;
readonly remoteStreamRevision = signal(0);
readonly includeSystemAudio = signal(false);
readonly screenShareQuality = signal<ScreenShareQuality>('balanced');
readonly askScreenShareQuality = signal(true);
readonly showScreenShareQualityDialog = signal(false);
readonly inviteUserId = signal('');
readonly focusedStreamId = signal<string | null>(null);
readonly showAllStreamsMode = signal(false);
readonly chatWidthPx = signal(384);
readonly inviteCandidates = computed(() => {
const participantIds = new Set(this.session()?.participantIds ?? []);
const currentUserId = this.currentUserKey();
return this.allUsers().filter((user) => {
const userId = this.userKey(user);
return userId !== currentUserId && !participantIds.has(userId);
});
});
readonly activeShares = computed<VoiceWorkspaceStreamItem[]>(() => {
this.remoteStreamRevision();
const shares: VoiceWorkspaceStreamItem[] = [];
const localUser = this.currentUser();
const localPeerKey = localUser ? this.userKey(localUser) : null;
const isJoinedToCurrentCall = this.isConnected();
const localScreenStream = isJoinedToCurrentCall ? this.screenShare.screenStream() : null;
const localCameraStream = isJoinedToCurrentCall && this.voice.isCameraEnabled() ? this.voice.getLocalCameraStream() : null;
if (localUser && localPeerKey && localScreenStream) {
shares.push(this.buildShare(localPeerKey, localUser, localScreenStream, true, 'screen'));
}
if (localUser && localPeerKey && localCameraStream) {
shares.push(this.buildShare(localPeerKey, localUser, localCameraStream, true, 'camera'));
}
for (const user of this.participantUsers()) {
const peerKey = this.getPeerKeyCandidates(user).find(
(candidate) => candidate !== localPeerKey
&& (
!!this.screenShare.getRemoteScreenShareStream(candidate)
|| !!this.voice.getRemoteCameraStream(candidate)
)
) ?? this.userKey(user);
if (peerKey === localPeerKey) {
continue;
}
const screenStream = this.screenShare.getRemoteScreenShareStream(peerKey);
const cameraStream = this.voice.getRemoteCameraStream(peerKey);
if (screenStream && this.hasActiveVideo(screenStream)) {
shares.push(this.buildShare(peerKey, user, screenStream, false, 'screen'));
}
if (cameraStream && this.hasActiveVideo(cameraStream)) {
shares.push(this.buildShare(peerKey, user, cameraStream, false, 'camera'));
}
}
return shares;
});
readonly featuredShare = computed(() => this.activeShares()[0] ?? null);
readonly hasMultipleShares = computed(() => this.activeShares().length > 1);
readonly focusedShareId = computed(() => {
const requested = this.focusedStreamId();
const activeShares = this.activeShares();
if (this.showAllStreamsMode() && activeShares.length > 1) {
return null;
}
if (requested && activeShares.some((share) => share.id === requested)) {
return requested;
}
if (activeShares.length === 1) {
return activeShares[0].id;
}
return null;
});
readonly focusedShare = computed(
() => this.activeShares().find((share) => share.id === this.focusedShareId()) ?? null
);
readonly thumbnailShares = computed(() => {
const focusedShareId = this.focusedShareId();
if (!focusedShareId) {
return [] as VoiceWorkspaceStreamItem[];
}
return this.activeShares().filter((share) => share.id !== focusedShareId);
});
constructor() {
effect(() => {
const callId = this.callId();
if (callId) {
untracked(() => void this.calls.openCall(callId));
}
});
effect(() => {
const session = this.session();
if (session && !this.calls.hasOngoingActivity(session)) {
untracked(() => void this.router.navigate(['/dm', session.conversationId], { replaceUrl: true }));
}
});
effect(() => {
const session = this.session();
const currentUserId = this.currentUserKey();
const peerIds = (session ? this.remoteParticipantPeerIds(session, currentUserId) : []);
this.screenShare.syncRemoteScreenShareRequests(peerIds, this.isConnected() && !!session && session.status === 'connected');
});
effect(() => {
this.session();
if (this.isConnected()) {
this.trackLocalMic();
return;
}
this.untrackLocalMic();
});
this.screenShare.onRemoteStream
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.bumpRemoteStreamRevision());
this.screenShare.onPeerDisconnected
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.bumpRemoteStreamRevision());
this.destroyRef.onDestroy(() => {
this.screenShare.syncRemoteScreenShareRequests([], false);
});
}
@HostListener('window:mousemove', ['$event'])
onWindowMouseMove(event: MouseEvent): void {
if (!this.chatResizing) {
return;
}
event.preventDefault();
this.chatWidthPx.set(this.clampChatWidth(window.innerWidth - event.clientX));
}
@HostListener('window:mouseup')
onWindowMouseUp(): void {
this.chatResizing = false;
}
async join(): Promise<void> {
const session = this.session();
if (session) {
await this.calls.joinCall(session.callId);
}
}
leave(): void {
const session = this.session();
if (!session) {
return;
}
this.calls.leaveCall(session.callId);
this.untrackLocalMic();
void this.router.navigate(['/dm', session.conversationId]);
}
toggleMute(): void {
this.voice.toggleMute(!this.isMuted());
this.broadcastLocalVoiceState();
}
toggleDeafen(): void {
const nextDeafened = !this.isDeafened();
this.voice.toggleDeafen(nextDeafened);
this.playback.updateDeafened(nextDeafened);
if (nextDeafened && !this.isMuted()) {
this.voice.toggleMute(true);
}
this.broadcastLocalVoiceState();
}
async toggleCamera(): Promise<void> {
const user = this.currentUser();
if (!this.isConnected() || !user?.id) {
return;
}
if (this.isCameraEnabled()) {
this.voice.disableCamera();
this.store.dispatch(UsersActions.updateCameraState({ userId: user.id, cameraState: { isEnabled: false } }));
this.bumpRemoteStreamRevision();
return;
}
await this.voice.enableCamera();
this.store.dispatch(UsersActions.updateCameraState({ userId: user.id, cameraState: { isEnabled: true } }));
this.bumpRemoteStreamRevision();
}
async toggleScreenShare(): Promise<void> {
if (this.isScreenSharing()) {
this.screenShare.stopScreenShare();
this.bumpRemoteStreamRevision();
return;
}
this.syncScreenShareSettings();
if (this.askScreenShareQuality()) {
this.showScreenShareQualityDialog.set(true);
return;
}
await this.startScreenShareWithOptions(this.screenShareQuality());
}
onScreenShareQualityCancelled(): void {
this.showScreenShareQualityDialog.set(false);
}
async onScreenShareQualityConfirmed(quality: ScreenShareQuality): Promise<void> {
this.showScreenShareQualityDialog.set(false);
this.screenShareQuality.set(quality);
saveVoiceSettingsToStorage({ screenShareQuality: quality });
await this.startScreenShareWithOptions(quality);
}
inviteSelectedUser(): void {
const callId = this.callId();
const userId = this.inviteUserId();
const user = this.allUsers().find((candidate) => this.userKey(candidate) === userId);
if (!callId || !user) {
return;
}
void this.calls.inviteUser(callId, user);
this.inviteUserId.set('');
}
isSpeaking(user: User): boolean {
return this.voiceActivity.isSpeaking(this.userKey(user))();
}
isParticipantConnected(user: User): boolean {
const session = this.session();
const userId = this.userKey(user);
if (!session) {
return false;
}
return !!session.participants[userId]?.joined
|| !!(
user.voiceState?.isConnected
&& user.voiceState.roomId === session.callId
&& user.voiceState.serverId === session.callId
);
}
participantIssueLabel(user: User): string | null {
return this.isParticipantConnected(user) ? null : 'Waiting';
}
streamLabel(share: VoiceWorkspaceStreamItem): string {
if (!share.isLocal) {
return share.user.displayName;
}
return share.kind === 'camera' ? 'Your camera' : 'Your screen';
}
focusShare(shareId: string): void {
this.showAllStreamsMode.set(false);
this.focusedStreamId.set(shareId);
}
showAllStreams(): void {
this.showAllStreamsMode.set(true);
this.focusedStreamId.set(null);
}
startChatResize(event: MouseEvent): void {
if (event.button !== 0) {
return;
}
event.preventDefault();
this.chatResizing = true;
}
userKey(user: User): string {
return user.oderId || user.id;
}
readonly trackUserKey = (index: number, user: User): string => this.userKey(user);
private currentUserKey(): string {
const user = this.currentUser();
return user ? this.userKey(user) : '';
}
private broadcastLocalVoiceState(): void {
const session = this.session();
const user = this.currentUser();
if (!session || !user?.id) {
return;
}
this.store.dispatch(UsersActions.updateVoiceState({
userId: user.id,
voiceState: {
isConnected: this.isConnected(),
isMuted: this.isMuted(),
isDeafened: this.isDeafened(),
roomId: session.callId,
serverId: session.callId
}
}));
}
private remoteParticipantPeerIds(session: DirectCallSession, currentUserId: string): string[] {
const peerIds = new Set<string>();
for (const participantId of session.participantIds) {
if (participantId === currentUserId) {
continue;
}
const user = this.userForSessionParticipant(session, participantId);
for (const peerId of [participantId, ...this.getPeerKeyCandidates(user)]) {
if (peerId && peerId !== currentUserId) {
peerIds.add(peerId);
}
}
}
return Array.from(peerIds);
}
private clampChatWidth(width: number): number {
const maxWidth = Math.min(640, Math.max(360, window.innerWidth - 560));
return Math.round(Math.max(320, Math.min(maxWidth, width)));
}
private getPeerKeyCandidates(user: User | null | undefined): string[] {
if (!user) {
return [];
}
return [
user.oderId,
user.peerId,
user.id
].filter((peerId, index, peerIds): peerId is string => !!peerId && peerIds.indexOf(peerId) === index);
}
private userForSessionParticipant(session: DirectCallSession, participantId: string): User | null {
const knownUser = this.calls.userForParticipant(participantId);
if (knownUser) {
return knownUser;
}
const participant = session.participants[participantId]?.profile;
return participant ? participantToUser(participant) : null;
}
private trackLocalMic(): void {
const userId = this.currentUserKey();
const stream = this.voice.getRawMicStream() ?? this.voice.getLocalStream();
if (userId && stream) {
this.voiceActivity.trackLocalMic(userId, stream);
}
}
private untrackLocalMic(): void {
const userId = this.currentUserKey();
if (userId) {
this.voiceActivity.untrackLocalMic(userId);
}
}
private syncScreenShareSettings(): void {
const settings = loadVoiceSettingsFromStorage();
this.includeSystemAudio.set(settings.includeSystemAudio);
this.screenShareQuality.set(settings.screenShareQuality);
this.askScreenShareQuality.set(settings.askScreenShareQuality);
}
private async startScreenShareWithOptions(quality: ScreenShareQuality): Promise<void> {
const options: ScreenShareStartOptions = {
includeSystemAudio: this.includeSystemAudio(),
quality
};
try {
await this.screenShare.startScreenShare(options);
this.bumpRemoteStreamRevision();
} catch {}
}
private buildShare(
peerKey: string,
user: User,
stream: MediaStream,
isLocal: boolean,
kind: VoiceWorkspaceStreamItem['kind']
): VoiceWorkspaceStreamItem {
return {
id: `${kind}:${peerKey}`,
peerKey,
user,
stream,
isLocal,
kind,
hasAudio: stream.getAudioTracks().some((track) => track.readyState === 'live')
};
}
private hasActiveVideo(stream: MediaStream): boolean {
return stream.getVideoTracks().some((track) => track.readyState === 'live');
}
private bumpRemoteStreamRevision(): void {
this.remoteStreamRevision.update((value) => value + 1);
}
}

View File

@@ -47,6 +47,7 @@ import {
} from '../../../domains/voice-connection';
import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session';
import { DirectMessageService } from '../../../domains/direct-message';
import { DirectCallService } from '../../../domains/direct-call';
import { VoicePlaybackService } from '../../../domains/voice-connection';
import { formatGameActivityElapsed } from '../../../domains/game-activity';
import { ExternalLinkService } from '../../../core/platform/external-link.service';
@@ -122,6 +123,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
private voiceSessionService = inject(VoiceSessionFacade);
private voiceWorkspace = inject(VoiceWorkspaceService);
private voicePlayback = inject(VoicePlaybackService);
private directCalls = inject(DirectCallService);
private profileCard = inject(ProfileCardService);
private directMessages = inject(DirectMessageService);
private readonly externalLinks = inject(ExternalLinkService);
@@ -623,31 +625,12 @@ export class RoomsSidePanelComponent implements OnDestroy {
return !current || resolveRoomPermission(room, current, 'joinVoice', roomId);
}
private prepareCrossServerVoiceJoin(room: Room, current: User | null): boolean {
private prepareVoiceJoin(room: Room, current: User | null): void {
if (!current?.voiceState?.isConnected || current.voiceState.serverId === room.id) {
return true;
return;
}
if (this.voiceConnection.isVoiceConnected()) {
return false;
}
if (current.id) {
this.store.dispatch(
UsersActions.updateVoiceState({
userId: current.id,
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: undefined,
serverId: undefined
}
})
);
}
return true;
this.disconnectCurrentVoiceTarget(current);
}
private enableVoiceForJoin(room: Room, current: User | null, roomId: string): Promise<void> {
@@ -675,10 +658,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
return;
}
if (!this.prepareCrossServerVoiceJoin(room, current ?? null)) {
this.voiceConnection.reportConnectionError('Disconnect from the current voice server before joining a different server.');
return;
}
this.directCalls.leaveCurrentJoinedCall();
this.prepareVoiceJoin(room, current ?? null);
this.enableVoiceForJoin(room, current ?? null, roomId)
.then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
@@ -775,10 +756,14 @@ export class RoomsSidePanelComponent implements OnDestroy {
if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId))
return;
this.disconnectCurrentVoiceTarget(current);
}
private disconnectCurrentVoiceTarget(current: User | null): void {
const previousVoiceState = current?.voiceState;
this.voiceConnection.stopVoiceHeartbeat();
this.untrackCurrentUserMic();
this.voiceConnection.disableVoice();
if (current?.id) {
@@ -811,8 +796,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: undefined,
serverId: undefined
roomId: previousVoiceState?.roomId,
serverId: previousVoiceState?.serverId
}
});

View File

@@ -153,7 +153,7 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
}
canToggleFullscreen(): boolean {
return !this.mini() && !this.compact() && (this.immersive() || this.focused());
return !this.mini() && !this.compact();
}
onTilePointerMove(): void {

View File

@@ -17,6 +17,56 @@
<ng-container *ngComponentOutlet="dmRailComponent()" />
}
@for (call of directCalls.visibleActiveSessions(); track call.callId + ':' + $index) {
<div class="group/call relative flex w-full justify-center">
<span
aria-hidden="true"
class="pointer-events-none absolute left-0 top-1/2 w-[3px] -translate-y-1/2 rounded-r-full bg-emerald-500 transition-[height,opacity] duration-100"
[ngClass]="isSelectedCall($index) ? 'h-5 opacity-100' : 'h-0 opacity-0 group-hover/call:h-2.5 group-hover/call:opacity-100'"
></span>
<button
type="button"
class="relative z-10 grid h-10 w-10 place-items-center overflow-hidden rounded-xl transition-colors hover:rounded-lg"
[ngClass]="
callAvatarUrls(call).length > 0
? 'bg-emerald-950 text-white shadow-sm hover:bg-emerald-900'
: 'bg-emerald-500/15 text-emerald-600 hover:bg-emerald-500/25'
"
[attr.data-testid]="'server-rail-call-' + call.callId"
title="Open private call"
[attr.aria-current]="isSelectedCall($index) ? 'page' : null"
(click)="openCall(call.callId)"
>
@let callAvatars = callAvatarUrls(call);
@if (callAvatars.length > 0) {
<span
aria-hidden="true"
class="absolute inset-0 bg-emerald-950"
></span>
@for (avatarUrl of callAvatars; track avatarUrl) {
<span
aria-hidden="true"
class="absolute inset-0 bg-cover bg-center opacity-65 mix-blend-screen saturate-125"
[style.backgroundImage]="'url(' + avatarUrl + ')'"
></span>
}
<span
aria-hidden="true"
class="absolute inset-0 bg-gradient-to-br from-black/10 via-emerald-950/20 to-black/45"
></span>
}
<ng-icon
name="lucidePhone"
class="relative z-10 h-5 w-5 drop-shadow"
/>
</button>
</div>
}
<!-- Saved servers icons -->
<div
appThemeNode="serversRailList"

View File

@@ -14,7 +14,7 @@ import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { NavigationEnd, Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePlus } from '@ng-icons/lucide';
import { lucidePhone, lucidePlus } from '@ng-icons/lucide';
import {
EMPTY,
Subject,
@@ -35,6 +35,7 @@ import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users
import { RoomsActions } from '../../../store/rooms/rooms.actions';
import { DatabaseService } from '../../../infrastructure/persistence';
import { NotificationsFacade } from '../../../domains/notifications';
import { DirectCallService, DirectCallSession } from '../../../domains/direct-call';
import { type ServerInfo, ServerDirectoryFacade } from '../../../domains/server-directory';
import { ThemeNodeDirective } from '../../../domains/theme';
import { hasRoomBanForUser } from '../../../domains/access-control';
@@ -57,7 +58,7 @@ import {
ThemeNodeDirective,
UserBarComponent
],
viewProviders: [provideIcons({ lucidePlus })],
viewProviders: [provideIcons({ lucidePhone, lucidePlus })],
templateUrl: './servers-rail.component.html'
})
export class ServersRailComponent {
@@ -66,6 +67,7 @@ export class ServersRailComponent {
private voiceSession = inject(VoiceSessionFacade);
private db = inject(DatabaseService);
private notifications = inject(NotificationsFacade);
readonly directCalls = inject(DirectCallService);
private serverDirectory = inject(ServerDirectoryFacade);
private destroyRef = inject(DestroyRef);
private banLookupRequestVersion = 0;
@@ -92,10 +94,44 @@ export class ServersRailComponent {
isOnDirectMessage = toSignal(
this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/dm/'))
map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/dm/') || navigationEvent.urlAfterRedirects.startsWith('/pm/'))
),
{ initialValue: this.router.url.startsWith('/dm/') }
{ initialValue: this.router.url.startsWith('/dm/') || this.router.url.startsWith('/pm/') }
);
isOnCall = toSignal(
this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/call/'))
),
{ initialValue: this.router.url.startsWith('/call/') }
);
currentCallId = toSignal(
this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
map((navigationEvent) => this.callIdFromUrl(navigationEvent.urlAfterRedirects))
),
{ initialValue: this.callIdFromUrl(this.router.url) }
);
selectedCallIndex = computed(() => {
const routeCallId = this.currentCallId();
const visibleCalls = this.directCalls.visibleActiveSessions();
if (routeCallId) {
const routeMatchIndex = visibleCalls.findIndex((call) => call.callId === routeCallId || call.conversationId === routeCallId);
if (routeMatchIndex >= 0) {
return routeMatchIndex;
}
}
const currentSession = this.directCalls.currentSession();
if (!currentSession) {
return -1;
}
return visibleCalls.findIndex((call) => call.callId === currentSession.callId);
});
bannedServerName = signal('');
showBannedDialog = signal(false);
showPasswordDialog = signal(false);
@@ -203,6 +239,26 @@ export class ServersRailComponent {
this.savedRoomJoinRequests.next({ room });
}
openCall(callId: string): void {
void this.router.navigate(['/call', callId]);
}
isSelectedCall(callIndex: number): boolean {
return this.selectedCallIndex() === callIndex;
}
callAvatarUrls(call: DirectCallSession): string[] {
if (call.participantIds.length <= 2) {
return [];
}
return Object.values(call.participants)
.filter((participant) => participant.joined)
.map((participant) => this.directCalls.userForParticipant(participant.userId)?.avatarUrl || participant.profile.avatarUrl)
.filter((avatarUrl): avatarUrl is string => !!avatarUrl)
.slice(0, 3);
}
closeBannedDialog(): void {
this.showBannedDialog.set(false);
this.bannedServerName.set('');
@@ -229,6 +285,13 @@ export class ServersRailComponent {
return !!this.bannedRoomLookup()[room.id];
}
private callIdFromUrl(url: string): string | null {
const path = url.split(/[?#]/, 1)[0];
const match = path.match(/^\/call\/([^/]+)/);
return match?.[1] ? decodeURIComponent(match[1]) : null;
}
openContextMenu(evt: MouseEvent, room: Room): void {
evt.preventDefault();
this.contextRoom.set(room);
@@ -311,7 +374,7 @@ export class ServersRailComponent {
}
isSelectedRoom(room: Room): boolean {
if (this.isOnDirectMessage()) {
if (this.isOnDirectMessage() || this.isOnCall()) {
return false;
}

View File

@@ -103,6 +103,40 @@
</label>
</div>
</div>
<div class="rounded-lg border border-border bg-secondary/20 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-foreground">Experimental VLC.js playback</p>
@if (experimentalMedia.vlcJsRuntimeStatus() === 'checking') {
<p class="text-xs text-muted-foreground">Checking for a bundled VLC.js runtime...</p>
} @else if (experimentalMedia.vlcJsRuntimeAvailable()) {
<p class="text-xs text-muted-foreground">Offer a manual player for unsupported downloaded audio and video files.</p>
} @else {
<p class="text-xs text-muted-foreground">No VLC.js runtime is bundled. Unsupported desktop media can be opened in the system player.</p>
}
</div>
<label
class="relative inline-flex items-center"
[class.cursor-pointer]="experimentalMedia.vlcJsRuntimeAvailable()"
[class.cursor-not-allowed]="!experimentalMedia.vlcJsRuntimeAvailable()"
>
<input
type="checkbox"
[checked]="experimentalMedia.vlcJsPlaybackEnabled()"
[disabled]="!experimentalMedia.vlcJsRuntimeAvailable()"
(change)="onExperimentalVlcPlaybackChange($event)"
id="general-experimental-vlc-playback-toggle"
aria-label="Toggle experimental VLC.js playback"
class="sr-only peer"
/>
<div
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-disabled:bg-muted/80 peer-disabled:after:bg-muted-foreground/40 peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
></div>
</label>
</div>
</div>
</div>
</section>
</div>

View File

@@ -12,6 +12,7 @@ import type { DesktopSettingsSnapshot } from '../../../../core/platform/electron
import { loadGeneralSettingsFromStorage, saveGeneralSettingsToStorage } from '../../../../infrastructure/persistence';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { PlatformService } from '../../../../core/platform';
import { ExperimentalMediaSettingsService } from '../../../../domains/experimental-media/application/services/experimental-media-settings.service';
@Component({
selector: 'app-general-settings',
@@ -27,6 +28,7 @@ import { PlatformService } from '../../../../core/platform';
export class GeneralSettingsComponent {
private platform = inject(PlatformService);
private electronBridge = inject(ElectronBridgeService);
readonly experimentalMedia = inject(ExperimentalMediaSettingsService);
readonly isElectron = this.platform.isElectron;
reopenLastViewedChat = signal(true);
@@ -98,6 +100,13 @@ export class GeneralSettingsComponent {
}
}
onExperimentalVlcPlaybackChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.experimentalMedia.setVlcJsPlaybackEnabled(!!input.checked);
input.checked = this.experimentalMedia.vlcJsPlaybackEnabled();
}
private async loadDesktopSettings(): Promise<void> {
const api = this.electronBridge.getApi();

View File

@@ -18,6 +18,7 @@ export class RemoteScreenShareRequestController {
private remoteScreenShareRequestsEnabled = false;
private readonly desiredRemoteScreenSharePeers = new Set<string>();
private readonly activeRemoteScreenSharePeers = new Set<string>();
private readonly requestRetryTimers = new Map<string, ReturnType<typeof setTimeout>>();
constructor(
private readonly dependencies: RemoteScreenShareRequestControllerDependencies
@@ -31,6 +32,7 @@ export class RemoteScreenShareRequestController {
handlePeerDisconnected(peerId: string): void {
this.activeRemoteScreenSharePeers.delete(peerId);
this.clearRequestRetry(peerId);
this.dependencies.clearScreenShareRequest(peerId);
}
@@ -62,6 +64,7 @@ export class RemoteScreenShareRequestController {
if (!enabled) {
this.remoteScreenShareRequestsEnabled = false;
this.desiredRemoteScreenSharePeers.clear();
this.clearAllRequestRetries();
this.stopRemoteScreenShares([...this.activeRemoteScreenSharePeers]);
return;
}
@@ -83,18 +86,20 @@ export class RemoteScreenShareRequestController {
this.remoteScreenShareRequestsEnabled = false;
this.desiredRemoteScreenSharePeers.clear();
this.activeRemoteScreenSharePeers.clear();
this.clearAllRequestRetries();
}
private requestRemoteScreenShares(peerIds: string[]): void {
private requestRemoteScreenShares(peerIds: string[], retryAttempt = 0): void {
const connectedPeerIds = new Set(this.dependencies.getConnectedPeerIds());
for (const peerId of peerIds) {
if (!connectedPeerIds.has(peerId) || this.activeRemoteScreenSharePeers.has(peerId)) {
if (!connectedPeerIds.has(peerId)) {
continue;
}
this.dependencies.sendToPeer(peerId, { type: P2P_TYPE_SCREEN_SHARE_REQUEST });
this.activeRemoteScreenSharePeers.add(peerId);
this.scheduleRequestRetry(peerId, retryAttempt);
}
}
@@ -107,7 +112,48 @@ export class RemoteScreenShareRequestController {
}
this.activeRemoteScreenSharePeers.delete(peerId);
this.clearRequestRetry(peerId);
this.dependencies.clearRemoteScreenShareStream(peerId);
}
}
private scheduleRequestRetry(peerId: string, retryAttempt: number): void {
if (!this.remoteScreenShareRequestsEnabled || !this.desiredRemoteScreenSharePeers.has(peerId)) {
return;
}
const retryDelays = [
300,
1_000,
2_500
];
const delay = retryDelays[retryAttempt];
if (delay === undefined) {
return;
}
this.clearRequestRetry(peerId);
this.requestRetryTimers.set(peerId, setTimeout(() => {
this.requestRetryTimers.delete(peerId);
this.requestRemoteScreenShares([peerId], retryAttempt + 1);
}, delay));
}
private clearRequestRetry(peerId: string): void {
const timer = this.requestRetryTimers.get(peerId);
if (timer) {
clearTimeout(timer);
this.requestRetryTimers.delete(peerId);
}
}
private clearAllRequestRetries(): void {
for (const timer of this.requestRetryTimers.values()) {
clearTimeout(timer);
}
this.requestRetryTimers.clear();
}
}

View File

@@ -13,8 +13,12 @@ import type { ChatAttachmentAnnouncement, ChatAttachmentMeta } from './attachmen
import type {
DirectMessageEventPayload,
DirectMessageMutationEventPayload,
DirectMessageStatusEventPayload
DirectMessageSyncEventPayload,
DirectMessageSyncRequestEventPayload,
DirectMessageStatusEventPayload,
DirectMessageTypingEventPayload
} from './direct-message-contracts';
import type { DirectCallEventPayload } from './direct-call-contracts';
export interface ChatInventoryItem {
id: string;
@@ -87,6 +91,10 @@ export interface ChatEventBase {
directMessage?: DirectMessageEventPayload;
directMessageStatus?: DirectMessageStatusEventPayload;
directMessageMutation?: DirectMessageMutationEventPayload;
directMessageTyping?: DirectMessageTypingEventPayload;
directMessageSyncRequest?: DirectMessageSyncRequestEventPayload;
directMessageSync?: DirectMessageSyncEventPayload;
directCall?: DirectCallEventPayload;
pluginMessage?: unknown;
}
@@ -391,6 +399,26 @@ export interface DirectMessageMutationPeerEvent extends ChatEventBase {
directMessageMutation: DirectMessageMutationEventPayload;
}
export interface DirectMessageTypingPeerEvent extends ChatEventBase {
type: 'direct-message-typing';
directMessageTyping: DirectMessageTypingEventPayload;
}
export interface DirectMessageSyncRequestPeerEvent extends ChatEventBase {
type: 'direct-message-sync-request';
directMessageSyncRequest: DirectMessageSyncRequestEventPayload;
}
export interface DirectMessageSyncPeerEvent extends ChatEventBase {
type: 'direct-message-sync';
directMessageSync: DirectMessageSyncEventPayload;
}
export interface DirectCallPeerEvent extends ChatEventBase {
type: 'direct-call';
directCall: DirectCallEventPayload;
}
export interface PluginMessageBusPeerEvent extends ChatEventBase {
type: 'plugin-message-bus';
pluginMessage: unknown;
@@ -449,6 +477,10 @@ export type ChatEvent =
| DirectMessagePeerEvent
| DirectMessageStatusPeerEvent
| DirectMessageMutationPeerEvent
| DirectMessageTypingPeerEvent
| DirectMessageSyncRequestPeerEvent
| DirectMessageSyncPeerEvent
| DirectCallPeerEvent
| PluginMessageBusPeerEvent;
/** All possible `type` values, derived from the union. */

View File

@@ -0,0 +1,13 @@
import type { DirectMessageParticipant } from './direct-message-contracts';
export type DirectCallEventAction = 'ring' | 'join' | 'leave' | 'end' | 'update';
export interface DirectCallEventPayload {
action: DirectCallEventAction;
callId: string;
conversationId: string;
createdAt: number;
sender: DirectMessageParticipant;
participantIds: string[];
participants?: DirectMessageParticipant[];
}

View File

@@ -20,6 +20,7 @@ export interface DirectMessage {
conversationId: string;
senderId: string;
recipientId: string;
recipientIds?: string[];
content: string;
timestamp: number;
status: DirectMessageStatus;
@@ -32,6 +33,9 @@ export interface DirectMessage {
export interface DirectMessageEventPayload {
message: DirectMessage;
sender: DirectMessageParticipant;
participants?: DirectMessageParticipant[];
conversationKind?: 'direct' | 'group';
conversationTitle?: string;
}
export interface DirectMessageStatusEventPayload {
@@ -52,3 +56,26 @@ export interface DirectMessageMutationEventPayload {
emoji?: string;
updatedAt: number;
}
export interface DirectMessageTypingEventPayload {
conversationId: string;
sender: DirectMessageParticipant;
isTyping: boolean;
updatedAt: number;
}
export interface DirectMessageSyncRequestEventPayload {
conversationId: string;
sender: DirectMessageParticipant;
requestedAt: number;
}
export interface DirectMessageSyncEventPayload {
conversationId: string;
sender: DirectMessageParticipant;
participants: DirectMessageParticipant[];
conversationKind?: 'direct' | 'group';
conversationTitle?: string;
messages: DirectMessage[];
syncedAt: number;
}

View File

@@ -6,6 +6,7 @@ export * from './moderation.models';
export * from './voice-state.models';
export * from './game-activity.models';
export * from './direct-message-contracts';
export * from './direct-call-contracts';
export * from './chat-events';
export * from './media-preferences';
export * from './signaling-contracts';

Binary file not shown.

View File

@@ -10,7 +10,7 @@
/>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: http: https:; script-src-elem 'self' 'unsafe-inline' 'unsafe-eval' data: blob: http: https:; connect-src 'self' blob: ws: wss: http: https:; media-src 'self' blob:; img-src 'self' data: blob: http: https:; frame-src https://www.youtube-nocookie.com https://open.spotify.com https://w.soundcloud.com;"
content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: http: https:; script-src-elem 'self' 'unsafe-inline' 'unsafe-eval' data: blob: http: https:; connect-src 'self' blob: ws: wss: http: https:; media-src 'self' blob: file:; img-src 'self' data: blob: http: https:; frame-src https://www.youtube-nocookie.com https://open.spotify.com https://w.soundcloud.com;"
/>
<link
rel="icon"