Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1656b8a17f |
@@ -22,7 +22,7 @@ export class ServerSearchPage {
|
|||||||
readonly dialogCancelButton: Locator;
|
readonly dialogCancelButton: Locator;
|
||||||
|
|
||||||
constructor(private page: Page) {
|
constructor(private page: Page) {
|
||||||
this.searchInput = page.getByPlaceholder('Search servers and users...');
|
this.searchInput = page.getByPlaceholder('Search servers...');
|
||||||
this.railCreateServerButton = page.locator('button[title="Create Server"]');
|
this.railCreateServerButton = page.locator('button[title="Create Server"]');
|
||||||
this.searchCreateServerButton = page.getByRole('button', { name: 'Create New Server' });
|
this.searchCreateServerButton = page.getByRole('button', { name: 'Create New Server' });
|
||||||
this.createServerButton = this.searchCreateServerButton;
|
this.createServerButton = this.searchCreateServerButton;
|
||||||
@@ -80,11 +80,6 @@ export class ServerSearchPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async joinServerFromSearch(name: string) {
|
async joinServerFromSearch(name: string) {
|
||||||
await this.searchInput.fill(name);
|
await this.page.locator('button', { hasText: name }).click();
|
||||||
|
|
||||||
const serverCard = this.page.locator('div[title]', { hasText: name }).first();
|
|
||||||
|
|
||||||
await expect(serverCard).toBeVisible({ timeout: 15_000 });
|
|
||||||
await serverCard.dblclick();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,11 +44,9 @@ test.describe('Chat messaging features', () => {
|
|||||||
|
|
||||||
await test.step('Opening first server once restores only its channels', async () => {
|
await test.step('Opening first server once restores only its channels', async () => {
|
||||||
await openSavedRoomByName(scenario.client.page, alphaServerName);
|
await openSavedRoomByName(scenario.client.page, alphaServerName);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`)
|
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`)
|
||||||
).toBeVisible({ timeout: 20_000 });
|
).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`)
|
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`)
|
||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
@@ -56,11 +54,9 @@ test.describe('Chat messaging features', () => {
|
|||||||
|
|
||||||
await test.step('Opening second server once restores only its channels', async () => {
|
await test.step('Opening second server once restores only its channels', async () => {
|
||||||
await openSavedRoomByName(scenario.client.page, betaServerName);
|
await openSavedRoomByName(scenario.client.page, betaServerName);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`)
|
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`)
|
||||||
).toBeVisible({ timeout: 20_000 });
|
).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`)
|
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`)
|
||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
@@ -308,8 +304,11 @@ async function createChatScenario(createClient: () => Promise<Client>): Promise<
|
|||||||
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
|
||||||
const bobSearchPage = new ServerSearchPage(bob.page);
|
const bobSearchPage = new ServerSearchPage(bob.page);
|
||||||
|
const serverCard = bob.page.locator('button', { hasText: serverName }).first();
|
||||||
|
|
||||||
await bobSearchPage.joinServerFromSearch(serverName);
|
await bobSearchPage.searchInput.fill(serverName);
|
||||||
|
await expect(serverCard).toBeVisible({ timeout: 15_000 });
|
||||||
|
await serverCard.click();
|
||||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
|
||||||
const aliceRoom = new ChatRoomPage(alice.page);
|
const aliceRoom = new ChatRoomPage(alice.page);
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
import { type Page } from '@playwright/test';
|
|
||||||
import {
|
|
||||||
test,
|
|
||||||
expect,
|
|
||||||
type Client
|
|
||||||
} from '../../fixtures/multi-client';
|
|
||||||
import { RegisterPage } from '../../pages/register.page';
|
|
||||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
|
||||||
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
|
||||||
|
|
||||||
test.describe('Direct message flow', () => {
|
|
||||||
test.describe.configure({ timeout: 180_000 });
|
|
||||||
|
|
||||||
test('opens a DM from a user card and queues messages while offline', async ({ createClient }) => {
|
|
||||||
const scenario = await createDmScenario(createClient);
|
|
||||||
const offlineMessage = `Offline DM ${uniqueName('msg')}`;
|
|
||||||
|
|
||||||
await test.step('Alice opens Bob from the room user list', async () => {
|
|
||||||
const bobUserCard = scenario.alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first();
|
|
||||||
|
|
||||||
await expect(bobUserCard).toBeVisible({ timeout: 20_000 });
|
|
||||||
await bobUserCard.getByRole('button', { name: 'Message Bob' }).click();
|
|
||||||
await expect(scenario.alice.page).toHaveURL(/\/dm\//, { timeout: 15_000 });
|
|
||||||
await expect(scenario.alice.page.getByRole('heading', { name: 'Bob' })).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Offline send persists locally as queued', async () => {
|
|
||||||
await scenario.alice.page.evaluate(() => window.simulateOffline?.());
|
|
||||||
await scenario.alice.page.getByTestId('dm-input').fill(offlineMessage);
|
|
||||||
await scenario.alice.page.getByTestId('dm-input').press('Enter');
|
|
||||||
|
|
||||||
await expect(scenario.alice.page.locator('app-dm-chat').getByText(offlineMessage)).toBeVisible({ timeout: 10_000 });
|
|
||||||
await expect(scenario.alice.page.getByTestId('message-status').last()).toContainText('QUEUED');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows friend and message actions on the search people list', async ({ createClient }) => {
|
|
||||||
const scenario = await createDmScenario(createClient);
|
|
||||||
|
|
||||||
await scenario.alice.page.goto('/search', { waitUntil: 'domcontentloaded' });
|
|
||||||
await expect(scenario.alice.page).toHaveURL(/\/search/, { timeout: 20_000 });
|
|
||||||
await expect(scenario.alice.page.locator('app-server-search')).toBeVisible({ timeout: 20_000 });
|
|
||||||
await expect(scenario.alice.page.locator('app-user-search-list')).toBeVisible({ timeout: 20_000 });
|
|
||||||
const bobPeopleCard = scenario.alice.page
|
|
||||||
.locator('app-user-search-list [data-testid$="-' + scenario.bobUserId + '"]', { hasText: 'Bob' })
|
|
||||||
.first();
|
|
||||||
|
|
||||||
await expect(bobPeopleCard).toBeVisible({ timeout: 15_000 });
|
|
||||||
const friendButton = bobPeopleCard.locator(`[data-testid="friend-button-${scenario.bobUserId}"]`);
|
|
||||||
const messageButton = bobPeopleCard.getByRole('button', { name: 'Message Bob' });
|
|
||||||
|
|
||||||
await expect(friendButton).toBeAttached({ timeout: 15_000 });
|
|
||||||
await expect(messageButton).toBeAttached({ timeout: 15_000 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
interface DmScenario {
|
|
||||||
alice: Client;
|
|
||||||
bob: Client;
|
|
||||||
bobUserId: string;
|
|
||||||
aliceSearch: ServerSearchPage;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createDmScenario(createClient: () => Promise<Client>): Promise<DmScenario> {
|
|
||||||
const suffix = uniqueName('dm');
|
|
||||||
const serverName = `DM Server ${suffix}`;
|
|
||||||
const alice = await createClient();
|
|
||||||
const bob = await createClient();
|
|
||||||
|
|
||||||
await registerUser(alice.page, `alice_${suffix}`, 'Alice');
|
|
||||||
await registerUser(bob.page, `bob_${suffix}`, 'Bob');
|
|
||||||
|
|
||||||
const aliceSearch = new ServerSearchPage(alice.page);
|
|
||||||
|
|
||||||
await aliceSearch.createServer(serverName, { description: 'E2E direct message discovery server' });
|
|
||||||
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
|
||||||
await new ChatMessagesPage(alice.page).waitForReady();
|
|
||||||
|
|
||||||
const bobSearch = new ServerSearchPage(bob.page);
|
|
||||||
|
|
||||||
await bobSearch.joinServerFromSearch(serverName);
|
|
||||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
|
||||||
await new ChatMessagesPage(bob.page).waitForReady();
|
|
||||||
const bobRoomCard = alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first();
|
|
||||||
|
|
||||||
await expect(bobRoomCard).toBeVisible({ timeout: 20_000 });
|
|
||||||
|
|
||||||
const bobUserCardTestId = await bobRoomCard.getAttribute('data-testid');
|
|
||||||
const bobUserId = bobUserCardTestId?.replace('room-user-card-', '');
|
|
||||||
|
|
||||||
if (!bobUserId) {
|
|
||||||
throw new Error('Expected Bob room user card to expose a stable test id.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
alice,
|
|
||||||
bob,
|
|
||||||
bobUserId,
|
|
||||||
aliceSearch
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function registerUser(page: Page, username: string, displayName: string): Promise<void> {
|
|
||||||
const registerPage = new RegisterPage(page);
|
|
||||||
|
|
||||||
await registerPage.goto();
|
|
||||||
await registerPage.register(username, displayName, 'TestPass123!');
|
|
||||||
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniqueName(prefix: string): string {
|
|
||||||
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
|
||||||
.slice(2, 8)}`;
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,10 @@ import {
|
|||||||
type Locator,
|
type Locator,
|
||||||
type Page
|
type Page
|
||||||
} from '@playwright/test';
|
} from '@playwright/test';
|
||||||
import { test, type Client } from '../../fixtures/multi-client';
|
import {
|
||||||
|
test,
|
||||||
|
type Client
|
||||||
|
} from '../../fixtures/multi-client';
|
||||||
import { RegisterPage } from '../../pages/register.page';
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
@@ -106,12 +109,14 @@ async function createNotificationScenario(createClient: () => Promise<Client>):
|
|||||||
await aliceSearch.createServer(serverName, {
|
await aliceSearch.createServer(serverName, {
|
||||||
description: 'E2E notification coverage server'
|
description: 'E2E notification coverage server'
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
|
||||||
const bobSearch = new ServerSearchPage(bob.page);
|
const bobSearch = new ServerSearchPage(bob.page);
|
||||||
|
const serverCard = bob.page.locator('button', { hasText: serverName }).first();
|
||||||
|
|
||||||
await bobSearch.joinServerFromSearch(serverName);
|
await bobSearch.searchInput.fill(serverName);
|
||||||
|
await expect(serverCard).toBeVisible({ timeout: 15_000 });
|
||||||
|
await serverCard.click();
|
||||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
|
||||||
const aliceRoom = new ChatRoomPage(alice.page);
|
const aliceRoom = new ChatRoomPage(alice.page);
|
||||||
@@ -150,6 +155,10 @@ async function installDesktopNotificationSpy(page: Page): Promise<void> {
|
|||||||
class MockNotification {
|
class MockNotification {
|
||||||
static permission = 'granted';
|
static permission = 'granted';
|
||||||
|
|
||||||
|
static async requestPermission(): Promise<NotificationPermission> {
|
||||||
|
return 'granted';
|
||||||
|
}
|
||||||
|
|
||||||
onclick: (() => void) | null = null;
|
onclick: (() => void) | null = null;
|
||||||
|
|
||||||
constructor(title: string, options?: NotificationOptions) {
|
constructor(title: string, options?: NotificationOptions) {
|
||||||
@@ -159,10 +168,6 @@ async function installDesktopNotificationSpy(page: Page): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static async requestPermission(): Promise<NotificationPermission> {
|
|
||||||
return 'granted';
|
|
||||||
}
|
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -251,8 +256,7 @@ function getUnreadBadge(container: Locator): Locator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function uniqueName(prefix: string): string {
|
function uniqueName(prefix: string): string {
|
||||||
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
.slice(2, 8)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WindowWithDesktopNotifications extends Window {
|
interface WindowWithDesktopNotifications extends Window {
|
||||||
|
|||||||
@@ -384,8 +384,11 @@ async function registerUser(client: PersistentClient): Promise<void> {
|
|||||||
|
|
||||||
async function joinServerFromSearch(page: Page, serverName: string): Promise<void> {
|
async function joinServerFromSearch(page: Page, serverName: string): Promise<void> {
|
||||||
const searchPage = new ServerSearchPage(page);
|
const searchPage = new ServerSearchPage(page);
|
||||||
|
const serverCard = page.locator('button', { hasText: serverName }).first();
|
||||||
|
|
||||||
await searchPage.joinServerFromSearch(serverName);
|
await searchPage.searchInput.fill(serverName);
|
||||||
|
await expect(serverCard).toBeVisible({ timeout: 15_000 });
|
||||||
|
await serverCard.click();
|
||||||
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,12 @@ async function setupServerWithBothUsers(
|
|||||||
// Bob joins server
|
// Bob joins server
|
||||||
const bobSearch = new ServerSearchPage(bob.page);
|
const bobSearch = new ServerSearchPage(bob.page);
|
||||||
|
|
||||||
await bobSearch.joinServerFromSearch(SERVER_NAME);
|
await bobSearch.searchInput.fill(SERVER_NAME);
|
||||||
|
|
||||||
|
const serverCard = bob.page.locator('button', { hasText: SERVER_NAME }).first();
|
||||||
|
|
||||||
|
await expect(serverCard).toBeVisible({ timeout: 10_000 });
|
||||||
|
await serverCard.click();
|
||||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ test.describe('Connectivity warning', () => {
|
|||||||
|
|
||||||
await register.goto();
|
await register.goto();
|
||||||
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
|
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
|
||||||
await expect(alice.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
|
await expect(alice.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Register Bob', async () => {
|
await test.step('Register Bob', async () => {
|
||||||
@@ -96,7 +96,7 @@ test.describe('Connectivity warning', () => {
|
|||||||
|
|
||||||
await register.goto();
|
await register.goto();
|
||||||
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
|
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
|
||||||
await expect(bob.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
|
await expect(bob.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Register Charlie', async () => {
|
await test.step('Register Charlie', async () => {
|
||||||
@@ -104,7 +104,7 @@ test.describe('Connectivity warning', () => {
|
|||||||
|
|
||||||
await register.goto();
|
await register.goto();
|
||||||
await register.register(`charlie_${suffix}`, 'Charlie', 'TestPass123!');
|
await register.register(`charlie_${suffix}`, 'Charlie', 'TestPass123!');
|
||||||
await expect(charlie.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
|
await expect(charlie.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Create server and have everyone join ──
|
// ── Create server and have everyone join ──
|
||||||
@@ -117,14 +117,22 @@ test.describe('Connectivity warning', () => {
|
|||||||
await test.step('Bob joins the server', async () => {
|
await test.step('Bob joins the server', async () => {
|
||||||
const search = new ServerSearchPage(bob.page);
|
const search = new ServerSearchPage(bob.page);
|
||||||
|
|
||||||
await search.joinServerFromSearch(serverName);
|
await search.searchInput.fill(serverName);
|
||||||
|
const card = bob.page.locator('button', { hasText: serverName }).first();
|
||||||
|
|
||||||
|
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||||
|
await card.click();
|
||||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Charlie joins the server', async () => {
|
await test.step('Charlie joins the server', async () => {
|
||||||
const search = new ServerSearchPage(charlie.page);
|
const search = new ServerSearchPage(charlie.page);
|
||||||
|
|
||||||
await search.joinServerFromSearch(serverName);
|
await search.searchInput.fill(serverName);
|
||||||
|
const card = charlie.page.locator('button', { hasText: serverName }).first();
|
||||||
|
|
||||||
|
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||||
|
await card.click();
|
||||||
await expect(charlie.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(charlie.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,10 @@ test.describe('ICE server settings', () => {
|
|||||||
|
|
||||||
await register.goto();
|
await register.goto();
|
||||||
await register.register(`user_${suffix}`, 'IceTestUser', 'TestPass123!');
|
await register.register(`user_${suffix}`, 'IceTestUser', 'TestPass123!');
|
||||||
await expect(page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
||||||
await page.getByTitle('Settings').click();
|
await page.getByTitle('Settings').click();
|
||||||
await expect(page.getByRole('button', { name: 'Network' })).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 });
|
||||||
await page.getByRole('button', { name: 'Network' }).click();
|
await page.getByRole('button', { name: 'Network' }).click();
|
||||||
await expect(page.getByTestId('ice-server-settings')).toBeVisible({ timeout: 10_000 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test('allows adding, removing, and reordering ICE servers', async ({ createClient }) => {
|
test('allows adding, removing, and reordering ICE servers', async ({ createClient }) => {
|
||||||
@@ -102,7 +101,7 @@ test.describe('ICE server settings', () => {
|
|||||||
|
|
||||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||||
await page.getByTitle('Settings').click();
|
await page.getByTitle('Settings').click();
|
||||||
await expect(page.getByRole('button', { name: 'Network' })).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 });
|
||||||
await page.getByRole('button', { name: 'Network' }).click();
|
await page.getByRole('button', { name: 'Network' }).click();
|
||||||
await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ test.describe('STUN/TURN fallback behaviour', () => {
|
|||||||
|
|
||||||
await register.goto();
|
await register.goto();
|
||||||
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
|
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
|
||||||
await expect(alice.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
|
await expect(alice.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Register Bob', async () => {
|
await test.step('Register Bob', async () => {
|
||||||
@@ -97,7 +97,7 @@ test.describe('STUN/TURN fallback behaviour', () => {
|
|||||||
|
|
||||||
await register.goto();
|
await register.goto();
|
||||||
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
|
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
|
||||||
await expect(bob.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
|
await expect(bob.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Alice creates a server', async () => {
|
await test.step('Alice creates a server', async () => {
|
||||||
@@ -109,7 +109,11 @@ test.describe('STUN/TURN fallback behaviour', () => {
|
|||||||
await test.step('Bob joins Alice server', async () => {
|
await test.step('Bob joins Alice server', async () => {
|
||||||
const search = new ServerSearchPage(bob.page);
|
const search = new ServerSearchPage(bob.page);
|
||||||
|
|
||||||
await search.joinServerFromSearch(serverName);
|
await search.searchInput.fill(serverName);
|
||||||
|
const serverCard = bob.page.locator('button', { hasText: serverName }).first();
|
||||||
|
|
||||||
|
await expect(serverCard).toBeVisible({ timeout: 15_000 });
|
||||||
|
await serverCard.click();
|
||||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -556,7 +556,7 @@ async function installDeterministicVoiceSettings(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openSearchView(page: Page): Promise<void> {
|
async function openSearchView(page: Page): Promise<void> {
|
||||||
const searchInput = page.getByPlaceholder('Search servers and users...');
|
const searchInput = page.getByPlaceholder('Search servers...');
|
||||||
|
|
||||||
if (await searchInput.isVisible().catch(() => false)) {
|
if (await searchInput.isVisible().catch(() => false)) {
|
||||||
return;
|
return;
|
||||||
@@ -567,15 +567,15 @@ async function openSearchView(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
|
async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
|
||||||
const searchInput = page.getByPlaceholder('Search servers and users...');
|
const searchInput = page.getByPlaceholder('Search servers...');
|
||||||
|
|
||||||
await expect(searchInput).toBeVisible({ timeout: 20_000 });
|
await expect(searchInput).toBeVisible({ timeout: 20_000 });
|
||||||
await searchInput.fill(roomName);
|
await searchInput.fill(roomName);
|
||||||
|
|
||||||
const roomCard = page.locator('div[title]', { hasText: roomName }).first();
|
const roomCard = page.locator('button', { hasText: roomName }).first();
|
||||||
|
|
||||||
await expect(roomCard).toBeVisible({ timeout: 20_000 });
|
await expect(roomCard).toBeVisible({ timeout: 20_000 });
|
||||||
await roomCard.dblclick();
|
await roomCard.click();
|
||||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
|
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
|
||||||
await waitForCurrentRoomName(page, roomName);
|
await waitForCurrentRoomName(page, roomName);
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ async function installDeterministicVoiceSettings(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openSearchView(page: Page): Promise<void> {
|
async function openSearchView(page: Page): Promise<void> {
|
||||||
const searchInput = page.getByPlaceholder('Search servers and users...');
|
const searchInput = page.getByPlaceholder('Search servers...');
|
||||||
|
|
||||||
if (await searchInput.isVisible().catch(() => false)) {
|
if (await searchInput.isVisible().catch(() => false)) {
|
||||||
return;
|
return;
|
||||||
@@ -330,15 +330,15 @@ async function openSearchView(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
|
async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
|
||||||
const searchInput = page.getByPlaceholder('Search servers and users...');
|
const searchInput = page.getByPlaceholder('Search servers...');
|
||||||
|
|
||||||
await expect(searchInput).toBeVisible({ timeout: 20_000 });
|
await expect(searchInput).toBeVisible({ timeout: 20_000 });
|
||||||
await searchInput.fill(roomName);
|
await searchInput.fill(roomName);
|
||||||
|
|
||||||
const roomCard = page.locator('div[title]', { hasText: roomName }).first();
|
const roomCard = page.locator('button', { hasText: roomName }).first();
|
||||||
|
|
||||||
await expect(roomCard).toBeVisible({ timeout: 20_000 });
|
await expect(roomCard).toBeVisible({ timeout: 20_000 });
|
||||||
await roomCard.dblclick();
|
await roomCard.click();
|
||||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
|
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
|
||||||
await waitForCurrentRoomName(page, roomName);
|
await waitForCurrentRoomName(page, roomName);
|
||||||
|
|||||||
@@ -96,7 +96,14 @@ test.describe('Full user journey: register -> server -> voice chat', () => {
|
|||||||
await test.step('Bob finds and joins the server', async () => {
|
await test.step('Bob finds and joins the server', async () => {
|
||||||
const searchPage = new ServerSearchPage(bob.page);
|
const searchPage = new ServerSearchPage(bob.page);
|
||||||
|
|
||||||
await searchPage.joinServerFromSearch(SERVER_NAME);
|
// Search for the server
|
||||||
|
await searchPage.searchInput.fill(SERVER_NAME);
|
||||||
|
|
||||||
|
// Wait for search results and click the server
|
||||||
|
const serverCard = bob.page.locator('button', { hasText: SERVER_NAME }).first();
|
||||||
|
|
||||||
|
await expect(serverCard).toBeVisible({ timeout: 10_000 });
|
||||||
|
await serverCard.click();
|
||||||
|
|
||||||
// Bob should be in the room now
|
// Bob should be in the room now
|
||||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ Electron main-process package for MetoYou / Toju. This directory owns desktop bo
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `main.ts` | Electron app bootstrap and process entry point |
|
| `main.ts` | Electron app bootstrap and process entry point |
|
||||||
| `preload.ts` | Typed renderer-facing preload bridge |
|
| `preload.ts` | Typed renderer-facing preload bridge |
|
||||||
| `process-list.ts` | Linux/Windows process-name scan used by now-playing game detection |
|
|
||||||
| `app/` | App lifecycle and startup composition |
|
| `app/` | App lifecycle and startup composition |
|
||||||
| `ipc/` | Renderer-invoked IPC handlers |
|
| `ipc/` | Renderer-invoked IPC handlers |
|
||||||
| `cqrs/` | Local database command/query handlers and mappings |
|
| `cqrs/` | Local database command/query handlers and mappings |
|
||||||
@@ -29,4 +28,4 @@ Electron main-process package for MetoYou / Toju. This directory owns desktop bo
|
|||||||
|
|
||||||
- When adding a renderer-facing capability, update the Electron implementation, `preload.ts`, and the renderer bridge in `toju-app/` together.
|
- When adding a renderer-facing capability, update the Electron implementation, `preload.ts`, and the renderer bridge in `toju-app/` together.
|
||||||
- Treat `dist/electron/` and `dist-electron/` as generated output.
|
- Treat `dist/electron/` and `dist-electron/` as generated output.
|
||||||
- See [AGENTS.md](AGENTS.md) for package-level editing rules.
|
- See [AGENTS.md](AGENTS.md) for package-level editing rules.
|
||||||
@@ -5,7 +5,6 @@ import { createWindow, getMainWindow } from '../window/create-window';
|
|||||||
const CUSTOM_PROTOCOL = 'toju';
|
const CUSTOM_PROTOCOL = 'toju';
|
||||||
const DEEP_LINK_PREFIX = `${CUSTOM_PROTOCOL}://`;
|
const DEEP_LINK_PREFIX = `${CUSTOM_PROTOCOL}://`;
|
||||||
const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE';
|
const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE';
|
||||||
const DEV_RELOAD_EXISTING_ARG = '--metoyou-dev-reload-existing';
|
|
||||||
|
|
||||||
let pendingDeepLink: string | null = null;
|
let pendingDeepLink: string | null = null;
|
||||||
|
|
||||||
@@ -96,12 +95,6 @@ export function initializeDeepLinkHandling(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.on('second-instance', (_event, argv) => {
|
app.on('second-instance', (_event, argv) => {
|
||||||
if (resolveDevSingleInstanceExitCode() != null && argv.includes(DEV_RELOAD_EXISTING_ARG)) {
|
|
||||||
app.relaunch();
|
|
||||||
app.exit(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
focusMainWindow();
|
focusMainWindow();
|
||||||
|
|
||||||
const deepLink = extractDeepLink(argv);
|
const deepLink = extractDeepLink(argv);
|
||||||
|
|||||||
@@ -1,229 +0,0 @@
|
|||||||
import * as fsp from 'fs/promises';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
export interface ZipArchiveEntry {
|
|
||||||
data: Buffer;
|
|
||||||
path: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CentralDirectoryEntry {
|
|
||||||
compressedSize: number;
|
|
||||||
crc: number;
|
|
||||||
data: Buffer;
|
|
||||||
localHeaderOffset: number;
|
|
||||||
name: Buffer;
|
|
||||||
uncompressedSize: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
|
|
||||||
const ZIP_CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50;
|
|
||||||
const ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
|
|
||||||
const ZIP_UTF8_FLAG = 0x0800;
|
|
||||||
const ZIP_STORE_METHOD = 0;
|
|
||||||
const ZIP_VERSION = 20;
|
|
||||||
const MAX_UINT32 = 0xffffffff;
|
|
||||||
|
|
||||||
const crcTable = buildCrcTable();
|
|
||||||
|
|
||||||
export function createZipArchive(entries: ZipArchiveEntry[]): Buffer {
|
|
||||||
const localParts: Buffer[] = [];
|
|
||||||
const centralEntries: CentralDirectoryEntry[] = [];
|
|
||||||
let offset = 0;
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const normalizedPath = normalizeZipPath(entry.path);
|
|
||||||
const name = Buffer.from(normalizedPath, 'utf8');
|
|
||||||
const data = entry.data;
|
|
||||||
|
|
||||||
if (name.length > 0xffff || data.length > MAX_UINT32 || offset > MAX_UINT32) {
|
|
||||||
throw new Error('Data archive is too large for the portable ZIP format.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const crc = crc32(data);
|
|
||||||
const localHeader = Buffer.alloc(30);
|
|
||||||
|
|
||||||
localHeader.writeUInt32LE(ZIP_LOCAL_FILE_HEADER_SIGNATURE, 0);
|
|
||||||
localHeader.writeUInt16LE(ZIP_VERSION, 4);
|
|
||||||
localHeader.writeUInt16LE(ZIP_UTF8_FLAG, 6);
|
|
||||||
localHeader.writeUInt16LE(ZIP_STORE_METHOD, 8);
|
|
||||||
localHeader.writeUInt16LE(0, 10);
|
|
||||||
localHeader.writeUInt16LE(0, 12);
|
|
||||||
localHeader.writeUInt32LE(crc, 14);
|
|
||||||
localHeader.writeUInt32LE(data.length, 18);
|
|
||||||
localHeader.writeUInt32LE(data.length, 22);
|
|
||||||
localHeader.writeUInt16LE(name.length, 26);
|
|
||||||
localHeader.writeUInt16LE(0, 28);
|
|
||||||
|
|
||||||
localParts.push(localHeader, name, data);
|
|
||||||
centralEntries.push({
|
|
||||||
compressedSize: data.length,
|
|
||||||
crc,
|
|
||||||
data,
|
|
||||||
localHeaderOffset: offset,
|
|
||||||
name,
|
|
||||||
uncompressedSize: data.length
|
|
||||||
});
|
|
||||||
|
|
||||||
offset += localHeader.length + name.length + data.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
const centralDirectoryOffset = offset;
|
|
||||||
const centralParts = centralEntries.map((entry) => {
|
|
||||||
const header = Buffer.alloc(46);
|
|
||||||
|
|
||||||
header.writeUInt32LE(ZIP_CENTRAL_DIRECTORY_SIGNATURE, 0);
|
|
||||||
header.writeUInt16LE(ZIP_VERSION, 4);
|
|
||||||
header.writeUInt16LE(ZIP_VERSION, 6);
|
|
||||||
header.writeUInt16LE(ZIP_UTF8_FLAG, 8);
|
|
||||||
header.writeUInt16LE(ZIP_STORE_METHOD, 10);
|
|
||||||
header.writeUInt16LE(0, 12);
|
|
||||||
header.writeUInt16LE(0, 14);
|
|
||||||
header.writeUInt32LE(entry.crc, 16);
|
|
||||||
header.writeUInt32LE(entry.compressedSize, 20);
|
|
||||||
header.writeUInt32LE(entry.uncompressedSize, 24);
|
|
||||||
header.writeUInt16LE(entry.name.length, 28);
|
|
||||||
header.writeUInt16LE(0, 30);
|
|
||||||
header.writeUInt16LE(0, 32);
|
|
||||||
header.writeUInt16LE(0, 34);
|
|
||||||
header.writeUInt16LE(0, 36);
|
|
||||||
header.writeUInt32LE(0, 38);
|
|
||||||
header.writeUInt32LE(entry.localHeaderOffset, 42);
|
|
||||||
|
|
||||||
offset += header.length + entry.name.length;
|
|
||||||
|
|
||||||
return Buffer.concat([header, entry.name]);
|
|
||||||
});
|
|
||||||
|
|
||||||
const centralDirectorySize = offset - centralDirectoryOffset;
|
|
||||||
|
|
||||||
if (centralEntries.length > 0xffff || centralDirectoryOffset > MAX_UINT32 || centralDirectorySize > MAX_UINT32) {
|
|
||||||
throw new Error('Data archive is too large for the portable ZIP format.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const end = Buffer.alloc(22);
|
|
||||||
|
|
||||||
end.writeUInt32LE(ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE, 0);
|
|
||||||
end.writeUInt16LE(0, 4);
|
|
||||||
end.writeUInt16LE(0, 6);
|
|
||||||
end.writeUInt16LE(centralEntries.length, 8);
|
|
||||||
end.writeUInt16LE(centralEntries.length, 10);
|
|
||||||
end.writeUInt32LE(centralDirectorySize, 12);
|
|
||||||
end.writeUInt32LE(centralDirectoryOffset, 16);
|
|
||||||
end.writeUInt16LE(0, 20);
|
|
||||||
|
|
||||||
return Buffer.concat([...localParts, ...centralParts, end]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readZipArchive(data: Buffer): ZipArchiveEntry[] {
|
|
||||||
const endOffset = findEndOfCentralDirectory(data);
|
|
||||||
|
|
||||||
if (endOffset < 0) {
|
|
||||||
throw new Error('The selected file is not a supported data archive.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const entryCount = data.readUInt16LE(endOffset + 10);
|
|
||||||
const centralDirectoryOffset = data.readUInt32LE(endOffset + 16);
|
|
||||||
const entries: ZipArchiveEntry[] = [];
|
|
||||||
let offset = centralDirectoryOffset;
|
|
||||||
|
|
||||||
for (let index = 0; index < entryCount; index += 1) {
|
|
||||||
if (data.readUInt32LE(offset) !== ZIP_CENTRAL_DIRECTORY_SIGNATURE) {
|
|
||||||
throw new Error('The data archive directory is invalid.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const method = data.readUInt16LE(offset + 10);
|
|
||||||
const compressedSize = data.readUInt32LE(offset + 20);
|
|
||||||
const uncompressedSize = data.readUInt32LE(offset + 24);
|
|
||||||
const nameLength = data.readUInt16LE(offset + 28);
|
|
||||||
const extraLength = data.readUInt16LE(offset + 30);
|
|
||||||
const commentLength = data.readUInt16LE(offset + 32);
|
|
||||||
const localHeaderOffset = data.readUInt32LE(offset + 42);
|
|
||||||
const entryPath = normalizeZipPath(data.subarray(offset + 46, offset + 46 + nameLength).toString('utf8'));
|
|
||||||
|
|
||||||
if (method !== ZIP_STORE_METHOD || compressedSize !== uncompressedSize) {
|
|
||||||
throw new Error('Compressed data archives are not supported by this build.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.readUInt32LE(localHeaderOffset) !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) {
|
|
||||||
throw new Error('The data archive contains an invalid file entry.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const localNameLength = data.readUInt16LE(localHeaderOffset + 26);
|
|
||||||
const localExtraLength = data.readUInt16LE(localHeaderOffset + 28);
|
|
||||||
const dataOffset = localHeaderOffset + 30 + localNameLength + localExtraLength;
|
|
||||||
|
|
||||||
entries.push({
|
|
||||||
data: Buffer.from(data.subarray(dataOffset, dataOffset + compressedSize)),
|
|
||||||
path: entryPath
|
|
||||||
});
|
|
||||||
|
|
||||||
offset += 46 + nameLength + extraLength + commentLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function extractZipEntries(entries: ZipArchiveEntry[], destinationPath: string): Promise<void> {
|
|
||||||
const destinationRoot = path.resolve(destinationPath);
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const targetPath = path.resolve(destinationRoot, entry.path);
|
|
||||||
|
|
||||||
if (!targetPath.startsWith(destinationRoot + path.sep) && targetPath !== destinationRoot) {
|
|
||||||
throw new Error('The data archive contains an unsafe path.');
|
|
||||||
}
|
|
||||||
|
|
||||||
await fsp.mkdir(path.dirname(targetPath), { recursive: true });
|
|
||||||
await fsp.writeFile(targetPath, entry.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function findEndOfCentralDirectory(data: Buffer): number {
|
|
||||||
const minimumOffset = Math.max(0, data.length - 0xffff - 22);
|
|
||||||
|
|
||||||
for (let offset = data.length - 22; offset >= minimumOffset; offset -= 1) {
|
|
||||||
if (data.readUInt32LE(offset) === ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE) {
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeZipPath(value: string): string {
|
|
||||||
const normalized = value.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
||||||
|
|
||||||
if (!normalized || normalized.split('/').some((part) => part === '..' || part === '')) {
|
|
||||||
throw new Error('The data archive contains an unsafe path.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCrcTable(): number[] {
|
|
||||||
const table: number[] = [];
|
|
||||||
|
|
||||||
for (let index = 0; index < 256; index += 1) {
|
|
||||||
let value = index;
|
|
||||||
|
|
||||||
for (let bit = 0; bit < 8; bit += 1) {
|
|
||||||
value = (value & 1) !== 0
|
|
||||||
? 0xedb88320 ^ (value >>> 1)
|
|
||||||
: value >>> 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
table[index] = value >>> 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return table;
|
|
||||||
}
|
|
||||||
|
|
||||||
function crc32(data: Buffer): number {
|
|
||||||
let crc = 0xffffffff;
|
|
||||||
|
|
||||||
for (const byte of data) {
|
|
||||||
crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (crc ^ 0xffffffff) >>> 0;
|
|
||||||
}
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
import {
|
|
||||||
app,
|
|
||||||
dialog,
|
|
||||||
shell
|
|
||||||
} from 'electron';
|
|
||||||
import * as fsp from 'fs/promises';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { destroyDatabase, initializeDatabase } from './db/database';
|
|
||||||
import {
|
|
||||||
createZipArchive,
|
|
||||||
extractZipEntries,
|
|
||||||
readZipArchive,
|
|
||||||
type ZipArchiveEntry
|
|
||||||
} from './data-archive';
|
|
||||||
|
|
||||||
export interface ExportUserDataResult {
|
|
||||||
cancelled: boolean;
|
|
||||||
exported: boolean;
|
|
||||||
filePath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImportUserDataResult {
|
|
||||||
backupPath?: string;
|
|
||||||
cancelled: boolean;
|
|
||||||
imported: boolean;
|
|
||||||
restartRequired: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EraseUserDataResult {
|
|
||||||
erased: boolean;
|
|
||||||
restartRequired: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ARCHIVE_MANIFEST_PATH = 'metoyou-data-manifest.json';
|
|
||||||
const ARCHIVE_DATA_PREFIX = 'data/';
|
|
||||||
const BACKUP_DIRECTORY_NAME = 'metoyou-data-backups';
|
|
||||||
|
|
||||||
export async function openCurrentDataFolder(): Promise<boolean> {
|
|
||||||
const error = await shell.openPath(app.getPath('userData'));
|
|
||||||
|
|
||||||
return error.length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function exportUserData(): Promise<ExportUserDataResult> {
|
|
||||||
const dataPath = app.getPath('userData');
|
|
||||||
const defaultFileName = `metoyou-data-${new Date().toISOString().slice(0, 10)}.dat`;
|
|
||||||
const { canceled, filePath } = await dialog.showSaveDialog({
|
|
||||||
defaultPath: path.join(app.getPath('documents'), defaultFileName),
|
|
||||||
filters: [
|
|
||||||
{ extensions: ['dat'], name: 'MetoYou data archive' }
|
|
||||||
],
|
|
||||||
title: 'Export MetoYou data'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (canceled || !filePath) {
|
|
||||||
return { cancelled: true, exported: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries: ZipArchiveEntry[] = [
|
|
||||||
{
|
|
||||||
data: Buffer.from(JSON.stringify({
|
|
||||||
appVersion: app.getVersion(),
|
|
||||||
exportedAt: new Date().toISOString(),
|
|
||||||
format: 'metoyou-user-data',
|
|
||||||
version: 1
|
|
||||||
}, null, 2)),
|
|
||||||
path: ARCHIVE_MANIFEST_PATH
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const file of await collectDataFiles(dataPath)) {
|
|
||||||
const relativePath = toArchivePath(path.relative(dataPath, file));
|
|
||||||
|
|
||||||
entries.push({
|
|
||||||
data: await fsp.readFile(file),
|
|
||||||
path: `${ARCHIVE_DATA_PREFIX}${relativePath}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await fsp.writeFile(ensureDatExtension(filePath), createZipArchive(entries));
|
|
||||||
|
|
||||||
return {
|
|
||||||
cancelled: false,
|
|
||||||
exported: true,
|
|
||||||
filePath: ensureDatExtension(filePath)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function importUserData(): Promise<ImportUserDataResult> {
|
|
||||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
|
||||||
filters: [
|
|
||||||
{ extensions: ['dat', 'zip'], name: 'MetoYou data archive' }
|
|
||||||
],
|
|
||||||
properties: ['openFile'],
|
|
||||||
title: 'Import MetoYou data'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (canceled || filePaths.length === 0) {
|
|
||||||
return {
|
|
||||||
cancelled: true,
|
|
||||||
imported: false,
|
|
||||||
restartRequired: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const archiveEntries = readZipArchive(await fsp.readFile(filePaths[0]));
|
|
||||||
|
|
||||||
validateArchiveManifest(archiveEntries);
|
|
||||||
|
|
||||||
const importRoot = path.join(app.getPath('temp'), `metoyou-import-${Date.now()}`);
|
|
||||||
const importDataPath = path.join(importRoot, 'data');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await extractZipEntries(
|
|
||||||
archiveEntries
|
|
||||||
.filter((entry) => entry.path.startsWith(ARCHIVE_DATA_PREFIX))
|
|
||||||
.map((entry) => ({
|
|
||||||
data: entry.data,
|
|
||||||
path: entry.path.slice(ARCHIVE_DATA_PREFIX.length)
|
|
||||||
})),
|
|
||||||
importDataPath
|
|
||||||
);
|
|
||||||
|
|
||||||
await destroyDatabase();
|
|
||||||
|
|
||||||
const backupPath = await moveCurrentDataAside();
|
|
||||||
|
|
||||||
await copyDirectory(importDataPath, app.getPath('userData'));
|
|
||||||
await initializeDatabase();
|
|
||||||
|
|
||||||
return {
|
|
||||||
backupPath,
|
|
||||||
cancelled: false,
|
|
||||||
imported: true,
|
|
||||||
restartRequired: true
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
await initializeDatabase().catch(() => {});
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
await fsp.rm(importRoot, { force: true, recursive: true }).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function eraseUserData(): Promise<EraseUserDataResult> {
|
|
||||||
const dataPath = app.getPath('userData');
|
|
||||||
|
|
||||||
await destroyDatabase();
|
|
||||||
|
|
||||||
for (const entry of await fsp.readdir(dataPath, { withFileTypes: true }).catch(() => [])) {
|
|
||||||
await fsp.rm(path.join(dataPath, entry.name), { force: true, recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
await fsp.mkdir(dataPath, { recursive: true });
|
|
||||||
await initializeDatabase();
|
|
||||||
|
|
||||||
return {
|
|
||||||
erased: true,
|
|
||||||
restartRequired: true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function collectDataFiles(directoryPath: string): Promise<string[]> {
|
|
||||||
const files: string[] = [];
|
|
||||||
const entries = await fsp.readdir(directoryPath, { withFileTypes: true }).catch(() => []);
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.name === BACKUP_DIRECTORY_NAME) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entryPath = path.join(directoryPath, entry.name);
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
files.push(...await collectDataFiles(entryPath));
|
|
||||||
} else if (entry.isFile()) {
|
|
||||||
files.push(entryPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function moveCurrentDataAside(): Promise<string | undefined> {
|
|
||||||
const dataPath = app.getPath('userData');
|
|
||||||
const backupRoot = path.join(dataPath, BACKUP_DIRECTORY_NAME);
|
|
||||||
const backupPath = path.join(backupRoot, `before-import-${new Date().toISOString().replace(/[:.]/g, '-')}`);
|
|
||||||
const entries = await fsp.readdir(dataPath, { withFileTypes: true }).catch(() => []);
|
|
||||||
|
|
||||||
await fsp.mkdir(backupPath, { recursive: true });
|
|
||||||
|
|
||||||
let movedAny = false;
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.name === BACKUP_DIRECTORY_NAME) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourcePath = path.join(dataPath, entry.name);
|
|
||||||
const targetPath = path.join(backupPath, entry.name);
|
|
||||||
|
|
||||||
await fsp.mkdir(path.dirname(targetPath), { recursive: true });
|
|
||||||
await fsp.rename(sourcePath, targetPath).catch(async () => {
|
|
||||||
await copyPath(sourcePath, targetPath);
|
|
||||||
await fsp.rm(sourcePath, { force: true, recursive: true });
|
|
||||||
});
|
|
||||||
movedAny = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return movedAny ? backupPath : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyDirectory(sourcePath: string, targetPath: string): Promise<void> {
|
|
||||||
await fsp.mkdir(targetPath, { recursive: true });
|
|
||||||
|
|
||||||
for (const entry of await fsp.readdir(sourcePath, { withFileTypes: true }).catch(() => [])) {
|
|
||||||
await copyPath(path.join(sourcePath, entry.name), path.join(targetPath, entry.name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyPath(sourcePath: string, targetPath: string): Promise<void> {
|
|
||||||
const stats = await fsp.stat(sourcePath);
|
|
||||||
|
|
||||||
if (stats.isDirectory()) {
|
|
||||||
await copyDirectory(sourcePath, targetPath);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stats.isFile()) {
|
|
||||||
await fsp.mkdir(path.dirname(targetPath), { recursive: true });
|
|
||||||
await fsp.copyFile(sourcePath, targetPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateArchiveManifest(entries: ZipArchiveEntry[]): void {
|
|
||||||
const manifest = entries.find((entry) => entry.path === ARCHIVE_MANIFEST_PATH);
|
|
||||||
|
|
||||||
if (!manifest) {
|
|
||||||
throw new Error('The selected file is missing a MetoYou data manifest.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = JSON.parse(manifest.data.toString('utf8')) as { format?: string; version?: number };
|
|
||||||
|
|
||||||
if (parsed.format !== 'metoyou-user-data' || parsed.version !== 1) {
|
|
||||||
throw new Error('The selected file uses an unsupported data archive format.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureDatExtension(filePath: string): string {
|
|
||||||
return path.extname(filePath).toLowerCase() === '.dat'
|
|
||||||
? filePath
|
|
||||||
: `${filePath}.dat`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toArchivePath(filePath: string): string {
|
|
||||||
return filePath.split(path.sep).join('/');
|
|
||||||
}
|
|
||||||
@@ -49,13 +49,6 @@ import {
|
|||||||
readSavedTheme,
|
readSavedTheme,
|
||||||
writeSavedTheme
|
writeSavedTheme
|
||||||
} from '../theme-library';
|
} from '../theme-library';
|
||||||
import {
|
|
||||||
eraseUserData,
|
|
||||||
exportUserData,
|
|
||||||
importUserData,
|
|
||||||
openCurrentDataFolder
|
|
||||||
} from '../data-management';
|
|
||||||
import { listRunningProcessNames } from '../process-list';
|
|
||||||
|
|
||||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||||
const FILE_CLIPBOARD_FORMATS = [
|
const FILE_CLIPBOARD_FORMATS = [
|
||||||
@@ -321,8 +314,6 @@ export function setupSystemHandlers(): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-running-process-names', async () => await listRunningProcessNames());
|
|
||||||
|
|
||||||
ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => {
|
ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => {
|
||||||
return await prepareLinuxScreenShareAudioRouting();
|
return await prepareLinuxScreenShareAudioRouting();
|
||||||
});
|
});
|
||||||
@@ -344,10 +335,6 @@ export function setupSystemHandlers(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-app-data-path', () => app.getPath('userData'));
|
ipcMain.handle('get-app-data-path', () => app.getPath('userData'));
|
||||||
ipcMain.handle('open-current-data-folder', async () => await openCurrentDataFolder());
|
|
||||||
ipcMain.handle('export-user-data', async () => await exportUserData());
|
|
||||||
ipcMain.handle('import-user-data', async () => await importUserData());
|
|
||||||
ipcMain.handle('erase-user-data', async () => await eraseUserData());
|
|
||||||
ipcMain.handle('get-saved-themes-path', async () => await getSavedThemesPath());
|
ipcMain.handle('get-saved-themes-path', async () => await getSavedThemesPath());
|
||||||
ipcMain.handle('list-saved-themes', async () => await listSavedThemes());
|
ipcMain.handle('list-saved-themes', async () => await listSavedThemes());
|
||||||
ipcMain.handle('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName));
|
ipcMain.handle('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName));
|
||||||
|
|||||||
@@ -109,24 +109,6 @@ export interface SavedThemeFileDescriptor {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExportUserDataResult {
|
|
||||||
cancelled: boolean;
|
|
||||||
exported: boolean;
|
|
||||||
filePath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImportUserDataResult {
|
|
||||||
backupPath?: string;
|
|
||||||
cancelled: boolean;
|
|
||||||
imported: boolean;
|
|
||||||
restartRequired: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EraseUserDataResult {
|
|
||||||
erased: boolean;
|
|
||||||
restartRequired: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readLinuxDisplayServer(): string {
|
function readLinuxDisplayServer(): string {
|
||||||
if (process.platform !== 'linux') {
|
if (process.platform !== 'linux') {
|
||||||
return 'N/A';
|
return 'N/A';
|
||||||
@@ -167,7 +149,6 @@ export interface ElectronAPI {
|
|||||||
|
|
||||||
openExternal: (url: string) => Promise<boolean>;
|
openExternal: (url: string) => Promise<boolean>;
|
||||||
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
|
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
|
||||||
getRunningProcessNames: () => Promise<string[]>;
|
|
||||||
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||||
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||||
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
|
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
|
||||||
@@ -176,10 +157,6 @@ export interface ElectronAPI {
|
|||||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||||
getAppDataPath: () => Promise<string>;
|
getAppDataPath: () => Promise<string>;
|
||||||
openCurrentDataFolder: () => Promise<boolean>;
|
|
||||||
exportUserData: () => Promise<ExportUserDataResult>;
|
|
||||||
importUserData: () => Promise<ImportUserDataResult>;
|
|
||||||
eraseUserData: () => Promise<EraseUserDataResult>;
|
|
||||||
getSavedThemesPath: () => Promise<string>;
|
getSavedThemesPath: () => Promise<string>;
|
||||||
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
||||||
readSavedTheme: (fileName: string) => Promise<string>;
|
readSavedTheme: (fileName: string) => Promise<string>;
|
||||||
@@ -253,7 +230,6 @@ const electronAPI: ElectronAPI = {
|
|||||||
|
|
||||||
openExternal: (url) => ipcRenderer.invoke('open-external', url),
|
openExternal: (url) => ipcRenderer.invoke('open-external', url),
|
||||||
getSources: () => ipcRenderer.invoke('get-sources'),
|
getSources: () => ipcRenderer.invoke('get-sources'),
|
||||||
getRunningProcessNames: () => ipcRenderer.invoke('get-running-process-names'),
|
|
||||||
prepareLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('prepare-linux-screen-share-audio-routing'),
|
prepareLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('prepare-linux-screen-share-audio-routing'),
|
||||||
activateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('activate-linux-screen-share-audio-routing'),
|
activateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('activate-linux-screen-share-audio-routing'),
|
||||||
deactivateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('deactivate-linux-screen-share-audio-routing'),
|
deactivateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('deactivate-linux-screen-share-audio-routing'),
|
||||||
@@ -289,10 +265,6 @@ const electronAPI: ElectronAPI = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
||||||
openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'),
|
|
||||||
exportUserData: () => ipcRenderer.invoke('export-user-data'),
|
|
||||||
importUserData: () => ipcRenderer.invoke('import-user-data'),
|
|
||||||
eraseUserData: () => ipcRenderer.invoke('erase-user-data'),
|
|
||||||
getSavedThemesPath: () => ipcRenderer.invoke('get-saved-themes-path'),
|
getSavedThemesPath: () => ipcRenderer.invoke('get-saved-themes-path'),
|
||||||
listSavedThemes: () => ipcRenderer.invoke('list-saved-themes'),
|
listSavedThemes: () => ipcRenderer.invoke('list-saved-themes'),
|
||||||
readSavedTheme: (fileName) => ipcRenderer.invoke('read-saved-theme', fileName),
|
readSavedTheme: (fileName) => ipcRenderer.invoke('read-saved-theme', fileName),
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
import { execFile } from 'child_process';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { promisify } from 'util';
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
const MAX_PROCESS_NAMES = 512;
|
|
||||||
|
|
||||||
export async function listRunningProcessNames(): Promise<string[]> {
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
return normalizeProcessNames(await listWindowsProcessNames());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.platform === 'linux') {
|
|
||||||
return normalizeProcessNames(await listLinuxProcessNames());
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listLinuxProcessNames(): Promise<string[]> {
|
|
||||||
const { stdout } = await execFileAsync('ps', ['-eo', 'comm='], {
|
|
||||||
maxBuffer: 1024 * 1024,
|
|
||||||
timeout: 5_000
|
|
||||||
});
|
|
||||||
|
|
||||||
return stdout.split('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listWindowsProcessNames(): Promise<string[]> {
|
|
||||||
const { stdout } = await execFileAsync('tasklist', [
|
|
||||||
'/FO',
|
|
||||||
'CSV',
|
|
||||||
'/NH'
|
|
||||||
], {
|
|
||||||
maxBuffer: 1024 * 1024,
|
|
||||||
timeout: 5_000,
|
|
||||||
windowsHide: true
|
|
||||||
});
|
|
||||||
|
|
||||||
return stdout
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => parseCsvFirstColumn(line));
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCsvFirstColumn(line: string): string {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
|
|
||||||
if (!trimmed) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!trimmed.startsWith('"')) {
|
|
||||||
return trimmed.split(',')[0] ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const endQuoteIndex = trimmed.indexOf('"', 1);
|
|
||||||
|
|
||||||
return endQuoteIndex > 1 ? trimmed.slice(1, endQuoteIndex) : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeProcessNames(names: string[]): string[] {
|
|
||||||
const normalized = new Set<string>();
|
|
||||||
|
|
||||||
for (const rawName of names) {
|
|
||||||
const name = normalizeProcessName(rawName);
|
|
||||||
|
|
||||||
if (name) {
|
|
||||||
normalized.add(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(normalized)
|
|
||||||
.sort()
|
|
||||||
.slice(0, MAX_PROCESS_NAMES);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeProcessName(rawName: string): string {
|
|
||||||
const baseName = path.basename(rawName.trim()).trim();
|
|
||||||
|
|
||||||
if (!baseName || baseName.length > 96) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseName;
|
|
||||||
}
|
|
||||||
26
package-lock.json
generated
26
package-lock.json
generated
@@ -15,10 +15,8 @@
|
|||||||
"@angular/platform-browser": "^21.0.0",
|
"@angular/platform-browser": "^21.0.0",
|
||||||
"@angular/router": "^21.0.0",
|
"@angular/router": "^21.0.0",
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@codemirror/commands": "^6.10.3",
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
|
||||||
"@codemirror/lang-json": "^6.0.2",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
"@codemirror/language": "^6.12.3",
|
"@codemirror/language": "^6.12.3",
|
||||||
"@codemirror/lint": "^6.9.5",
|
|
||||||
"@codemirror/state": "^6.6.0",
|
"@codemirror/state": "^6.6.0",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.41.0",
|
"@codemirror/view": "^6.41.0",
|
||||||
@@ -2733,19 +2731,6 @@
|
|||||||
"@lezer/common": "^1.1.0"
|
"@lezer/common": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@codemirror/lang-css": {
|
|
||||||
"version": "6.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
|
||||||
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@codemirror/autocomplete": "^6.0.0",
|
|
||||||
"@codemirror/language": "^6.0.0",
|
|
||||||
"@codemirror/state": "^6.0.0",
|
|
||||||
"@lezer/common": "^1.0.2",
|
|
||||||
"@lezer/css": "^1.1.7"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@codemirror/lang-json": {
|
"node_modules/@codemirror/lang-json": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
|
||||||
@@ -5806,17 +5791,6 @@
|
|||||||
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
|
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@lezer/css": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.3.tgz",
|
|
||||||
"integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@lezer/common": "^1.2.0",
|
|
||||||
"@lezer/highlight": "^1.0.0",
|
|
||||||
"@lezer/lr": "^1.3.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@lezer/highlight": {
|
"node_modules/@lezer/highlight": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||||
|
|||||||
@@ -65,10 +65,8 @@
|
|||||||
"@angular/platform-browser": "^21.0.0",
|
"@angular/platform-browser": "^21.0.0",
|
||||||
"@angular/router": "^21.0.0",
|
"@angular/router": "^21.0.0",
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@codemirror/commands": "^6.10.3",
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
|
||||||
"@codemirror/lang-json": "^6.0.2",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
"@codemirror/language": "^6.12.3",
|
"@codemirror/language": "^6.12.3",
|
||||||
"@codemirror/lint": "^6.9.5",
|
|
||||||
"@codemirror/state": "^6.6.0",
|
"@codemirror/state": "^6.6.0",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.41.0",
|
"@codemirror/view": "^6.41.0",
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ Node/TypeScript signaling server for MetoYou / Toju. This package owns the publi
|
|||||||
- The server loads the repository-root `.env` file on startup.
|
- The server loads the repository-root `.env` file on startup.
|
||||||
- `SSL` can override the effective HTTP protocol, and `PORT` can override the effective port.
|
- `SSL` can override the effective HTTP protocol, and `PORT` can override the effective port.
|
||||||
- `DB_PATH` can override the SQLite database file location.
|
- `DB_PATH` can override the SQLite database file location.
|
||||||
- `data/variables.json` is normalized on startup and stores `klipyApiKey`, `rawgApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`.
|
- `data/variables.json` is normalized on startup and stores `klipyApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`.
|
||||||
- `RAWG_API_KEY` can override `rawgApiKey` for the `/api/games/match` now-playing metadata resolver. Successful matches include a preferred store link from RAWG store metadata, with Steam selected first when available. Negative game-match results are stored in the SQLite `game_match_misses` table so non-game process names do not repeatedly consume RAWG quota.
|
|
||||||
- Packaged server builds store `metoyou.sqlite` in the OS app-data directory by default so upgrades do not overwrite runtime data. On first start, the server copies forward legacy packaged databases that still live beside the executable.
|
- Packaged server builds store `metoyou.sqlite` in the OS app-data directory by default so upgrades do not overwrite runtime data. On first start, the server copies forward legacy packaged databases that still live beside the executable.
|
||||||
- When HTTPS is enabled, certificates are read from the repository `.certs/` directory.
|
- When HTTPS is enabled, certificates are read from the repository `.certs/` directory.
|
||||||
|
|
||||||
@@ -40,4 +39,4 @@ Node/TypeScript signaling server for MetoYou / Toju. This package owns the publi
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- `dist/` and `../dist-server/` are generated output.
|
- `dist/` and `../dist-server/` are generated output.
|
||||||
- See [AGENTS.md](AGENTS.md) for package-specific editing guidance.
|
- See [AGENTS.md](AGENTS.md) for package-specific editing guidance.
|
||||||
@@ -12,7 +12,6 @@ export interface LinkPreviewConfig {
|
|||||||
|
|
||||||
export interface ServerVariablesConfig {
|
export interface ServerVariablesConfig {
|
||||||
klipyApiKey: string;
|
klipyApiKey: string;
|
||||||
rawgApiKey: string;
|
|
||||||
releaseManifestUrl: string;
|
releaseManifestUrl: string;
|
||||||
serverPort: number;
|
serverPort: number;
|
||||||
serverProtocol: ServerHttpProtocol;
|
serverProtocol: ServerHttpProtocol;
|
||||||
@@ -32,10 +31,6 @@ function normalizeKlipyApiKey(value: unknown): string {
|
|||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRawgApiKey(value: unknown): string {
|
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeReleaseManifestUrl(value: unknown): string {
|
function normalizeReleaseManifestUrl(value: unknown): string {
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
}
|
}
|
||||||
@@ -144,7 +139,6 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
|||||||
const normalized = {
|
const normalized = {
|
||||||
...remainingParsed,
|
...remainingParsed,
|
||||||
klipyApiKey: normalizeKlipyApiKey(remainingParsed.klipyApiKey),
|
klipyApiKey: normalizeKlipyApiKey(remainingParsed.klipyApiKey),
|
||||||
rawgApiKey: normalizeRawgApiKey(remainingParsed.rawgApiKey),
|
|
||||||
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl),
|
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl),
|
||||||
serverPort: normalizeServerPort(remainingParsed.serverPort),
|
serverPort: normalizeServerPort(remainingParsed.serverPort),
|
||||||
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
||||||
@@ -159,7 +153,6 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
klipyApiKey: normalized.klipyApiKey,
|
klipyApiKey: normalized.klipyApiKey,
|
||||||
rawgApiKey: normalized.rawgApiKey,
|
|
||||||
releaseManifestUrl: normalized.releaseManifestUrl,
|
releaseManifestUrl: normalized.releaseManifestUrl,
|
||||||
serverPort: normalized.serverPort,
|
serverPort: normalized.serverPort,
|
||||||
serverProtocol: normalized.serverProtocol,
|
serverProtocol: normalized.serverProtocol,
|
||||||
@@ -176,14 +169,6 @@ export function getKlipyApiKey(): string {
|
|||||||
return getVariablesConfig().klipyApiKey;
|
return getVariablesConfig().klipyApiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRawgApiKey(): string {
|
|
||||||
if (hasEnvironmentOverride(process.env.RAWG_API_KEY)) {
|
|
||||||
return process.env.RAWG_API_KEY.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
return getVariablesConfig().rawgApiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasKlipyApiKey(): boolean {
|
export function hasKlipyApiKey(): boolean {
|
||||||
return getKlipyApiKey().length > 0;
|
return getKlipyApiKey().length > 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ import {
|
|||||||
JoinRequestEntity,
|
JoinRequestEntity,
|
||||||
ServerMembershipEntity,
|
ServerMembershipEntity,
|
||||||
ServerInviteEntity,
|
ServerInviteEntity,
|
||||||
ServerBanEntity,
|
ServerBanEntity
|
||||||
GameMatchMissEntity
|
|
||||||
} from '../entities';
|
} from '../entities';
|
||||||
import { serverMigrations } from '../migrations';
|
import { serverMigrations } from '../migrations';
|
||||||
import {
|
import {
|
||||||
@@ -203,8 +202,7 @@ export async function initDatabase(): Promise<void> {
|
|||||||
JoinRequestEntity,
|
JoinRequestEntity,
|
||||||
ServerMembershipEntity,
|
ServerMembershipEntity,
|
||||||
ServerInviteEntity,
|
ServerInviteEntity,
|
||||||
ServerBanEntity,
|
ServerBanEntity
|
||||||
GameMatchMissEntity
|
|
||||||
],
|
],
|
||||||
migrations: serverMigrations,
|
migrations: serverMigrations,
|
||||||
synchronize: process.env.DB_SYNCHRONIZE === 'true',
|
synchronize: process.env.DB_SYNCHRONIZE === 'true',
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import {
|
|
||||||
Column,
|
|
||||||
Entity,
|
|
||||||
Index,
|
|
||||||
PrimaryColumn
|
|
||||||
} from 'typeorm';
|
|
||||||
|
|
||||||
@Entity('game_match_misses')
|
|
||||||
export class GameMatchMissEntity {
|
|
||||||
@PrimaryColumn('text')
|
|
||||||
processKey!: string;
|
|
||||||
|
|
||||||
@Column('text')
|
|
||||||
processName!: string;
|
|
||||||
|
|
||||||
@Column('integer')
|
|
||||||
missedAt!: number;
|
|
||||||
|
|
||||||
@Index()
|
|
||||||
@Column('integer')
|
|
||||||
expiresAt!: number;
|
|
||||||
}
|
|
||||||
@@ -9,4 +9,3 @@ export { JoinRequestEntity } from './JoinRequestEntity';
|
|||||||
export { ServerMembershipEntity } from './ServerMembershipEntity';
|
export { ServerMembershipEntity } from './ServerMembershipEntity';
|
||||||
export { ServerInviteEntity } from './ServerInviteEntity';
|
export { ServerInviteEntity } from './ServerInviteEntity';
|
||||||
export { ServerBanEntity } from './ServerBanEntity';
|
export { ServerBanEntity } from './ServerBanEntity';
|
||||||
export { GameMatchMissEntity } from './GameMatchMissEntity';
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
||||||
|
|
||||||
export class GameMatchMisses1000000000006 implements MigrationInterface {
|
|
||||||
name = 'GameMatchMisses1000000000006';
|
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS "game_match_misses" (
|
|
||||||
"processKey" TEXT PRIMARY KEY NOT NULL,
|
|
||||||
"processName" TEXT NOT NULL,
|
|
||||||
"missedAt" INTEGER NOT NULL,
|
|
||||||
"expiresAt" INTEGER NOT NULL
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE INDEX IF NOT EXISTS "idx_game_match_misses_expiresAt"
|
|
||||||
ON "game_match_misses" ("expiresAt")
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`DROP TABLE IF EXISTS "game_match_misses"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ import { ServerChannels1000000000002 } from './1000000000002-ServerChannels';
|
|||||||
import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLegacyVoiceChannels';
|
import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLegacyVoiceChannels';
|
||||||
import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays';
|
import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays';
|
||||||
import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl';
|
import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl';
|
||||||
import { GameMatchMisses1000000000006 } from './1000000000006-GameMatchMisses';
|
|
||||||
|
|
||||||
export const serverMigrations = [
|
export const serverMigrations = [
|
||||||
InitialSchema1000000000000,
|
InitialSchema1000000000000,
|
||||||
@@ -12,6 +11,5 @@ export const serverMigrations = [
|
|||||||
ServerChannels1000000000002,
|
ServerChannels1000000000002,
|
||||||
RepairLegacyVoiceChannels1000000000003,
|
RepairLegacyVoiceChannels1000000000003,
|
||||||
NormalizeServerArrays1000000000004,
|
NormalizeServerArrays1000000000004,
|
||||||
ServerRoleAccessControl1000000000005,
|
ServerRoleAccessControl1000000000005
|
||||||
GameMatchMisses1000000000006
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import { Router } from 'express';
|
|
||||||
import { matchRunningGames } from '../services/game-matching.service';
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
router.post('/match', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const result = await matchRunningGames(req.body?.processes, req.body?.userId ?? req.ip);
|
|
||||||
|
|
||||||
res.json(result);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Games] Failed to match running games', error);
|
|
||||||
res.status(500).json({ error: 'Failed to match running games' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -2,7 +2,6 @@ import { Express } from 'express';
|
|||||||
import healthRouter from './health';
|
import healthRouter from './health';
|
||||||
import klipyRouter from './klipy';
|
import klipyRouter from './klipy';
|
||||||
import linkMetadataRouter from './link-metadata';
|
import linkMetadataRouter from './link-metadata';
|
||||||
import gamesRouter from './games';
|
|
||||||
import proxyRouter from './proxy';
|
import proxyRouter from './proxy';
|
||||||
import usersRouter from './users';
|
import usersRouter from './users';
|
||||||
import serversRouter from './servers';
|
import serversRouter from './servers';
|
||||||
@@ -13,7 +12,6 @@ export function registerRoutes(app: Express): void {
|
|||||||
app.use('/api', healthRouter);
|
app.use('/api', healthRouter);
|
||||||
app.use('/api', klipyRouter);
|
app.use('/api', klipyRouter);
|
||||||
app.use('/api', linkMetadataRouter);
|
app.use('/api', linkMetadataRouter);
|
||||||
app.use('/api/games', gamesRouter);
|
|
||||||
app.use('/api', proxyRouter);
|
app.use('/api', proxyRouter);
|
||||||
app.use('/api/users', usersRouter);
|
app.use('/api/users', usersRouter);
|
||||||
app.use('/api/servers', serversRouter);
|
app.use('/api/servers', serversRouter);
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ import {
|
|||||||
ServerAccessError,
|
ServerAccessError,
|
||||||
kickServerUser,
|
kickServerUser,
|
||||||
ensureServerMembership,
|
ensureServerMembership,
|
||||||
unbanServerUser,
|
unbanServerUser
|
||||||
countServerMemberships
|
|
||||||
} from '../services/server-access.service';
|
} from '../services/server-access.service';
|
||||||
import {
|
import {
|
||||||
buildAppInviteUrl,
|
buildAppInviteUrl,
|
||||||
@@ -79,7 +78,6 @@ function normalizeServerChannels(value: unknown): ServerChannelPayload[] {
|
|||||||
|
|
||||||
async function enrichServer(server: ServerPayload, sourceUrl?: string) {
|
async function enrichServer(server: ServerPayload, sourceUrl?: string) {
|
||||||
const owner = await getUserById(server.ownerId);
|
const owner = await getUserById(server.ownerId);
|
||||||
const userCount = await countServerMemberships(server.id);
|
|
||||||
const { passwordHash, ...publicServer } = server;
|
const { passwordHash, ...publicServer } = server;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -87,8 +85,7 @@ async function enrichServer(server: ServerPayload, sourceUrl?: string) {
|
|||||||
hasPassword: server.hasPassword ?? !!passwordHash,
|
hasPassword: server.hasPassword ?? !!passwordHash,
|
||||||
ownerName: owner?.displayName,
|
ownerName: owner?.displayName,
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
currentUsers: userCount,
|
userCount: server.currentUsers
|
||||||
userCount
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,591 +0,0 @@
|
|||||||
import { getRawgApiKey } from '../config/variables';
|
|
||||||
import { getDataSource } from '../db/database';
|
|
||||||
import { GameMatchMissEntity } from '../entities';
|
|
||||||
|
|
||||||
export interface MatchedGame {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
iconUrl?: string;
|
|
||||||
store?: GameStoreLink;
|
|
||||||
processName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GameStoreLink {
|
|
||||||
id?: string;
|
|
||||||
name: string;
|
|
||||||
slug?: string;
|
|
||||||
domain?: string;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CacheEntry {
|
|
||||||
expiresAt: number;
|
|
||||||
game: Omit<MatchedGame, 'processName'> | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RawgSearchResponse {
|
|
||||||
results?: RawgGameResult[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RawgGameResult {
|
|
||||||
id?: number;
|
|
||||||
name?: string;
|
|
||||||
background_image?: string | null;
|
|
||||||
slug?: string;
|
|
||||||
stores?: RawgStoreEntry[] | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RawgStoreEntry {
|
|
||||||
url?: string | null;
|
|
||||||
store?: RawgStore | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RawgStore {
|
|
||||||
id?: number;
|
|
||||||
name?: string;
|
|
||||||
slug?: string;
|
|
||||||
domain?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CandidateProcess {
|
|
||||||
processName: string;
|
|
||||||
score: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GameMatchResult {
|
|
||||||
games: MatchedGame[];
|
|
||||||
rateLimited?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RawgLookupBudget {
|
|
||||||
used: number;
|
|
||||||
windowStartedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
||||||
const PERSISTED_MISS_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
||||||
const RAWG_LOOKUP_WINDOW_MS = 60 * 60 * 1000;
|
|
||||||
const RAWG_SEARCH_TIMEOUT_MS = 4_000;
|
|
||||||
const MAX_INCOMING_PROCESSES = 256;
|
|
||||||
const MAX_CANDIDATE_PROCESSES = 24;
|
|
||||||
const MAX_UNCACHED_LOOKUPS_PER_REQUEST = 4;
|
|
||||||
const MAX_RAWG_LOOKUPS_PER_USER_PER_WINDOW = 8;
|
|
||||||
const RAWG_SEARCH_URL = 'https://api.rawg.io/api/games';
|
|
||||||
const MIN_SEARCH_QUERY_LENGTH = 4;
|
|
||||||
const IGNORED_PROCESS_NAMES = new Set([
|
|
||||||
'agent',
|
|
||||||
'bash',
|
|
||||||
'baloorunner',
|
|
||||||
'chrome',
|
|
||||||
'code',
|
|
||||||
'conhost',
|
|
||||||
'cursor',
|
|
||||||
'csrss',
|
|
||||||
'dbus-daemon',
|
|
||||||
'discord',
|
|
||||||
'dwm',
|
|
||||||
'electron',
|
|
||||||
'explorer',
|
|
||||||
'firefox',
|
|
||||||
'gameoverlayui',
|
|
||||||
'gamemoded',
|
|
||||||
'gamescopereaper',
|
|
||||||
'gnome-shell',
|
|
||||||
'init',
|
|
||||||
'kernel_task',
|
|
||||||
'metoyou',
|
|
||||||
'nvidia-settings',
|
|
||||||
'node',
|
|
||||||
'npm',
|
|
||||||
'obs',
|
|
||||||
'powershell',
|
|
||||||
'pulseaudio',
|
|
||||||
'services',
|
|
||||||
'steam',
|
|
||||||
'steamwebhelper',
|
|
||||||
'system',
|
|
||||||
'systemd',
|
|
||||||
'taskhostw',
|
|
||||||
'wininit',
|
|
||||||
'winlogon',
|
|
||||||
'xorg'
|
|
||||||
]);
|
|
||||||
const IGNORED_PROCESS_PATTERNS = [
|
|
||||||
new RegExp('(^|\\s)(agent|browser|daemon|desktop|helper|indexer|launcher|monitor|renderer|runner)(\\s|$)'),
|
|
||||||
new RegExp('(^|\\s)(service|settings|shell|tray|updater|utility|watcher|worker)(\\s|$)'),
|
|
||||||
new RegExp('(^|\\s)(audio|bluetooth|clipboard|crash|dbus|file|gpu|input|network|notification)(\\s|$)'),
|
|
||||||
new RegExp('(^|\\s)(portal|proxy|screen|session|sync|system|tracker|web|window)(\\s|$)'),
|
|
||||||
/^(appimage|at-spi|baloo|dconf|gvfs|ibus|kde|kworker)/,
|
|
||||||
/^(pipewire|plasmashell|pulseaudio|xdg|xwayland|zeitgeist)/,
|
|
||||||
/(helper|service|daemon|runner|tracker|portal|updater|worker)$/
|
|
||||||
];
|
|
||||||
const STORE_SEARCH_URL_BUILDERS: Record<string, (query: string) => string> = {
|
|
||||||
steam: (query) => `https://store.steampowered.com/search/?term=${query}`,
|
|
||||||
'epic-games': (query) => `https://store.epicgames.com/en-US/browse?q=${query}`,
|
|
||||||
gog: (query) => `https://www.gog.com/en/games?query=${query}`,
|
|
||||||
itch: (query) => `https://itch.io/search?q=${query}`,
|
|
||||||
'xbox-store': (query) => `https://www.xbox.com/search?q=${query}`,
|
|
||||||
'playstation-store': (query) => `https://store.playstation.com/search/${query}`,
|
|
||||||
nintendo: (query) => `https://www.nintendo.com/search/#q=${query}`,
|
|
||||||
'apple-appstore': (query) => `https://apps.apple.com/us/search?term=${query}`,
|
|
||||||
'google-play': (query) => `https://play.google.com/store/search?q=${query}&c=apps`
|
|
||||||
};
|
|
||||||
const STORE_SEARCH_ALIASES = new Map<string, string>([
|
|
||||||
['steam', 'steam'],
|
|
||||||
['store.steampowered.com', 'steam'],
|
|
||||||
['epic-games', 'epic-games'],
|
|
||||||
['store.epicgames.com', 'epic-games'],
|
|
||||||
['gog', 'gog'],
|
|
||||||
['www.gog.com', 'gog'],
|
|
||||||
['gog.com', 'gog'],
|
|
||||||
['itch', 'itch'],
|
|
||||||
['itch.io', 'itch'],
|
|
||||||
['xbox-store', 'xbox-store'],
|
|
||||||
['www.xbox.com', 'xbox-store'],
|
|
||||||
['xbox.com', 'xbox-store'],
|
|
||||||
['playstation-store', 'playstation-store'],
|
|
||||||
['store.playstation.com', 'playstation-store'],
|
|
||||||
['nintendo', 'nintendo'],
|
|
||||||
['www.nintendo.com', 'nintendo'],
|
|
||||||
['nintendo.com', 'nintendo'],
|
|
||||||
['apple-appstore', 'apple-appstore'],
|
|
||||||
['apps.apple.com', 'apple-appstore'],
|
|
||||||
['google-play', 'google-play'],
|
|
||||||
['play.google.com', 'google-play']
|
|
||||||
]);
|
|
||||||
const STORE_PRIORITY = new Map<string, number>([
|
|
||||||
['steam', 0],
|
|
||||||
['gog', 10],
|
|
||||||
['epic-games', 20],
|
|
||||||
['itch', 30],
|
|
||||||
['xbox-store', 80],
|
|
||||||
['playstation-store', 90]
|
|
||||||
]);
|
|
||||||
const cache = new Map<string, CacheEntry>();
|
|
||||||
const rawgLookupBudgets = new Map<string, RawgLookupBudget>();
|
|
||||||
|
|
||||||
export async function matchRunningGames(
|
|
||||||
processNames: unknown,
|
|
||||||
requester: unknown = 'anonymous'
|
|
||||||
): Promise<GameMatchResult> {
|
|
||||||
const candidates = normalizeProcessList(processNames).slice(0, MAX_CANDIDATE_PROCESSES);
|
|
||||||
const matches: MatchedGame[] = [];
|
|
||||||
const seenGameIds = new Set<string>();
|
|
||||||
const requesterKey = normalizeRequesterKey(requester);
|
|
||||||
const persistedMisses = await loadPersistedMissKeys(candidates.map((candidate) => candidate.processName));
|
|
||||||
|
|
||||||
let uncachedLookups = 0;
|
|
||||||
let rateLimited = false;
|
|
||||||
|
|
||||||
for (const { processName } of candidates) {
|
|
||||||
const cacheKey = normalizeCacheKey(processName);
|
|
||||||
const cached = getCachedGame(cacheKey);
|
|
||||||
|
|
||||||
if (cached !== undefined) {
|
|
||||||
appendMatch(matches, seenGameIds, processName, cached);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (persistedMisses.has(cacheKey)) {
|
|
||||||
setCachedGame(cacheKey, null);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uncachedLookups >= MAX_UNCACHED_LOOKUPS_PER_REQUEST) {
|
|
||||||
rateLimited = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!tryConsumeRawgLookup(requesterKey)) {
|
|
||||||
rateLimited = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
uncachedLookups += 1;
|
|
||||||
|
|
||||||
const game = await resolveRawgGame(processName);
|
|
||||||
|
|
||||||
setCachedGame(cacheKey, game);
|
|
||||||
|
|
||||||
if (!game) {
|
|
||||||
await rememberPersistedMiss(cacheKey, processName);
|
|
||||||
}
|
|
||||||
|
|
||||||
appendMatch(matches, seenGameIds, processName, game);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
games: matches,
|
|
||||||
rateLimited: rateLimited || undefined
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeProcessList(value: unknown): CandidateProcess[] {
|
|
||||||
if (!Array.isArray(value)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const processes = new Map<string, CandidateProcess>();
|
|
||||||
|
|
||||||
for (const entry of value.slice(0, MAX_INCOMING_PROCESSES)) {
|
|
||||||
const processName = normalizeProcessName(entry);
|
|
||||||
|
|
||||||
if (processName) {
|
|
||||||
const cacheKey = normalizeCacheKey(processName);
|
|
||||||
|
|
||||||
if (!processes.has(cacheKey)) {
|
|
||||||
processes.set(cacheKey, {
|
|
||||||
processName,
|
|
||||||
score: scoreCandidateProcess(String(entry), processName)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(processes.values())
|
|
||||||
.sort((left, right) => right.score - left.score || left.processName.localeCompare(right.processName));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeProcessName(value: unknown): string {
|
|
||||||
if (typeof value !== 'string') {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = value
|
|
||||||
.trim()
|
|
||||||
.replace(/\.exe$/i, '')
|
|
||||||
.replace(/[_-]+/g, ' ')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
const cacheKey = normalizeCacheKey(normalized);
|
|
||||||
|
|
||||||
if (normalized.length < 3 || normalized.length > 96 || shouldIgnoreProcessName(cacheKey)) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldIgnoreProcessName(cacheKey: string): boolean {
|
|
||||||
return IGNORED_PROCESS_NAMES.has(cacheKey)
|
|
||||||
|| IGNORED_PROCESS_PATTERNS.some((pattern) => pattern.test(cacheKey));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeRequesterKey(value: unknown): string {
|
|
||||||
if (typeof value !== 'string') {
|
|
||||||
return 'anonymous';
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = value.trim().toLowerCase();
|
|
||||||
|
|
||||||
return normalized || 'anonymous';
|
|
||||||
}
|
|
||||||
|
|
||||||
function tryConsumeRawgLookup(requesterKey: string): boolean {
|
|
||||||
const now = Date.now();
|
|
||||||
const existing = rawgLookupBudgets.get(requesterKey);
|
|
||||||
|
|
||||||
if (!existing || existing.windowStartedAt + RAWG_LOOKUP_WINDOW_MS <= now) {
|
|
||||||
rawgLookupBudgets.set(requesterKey, {
|
|
||||||
used: 1,
|
|
||||||
windowStartedAt: now
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existing.used >= MAX_RAWG_LOOKUPS_PER_USER_PER_WINDOW) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
existing.used += 1;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function scoreCandidateProcess(rawValue: string, processName: string): number {
|
|
||||||
let score = 0;
|
|
||||||
|
|
||||||
if (/\.exe$/i.test(rawValue.trim())) {
|
|
||||||
score += 12;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/[A-Z]/.test(processName) && /[a-z]/.test(processName)) {
|
|
||||||
score += 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/\d/.test(processName)) {
|
|
||||||
score += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (processName.length >= 5 && processName.length <= 32) {
|
|
||||||
score += 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (processName.includes(' ')) {
|
|
||||||
score -= 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
return score;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCacheKey(value: string): string {
|
|
||||||
return value.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/\s+/g, ' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCachedGame(cacheKey: string): Omit<MatchedGame, 'processName'> | null | undefined {
|
|
||||||
const cached = cache.get(cacheKey);
|
|
||||||
|
|
||||||
if (!cached) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cached.expiresAt <= Date.now()) {
|
|
||||||
cache.delete(cacheKey);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return cached.game;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCachedGame(cacheKey: string, game: Omit<MatchedGame, 'processName'> | null): void {
|
|
||||||
cache.set(cacheKey, {
|
|
||||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
|
||||||
game
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPersistedMissKeys(processNames: string[]): Promise<Set<string>> {
|
|
||||||
const cacheKeys = Array.from(new Set(processNames.map((name) => normalizeCacheKey(name))));
|
|
||||||
|
|
||||||
if (cacheKeys.length === 0) {
|
|
||||||
return new Set();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const repository = getDataSource().getRepository(GameMatchMissEntity);
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
await repository.createQueryBuilder()
|
|
||||||
.delete()
|
|
||||||
.where('expiresAt <= :now', { now })
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
const rows = await repository.createQueryBuilder('miss')
|
|
||||||
.select('miss.processKey')
|
|
||||||
.where('miss.processKey IN (:...cacheKeys)', { cacheKeys })
|
|
||||||
.andWhere('miss.expiresAt > :now', { now })
|
|
||||||
.getMany();
|
|
||||||
|
|
||||||
return new Set(rows.map((row) => row.processKey));
|
|
||||||
} catch {
|
|
||||||
return new Set();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rememberPersistedMiss(cacheKey: string, processName: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
await getDataSource().getRepository(GameMatchMissEntity)
|
|
||||||
.save({
|
|
||||||
processKey: cacheKey,
|
|
||||||
processName,
|
|
||||||
missedAt: now,
|
|
||||||
expiresAt: now + PERSISTED_MISS_TTL_MS
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveRawgGame(processName: string): Promise<Omit<MatchedGame, 'processName'> | null> {
|
|
||||||
const apiKey = getRawgApiKey();
|
|
||||||
|
|
||||||
if (!apiKey) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = buildSearchQuery(processName);
|
|
||||||
|
|
||||||
if (!query) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(RAWG_SEARCH_URL);
|
|
||||||
|
|
||||||
url.searchParams.set('key', apiKey);
|
|
||||||
url.searchParams.set('search', query);
|
|
||||||
url.searchParams.set('search_precise', 'true');
|
|
||||||
url.searchParams.set('exclude_additions', 'true');
|
|
||||||
url.searchParams.set('page_size', '1');
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), RAWG_SEARCH_TIMEOUT_MS);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, { signal: controller.signal });
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await response.json() as RawgSearchResponse;
|
|
||||||
const result = body.results?.[0];
|
|
||||||
|
|
||||||
if (!isAcceptableRawgMatch(query, result)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: String(result.id),
|
|
||||||
name: result.name.trim(),
|
|
||||||
iconUrl: result.background_image || undefined,
|
|
||||||
store: selectPreferredStore(result, result.name.trim())
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectPreferredStore(result: RawgGameResult, gameName: string): GameStoreLink | undefined {
|
|
||||||
const stores = Array.isArray(result.stores) ? result.stores : [];
|
|
||||||
const usableStores = stores
|
|
||||||
.map((entry) => buildStoreLink(entry, gameName))
|
|
||||||
.filter((store): store is GameStoreLink => !!store);
|
|
||||||
|
|
||||||
return usableStores.sort((left, right) => getStorePriority(left) - getStorePriority(right))[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStorePriority(store: GameStoreLink): number {
|
|
||||||
const storeKey = STORE_SEARCH_ALIASES.get(store.slug ?? '')
|
|
||||||
?? STORE_SEARCH_ALIASES.get(store.domain ?? '')
|
|
||||||
?? store.name.trim().toLowerCase();
|
|
||||||
|
|
||||||
return STORE_PRIORITY.get(storeKey) ?? 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStoreLink(entry: RawgStoreEntry, gameName: string): GameStoreLink | undefined {
|
|
||||||
const store = entry.store;
|
|
||||||
|
|
||||||
if (!store || typeof store.name !== 'string' || !store.name.trim()) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const slug = typeof store.slug === 'string' && store.slug.trim()
|
|
||||||
? store.slug.trim().toLowerCase()
|
|
||||||
: undefined;
|
|
||||||
const domain = typeof store.domain === 'string' && store.domain.trim()
|
|
||||||
? store.domain.trim()
|
|
||||||
.replace(/^https?:\/\//i, '')
|
|
||||||
.replace(/\/$/, '')
|
|
||||||
: undefined;
|
|
||||||
const url = normalizeExternalUrl(entry.url) ?? buildStoreSearchUrl(slug, domain, gameName);
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: typeof store.id === 'number' ? String(store.id) : undefined,
|
|
||||||
name: store.name.trim(),
|
|
||||||
slug,
|
|
||||||
domain,
|
|
||||||
url
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeExternalUrl(value: unknown): string | undefined {
|
|
||||||
if (typeof value !== 'string' || !value.trim()) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmed = value.trim();
|
|
||||||
|
|
||||||
return trimmed.startsWith('http://') || trimmed.startsWith('https://')
|
|
||||||
? trimmed
|
|
||||||
: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStoreSearchUrl(slug: string | undefined, domain: string | undefined, gameName: string): string | undefined {
|
|
||||||
const query = encodeURIComponent(gameName);
|
|
||||||
const storeKey = STORE_SEARCH_ALIASES.get(slug ?? '') ?? STORE_SEARCH_ALIASES.get(domain ?? '');
|
|
||||||
const buildUrl = storeKey ? STORE_SEARCH_URL_BUILDERS[storeKey] : undefined;
|
|
||||||
|
|
||||||
return buildUrl?.(query) ?? (domain ? `https://${domain}` : undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSearchQuery(processName: string): string {
|
|
||||||
const query = processName
|
|
||||||
.replace(/\.exe$/i, '')
|
|
||||||
.replace(/\b(x64|x86|win64|win32|linux|shipping|client|launcher|game)\b/gi, ' ')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
return query.length >= MIN_SEARCH_QUERY_LENGTH ? query : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAcceptableRawgMatch(
|
|
||||||
query: string,
|
|
||||||
result: RawgGameResult | undefined
|
|
||||||
): result is Required<Pick<RawgGameResult, 'id' | 'name'>> & RawgGameResult {
|
|
||||||
if (!result || typeof result.id !== 'number' || typeof result.name !== 'string' || !result.name.trim()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryKey = normalizeComparableText(query);
|
|
||||||
const nameKey = normalizeComparableText(result.name);
|
|
||||||
const slugKey = normalizeComparableText(result.slug ?? '');
|
|
||||||
const queryTokens = tokenizeComparableText(queryKey);
|
|
||||||
const nameTokens = tokenizeComparableText(nameKey);
|
|
||||||
const slugTokens = tokenizeComparableText(slugKey);
|
|
||||||
|
|
||||||
if (queryKey.length < MIN_SEARCH_QUERY_LENGTH || queryTokens.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (queryKey === nameKey || queryKey === slugKey) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (queryTokens.length === 1) {
|
|
||||||
const [queryToken] = queryTokens;
|
|
||||||
|
|
||||||
return queryToken.length >= 5
|
|
||||||
&& (nameTokens.includes(queryToken) || slugTokens.includes(queryToken));
|
|
||||||
}
|
|
||||||
|
|
||||||
return queryTokens.every((token) => nameTokens.includes(token) || slugTokens.includes(token));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeComparableText(value: string): string {
|
|
||||||
return value.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function tokenizeComparableText(value: string): string[] {
|
|
||||||
return value.split(' ')
|
|
||||||
.filter((token) => token.length >= 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendMatch(
|
|
||||||
matches: MatchedGame[],
|
|
||||||
seenGameIds: Set<string>,
|
|
||||||
processName: string,
|
|
||||||
game: Omit<MatchedGame, 'processName'> | null
|
|
||||||
): void {
|
|
||||||
if (!game || seenGameIds.has(game.id)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
seenGameIds.add(game.id);
|
|
||||||
matches.push({
|
|
||||||
...game,
|
|
||||||
processName
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -130,10 +130,6 @@ export async function findServerMembership(serverId: string, userId: string): Pr
|
|||||||
return await getMembershipRepository().findOne({ where: { serverId, userId } });
|
return await getMembershipRepository().findOne({ where: { serverId, userId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function countServerMemberships(serverId: string): Promise<number> {
|
|
||||||
return await getMembershipRepository().count({ where: { serverId } });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity> {
|
export async function ensureServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity> {
|
||||||
const repo = getMembershipRepository();
|
const repo = getMembershipRepository();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
@@ -67,14 +67,6 @@ describe('server websocket handler - status_update', () => {
|
|||||||
connectedUsers.clear();
|
connectedUsers.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats signaling keepalive messages as connection liveness', async () => {
|
|
||||||
createConnectedUser('conn-1', 'user-1', { lastPong: 1 });
|
|
||||||
|
|
||||||
await handleWebSocketMessage('conn-1', { type: 'keepalive' });
|
|
||||||
|
|
||||||
expect(connectedUsers.get('conn-1')?.lastPong).toBeGreaterThan(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates user status on valid status_update message', async () => {
|
it('updates user status on valid status_update message', async () => {
|
||||||
const user = createConnectedUser('conn-1', 'user-1');
|
const user = createConnectedUser('conn-1', 'user-1');
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,25 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
|
|||||||
const previousDescription = user.description;
|
const previousDescription = user.description;
|
||||||
const previousProfileUpdatedAt = user.profileUpdatedAt;
|
const previousProfileUpdatedAt = user.profileUpdatedAt;
|
||||||
|
|
||||||
|
// Close stale connections from the same identity AND the same connection
|
||||||
|
// scope so offer routing always targets the freshest socket (e.g. after
|
||||||
|
// page refresh). Connections with a *different* scope (= a different
|
||||||
|
// signal URL that happens to route to this server) are left untouched so
|
||||||
|
// multi-signal-URL setups don't trigger an eviction loop.
|
||||||
|
connectedUsers.forEach((existing, existingId) => {
|
||||||
|
if (existingId !== connectionId
|
||||||
|
&& existing.oderId === newOderId
|
||||||
|
&& existing.connectionScope === newScope) {
|
||||||
|
console.log(`Closing stale connection for ${newOderId} (old=${existingId}, new=${connectionId}, scope=${newScope ?? 'none'})`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
existing.ws.close();
|
||||||
|
} catch { /* already closing */ }
|
||||||
|
|
||||||
|
connectedUsers.delete(existingId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
user.oderId = newOderId;
|
user.oderId = newOderId;
|
||||||
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
||||||
|
|
||||||
@@ -274,13 +293,7 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
|||||||
if (!user)
|
if (!user)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
user.lastPong = Date.now();
|
|
||||||
connectedUsers.set(connectionId, user);
|
|
||||||
|
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case 'keepalive':
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'identify':
|
case 'identify':
|
||||||
handleIdentify(user, message, connectionId);
|
handleIdentify(user, message, connectionId);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -17,6 +17,6 @@ export interface ConnectedUser {
|
|||||||
connectionScope?: string;
|
connectionScope?: string;
|
||||||
/** User availability status (online, away, busy, offline). */
|
/** User availability status (online, away, busy, offline). */
|
||||||
status?: 'online' | 'away' | 'busy' | 'offline';
|
status?: 'online' | 'away' | 'busy' | 'offline';
|
||||||
/** Timestamp of the last pong or client message received (used to detect dead connections). */
|
/** Timestamp of the last pong received (used to detect dead connections). */
|
||||||
lastPong: number;
|
lastPong: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@
|
|||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "2.2MB",
|
"maximumWarning": "2.2MB",
|
||||||
"maximumError": "2.38MB"
|
"maximumError": "2.35MB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
|
|||||||
@@ -145,7 +145,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @if (!isThemeStudioFullscreen() && !isDirectMessageRoute()) {
|
} @if (!isThemeStudioFullscreen()) {
|
||||||
<app-floating-voice-controls />
|
<app-floating-voice-controls />
|
||||||
}
|
}
|
||||||
<app-settings-modal />
|
<app-settings-modal />
|
||||||
|
|||||||
@@ -34,16 +34,6 @@ export const routes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/room/chat-room/chat-room.component').then((module) => module.ChatRoomComponent)
|
import('./features/room/chat-room/chat-room.component').then((module) => module.ChatRoomComponent)
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'dm',
|
|
||||||
loadComponent: () =>
|
|
||||||
import('./domains/direct-message/feature/dm-workspace/dm-workspace.component').then((module) => module.DmWorkspaceComponent)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'dm/:conversationId',
|
|
||||||
loadComponent: () =>
|
|
||||||
import('./domains/direct-message/feature/dm-workspace/dm-workspace.component').then((module) => module.DmWorkspaceComponent)
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import { ExternalLinkService } from './core/platform';
|
|||||||
import { SettingsModalService } from './core/services/settings-modal.service';
|
import { SettingsModalService } from './core/services/settings-modal.service';
|
||||||
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
|
||||||
import { UserStatusService } from './core/services/user-status.service';
|
import { UserStatusService } from './core/services/user-status.service';
|
||||||
import { GameActivityService } from './domains/game-activity';
|
|
||||||
import { ServersRailComponent } from './features/servers/servers-rail/servers-rail.component';
|
import { ServersRailComponent } from './features/servers/servers-rail/servers-rail.component';
|
||||||
import { TitleBarComponent } from './features/shell/title-bar/title-bar.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';
|
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
|
||||||
@@ -46,7 +45,10 @@ import { UsersActions } from './store/users/users.actions';
|
|||||||
import { RoomsActions } from './store/rooms/rooms.actions';
|
import { RoomsActions } from './store/rooms/rooms.actions';
|
||||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||||
import { ROOM_URL_PATTERN } from './core/constants';
|
import { ROOM_URL_PATTERN } from './core/constants';
|
||||||
import { clearStoredCurrentUserId, getStoredCurrentUserId } from './core/storage/current-user-storage';
|
import {
|
||||||
|
clearStoredCurrentUserId,
|
||||||
|
getStoredCurrentUserId
|
||||||
|
} from './core/storage/current-user-storage';
|
||||||
import {
|
import {
|
||||||
ThemeNodeDirective,
|
ThemeNodeDirective,
|
||||||
ThemePickerOverlayComponent,
|
ThemePickerOverlayComponent,
|
||||||
@@ -96,12 +98,10 @@ export class App implements OnInit, OnDestroy {
|
|||||||
readonly externalLinks = inject(ExternalLinkService);
|
readonly externalLinks = inject(ExternalLinkService);
|
||||||
readonly electronBridge = inject(ElectronBridgeService);
|
readonly electronBridge = inject(ElectronBridgeService);
|
||||||
readonly userStatus = inject(UserStatusService);
|
readonly userStatus = inject(UserStatusService);
|
||||||
readonly gameActivity = inject(GameActivityService);
|
|
||||||
readonly dismissedDesktopUpdateNoticeKey = signal<string | null>(null);
|
readonly dismissedDesktopUpdateNoticeKey = signal<string | null>(null);
|
||||||
readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null);
|
readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null);
|
||||||
readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null);
|
readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null);
|
||||||
readonly isDraggingThemeStudioControls = signal(false);
|
readonly isDraggingThemeStudioControls = signal(false);
|
||||||
readonly currentRouteUrl = signal(this.getCurrentRouteUrl());
|
|
||||||
|
|
||||||
readonly appShellLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('appShell'));
|
readonly appShellLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('appShell'));
|
||||||
readonly serversRailLayoutStyles = computed(() => this.theme.getLayoutItemStyles('serversRail'));
|
readonly serversRailLayoutStyles = computed(() => this.theme.getLayoutItemStyles('serversRail'));
|
||||||
@@ -115,7 +115,6 @@ export class App implements OnInit, OnDestroy {
|
|||||||
return this.settingsModal.activePage() === 'theme'
|
return this.settingsModal.activePage() === 'theme'
|
||||||
&& this.settingsModal.themeStudioMinimized();
|
&& this.settingsModal.themeStudioMinimized();
|
||||||
});
|
});
|
||||||
readonly isDirectMessageRoute = computed(() => this.getRoutePath(this.currentRouteUrl()).startsWith('/dm'));
|
|
||||||
readonly desktopUpdateNoticeKey = computed(() => {
|
readonly desktopUpdateNoticeKey = computed(() => {
|
||||||
const updateState = this.desktopUpdateState();
|
const updateState = this.desktopUpdateState();
|
||||||
|
|
||||||
@@ -248,7 +247,6 @@ export class App implements OnInit, OnDestroy {
|
|||||||
await this.setupDesktopDeepLinks();
|
await this.setupDesktopDeepLinks();
|
||||||
|
|
||||||
this.userStatus.start();
|
this.userStatus.start();
|
||||||
this.gameActivity.start();
|
|
||||||
const currentUrl = this.getCurrentRouteUrl();
|
const currentUrl = this.getCurrentRouteUrl();
|
||||||
|
|
||||||
if (!currentUserId) {
|
if (!currentUserId) {
|
||||||
@@ -278,8 +276,6 @@ export class App implements OnInit, OnDestroy {
|
|||||||
this.router.events.subscribe((evt) => {
|
this.router.events.subscribe((evt) => {
|
||||||
if (evt instanceof NavigationEnd) {
|
if (evt instanceof NavigationEnd) {
|
||||||
const url = evt.urlAfterRedirects || evt.url;
|
const url = evt.urlAfterRedirects || evt.url;
|
||||||
|
|
||||||
this.currentRouteUrl.set(url);
|
|
||||||
const roomMatch = url.match(ROOM_URL_PATTERN);
|
const roomMatch = url.match(ROOM_URL_PATTERN);
|
||||||
const currentRoomId = roomMatch ? roomMatch[1] : null;
|
const currentRoomId = roomMatch ? roomMatch[1] : null;
|
||||||
|
|
||||||
|
|||||||
@@ -124,24 +124,6 @@ export interface SavedThemeFileDescriptor {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExportUserDataResult {
|
|
||||||
cancelled: boolean;
|
|
||||||
exported: boolean;
|
|
||||||
filePath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImportUserDataResult {
|
|
||||||
backupPath?: string;
|
|
||||||
cancelled: boolean;
|
|
||||||
imported: boolean;
|
|
||||||
restartRequired: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EraseUserDataResult {
|
|
||||||
erased: boolean;
|
|
||||||
restartRequired: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ElectronCommand {
|
export interface ElectronCommand {
|
||||||
type: string;
|
type: string;
|
||||||
payload: unknown;
|
payload: unknown;
|
||||||
@@ -175,7 +157,6 @@ export interface ElectronApi {
|
|||||||
closeWindow: () => void;
|
closeWindow: () => void;
|
||||||
openExternal: (url: string) => Promise<boolean>;
|
openExternal: (url: string) => Promise<boolean>;
|
||||||
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
|
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
|
||||||
getRunningProcessNames: () => Promise<string[]>;
|
|
||||||
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||||
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||||
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
|
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
|
||||||
@@ -184,10 +165,6 @@ export interface ElectronApi {
|
|||||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||||
getAppDataPath: () => Promise<string>;
|
getAppDataPath: () => Promise<string>;
|
||||||
openCurrentDataFolder: () => Promise<boolean>;
|
|
||||||
exportUserData: () => Promise<ExportUserDataResult>;
|
|
||||||
importUserData: () => Promise<ImportUserDataResult>;
|
|
||||||
eraseUserData: () => Promise<EraseUserDataResult>;
|
|
||||||
getSavedThemesPath: () => Promise<string>;
|
getSavedThemesPath: () => Promise<string>;
|
||||||
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
||||||
readSavedTheme: (fileName: string) => Promise<string>;
|
readSavedTheme: (fileName: string) => Promise<string>;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export type SettingsPage =
|
|||||||
| 'notifications'
|
| 'notifications'
|
||||||
| 'voice'
|
| 'voice'
|
||||||
| 'updates'
|
| 'updates'
|
||||||
| 'data'
|
|
||||||
| 'debugging'
|
| 'debugging'
|
||||||
| 'server'
|
| 'server'
|
||||||
| 'members'
|
| 'members'
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ infrastructure adapters and UI.
|
|||||||
| **access-control** | Role, permission, ban matching, moderation, and room access rules | `normalizeRoomAccessControl()`, `resolveRoomPermission()`, `hasRoomBanForUser()` |
|
| **access-control** | Role, permission, ban matching, moderation, and room access rules | `normalizeRoomAccessControl()`, `resolveRoomPermission()`, `hasRoomBanForUser()` |
|
||||||
| **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` |
|
| **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` |
|
||||||
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
|
| **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` |
|
|
||||||
| **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` |
|
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
|
||||||
| **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` |
|
| **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` |
|
||||||
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
|
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
|
||||||
@@ -30,7 +28,6 @@ The larger domains also keep longer design notes in their own folders:
|
|||||||
- [access-control/README.md](access-control/README.md)
|
- [access-control/README.md](access-control/README.md)
|
||||||
- [authentication/README.md](authentication/README.md)
|
- [authentication/README.md](authentication/README.md)
|
||||||
- [chat/README.md](chat/README.md)
|
- [chat/README.md](chat/README.md)
|
||||||
- [direct-message/README.md](direct-message/README.md)
|
|
||||||
- [notifications/README.md](notifications/README.md)
|
- [notifications/README.md](notifications/README.md)
|
||||||
- [profile-avatar/README.md](profile-avatar/README.md)
|
- [profile-avatar/README.md](profile-avatar/README.md)
|
||||||
- [screen-share/README.md](screen-share/README.md)
|
- [screen-share/README.md](screen-share/README.md)
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
inject,
|
inject,
|
||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
@@ -10,11 +13,7 @@ import {
|
|||||||
throwError
|
throwError
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
import { catchError, map } from 'rxjs/operators';
|
import { catchError, map } from 'rxjs/operators';
|
||||||
import {
|
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||||
ServerDirectoryFacade,
|
|
||||||
type RoomSignalSourceInput,
|
|
||||||
type ServerSourceSelector
|
|
||||||
} from '../../../server-directory';
|
|
||||||
|
|
||||||
export interface KlipyGif {
|
export interface KlipyGif {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -38,47 +37,51 @@ export interface KlipyGifSearchResponse {
|
|||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 24;
|
const DEFAULT_PAGE_SIZE = 24;
|
||||||
const KLIPY_CUSTOMER_ID_STORAGE_KEY = 'metoyou_klipy_customer_id';
|
const KLIPY_CUSTOMER_ID_STORAGE_KEY = 'metoyou_klipy_customer_id';
|
||||||
const DEFAULT_AVAILABILITY_KEY = 'default';
|
|
||||||
|
|
||||||
interface KlipyAvailabilityState {
|
|
||||||
enabled: boolean;
|
|
||||||
loading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class KlipyService {
|
export class KlipyService {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||||
private readonly availabilityByKey = signal<Record<string, KlipyAvailabilityState>>({});
|
private readonly availabilityState = signal({
|
||||||
|
enabled: false,
|
||||||
|
loading: true
|
||||||
|
});
|
||||||
|
private lastAvailabilityKey = '';
|
||||||
|
|
||||||
isEnabled(source?: RoomSignalSourceInput | null): boolean {
|
readonly isEnabled = computed(() => this.availabilityState().enabled);
|
||||||
return this.getAvailabilityState(source).enabled;
|
readonly isLoading = computed(() => this.availabilityState().loading);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const activeServer = this.serverDirectory.activeServer();
|
||||||
|
const apiBaseUrl = this.serverDirectory.getApiBaseUrl();
|
||||||
|
const nextKey = `${activeServer?.id ?? 'default'}:${apiBaseUrl}`;
|
||||||
|
|
||||||
|
if (nextKey === this.lastAvailabilityKey)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.lastAvailabilityKey = nextKey;
|
||||||
|
void this.refreshAvailability();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoading(source?: RoomSignalSourceInput | null): boolean {
|
async refreshAvailability(): Promise<void> {
|
||||||
return this.getAvailabilityState(source).loading;
|
this.availabilityState.set({ enabled: false,
|
||||||
}
|
|
||||||
|
|
||||||
async refreshAvailability(source?: RoomSignalSourceInput | null): Promise<void> {
|
|
||||||
const selector = this.getSourceSelector(source);
|
|
||||||
const key = this.getAvailabilityKey(selector);
|
|
||||||
|
|
||||||
this.setAvailabilityState(key, { enabled: false,
|
|
||||||
loading: true });
|
loading: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await firstValueFrom(
|
const response = await firstValueFrom(
|
||||||
this.http.get<KlipyAvailabilityResponse>(
|
this.http.get<KlipyAvailabilityResponse>(
|
||||||
`${this.serverDirectory.getApiBaseUrl(selector)}/klipy/config`
|
`${this.serverDirectory.getApiBaseUrl()}/klipy/config`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
this.setAvailabilityState(key, {
|
this.availabilityState.set({
|
||||||
enabled: response.enabled === true,
|
enabled: response.enabled === true,
|
||||||
loading: false
|
loading: false
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
this.setAvailabilityState(key, { enabled: false,
|
this.availabilityState.set({ enabled: false,
|
||||||
loading: false });
|
loading: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,11 +89,8 @@ export class KlipyService {
|
|||||||
searchGifs(
|
searchGifs(
|
||||||
query: string,
|
query: string,
|
||||||
page = 1,
|
page = 1,
|
||||||
perPage = DEFAULT_PAGE_SIZE,
|
perPage = DEFAULT_PAGE_SIZE
|
||||||
source?: RoomSignalSourceInput | null
|
|
||||||
): Observable<KlipyGifSearchResponse> {
|
): Observable<KlipyGifSearchResponse> {
|
||||||
const selector = this.getSourceSelector(source);
|
|
||||||
|
|
||||||
let params = new HttpParams()
|
let params = new HttpParams()
|
||||||
.set('page', String(Math.max(1, Math.floor(page))))
|
.set('page', String(Math.max(1, Math.floor(page))))
|
||||||
.set('per_page', String(Math.max(1, Math.floor(perPage))))
|
.set('per_page', String(Math.max(1, Math.floor(perPage))))
|
||||||
@@ -109,7 +109,7 @@ export class KlipyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.http
|
return this.http
|
||||||
.get<KlipyGifSearchResponse>(`${this.serverDirectory.getApiBaseUrl(selector)}/klipy/gifs`, { params })
|
.get<KlipyGifSearchResponse>(`${this.serverDirectory.getApiBaseUrl()}/klipy/gifs`, { params })
|
||||||
.pipe(
|
.pipe(
|
||||||
map((response) => ({
|
map((response) => ({
|
||||||
enabled: response.enabled !== false,
|
enabled: response.enabled !== false,
|
||||||
@@ -138,7 +138,7 @@ export class KlipyService {
|
|||||||
return this.normalizeMediaUrl(url);
|
return this.normalizeMediaUrl(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
buildImageProxyUrl(url: string, source?: RoomSignalSourceInput | null): string {
|
buildImageProxyUrl(url: string): string {
|
||||||
const trimmed = this.normalizeMediaUrl(url);
|
const trimmed = this.normalizeMediaUrl(url);
|
||||||
|
|
||||||
if (!trimmed)
|
if (!trimmed)
|
||||||
@@ -147,36 +147,7 @@ export class KlipyService {
|
|||||||
if (!/^https?:\/\//i.test(trimmed))
|
if (!/^https?:\/\//i.test(trimmed))
|
||||||
return trimmed;
|
return trimmed;
|
||||||
|
|
||||||
return `${this.serverDirectory.getApiBaseUrl(this.getSourceSelector(source))}/image-proxy?url=${encodeURIComponent(trimmed)}`;
|
return `${this.serverDirectory.getApiBaseUrl()}/image-proxy?url=${encodeURIComponent(trimmed)}`;
|
||||||
}
|
|
||||||
|
|
||||||
private getAvailabilityState(source?: RoomSignalSourceInput | null): KlipyAvailabilityState {
|
|
||||||
return this.availabilityByKey()[this.getAvailabilityKey(this.getSourceSelector(source))]
|
|
||||||
?? { enabled: false,
|
|
||||||
loading: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
private setAvailabilityState(key: string, state: KlipyAvailabilityState): void {
|
|
||||||
this.availabilityByKey.update((availabilityByKey) => ({
|
|
||||||
...availabilityByKey,
|
|
||||||
[key]: state
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
private getSourceSelector(source?: RoomSignalSourceInput | null): ServerSourceSelector | undefined {
|
|
||||||
return this.serverDirectory.buildRoomSignalSelector(source ?? undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getAvailabilityKey(selector?: ServerSourceSelector): string {
|
|
||||||
if (selector?.sourceId) {
|
|
||||||
return `id:${selector.sourceId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selector?.sourceUrl) {
|
|
||||||
return `url:${selector.sourceUrl}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return DEFAULT_AVAILABILITY_KEY;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPreferredLocale(): string | null {
|
private getPreferredLocale(): string | null {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { KlipyService } from '../application/services/klipy.service';
|
import { KlipyService } from '../application/services/klipy.service';
|
||||||
import type { RoomSignalSourceInput } from '../../server-directory';
|
|
||||||
|
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: 'img[appChatImageProxyFallback]',
|
selector: 'img[appChatImageProxyFallback]',
|
||||||
@@ -16,7 +15,6 @@ import type { RoomSignalSourceInput } from '../../server-directory';
|
|||||||
})
|
})
|
||||||
export class ChatImageProxyFallbackDirective {
|
export class ChatImageProxyFallbackDirective {
|
||||||
readonly sourceUrl = input('', { alias: 'appChatImageProxyFallback' });
|
readonly sourceUrl = input('', { alias: 'appChatImageProxyFallback' });
|
||||||
readonly signalSource = input<RoomSignalSourceInput | null>(null);
|
|
||||||
|
|
||||||
private readonly klipy = inject(KlipyService);
|
private readonly klipy = inject(KlipyService);
|
||||||
private readonly renderedSource = signal('');
|
private readonly renderedSource = signal('');
|
||||||
@@ -40,7 +38,7 @@ export class ChatImageProxyFallbackDirective {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const proxyUrl = this.klipy.buildImageProxyUrl(this.sourceUrl(), this.signalSource());
|
const proxyUrl = this.klipy.buildImageProxyUrl(this.sourceUrl());
|
||||||
|
|
||||||
if (!proxyUrl || proxyUrl === this.renderedSource()) {
|
if (!proxyUrl || proxyUrl === this.renderedSource()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
<div
|
<div class="chat-layout relative h-full">
|
||||||
appThemeNode="chatSurface"
|
|
||||||
class="chat-layout relative h-full"
|
|
||||||
>
|
|
||||||
<app-chat-message-list
|
<app-chat-message-list
|
||||||
[allMessages]="allMessages()"
|
[allMessages]="allMessages()"
|
||||||
[channelMessages]="channelMessages()"
|
[channelMessages]="channelMessages()"
|
||||||
@@ -22,15 +19,10 @@
|
|||||||
(embedRemoved)="handleEmbedRemoved($event)"
|
(embedRemoved)="handleEmbedRemoved($event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10">
|
||||||
appThemeNode="chatComposerBar"
|
|
||||||
class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10"
|
|
||||||
>
|
|
||||||
<app-chat-message-composer
|
<app-chat-message-composer
|
||||||
[replyTo]="replyTo()"
|
[replyTo]="replyTo()"
|
||||||
[showKlipyGifPicker]="showKlipyGifPicker()"
|
[showKlipyGifPicker]="showKlipyGifPicker()"
|
||||||
[klipyEnabled]="klipyEnabled()"
|
|
||||||
[klipySignalSource]="currentRoom()"
|
|
||||||
(messageSubmitted)="handleMessageSubmitted($event)"
|
(messageSubmitted)="handleMessageSubmitted($event)"
|
||||||
(typingStarted)="handleTypingStarted()"
|
(typingStarted)="handleTypingStarted()"
|
||||||
(replyCleared)="clearReply()"
|
(replyCleared)="clearReply()"
|
||||||
@@ -53,13 +45,11 @@
|
|||||||
|
|
||||||
<div class="pointer-events-none fixed inset-0 z-[90]">
|
<div class="pointer-events-none fixed inset-0 z-[90]">
|
||||||
<div
|
<div
|
||||||
appThemeNode="chatGifPickerSurface"
|
|
||||||
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
|
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
|
||||||
[style.bottom.px]="composerBottomPadding() + 8"
|
[style.bottom.px]="composerBottomPadding() + 8"
|
||||||
[style.right.px]="klipyGifPickerAnchorRight()"
|
[style.right.px]="klipyGifPickerAnchorRight()"
|
||||||
>
|
>
|
||||||
<app-klipy-gif-picker
|
<app-klipy-gif-picker
|
||||||
[signalSource]="currentRoom()"
|
|
||||||
(gifSelected)="handleKlipyGifSelected($event)"
|
(gifSelected)="handleKlipyGifSelected($event)"
|
||||||
(closed)="closeKlipyGifPicker()"
|
(closed)="closeKlipyGifPicker()"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
HostListener,
|
HostListener,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
computed,
|
computed,
|
||||||
effect,
|
|
||||||
inject,
|
inject,
|
||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
@@ -12,7 +11,7 @@ import { Store } from '@ngrx/store';
|
|||||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||||
import { Attachment, AttachmentFacade } from '../../../attachment';
|
import { Attachment, AttachmentFacade } from '../../../attachment';
|
||||||
import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
|
import { KlipyGif } from '../../application/services/klipy.service';
|
||||||
import { MessagesActions } from '../../../../store/messages/messages.actions';
|
import { MessagesActions } from '../../../../store/messages/messages.actions';
|
||||||
import {
|
import {
|
||||||
selectAllMessages,
|
selectAllMessages,
|
||||||
@@ -22,7 +21,6 @@ import {
|
|||||||
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors';
|
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors';
|
||||||
import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
||||||
import { Message } from '../../../../shared-kernel';
|
import { Message } from '../../../../shared-kernel';
|
||||||
import { ThemeNodeDirective } from '../../../theme';
|
|
||||||
import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component';
|
import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component';
|
||||||
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
|
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
|
||||||
import { ChatMessageListComponent } from './components/message-list/chat-message-list.component';
|
import { ChatMessageListComponent } from './components/message-list/chat-message-list.component';
|
||||||
@@ -44,8 +42,7 @@ import {
|
|||||||
ChatMessageComposerComponent,
|
ChatMessageComposerComponent,
|
||||||
KlipyGifPickerComponent,
|
KlipyGifPickerComponent,
|
||||||
ChatMessageListComponent,
|
ChatMessageListComponent,
|
||||||
ChatMessageOverlaysComponent,
|
ChatMessageOverlaysComponent
|
||||||
ThemeNodeDirective
|
|
||||||
],
|
],
|
||||||
templateUrl: './chat-messages.component.html',
|
templateUrl: './chat-messages.component.html',
|
||||||
styleUrl: './chat-messages.component.scss'
|
styleUrl: './chat-messages.component.scss'
|
||||||
@@ -57,11 +54,10 @@ export class ChatMessagesComponent {
|
|||||||
private readonly store = inject(Store);
|
private readonly store = inject(Store);
|
||||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||||
private readonly attachmentsSvc = inject(AttachmentFacade);
|
private readonly attachmentsSvc = inject(AttachmentFacade);
|
||||||
private readonly klipy = inject(KlipyService);
|
|
||||||
|
|
||||||
readonly allMessages = this.store.selectSignal(selectAllMessages);
|
readonly allMessages = this.store.selectSignal(selectAllMessages);
|
||||||
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||||
readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
|
||||||
readonly loading = this.store.selectSignal(selectMessagesLoading);
|
readonly loading = this.store.selectSignal(selectMessagesLoading);
|
||||||
readonly syncing = this.store.selectSignal(selectMessagesSyncing);
|
readonly syncing = this.store.selectSignal(selectMessagesSyncing);
|
||||||
@@ -72,11 +68,16 @@ export class ChatMessagesComponent {
|
|||||||
const channelId = this.activeChannelId();
|
const channelId = this.activeChannelId();
|
||||||
const roomId = this.currentRoom()?.id;
|
const roomId = this.currentRoom()?.id;
|
||||||
|
|
||||||
return this.allMessages().filter((message) => message.roomId === roomId && (message.channelId || 'general') === channelId);
|
return this.allMessages().filter(
|
||||||
|
(message) =>
|
||||||
|
message.roomId === roomId &&
|
||||||
|
(message.channelId || 'general') === channelId
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly conversationKey = computed(() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`);
|
readonly conversationKey = computed(
|
||||||
readonly klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom()));
|
() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`
|
||||||
|
);
|
||||||
readonly composerBottomPadding = signal(140);
|
readonly composerBottomPadding = signal(140);
|
||||||
readonly klipyGifPickerAnchorRight = signal(16);
|
readonly klipyGifPickerAnchorRight = signal(16);
|
||||||
readonly replyTo = signal<Message | null>(null);
|
readonly replyTo = signal<Message | null>(null);
|
||||||
@@ -84,12 +85,6 @@ export class ChatMessagesComponent {
|
|||||||
readonly lightboxAttachment = signal<Attachment | null>(null);
|
readonly lightboxAttachment = signal<Attachment | null>(null);
|
||||||
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
|
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
|
||||||
|
|
||||||
constructor() {
|
|
||||||
effect(() => {
|
|
||||||
void this.klipy.refreshAvailability(this.currentRoom());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@HostListener('window:resize')
|
@HostListener('window:resize')
|
||||||
onWindowResize(): void {
|
onWindowResize(): void {
|
||||||
if (this.showKlipyGifPicker()) {
|
if (this.showKlipyGifPicker()) {
|
||||||
@@ -172,7 +167,9 @@ export class ChatMessagesComponent {
|
|||||||
if (!message || !currentUserId)
|
if (!message || !currentUserId)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const hasReacted = message.reactions.some((reaction) => reaction.emoji === event.emoji && reaction.userId === currentUserId);
|
const hasReacted = message.reactions.some(
|
||||||
|
(reaction) => reaction.emoji === event.emoji && reaction.userId === currentUserId
|
||||||
|
);
|
||||||
|
|
||||||
if (hasReacted) {
|
if (hasReacted) {
|
||||||
this.store.dispatch(
|
this.store.dispatch(
|
||||||
@@ -237,7 +234,9 @@ export class ChatMessagesComponent {
|
|||||||
const minRight = 16;
|
const minRight = 16;
|
||||||
const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16);
|
const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16);
|
||||||
|
|
||||||
this.klipyGifPickerAnchorRight.set(Math.min(Math.max(Math.round(preferredRight), minRight), maxRight));
|
this.klipyGifPickerAnchorRight.set(
|
||||||
|
Math.min(Math.max(Math.round(preferredRight), minRight), maxRight)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getKlipyGifPickerWidth(viewportWidth: number): number {
|
private getKlipyGifPickerWidth(viewportWidth: number): number {
|
||||||
@@ -282,7 +281,10 @@ export class ChatMessagesComponent {
|
|||||||
|
|
||||||
if (blob) {
|
if (blob) {
|
||||||
try {
|
try {
|
||||||
const result = await electronApi.saveFileAs(attachment.filename, await this.blobToBase64(blob));
|
const result = await electronApi.saveFileAs(
|
||||||
|
attachment.filename,
|
||||||
|
await this.blobToBase64(blob)
|
||||||
|
);
|
||||||
|
|
||||||
if (result.saved || result.cancelled)
|
if (result.saved || result.cancelled)
|
||||||
return;
|
return;
|
||||||
@@ -405,7 +407,12 @@ export class ChatMessagesComponent {
|
|||||||
|
|
||||||
const message = [...this.channelMessages()]
|
const message = [...this.channelMessages()]
|
||||||
.reverse()
|
.reverse()
|
||||||
.find((entry) => entry.senderId === currentUserId && entry.content === content && !entry.isDeleted);
|
.find(
|
||||||
|
(entry) =>
|
||||||
|
entry.senderId === currentUserId &&
|
||||||
|
entry.content === content &&
|
||||||
|
!entry.isDeleted
|
||||||
|
);
|
||||||
|
|
||||||
if (!message) {
|
if (!message) {
|
||||||
setTimeout(() => this.attachFilesToLastOwnMessage(content, pendingFiles), 150);
|
setTimeout(() => this.attachFilesToLastOwnMessage(content, pendingFiles), 150);
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
<!-- eslint-disable @angular-eslint/template/button-has-type -->
|
<!-- eslint-disable @angular-eslint/template/button-has-type -->
|
||||||
<div
|
<div #composerRoot>
|
||||||
#composerRoot
|
|
||||||
appThemeNode="chatComposerBar"
|
|
||||||
>
|
|
||||||
@if (replyTo()) {
|
@if (replyTo()) {
|
||||||
<div
|
<div class="pointer-events-auto flex items-center gap-2 bg-secondary/50 px-4 py-2">
|
||||||
appThemeNode="chatComposerReplyBar"
|
|
||||||
class="pointer-events-auto flex items-center gap-2 bg-secondary/50 px-4 py-2"
|
|
||||||
>
|
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideReply"
|
name="lucideReply"
|
||||||
class="h-4 w-4 text-muted-foreground"
|
class="h-4 w-4 text-muted-foreground"
|
||||||
@@ -37,7 +31,6 @@
|
|||||||
(mouseleave)="onToolbarMouseLeave()"
|
(mouseleave)="onToolbarMouseLeave()"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
appThemeNode="chatComposerToolbar"
|
|
||||||
class="mx-4 -mb-2 flex flex-wrap items-center justify-start gap-2 rounded-lg border border-border bg-card/70 px-2 py-1 shadow-sm backdrop-blur"
|
class="mx-4 -mb-2 flex flex-wrap items-center justify-start gap-2 rounded-lg border border-border bg-card/70 px-2 py-1 shadow-sm backdrop-blur"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -131,7 +124,6 @@
|
|||||||
|
|
||||||
<div class="border-border p-4">
|
<div class="border-border p-4">
|
||||||
<div
|
<div
|
||||||
appThemeNode="chatComposerInput"
|
|
||||||
class="chat-input-wrapper relative"
|
class="chat-input-wrapper relative"
|
||||||
(mouseenter)="inputHovered.set(true)"
|
(mouseenter)="inputHovered.set(true)"
|
||||||
(mouseleave)="inputHovered.set(false)"
|
(mouseleave)="inputHovered.set(false)"
|
||||||
@@ -141,7 +133,7 @@
|
|||||||
(drop)="onDrop($event)"
|
(drop)="onDrop($event)"
|
||||||
>
|
>
|
||||||
<div class="absolute bottom-3 right-3 z-10 flex items-center gap-2 m-0.5">
|
<div class="absolute bottom-3 right-3 z-10 flex items-center gap-2 m-0.5">
|
||||||
@if (klipyEnabled()) {
|
@if (klipy.isEnabled()) {
|
||||||
<button
|
<button
|
||||||
#klipyTrigger
|
#klipyTrigger
|
||||||
type="button"
|
type="button"
|
||||||
@@ -164,7 +156,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
appThemeNode="chatComposerSendButton"
|
|
||||||
type="button"
|
type="button"
|
||||||
(click)="sendMessage()"
|
(click)="sendMessage()"
|
||||||
[disabled]="!messageContent.trim() && pendingFiles.length === 0 && !pendingKlipyGif()"
|
[disabled]="!messageContent.trim() && pendingFiles.length === 0 && !pendingKlipyGif()"
|
||||||
@@ -181,7 +172,6 @@
|
|||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
#messageInputRef
|
#messageInputRef
|
||||||
[attr.data-testid]="textareaTestId()"
|
|
||||||
rows="1"
|
rows="1"
|
||||||
[(ngModel)]="messageContent"
|
[(ngModel)]="messageContent"
|
||||||
(focus)="onInputFocus()"
|
(focus)="onInputFocus()"
|
||||||
@@ -199,8 +189,8 @@
|
|||||||
[class.border-primary]="dragActive()"
|
[class.border-primary]="dragActive()"
|
||||||
[class.chat-textarea-expanded]="textareaExpanded()"
|
[class.chat-textarea-expanded]="textareaExpanded()"
|
||||||
[class.ctrl-resize]="ctrlHeld()"
|
[class.ctrl-resize]="ctrlHeld()"
|
||||||
[class.pr-16]="!klipyEnabled()"
|
[class.pr-16]="!klipy.isEnabled()"
|
||||||
[class.pr-40]="klipyEnabled()"
|
[class.pr-40]="klipy.isEnabled()"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
@if (dragActive()) {
|
@if (dragActive()) {
|
||||||
@@ -217,7 +207,6 @@
|
|||||||
<div class="relative h-12 w-12 overflow-hidden rounded-lg bg-secondary/80">
|
<div class="relative h-12 w-12 overflow-hidden rounded-lg bg-secondary/80">
|
||||||
<img
|
<img
|
||||||
[appChatImageProxyFallback]="pendingKlipyGif()!.previewUrl || pendingKlipyGif()!.url"
|
[appChatImageProxyFallback]="pendingKlipyGif()!.previewUrl || pendingKlipyGif()!.url"
|
||||||
[signalSource]="klipySignalSource()"
|
|
||||||
[alt]="pendingKlipyGif()!.title || 'KLIPY GIF'"
|
[alt]="pendingKlipyGif()!.title || 'KLIPY GIF'"
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ import type { ClipboardFilePayload } from '../../../../../../core/platform/elect
|
|||||||
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
|
||||||
import { KlipyGif, KlipyService } from '../../../../application/services/klipy.service';
|
import { KlipyGif, KlipyService } from '../../../../application/services/klipy.service';
|
||||||
import { Message } from '../../../../../../shared-kernel';
|
import { Message } from '../../../../../../shared-kernel';
|
||||||
import { ThemeNodeDirective } from '../../../../../theme';
|
|
||||||
import type { RoomSignalSourceInput } from '../../../../../server-directory';
|
|
||||||
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
|
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
|
||||||
import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component';
|
import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component';
|
||||||
import { ChatMarkdownService } from '../../services/chat-markdown.service';
|
import { ChatMarkdownService } from '../../services/chat-markdown.service';
|
||||||
@@ -44,8 +42,7 @@ const DEFAULT_TEXTAREA_HEIGHT = 62;
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
NgIcon,
|
NgIcon,
|
||||||
ChatImageProxyFallbackDirective,
|
ChatImageProxyFallbackDirective,
|
||||||
TypingIndicatorComponent,
|
TypingIndicatorComponent
|
||||||
ThemeNodeDirective
|
|
||||||
],
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
@@ -69,9 +66,6 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
readonly replyTo = input<Message | null>(null);
|
readonly replyTo = input<Message | null>(null);
|
||||||
readonly showKlipyGifPicker = input(false);
|
readonly showKlipyGifPicker = input(false);
|
||||||
readonly klipyEnabled = input(false);
|
|
||||||
readonly klipySignalSource = input<RoomSignalSourceInput | null>(null);
|
|
||||||
readonly textareaTestId = input<string | null>(null);
|
|
||||||
|
|
||||||
readonly messageSubmitted = output<ChatMessageComposerSubmitEvent>();
|
readonly messageSubmitted = output<ChatMessageComposerSubmitEvent>();
|
||||||
readonly typingStarted = output();
|
readonly typingStarted = output();
|
||||||
@@ -79,7 +73,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
|||||||
readonly heightChanged = output<number>();
|
readonly heightChanged = output<number>();
|
||||||
readonly klipyGifPickerToggleRequested = output();
|
readonly klipyGifPickerToggleRequested = output();
|
||||||
|
|
||||||
private readonly klipy = inject(KlipyService);
|
readonly klipy = inject(KlipyService);
|
||||||
private readonly markdown = inject(ChatMarkdownService);
|
private readonly markdown = inject(ChatMarkdownService);
|
||||||
private readonly electronBridge = inject(ElectronBridgeService);
|
private readonly electronBridge = inject(ElectronBridgeService);
|
||||||
|
|
||||||
@@ -213,7 +207,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggleKlipyGifPicker(): void {
|
toggleKlipyGifPicker(): void {
|
||||||
if (!this.klipyEnabled())
|
if (!this.klipy.isEnabled())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
this.klipyGifPickerToggleRequested.emit();
|
this.klipyGifPickerToggleRequested.emit();
|
||||||
@@ -417,7 +411,11 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
|||||||
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
|
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
|
||||||
}
|
}
|
||||||
|
|
||||||
private hasPotentialFilePayload(dataTransfer: DataTransfer | null, treatMissingTypesAsPotentialFile = true): boolean {
|
private hasPotentialFilePayload(
|
||||||
|
dataTransfer: DataTransfer | null,
|
||||||
|
treatMissingTypesAsPotentialFile = true
|
||||||
|
): boolean {
|
||||||
|
|
||||||
if (!dataTransfer)
|
if (!dataTransfer)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,11 @@
|
|||||||
@let msg = message();
|
@let msg = message();
|
||||||
@let attachmentsList = attachmentViewModels();
|
@let attachmentsList = attachmentViewModels();
|
||||||
<div
|
<div
|
||||||
appThemeNode="chatMessageBubble"
|
|
||||||
[attr.data-message-id]="msg.id"
|
[attr.data-message-id]="msg.id"
|
||||||
class="group relative flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30"
|
class="group relative flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30"
|
||||||
[class.opacity-50]="msg.isDeleted"
|
[class.opacity-50]="msg.isDeleted"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
appThemeNode="chatMessageAvatar"
|
|
||||||
class="flex-shrink-0 cursor-pointer"
|
class="flex-shrink-0 cursor-pointer"
|
||||||
(click)="openSenderProfileCard($event); $event.stopPropagation()"
|
(click)="openSenderProfileCard($event); $event.stopPropagation()"
|
||||||
>
|
>
|
||||||
@@ -19,10 +17,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div class="min-w-0 flex-1">
|
||||||
appThemeNode="chatMessageContent"
|
|
||||||
class="min-w-0 flex-1"
|
|
||||||
>
|
|
||||||
@if (msg.replyToId) {
|
@if (msg.replyToId) {
|
||||||
@let reply = repliedMessage();
|
@let reply = repliedMessage();
|
||||||
<div
|
<div
|
||||||
@@ -155,10 +150,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else if ((att.receivedBytes || 0) > 0) {
|
} @else if ((att.receivedBytes || 0) > 0) {
|
||||||
<div
|
<div class="max-w-xs rounded-md border border-border bg-secondary/40 p-3">
|
||||||
appThemeNode="chatAttachmentCard"
|
|
||||||
class="max-w-xs rounded-md border border-border bg-secondary/40 p-3"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-primary/10">
|
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-primary/10">
|
||||||
<ng-icon
|
<ng-icon
|
||||||
@@ -180,10 +172,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div
|
<div class="max-w-xs rounded-md border border-dashed border-border bg-secondary/20 p-4">
|
||||||
appThemeNode="chatAttachmentCard"
|
|
||||||
class="max-w-xs rounded-md border border-dashed border-border bg-secondary/20 p-4"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-muted">
|
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-muted">
|
||||||
<ng-icon
|
<ng-icon
|
||||||
@@ -231,10 +220,7 @@
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
} @else if ((att.receivedBytes || 0) > 0) {
|
} @else if ((att.receivedBytes || 0) > 0) {
|
||||||
<div
|
<div class="max-w-xl rounded-md border border-border bg-secondary/40 p-3">
|
||||||
appThemeNode="chatAttachmentCard"
|
|
||||||
class="max-w-xl rounded-md border border-border bg-secondary/40 p-3"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
|
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
|
||||||
@@ -261,10 +247,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div
|
<div class="max-w-xl rounded-md border border-dashed border-border bg-secondary/20 p-4">
|
||||||
appThemeNode="chatAttachmentCard"
|
|
||||||
class="max-w-xl rounded-md border border-dashed border-border bg-secondary/20 p-4"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="truncate text-sm font-medium text-foreground">{{ att.filename }}</div>
|
<div class="truncate text-sm font-medium text-foreground">{{ att.filename }}</div>
|
||||||
@@ -288,10 +271,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
<div
|
<div class="rounded-md border border-border bg-secondary/40 p-2">
|
||||||
appThemeNode="chatAttachmentCard"
|
|
||||||
class="rounded-md border border-border bg-secondary/40 p-2"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
|
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
|
||||||
@@ -359,7 +339,6 @@
|
|||||||
<div class="mt-2 flex flex-wrap gap-1">
|
<div class="mt-2 flex flex-wrap gap-1">
|
||||||
@for (reaction of getGroupedReactions(); track reaction.emoji) {
|
@for (reaction of getGroupedReactions(); track reaction.emoji) {
|
||||||
<button
|
<button
|
||||||
appThemeNode="chatReactionPill"
|
|
||||||
(click)="toggleReaction(reaction.emoji)"
|
(click)="toggleReaction(reaction.emoji)"
|
||||||
class="flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-xs transition-colors hover:bg-secondary/80"
|
class="flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-xs transition-colors hover:bg-secondary/80"
|
||||||
[class.ring-1]="reaction.hasCurrentUser"
|
[class.ring-1]="reaction.hasCurrentUser"
|
||||||
@@ -375,7 +354,6 @@
|
|||||||
|
|
||||||
@if (!msg.isDeleted) {
|
@if (!msg.isDeleted) {
|
||||||
<div
|
<div
|
||||||
appThemeNode="chatMessageActions"
|
|
||||||
class="absolute right-2 top-2 flex items-center gap-1 rounded-lg border border-border bg-card shadow-lg opacity-0 transition-opacity group-hover:opacity-100"
|
class="absolute right-2 top-2 flex items-center gap-1 rounded-lg border border-border bg-card shadow-lg opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ import {
|
|||||||
Message,
|
Message,
|
||||||
User
|
User
|
||||||
} from '../../../../../../shared-kernel';
|
} from '../../../../../../shared-kernel';
|
||||||
import { ThemeNodeDirective } from '../../../../../theme';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ChatAudioPlayerComponent,
|
ChatAudioPlayerComponent,
|
||||||
@@ -97,8 +96,7 @@ interface ChatMessageAttachmentViewModel extends Attachment {
|
|||||||
ChatVideoPlayerComponent,
|
ChatVideoPlayerComponent,
|
||||||
ChatMessageMarkdownComponent,
|
ChatMessageMarkdownComponent,
|
||||||
ChatLinkEmbedComponent,
|
ChatLinkEmbedComponent,
|
||||||
UserAvatarComponent,
|
UserAvatarComponent
|
||||||
ThemeNodeDirective
|
|
||||||
],
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
@@ -152,17 +150,15 @@ export class ChatMessageItemComponent {
|
|||||||
const msg = this.message();
|
const msg = this.message();
|
||||||
const found = this.userLookup().get(msg.senderId);
|
const found = this.userLookup().get(msg.senderId);
|
||||||
|
|
||||||
return (
|
return found ?? {
|
||||||
found ?? {
|
id: msg.senderId,
|
||||||
id: msg.senderId,
|
oderId: msg.senderId,
|
||||||
oderId: msg.senderId,
|
username: msg.senderName,
|
||||||
username: msg.senderName,
|
displayName: msg.senderName,
|
||||||
displayName: msg.senderName,
|
status: 'disconnected',
|
||||||
status: 'disconnected',
|
role: 'member',
|
||||||
role: 'member',
|
joinedAt: 0
|
||||||
joinedAt: 0
|
};
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
editContent = '';
|
editContent = '';
|
||||||
@@ -179,7 +175,9 @@ export class ChatMessageItemComponent {
|
|||||||
readonly attachmentViewModels = computed<ChatMessageAttachmentViewModel[]>(() => {
|
readonly attachmentViewModels = computed<ChatMessageAttachmentViewModel[]>(() => {
|
||||||
void this.attachmentVersion();
|
void this.attachmentVersion();
|
||||||
|
|
||||||
return this.attachmentsSvc.getForMessage(this.message().id).map((attachment) => this.buildAttachmentViewModel(attachment));
|
return this.attachmentsSvc.getForMessage(this.message().id).map((attachment) =>
|
||||||
|
this.buildAttachmentViewModel(attachment)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
private readonly syncAttachmentVersion = effect(() => {
|
private readonly syncAttachmentVersion = effect(() => {
|
||||||
const version = this.attachmentsSvc.updated();
|
const version = this.attachmentsSvc.updated();
|
||||||
@@ -322,7 +320,8 @@ export class ChatMessageItemComponent {
|
|||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
});
|
});
|
||||||
const toDay = (value: Date) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime();
|
const toDay = (value: Date) =>
|
||||||
|
new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime();
|
||||||
const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24));
|
const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
if (dayDiff === 0)
|
if (dayDiff === 0)
|
||||||
@@ -332,7 +331,11 @@ export class ChatMessageItemComponent {
|
|||||||
return 'Yesterday ' + time;
|
return 'Yesterday ' + time;
|
||||||
|
|
||||||
if (dayDiff < 7) {
|
if (dayDiff < 7) {
|
||||||
return date.toLocaleDateString([], { weekday: 'short' }) + ' ' + time;
|
return (
|
||||||
|
date.toLocaleDateString([], { weekday: 'short' }) +
|
||||||
|
' ' +
|
||||||
|
time
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -399,7 +402,10 @@ export class ChatMessageItemComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
requiresMediaDownloadAcceptance(attachment: Attachment): boolean {
|
requiresMediaDownloadAcceptance(attachment: Attachment): boolean {
|
||||||
return (this.isVideoAttachment(attachment) || this.isAudioAttachment(attachment)) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
|
return (
|
||||||
|
(this.isVideoAttachment(attachment) || this.isAudioAttachment(attachment)) &&
|
||||||
|
attachment.size > MAX_AUTO_SAVE_SIZE_BYTES
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getMediaAttachmentStatusText(attachment: Attachment): string {
|
getMediaAttachmentStatusText(attachment: Attachment): string {
|
||||||
@@ -412,7 +418,9 @@ export class ChatMessageItemComponent {
|
|||||||
: 'Large audio file. Accept the download to play it in chat.';
|
: 'Large audio file. Accept the download to play it in chat.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.isVideoAttachment(attachment) ? 'Waiting for video source...' : 'Waiting for audio source...';
|
return this.isVideoAttachment(attachment)
|
||||||
|
? 'Waiting for video source...'
|
||||||
|
: 'Waiting for audio source...';
|
||||||
}
|
}
|
||||||
|
|
||||||
getMediaAttachmentActionLabel(attachment: Attachment): string {
|
getMediaAttachmentActionLabel(attachment: Attachment): string {
|
||||||
@@ -476,7 +484,8 @@ export class ChatMessageItemComponent {
|
|||||||
private buildAttachmentViewModel(attachment: Attachment): ChatMessageAttachmentViewModel {
|
private buildAttachmentViewModel(attachment: Attachment): ChatMessageAttachmentViewModel {
|
||||||
const isVideo = this.isVideoAttachment(attachment);
|
const isVideo = this.isVideoAttachment(attachment);
|
||||||
const isAudio = this.isAudioAttachment(attachment);
|
const isAudio = this.isAudioAttachment(attachment);
|
||||||
const requiresMediaDownloadAcceptance = (isVideo || isAudio) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
|
const requiresMediaDownloadAcceptance =
|
||||||
|
(isVideo || isAudio) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...attachment,
|
...attachment,
|
||||||
@@ -484,12 +493,8 @@ export class ChatMessageItemComponent {
|
|||||||
isUploader: this.isUploader(attachment),
|
isUploader: this.isUploader(attachment),
|
||||||
isVideo,
|
isVideo,
|
||||||
mediaActionLabel: requiresMediaDownloadAcceptance
|
mediaActionLabel: requiresMediaDownloadAcceptance
|
||||||
? attachment.requestError
|
? attachment.requestError ? 'Retry download' : 'Accept download'
|
||||||
? 'Retry download'
|
: attachment.requestError ? 'Retry' : 'Request',
|
||||||
: 'Accept download'
|
|
||||||
: attachment.requestError
|
|
||||||
? 'Retry'
|
|
||||||
: 'Request',
|
|
||||||
mediaStatusText: attachment.requestError
|
mediaStatusText: attachment.requestError
|
||||||
? attachment.requestError
|
? attachment.requestError
|
||||||
: requiresMediaDownloadAcceptance
|
: requiresMediaDownloadAcceptance
|
||||||
@@ -499,11 +504,15 @@ export class ChatMessageItemComponent {
|
|||||||
: isVideo
|
: isVideo
|
||||||
? 'Waiting for video source...'
|
? 'Waiting for video source...'
|
||||||
: 'Waiting for audio source...',
|
: 'Waiting for audio source...',
|
||||||
progressPercent: attachment.size > 0 ? ((attachment.receivedBytes || 0) * 100) / attachment.size : 0
|
progressPercent: attachment.size > 0
|
||||||
|
? ((attachment.receivedBytes || 0) * 100) / attachment.size
|
||||||
|
: 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getLiveAttachment(attachmentId: string): Attachment | undefined {
|
private getLiveAttachment(attachmentId: string): Attachment | undefined {
|
||||||
return this.attachmentsSvc.getForMessage(this.message().id).find((attachment) => attachment.id === attachmentId);
|
return this.attachmentsSvc
|
||||||
|
.getForMessage(this.message().id)
|
||||||
|
.find((attachment) => attachment.id === attachmentId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<div
|
<div
|
||||||
#messagesContainer
|
#messagesContainer
|
||||||
appThemeNode="chatMessageList"
|
|
||||||
class="absolute inset-0 space-y-4 overflow-y-auto p-4"
|
class="absolute inset-0 space-y-4 overflow-y-auto p-4"
|
||||||
[style.padding-bottom.px]="bottomPadding()"
|
[style.padding-bottom.px]="bottomPadding()"
|
||||||
(scroll)="onScroll()"
|
(scroll)="onScroll()"
|
||||||
@@ -40,10 +39,7 @@
|
|||||||
|
|
||||||
@for (message of messages(); track message.id; let index = $index) {
|
@for (message of messages(); track message.id; let index = $index) {
|
||||||
@if (dateSeparatorLabels().get(index); as separatorLabel) {
|
@if (dateSeparatorLabels().get(index); as separatorLabel) {
|
||||||
<div
|
<div class="flex items-center gap-3 py-1">
|
||||||
appThemeNode="chatDateSeparator"
|
|
||||||
class="flex items-center gap-3 py-1"
|
|
||||||
>
|
|
||||||
<div class="h-px flex-1 bg-border"></div>
|
<div class="h-px flex-1 bg-border"></div>
|
||||||
<span class="rounded-full border border-border bg-background/90 px-3 py-1 text-xs font-medium text-muted-foreground shadow-sm">
|
<span class="rounded-full border border-border bg-background/90 px-3 py-1 text-xs font-medium text-muted-foreground shadow-sm">
|
||||||
{{ separatorLabel }}
|
{{ separatorLabel }}
|
||||||
@@ -74,10 +70,7 @@
|
|||||||
|
|
||||||
@if (showNewMessagesBar()) {
|
@if (showNewMessagesBar()) {
|
||||||
<div class="pointer-events-none sticky bottom-4 flex justify-center">
|
<div class="pointer-events-none sticky bottom-4 flex justify-center">
|
||||||
<div
|
<div class="pointer-events-auto flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2 shadow">
|
||||||
appThemeNode="chatNewMessagesBar"
|
|
||||||
class="pointer-events-auto flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2 shadow"
|
|
||||||
>
|
|
||||||
<span class="text-sm text-muted-foreground">New messages</span>
|
<span class="text-sm text-muted-foreground">New messages</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
ChatMessageReplyEvent
|
ChatMessageReplyEvent
|
||||||
} from '../../models/chat-messages.model';
|
} from '../../models/chat-messages.model';
|
||||||
import { selectAllUsers } from '../../../../../../store/users/users.selectors';
|
import { selectAllUsers } from '../../../../../../store/users/users.selectors';
|
||||||
import { ThemeNodeDirective } from '../../../../../theme';
|
|
||||||
import { ChatMessageItemComponent } from '../message-item/chat-message-item.component';
|
import { ChatMessageItemComponent } from '../message-item/chat-message-item.component';
|
||||||
|
|
||||||
interface PrismGlobal {
|
interface PrismGlobal {
|
||||||
@@ -42,11 +41,7 @@ declare global {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-chat-message-list',
|
selector: 'app-chat-message-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [CommonModule, ChatMessageItemComponent],
|
||||||
CommonModule,
|
|
||||||
ChatMessageItemComponent,
|
|
||||||
ThemeNodeDirective
|
|
||||||
],
|
|
||||||
templateUrl: './chat-message-list.component.html',
|
templateUrl: './chat-message-list.component.html',
|
||||||
host: {
|
host: {
|
||||||
style: 'display: contents;'
|
style: 'display: contents;'
|
||||||
@@ -71,7 +66,6 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
readonly isAdmin = input(false);
|
readonly isAdmin = input(false);
|
||||||
readonly bottomPadding = input(120);
|
readonly bottomPadding = input(120);
|
||||||
readonly conversationKey = input.required<string>();
|
readonly conversationKey = input.required<string>();
|
||||||
readonly userLookupOverrides = input<User[]>([]);
|
|
||||||
|
|
||||||
readonly replyRequested = output<ChatMessageReplyEvent>();
|
readonly replyRequested = output<ChatMessageReplyEvent>();
|
||||||
readonly deleteRequested = output<ChatMessageDeleteEvent>();
|
readonly deleteRequested = output<ChatMessageDeleteEvent>();
|
||||||
@@ -99,7 +93,9 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
return all.slice(all.length - limit);
|
return all.slice(all.length - limit);
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly hasMoreMessages = computed(() => this.channelMessages().length > this.displayLimit());
|
readonly hasMoreMessages = computed(
|
||||||
|
() => this.channelMessages().length > this.displayLimit()
|
||||||
|
);
|
||||||
|
|
||||||
readonly dateSeparatorLabels = computed(() => {
|
readonly dateSeparatorLabels = computed(() => {
|
||||||
const labels = new Map<number, string>();
|
const labels = new Map<number, string>();
|
||||||
@@ -130,14 +126,6 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const user of this.userLookupOverrides()) {
|
|
||||||
lookup.set(user.id, user);
|
|
||||||
|
|
||||||
if (user.oderId && user.oderId !== user.id) {
|
|
||||||
lookup.set(user.oderId, user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lookup;
|
return lookup;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -168,7 +156,8 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
|
const distanceFromBottom =
|
||||||
|
element.scrollHeight - element.scrollTop - element.clientHeight;
|
||||||
const newMessages = currentCount > this.lastMessageCount;
|
const newMessages = currentCount > this.lastMessageCount;
|
||||||
|
|
||||||
if (newMessages) {
|
if (newMessages) {
|
||||||
@@ -230,7 +219,8 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
if (!element || this.isAutoScrolling)
|
if (!element || this.isAutoScrolling)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
|
const distanceFromBottom =
|
||||||
|
element.scrollHeight - element.scrollTop - element.clientHeight;
|
||||||
const shouldStickToBottom = distanceFromBottom <= 300;
|
const shouldStickToBottom = distanceFromBottom <= 300;
|
||||||
|
|
||||||
if (shouldStickToBottom) {
|
if (shouldStickToBottom) {
|
||||||
@@ -387,7 +377,11 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.boundOnImageLoad && this.messagesContainer) {
|
if (this.boundOnImageLoad && this.messagesContainer) {
|
||||||
this.messagesContainer.nativeElement.removeEventListener('load', this.boundOnImageLoad, true);
|
this.messagesContainer.nativeElement.removeEventListener(
|
||||||
|
'load',
|
||||||
|
this.boundOnImageLoad,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
this.boundOnImageLoad = null;
|
this.boundOnImageLoad = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,6 @@
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
[appChatImageProxyFallback]="gif.previewUrl || gif.url"
|
[appChatImageProxyFallback]="gif.previewUrl || gif.url"
|
||||||
[signalSource]="signalSource()"
|
|
||||||
[alt]="gif.title || 'KLIPY GIF'"
|
[alt]="gif.title || 'KLIPY GIF'"
|
||||||
class="h-full w-full object-contain p-1.5 transition-transform duration-200 group-hover:scale-[1.03]"
|
class="h-full w-full object-contain p-1.5 transition-transform duration-200 group-hover:scale-[1.03]"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
OnInit,
|
OnInit,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
inject,
|
inject,
|
||||||
input,
|
|
||||||
output,
|
output,
|
||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
@@ -22,7 +21,6 @@ import {
|
|||||||
lucideX
|
lucideX
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
|
import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
|
||||||
import type { RoomSignalSourceInput } from '../../../server-directory';
|
|
||||||
import { ChatImageProxyFallbackDirective } from '../chat-image-proxy-fallback.directive';
|
import { ChatImageProxyFallbackDirective } from '../chat-image-proxy-fallback.directive';
|
||||||
|
|
||||||
const KLIPY_CARD_MIN_WIDTH = 140;
|
const KLIPY_CARD_MIN_WIDTH = 140;
|
||||||
@@ -50,8 +48,6 @@ const KLIPY_CARD_FALLBACK_SIZE = 160;
|
|||||||
templateUrl: './klipy-gif-picker.component.html'
|
templateUrl: './klipy-gif-picker.component.html'
|
||||||
})
|
})
|
||||||
export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy {
|
export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
readonly signalSource = input<RoomSignalSourceInput | null>(null);
|
|
||||||
|
|
||||||
readonly gifSelected = output<KlipyGif>();
|
readonly gifSelected = output<KlipyGif>();
|
||||||
readonly closed = output<undefined>();
|
readonly closed = output<undefined>();
|
||||||
|
|
||||||
@@ -132,7 +128,7 @@ export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await firstValueFrom(
|
const response = await firstValueFrom(
|
||||||
this.klipy.searchGifs(this.searchQuery, this.currentPage, undefined, this.signalSource())
|
this.klipy.searchGifs(this.searchQuery, this.currentPage)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (requestId !== this.requestId)
|
if (requestId !== this.requestId)
|
||||||
|
|||||||
@@ -19,10 +19,11 @@
|
|||||||
@for (user of onlineUsers(); track user.id) {
|
@for (user of onlineUsers(); track user.id) {
|
||||||
<div
|
<div
|
||||||
class="group relative flex items-center gap-3 p-2 rounded-lg hover:bg-secondary/50 transition-colors cursor-pointer"
|
class="group relative flex items-center gap-3 p-2 rounded-lg hover:bg-secondary/50 transition-colors cursor-pointer"
|
||||||
[attr.data-testid]="'user-card-' + (user.oderId || user.id)"
|
(click)="toggleUserMenu(user.id)"
|
||||||
(click)="openDirectMessage(user)"
|
(keydown.enter)="toggleUserMenu(user.id)"
|
||||||
(keydown.enter)="openDirectMessage(user)"
|
(keydown.space)="toggleUserMenu(user.id)"
|
||||||
(keydown.space)="openDirectMessage(user)"
|
(keyup.enter)="toggleUserMenu(user.id)"
|
||||||
|
(keyup.space)="toggleUserMenu(user.id)"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
@@ -69,19 +70,6 @@
|
|||||||
|
|
||||||
<!-- Voice/Screen Status -->
|
<!-- Voice/Screen Status -->
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="grid h-7 w-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-card hover:text-foreground"
|
|
||||||
[class.hidden]="isCurrentUser(user)"
|
|
||||||
title="Message"
|
|
||||||
(click)="$event.stopPropagation(); openDirectMessage(user)"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideMessageCircle"
|
|
||||||
class="w-4 h-4"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
@if (user.voiceState?.isSpeaking) {
|
@if (user.voiceState?.isSpeaking) {
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideMic"
|
name="lucideMic"
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { Router } from '@angular/router';
|
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import {
|
import {
|
||||||
lucideMic,
|
lucideMic,
|
||||||
@@ -20,8 +19,7 @@ import {
|
|||||||
lucideBan,
|
lucideBan,
|
||||||
lucideUserX,
|
lucideUserX,
|
||||||
lucideVolume2,
|
lucideVolume2,
|
||||||
lucideVolumeX,
|
lucideVolumeX
|
||||||
lucideMessageCircle
|
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { UsersActions } from '../../../../store/users/users.actions';
|
import { UsersActions } from '../../../../store/users/users.actions';
|
||||||
@@ -32,7 +30,6 @@ import {
|
|||||||
} from '../../../../store/users/users.selectors';
|
} from '../../../../store/users/users.selectors';
|
||||||
import { User } from '../../../../shared-kernel';
|
import { User } from '../../../../shared-kernel';
|
||||||
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../../shared';
|
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../../shared';
|
||||||
import { DirectMessageService } from '../../../direct-message';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-user-list',
|
selector: 'app-user-list',
|
||||||
@@ -55,8 +52,7 @@ import { DirectMessageService } from '../../../direct-message';
|
|||||||
lucideBan,
|
lucideBan,
|
||||||
lucideUserX,
|
lucideUserX,
|
||||||
lucideVolume2,
|
lucideVolume2,
|
||||||
lucideVolumeX,
|
lucideVolumeX
|
||||||
lucideMessageCircle
|
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
templateUrl: './user-list.component.html'
|
templateUrl: './user-list.component.html'
|
||||||
@@ -66,8 +62,6 @@ import { DirectMessageService } from '../../../direct-message';
|
|||||||
*/
|
*/
|
||||||
export class UserListComponent {
|
export class UserListComponent {
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private router = inject(Router);
|
|
||||||
private directMessages = inject(DirectMessageService);
|
|
||||||
|
|
||||||
onlineUsers = this.store.selectSignal(selectOnlineUsers) as import('@angular/core').Signal<User[]>;
|
onlineUsers = this.store.selectSignal(selectOnlineUsers) as import('@angular/core').Signal<User[]>;
|
||||||
voiceUsers = computed(() => this.onlineUsers().filter((user: User) => !!user.voiceState?.isConnected));
|
voiceUsers = computed(() => this.onlineUsers().filter((user: User) => !!user.voiceState?.isConnected));
|
||||||
@@ -90,16 +84,6 @@ export class UserListComponent {
|
|||||||
return user.id === this.currentUser()?.id;
|
return user.id === this.currentUser()?.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
async openDirectMessage(user: User): Promise<void> {
|
|
||||||
if (this.isCurrentUser(user)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const conversation = await this.directMessages.createConversation(user);
|
|
||||||
|
|
||||||
await this.router.navigate(['/dm', conversation.id]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Toggle server-side mute on a user (admin action). */
|
/** Toggle server-side mute on a user (admin action). */
|
||||||
muteUser(user: User): void {
|
muteUser(user: User): void {
|
||||||
if (user.voiceState?.isMutedByAdmin) {
|
if (user.voiceState?.isMutedByAdmin) {
|
||||||
|
|||||||
@@ -1,23 +1,8 @@
|
|||||||
export * from './application/services/klipy.service';
|
export * from './application/services/klipy.service';
|
||||||
export * from './application/services/link-metadata.service';
|
export * from './application/services/link-metadata.service';
|
||||||
export * from './domain/rules/link-embed.rules';
|
|
||||||
export * from './domain/rules/message.rules';
|
export * from './domain/rules/message.rules';
|
||||||
export * from './domain/rules/message-sync.rules';
|
export * from './domain/rules/message-sync.rules';
|
||||||
export { ChatMarkdownService } from './feature/chat-messages/services/chat-markdown.service';
|
|
||||||
export { ChatMessagesComponent } from './feature/chat-messages/chat-messages.component';
|
export { ChatMessagesComponent } from './feature/chat-messages/chat-messages.component';
|
||||||
export type { ChatMessageEmbedRemoveEvent } from './feature/chat-messages/models/chat-messages.model';
|
|
||||||
export type {
|
|
||||||
ChatMessageComposerSubmitEvent,
|
|
||||||
ChatMessageDeleteEvent,
|
|
||||||
ChatMessageEditEvent,
|
|
||||||
ChatMessageImageContextMenuEvent,
|
|
||||||
ChatMessageReactionEvent,
|
|
||||||
ChatMessageReplyEvent
|
|
||||||
} from './feature/chat-messages/models/chat-messages.model';
|
|
||||||
export { ChatMessageComposerComponent } from './feature/chat-messages/components/message-composer/chat-message-composer.component';
|
|
||||||
export { ChatMessageListComponent } from './feature/chat-messages/components/message-list/chat-message-list.component';
|
|
||||||
export { ChatMessageOverlaysComponent } from './feature/chat-messages/components/message-overlays/chat-message-overlays.component';
|
|
||||||
export { TypingIndicatorComponent } from './feature/typing-indicator/typing-indicator.component';
|
export { TypingIndicatorComponent } from './feature/typing-indicator/typing-indicator.component';
|
||||||
export { KlipyGifPickerComponent } from './feature/klipy-gif-picker/klipy-gif-picker.component';
|
export { KlipyGifPickerComponent } from './feature/klipy-gif-picker/klipy-gif-picker.component';
|
||||||
export { ChatMessageMarkdownComponent } from './feature/chat-messages/components/message-item/chat-message-markdown/chat-message-markdown.component';
|
|
||||||
export { UserListComponent } from './feature/user-list/user-list.component';
|
export { UserListComponent } from './feature/user-list/user-list.component';
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
# Direct Message Domain
|
|
||||||
|
|
||||||
Direct messages provide local, offline-safe one-to-one messaging over the existing WebRTC data channel.
|
|
||||||
|
|
||||||
## Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
direct-message/
|
|
||||||
├── application/services/ DirectMessageService, OfflineMessageQueueService, FriendService, PeerDeliveryService
|
|
||||||
├── domain/ Direct message models and status-transition rules
|
|
||||||
├── infrastructure/ User-scoped local repositories
|
|
||||||
└── feature/ DM rail, chat view, message rows, user search, friend button
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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 the peer is connected, the sender advances to `SENT`; otherwise the message id remains in `OfflineMessageQueueService`.
|
|
||||||
4. The recipient persists the message as `DELIVERED` and sends a `direct-message-status` event back.
|
|
||||||
5. Opening the conversation marks incoming messages as `ACKNOWLEDGED` and emits a status event.
|
|
||||||
|
|
||||||
Status transitions are monotonic, so a stale `SENT` event cannot overwrite `DELIVERED` or `ACKNOWLEDGED`.
|
|
||||||
|
|
||||||
## Chat View
|
|
||||||
|
|
||||||
The DM view reuses the chat domain's shared message list, composer, overlays, markdown renderer, link embeds, media players, and attachment controls. Direct-message records are mapped into the shared `Message` shape at the feature boundary so PMs keep the same date separators, replies, editing, deletion, reactions, image lightbox, audio playback, and video playback as server text channels.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Avatars
|
|
||||||
|
|
||||||
Conversation participants keep avatar/profile metadata captured from user cards or room membership. When a PM is opened and the peer avatar is missing, the view asks the peer for the existing profile-avatar sync payload so downloaded user icons can be filled in without adding a DM-specific avatar transport.
|
|
||||||
|
|
||||||
## Persistence
|
|
||||||
|
|
||||||
Repositories are user-scoped and stored locally under `metoyou_direct_message_*` keys. The storage is intentionally domain-owned so browser and Electron runtimes share the same renderer API without changing the existing chat-message database tables.
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import {
|
|
||||||
advanceDirectMessageStatus,
|
|
||||||
createDirectConversation,
|
|
||||||
getDirectConversationId,
|
|
||||||
updateMessageStatusInConversation,
|
|
||||||
upsertDirectMessage
|
|
||||||
} from '../../domain/logic/direct-message.logic';
|
|
||||||
import type {
|
|
||||||
DirectMessage,
|
|
||||||
DirectMessageParticipant
|
|
||||||
} from '../../domain/models/direct-message.model';
|
|
||||||
|
|
||||||
const alice: DirectMessageParticipant = {
|
|
||||||
userId: 'alice',
|
|
||||||
username: 'alice',
|
|
||||||
displayName: 'Alice'
|
|
||||||
};
|
|
||||||
|
|
||||||
const bob: DirectMessageParticipant = {
|
|
||||||
userId: 'bob',
|
|
||||||
username: 'bob',
|
|
||||||
displayName: 'Bob'
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('DirectMessageService domain flow', () => {
|
|
||||||
it('should create conversation', () => {
|
|
||||||
const conversation = createDirectConversation(alice, bob, 10);
|
|
||||||
|
|
||||||
expect(conversation.id).toBe(getDirectConversationId('alice', 'bob'));
|
|
||||||
expect(conversation.participants).toEqual(['alice', 'bob']);
|
|
||||||
expect(conversation.unreadCount).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should send message', () => {
|
|
||||||
const conversation = createDirectConversation(alice, bob, 10);
|
|
||||||
const queuedMessage = createMessage('message-1', 'QUEUED');
|
|
||||||
const withQueuedMessage = upsertDirectMessage(conversation, queuedMessage, false);
|
|
||||||
const withSentMessage = updateMessageStatusInConversation(withQueuedMessage, queuedMessage.id, 'SENT');
|
|
||||||
|
|
||||||
expect(withSentMessage.messages[0].status).toBe('SENT');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should queue message when offline', () => {
|
|
||||||
const conversation = createDirectConversation(alice, bob, 10);
|
|
||||||
const queuedMessage = createMessage('message-1', 'QUEUED');
|
|
||||||
const updatedConversation = upsertDirectMessage(conversation, queuedMessage, false);
|
|
||||||
|
|
||||||
expect(updatedConversation.messages[0].status).toBe('QUEUED');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update status correctly', () => {
|
|
||||||
expect(advanceDirectMessageStatus('QUEUED', 'SENT')).toBe('SENT');
|
|
||||||
expect(advanceDirectMessageStatus('SENT', 'DELIVERED')).toBe('DELIVERED');
|
|
||||||
expect(advanceDirectMessageStatus('DELIVERED', 'SENT')).toBe('DELIVERED');
|
|
||||||
expect(advanceDirectMessageStatus('DELIVERED', 'ACKNOWLEDGED')).toBe('ACKNOWLEDGED');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function createMessage(id: string, status: DirectMessage['status']): DirectMessage {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
conversationId: getDirectConversationId('alice', 'bob'),
|
|
||||||
senderId: 'alice',
|
|
||||||
recipientId: 'bob',
|
|
||||||
content: 'Hello',
|
|
||||||
timestamp: 20,
|
|
||||||
status
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,566 +0,0 @@
|
|||||||
/* 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 { 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 {
|
|
||||||
advanceDirectMessageStatus,
|
|
||||||
createDirectConversation,
|
|
||||||
getDirectConversationId,
|
|
||||||
updateMessageStatusInConversation,
|
|
||||||
upsertDirectMessage
|
|
||||||
} from '../../domain/logic/direct-message.logic';
|
|
||||||
import {
|
|
||||||
DirectMessage,
|
|
||||||
DirectMessageConversation,
|
|
||||||
DirectMessageEventPayload,
|
|
||||||
DirectMessageMutationEventPayload,
|
|
||||||
DirectMessageStatus,
|
|
||||||
DirectMessageStatusEventPayload,
|
|
||||||
toDirectMessageParticipant
|
|
||||||
} from '../../domain/models/direct-message.model';
|
|
||||||
import type {
|
|
||||||
ChatEvent,
|
|
||||||
Reaction,
|
|
||||||
User
|
|
||||||
} from '../../../../shared-kernel';
|
|
||||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class DirectMessageService {
|
|
||||||
private readonly repository = inject(DirectMessageRepository);
|
|
||||||
private readonly offlineQueue = inject(OfflineMessageQueueService);
|
|
||||||
private readonly delivery = inject(PeerDeliveryService);
|
|
||||||
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 loadedOwnerId: string | null = null;
|
|
||||||
|
|
||||||
readonly conversations = computed(() => [...this.conversationsSignal()].sort(
|
|
||||||
(firstConversation, secondConversation) => secondConversation.lastMessageAt - firstConversation.lastMessageAt
|
|
||||||
));
|
|
||||||
readonly selectedConversationId = this.selectedConversationIdSignal.asReadonly();
|
|
||||||
readonly selectedConversation = computed(() => {
|
|
||||||
const selectedId = this.selectedConversationIdSignal();
|
|
||||||
|
|
||||||
return selectedId
|
|
||||||
? this.conversationsSignal().find((conversation) => conversation.id === selectedId) ?? null
|
|
||||||
: null;
|
|
||||||
});
|
|
||||||
readonly totalUnreadCount = computed(() => this.conversationsSignal().reduce(
|
|
||||||
(total, conversation) => total + conversation.unreadCount,
|
|
||||||
0
|
|
||||||
));
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
effect(() => {
|
|
||||||
const ownerId = this.getCurrentUserId();
|
|
||||||
|
|
||||||
void this.loadForOwner(ownerId);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.delivery.directMessageEvents$.subscribe((event) => {
|
|
||||||
void this.handlePeerEvent(event);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.delivery.peerConnected$.subscribe(() => {
|
|
||||||
void this.retryPending();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.delivery.networkRestored$.subscribe(() => {
|
|
||||||
void this.retryPending();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async createConversation(user: User): Promise<DirectMessageConversation> {
|
|
||||||
const currentUser = this.requireCurrentUser();
|
|
||||||
const ownerId = this.getCurrentUserIdOrThrow();
|
|
||||||
|
|
||||||
await this.loadForOwner(ownerId);
|
|
||||||
|
|
||||||
const currentParticipant = toDirectMessageParticipant(currentUser);
|
|
||||||
const peerParticipant = toDirectMessageParticipant(user);
|
|
||||||
const conversationId = getDirectConversationId(currentParticipant.userId, peerParticipant.userId);
|
|
||||||
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId);
|
|
||||||
|
|
||||||
if (existingConversation) {
|
|
||||||
this.selectedConversationIdSignal.set(existingConversation.id);
|
|
||||||
return existingConversation;
|
|
||||||
}
|
|
||||||
|
|
||||||
const conversation = createDirectConversation(currentParticipant, peerParticipant, Date.now());
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
closeConversationView(conversationId?: string | null): void {
|
|
||||||
if (!conversationId || this.selectedConversationIdSignal() === conversationId) {
|
|
||||||
this.selectedConversationIdSignal.set(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async forgetConversation(conversationId: string): Promise<void> {
|
|
||||||
const ownerId = this.getCurrentUserIdOrThrow();
|
|
||||||
const conversation = await this.repository.getConversation(ownerId, conversationId);
|
|
||||||
|
|
||||||
if (!conversation) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.repository.deleteConversation(ownerId, conversationId);
|
|
||||||
|
|
||||||
for (const message of conversation.messages) {
|
|
||||||
await this.offlineQueue.markDelivered(ownerId, message.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.conversationsSignal.update((conversations) => conversations.filter((entry) => entry.id !== conversationId));
|
|
||||||
|
|
||||||
if (this.selectedConversationIdSignal() === conversationId) {
|
|
||||||
this.selectedConversationIdSignal.set(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendMessage(conversationId: string, content: string, replyToId?: string): Promise<DirectMessage> {
|
|
||||||
const normalizedContent = content.trim();
|
|
||||||
|
|
||||||
if (!normalizedContent) {
|
|
||||||
throw new Error('Cannot send an empty direct message.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentUser = this.requireCurrentUser();
|
|
||||||
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);
|
|
||||||
|
|
||||||
if (!recipientId) {
|
|
||||||
throw new Error('Direct message conversation has no recipient.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const message: DirectMessage = {
|
|
||||||
id: uuidv4(),
|
|
||||||
conversationId,
|
|
||||||
senderId,
|
|
||||||
recipientId,
|
|
||||||
content: normalizedContent,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
status: 'QUEUED',
|
|
||||||
reactions: [],
|
|
||||||
isDeleted: false,
|
|
||||||
replyToId
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.persistConversation(ownerId, upsertDirectMessage(conversation, message, false));
|
|
||||||
await this.attemptDelivery(ownerId, message);
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
async editMessage(conversationId: string, messageId: string, content: string): Promise<void> {
|
|
||||||
const normalizedContent = content.trim();
|
|
||||||
|
|
||||||
if (!normalizedContent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.applyAndSendMutation(conversationId, {
|
|
||||||
conversationId,
|
|
||||||
messageId,
|
|
||||||
type: 'edit',
|
|
||||||
content: normalizedContent,
|
|
||||||
editedAt: Date.now(),
|
|
||||||
updatedAt: Date.now()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteMessage(conversationId: string, messageId: string): Promise<void> {
|
|
||||||
await this.applyAndSendMutation(conversationId, {
|
|
||||||
conversationId,
|
|
||||||
messageId,
|
|
||||||
type: 'delete',
|
|
||||||
updatedAt: Date.now()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async addReaction(conversationId: string, messageId: string, emoji: string): Promise<void> {
|
|
||||||
const userId = this.getCurrentUserIdOrThrow();
|
|
||||||
const reaction: Reaction = {
|
|
||||||
id: uuidv4(),
|
|
||||||
messageId,
|
|
||||||
oderId: userId,
|
|
||||||
userId,
|
|
||||||
emoji,
|
|
||||||
timestamp: Date.now()
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.applyAndSendMutation(conversationId, {
|
|
||||||
conversationId,
|
|
||||||
messageId,
|
|
||||||
type: 'reaction-add',
|
|
||||||
reaction,
|
|
||||||
updatedAt: reaction.timestamp
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async toggleReaction(conversationId: string, messageId: string, emoji: string): Promise<void> {
|
|
||||||
const userId = this.getCurrentUserIdOrThrow();
|
|
||||||
const conversation = await this.requireConversation(userId, conversationId);
|
|
||||||
const message = conversation.messages.find((entry) => entry.id === messageId);
|
|
||||||
const existingReaction = message?.reactions?.find((reaction) =>
|
|
||||||
reaction.emoji === emoji && (reaction.userId === userId || reaction.oderId === userId)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingReaction) {
|
|
||||||
await this.applyAndSendMutation(conversationId, {
|
|
||||||
conversationId,
|
|
||||||
messageId,
|
|
||||||
type: 'reaction-remove',
|
|
||||||
oderId: userId,
|
|
||||||
emoji,
|
|
||||||
updatedAt: Date.now()
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.addReaction(conversationId, messageId, emoji);
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
this.delivery.requestUserAvatar(peerId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
currentUserId(): string | null {
|
|
||||||
return this.getCurrentUserId();
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateStatus(messageId: string, status: DirectMessageStatus): Promise<void> {
|
|
||||||
const ownerId = this.getCurrentUserIdOrThrow();
|
|
||||||
const conversation = this.conversationsSignal().find((entry) => entry.messages.some((message) => message.id === messageId));
|
|
||||||
|
|
||||||
if (!conversation) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.persistConversation(ownerId, updateMessageStatusInConversation(conversation, messageId, status));
|
|
||||||
}
|
|
||||||
|
|
||||||
async receiveMessage(message: DirectMessage, sender: User): Promise<void> {
|
|
||||||
await this.handleIncomingMessage({
|
|
||||||
message,
|
|
||||||
sender: toDirectMessageParticipant(sender)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async markRead(conversationId: string): Promise<void> {
|
|
||||||
const ownerId = this.getCurrentUserIdOrThrow();
|
|
||||||
const currentUserId = this.getCurrentUserIdOrThrow();
|
|
||||||
const conversation = await this.requireConversation(ownerId, conversationId);
|
|
||||||
const updatedConversation = { ...conversation, unreadCount: 0 };
|
|
||||||
|
|
||||||
await this.persistConversation(ownerId, updatedConversation);
|
|
||||||
await this.repository.markRead(ownerId, conversationId);
|
|
||||||
|
|
||||||
for (const message of updatedConversation.messages) {
|
|
||||||
if (message.recipientId !== currentUserId || message.status === 'ACKNOWLEDGED') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextStatus = advanceDirectMessageStatus(message.status, 'ACKNOWLEDGED');
|
|
||||||
|
|
||||||
if (nextStatus !== message.status) {
|
|
||||||
await this.persistConversation(ownerId, updateMessageStatusInConversation(updatedConversation, message.id, nextStatus));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sendStatusUpdate(message.senderId, {
|
|
||||||
conversationId,
|
|
||||||
messageId: message.id,
|
|
||||||
status: 'ACKNOWLEDGED',
|
|
||||||
updatedAt: Date.now()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async retryPending(): Promise<void> {
|
|
||||||
const ownerId = this.getCurrentUserId();
|
|
||||||
|
|
||||||
if (!ownerId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.loadForOwner(ownerId);
|
|
||||||
|
|
||||||
const pendingMessageIds = await this.offlineQueue.retryPending(ownerId);
|
|
||||||
const messages = this.conversationsSignal().flatMap((conversation) => conversation.messages);
|
|
||||||
|
|
||||||
for (const messageId of pendingMessageIds) {
|
|
||||||
const message = messages.find((entry) => entry.id === messageId);
|
|
||||||
|
|
||||||
if (message) {
|
|
||||||
await this.attemptDelivery(ownerId, message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handlePeerEvent(event: ChatEvent): Promise<void> {
|
|
||||||
if (event.type === 'direct-message' && event.directMessage) {
|
|
||||||
await this.handleIncomingMessage(event.directMessage);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === 'direct-message-status' && event.directMessageStatus) {
|
|
||||||
await this.handleIncomingStatus(event.directMessageStatus);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === 'direct-message-mutation' && event.directMessageMutation) {
|
|
||||||
await this.handleIncomingMutation(event.directMessageMutation);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleIncomingMessage(payload: DirectMessageEventPayload): Promise<void> {
|
|
||||||
const ownerId = this.getCurrentUserIdOrThrow();
|
|
||||||
const currentUser = this.requireCurrentUser();
|
|
||||||
const currentParticipant = toDirectMessageParticipant(currentUser);
|
|
||||||
const sender = payload.sender;
|
|
||||||
const conversationId = payload.message.conversationId
|
|
||||||
|| getDirectConversationId(currentParticipant.userId, sender.userId);
|
|
||||||
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId)
|
|
||||||
?? createDirectConversation(currentParticipant, sender, payload.message.timestamp);
|
|
||||||
const incomingMessage: DirectMessage = {
|
|
||||||
...payload.message,
|
|
||||||
conversationId,
|
|
||||||
status: advanceDirectMessageStatus(payload.message.status, 'DELIVERED')
|
|
||||||
};
|
|
||||||
const shouldIncrementUnread = !this.isConversationVisible(conversationId);
|
|
||||||
|
|
||||||
await this.persistConversation(ownerId, upsertDirectMessage(existingConversation, incomingMessage, shouldIncrementUnread));
|
|
||||||
this.sendStatusUpdate(incomingMessage.senderId, {
|
|
||||||
conversationId,
|
|
||||||
messageId: incomingMessage.id,
|
|
||||||
status: 'DELIVERED',
|
|
||||||
updatedAt: Date.now()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!shouldIncrementUnread) {
|
|
||||||
await this.markRead(conversationId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleIncomingStatus(payload: DirectMessageStatusEventPayload): Promise<void> {
|
|
||||||
await this.updateStatus(payload.messageId, payload.status);
|
|
||||||
|
|
||||||
if (payload.status === 'DELIVERED' || payload.status === 'ACKNOWLEDGED') {
|
|
||||||
await this.offlineQueue.markDelivered(this.getCurrentUserIdOrThrow(), payload.messageId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private isConversationVisible(conversationId: string): boolean {
|
|
||||||
const currentUrl = this.router.url.split(/[?#]/, 1)[0];
|
|
||||||
|
|
||||||
if (!currentUrl.startsWith('/dm/')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return decodeURIComponent(currentUrl.slice('/dm/'.length)) === conversationId;
|
|
||||||
} catch {
|
|
||||||
return currentUrl.slice('/dm/'.length) === conversationId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleIncomingMutation(payload: DirectMessageMutationEventPayload): Promise<void> {
|
|
||||||
const ownerId = this.getCurrentUserIdOrThrow();
|
|
||||||
const conversation = await this.requireConversation(ownerId, payload.conversationId);
|
|
||||||
|
|
||||||
await this.persistConversation(ownerId, this.applyMutation(conversation, payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async applyAndSendMutation(
|
|
||||||
conversationId: string,
|
|
||||||
payload: DirectMessageMutationEventPayload
|
|
||||||
): Promise<void> {
|
|
||||||
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);
|
|
||||||
|
|
||||||
await this.persistConversation(ownerId, updatedConversation);
|
|
||||||
|
|
||||||
if (recipientId) {
|
|
||||||
this.delivery.sendViaWebRTC(recipientId, {
|
|
||||||
type: 'direct-message-mutation',
|
|
||||||
directMessageMutation: payload
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyMutation(
|
|
||||||
conversation: DirectMessageConversation,
|
|
||||||
payload: DirectMessageMutationEventPayload
|
|
||||||
): DirectMessageConversation {
|
|
||||||
const messages = conversation.messages.map((message) => {
|
|
||||||
if (message.id !== payload.messageId) {
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.type === 'edit' && payload.content) {
|
|
||||||
return {
|
|
||||||
...message,
|
|
||||||
content: payload.content,
|
|
||||||
editedAt: payload.editedAt ?? payload.updatedAt,
|
|
||||||
isDeleted: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.type === 'delete') {
|
|
||||||
return {
|
|
||||||
...message,
|
|
||||||
content: '',
|
|
||||||
isDeleted: true,
|
|
||||||
editedAt: payload.updatedAt
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.type === 'reaction-add' && payload.reaction) {
|
|
||||||
const reactions = (message.reactions ?? []).filter((reaction) =>
|
|
||||||
!(reaction.emoji === payload.reaction?.emoji && reaction.userId === payload.reaction.userId)
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...message,
|
|
||||||
reactions: [...reactions, payload.reaction]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.type === 'reaction-remove' && payload.oderId && payload.emoji) {
|
|
||||||
return {
|
|
||||||
...message,
|
|
||||||
reactions: (message.reactions ?? []).filter((reaction) =>
|
|
||||||
!(reaction.emoji === payload.emoji && (reaction.userId === payload.oderId || reaction.oderId === payload.oderId))
|
|
||||||
)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return message;
|
|
||||||
});
|
|
||||||
|
|
||||||
return { ...conversation, messages };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async attemptDelivery(ownerId: string, message: DirectMessage): Promise<void> {
|
|
||||||
const currentUser = this.requireCurrentUser();
|
|
||||||
const sent = this.delivery.sendViaWebRTC(message.recipientId, {
|
|
||||||
type: 'direct-message',
|
|
||||||
directMessage: {
|
|
||||||
message,
|
|
||||||
sender: toDirectMessageParticipant(currentUser)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!sent) {
|
|
||||||
await this.offlineQueue.enqueue(ownerId, message.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.offlineQueue.markDelivered(ownerId, message.id);
|
|
||||||
await this.updateStatus(message.id, 'SENT');
|
|
||||||
}
|
|
||||||
|
|
||||||
private sendStatusUpdate(recipientId: string, payload: DirectMessageStatusEventPayload): void {
|
|
||||||
this.delivery.handleAck(recipientId, {
|
|
||||||
type: 'direct-message-status',
|
|
||||||
directMessageStatus: payload
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadForOwner(ownerId: string | null): Promise<void> {
|
|
||||||
if (!ownerId) {
|
|
||||||
this.loadedOwnerId = null;
|
|
||||||
this.conversationsSignal.set([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.loadedOwnerId === ownerId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.loadedOwnerId = ownerId;
|
|
||||||
this.conversationsSignal.set(await this.repository.loadConversations(ownerId));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async persistConversation(ownerId: string, conversation: DirectMessageConversation): Promise<void> {
|
|
||||||
await this.repository.saveConversation(ownerId, conversation);
|
|
||||||
this.conversationsSignal.update((conversations) => {
|
|
||||||
const nextConversations = conversations.filter((entry) => entry.id !== conversation.id);
|
|
||||||
|
|
||||||
nextConversations.push(conversation);
|
|
||||||
return nextConversations;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async requireConversation(ownerId: string, conversationId: string): Promise<DirectMessageConversation> {
|
|
||||||
await this.loadForOwner(ownerId);
|
|
||||||
|
|
||||||
const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId)
|
|
||||||
?? await this.repository.getConversation(ownerId, conversationId);
|
|
||||||
|
|
||||||
if (!conversation) {
|
|
||||||
throw new Error('Direct message conversation not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return conversation;
|
|
||||||
}
|
|
||||||
|
|
||||||
private requireCurrentUser(): User {
|
|
||||||
const currentUser = this.currentUser();
|
|
||||||
|
|
||||||
if (!currentUser) {
|
|
||||||
throw new Error('Cannot use direct messages without a current user.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getCurrentUserId(): string | null {
|
|
||||||
const user = this.currentUser();
|
|
||||||
|
|
||||||
return user?.oderId || user?.id || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getCurrentUserIdOrThrow(): string {
|
|
||||||
const ownerId = this.getCurrentUserId();
|
|
||||||
|
|
||||||
if (!ownerId) {
|
|
||||||
throw new Error('Cannot use direct messages without a current user.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return ownerId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { FriendRepository } from '../../infrastructure/friend.repository';
|
|
||||||
|
|
||||||
describe('FriendService storage contract', () => {
|
|
||||||
let repository: FriendRepository;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
installLocalStorageMock();
|
|
||||||
repository = new FriendRepository();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should add friend', async () => {
|
|
||||||
await repository.addFriend('alice', { userId: 'bob', addedAt: 10 });
|
|
||||||
|
|
||||||
expect(await repository.loadFriends('alice')).toEqual([{ userId: 'bob', addedAt: 10 }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should remove friend', async () => {
|
|
||||||
await repository.addFriend('alice', { userId: 'bob', addedAt: 10 });
|
|
||||||
await repository.removeFriend('alice', 'bob');
|
|
||||||
|
|
||||||
expect(await repository.loadFriends('alice')).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should persist friends', async () => {
|
|
||||||
await repository.addFriend('alice', { userId: 'bob', addedAt: 10 });
|
|
||||||
const reloadedRepository = new FriendRepository();
|
|
||||||
|
|
||||||
expect(await reloadedRepository.loadFriends('alice')).toEqual([{ userId: 'bob', addedAt: 10 }]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function installLocalStorageMock(): void {
|
|
||||||
const values = new Map<string, string>();
|
|
||||||
|
|
||||||
vi.stubGlobal('localStorage', {
|
|
||||||
getItem: (key: string) => values.get(key) ?? null,
|
|
||||||
setItem: (key: string, value: string) => values.set(key, value),
|
|
||||||
removeItem: (key: string) => values.delete(key),
|
|
||||||
clear: () => values.clear()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import {
|
|
||||||
Injectable,
|
|
||||||
computed,
|
|
||||||
effect,
|
|
||||||
inject,
|
|
||||||
signal
|
|
||||||
} from '@angular/core';
|
|
||||||
import { Store } from '@ngrx/store';
|
|
||||||
import { FriendRepository } from '../../infrastructure/friend.repository';
|
|
||||||
import type { Friend } from '../../domain/models/direct-message.model';
|
|
||||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class FriendService {
|
|
||||||
private readonly repository = inject(FriendRepository);
|
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
|
||||||
private readonly friendsSignal = signal<Friend[]>([]);
|
|
||||||
private loadedOwnerId: string | null = null;
|
|
||||||
|
|
||||||
readonly friends = this.friendsSignal.asReadonly();
|
|
||||||
readonly friendIds = computed(() => new Set(this.friendsSignal().map((friend) => friend.userId)));
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
effect(() => {
|
|
||||||
const ownerId = this.currentUser()?.oderId || this.currentUser()?.id || null;
|
|
||||||
|
|
||||||
void this.loadForOwner(ownerId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async addFriend(userId: string): Promise<void> {
|
|
||||||
const ownerId = await this.requireOwnerId();
|
|
||||||
const friend: Friend = { userId, addedAt: Date.now() };
|
|
||||||
|
|
||||||
await this.repository.addFriend(ownerId, friend);
|
|
||||||
await this.loadForOwner(ownerId, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeFriend(userId: string): Promise<void> {
|
|
||||||
const ownerId = await this.requireOwnerId();
|
|
||||||
|
|
||||||
await this.repository.removeFriend(ownerId, userId);
|
|
||||||
await this.loadForOwner(ownerId, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
isFriend(userId: string): boolean {
|
|
||||||
return this.friendIds().has(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async toggleFriend(userId: string): Promise<void> {
|
|
||||||
if (this.isFriend(userId)) {
|
|
||||||
await this.removeFriend(userId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.addFriend(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadForOwner(ownerId: string | null, force = false): Promise<void> {
|
|
||||||
if (!ownerId) {
|
|
||||||
this.loadedOwnerId = null;
|
|
||||||
this.friendsSignal.set([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!force && this.loadedOwnerId === ownerId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.loadedOwnerId = ownerId;
|
|
||||||
this.friendsSignal.set(await this.repository.loadFriends(ownerId));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async requireOwnerId(): Promise<string> {
|
|
||||||
const ownerId = this.currentUser()?.oderId || this.currentUser()?.id;
|
|
||||||
|
|
||||||
if (!ownerId) {
|
|
||||||
throw new Error('Cannot manage friends without a current user.');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.loadForOwner(ownerId);
|
|
||||||
return ownerId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
|
||||||
import { OfflineQueueRepository } from '../../infrastructure/offline-queue.repository';
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class OfflineMessageQueueService {
|
|
||||||
private readonly repository = inject(OfflineQueueRepository);
|
|
||||||
|
|
||||||
enqueue(ownerId: string, messageId: string): Promise<void> {
|
|
||||||
return this.repository.enqueue(ownerId, messageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
retryPending(ownerId: string): Promise<string[]> {
|
|
||||||
return this.repository.load(ownerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
markDelivered(ownerId: string, messageId: string): Promise<void> {
|
|
||||||
return this.repository.remove(ownerId, messageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
clear(ownerId: string): Promise<void> {
|
|
||||||
return this.repository.clear(ownerId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { OfflineQueueRepository } from '../../infrastructure/offline-queue.repository';
|
|
||||||
|
|
||||||
describe('OfflineMessageQueueService storage contract', () => {
|
|
||||||
let repository: OfflineQueueRepository;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
installLocalStorageMock();
|
|
||||||
repository = new OfflineQueueRepository();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should enqueue messages', async () => {
|
|
||||||
await repository.enqueue('alice', 'message-1');
|
|
||||||
await repository.enqueue('alice', 'message-1');
|
|
||||||
|
|
||||||
expect(await repository.load('alice')).toEqual(['message-1']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should retry on reconnect', async () => {
|
|
||||||
await repository.enqueue('alice', 'message-1');
|
|
||||||
await repository.enqueue('alice', 'message-2');
|
|
||||||
|
|
||||||
expect(await repository.load('alice')).toEqual(['message-1', 'message-2']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should clear delivered messages', async () => {
|
|
||||||
await repository.enqueue('alice', 'message-1');
|
|
||||||
await repository.remove('alice', 'message-1');
|
|
||||||
|
|
||||||
expect(await repository.load('alice')).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function installLocalStorageMock(): void {
|
|
||||||
const values = new Map<string, string>();
|
|
||||||
|
|
||||||
vi.stubGlobal('localStorage', {
|
|
||||||
getItem: (key: string) => values.get(key) ?? null,
|
|
||||||
setItem: (key: string, value: string) => values.set(key, value),
|
|
||||||
removeItem: (key: string) => values.delete(key),
|
|
||||||
clear: () => values.clear()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import { Injectable, inject } from '@angular/core';
|
|
||||||
import { Store } from '@ngrx/store';
|
|
||||||
import {
|
|
||||||
Subject,
|
|
||||||
filter,
|
|
||||||
type Observable
|
|
||||||
} from 'rxjs';
|
|
||||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
|
||||||
import { selectAllUsers } from '../../../../store/users/users.selectors';
|
|
||||||
import type { ChatEvent, User } from '../../../../shared-kernel';
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class PeerDeliveryService {
|
|
||||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly users = this.store.selectSignal(selectAllUsers);
|
|
||||||
private readonly networkRestoredSubject = new Subject<void>();
|
|
||||||
|
|
||||||
readonly directMessageEvents$: Observable<ChatEvent> = this.webrtc.onMessageReceived.pipe(
|
|
||||||
filter((event) => event.type === 'direct-message' || event.type === 'direct-message-status' || event.type === 'direct-message-mutation')
|
|
||||||
);
|
|
||||||
|
|
||||||
readonly peerConnected$ = this.webrtc.onPeerConnected;
|
|
||||||
readonly networkRestored$ = this.networkRestoredSubject.asObservable();
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.installNetworkTestHooks();
|
|
||||||
}
|
|
||||||
|
|
||||||
sendViaWebRTC(recipientId: string, event: ChatEvent): boolean {
|
|
||||||
if (this.isOfflineOverrideEnabled()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const peerId = this.resolvePeerId(recipientId);
|
|
||||||
|
|
||||||
if (!peerId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.webrtc.sendToPeer(peerId, event);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleAck(recipientId: string, event: ChatEvent): boolean {
|
|
||||||
return this.sendViaWebRTC(recipientId, event);
|
|
||||||
}
|
|
||||||
|
|
||||||
requestUserAvatar(recipientId: string): boolean {
|
|
||||||
return this.sendViaWebRTC(recipientId, {
|
|
||||||
type: 'user-avatar-request',
|
|
||||||
oderId: recipientId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
syncOnReconnect(onReconnect: () => void): void {
|
|
||||||
this.peerConnected$.subscribe(() => onReconnect());
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolvePeerId(recipientId: string): string | null {
|
|
||||||
const connectedPeerIds = new Set(this.webrtc.getConnectedPeers());
|
|
||||||
|
|
||||||
if (connectedPeerIds.has(recipientId)) {
|
|
||||||
return recipientId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = this.users().find((candidate: User) =>
|
|
||||||
candidate.id === recipientId || candidate.oderId === recipientId || candidate.peerId === recipientId
|
|
||||||
);
|
|
||||||
const candidates = [
|
|
||||||
user?.oderId,
|
|
||||||
user?.peerId,
|
|
||||||
user?.id
|
|
||||||
].filter((candidate): candidate is string => !!candidate);
|
|
||||||
|
|
||||||
return candidates.find((candidate) => connectedPeerIds.has(candidate)) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private isOfflineOverrideEnabled(): boolean {
|
|
||||||
return typeof window !== 'undefined'
|
|
||||||
&& !!(window as Window & { metoyouDmNetworkOffline?: boolean }).metoyouDmNetworkOffline;
|
|
||||||
}
|
|
||||||
|
|
||||||
private installNetworkTestHooks(): void {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const testWindow = window as Window & {
|
|
||||||
simulateOffline?: () => void;
|
|
||||||
simulateOnline?: () => void;
|
|
||||||
metoyouDmNetworkOffline?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
testWindow.simulateOffline = () => {
|
|
||||||
testWindow.metoyouDmNetworkOffline = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
testWindow.simulateOnline = () => {
|
|
||||||
testWindow.metoyouDmNetworkOffline = false;
|
|
||||||
this.networkRestoredSubject.next();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import type {
|
|
||||||
DirectMessage,
|
|
||||||
DirectMessageConversation,
|
|
||||||
DirectMessageParticipant,
|
|
||||||
DirectMessageStatus
|
|
||||||
} from '../models/direct-message.model';
|
|
||||||
|
|
||||||
const STATUS_ORDER: Record<DirectMessageStatus, number> = {
|
|
||||||
QUEUED: 0,
|
|
||||||
SENT: 1,
|
|
||||||
DELIVERED: 2,
|
|
||||||
ACKNOWLEDGED: 3
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getDirectConversationId(firstUserId: string, secondUserId: string): string {
|
|
||||||
return `dm-${[firstUserId, secondUserId]
|
|
||||||
.map((userId) => encodeURIComponent(userId.trim()))
|
|
||||||
.sort()
|
|
||||||
.join('--')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function advanceDirectMessageStatus(
|
|
||||||
currentStatus: DirectMessageStatus,
|
|
||||||
incomingStatus: DirectMessageStatus
|
|
||||||
): DirectMessageStatus {
|
|
||||||
return STATUS_ORDER[incomingStatus] > STATUS_ORDER[currentStatus]
|
|
||||||
? incomingStatus
|
|
||||||
: currentStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createDirectConversation(
|
|
||||||
currentUser: DirectMessageParticipant,
|
|
||||||
peer: DirectMessageParticipant,
|
|
||||||
now: number
|
|
||||||
): DirectMessageConversation {
|
|
||||||
const participants = [currentUser.userId, peer.userId].sort();
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: getDirectConversationId(currentUser.userId, peer.userId),
|
|
||||||
participants,
|
|
||||||
participantProfiles: {
|
|
||||||
[currentUser.userId]: currentUser,
|
|
||||||
[peer.userId]: peer
|
|
||||||
},
|
|
||||||
messages: [],
|
|
||||||
lastMessageAt: now,
|
|
||||||
unreadCount: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function upsertDirectMessage(
|
|
||||||
conversation: DirectMessageConversation,
|
|
||||||
message: DirectMessage,
|
|
||||||
incrementUnread: boolean
|
|
||||||
): DirectMessageConversation {
|
|
||||||
const existingIndex = conversation.messages.findIndex((entry) => entry.id === message.id);
|
|
||||||
const messages = [...conversation.messages];
|
|
||||||
|
|
||||||
if (existingIndex >= 0) {
|
|
||||||
const existing = messages[existingIndex];
|
|
||||||
|
|
||||||
messages[existingIndex] = {
|
|
||||||
...existing,
|
|
||||||
...message,
|
|
||||||
status: advanceDirectMessageStatus(existing.status, message.status)
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
messages.push(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
messages.sort((firstMessage, secondMessage) => firstMessage.timestamp - secondMessage.timestamp);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...conversation,
|
|
||||||
messages,
|
|
||||||
lastMessageAt: Math.max(conversation.lastMessageAt, message.timestamp),
|
|
||||||
unreadCount: incrementUnread ? conversation.unreadCount + 1 : conversation.unreadCount
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateMessageStatusInConversation(
|
|
||||||
conversation: DirectMessageConversation,
|
|
||||||
messageId: string,
|
|
||||||
status: DirectMessageStatus
|
|
||||||
): DirectMessageConversation {
|
|
||||||
const messages = conversation.messages.map((message) => message.id === messageId
|
|
||||||
? { ...message, status: advanceDirectMessageStatus(message.status, status) }
|
|
||||||
: message);
|
|
||||||
|
|
||||||
return { ...conversation, messages };
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import type { User } from '../../../../shared-kernel';
|
|
||||||
import type { DirectMessage, DirectMessageParticipant } from '../../../../shared-kernel';
|
|
||||||
|
|
||||||
export type {
|
|
||||||
DirectMessage,
|
|
||||||
DirectMessageEventPayload,
|
|
||||||
DirectMessageMutationEventPayload,
|
|
||||||
DirectMessageParticipant,
|
|
||||||
DirectMessageStatus,
|
|
||||||
DirectMessageStatusEventPayload
|
|
||||||
} from '../../../../shared-kernel';
|
|
||||||
|
|
||||||
export interface DirectMessageConversation {
|
|
||||||
id: string;
|
|
||||||
participants: string[];
|
|
||||||
participantProfiles: Record<string, DirectMessageParticipant>;
|
|
||||||
messages: DirectMessage[];
|
|
||||||
lastMessageAt: number;
|
|
||||||
unreadCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Friend {
|
|
||||||
userId: string;
|
|
||||||
addedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toDirectMessageParticipant(user: User): DirectMessageParticipant {
|
|
||||||
return {
|
|
||||||
userId: user.oderId || user.id,
|
|
||||||
username: user.username,
|
|
||||||
displayName: user.displayName || user.username,
|
|
||||||
description: user.description,
|
|
||||||
avatarUrl: user.avatarUrl,
|
|
||||||
avatarHash: user.avatarHash,
|
|
||||||
avatarMime: user.avatarMime,
|
|
||||||
avatarUpdatedAt: user.avatarUpdatedAt,
|
|
||||||
profileUpdatedAt: user.profileUpdatedAt
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
<section
|
|
||||||
appThemeNode="dmChatSurface"
|
|
||||||
class="chat-layout relative h-full bg-background"
|
|
||||||
>
|
|
||||||
<header
|
|
||||||
appThemeNode="dmChatHeader"
|
|
||||||
class="flex h-14 shrink-0 items-center gap-3 border-b border-border px-4"
|
|
||||||
>
|
|
||||||
<app-user-avatar
|
|
||||||
[name]="peerName()"
|
|
||||||
[avatarUrl]="peerUser()?.avatarUrl"
|
|
||||||
[status]="peerUser()?.status"
|
|
||||||
[showStatusBadge]="true"
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<h1 class="truncate text-base font-semibold text-foreground">{{ peerName() }}</h1>
|
|
||||||
<p class="text-xs text-muted-foreground">Direct Message</p>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
@if (conversation()) {
|
|
||||||
<div
|
|
||||||
appThemeNode="dmMessageRegion"
|
|
||||||
class="absolute inset-x-0 bottom-0 top-14"
|
|
||||||
>
|
|
||||||
<app-chat-message-list
|
|
||||||
[allMessages]="chatMessages()"
|
|
||||||
[channelMessages]="chatMessages()"
|
|
||||||
[loading]="false"
|
|
||||||
[syncing]="false"
|
|
||||||
[currentUserId]="currentUserId()"
|
|
||||||
[isAdmin]="false"
|
|
||||||
[bottomPadding]="composerBottomPadding()"
|
|
||||||
[conversationKey]="conversationKey()"
|
|
||||||
[userLookupOverrides]="participantUsers()"
|
|
||||||
(replyRequested)="setReplyTo($event)"
|
|
||||||
(deleteRequested)="handleDeleteRequested($event)"
|
|
||||||
(editSaved)="handleEditSaved($event)"
|
|
||||||
(reactionAdded)="handleReactionAdded($event)"
|
|
||||||
(reactionToggled)="handleReactionToggled($event)"
|
|
||||||
(downloadRequested)="downloadAttachment($event)"
|
|
||||||
(imageOpened)="openLightbox($event)"
|
|
||||||
(imageContextMenuRequested)="openImageContextMenu($event)"
|
|
||||||
(embedRemoved)="handleEmbedRemoved($event)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
@for (messageStatus of messageStatuses(); track messageStatus.id) {
|
|
||||||
<span
|
|
||||||
data-testid="message-status"
|
|
||||||
class="sr-only"
|
|
||||||
>{{ messageStatus.status }}</span
|
|
||||||
>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
appThemeNode="chatComposerBar"
|
|
||||||
class="chat-bottom-bar absolute bottom-0 left-0 right-2 z-10 bg-background/85 backdrop-blur-md"
|
|
||||||
>
|
|
||||||
<app-chat-message-composer
|
|
||||||
[replyTo]="replyTo()"
|
|
||||||
[showKlipyGifPicker]="showGifPicker()"
|
|
||||||
[klipyEnabled]="klipyEnabled()"
|
|
||||||
[klipySignalSource]="null"
|
|
||||||
[textareaTestId]="'dm-input'"
|
|
||||||
(messageSubmitted)="handleMessageSubmitted($event)"
|
|
||||||
(replyCleared)="clearReply()"
|
|
||||||
(heightChanged)="composerBottomPadding.set($event + 20)"
|
|
||||||
(klipyGifPickerToggleRequested)="toggleGifPicker()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (showGifPicker()) {
|
|
||||||
<div
|
|
||||||
class="fixed inset-0 z-[89]"
|
|
||||||
tabindex="0"
|
|
||||||
role="button"
|
|
||||||
aria-label="Close GIF picker"
|
|
||||||
(click)="closeGifPicker()"
|
|
||||||
(keydown.enter)="closeGifPicker()"
|
|
||||||
(keydown.space)="closeGifPicker()"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<div class="pointer-events-none fixed inset-0 z-[90]">
|
|
||||||
<div
|
|
||||||
appThemeNode="chatGifPickerSurface"
|
|
||||||
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
|
|
||||||
[style.bottom.px]="composerBottomPadding() + 8"
|
|
||||||
[style.right.px]="gifPickerAnchorRight()"
|
|
||||||
>
|
|
||||||
<app-klipy-gif-picker
|
|
||||||
(gifSelected)="handleGifSelected($event)"
|
|
||||||
(closed)="closeGifPicker()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<app-chat-message-overlays
|
|
||||||
[lightboxAttachment]="lightboxAttachment()"
|
|
||||||
[imageContextMenu]="imageContextMenu()"
|
|
||||||
(lightboxClosed)="closeLightbox()"
|
|
||||||
(contextMenuClosed)="closeImageContextMenu()"
|
|
||||||
(downloadRequested)="downloadAttachment($event)"
|
|
||||||
(copyRequested)="copyImageToClipboard($event)"
|
|
||||||
(imageContextMenuRequested)="openImageContextMenu($event)"
|
|
||||||
/>
|
|
||||||
} @else {
|
|
||||||
<div class="flex flex-1 items-center justify-center px-6 text-sm text-muted-foreground">Select a direct message from the rail.</div>
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
@@ -1,457 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
|
||||||
Component,
|
|
||||||
computed,
|
|
||||||
effect,
|
|
||||||
HostListener,
|
|
||||||
inject,
|
|
||||||
signal,
|
|
||||||
ViewChild
|
|
||||||
} from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { ActivatedRoute } from '@angular/router';
|
|
||||||
import { Store } from '@ngrx/store';
|
|
||||||
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 { 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 {
|
|
||||||
ChatMessageComposerSubmitEvent,
|
|
||||||
ChatMessageComposerComponent,
|
|
||||||
ChatMessageDeleteEvent,
|
|
||||||
ChatMessageEditEvent,
|
|
||||||
ChatMessageImageContextMenuEvent,
|
|
||||||
ChatMessageListComponent,
|
|
||||||
ChatMessageOverlaysComponent,
|
|
||||||
ChatMessageReactionEvent,
|
|
||||||
ChatMessageReplyEvent,
|
|
||||||
hasDedicatedChatEmbed,
|
|
||||||
KlipyGif,
|
|
||||||
KlipyGifPickerComponent,
|
|
||||||
KlipyService,
|
|
||||||
LinkMetadataService,
|
|
||||||
type ChatMessageEmbedRemoveEvent
|
|
||||||
} from '../../../chat';
|
|
||||||
import type {
|
|
||||||
DirectMessageStatus,
|
|
||||||
LinkMetadata,
|
|
||||||
Message,
|
|
||||||
User
|
|
||||||
} from '../../../../shared-kernel';
|
|
||||||
|
|
||||||
interface DmStatusLabel {
|
|
||||||
id: string;
|
|
||||||
status: DirectMessageStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-dm-chat',
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
ChatMessageComposerComponent,
|
|
||||||
ChatMessageListComponent,
|
|
||||||
ChatMessageOverlaysComponent,
|
|
||||||
KlipyGifPickerComponent,
|
|
||||||
ThemeNodeDirective,
|
|
||||||
UserAvatarComponent
|
|
||||||
],
|
|
||||||
templateUrl: './dm-chat.component.html',
|
|
||||||
host: {
|
|
||||||
class: 'block h-full'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
export class DmChatComponent {
|
|
||||||
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
|
|
||||||
|
|
||||||
private readonly route = inject(ActivatedRoute);
|
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly electronBridge = inject(ElectronBridgeService);
|
|
||||||
private readonly attachments = inject(AttachmentFacade);
|
|
||||||
private readonly klipy = inject(KlipyService);
|
|
||||||
private readonly linkMetadata = inject(LinkMetadataService);
|
|
||||||
readonly directMessages = inject(DirectMessageService);
|
|
||||||
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
|
||||||
readonly allUsers = this.store.selectSignal(selectAllUsers);
|
|
||||||
readonly showGifPicker = signal(false);
|
|
||||||
readonly composerBottomPadding = signal(140);
|
|
||||||
readonly gifPickerAnchorRight = signal(16);
|
|
||||||
readonly linkMetadataByMessageId = signal<Record<string, LinkMetadata[]>>({});
|
|
||||||
readonly replyTo = signal<Message | null>(null);
|
|
||||||
readonly lightboxAttachment = signal<Attachment | null>(null);
|
|
||||||
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
|
|
||||||
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
|
|
||||||
initialValue: this.route.snapshot.paramMap.get('conversationId')
|
|
||||||
});
|
|
||||||
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 peerUser = computed(() => {
|
|
||||||
const conversation = this.conversation();
|
|
||||||
|
|
||||||
return conversation ? this.peerUserFor(conversation) : null;
|
|
||||||
});
|
|
||||||
readonly participantUsers = computed<User[]>(() => {
|
|
||||||
const conversation = this.conversation();
|
|
||||||
const knownUsers = this.allUsers();
|
|
||||||
|
|
||||||
if (!conversation) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return conversation.participants.map((participantId) => {
|
|
||||||
const knownUser = knownUsers.find((user) => user.id === participantId || user.oderId === participantId);
|
|
||||||
const participant = conversation.participantProfiles[participantId];
|
|
||||||
|
|
||||||
return (
|
|
||||||
knownUser ?? {
|
|
||||||
id: participantId,
|
|
||||||
oderId: participantId,
|
|
||||||
username: participant?.username || participant?.displayName || participantId,
|
|
||||||
displayName: participant?.displayName || participant?.username || participantId,
|
|
||||||
description: participant?.description,
|
|
||||||
profileUpdatedAt: participant?.profileUpdatedAt,
|
|
||||||
avatarUrl: participant?.avatarUrl,
|
|
||||||
avatarHash: participant?.avatarHash,
|
|
||||||
avatarMime: participant?.avatarMime,
|
|
||||||
avatarUpdatedAt: participant?.avatarUpdatedAt,
|
|
||||||
status: 'disconnected',
|
|
||||||
role: 'member',
|
|
||||||
joinedAt: 0
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
readonly messageStatuses = computed<DmStatusLabel[]>(() => {
|
|
||||||
const conversation = this.conversation();
|
|
||||||
const currentUserId = this.currentUserId();
|
|
||||||
|
|
||||||
if (!conversation || !currentUserId) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return conversation.messages
|
|
||||||
.filter((message) => message.senderId === currentUserId)
|
|
||||||
.map((message) => ({
|
|
||||||
id: message.id,
|
|
||||||
status: message.status
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
readonly chatMessages = computed<Message[]>(() => {
|
|
||||||
const conversation = this.conversation();
|
|
||||||
const metadataByMessageId = this.linkMetadataByMessageId();
|
|
||||||
|
|
||||||
if (!conversation) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return conversation.messages.map((message) => {
|
|
||||||
const participant = conversation.participantProfiles[message.senderId];
|
|
||||||
const knownUser = this.participantUsers().find((user) => user.id === message.senderId || user.oderId === message.senderId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: message.id,
|
|
||||||
roomId: conversation.id,
|
|
||||||
channelId: 'direct-message',
|
|
||||||
senderId: message.senderId,
|
|
||||||
senderName: knownUser?.displayName || participant?.displayName || (message.senderId === this.currentUserId() ? 'You' : message.senderId),
|
|
||||||
content: message.content,
|
|
||||||
timestamp: message.timestamp,
|
|
||||||
editedAt: message.editedAt,
|
|
||||||
reactions: message.reactions ?? [],
|
|
||||||
isDeleted: !!message.isDeleted,
|
|
||||||
replyToId: message.replyToId,
|
|
||||||
linkMetadata: metadataByMessageId[message.id]
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
readonly peerName = computed(() => {
|
|
||||||
const conversation = this.conversation();
|
|
||||||
const currentUserId = this.currentUserId();
|
|
||||||
const peerId = conversation?.participants.find((participantId) => participantId !== currentUserId);
|
|
||||||
|
|
||||||
return peerId ? conversation?.participantProfiles[peerId]?.displayName || peerId : 'Direct Message';
|
|
||||||
});
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
effect(() => {
|
|
||||||
const conversationId = this.routeConversationId();
|
|
||||||
|
|
||||||
if (conversationId) {
|
|
||||||
void this.directMessages.openConversation(conversationId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
effect(() => {
|
|
||||||
void this.routeConversationId();
|
|
||||||
void this.klipy.refreshAvailability(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
effect(() => {
|
|
||||||
void this.refreshLinkMetadata(this.chatMessages());
|
|
||||||
});
|
|
||||||
|
|
||||||
effect(() => {
|
|
||||||
const conversation = this.conversation();
|
|
||||||
const peerUser = this.peerUser();
|
|
||||||
|
|
||||||
if (conversation && !peerUser?.avatarUrl) {
|
|
||||||
this.directMessages.requestPeerAvatarSync(conversation.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@HostListener('window:resize')
|
|
||||||
onWindowResize(): void {
|
|
||||||
if (this.showGifPicker()) {
|
|
||||||
this.syncGifPickerAnchor();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMessageSubmitted(event: ChatMessageComposerSubmitEvent): void {
|
|
||||||
const conversation = this.conversation();
|
|
||||||
|
|
||||||
if (!conversation || (!event.content.trim() && event.pendingFiles.length === 0)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = event.content.trim() || event.pendingFiles.map((file) => file.name).join('\n');
|
|
||||||
|
|
||||||
void this.directMessages.sendMessage(conversation.id, content, this.replyTo()?.id).then((message) => {
|
|
||||||
this.replyTo.set(null);
|
|
||||||
|
|
||||||
if (event.pendingFiles.length > 0) {
|
|
||||||
this.attachments.publishAttachments(message.id, event.pendingFiles, this.currentUserId() || undefined);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setReplyTo(message: ChatMessageReplyEvent): void {
|
|
||||||
this.replyTo.set(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearReply(): void {
|
|
||||||
this.replyTo.set(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleEditSaved(event: ChatMessageEditEvent): void {
|
|
||||||
const conversation = this.conversation();
|
|
||||||
|
|
||||||
if (conversation) {
|
|
||||||
void this.directMessages.editMessage(conversation.id, event.messageId, event.content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDeleteRequested(message: ChatMessageDeleteEvent): void {
|
|
||||||
const conversation = this.conversation();
|
|
||||||
|
|
||||||
if (conversation && message.senderId === this.currentUserId()) {
|
|
||||||
void this.directMessages.deleteMessage(conversation.id, message.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleReactionAdded(event: ChatMessageReactionEvent): void {
|
|
||||||
const conversation = this.conversation();
|
|
||||||
|
|
||||||
if (conversation) {
|
|
||||||
void this.directMessages.addReaction(conversation.id, event.messageId, event.emoji);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleReactionToggled(event: ChatMessageReactionEvent): void {
|
|
||||||
const conversation = this.conversation();
|
|
||||||
|
|
||||||
if (conversation) {
|
|
||||||
void this.directMessages.toggleReaction(conversation.id, event.messageId, event.emoji);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleGifPicker(): void {
|
|
||||||
if (!this.klipyEnabled()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.showGifPicker.update((visible) => !visible);
|
|
||||||
|
|
||||||
if (this.showGifPicker()) {
|
|
||||||
requestAnimationFrame(() => this.syncGifPickerAnchor());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
closeGifPicker(): void {
|
|
||||||
this.showGifPicker.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleGifSelected(gif: KlipyGif): void {
|
|
||||||
this.closeGifPicker();
|
|
||||||
this.composer?.handleKlipyGifSelected(gif);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleEmbedRemoved(event: ChatMessageEmbedRemoveEvent): void {
|
|
||||||
this.linkMetadataByMessageId.update((metadataByMessageId) => ({
|
|
||||||
...metadataByMessageId,
|
|
||||||
[event.messageId]: (metadataByMessageId[event.messageId] ?? []).filter((metadata) => metadata.url !== event.url)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
openLightbox(attachment: Attachment): void {
|
|
||||||
if (attachment.available && attachment.objectUrl) {
|
|
||||||
this.lightboxAttachment.set(attachment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
closeLightbox(): void {
|
|
||||||
this.lightboxAttachment.set(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
openImageContextMenu(event: ChatMessageImageContextMenuEvent): void {
|
|
||||||
this.imageContextMenu.set(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
closeImageContextMenu(): void {
|
|
||||||
this.imageContextMenu.set(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
async downloadAttachment(attachment: Attachment): Promise<void> {
|
|
||||||
if (!attachment.available || !attachment.objectUrl) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const electronApi = this.electronBridge.getApi();
|
|
||||||
|
|
||||||
if (electronApi) {
|
|
||||||
const blob = await this.getAttachmentBlob(attachment);
|
|
||||||
|
|
||||||
if (blob) {
|
|
||||||
try {
|
|
||||||
const result = await electronApi.saveFileAs(attachment.filename, await this.blobToBase64(blob));
|
|
||||||
|
|
||||||
if (result.saved || result.cancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* fall back to browser download */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const link = document.createElement('a');
|
|
||||||
|
|
||||||
link.href = attachment.objectUrl;
|
|
||||||
link.download = attachment.filename;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
}
|
|
||||||
|
|
||||||
async copyImageToClipboard(attachment: Attachment): Promise<void> {
|
|
||||||
this.closeImageContextMenu();
|
|
||||||
|
|
||||||
if (!attachment.objectUrl) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(attachment.objectUrl);
|
|
||||||
const blob = await response.blob();
|
|
||||||
|
|
||||||
await navigator.clipboard.write([new ClipboardItem({ [blob.type || 'image/png']: blob })]);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private syncGifPickerAnchor(): void {
|
|
||||||
const triggerRect = this.composer?.getKlipyTriggerRect();
|
|
||||||
|
|
||||||
if (!triggerRect) {
|
|
||||||
this.gifPickerAnchorRight.set(16);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewportWidth = window.innerWidth;
|
|
||||||
const popupWidth = viewportWidth >= 1280 ? 52 * 16 : viewportWidth >= 768 ? 42 * 16 : 34 * 16;
|
|
||||||
const preferredRight = viewportWidth - triggerRect.right;
|
|
||||||
const minRight = 16;
|
|
||||||
const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16);
|
|
||||||
|
|
||||||
this.gifPickerAnchorRight.set(Math.min(Math.max(Math.round(preferredRight), minRight), maxRight));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async refreshLinkMetadata(messages: Message[]): Promise<void> {
|
|
||||||
const metadataByMessageId = this.linkMetadataByMessageId();
|
|
||||||
|
|
||||||
for (const message of messages) {
|
|
||||||
if (metadataByMessageId[message.id]?.length) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const urls = this.linkMetadata.extractUrls(message.content).filter((url) => !hasDedicatedChatEmbed(url));
|
|
||||||
|
|
||||||
if (urls.length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadata = (await this.linkMetadata.fetchAllMetadata(urls)).filter((entry) => !entry.failed);
|
|
||||||
|
|
||||||
if (metadata.length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.linkMetadataByMessageId.update((currentMetadata) => ({
|
|
||||||
...currentMetadata,
|
|
||||||
[message.id]: metadata
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getAttachmentBlob(attachment: Attachment): Promise<Blob | null> {
|
|
||||||
if (!attachment.objectUrl) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(attachment.objectUrl);
|
|
||||||
|
|
||||||
return await response.blob();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private blobToBase64(blob: Blob): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
|
|
||||||
reader.onload = () => {
|
|
||||||
if (typeof reader.result !== 'string') {
|
|
||||||
reject(new Error('Failed to encode attachment'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [, base64 = ''] = reader.result.split(',', 2);
|
|
||||||
|
|
||||||
resolve(base64);
|
|
||||||
};
|
|
||||||
|
|
||||||
reader.onerror = () => reject(reader.error ?? new Error('Failed to read attachment'));
|
|
||||||
reader.readAsDataURL(blob);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private peerUserFor(conversation: NonNullable<ReturnType<typeof this.conversation>>): User | null {
|
|
||||||
const currentUserId = this.currentUserId();
|
|
||||||
const peerId = conversation.participants.find((participantId) => participantId !== currentUserId);
|
|
||||||
|
|
||||||
if (!peerId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.participantUsers().find((user) => user.id === peerId || user.oderId === peerId) ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<div
|
|
||||||
class="group flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30"
|
|
||||||
[class.flex-row-reverse]="isOutgoing()"
|
|
||||||
>
|
|
||||||
<div class="grid h-9 w-9 flex-shrink-0 place-items-center rounded-full bg-secondary text-xs font-semibold text-foreground">
|
|
||||||
{{ isOutgoing() ? 'You'[0] : message().senderId[0] || '?' }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="min-w-0 max-w-3xl flex-1"
|
|
||||||
[class.text-right]="isOutgoing()"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="mb-0.5 flex items-baseline gap-2"
|
|
||||||
[class.justify-end]="isOutgoing()"
|
|
||||||
>
|
|
||||||
<span class="text-sm font-semibold text-foreground">{{ isOutgoing() ? 'You' : message().senderId }}</span>
|
|
||||||
<span class="text-xs text-muted-foreground">{{ message().timestamp | date: 'shortTime' }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (requiresRichMarkdown(message().content)) {
|
|
||||||
<div class="mt-1 inline-block max-w-full rounded-lg bg-card px-3 py-2 text-left text-sm text-foreground">
|
|
||||||
<app-chat-message-markdown [content]="message().content" />
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<p
|
|
||||||
class="mt-1 inline-block max-w-full whitespace-pre-wrap break-words rounded-lg bg-card px-3 py-2 text-left text-sm leading-5 text-foreground"
|
|
||||||
>
|
|
||||||
{{ message().content }}
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (isOutgoing()) {
|
|
||||||
<span
|
|
||||||
data-testid="message-status"
|
|
||||||
class="mt-1 inline-flex items-center gap-1 text-[10px] font-semibold uppercase text-muted-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
[name]="statusIcon(message().status)"
|
|
||||||
class="h-3 w-3"
|
|
||||||
[class.fill-current]="message().status === 'ACKNOWLEDGED'"
|
|
||||||
/>
|
|
||||||
{{ message().status }}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import {
|
|
||||||
Component,
|
|
||||||
computed,
|
|
||||||
input
|
|
||||||
} from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
|
||||||
import {
|
|
||||||
lucideCheck,
|
|
||||||
lucideCheckCheck,
|
|
||||||
lucideClock3
|
|
||||||
} from '@ng-icons/lucide';
|
|
||||||
import { ChatMessageMarkdownComponent } from '../../../chat';
|
|
||||||
import type { DirectMessage } from '../../domain/models/direct-message.model';
|
|
||||||
|
|
||||||
const RICH_MARKDOWN_PATTERNS = [
|
|
||||||
|
|
||||||
/!\[[^\]]*\]\([^\s)]+\)/,
|
|
||||||
|
|
||||||
/https?:\/\/[^\s)]+?\.(?:png|jpe?g|gif|webp|svg)(?:\?[^\s)]*)?/i,
|
|
||||||
|
|
||||||
/\[[^\]]+\]\([^\s)]+\)/
|
|
||||||
|
|
||||||
];
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-dm-message',
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
NgIcon,
|
|
||||||
ChatMessageMarkdownComponent
|
|
||||||
],
|
|
||||||
viewProviders: [provideIcons({ lucideCheck, lucideCheckCheck, lucideClock3 })],
|
|
||||||
templateUrl: './dm-message.component.html'
|
|
||||||
})
|
|
||||||
export class DmMessageComponent {
|
|
||||||
readonly message = input.required<DirectMessage>();
|
|
||||||
readonly currentUserId = input.required<string>();
|
|
||||||
readonly isOutgoing = computed(() => this.message().senderId === this.currentUserId());
|
|
||||||
|
|
||||||
requiresRichMarkdown(content: string): boolean {
|
|
||||||
return RICH_MARKDOWN_PATTERNS.some((pattern) => pattern.test(content));
|
|
||||||
}
|
|
||||||
|
|
||||||
statusIcon(status: DirectMessage['status']): string {
|
|
||||||
if (status === 'QUEUED') {
|
|
||||||
return 'lucideClock3';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === 'SENT') {
|
|
||||||
return 'lucideCheck';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'lucideCheckCheck';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
<!-- eslint-disable @angular-eslint/template/prefer-ngsrc -->
|
|
||||||
<div class="mt-2 flex w-full flex-col items-center gap-2 border-b border-border/70 pb-2">
|
|
||||||
<div class="group/server relative flex w-full justify-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="relative z-10 flex h-10 w-10 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent text-muted-foreground transition-[border-radius,box-shadow,background-color,color] duration-100 hover:rounded-lg hover:bg-card hover:text-foreground"
|
|
||||||
title="Direct Messages"
|
|
||||||
aria-label="Direct Messages"
|
|
||||||
[ngClass]="isOnDirectMessages() ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10 text-foreground' : 'rounded-xl bg-card'"
|
|
||||||
[attr.aria-current]="isOnDirectMessages() ? 'page' : null"
|
|
||||||
(click)="openDirectMessages()"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideMessageCircle"
|
|
||||||
class="h-4 w-4"
|
|
||||||
/>
|
|
||||||
@if (directMessages.totalUnreadCount() > 0) {
|
|
||||||
<span class="dm-rail-slide-in absolute -right-1 -top-1 h-3 w-3 rounded-full bg-amber-400 ring-2 ring-card"></span>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@for (item of railItems(); track item.id) {
|
|
||||||
<div class="group/server relative flex w-full justify-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="relative z-10 flex h-10 w-10 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent transition-[border-radius,box-shadow,background-color] duration-100 hover:rounded-lg hover:bg-card"
|
|
||||||
[class.dm-rail-slide-in]="!item.isExiting"
|
|
||||||
[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'"
|
|
||||||
[title]="item.label"
|
|
||||||
[attr.aria-current]="isSelectedItem(item) ? 'page' : null"
|
|
||||||
(click)="openItem(item)"
|
|
||||||
>
|
|
||||||
<div class="h-full w-full overflow-hidden rounded-[inherit]">
|
|
||||||
@if (item.avatarUrl) {
|
|
||||||
<img
|
|
||||||
[src]="item.avatarUrl"
|
|
||||||
[alt]="item.label"
|
|
||||||
class="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
} @else {
|
|
||||||
<div
|
|
||||||
class="flex h-full w-full items-center justify-center bg-secondary transition-colors"
|
|
||||||
[class.bg-primary/15]="isSelectedItem(item)"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="text-sm font-semibold text-muted-foreground transition-colors"
|
|
||||||
[class.text-foreground]="isSelectedItem(item)"
|
|
||||||
>{{ initial(item.label) }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span
|
|
||||||
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"
|
|
||||||
class="h-2.5 w-2.5"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
@if (!item.isExiting && item.unreadCount > 0) {
|
|
||||||
<span class="absolute -right-1 -top-1 min-w-5 rounded-full bg-amber-400 px-1.5 py-0.5 text-[10px] font-semibold text-black shadow-sm">
|
|
||||||
{{ formatUnreadCount(item.unreadCount) }}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
@keyframes dm-rail-slide-in {
|
|
||||||
0% {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-0.5rem) scale(0.94);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0) scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dm-rail-slide-out {
|
|
||||||
0% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0) scale(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-0.5rem) scale(0.94);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dm-rail-slide-in {
|
|
||||||
animation: dm-rail-slide-in 140ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dm-rail-slide-out {
|
|
||||||
animation: dm-rail-slide-out 140ms cubic-bezier(0.4, 0, 1, 1) both;
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
|
||||||
Component,
|
|
||||||
computed,
|
|
||||||
effect,
|
|
||||||
inject,
|
|
||||||
OnDestroy,
|
|
||||||
signal
|
|
||||||
} from '@angular/core';
|
|
||||||
import { toSignal } from '@angular/core/rxjs-interop';
|
|
||||||
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 { filter, map } from 'rxjs';
|
|
||||||
import { DirectMessageService } from '../../application/services/direct-message.service';
|
|
||||||
import { FriendService } from '../../application/services/friend.service';
|
|
||||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
|
||||||
import type { DirectMessageConversation } from '../../domain/models/direct-message.model';
|
|
||||||
import type { User } from '../../../../shared-kernel';
|
|
||||||
|
|
||||||
interface DmRailItem {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
avatarUrl?: string;
|
|
||||||
conversation: DirectMessageConversation | null;
|
|
||||||
isExiting: boolean;
|
|
||||||
user: User | null;
|
|
||||||
unreadCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EXIT_ANIMATION_MS = 160;
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-dm-rail',
|
|
||||||
standalone: true,
|
|
||||||
imports: [CommonModule, NgIcon],
|
|
||||||
viewProviders: [provideIcons({ lucideMessageCircle, lucideUser })],
|
|
||||||
templateUrl: './dm-rail.component.html',
|
|
||||||
styleUrl: './dm-rail.component.scss'
|
|
||||||
})
|
|
||||||
export class DmRailComponent implements OnDestroy {
|
|
||||||
private readonly router = inject(Router);
|
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly exitTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
||||||
readonly directMessages = inject(DirectMessageService);
|
|
||||||
readonly friends = inject(FriendService);
|
|
||||||
readonly users = this.store.selectSignal(selectAllUsers);
|
|
||||||
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
|
||||||
readonly currentUserId = computed(() => this.currentUser()?.oderId || this.currentUser()?.id || '');
|
|
||||||
readonly activeConversationId = toSignal(
|
|
||||||
this.router.events.pipe(
|
|
||||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
|
||||||
map((navigationEvent) => this.getConversationIdFromUrl(navigationEvent.urlAfterRedirects))
|
|
||||||
),
|
|
||||||
{ initialValue: this.getConversationIdFromUrl(this.router.url) }
|
|
||||||
);
|
|
||||||
readonly friendUsers = computed(() => this.users().filter((user) =>
|
|
||||||
this.friends.isFriend(user.oderId || user.id) && (user.oderId || user.id) !== this.currentUserId()
|
|
||||||
));
|
|
||||||
readonly railItems = signal<DmRailItem[]>([]);
|
|
||||||
readonly unreadRailItems = computed<DmRailItem[]>(() => {
|
|
||||||
const currentUserId = this.currentUserId();
|
|
||||||
const items = new Map<string, DmRailItem>();
|
|
||||||
|
|
||||||
for (const conversation of this.directMessages.conversations()) {
|
|
||||||
const peerId = conversation.participants.find((participantId) => participantId !== currentUserId);
|
|
||||||
|
|
||||||
if (!peerId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const knownUser = this.users().find((user) => user.id === peerId || user.oderId === peerId) ?? null;
|
|
||||||
const profile = conversation.participantProfiles[peerId];
|
|
||||||
|
|
||||||
items.set(peerId, {
|
|
||||||
id: peerId,
|
|
||||||
label: knownUser?.displayName || profile?.displayName || peerId,
|
|
||||||
avatarUrl: knownUser?.avatarUrl || profile?.avatarUrl,
|
|
||||||
conversation,
|
|
||||||
isExiting: false,
|
|
||||||
user: knownUser,
|
|
||||||
unreadCount: conversation.unreadCount
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const user of this.friendUsers()) {
|
|
||||||
const userId = user.oderId || user.id;
|
|
||||||
|
|
||||||
if (items.has(userId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
items.set(userId, {
|
|
||||||
id: userId,
|
|
||||||
label: user.displayName || user.username,
|
|
||||||
avatarUrl: user.avatarUrl,
|
|
||||||
conversation: null,
|
|
||||||
isExiting: false,
|
|
||||||
user,
|
|
||||||
unreadCount: 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(items.values()).filter((item) => item.unreadCount > 0);
|
|
||||||
});
|
|
||||||
readonly isOnDirectMessages = toSignal(
|
|
||||||
this.router.events.pipe(
|
|
||||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
|
||||||
map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/dm'))
|
|
||||||
),
|
|
||||||
{ initialValue: this.router.url.startsWith('/dm') }
|
|
||||||
);
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
effect(() => {
|
|
||||||
const unreadItems = this.unreadRailItems();
|
|
||||||
|
|
||||||
queueMicrotask(() => this.syncRailItems(unreadItems));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
for (const timer of this.exitTimers.values()) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.exitTimers.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
async openConversation(conversation: DirectMessageConversation): Promise<void> {
|
|
||||||
await this.router.navigate(['/dm', conversation.id]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async openFriend(user: User): Promise<void> {
|
|
||||||
const conversation = await this.directMessages.createConversation(user);
|
|
||||||
|
|
||||||
await this.router.navigate(['/dm', conversation.id]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async openItem(item: DmRailItem): Promise<void> {
|
|
||||||
if (item.conversation) {
|
|
||||||
await this.openConversation(item.conversation);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.user) {
|
|
||||||
await this.openFriend(item.user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
openDirectMessages(): void {
|
|
||||||
void this.router.navigate(['/dm']);
|
|
||||||
}
|
|
||||||
|
|
||||||
titleFor(conversation: DirectMessageConversation): string {
|
|
||||||
const peerId = conversation.participants.find((participantId) => participantId !== this.currentUserId());
|
|
||||||
|
|
||||||
return peerId ? conversation.participantProfiles[peerId]?.displayName || peerId : 'DM';
|
|
||||||
}
|
|
||||||
|
|
||||||
initial(label: string): string {
|
|
||||||
return label.trim()[0]?.toUpperCase() || '?';
|
|
||||||
}
|
|
||||||
|
|
||||||
conversationForFriend(user: User): DirectMessageConversation | null {
|
|
||||||
const userId = user.oderId || user.id;
|
|
||||||
|
|
||||||
return this.directMessages.conversations().find((conversation) => conversation.participants.includes(userId)) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
isSelectedConversation(conversation: DirectMessageConversation): boolean {
|
|
||||||
return this.activeConversationId() === conversation.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
isSelectedFriend(user: User): boolean {
|
|
||||||
const conversation = this.conversationForFriend(user);
|
|
||||||
|
|
||||||
return !!conversation && this.isSelectedConversation(conversation);
|
|
||||||
}
|
|
||||||
|
|
||||||
isSelectedItem(item: DmRailItem): boolean {
|
|
||||||
return !!item.conversation && this.isSelectedConversation(item.conversation);
|
|
||||||
}
|
|
||||||
|
|
||||||
formatUnreadCount(count: number): string {
|
|
||||||
return count > 99 ? '99+' : String(count);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getConversationIdFromUrl(url: string): string | null {
|
|
||||||
const match = /^\/dm\/([^/?#]+)/.exec(url);
|
|
||||||
|
|
||||||
return match ? decodeURIComponent(match[1]) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private syncRailItems(unreadItems: DmRailItem[]): void {
|
|
||||||
const unreadById = new Map(unreadItems.map((item) => [item.id, item]));
|
|
||||||
const currentItems = this.railItems();
|
|
||||||
const nextItems: DmRailItem[] = [];
|
|
||||||
|
|
||||||
for (const item of unreadItems) {
|
|
||||||
const timer = this.exitTimers.get(item.id);
|
|
||||||
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
this.exitTimers.delete(item.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
nextItems.push({ ...item, isExiting: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of currentItems) {
|
|
||||||
if (unreadById.has(item.id)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
nextItems.push({ ...item, isExiting: true });
|
|
||||||
|
|
||||||
if (!this.exitTimers.has(item.id)) {
|
|
||||||
this.exitTimers.set(item.id, setTimeout(() => {
|
|
||||||
this.exitTimers.delete(item.id);
|
|
||||||
this.railItems.update((items) => items.filter((entry) => entry.id !== item.id));
|
|
||||||
}, EXIT_ANIMATION_MS));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.railItems.set(nextItems);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
<div
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
|
||||||
Component,
|
|
||||||
computed,
|
|
||||||
effect,
|
|
||||||
inject,
|
|
||||||
OnDestroy
|
|
||||||
} 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 { 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 { 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';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-dm-workspace',
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
NgIcon,
|
|
||||||
UserAvatarComponent,
|
|
||||||
ThemeNodeDirective,
|
|
||||||
DmChatComponent,
|
|
||||||
VoiceControlsComponent
|
|
||||||
],
|
|
||||||
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(() => {
|
|
||||||
const conversationId = this.routeConversationId();
|
|
||||||
|
|
||||||
if (conversationId) {
|
|
||||||
void this.directMessages.openConversation(conversationId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstConversation = this.directMessages.conversations()[0];
|
|
||||||
|
|
||||||
if (firstConversation) {
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<button
|
|
||||||
type="button"
|
|
||||||
[attr.data-testid]="'friend-button-' + userId()"
|
|
||||||
class="grid h-8 w-8 place-items-center rounded-md border border-border bg-secondary text-foreground transition-colors hover:bg-secondary/80"
|
|
||||||
[attr.aria-pressed]="isFriend()"
|
|
||||||
[attr.aria-label]="isFriend() ? 'Remove friend' : 'Add friend'"
|
|
||||||
[title]="isFriend() ? 'Remove friend' : 'Add friend'"
|
|
||||||
(click)="toggle($event)"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
[name]="isFriend() ? 'lucideUserCheck' : 'lucideUserPlus'"
|
|
||||||
class="h-4 w-4"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import {
|
|
||||||
Component,
|
|
||||||
computed,
|
|
||||||
inject,
|
|
||||||
input
|
|
||||||
} from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
|
||||||
import { lucideUserCheck, lucideUserPlus } from '@ng-icons/lucide';
|
|
||||||
import { FriendService } from '../../application/services/friend.service';
|
|
||||||
import type { User } from '../../../../shared-kernel';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-friend-button',
|
|
||||||
standalone: true,
|
|
||||||
imports: [CommonModule, NgIcon],
|
|
||||||
viewProviders: [provideIcons({ lucideUserCheck, lucideUserPlus })],
|
|
||||||
templateUrl: './friend-button.component.html'
|
|
||||||
})
|
|
||||||
export class FriendButtonComponent {
|
|
||||||
private readonly friends = inject(FriendService);
|
|
||||||
|
|
||||||
readonly user = input.required<User>();
|
|
||||||
readonly userId = computed(() => this.user().oderId || this.user().id);
|
|
||||||
readonly isFriend = computed(() => this.friends.isFriend(this.userId()));
|
|
||||||
|
|
||||||
toggle(event: Event): void {
|
|
||||||
event.stopPropagation();
|
|
||||||
void this.friends.toggleFriend(this.userId());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
<section class="min-h-full p-3">
|
|
||||||
<div class="mb-2 flex items-center justify-between gap-3">
|
|
||||||
<h3 class="text-sm font-semibold text-foreground">People</h3>
|
|
||||||
<span class="text-xs text-muted-foreground">{{ matchingUsers().length }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (friendResults().length > 0) {
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="mb-1 flex items-center justify-between">
|
|
||||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Friends</h4>
|
|
||||||
<span class="text-xs text-muted-foreground">{{ friendResults().length }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
@for (user of friendResults(); track user.id) {
|
|
||||||
<div
|
|
||||||
class="group flex items-center gap-2 rounded-lg border border-emerald-500/25 bg-emerald-500/10 p-2"
|
|
||||||
[attr.data-testid]="'friend-card-' + userKey(user)"
|
|
||||||
>
|
|
||||||
<app-user-avatar
|
|
||||||
[avatarUrl]="user.avatarUrl"
|
|
||||||
[name]="user.displayName"
|
|
||||||
[showStatusBadge]="true"
|
|
||||||
[status]="user.status"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<p class="truncate text-sm font-semibold text-foreground">{{ user.displayName }}</p>
|
|
||||||
<p class="truncate text-xs text-muted-foreground">{{ user.username }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
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]="'message-friend-' + userKey(user)"
|
|
||||||
class="grid h-8 w-8 place-items-center rounded-md bg-primary text-primary-foreground transition-colors hover:bg-primary/90"
|
|
||||||
[attr.aria-label]="'Message ' + user.displayName"
|
|
||||||
[title]="'Message ' + user.displayName"
|
|
||||||
(click)="messageUser(user)"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideMessageCircle"
|
|
||||||
class="h-4 w-4"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (friendResults().length > 0) {
|
|
||||||
<div class="mb-1 flex items-center justify-between">
|
|
||||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Others</h4>
|
|
||||||
<span class="text-xs text-muted-foreground">{{ results().length }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (matchingUsers().length === 0) {
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-3 text-sm text-muted-foreground">
|
|
||||||
<ng-icon
|
|
||||||
name="lucideSearch"
|
|
||||||
class="h-4 w-4"
|
|
||||||
/>
|
|
||||||
No users found
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
@for (user of results(); track user.id) {
|
|
||||||
<div
|
|
||||||
class="group flex items-center gap-2 rounded-lg border border-border bg-card p-2 transition-colors hover:bg-card/80"
|
|
||||||
[attr.data-testid]="'user-card-' + userKey(user)"
|
|
||||||
>
|
|
||||||
<app-user-avatar
|
|
||||||
[avatarUrl]="user.avatarUrl"
|
|
||||||
[name]="user.displayName"
|
|
||||||
[showStatusBadge]="true"
|
|
||||||
[status]="user.status"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<p class="truncate text-sm font-semibold text-foreground">{{ user.displayName }}</p>
|
|
||||||
</div>
|
|
||||||
<p class="truncate text-xs text-muted-foreground">{{ user.username }}</p>
|
|
||||||
@if (user.description) {
|
|
||||||
<p class="line-clamp-1 text-xs text-muted-foreground">{{ user.description }}</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
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]="'message-user-' + userKey(user)"
|
|
||||||
class="grid h-8 w-8 place-items-center rounded-md bg-primary text-primary-foreground transition-colors hover:bg-primary/90"
|
|
||||||
[attr.aria-label]="'Message ' + user.displayName"
|
|
||||||
[title]="'Message ' + user.displayName"
|
|
||||||
(click)="messageUser(user)"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideMessageCircle"
|
|
||||||
class="h-4 w-4"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
|
||||||
Component,
|
|
||||||
computed,
|
|
||||||
inject,
|
|
||||||
input
|
|
||||||
} from '@angular/core';
|
|
||||||
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 { 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 { FriendService } from '../../application/services/friend.service';
|
|
||||||
import { FriendButtonComponent } from '../friend-button/friend-button.component';
|
|
||||||
import type { User } from '../../../../shared-kernel';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-user-search-list',
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
NgIcon,
|
|
||||||
UserAvatarComponent,
|
|
||||||
FriendButtonComponent
|
|
||||||
],
|
|
||||||
viewProviders: [provideIcons({ lucideMessageCircle, 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 friends = inject(FriendService);
|
|
||||||
readonly searchQuery = input('');
|
|
||||||
readonly users = this.store.selectSignal(selectAllUsers);
|
|
||||||
readonly savedRooms = this.store.selectSignal(selectSavedRooms);
|
|
||||||
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
|
||||||
readonly discoveredUsers = computed(() => {
|
|
||||||
const usersById = new Map<string, User>();
|
|
||||||
|
|
||||||
for (const user of this.users()) {
|
|
||||||
usersById.set(user.oderId || user.id, user);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const room of this.savedRooms()) {
|
|
||||||
for (const member of room.members ?? []) {
|
|
||||||
const userId = member.oderId || member.id;
|
|
||||||
|
|
||||||
if (usersById.has(userId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
usersById.set(userId, {
|
|
||||||
id: member.id,
|
|
||||||
oderId: userId,
|
|
||||||
username: member.username,
|
|
||||||
displayName: member.displayName,
|
|
||||||
description: member.description,
|
|
||||||
avatarUrl: member.avatarUrl,
|
|
||||||
profileUpdatedAt: member.profileUpdatedAt,
|
|
||||||
role: member.role,
|
|
||||||
joinedAt: member.joinedAt,
|
|
||||||
status: 'disconnected'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(usersById.values());
|
|
||||||
});
|
|
||||||
readonly matchingUsers = computed(() => {
|
|
||||||
const query = this.normalizedSearchQuery();
|
|
||||||
const currentUserId = this.currentUserKey();
|
|
||||||
|
|
||||||
return this.discoveredUsers()
|
|
||||||
.filter((user) => this.userKey(user) !== currentUserId)
|
|
||||||
.filter((user) => this.matchesQuery(user, query))
|
|
||||||
.slice(0, 24);
|
|
||||||
});
|
|
||||||
readonly friendResults = computed(() => this.matchingUsers().filter((user) => this.friends.isFriend(this.userKey(user))));
|
|
||||||
readonly results = computed(() => {
|
|
||||||
const friendIds = this.friends.friendIds();
|
|
||||||
|
|
||||||
return this.matchingUsers().filter((user) => !friendIds.has(this.userKey(user)));
|
|
||||||
});
|
|
||||||
|
|
||||||
async messageUser(user: User): Promise<void> {
|
|
||||||
const conversation = await this.directMessages.createConversation(user);
|
|
||||||
|
|
||||||
await this.router.navigate(['/dm', conversation.id]);
|
|
||||||
}
|
|
||||||
|
|
||||||
userKey(user: User): string {
|
|
||||||
return user.oderId || user.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
initial(label: string): string {
|
|
||||||
return label.trim()[0]?.toUpperCase() || '?';
|
|
||||||
}
|
|
||||||
|
|
||||||
private currentUserKey(): string {
|
|
||||||
const currentUser = this.currentUser();
|
|
||||||
|
|
||||||
return currentUser ? this.userKey(currentUser) : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizedSearchQuery(): string {
|
|
||||||
return this.searchQuery().trim()
|
|
||||||
.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
private matchesQuery(user: User, query: string): boolean {
|
|
||||||
if (!query) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
user.displayName,
|
|
||||||
user.username,
|
|
||||||
user.description
|
|
||||||
]
|
|
||||||
.filter((value): value is string => !!value)
|
|
||||||
.some((value) => value.toLowerCase().includes(query));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export * from './application/services/direct-message.service';
|
|
||||||
export * from './application/services/friend.service';
|
|
||||||
export * from './application/services/offline-message-queue.service';
|
|
||||||
export * from './application/services/peer-delivery.service';
|
|
||||||
export * from './domain/models/direct-message.model';
|
|
||||||
export * from './domain/logic/direct-message.logic';
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { Injectable } from '@angular/core';
|
|
||||||
import type { DirectMessageConversation } from '../domain/models/direct-message.model';
|
|
||||||
|
|
||||||
const STORAGE_PREFIX = 'metoyou_direct_message_conversations';
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class DirectMessageRepository {
|
|
||||||
async loadConversations(ownerId: string): Promise<DirectMessageConversation[]> {
|
|
||||||
return this.read(ownerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveConversation(ownerId: string, conversation: DirectMessageConversation): Promise<void> {
|
|
||||||
const conversations = this.read(ownerId).filter((entry) => entry.id !== conversation.id);
|
|
||||||
|
|
||||||
conversations.push(conversation);
|
|
||||||
this.write(ownerId, conversations);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getConversation(ownerId: string, conversationId: string): Promise<DirectMessageConversation | null> {
|
|
||||||
return this.read(ownerId).find((conversation) => conversation.id === conversationId) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteConversation(ownerId: string, conversationId: string): Promise<void> {
|
|
||||||
this.write(ownerId, this.read(ownerId).filter((conversation) => conversation.id !== conversationId));
|
|
||||||
}
|
|
||||||
|
|
||||||
async markRead(ownerId: string, conversationId: string): Promise<void> {
|
|
||||||
const conversations = this.read(ownerId).map((conversation) =>
|
|
||||||
conversation.id === conversationId ? { ...conversation, unreadCount: 0 } : conversation
|
|
||||||
);
|
|
||||||
|
|
||||||
this.write(ownerId, conversations);
|
|
||||||
}
|
|
||||||
|
|
||||||
private read(ownerId: string): DirectMessageConversation[] {
|
|
||||||
const rawValue = localStorage.getItem(this.key(ownerId));
|
|
||||||
|
|
||||||
if (!rawValue) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(rawValue) as DirectMessageConversation[];
|
|
||||||
|
|
||||||
return Array.isArray(parsed) ? parsed : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private write(ownerId: string, conversations: DirectMessageConversation[]): void {
|
|
||||||
localStorage.setItem(this.key(ownerId), JSON.stringify(conversations));
|
|
||||||
}
|
|
||||||
|
|
||||||
private key(ownerId: string): string {
|
|
||||||
return `${STORAGE_PREFIX}:${ownerId}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { Injectable } from '@angular/core';
|
|
||||||
import type { Friend } from '../domain/models/direct-message.model';
|
|
||||||
|
|
||||||
const STORAGE_PREFIX = 'metoyou_friends';
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class FriendRepository {
|
|
||||||
async loadFriends(ownerId: string): Promise<Friend[]> {
|
|
||||||
return this.read(ownerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async addFriend(ownerId: string, friend: Friend): Promise<void> {
|
|
||||||
const friends = this.read(ownerId).filter((entry) => entry.userId !== friend.userId);
|
|
||||||
|
|
||||||
friends.push(friend);
|
|
||||||
this.write(ownerId, friends);
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeFriend(ownerId: string, userId: string): Promise<void> {
|
|
||||||
this.write(
|
|
||||||
ownerId,
|
|
||||||
this.read(ownerId).filter((entry) => entry.userId !== userId)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private read(ownerId: string): Friend[] {
|
|
||||||
const rawValue = localStorage.getItem(this.key(ownerId));
|
|
||||||
|
|
||||||
if (!rawValue) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(rawValue) as Friend[];
|
|
||||||
|
|
||||||
return Array.isArray(parsed) ? parsed : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private write(ownerId: string, friends: Friend[]): void {
|
|
||||||
localStorage.setItem(this.key(ownerId), JSON.stringify(friends));
|
|
||||||
}
|
|
||||||
|
|
||||||
private key(ownerId: string): string {
|
|
||||||
return `${STORAGE_PREFIX}:${ownerId}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { Injectable } from '@angular/core';
|
|
||||||
|
|
||||||
const STORAGE_PREFIX = 'metoyou_direct_message_queue';
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class OfflineQueueRepository {
|
|
||||||
async load(ownerId: string): Promise<string[]> {
|
|
||||||
return this.read(ownerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async enqueue(ownerId: string, messageId: string): Promise<void> {
|
|
||||||
this.write(ownerId, Array.from(new Set([...this.read(ownerId), messageId])));
|
|
||||||
}
|
|
||||||
|
|
||||||
async remove(ownerId: string, messageId: string): Promise<void> {
|
|
||||||
this.write(
|
|
||||||
ownerId,
|
|
||||||
this.read(ownerId).filter((entry) => entry !== messageId)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async clear(ownerId: string): Promise<void> {
|
|
||||||
this.write(ownerId, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
private read(ownerId: string): string[] {
|
|
||||||
const rawValue = localStorage.getItem(this.key(ownerId));
|
|
||||||
|
|
||||||
if (!rawValue) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(rawValue) as string[];
|
|
||||||
|
|
||||||
return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === 'string') : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private write(ownerId: string, messageIds: string[]): void {
|
|
||||||
localStorage.setItem(this.key(ownerId), JSON.stringify(messageIds));
|
|
||||||
}
|
|
||||||
|
|
||||||
private key(ownerId: string): string {
|
|
||||||
return `${STORAGE_PREFIX}:${ownerId}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
import {
|
|
||||||
Injector,
|
|
||||||
NgZone,
|
|
||||||
runInInjectionContext,
|
|
||||||
signal
|
|
||||||
} from '@angular/core';
|
|
||||||
import { HttpClient } from '@angular/common/http';
|
|
||||||
import { Store } from '@ngrx/store';
|
|
||||||
import { Subject, of } from 'rxjs';
|
|
||||||
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
|
||||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
|
||||||
import { ServerDirectoryFacade } from '../../server-directory';
|
|
||||||
import { UsersActions } from '../../../store/users/users.actions';
|
|
||||||
import { selectAllUsers, selectCurrentUser } from '../../../store/users/users.selectors';
|
|
||||||
import type {
|
|
||||||
ChatEvent,
|
|
||||||
GameActivity,
|
|
||||||
GameMatchResponse,
|
|
||||||
MatchedGame,
|
|
||||||
User
|
|
||||||
} from '../../../shared-kernel';
|
|
||||||
import { GameActivityService } from './game-activity.service';
|
|
||||||
|
|
||||||
const alice = createUser('alice-id', 'alice-oder', 'Alice');
|
|
||||||
const bob = createUser('bob-id', 'bob-oder', 'Bob');
|
|
||||||
const carol = createUser('carol-id', 'carol-oder', 'Carol');
|
|
||||||
|
|
||||||
let contexts: ServiceContext[] = [];
|
|
||||||
|
|
||||||
describe('GameActivityService sync', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
contexts = [];
|
|
||||||
installLocalStorageMock();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
for (const context of contexts) {
|
|
||||||
context.service.ngOnDestroy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('subscribes to incoming activity on browser clients without local process scanning', () => {
|
|
||||||
const context = createServiceContext({
|
|
||||||
currentUser: bob,
|
|
||||||
allUsers: [alice, bob],
|
|
||||||
electronApi: null
|
|
||||||
});
|
|
||||||
|
|
||||||
context.service.start();
|
|
||||||
context.incomingMessages.next({
|
|
||||||
type: 'game-activity',
|
|
||||||
fromPeerId: alice.oderId,
|
|
||||||
oderId: alice.oderId,
|
|
||||||
displayName: alice.displayName,
|
|
||||||
gameActivity: createActivity('game-1', 'Deep Rock Galactic')
|
|
||||||
} as ChatEvent);
|
|
||||||
|
|
||||||
expect(context.store.dispatch).toHaveBeenCalledWith(UsersActions.updateGameActivity({
|
|
||||||
userId: alice.id,
|
|
||||||
gameActivity: createActivity('game-1', 'Deep Rock Galactic')
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('broadcasts local activity changes to peers already online', async () => {
|
|
||||||
const matchedGame = createMatchedGame('game-2', 'Stardew Valley', 'StardewValley.exe');
|
|
||||||
const context = createServiceContext({
|
|
||||||
currentUser: alice,
|
|
||||||
allUsers: [alice, bob],
|
|
||||||
processNames: ['StardewValley.exe'],
|
|
||||||
gameMatchResponse: { games: [matchedGame] }
|
|
||||||
});
|
|
||||||
|
|
||||||
context.service.start();
|
|
||||||
|
|
||||||
await vi.waitFor(() => expect(context.realtime.broadcastMessage).toHaveBeenCalledWith(expect.objectContaining({
|
|
||||||
type: 'game-activity',
|
|
||||||
oderId: alice.oderId,
|
|
||||||
displayName: alice.displayName,
|
|
||||||
gameActivity: expect.objectContaining({
|
|
||||||
id: matchedGame.id,
|
|
||||||
name: matchedGame.name,
|
|
||||||
iconUrl: matchedGame.iconUrl,
|
|
||||||
store: matchedGame.store
|
|
||||||
})
|
|
||||||
})));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends current activity directly to peers that connect after the status was set', async () => {
|
|
||||||
const matchedGame = createMatchedGame('game-3', 'Hades', 'Hades.exe');
|
|
||||||
const context = createServiceContext({
|
|
||||||
currentUser: alice,
|
|
||||||
allUsers: [
|
|
||||||
alice,
|
|
||||||
bob,
|
|
||||||
carol
|
|
||||||
],
|
|
||||||
processNames: ['Hades.exe'],
|
|
||||||
gameMatchResponse: { games: [matchedGame] }
|
|
||||||
});
|
|
||||||
|
|
||||||
context.service.start();
|
|
||||||
|
|
||||||
await vi.waitFor(() => expect(context.realtime.broadcastMessage).toHaveBeenCalled());
|
|
||||||
|
|
||||||
context.realtime.sendToPeer.mockClear();
|
|
||||||
context.peerConnected.next(carol.oderId);
|
|
||||||
|
|
||||||
expect(context.realtime.sendToPeer).toHaveBeenCalledWith(carol.oderId, expect.objectContaining({
|
|
||||||
type: 'game-activity',
|
|
||||||
oderId: alice.oderId,
|
|
||||||
displayName: alice.displayName,
|
|
||||||
gameActivity: expect.objectContaining({
|
|
||||||
id: matchedGame.id,
|
|
||||||
name: matchedGame.name,
|
|
||||||
iconUrl: matchedGame.iconUrl,
|
|
||||||
store: matchedGame.store
|
|
||||||
})
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
interface ServiceContextOptions {
|
|
||||||
currentUser: User;
|
|
||||||
allUsers: User[];
|
|
||||||
electronApi?: { getRunningProcessNames: () => Promise<string[]> } | null;
|
|
||||||
processNames?: string[];
|
|
||||||
gameMatchResponse?: GameMatchResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServiceContext {
|
|
||||||
incomingMessages: Subject<ChatEvent>;
|
|
||||||
peerConnected: Subject<string>;
|
|
||||||
realtime: {
|
|
||||||
broadcastMessage: ReturnType<typeof vi.fn>;
|
|
||||||
sendToPeer: ReturnType<typeof vi.fn>;
|
|
||||||
};
|
|
||||||
service: GameActivityService;
|
|
||||||
store: {
|
|
||||||
dispatch: ReturnType<typeof vi.fn>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createServiceContext(options: ServiceContextOptions): ServiceContext {
|
|
||||||
const currentUser = signal<User | null>(options.currentUser);
|
|
||||||
const allUsers = signal<User[]>(options.allUsers);
|
|
||||||
const incomingMessages = new Subject<ChatEvent>();
|
|
||||||
const peerConnected = new Subject<string>();
|
|
||||||
const realtime = {
|
|
||||||
onMessageReceived: incomingMessages.asObservable(),
|
|
||||||
onPeerConnected: peerConnected.asObservable(),
|
|
||||||
broadcastMessage: vi.fn(),
|
|
||||||
sendToPeer: vi.fn()
|
|
||||||
};
|
|
||||||
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 GameActivityService test.');
|
|
||||||
})
|
|
||||||
};
|
|
||||||
const electronApi = options.electronApi === undefined
|
|
||||||
? { getRunningProcessNames: vi.fn(async () => options.processNames ?? []) }
|
|
||||||
: options.electronApi;
|
|
||||||
const injector = Injector.create({
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: ElectronBridgeService,
|
|
||||||
useValue: { getApi: () => electronApi }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: HttpClient,
|
|
||||||
useValue: {
|
|
||||||
post: vi.fn(() => of(options.gameMatchResponse ?? { games: [] }))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: NgZone,
|
|
||||||
useValue: {
|
|
||||||
run: (fn: () => void) => fn(),
|
|
||||||
runOutsideAngular: (fn: () => void) => fn()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: RealtimeSessionFacade,
|
|
||||||
useValue: realtime
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: ServerDirectoryFacade,
|
|
||||||
useValue: { getApiBaseUrl: () => 'http://localhost:3001/api' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: Store,
|
|
||||||
useValue: store
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
const service = runInInjectionContext(injector, () => new GameActivityService());
|
|
||||||
const context = {
|
|
||||||
incomingMessages,
|
|
||||||
peerConnected,
|
|
||||||
realtime,
|
|
||||||
service,
|
|
||||||
store
|
|
||||||
};
|
|
||||||
|
|
||||||
contexts.push(context);
|
|
||||||
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createUser(id: string, oderId: string, displayName: string): User {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
oderId,
|
|
||||||
username: displayName.toLowerCase(),
|
|
||||||
displayName,
|
|
||||||
status: 'online',
|
|
||||||
role: 'member',
|
|
||||||
joinedAt: 1
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createActivity(id: string, name: string): GameActivity {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
startedAt: 1_000
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMatchedGame(id: string, name: string, processName: string): MatchedGame {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
iconUrl: `https://img.example.test/${id}.jpg`,
|
|
||||||
store: {
|
|
||||||
name: 'Steam',
|
|
||||||
slug: 'steam',
|
|
||||||
url: `https://store.steampowered.com/search/?term=${encodeURIComponent(name)}`
|
|
||||||
},
|
|
||||||
processName
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function installLocalStorageMock(): void {
|
|
||||||
const values = new Map<string, string>();
|
|
||||||
|
|
||||||
vi.stubGlobal('localStorage', {
|
|
||||||
getItem: (key: string) => values.get(key) ?? null,
|
|
||||||
setItem: (key: string, value: string) => values.set(key, value),
|
|
||||||
removeItem: (key: string) => values.delete(key),
|
|
||||||
clear: () => values.clear()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,581 +0,0 @@
|
|||||||
import {
|
|
||||||
Injectable,
|
|
||||||
NgZone,
|
|
||||||
OnDestroy,
|
|
||||||
inject
|
|
||||||
} from '@angular/core';
|
|
||||||
import { HttpClient } from '@angular/common/http';
|
|
||||||
import { Store } from '@ngrx/store';
|
|
||||||
import { Subscription, firstValueFrom } from 'rxjs';
|
|
||||||
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
|
||||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
|
||||||
import { ServerDirectoryFacade } from '../../server-directory';
|
|
||||||
import { UsersActions } from '../../../store/users/users.actions';
|
|
||||||
import { selectAllUsers, selectCurrentUser } from '../../../store/users/users.selectors';
|
|
||||||
import type {
|
|
||||||
ChatEvent,
|
|
||||||
GameActivity,
|
|
||||||
GameStoreLink,
|
|
||||||
GameMatchResponse,
|
|
||||||
MatchedGame,
|
|
||||||
User
|
|
||||||
} from '../../../shared-kernel';
|
|
||||||
|
|
||||||
const DEFAULT_SCAN_INTERVAL_MS = 10_000;
|
|
||||||
const MIN_SCAN_INTERVAL_MS = 5_000;
|
|
||||||
const MAX_SCAN_INTERVAL_MS = 60_000;
|
|
||||||
const MAX_PROCESS_NAMES_PER_REQUEST = 256;
|
|
||||||
const MAX_CANDIDATE_PROCESSES_PER_REQUEST = 12;
|
|
||||||
const POSITIVE_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
||||||
const NEGATIVE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
||||||
const MAX_LOCAL_CACHE_ENTRIES = 128;
|
|
||||||
const SCAN_INTERVAL_STORAGE_KEY = 'metoyou_game_scan_interval_ms';
|
|
||||||
const GAME_MATCH_CACHE_STORAGE_KEY = 'metoyou_game_match_cache_v1';
|
|
||||||
|
|
||||||
interface CachedGameMatch {
|
|
||||||
expiresAt: number;
|
|
||||||
game: MatchedGame | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CandidateProcess {
|
|
||||||
processName: string;
|
|
||||||
score: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const IGNORED_PROCESS_NAMES = new Set([
|
|
||||||
'agent',
|
|
||||||
'bash',
|
|
||||||
'baloorunner',
|
|
||||||
'chrome',
|
|
||||||
'code',
|
|
||||||
'conhost',
|
|
||||||
'cursor',
|
|
||||||
'csrss',
|
|
||||||
'dbus daemon',
|
|
||||||
'discord',
|
|
||||||
'dwm',
|
|
||||||
'electron',
|
|
||||||
'explorer',
|
|
||||||
'firefox',
|
|
||||||
'gameoverlayui',
|
|
||||||
'gamemoded',
|
|
||||||
'gamescopereaper',
|
|
||||||
'gnome shell',
|
|
||||||
'metoyou',
|
|
||||||
'node',
|
|
||||||
'npm',
|
|
||||||
'powershell',
|
|
||||||
'pulseaudio',
|
|
||||||
'steam',
|
|
||||||
'steamwebhelper',
|
|
||||||
'systemd',
|
|
||||||
'taskhostw',
|
|
||||||
'wininit',
|
|
||||||
'winlogon',
|
|
||||||
'xorg'
|
|
||||||
]);
|
|
||||||
const IGNORED_PROCESS_PATTERNS = [
|
|
||||||
new RegExp('(^|\\s)(agent|browser|daemon|desktop|helper|indexer|launcher|monitor|renderer|runner)(\\s|$)'),
|
|
||||||
new RegExp('(^|\\s)(service|settings|shell|tray|updater|utility|watcher|worker)(\\s|$)'),
|
|
||||||
new RegExp('(^|\\s)(audio|bluetooth|clipboard|crash|dbus|file|gpu|input|network|notification)(\\s|$)'),
|
|
||||||
new RegExp('(^|\\s)(portal|proxy|screen|session|sync|system|tracker|web|window)(\\s|$)'),
|
|
||||||
/^(appimage|at spi|baloo|dconf|gvfs|ibus|kde|kworker)/,
|
|
||||||
/^(pipewire|plasmashell|pulseaudio|xdg|xwayland|zeitgeist)/,
|
|
||||||
/(helper|service|daemon|runner|tracker|portal|updater|worker)$/
|
|
||||||
];
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class GameActivityService implements OnDestroy {
|
|
||||||
private readonly electron = inject(ElectronBridgeService);
|
|
||||||
private readonly http = inject(HttpClient);
|
|
||||||
private readonly ngZone = inject(NgZone);
|
|
||||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
|
||||||
|
|
||||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
|
||||||
private readonly allUsers = this.store.selectSignal(selectAllUsers);
|
|
||||||
private readonly subscriptions = new Subscription();
|
|
||||||
|
|
||||||
private scanTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
private lastProcessHash = '';
|
|
||||||
private currentActivity: GameActivity | null = null;
|
|
||||||
private scanInFlight = false;
|
|
||||||
private started = false;
|
|
||||||
|
|
||||||
start(): void {
|
|
||||||
if (this.started) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.started = true;
|
|
||||||
|
|
||||||
this.subscriptions.add(
|
|
||||||
this.webrtc.onMessageReceived.subscribe((event) => this.handlePeerEvent(event))
|
|
||||||
);
|
|
||||||
|
|
||||||
this.subscriptions.add(
|
|
||||||
this.webrtc.onPeerConnected.subscribe((peerId) => this.sendCurrentActivityToPeer(peerId))
|
|
||||||
);
|
|
||||||
|
|
||||||
const api = this.electron.getApi();
|
|
||||||
|
|
||||||
if (!api?.getRunningProcessNames) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ngZone.runOutsideAngular(() => {
|
|
||||||
this.scanTimer = setInterval(() => {
|
|
||||||
void this.scanRunningProcesses();
|
|
||||||
}, this.getScanIntervalMs());
|
|
||||||
});
|
|
||||||
|
|
||||||
void this.scanRunningProcesses();
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
private stop(): void {
|
|
||||||
if (this.scanTimer) {
|
|
||||||
clearInterval(this.scanTimer);
|
|
||||||
this.scanTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.subscriptions.unsubscribe();
|
|
||||||
this.started = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async scanRunningProcesses(): Promise<void> {
|
|
||||||
if (this.scanInFlight || !this.currentUser()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const api = this.electron.getApi();
|
|
||||||
|
|
||||||
if (!api?.getRunningProcessNames) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.scanInFlight = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const processNames = (await api.getRunningProcessNames()).slice(0, MAX_PROCESS_NAMES_PER_REQUEST);
|
|
||||||
const processHash = this.buildProcessHash(processNames);
|
|
||||||
|
|
||||||
if (processHash === this.lastProcessHash) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.lastProcessHash = processHash;
|
|
||||||
|
|
||||||
const matchedGame = await this.matchRunningGame(processNames);
|
|
||||||
|
|
||||||
this.ngZone.run(() => this.applyMatchedGame(matchedGame));
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
} finally {
|
|
||||||
this.scanInFlight = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async matchRunningGame(processes: string[]): Promise<MatchedGame | null> {
|
|
||||||
const candidates = this.selectCandidateProcesses(processes);
|
|
||||||
const cachedGame = this.findCachedGame(candidates);
|
|
||||||
|
|
||||||
if (cachedGame !== undefined) {
|
|
||||||
return cachedGame;
|
|
||||||
}
|
|
||||||
|
|
||||||
const unknownCandidates = candidates
|
|
||||||
.filter((candidate) => !this.hasFreshCacheEntry(candidate.processName))
|
|
||||||
.slice(0, MAX_CANDIDATE_PROCESSES_PER_REQUEST);
|
|
||||||
|
|
||||||
if (unknownCandidates.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiBase = this.serverDirectory.getApiBaseUrl();
|
|
||||||
const currentUser = this.currentUser();
|
|
||||||
const response = await firstValueFrom(
|
|
||||||
this.http.post<GameMatchResponse>(`${apiBase}/games/match`, {
|
|
||||||
processes: unknownCandidates.map((candidate) => candidate.processName),
|
|
||||||
userId: currentUser?.id ?? currentUser?.oderId
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
this.storeMatchResponse(unknownCandidates, response);
|
|
||||||
|
|
||||||
return response.games[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private selectCandidateProcesses(processes: string[]): CandidateProcess[] {
|
|
||||||
const candidates = new Map<string, CandidateProcess>();
|
|
||||||
|
|
||||||
for (const processName of processes.slice(0, MAX_PROCESS_NAMES_PER_REQUEST)) {
|
|
||||||
const normalized = this.normalizeProcessName(processName);
|
|
||||||
|
|
||||||
if (!normalized) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheKey = this.normalizeCacheKey(normalized);
|
|
||||||
const existing = candidates.get(cacheKey);
|
|
||||||
const candidate = {
|
|
||||||
processName,
|
|
||||||
score: this.scoreCandidateProcess(processName, normalized)
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!existing || candidate.score > existing.score) {
|
|
||||||
candidates.set(cacheKey, candidate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(candidates.values())
|
|
||||||
.sort((left, right) => right.score - left.score || left.processName.localeCompare(right.processName));
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeProcessName(value: string): string {
|
|
||||||
const normalized = value.trim()
|
|
||||||
.replace(/\.exe$/i, '')
|
|
||||||
.replace(/[_-]+/g, ' ')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
const cacheKey = this.normalizeCacheKey(normalized);
|
|
||||||
|
|
||||||
if (normalized.length < 4 || normalized.length > 96 || this.shouldIgnoreProcessName(cacheKey)) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
private shouldIgnoreProcessName(cacheKey: string): boolean {
|
|
||||||
return IGNORED_PROCESS_NAMES.has(cacheKey)
|
|
||||||
|| IGNORED_PROCESS_PATTERNS.some((pattern) => pattern.test(cacheKey));
|
|
||||||
}
|
|
||||||
|
|
||||||
private scoreCandidateProcess(rawValue: string, normalized: string): number {
|
|
||||||
let score = 0;
|
|
||||||
|
|
||||||
if (/\.exe$/i.test(rawValue.trim())) {
|
|
||||||
score += 12;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/[A-Z]/.test(normalized) && /[a-z]/.test(normalized)) {
|
|
||||||
score += 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/\d/.test(normalized)) {
|
|
||||||
score += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalized.length >= 5 && normalized.length <= 32) {
|
|
||||||
score += 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalized.includes(' ')) {
|
|
||||||
score -= 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
return score;
|
|
||||||
}
|
|
||||||
|
|
||||||
private findCachedGame(candidates: CandidateProcess[]): MatchedGame | null | undefined {
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let hasCachedMissForEveryCandidate = true;
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
const cached = this.getCachedMatch(candidate.processName);
|
|
||||||
|
|
||||||
if (cached === undefined) {
|
|
||||||
hasCachedMissForEveryCandidate = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cached) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return hasCachedMissForEveryCandidate ? null : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private storeMatchResponse(candidates: CandidateProcess[], response: GameMatchResponse): void {
|
|
||||||
for (const game of response.games) {
|
|
||||||
this.setCachedMatch(game.processName, game, POSITIVE_CACHE_TTL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.rateLimited) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchedProcessKeys = new Set(response.games.map((game) => this.normalizeCacheKey(game.processName)));
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (!matchedProcessKeys.has(this.normalizeCacheKey(candidate.processName))) {
|
|
||||||
this.setCachedMatch(candidate.processName, null, NEGATIVE_CACHE_TTL_MS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private hasFreshCacheEntry(processName: string): boolean {
|
|
||||||
return this.getCachedMatch(processName) !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getCachedMatch(processName: string): MatchedGame | null | undefined {
|
|
||||||
const cache = this.readMatchCache();
|
|
||||||
const cacheKey = this.normalizeCacheKey(processName);
|
|
||||||
const cached = cache[cacheKey];
|
|
||||||
|
|
||||||
if (!cached) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cached.expiresAt <= Date.now()) {
|
|
||||||
this.writeMatchCache(Object.fromEntries(
|
|
||||||
Object.entries(cache).filter(([key]) => key !== cacheKey)
|
|
||||||
));
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return cached.game;
|
|
||||||
}
|
|
||||||
|
|
||||||
private setCachedMatch(processName: string, game: MatchedGame | null, ttlMs: number): void {
|
|
||||||
const cache = this.readMatchCache();
|
|
||||||
|
|
||||||
cache[this.normalizeCacheKey(processName)] = {
|
|
||||||
expiresAt: Date.now() + ttlMs,
|
|
||||||
game
|
|
||||||
};
|
|
||||||
|
|
||||||
this.writeMatchCache(cache);
|
|
||||||
}
|
|
||||||
|
|
||||||
private readMatchCache(): Record<string, CachedGameMatch> {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(localStorage.getItem(GAME_MATCH_CACHE_STORAGE_KEY) ?? '{}') as unknown;
|
|
||||||
|
|
||||||
return this.normalizeMatchCache(parsed);
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeMatchCache(value: unknown): Record<string, CachedGameMatch> {
|
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const cache: Record<string, CachedGameMatch> = {};
|
|
||||||
|
|
||||||
for (const [key, entry] of Object.entries(value)) {
|
|
||||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cached = entry as Partial<CachedGameMatch>;
|
|
||||||
|
|
||||||
if (typeof cached.expiresAt === 'number') {
|
|
||||||
cache[key] = {
|
|
||||||
expiresAt: cached.expiresAt,
|
|
||||||
game: this.normalizeCachedGame(cached.game)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cache;
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeCachedGame(value: unknown): MatchedGame | null {
|
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const game = value as Partial<MatchedGame>;
|
|
||||||
|
|
||||||
if (typeof game.id !== 'string' || typeof game.name !== 'string' || typeof game.processName !== 'string') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: game.id,
|
|
||||||
name: game.name,
|
|
||||||
iconUrl: typeof game.iconUrl === 'string' ? game.iconUrl : undefined,
|
|
||||||
store: this.normalizeGameStore(game.store),
|
|
||||||
processName: game.processName
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeGameStore(value: unknown): GameStoreLink | undefined {
|
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = value as Partial<GameStoreLink>;
|
|
||||||
|
|
||||||
if (typeof store.name !== 'string' || typeof store.url !== 'string' || !this.isExternalUrl(store.url)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: typeof store.id === 'string' ? store.id : undefined,
|
|
||||||
name: store.name,
|
|
||||||
slug: typeof store.slug === 'string' ? store.slug : undefined,
|
|
||||||
domain: typeof store.domain === 'string' ? store.domain : undefined,
|
|
||||||
url: store.url
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private writeMatchCache(cache: Record<string, CachedGameMatch>): void {
|
|
||||||
const entries = Object.entries(cache)
|
|
||||||
.filter(([, entry]) => entry.expiresAt > Date.now())
|
|
||||||
.sort((left, right) => right[1].expiresAt - left[1].expiresAt)
|
|
||||||
.slice(0, MAX_LOCAL_CACHE_ENTRIES);
|
|
||||||
|
|
||||||
localStorage.setItem(GAME_MATCH_CACHE_STORAGE_KEY, JSON.stringify(Object.fromEntries(entries)));
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeCacheKey(value: string): string {
|
|
||||||
return value.trim()
|
|
||||||
.replace(/\.exe$/i, '')
|
|
||||||
.replace(/[_-]+/g, ' ')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyMatchedGame(game: MatchedGame | null): void {
|
|
||||||
if (!game) {
|
|
||||||
this.setCurrentActivity(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const previous = this.currentActivity;
|
|
||||||
const activity: GameActivity = {
|
|
||||||
id: game.id,
|
|
||||||
name: game.name,
|
|
||||||
iconUrl: game.iconUrl,
|
|
||||||
store: game.store,
|
|
||||||
startedAt: previous?.id === game.id ? previous.startedAt : Date.now()
|
|
||||||
};
|
|
||||||
|
|
||||||
this.setCurrentActivity(activity);
|
|
||||||
}
|
|
||||||
|
|
||||||
private setCurrentActivity(activity: GameActivity | null): void {
|
|
||||||
if (this.isSameActivity(this.currentActivity, activity)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentActivity = activity;
|
|
||||||
|
|
||||||
const user = this.currentUser();
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
this.store.dispatch(UsersActions.updateGameActivity({
|
|
||||||
userId: user.id,
|
|
||||||
gameActivity: activity
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.webrtc.broadcastMessage({
|
|
||||||
type: 'game-activity',
|
|
||||||
oderId: user?.oderId || user?.id,
|
|
||||||
displayName: user?.displayName || 'User',
|
|
||||||
gameActivity: activity
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private handlePeerEvent(event: ChatEvent): void {
|
|
||||||
if (event.type !== 'game-activity') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const peerIdentifier = event.fromPeerId ?? event.oderId;
|
|
||||||
|
|
||||||
if (!peerIdentifier) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentUser = this.currentUser();
|
|
||||||
|
|
||||||
if (peerIdentifier === currentUser?.id || peerIdentifier === currentUser?.oderId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = this.findUser(peerIdentifier);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.store.dispatch(UsersActions.updateGameActivity({
|
|
||||||
userId: user.id,
|
|
||||||
gameActivity: this.normalizeIncomingActivity(event.gameActivity)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
private sendCurrentActivityToPeer(peerId: string): void {
|
|
||||||
const user = this.currentUser();
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.webrtc.sendToPeer(peerId, {
|
|
||||||
type: 'game-activity',
|
|
||||||
oderId: user.oderId || user.id,
|
|
||||||
displayName: user.displayName || 'User',
|
|
||||||
gameActivity: this.currentActivity
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private findUser(identifier: string): User | null {
|
|
||||||
return this.allUsers().find((user) => user.id === identifier || user.oderId === identifier) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeIncomingActivity(value: GameActivity | null | undefined): GameActivity | null {
|
|
||||||
if (!value || typeof value.id !== 'string' || typeof value.name !== 'string' || typeof value.startedAt !== 'number') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: value.id,
|
|
||||||
name: value.name,
|
|
||||||
iconUrl: typeof value.iconUrl === 'string' ? value.iconUrl : undefined,
|
|
||||||
store: this.normalizeGameStore(value.store),
|
|
||||||
startedAt: value.startedAt
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private isSameActivity(previous: GameActivity | null, next: GameActivity | null): boolean {
|
|
||||||
return previous?.id === next?.id
|
|
||||||
&& previous?.name === next?.name
|
|
||||||
&& previous?.iconUrl === next?.iconUrl
|
|
||||||
&& previous?.store?.url === next?.store?.url
|
|
||||||
&& previous?.startedAt === next?.startedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
private isExternalUrl(value: string): boolean {
|
|
||||||
return value.startsWith('http://') || value.startsWith('https://');
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildProcessHash(processNames: string[]): string {
|
|
||||||
return processNames.map((name) => name.trim().toLowerCase())
|
|
||||||
.sort()
|
|
||||||
.join('|');
|
|
||||||
}
|
|
||||||
|
|
||||||
private getScanIntervalMs(): number {
|
|
||||||
const storedValue = Number.parseInt(localStorage.getItem(SCAN_INTERVAL_STORAGE_KEY) ?? '', 10);
|
|
||||||
const interval = Number.isFinite(storedValue) ? storedValue : DEFAULT_SCAN_INTERVAL_MS;
|
|
||||||
|
|
||||||
return Math.min(Math.max(interval, MIN_SCAN_INTERVAL_MS), MAX_SCAN_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
export function formatGameActivityElapsed(startedAt: number, now = Date.now()): string {
|
|
||||||
const elapsedSeconds = Math.max(0, Math.floor((now - startedAt) / 1000));
|
|
||||||
const hours = Math.floor(elapsedSeconds / 3600);
|
|
||||||
const minutes = Math.floor((elapsedSeconds % 3600) / 60);
|
|
||||||
const seconds = elapsedSeconds % 60;
|
|
||||||
|
|
||||||
return [
|
|
||||||
hours,
|
|
||||||
minutes,
|
|
||||||
seconds
|
|
||||||
]
|
|
||||||
.map((value) => value.toString().padStart(2, '0'))
|
|
||||||
.join(':');
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import type { GameActivity } from '../../../shared-kernel';
|
|
||||||
|
|
||||||
export type CurrentGameActivity = GameActivity | null;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export * from './application/game-activity.service';
|
|
||||||
export * from './domain/game-activity.models';
|
|
||||||
export * from './domain/game-activity-time';
|
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Injector, runInInjectionContext } from '@angular/core';
|
import { Injector, runInInjectionContext } from '@angular/core';
|
||||||
import { environment } from '../../../../../environments/environment';
|
import { environment } from '../../../../../environments/environment';
|
||||||
import type { ServerEndpoint } from '../../domain/models/server-directory.model';
|
import type { ServerEndpoint } from '../../domain/models/server-directory.model';
|
||||||
import * as serverDirectoryStorageKeys from '../../infrastructure/constants/server-directory.infrastructure.constants';
|
import {
|
||||||
|
DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY,
|
||||||
|
SERVER_ENDPOINTS_STORAGE_KEY
|
||||||
|
} from '../../infrastructure/constants/server-directory.infrastructure.constants';
|
||||||
import { ServerEndpointStorageService } from '../../infrastructure/services/server-endpoint-storage.service';
|
import { ServerEndpointStorageService } from '../../infrastructure/services/server-endpoint-storage.service';
|
||||||
import { ServerEndpointStateService } from './server-endpoint-state.service';
|
import { ServerEndpointStateService } from './server-endpoint-state.service';
|
||||||
|
|
||||||
@@ -46,7 +49,7 @@ function getConfiguredDefaultServer(key: string): { key?: string; name?: string;
|
|||||||
}
|
}
|
||||||
|
|
||||||
function seedStoredEndpoints(endpoints: ServerEndpoint[]): void {
|
function seedStoredEndpoints(endpoints: ServerEndpoint[]): void {
|
||||||
localStorage.setItem(serverDirectoryStorageKeys.SERVER_ENDPOINTS_STORAGE_KEY, JSON.stringify(endpoints));
|
localStorage.setItem(SERVER_ENDPOINTS_STORAGE_KEY, JSON.stringify(endpoints));
|
||||||
}
|
}
|
||||||
|
|
||||||
function createService(): ServerEndpointStateService {
|
function createService(): ServerEndpointStateService {
|
||||||
@@ -63,16 +66,6 @@ function createService(): ServerEndpointStateService {
|
|||||||
return runInInjectionContext(injector, () => new ServerEndpointStateService());
|
return runInInjectionContext(injector, () => new ServerEndpointStateService());
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRequiredDefaultEndpoint(service: ServerEndpointStateService, defaultKey: string | undefined): ServerEndpoint {
|
|
||||||
const endpoint = service.servers().find((candidate) => candidate.defaultKey === defaultKey);
|
|
||||||
|
|
||||||
if (!endpoint) {
|
|
||||||
throw new Error(`Expected default endpoint for key: ${defaultKey ?? 'unknown'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return endpoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ServerEndpointStateService', () => {
|
describe('ServerEndpointStateService', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
@@ -113,11 +106,7 @@ describe('ServerEndpointStateService', () => {
|
|||||||
status: 'unknown'
|
status: 'unknown'
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
localStorage.setItem(DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY, JSON.stringify([defaultServer.key]));
|
||||||
localStorage.setItem(
|
|
||||||
serverDirectoryStorageKeys.DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY,
|
|
||||||
JSON.stringify([defaultServer.key])
|
|
||||||
);
|
|
||||||
|
|
||||||
const service = createService();
|
const service = createService();
|
||||||
const endpoint = service.servers().find((candidate) => candidate.defaultKey === defaultServer.key);
|
const endpoint = service.servers().find((candidate) => candidate.defaultKey === defaultServer.key);
|
||||||
@@ -125,61 +114,21 @@ describe('ServerEndpointStateService', () => {
|
|||||||
expect(endpoint?.isActive).toBe(false);
|
expect(endpoint?.isActive).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps configured default endpoints active even when stored as incompatible unless the user disabled them', () => {
|
|
||||||
const defaultServer = getConfiguredDefaultServer('toju-primary');
|
|
||||||
|
|
||||||
seedStoredEndpoints([
|
|
||||||
{
|
|
||||||
id: 'default-server',
|
|
||||||
name: 'Stored Default',
|
|
||||||
url: defaultServer.url ?? '',
|
|
||||||
isActive: false,
|
|
||||||
isDefault: true,
|
|
||||||
defaultKey: defaultServer.key,
|
|
||||||
status: 'incompatible'
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
const service = createService();
|
|
||||||
const endpoint = service.servers().find((candidate) => candidate.defaultKey === defaultServer.key);
|
|
||||||
|
|
||||||
expect(endpoint?.isActive).toBe(true);
|
|
||||||
expect(endpoint?.status).toBe('incompatible');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not deactivate configured default endpoints when compatibility checks fail', () => {
|
|
||||||
const defaultServer = getConfiguredDefaultServer('toju-primary');
|
|
||||||
const service = createService();
|
|
||||||
const endpoint = getRequiredDefaultEndpoint(service, defaultServer.key);
|
|
||||||
|
|
||||||
service.updateServerStatus(endpoint.id, 'incompatible');
|
|
||||||
|
|
||||||
expect(service.servers().find((candidate) => candidate.id === endpoint.id)?.isActive).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolves legacy https source URLs to the local http default endpoint on the same host', () => {
|
|
||||||
const defaultServer = getConfiguredDefaultServer('default');
|
|
||||||
const service = createService();
|
|
||||||
const legacyHttpsUrl = defaultServer.url?.replace(/^http:\/\//, 'https://') ?? '';
|
|
||||||
|
|
||||||
expect(service.findServerByUrl(legacyHttpsUrl)?.url).toBe(defaultServer.url);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('persists turning a configured default endpoint off and back on', () => {
|
it('persists turning a configured default endpoint off and back on', () => {
|
||||||
const defaultServer = getConfiguredDefaultServer('toju-primary');
|
const defaultServer = getConfiguredDefaultServer('toju-primary');
|
||||||
const service = createService();
|
const service = createService();
|
||||||
const endpoint = getRequiredDefaultEndpoint(service, defaultServer.key);
|
const endpoint = service.servers().find((candidate) => candidate.defaultKey === defaultServer.key);
|
||||||
|
|
||||||
service.deactivateServer(endpoint.id);
|
expect(endpoint).toBeDefined();
|
||||||
|
|
||||||
expect(service.servers().find((candidate) => candidate.id === endpoint.id)?.isActive).toBe(false);
|
service.deactivateServer(endpoint!.id);
|
||||||
expect(JSON.parse(
|
|
||||||
localStorage.getItem(serverDirectoryStorageKeys.DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY) ?? '[]'
|
|
||||||
)).toContain(defaultServer.key);
|
|
||||||
|
|
||||||
service.setActiveServer(endpoint.id);
|
expect(service.servers().find((candidate) => candidate.id === endpoint!.id)?.isActive).toBe(false);
|
||||||
|
expect(JSON.parse(localStorage.getItem(DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY) ?? '[]')).toContain(defaultServer.key);
|
||||||
|
|
||||||
expect(service.servers().find((candidate) => candidate.id === endpoint.id)?.isActive).toBe(true);
|
service.setActiveServer(endpoint!.id);
|
||||||
expect(localStorage.getItem(serverDirectoryStorageKeys.DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY)).toBeNull();
|
|
||||||
|
expect(service.servers().find((candidate) => candidate.id === endpoint!.id)?.isActive).toBe(true);
|
||||||
|
expect(localStorage.getItem(DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -117,9 +117,8 @@ export class ServerEndpointStateService {
|
|||||||
|
|
||||||
findServerByUrl(url: string): ServerEndpoint | undefined {
|
findServerByUrl(url: string): ServerEndpoint | undefined {
|
||||||
const sanitisedUrl = this.sanitiseUrl(url);
|
const sanitisedUrl = this.sanitiseUrl(url);
|
||||||
const exactEndpoint = this._servers().find((endpoint) => this.sanitiseUrl(endpoint.url) === sanitisedUrl);
|
|
||||||
|
|
||||||
return exactEndpoint ?? this.findHttpEndpointForHttpsUrl(sanitisedUrl);
|
return this._servers().find((endpoint) => this.sanitiseUrl(endpoint.url) === sanitisedUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
resolveCanonicalEndpoint(endpoint: ServerEndpoint | null | undefined): ServerEndpoint | null {
|
resolveCanonicalEndpoint(endpoint: ServerEndpoint | null | undefined): ServerEndpoint | null {
|
||||||
@@ -240,7 +239,7 @@ export class ServerEndpointStateService {
|
|||||||
instanceId: versions?.serverInstanceId ?? endpoint.instanceId,
|
instanceId: versions?.serverInstanceId ?? endpoint.instanceId,
|
||||||
status,
|
status,
|
||||||
latency,
|
latency,
|
||||||
isActive: status === 'incompatible' && !endpoint.isDefault ? false : endpoint.isActive,
|
isActive: status === 'incompatible' ? false : endpoint.isActive,
|
||||||
serverVersion: versions?.serverVersion ?? endpoint.serverVersion,
|
serverVersion: versions?.serverVersion ?? endpoint.serverVersion,
|
||||||
clientVersion: versions?.clientVersion ?? endpoint.clientVersion
|
clientVersion: versions?.clientVersion ?? endpoint.clientVersion
|
||||||
};
|
};
|
||||||
@@ -295,7 +294,7 @@ export class ServerEndpointStateService {
|
|||||||
...endpoint,
|
...endpoint,
|
||||||
name: matchedDefault.name,
|
name: matchedDefault.name,
|
||||||
url: matchedDefault.url,
|
url: matchedDefault.url,
|
||||||
isActive: this.isDefaultEndpointActive(matchedDefault.defaultKey, disabledDefaultKeys),
|
isActive: this.isDefaultEndpointActive(matchedDefault.defaultKey, endpoint.status ?? 'unknown', disabledDefaultKeys),
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
defaultKey: matchedDefault.defaultKey,
|
defaultKey: matchedDefault.defaultKey,
|
||||||
status: endpoint.status ?? 'unknown'
|
status: endpoint.status ?? 'unknown'
|
||||||
@@ -320,7 +319,7 @@ export class ServerEndpointStateService {
|
|||||||
reconciled.push({
|
reconciled.push({
|
||||||
...defaultEndpoint,
|
...defaultEndpoint,
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
isActive: this.isDefaultEndpointActive(defaultEndpoint.defaultKey, disabledDefaultKeys)
|
isActive: this.isDefaultEndpointActive(defaultEndpoint.defaultKey, defaultEndpoint.status, disabledDefaultKeys)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -372,7 +371,6 @@ export class ServerEndpointStateService {
|
|||||||
|
|
||||||
private clearDisabledDefaultEndpointKeys(endpoints: ServerEndpoint[]): void {
|
private clearDisabledDefaultEndpointKeys(endpoints: ServerEndpoint[]): void {
|
||||||
const disabledDefaultKeys = this.storage.loadDisabledDefaultEndpointKeys();
|
const disabledDefaultKeys = this.storage.loadDisabledDefaultEndpointKeys();
|
||||||
|
|
||||||
let didChange = false;
|
let didChange = false;
|
||||||
|
|
||||||
for (const endpoint of endpoints) {
|
for (const endpoint of endpoints) {
|
||||||
@@ -394,9 +392,10 @@ export class ServerEndpointStateService {
|
|||||||
|
|
||||||
private isDefaultEndpointActive(
|
private isDefaultEndpointActive(
|
||||||
defaultKey: string,
|
defaultKey: string,
|
||||||
|
status: ServerEndpoint['status'],
|
||||||
disabledDefaultKeys: Set<string>
|
disabledDefaultKeys: Set<string>
|
||||||
): boolean {
|
): boolean {
|
||||||
return !disabledDefaultKeys.has(defaultKey);
|
return status !== 'incompatible' && !disabledDefaultKeys.has(defaultKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
private saveEndpoints(): void {
|
private saveEndpoints(): void {
|
||||||
@@ -448,28 +447,4 @@ export class ServerEndpointStateService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private findHttpEndpointForHttpsUrl(url: string): ServerEndpoint | undefined {
|
|
||||||
const requestedUrl = this.parseUrl(url);
|
|
||||||
|
|
||||||
if (requestedUrl?.protocol !== 'https:') {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this._servers().find((endpoint) => {
|
|
||||||
const endpointUrl = this.parseUrl(endpoint.url);
|
|
||||||
|
|
||||||
return endpointUrl?.protocol === 'http:'
|
|
||||||
&& endpointUrl.hostname === requestedUrl.hostname
|
|
||||||
&& endpointUrl.port === requestedUrl.port;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseUrl(url: string): URL | null {
|
|
||||||
try {
|
|
||||||
return new URL(url);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,16 @@
|
|||||||
<div class="flex h-full min-h-0 flex-col">
|
<div class="flex flex-col h-full">
|
||||||
<div class="border-b border-border px-3 py-3">
|
<!-- My Servers -->
|
||||||
<div class="flex flex-col gap-2 md:flex-row md:items-center">
|
<div class="p-4 border-b border-border">
|
||||||
<div class="relative min-w-0 flex-1">
|
<h3 class="font-semibold text-foreground mb-2">My Servers</h3>
|
||||||
<ng-icon
|
@if (savedRooms().length === 0) {
|
||||||
name="lucideSearch"
|
<p class="text-sm text-muted-foreground">No joined servers yet</p>
|
||||||
class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
} @else {
|
||||||
/>
|
<div class="flex flex-wrap gap-2">
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
aria-label="Search people and servers"
|
|
||||||
class="h-10 w-full rounded-lg border border-border bg-secondary py-2 pl-10 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
placeholder="Search servers and users..."
|
|
||||||
[(ngModel)]="searchQuery"
|
|
||||||
(ngModelChange)="onSearchChange($event)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex shrink-0 items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Create New Server"
|
|
||||||
class="inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
|
|
||||||
(click)="openCreateDialog()"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucidePlus"
|
|
||||||
class="h-4 w-4"
|
|
||||||
/>
|
|
||||||
Create
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="grid h-10 w-10 place-items-center rounded-lg border border-border bg-secondary transition-colors hover:bg-secondary/80"
|
|
||||||
title="Settings"
|
|
||||||
(click)="openSettings()"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideSettings"
|
|
||||||
class="h-5 w-5 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (savedRooms().length > 0) {
|
|
||||||
<div class="mt-2 flex items-center gap-2 overflow-x-auto pb-1">
|
|
||||||
<span class="shrink-0 text-xs font-medium text-muted-foreground">My Servers</span>
|
|
||||||
@for (room of savedRooms(); track room.id) {
|
@for (room of savedRooms(); track room.id) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
class="shrink-0 rounded-md border border-border bg-card px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
|
|
||||||
(click)="joinSavedRoom(room)"
|
(click)="joinSavedRoom(room)"
|
||||||
|
type="button"
|
||||||
|
class="px-3 py-1.5 text-xs rounded-full bg-secondary hover:bg-secondary/80 border border-border text-foreground"
|
||||||
>
|
>
|
||||||
{{ room.name }}
|
{{ room.name }}
|
||||||
</button>
|
</button>
|
||||||
@@ -59,169 +18,160 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Search Header -->
|
||||||
<div class="grid min-h-0 flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[minmax(300px,380px)_1fr]">
|
<div class="p-4 border-b border-border">
|
||||||
<app-user-search-list
|
<div class="flex items-center gap-2">
|
||||||
class="min-h-0 overflow-y-auto border-b border-border lg:border-b-0 lg:border-r"
|
<div class="relative flex-1">
|
||||||
[searchQuery]="searchQuery"
|
<ng-icon
|
||||||
/>
|
name="lucideSearch"
|
||||||
|
class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4"
|
||||||
<section class="min-h-0 overflow-y-auto">
|
/>
|
||||||
<div class="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-background/95 px-3 py-2 backdrop-blur">
|
<input
|
||||||
<div>
|
type="text"
|
||||||
<h3 class="text-sm font-semibold text-foreground">Servers</h3>
|
[(ngModel)]="searchQuery"
|
||||||
<p class="text-xs text-muted-foreground">{{ searchResults().length }} found</p>
|
(ngModelChange)="onSearchChange($event)"
|
||||||
</div>
|
placeholder="Search servers..."
|
||||||
|
class="w-full pl-10 pr-4 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
(click)="openSettings()"
|
||||||
|
type="button"
|
||||||
|
class="grid h-9 w-9 place-items-center rounded-lg border border-border bg-secondary transition-colors hover:bg-secondary/80"
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideSettings"
|
||||||
|
class="w-5 h-5 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (isSearching()) {
|
<!-- Create Server Button -->
|
||||||
<div class="flex items-center justify-center py-8">
|
<div class="p-4 border-b border-border">
|
||||||
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
|
<button
|
||||||
</div>
|
(click)="openCreateDialog()"
|
||||||
} @else if (searchResults().length === 0) {
|
type="button"
|
||||||
<div class="flex flex-col items-center justify-center px-4 py-10 text-muted-foreground">
|
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
||||||
<ng-icon
|
>
|
||||||
name="lucideSearch"
|
<ng-icon
|
||||||
class="mb-3 h-10 w-10 opacity-50"
|
name="lucidePlus"
|
||||||
/>
|
class="w-4 h-4"
|
||||||
<p class="text-sm font-medium">No servers found</p>
|
/>
|
||||||
</div>
|
Create New Server
|
||||||
} @else {
|
</button>
|
||||||
<div class="space-y-2 p-3">
|
</div>
|
||||||
@for (server of searchResults(); track server.id) {
|
|
||||||
<div
|
|
||||||
class="group w-full cursor-pointer rounded-lg border bg-card p-3 text-left transition-colors"
|
|
||||||
[class.border-border]="!isServerMarkedBanned(server)"
|
|
||||||
[class.hover:border-primary/50]="!isServerMarkedBanned(server)"
|
|
||||||
[class.hover:bg-card/80]="!isServerMarkedBanned(server)"
|
|
||||||
[class.border-destructive/40]="isServerMarkedBanned(server)"
|
|
||||||
[class.bg-destructive/5]="isServerMarkedBanned(server)"
|
|
||||||
[class.hover:border-destructive/60]="isServerMarkedBanned(server)"
|
|
||||||
[title]="isJoinedServer(server) ? 'Double-click to open ' + server.name : 'Double-click to join ' + server.name"
|
|
||||||
(dblclick)="openServerCard(server)"
|
|
||||||
>
|
|
||||||
<div class="flex min-w-0 items-start gap-3">
|
|
||||||
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-secondary text-sm font-semibold text-foreground">
|
|
||||||
{{ server.name[0] || '?' }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="min-w-0 flex-1">
|
<!-- Search Results -->
|
||||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
<div class="flex-1 overflow-y-auto">
|
||||||
<h3
|
@if (isSearching()) {
|
||||||
class="truncate text-sm font-semibold transition-colors"
|
<div class="flex items-center justify-center py-8">
|
||||||
[class.text-foreground]="!isServerMarkedBanned(server)"
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
[class.group-hover:text-primary]="!isServerMarkedBanned(server)"
|
</div>
|
||||||
[class.text-destructive]="isServerMarkedBanned(server)"
|
} @else if (searchResults().length === 0) {
|
||||||
|
<div class="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||||
|
<ng-icon
|
||||||
|
name="lucideSearch"
|
||||||
|
class="w-12 h-12 mb-4 opacity-50"
|
||||||
|
/>
|
||||||
|
<p class="text-lg">No servers found</p>
|
||||||
|
<p class="text-sm">Try a different search or create your own</p>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="p-4 space-y-3">
|
||||||
|
@for (server of searchResults(); track server.id) {
|
||||||
|
<button
|
||||||
|
(click)="joinServer(server)"
|
||||||
|
type="button"
|
||||||
|
class="w-full p-4 bg-card rounded-lg border transition-all text-left group"
|
||||||
|
[class.border-border]="!isServerMarkedBanned(server)"
|
||||||
|
[class.hover:border-primary/50]="!isServerMarkedBanned(server)"
|
||||||
|
[class.hover:bg-card/80]="!isServerMarkedBanned(server)"
|
||||||
|
[class.border-destructive/40]="isServerMarkedBanned(server)"
|
||||||
|
[class.bg-destructive/5]="isServerMarkedBanned(server)"
|
||||||
|
[class.hover:border-destructive/60]="isServerMarkedBanned(server)"
|
||||||
|
[attr.aria-label]="isServerMarkedBanned(server) ? 'Banned server' : 'Join server'"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h3
|
||||||
|
class="font-semibold transition-colors"
|
||||||
|
[class.text-foreground]="!isServerMarkedBanned(server)"
|
||||||
|
[class.group-hover:text-primary]="!isServerMarkedBanned(server)"
|
||||||
|
[class.text-destructive]="isServerMarkedBanned(server)"
|
||||||
|
>
|
||||||
|
{{ server.name }}
|
||||||
|
</h3>
|
||||||
|
@if (isServerMarkedBanned(server)) {
|
||||||
|
<ng-icon
|
||||||
|
name="lucideLock"
|
||||||
|
class="w-4 h-4 text-destructive"
|
||||||
|
/>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-destructive/10 px-2 py-0.5 text-[11px] font-medium text-destructive"
|
||||||
|
>Banned</span
|
||||||
>
|
>
|
||||||
{{ server.name }}
|
} @else if (server.isPrivate) {
|
||||||
</h3>
|
<ng-icon
|
||||||
|
name="lucideLock"
|
||||||
@if (isServerMarkedBanned(server)) {
|
class="w-4 h-4 text-muted-foreground"
|
||||||
<span
|
/>
|
||||||
class="inline-flex items-center gap-1 rounded-full bg-destructive/10 px-2 py-0.5 text-[11px] font-medium text-destructive"
|
<span class="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
|
||||||
>
|
>Private</span
|
||||||
<ng-icon
|
>
|
||||||
name="lucideLock"
|
} @else if (server.hasPassword) {
|
||||||
class="h-3 w-3"
|
<ng-icon
|
||||||
/>
|
name="lucideLock"
|
||||||
Banned
|
class="w-4 h-4 text-muted-foreground"
|
||||||
</span>
|
/>
|
||||||
} @else if (server.isPrivate) {
|
<span class="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
|
||||||
<span
|
>Password</span
|
||||||
class="inline-flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideLock"
|
|
||||||
class="h-3 w-3"
|
|
||||||
/>
|
|
||||||
Private
|
|
||||||
</span>
|
|
||||||
} @else if (server.hasPassword) {
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideLock"
|
|
||||||
class="h-3 w-3"
|
|
||||||
/>
|
|
||||||
Password
|
|
||||||
</span>
|
|
||||||
} @else {
|
|
||||||
<ng-icon
|
|
||||||
name="lucideGlobe"
|
|
||||||
class="h-4 w-4 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (server.description) {
|
|
||||||
<p class="mt-1 line-clamp-1 text-xs text-muted-foreground">{{ server.description }}</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
|
||||||
<span class="inline-flex items-center gap-1">
|
|
||||||
<ng-icon
|
|
||||||
name="lucideUsers"
|
|
||||||
class="h-3.5 w-3.5"
|
|
||||||
/>
|
|
||||||
{{ getServerUserCount(server) }}/{{ getServerCapacityLabel(server) }}
|
|
||||||
</span>
|
|
||||||
@if (server.topic) {
|
|
||||||
<span class="truncate">{{ server.topic }}</span>
|
|
||||||
}
|
|
||||||
<span class="truncate">Owner: {{ getServerOwnerLabel(server) }}</span>
|
|
||||||
<span class="truncate">{{ server.sourceName || server.hostName || 'Unknown' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative shrink-0">
|
|
||||||
@if (isJoinedServer(server)) {
|
|
||||||
<div
|
|
||||||
class="flex items-center overflow-hidden rounded-md border border-emerald-500/30 bg-emerald-500/10 text-xs font-semibold text-emerald-500"
|
|
||||||
>
|
>
|
||||||
<span class="px-2.5 py-1.5">Joined</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="grid h-8 w-8 place-items-center border-l border-emerald-500/20 transition-colors hover:bg-emerald-500/15"
|
|
||||||
[attr.aria-label]="'Server actions for ' + server.name"
|
|
||||||
(click)="toggleJoinedServerMenu($event, server)"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideChevronDown"
|
|
||||||
class="h-4 w-4"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (joinedServerMenuId() === server.id) {
|
|
||||||
<div class="absolute right-0 top-full z-20 mt-1 w-36 rounded-md border border-border bg-card py-1 shadow-lg">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="w-full px-3 py-2 text-left text-xs font-medium text-destructive transition-colors hover:bg-destructive/10"
|
|
||||||
(click)="openLeaveDialog($event, server)"
|
|
||||||
>
|
|
||||||
Leave
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
} @else {
|
} @else {
|
||||||
<button
|
<ng-icon
|
||||||
type="button"
|
name="lucideGlobe"
|
||||||
class="pointer-events-none scale-95 rounded-md bg-primary px-2.5 py-1.5 text-xs font-semibold text-primary-foreground opacity-0 transition-[opacity,transform] duration-75 ease-out hover:scale-100 hover:opacity-100 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"
|
class="w-4 h-4 text-muted-foreground"
|
||||||
[attr.aria-label]="'Join ' + server.name"
|
/>
|
||||||
(click)="joinServer(server)"
|
|
||||||
>
|
|
||||||
<span class="sr-only">{{ server.name }}</span>
|
|
||||||
Join
|
|
||||||
</button>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@if (server.description) {
|
||||||
|
<p class="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||||
|
{{ server.description }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
@if (server.topic) {
|
||||||
|
<span class="inline-block mt-2 px-2 py-0.5 text-xs bg-secondary rounded-full text-muted-foreground">
|
||||||
|
{{ server.topic }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 text-muted-foreground text-sm ml-4">
|
||||||
|
<ng-icon
|
||||||
|
name="lucideUsers"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span>{{ getServerUserCount(server) }}/{{ getServerCapacityLabel(server) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
<div class="mt-3 space-y-1 text-xs">
|
||||||
</div>
|
<div class="text-muted-foreground">
|
||||||
}
|
Users: <span class="text-foreground/80">{{ getServerUserCount(server) }}/{{ getServerCapacityLabel(server) }}</span>
|
||||||
</section>
|
</div>
|
||||||
|
<div class="text-muted-foreground">
|
||||||
|
Listed by: <span class="text-foreground/80">{{ server.sourceName || server.hostName || 'Unknown' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted-foreground">
|
||||||
|
Owner: <span class="text-foreground/80">{{ server.ownerName || server.ownerId || 'Unknown' }}</span>
|
||||||
|
</div>
|
||||||
|
@if (server.hasPassword && !server.isPrivate && !isServerMarkedBanned(server)) {
|
||||||
|
<div class="text-muted-foreground">Access: <span class="text-foreground/80">Password required</span></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (joinErrorMessage() || error()) {
|
@if (joinErrorMessage() || error()) {
|
||||||
@@ -231,15 +181,6 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (leaveDialogRoom()) {
|
|
||||||
<app-leave-server-dialog
|
|
||||||
[room]="leaveDialogRoom()!"
|
|
||||||
[currentUser]="currentUser() ?? null"
|
|
||||||
(confirmed)="confirmLeaveServer($event)"
|
|
||||||
(cancelled)="closeLeaveDialog()"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (showBannedDialog()) {
|
@if (showBannedDialog()) {
|
||||||
<app-confirm-dialog
|
<app-confirm-dialog
|
||||||
title="Banned"
|
title="Banned"
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ import {
|
|||||||
lucideLock,
|
lucideLock,
|
||||||
lucideGlobe,
|
lucideGlobe,
|
||||||
lucidePlus,
|
lucidePlus,
|
||||||
lucideSettings,
|
lucideSettings
|
||||||
lucideChevronDown
|
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||||
@@ -40,13 +39,8 @@ import { DatabaseService } from '../../../../infrastructure/persistence';
|
|||||||
import { type ServerInfo } from '../../domain/models/server-directory.model';
|
import { type ServerInfo } from '../../domain/models/server-directory.model';
|
||||||
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
|
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
|
||||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||||
import {
|
import { ConfirmDialogComponent } from '../../../../shared';
|
||||||
ConfirmDialogComponent,
|
|
||||||
LeaveServerDialogComponent,
|
|
||||||
type LeaveServerDialogResult
|
|
||||||
} from '../../../../shared';
|
|
||||||
import { hasRoomBanForUser } from '../../../access-control';
|
import { hasRoomBanForUser } from '../../../access-control';
|
||||||
import { UserSearchListComponent } from '../../../direct-message/feature/user-search-list/user-search-list.component';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-server-search',
|
selector: 'app-server-search',
|
||||||
@@ -55,9 +49,7 @@ import { UserSearchListComponent } from '../../../direct-message/feature/user-se
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
NgIcon,
|
NgIcon,
|
||||||
ConfirmDialogComponent,
|
ConfirmDialogComponent
|
||||||
LeaveServerDialogComponent,
|
|
||||||
UserSearchListComponent
|
|
||||||
],
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
@@ -66,8 +58,7 @@ import { UserSearchListComponent } from '../../../direct-message/feature/user-se
|
|||||||
lucideLock,
|
lucideLock,
|
||||||
lucideGlobe,
|
lucideGlobe,
|
||||||
lucidePlus,
|
lucidePlus,
|
||||||
lucideSettings,
|
lucideSettings
|
||||||
lucideChevronDown
|
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
templateUrl: './server-search.component.html'
|
templateUrl: './server-search.component.html'
|
||||||
@@ -100,8 +91,6 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
joinPassword = signal('');
|
joinPassword = signal('');
|
||||||
joinPasswordError = signal<string | null>(null);
|
joinPasswordError = signal<string | null>(null);
|
||||||
joinErrorMessage = signal<string | null>(null);
|
joinErrorMessage = signal<string | null>(null);
|
||||||
joinedServerMenuId = signal<string | null>(null);
|
|
||||||
leaveDialogRoom = signal<Room | null>(null);
|
|
||||||
|
|
||||||
// Create dialog state
|
// Create dialog state
|
||||||
showCreateDialog = signal(false);
|
showCreateDialog = signal(false);
|
||||||
@@ -128,7 +117,7 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
this.store.dispatch(RoomsActions.loadRooms());
|
this.store.dispatch(RoomsActions.loadRooms());
|
||||||
|
|
||||||
// Setup debounced search
|
// Setup debounced search
|
||||||
this.searchSubject.pipe(debounceTime(120), distinctUntilChanged()).subscribe((query) => {
|
this.searchSubject.pipe(debounceTime(300), distinctUntilChanged()).subscribe((query) => {
|
||||||
this.store.dispatch(RoomsActions.searchServers({ query }));
|
this.store.dispatch(RoomsActions.searchServers({ query }));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -201,66 +190,7 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
|
|
||||||
/** Join a previously saved room by converting it to a ServerInfo payload. */
|
/** Join a previously saved room by converting it to a ServerInfo payload. */
|
||||||
joinSavedRoom(room: Room): void {
|
joinSavedRoom(room: Room): void {
|
||||||
this.openJoinedRoom(room);
|
void this.joinServer(this.toServerInfo(room));
|
||||||
}
|
|
||||||
|
|
||||||
openServerCard(server: ServerInfo): void {
|
|
||||||
const joinedRoom = this.joinedRoomForServer(server);
|
|
||||||
|
|
||||||
if (joinedRoom) {
|
|
||||||
this.openJoinedRoom(joinedRoom);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void this.joinServer(server);
|
|
||||||
}
|
|
||||||
|
|
||||||
joinedRoomForServer(server: ServerInfo): Room | null {
|
|
||||||
return this.savedRooms().find((room) => room.id === server.id) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
isJoinedServer(server: ServerInfo): boolean {
|
|
||||||
return !!this.joinedRoomForServer(server);
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleJoinedServerMenu(event: Event, server: ServerInfo): void {
|
|
||||||
event.stopPropagation();
|
|
||||||
this.joinedServerMenuId.update((currentId) => currentId === server.id ? null : server.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
closeJoinedServerMenu(): void {
|
|
||||||
this.joinedServerMenuId.set(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
openLeaveDialog(event: Event, server: ServerInfo): void {
|
|
||||||
event.stopPropagation();
|
|
||||||
const room = this.joinedRoomForServer(server);
|
|
||||||
|
|
||||||
if (!room) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.joinedServerMenuId.set(null);
|
|
||||||
this.leaveDialogRoom.set(room);
|
|
||||||
}
|
|
||||||
|
|
||||||
closeLeaveDialog(): void {
|
|
||||||
this.leaveDialogRoom.set(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
confirmLeaveServer(result: LeaveServerDialogResult): void {
|
|
||||||
const room = this.leaveDialogRoom();
|
|
||||||
|
|
||||||
if (!room) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.store.dispatch(RoomsActions.forgetRoom({
|
|
||||||
roomId: room.id,
|
|
||||||
nextOwnerKey: result.nextOwnerKey
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.leaveDialogRoom.set(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
closeBannedDialog(): void {
|
closeBannedDialog(): void {
|
||||||
@@ -301,21 +231,6 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
return server.maxUsers > 0 ? String(server.maxUsers) : '∞';
|
return server.maxUsers > 0 ? String(server.maxUsers) : '∞';
|
||||||
}
|
}
|
||||||
|
|
||||||
getServerOwnerLabel(server: ServerInfo): string {
|
|
||||||
const joinedRoom = this.joinedRoomForServer(server);
|
|
||||||
const ownerKey = server.ownerId || joinedRoom?.hostId || '';
|
|
||||||
const ownerMember = joinedRoom?.members?.find((member) =>
|
|
||||||
member.id === ownerKey || member.oderId === ownerKey
|
|
||||||
);
|
|
||||||
|
|
||||||
return server.ownerName || ownerMember?.displayName || server.ownerId || joinedRoom?.hostId || 'Unknown owner';
|
|
||||||
}
|
|
||||||
|
|
||||||
private openJoinedRoom(room: Room): void {
|
|
||||||
this.joinedServerMenuId.set(null);
|
|
||||||
this.store.dispatch(RoomsActions.viewServer({ room }));
|
|
||||||
}
|
|
||||||
|
|
||||||
private toServerInfo(room: Room): ServerInfo {
|
private toServerInfo(room: Room): ServerInfo {
|
||||||
return {
|
return {
|
||||||
id: room.id,
|
id: room.id,
|
||||||
|
|||||||
@@ -4,15 +4,10 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
|||||||
import {
|
import {
|
||||||
Observable,
|
Observable,
|
||||||
forkJoin,
|
forkJoin,
|
||||||
merge,
|
|
||||||
of,
|
of,
|
||||||
throwError
|
throwError
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
import {
|
import { catchError, map } from 'rxjs/operators';
|
||||||
catchError,
|
|
||||||
map,
|
|
||||||
scan
|
|
||||||
} from 'rxjs/operators';
|
|
||||||
import {
|
import {
|
||||||
ChannelPermissionOverride,
|
ChannelPermissionOverride,
|
||||||
type Channel,
|
type Channel,
|
||||||
@@ -36,19 +31,6 @@ import type {
|
|||||||
} from '../../domain/models/server-directory.model';
|
} from '../../domain/models/server-directory.model';
|
||||||
import type { RoomSignalSourceInput } from '../../domain/logic/room-signal-source.logic';
|
import type { RoomSignalSourceInput } from '../../domain/logic/room-signal-source.logic';
|
||||||
|
|
||||||
interface ServerLookupError {
|
|
||||||
status?: number;
|
|
||||||
error?: {
|
|
||||||
errorCode?: unknown;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function isServerNotFoundError(error: unknown): boolean {
|
|
||||||
const lookupError = error as ServerLookupError;
|
|
||||||
|
|
||||||
return lookupError?.status === 404 && lookupError.error?.errorCode === 'SERVER_NOT_FOUND';
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ServerDirectoryApiService {
|
export class ServerDirectoryApiService {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
@@ -107,10 +89,6 @@ export class ServerDirectoryApiService {
|
|||||||
return this.http.get<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`).pipe(
|
return this.http.get<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`).pipe(
|
||||||
map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))),
|
map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))),
|
||||||
catchError((error) => {
|
catchError((error) => {
|
||||||
if (isServerNotFoundError(error)) {
|
|
||||||
return of(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('Failed to get server:', error);
|
console.error('Failed to get server:', error);
|
||||||
return of(null);
|
return of(null);
|
||||||
})
|
})
|
||||||
@@ -321,8 +299,9 @@ export class ServerDirectoryApiService {
|
|||||||
return this.searchSingleEndpoint(query, this.getApiBaseUrl(), this.endpointState.activeServer());
|
return this.searchSingleEndpoint(query, this.getApiBaseUrl(), this.endpointState.activeServer());
|
||||||
}
|
}
|
||||||
|
|
||||||
return merge(...onlineEndpoints.map((endpoint) => this.searchSingleEndpoint(query, `${endpoint.url}/api`, endpoint))).pipe(
|
return forkJoin(onlineEndpoints.map((endpoint) => this.searchSingleEndpoint(query, `${endpoint.url}/api`, endpoint))).pipe(
|
||||||
scan((servers, endpointServers) => this.deduplicateById([...servers, ...endpointServers]), [] as ServerInfo[])
|
map((resultArrays) => resultArrays.flat()),
|
||||||
|
map((servers) => this.deduplicateById(servers))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,11 +59,6 @@ export class ElementPickerService {
|
|||||||
this.hoveredKey.set(null);
|
this.hoveredKey.set(null);
|
||||||
this.isPicking.set(false);
|
this.isPicking.set(false);
|
||||||
|
|
||||||
if (this.resumePage === 'theme') {
|
|
||||||
this.modal.openThemeStudio();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.resumePage) {
|
if (this.resumePage) {
|
||||||
this.modal.open(this.resumePage);
|
this.modal.open(this.resumePage);
|
||||||
}
|
}
|
||||||
@@ -129,6 +124,8 @@ export class ElementPickerService {
|
|||||||
const key = themedElement?.dataset['themeKey'] ?? null;
|
const key = themedElement?.dataset['themeKey'] ?? null;
|
||||||
const definition = this.registry.getDefinition(key);
|
const definition = this.registry.getDefinition(key);
|
||||||
|
|
||||||
return definition?.pickerVisible ? key : null;
|
return definition?.pickerVisible
|
||||||
|
? key
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
ThemeGridEditorItem,
|
ThemeGridEditorItem,
|
||||||
ThemeGridRect
|
ThemeGridRect
|
||||||
} from '../../domain/models/theme.model';
|
} from '../../domain/models/theme.model';
|
||||||
import { createDefaultThemeDocument, createDefaultThemeLayout } from '../../domain/logic/theme-defaults.logic';
|
import { createDefaultThemeDocument } from '../../domain/logic/theme-defaults.logic';
|
||||||
import { ThemeRegistryService } from './theme-registry.service';
|
import { ThemeRegistryService } from './theme-registry.service';
|
||||||
import { ThemeService } from './theme.service';
|
import { ThemeService } from './theme.service';
|
||||||
|
|
||||||
@@ -26,46 +26,37 @@ export class LayoutSyncService {
|
|||||||
|
|
||||||
itemsForContainer(containerKey: ThemeContainerKey): ThemeGridEditorItem[] {
|
itemsForContainer(containerKey: ThemeContainerKey): ThemeGridEditorItem[] {
|
||||||
const draftTheme = this.theme.draftTheme();
|
const draftTheme = this.theme.draftTheme();
|
||||||
const defaults = createDefaultThemeLayout();
|
const defaults = createDefaultThemeDocument();
|
||||||
|
|
||||||
return this.registry
|
return this.registry.entries()
|
||||||
.entries()
|
|
||||||
.filter((entry) => entry.layoutEditable && entry.container === containerKey)
|
.filter((entry) => entry.layoutEditable && entry.container === containerKey)
|
||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
key: entry.key,
|
key: entry.key,
|
||||||
label: entry.label,
|
label: entry.label,
|
||||||
description: entry.description,
|
description: entry.description,
|
||||||
grid: draftTheme.layout[entry.key]?.grid ?? defaults[entry.key].grid
|
grid: draftTheme.layout[entry.key]?.grid ?? defaults.layout[entry.key].grid
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
updateGrid(key: string, grid: ThemeGridRect): void {
|
updateGrid(key: string, grid: ThemeGridRect): void {
|
||||||
this.theme.ensureLayoutEntry(key);
|
this.theme.ensureLayoutEntry(key);
|
||||||
this.theme.updateStructuredDraft(
|
this.theme.updateStructuredDraft((draft: ReturnType<typeof createDefaultThemeDocument>) => {
|
||||||
(draft: ReturnType<typeof createDefaultThemeDocument>) => {
|
draft.layout[key] = {
|
||||||
draft.layout[key] = {
|
...draft.layout[key],
|
||||||
...draft.layout[key],
|
grid
|
||||||
grid
|
};
|
||||||
};
|
}, true, `${key} layout updated.`);
|
||||||
},
|
|
||||||
true,
|
|
||||||
`${key} layout updated.`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resetContainer(containerKey: ThemeContainerKey): void {
|
resetContainer(containerKey: ThemeContainerKey): void {
|
||||||
const defaults = createDefaultThemeLayout();
|
const defaults = createDefaultThemeDocument();
|
||||||
|
|
||||||
this.theme.updateStructuredDraft(
|
this.theme.updateStructuredDraft((draft: ReturnType<typeof createDefaultThemeDocument>) => {
|
||||||
(draft: ReturnType<typeof createDefaultThemeDocument>) => {
|
for (const entry of this.registry.entries()) {
|
||||||
for (const entry of this.registry.entries()) {
|
if (entry.container === containerKey && entry.layoutEditable) {
|
||||||
if (entry.container === containerKey && entry.layoutEditable) {
|
draft.layout[entry.key] = defaults.layout[entry.key];
|
||||||
draft.layout[entry.key] = defaults[entry.key];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
true,
|
}, true, `${containerKey} restored to its default layout.`);
|
||||||
`${containerKey} restored to its default layout.`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,489 +0,0 @@
|
|||||||
import { DOCUMENT } from '@angular/common';
|
|
||||||
import { EnvironmentInjector, createEnvironmentInjector } from '@angular/core';
|
|
||||||
|
|
||||||
import { DEFAULT_THEME_JSON, createDefaultThemeDocument } from '../../domain/logic/theme-defaults.logic';
|
|
||||||
import { validateThemeDocument } from '../../domain/logic/theme-validation.logic';
|
|
||||||
import { ThemeService } from './theme.service';
|
|
||||||
|
|
||||||
describe('ThemeService theme application', () => {
|
|
||||||
let injector: EnvironmentInjector;
|
|
||||||
let styleElements: TestStyleElement[];
|
|
||||||
let service: ThemeService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
installLocalStorageMock();
|
|
||||||
styleElements = [];
|
|
||||||
injector = createEnvironmentInjector([
|
|
||||||
ThemeService,
|
|
||||||
{
|
|
||||||
provide: DOCUMENT,
|
|
||||||
useValue: createDocumentStub(styleElements)
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
service = injector.get(ThemeService);
|
|
||||||
service.initialize();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
injector.destroy();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the compact Toju dark theme as the built-in default JSON', () => {
|
|
||||||
expect(JSON.parse(DEFAULT_THEME_JSON) as unknown).toEqual({
|
|
||||||
meta: {
|
|
||||||
name: 'Toju Default Dark',
|
|
||||||
version: '2.0.0',
|
|
||||||
description: 'Built-in dark glass theme for the full Toju app shell.'
|
|
||||||
},
|
|
||||||
tokens: {
|
|
||||||
colors: {
|
|
||||||
background: '224 28% 7%',
|
|
||||||
foreground: '210 40% 96%',
|
|
||||||
card: '224 25% 10%',
|
|
||||||
cardForeground: '210 40% 96%',
|
|
||||||
popover: '224 26% 9%',
|
|
||||||
popoverForeground: '210 40% 96%',
|
|
||||||
primary: '193 95% 68%',
|
|
||||||
primaryForeground: '222 47% 11%',
|
|
||||||
secondary: '223 19% 16%',
|
|
||||||
secondaryForeground: '210 40% 96%',
|
|
||||||
muted: '223 18% 14%',
|
|
||||||
mutedForeground: '215 20% 70%',
|
|
||||||
accent: '218 22% 18%',
|
|
||||||
accentForeground: '210 40% 98%',
|
|
||||||
destructive: '0 72% 55%',
|
|
||||||
destructiveForeground: '0 0% 100%',
|
|
||||||
border: '222 18% 22%',
|
|
||||||
input: '222 18% 22%',
|
|
||||||
ring: '193 95% 68%',
|
|
||||||
railBackground: '226 33% 8%',
|
|
||||||
workspaceBackground: '224 30% 9%',
|
|
||||||
panelBackground: '224 24% 11%',
|
|
||||||
panelBackgroundAlt: '222 22% 13%',
|
|
||||||
titleBarBackground: '226 34% 7%',
|
|
||||||
surfaceHighlight: '193 95% 68%',
|
|
||||||
surfaceHighlightAlt: '261 82% 72%'
|
|
||||||
},
|
|
||||||
spacing: {},
|
|
||||||
radii: {
|
|
||||||
radius: '0.875rem',
|
|
||||||
surface: '1.35rem',
|
|
||||||
pill: '999px'
|
|
||||||
},
|
|
||||||
effects: {
|
|
||||||
panelShadow: '0 24px 60px rgba(0, 0, 0, 0.42)',
|
|
||||||
softShadow: '0 14px 36px rgba(0, 0, 0, 0.28)',
|
|
||||||
glassBlur: 'blur(18px) saturate(135%)'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
layout: {
|
|
||||||
serversRail: {
|
|
||||||
container: 'appShell',
|
|
||||||
grid: { x: 0, y: 0, w: 1, h: 1 }
|
|
||||||
},
|
|
||||||
appWorkspace: {
|
|
||||||
container: 'appShell',
|
|
||||||
grid: { x: 1, y: 0, w: 19, h: 1 }
|
|
||||||
},
|
|
||||||
chatRoomChannelsPanel: {
|
|
||||||
container: 'roomLayout',
|
|
||||||
grid: { x: 0, y: 0, w: 4, h: 12 }
|
|
||||||
},
|
|
||||||
chatRoomMainPanel: {
|
|
||||||
container: 'roomLayout',
|
|
||||||
grid: { x: 4, y: 0, w: 12, h: 12 }
|
|
||||||
},
|
|
||||||
chatRoomMembersPanel: {
|
|
||||||
container: 'roomLayout',
|
|
||||||
grid: { x: 16, y: 0, w: 4, h: 12 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies a JSON theme with tokens, layout, backgrounds, effects, metadata, and animations', () => {
|
|
||||||
const theme = createCompleteThemeDocument();
|
|
||||||
const loaded = service.loadThemeText(JSON.stringify(theme), 'apply', 'Theme applied.', 'complete JSON theme');
|
|
||||||
|
|
||||||
expect(loaded).toBe(true);
|
|
||||||
expect(service.activeThemeName()).toBe('Complete Theme Fixture');
|
|
||||||
expect(service.getTextOverride('titleBar')).toBe('MetoYou Lab');
|
|
||||||
expect(service.getIcon('titleBar')).toBe('MT');
|
|
||||||
expect(service.getLink('titleBar')).toBe('https://example.com/theme');
|
|
||||||
expect(service.getAnimationClass('titleBar')).toBe('themePulse');
|
|
||||||
|
|
||||||
expect(service.getHostStyles('appRoot')).toMatchObject({
|
|
||||||
'--background': '210 22% 8%',
|
|
||||||
'--theme-spacing-panel-gap': '14px',
|
|
||||||
'--radius': '1rem',
|
|
||||||
'--theme-radius-surface': '1.25rem',
|
|
||||||
'--theme-effect-glass-blur': 'blur(20px) saturate(140%)'
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(service.getHostStyles('titleBar')).toMatchObject({
|
|
||||||
width: 'min(100%, 64rem)',
|
|
||||||
height: '4rem',
|
|
||||||
minWidth: '18rem',
|
|
||||||
minHeight: '3rem',
|
|
||||||
maxWidth: '72rem',
|
|
||||||
maxHeight: '5rem',
|
|
||||||
position: 'sticky',
|
|
||||||
top: '0',
|
|
||||||
right: '0',
|
|
||||||
bottom: 'auto',
|
|
||||||
left: '0',
|
|
||||||
padding: '0.75rem 1rem',
|
|
||||||
margin: '0 auto',
|
|
||||||
border: '1px solid hsl(var(--border) / 0.75)',
|
|
||||||
borderRadius: '1rem',
|
|
||||||
backgroundColor: 'rgba(7, 11, 20, 0.88)',
|
|
||||||
color: '#f8fafc',
|
|
||||||
backgroundImage: 'linear-gradient(90deg, rgba(14, 165, 233, 0.28), transparent), url("/themes/city.png")',
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
boxShadow: '0 18px 50px rgba(0, 0, 0, 0.35)',
|
|
||||||
backdropFilter: 'var(--theme-effect-glass-blur)',
|
|
||||||
opacity: '0.82'
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(service.getLayoutContainerStyles('dmLayout')).toMatchObject({
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(4, 4.25rem) repeat(16, minmax(0, 1fr))'
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(service.getLayoutItemStyles('dmChatPanel')).toMatchObject({
|
|
||||||
gridColumn: '7 / span 14',
|
|
||||||
gridRow: '2 / span 10'
|
|
||||||
});
|
|
||||||
|
|
||||||
const animationStylesheet = styleElements.find((styleElement) => styleElement.textContent?.includes('@keyframes themePulse'));
|
|
||||||
|
|
||||||
expect(animationStylesheet?.textContent).toContain('@keyframes themePulse');
|
|
||||||
expect(animationStylesheet?.textContent).toContain('animation-direction: alternate-reverse;');
|
|
||||||
expect(animationStylesheet?.textContent).toContain('transform: scale(1);');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies raw CSS from the theme JSON', () => {
|
|
||||||
const loaded = service.loadThemeText(
|
|
||||||
JSON.stringify({
|
|
||||||
meta: {
|
|
||||||
name: 'CSS Theme',
|
|
||||||
version: '1.0.0'
|
|
||||||
},
|
|
||||||
css: '.theme-css-probe { color: hsl(var(--primary)); }'
|
|
||||||
}),
|
|
||||||
'apply',
|
|
||||||
'Theme applied.',
|
|
||||||
'CSS JSON theme'
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(loaded).toBe(true);
|
|
||||||
expect(styleElements.some((styleElement) => styleElement.textContent === '.theme-css-probe { color: hsl(var(--primary)); }')).toBe(true);
|
|
||||||
expect(service.activeThemeText()).toContain('"css"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies a CSS-only theme over the default JSON theme', () => {
|
|
||||||
const applied = service.applyCssOnlyTheme('.css-only-theme { background: hsl(var(--background)); }');
|
|
||||||
|
|
||||||
expect(applied).toBe(true);
|
|
||||||
expect(service.activeThemeName()).toBe('Toju Default Dark');
|
|
||||||
expect(service.getLayoutItemStyles('dmChatPanel')).toMatchObject({
|
|
||||||
gridColumn: '5 / span 16',
|
|
||||||
gridRow: '1 / span 12'
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(service.getHostStyles('appRoot')).toMatchObject({
|
|
||||||
'--background': '224 28% 7%'
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(styleElements.some((styleElement) => styleElement.textContent === '.css-only-theme { background: hsl(var(--background)); }')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies CSS over the current JSON draft theme', () => {
|
|
||||||
const draftTheme = createDefaultThemeDocument();
|
|
||||||
|
|
||||||
draftTheme.meta = {
|
|
||||||
name: 'Draft Base Theme',
|
|
||||||
version: '1.0.0'
|
|
||||||
};
|
|
||||||
|
|
||||||
draftTheme.tokens.colors['background'] = '200 50% 10%';
|
|
||||||
draftTheme.layout['dmChatPanel'] = {
|
|
||||||
container: 'dmLayout',
|
|
||||||
grid: { x: 6, y: 0, w: 14, h: 12 }
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadedDraft = service.loadThemeText(JSON.stringify(draftTheme), 'draft', 'Draft loaded.', 'draft JSON theme');
|
|
||||||
const applied = service.applyCssOnlyTheme('.draft-base-theme { color: hsl(var(--foreground)); }');
|
|
||||||
|
|
||||||
expect(loadedDraft).toBe(true);
|
|
||||||
expect(applied).toBe(true);
|
|
||||||
expect(service.activeThemeName()).toBe('Draft Base Theme');
|
|
||||||
expect(service.getHostStyles('appRoot')).toMatchObject({
|
|
||||||
'--background': '200 50% 10%'
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(service.getLayoutItemStyles('dmChatPanel')).toMatchObject({
|
|
||||||
gridColumn: '7 / span 14',
|
|
||||||
gridRow: '1 / span 12'
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(styleElements.some((styleElement) => styleElement.textContent === '.draft-base-theme { color: hsl(var(--foreground)); }')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('builds exportable JSON with CSS over the current draft without applying it', () => {
|
|
||||||
const draftTheme = createDefaultThemeDocument();
|
|
||||||
|
|
||||||
draftTheme.meta = {
|
|
||||||
name: 'Export Base Theme',
|
|
||||||
version: '1.0.0'
|
|
||||||
};
|
|
||||||
|
|
||||||
draftTheme.tokens.colors['background'] = '180 40% 12%';
|
|
||||||
|
|
||||||
const loadedDraft = service.loadThemeText(JSON.stringify(draftTheme), 'draft', 'Draft loaded.', 'draft JSON theme');
|
|
||||||
const exportText = service.buildDraftTextWithCss('.export-base-theme { color: hsl(var(--foreground)); }');
|
|
||||||
|
|
||||||
expect(loadedDraft).toBe(true);
|
|
||||||
expect(exportText).not.toBeNull();
|
|
||||||
expect(JSON.parse(exportText ?? '{}')).toMatchObject({
|
|
||||||
meta: {
|
|
||||||
name: 'Export Base Theme'
|
|
||||||
},
|
|
||||||
css: '.export-base-theme { color: hsl(var(--foreground)); }',
|
|
||||||
tokens: {
|
|
||||||
colors: {
|
|
||||||
background: '180 40% 12%'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(service.activeThemeName()).toBe('Toju Default Dark');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('validates the dedicated DM workspace layout container', () => {
|
|
||||||
const theme = createDefaultThemeDocument();
|
|
||||||
|
|
||||||
theme.layout['dmConversationsPanel'] = {
|
|
||||||
container: 'dmLayout',
|
|
||||||
grid: { x: 0, y: 0, w: 5, h: 12 }
|
|
||||||
};
|
|
||||||
|
|
||||||
theme.layout['dmChatPanel'] = {
|
|
||||||
container: 'dmLayout',
|
|
||||||
grid: { x: 5, y: 0, w: 15, h: 12 }
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = validateThemeDocument(theme);
|
|
||||||
|
|
||||||
expect(result.valid).toBe(true);
|
|
||||||
expect(result.value?.layout['dmChatPanel'].container).toBe('dmLayout');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows compact JSON themes with omitted element sections', () => {
|
|
||||||
const loaded = service.loadThemeText(
|
|
||||||
JSON.stringify({
|
|
||||||
meta: {
|
|
||||||
name: 'Compact Theme',
|
|
||||||
version: '1.0.0'
|
|
||||||
},
|
|
||||||
elements: {
|
|
||||||
titleBar: {
|
|
||||||
backgroundImage: '/themes/city.png',
|
|
||||||
backgroundSize: 'cover'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
'apply',
|
|
||||||
'Theme applied.',
|
|
||||||
'compact JSON theme'
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(loaded).toBe(true);
|
|
||||||
expect(service.getHostStyles('titleBar')).toMatchObject({
|
|
||||||
backgroundImage: 'url("/themes/city.png")',
|
|
||||||
backgroundSize: 'cover'
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(service.getHostStyles('voiceWorkspace')).toEqual({});
|
|
||||||
expect(service.activeThemeText()).not.toContain('voiceWorkspace');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('omits empty element stubs when formatting themes', () => {
|
|
||||||
const loaded = service.loadThemeText(
|
|
||||||
JSON.stringify({
|
|
||||||
meta: {
|
|
||||||
name: 'Theme With Empty Elements',
|
|
||||||
version: '1.0.0'
|
|
||||||
},
|
|
||||||
elements: {
|
|
||||||
titleBar: {},
|
|
||||||
voiceWorkspace: {}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
'apply',
|
|
||||||
'Theme applied.',
|
|
||||||
'theme with empty elements'
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(loaded).toBe(true);
|
|
||||||
expect(service.activeThemeText()).not.toContain('"elements"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps only non-empty element overrides when formatting themes', () => {
|
|
||||||
const loaded = service.loadThemeText(
|
|
||||||
JSON.stringify({
|
|
||||||
meta: {
|
|
||||||
name: 'Theme With Mixed Elements',
|
|
||||||
version: '1.0.0'
|
|
||||||
},
|
|
||||||
elements: {
|
|
||||||
titleBar: {},
|
|
||||||
chatRoomMainPanel: {
|
|
||||||
backgroundImage: '/themes/city.png'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
'apply',
|
|
||||||
'Theme applied.',
|
|
||||||
'theme with mixed elements'
|
|
||||||
);
|
|
||||||
const activeTheme = JSON.parse(service.activeThemeText()) as { elements?: Record<string, unknown> };
|
|
||||||
|
|
||||||
expect(loaded).toBe(true);
|
|
||||||
expect(activeTheme.elements).toEqual({
|
|
||||||
chatRoomMainPanel: {
|
|
||||||
backgroundImage: '/themes/city.png'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows removing the entire elements section', () => {
|
|
||||||
const loaded = service.loadThemeText(
|
|
||||||
JSON.stringify({
|
|
||||||
meta: {
|
|
||||||
name: 'No Elements Theme',
|
|
||||||
version: '1.0.0'
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
'apply',
|
|
||||||
'Theme applied.',
|
|
||||||
'compact JSON theme'
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(loaded).toBe(true);
|
|
||||||
expect(service.getHostStyles('titleBar')).toEqual({});
|
|
||||||
expect(service.activeThemeText()).not.toContain('"elements"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
interface TestStyleElement {
|
|
||||||
textContent: string | null;
|
|
||||||
setAttribute(name: string, value: string): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createCompleteThemeDocument() {
|
|
||||||
const theme = createDefaultThemeDocument();
|
|
||||||
|
|
||||||
theme.meta = {
|
|
||||||
name: 'Complete Theme Fixture',
|
|
||||||
version: '9.9.9',
|
|
||||||
description: 'Exercises every supported applied theme surface.'
|
|
||||||
};
|
|
||||||
|
|
||||||
theme.tokens.colors['background'] = '210 22% 8%';
|
|
||||||
theme.tokens.spacing['panelGap'] = '14px';
|
|
||||||
theme.tokens.radii['radius'] = '1rem';
|
|
||||||
theme.tokens.radii['surface'] = '1.25rem';
|
|
||||||
theme.tokens.effects['glassBlur'] = 'blur(20px) saturate(140%)';
|
|
||||||
theme.layout['dmChatPanel'] = {
|
|
||||||
container: 'dmLayout',
|
|
||||||
grid: { x: 6, y: 1, w: 14, h: 10 }
|
|
||||||
};
|
|
||||||
|
|
||||||
theme.elements['titleBar'] = {
|
|
||||||
width: 'min(100%, 64rem)',
|
|
||||||
height: '4rem',
|
|
||||||
minWidth: '18rem',
|
|
||||||
minHeight: '3rem',
|
|
||||||
maxWidth: '72rem',
|
|
||||||
maxHeight: '5rem',
|
|
||||||
position: 'sticky',
|
|
||||||
top: '0',
|
|
||||||
right: '0',
|
|
||||||
bottom: 'auto',
|
|
||||||
left: '0',
|
|
||||||
opacity: 0.82,
|
|
||||||
padding: '0.75rem 1rem',
|
|
||||||
margin: '0 auto',
|
|
||||||
border: '1px solid hsl(var(--border) / 0.75)',
|
|
||||||
borderRadius: '1rem',
|
|
||||||
backgroundColor: 'rgba(7, 11, 20, 0.88)',
|
|
||||||
color: '#f8fafc',
|
|
||||||
backgroundImage: '/themes/city.png',
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
gradient: 'linear-gradient(90deg, rgba(14, 165, 233, 0.28), transparent)',
|
|
||||||
boxShadow: '0 18px 50px rgba(0, 0, 0, 0.35)',
|
|
||||||
backdropFilter: 'var(--theme-effect-glass-blur)',
|
|
||||||
icon: 'MT',
|
|
||||||
textOverride: 'MetoYou Lab',
|
|
||||||
link: 'https://example.com/theme',
|
|
||||||
animationClass: 'themePulse'
|
|
||||||
};
|
|
||||||
|
|
||||||
theme.animations['themePulse'] = {
|
|
||||||
duration: '600ms',
|
|
||||||
easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
|
|
||||||
delay: '50ms',
|
|
||||||
iterationCount: 'infinite',
|
|
||||||
fillMode: 'both',
|
|
||||||
direction: 'alternate-reverse',
|
|
||||||
keyframes: {
|
|
||||||
from: {
|
|
||||||
opacity: 0,
|
|
||||||
transform: 'scale(0.98)'
|
|
||||||
},
|
|
||||||
to: {
|
|
||||||
opacity: 1,
|
|
||||||
transform: 'scale(1)'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return theme;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDocumentStub(styleElements: TestStyleElement[]): Document {
|
|
||||||
return {
|
|
||||||
createElement: () => {
|
|
||||||
const styleElement: TestStyleElement = {
|
|
||||||
textContent: null,
|
|
||||||
setAttribute: () => undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
return styleElement;
|
|
||||||
},
|
|
||||||
head: {
|
|
||||||
appendChild: (styleElement: TestStyleElement) => {
|
|
||||||
styleElements.push(styleElement);
|
|
||||||
return styleElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} as unknown as Document;
|
|
||||||
}
|
|
||||||
|
|
||||||
function installLocalStorageMock(): void {
|
|
||||||
const values = new Map<string, string>();
|
|
||||||
|
|
||||||
vi.stubGlobal('localStorage', {
|
|
||||||
getItem: (key: string) => values.get(key) ?? null,
|
|
||||||
setItem: (key: string, value: string) => values.set(key, value),
|
|
||||||
removeItem: (key: string) => values.delete(key),
|
|
||||||
clear: () => values.clear()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
DEFAULT_THEME_JSON,
|
DEFAULT_THEME_JSON,
|
||||||
createDefaultThemeDocument,
|
createDefaultThemeDocument,
|
||||||
createDefaultThemeLayout,
|
|
||||||
isLegacyDefaultThemeDocument
|
isLegacyDefaultThemeDocument
|
||||||
} from '../../domain/logic/theme-defaults.logic';
|
} from '../../domain/logic/theme-defaults.logic';
|
||||||
import { createAnimationStarterDefinition } from '../../domain/logic/theme-schema.logic';
|
import { createAnimationStarterDefinition } from '../../domain/logic/theme-schema.logic';
|
||||||
@@ -36,68 +35,14 @@ function toKebabCase(value: string): string {
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isEmptyRecord(value: Record<string, unknown>): boolean {
|
|
||||||
return Object.keys(value).length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasTokenOverrides(document: ThemeDocument): boolean {
|
|
||||||
return (
|
|
||||||
!isEmptyRecord(document.tokens.colors) ||
|
|
||||||
!isEmptyRecord(document.tokens.spacing) ||
|
|
||||||
!isEmptyRecord(document.tokens.radii) ||
|
|
||||||
!isEmptyRecord(document.tokens.effects)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function compactThemeElements(elements: ThemeDocument['elements']): ThemeDocument['elements'] {
|
|
||||||
return Object.fromEntries(Object.entries(elements).filter(([_key, styles]) => !isEmptyRecord(styles as Record<string, unknown>)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringifyTheme(document: ThemeDocument): string {
|
function stringifyTheme(document: ThemeDocument): string {
|
||||||
const jsonDocument: Partial<ThemeDocument> = {
|
return JSON.stringify(document, null, 2);
|
||||||
meta: document.meta
|
|
||||||
};
|
|
||||||
const compactElements = compactThemeElements(document.elements);
|
|
||||||
|
|
||||||
if (document.css.trim().length > 0) {
|
|
||||||
jsonDocument.css = document.css;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasTokenOverrides(document)) {
|
|
||||||
jsonDocument.tokens = document.tokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isEmptyRecord(document.layout)) {
|
|
||||||
jsonDocument.layout = document.layout;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isEmptyRecord(compactElements)) {
|
|
||||||
jsonDocument.elements = compactElements;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isEmptyRecord(document.animations)) {
|
|
||||||
jsonDocument.animations = document.animations;
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.stringify(jsonDocument, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
function looksLikeImageReference(value: string): boolean {
|
|
||||||
return value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/') || value.startsWith('./') || value.startsWith('../');
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeBackgroundImageLayer(value: string): string {
|
|
||||||
const trimmedValue = value.trim();
|
|
||||||
|
|
||||||
if (!looksLikeImageReference(trimmedValue)) {
|
|
||||||
return trimmedValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `url("${trimmedValue.replace(/"/g, '\\"')}")`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveBuiltInDefaultMigration(document: ThemeDocument): ThemeDocument {
|
function resolveBuiltInDefaultMigration(document: ThemeDocument): ThemeDocument {
|
||||||
return isLegacyDefaultThemeDocument(document) ? createDefaultThemeDocument() : document;
|
return isLegacyDefaultThemeDocument(document)
|
||||||
|
? createDefaultThemeDocument()
|
||||||
|
: document;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hostStylePropertyKeys = [
|
const hostStylePropertyKeys = [
|
||||||
@@ -151,7 +96,6 @@ export class ThemeService {
|
|||||||
private initialized = false;
|
private initialized = false;
|
||||||
private statusTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
private statusTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
private animationStyleElement: HTMLStyleElement | null = null;
|
private animationStyleElement: HTMLStyleElement | null = null;
|
||||||
private cssStyleElement: HTMLStyleElement | null = null;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.activeTheme = this.activeThemeInternal.asReadonly();
|
this.activeTheme = this.activeThemeInternal.asReadonly();
|
||||||
@@ -215,7 +159,6 @@ export class ThemeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.syncAnimationStylesheet();
|
this.syncAnimationStylesheet();
|
||||||
this.syncCssStylesheet();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDraftText(text: string): void {
|
updateDraftText(text: string): void {
|
||||||
@@ -260,26 +203,12 @@ export class ThemeService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
buildDraftTextWithCss(css: string): string | null {
|
loadThemeText(
|
||||||
const theme = this.composeDraftThemeWithCss(css);
|
text: string,
|
||||||
|
mode: 'draft' | 'apply',
|
||||||
return theme ? stringifyTheme(theme) : null;
|
successMessage: string,
|
||||||
}
|
sourceLabel = 'theme'
|
||||||
|
): boolean {
|
||||||
applyCssOnlyTheme(css: string): boolean {
|
|
||||||
const theme = this.composeDraftThemeWithCss(css);
|
|
||||||
|
|
||||||
if (!theme) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatted = stringifyTheme(theme);
|
|
||||||
|
|
||||||
this.commitTheme(theme, formatted, 'CSS applied over the JSON theme.');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadThemeText(text: string, mode: 'draft' | 'apply', successMessage: string, sourceLabel = 'theme'): boolean {
|
|
||||||
const result = this.parseAndValidateTheme(text, sourceLabel);
|
const result = this.parseAndValidateTheme(text, sourceLabel);
|
||||||
|
|
||||||
if (!result.valid || !result.value) {
|
if (!result.valid || !result.value) {
|
||||||
@@ -321,8 +250,9 @@ export class ThemeService {
|
|||||||
saveActiveThemeText(defaultText);
|
saveActiveThemeText(defaultText);
|
||||||
saveDraftThemeText(defaultText);
|
saveDraftThemeText(defaultText);
|
||||||
this.syncAnimationStylesheet();
|
this.syncAnimationStylesheet();
|
||||||
this.syncCssStylesheet();
|
this.setStatusMessage(reason === 'shortcut'
|
||||||
this.setStatusMessage(reason === 'shortcut' ? 'Theme reset to the default preset by shortcut.' : 'Theme reset to the default preset.');
|
? 'Theme reset to the default preset by shortcut.'
|
||||||
|
: 'Theme reset to the default preset.');
|
||||||
}
|
}
|
||||||
|
|
||||||
handleGlobalShortcut(event: KeyboardEvent): boolean {
|
handleGlobalShortcut(event: KeyboardEvent): boolean {
|
||||||
@@ -338,71 +268,41 @@ export class ThemeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ensureElementEntry(key: string): void {
|
ensureElementEntry(key: string): void {
|
||||||
if (!this.draftIsValidInternal()) {
|
this.updateStructuredDraft((draft) => {
|
||||||
this.setStatusMessage('Fix JSON errors before using the structured theme tools.');
|
draft.elements[key] = draft.elements[key] ?? {};
|
||||||
return;
|
}, false, `Prepared ${key} in the theme draft.`);
|
||||||
}
|
|
||||||
|
|
||||||
const draftJson = JSON.parse(this.draftTextInternal()) as Record<string, unknown>;
|
|
||||||
const existingElements = draftJson['elements'];
|
|
||||||
const elements =
|
|
||||||
existingElements && typeof existingElements === 'object' && !Array.isArray(existingElements)
|
|
||||||
? { ...(existingElements as Record<string, unknown>) }
|
|
||||||
: {};
|
|
||||||
|
|
||||||
elements[key] = elements[key] ?? {};
|
|
||||||
draftJson['elements'] = elements;
|
|
||||||
|
|
||||||
const result = validateThemeDocument(draftJson);
|
|
||||||
|
|
||||||
if (!result.valid || !result.value) {
|
|
||||||
this.setStatusMessage('The structured change could not be validated.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatted = JSON.stringify(draftJson, null, 2);
|
|
||||||
|
|
||||||
this.draftThemeInternal.set(result.value);
|
|
||||||
this.draftTextInternal.set(formatted);
|
|
||||||
this.draftIsValidInternal.set(true);
|
|
||||||
this.draftErrorsInternal.set([]);
|
|
||||||
saveDraftThemeText(formatted);
|
|
||||||
this.setStatusMessage(`Prepared ${key} in the theme draft.`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureLayoutEntry(key: string): void {
|
ensureLayoutEntry(key: string): void {
|
||||||
this.updateStructuredDraft(
|
this.updateStructuredDraft((draft) => {
|
||||||
(draft) => {
|
const defaults = createDefaultThemeDocument();
|
||||||
const defaults = createDefaultThemeLayout();
|
|
||||||
|
|
||||||
draft.layout[key] = draft.layout[key] ?? defaults[key];
|
draft.layout[key] = draft.layout[key] ?? defaults.layout[key];
|
||||||
},
|
}, false, `Prepared ${key} layout in the theme draft.`);
|
||||||
false,
|
|
||||||
`Prepared ${key} layout in the theme draft.`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setElementStyle(key: string, property: ThemeElementStyleProperty, value: string | number, applyImmediately = true): void {
|
setElementStyle(
|
||||||
this.updateStructuredDraft(
|
key: string,
|
||||||
(draft) => {
|
property: ThemeElementStyleProperty,
|
||||||
draft.elements[key] = {
|
value: string | number,
|
||||||
...draft.elements[key],
|
applyImmediately = true
|
||||||
[property]: value
|
): void {
|
||||||
};
|
this.updateStructuredDraft((draft) => {
|
||||||
},
|
draft.elements[key] = {
|
||||||
applyImmediately,
|
...draft.elements[key],
|
||||||
`${key} updated.`
|
[property]: value
|
||||||
);
|
};
|
||||||
|
}, applyImmediately, `${key} updated.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
setAnimation(key: string, definition: ThemeAnimationDefinition = createAnimationStarterDefinition(), applyImmediately = true): void {
|
setAnimation(
|
||||||
this.updateStructuredDraft(
|
key: string,
|
||||||
(draft) => {
|
definition: ThemeAnimationDefinition = createAnimationStarterDefinition(),
|
||||||
draft.animations[key] = definition;
|
applyImmediately = true
|
||||||
},
|
): void {
|
||||||
applyImmediately,
|
this.updateStructuredDraft((draft) => {
|
||||||
`Animation ${key} updated.`
|
draft.animations[key] = definition;
|
||||||
);
|
}, applyImmediately, `Animation ${key} updated.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getHostStyles(key: string): Record<string, string> {
|
getHostStyles(key: string): Record<string, string> {
|
||||||
@@ -414,8 +314,7 @@ export class ThemeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const backgroundLayers = [elementTheme.gradient, elementTheme.backgroundImage]
|
const backgroundLayers = [elementTheme.gradient, elementTheme.backgroundImage]
|
||||||
.filter((layer): layer is string => typeof layer === 'string' && layer.trim().length > 0)
|
.filter((layer): layer is string => typeof layer === 'string' && layer.trim().length > 0);
|
||||||
.map((layer) => normalizeBackgroundImageLayer(layer));
|
|
||||||
|
|
||||||
if (backgroundLayers.length > 0) {
|
if (backgroundLayers.length > 0) {
|
||||||
styles['backgroundImage'] = backgroundLayers.join(', ');
|
styles['backgroundImage'] = backgroundLayers.join(', ');
|
||||||
@@ -439,7 +338,9 @@ export class ThemeService {
|
|||||||
getAnimationClass(key: string): string | null {
|
getAnimationClass(key: string): string | null {
|
||||||
const animationClass = this.activeThemeInternal().elements[key]?.animationClass?.trim();
|
const animationClass = this.activeThemeInternal().elements[key]?.animationClass?.trim();
|
||||||
|
|
||||||
return animationClass && animationClass.length > 0 ? animationClass : null;
|
return animationClass && animationClass.length > 0
|
||||||
|
? animationClass
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getLink(key: string): string | null {
|
getLink(key: string): string | null {
|
||||||
@@ -466,15 +367,17 @@ export class ThemeService {
|
|||||||
return {
|
return {
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: container.templateColumns ?? `repeat(${container.columns}, minmax(0, 1fr))`,
|
gridTemplateColumns: container.templateColumns ?? `repeat(${container.columns}, minmax(0, 1fr))`,
|
||||||
gridTemplateRows: container.templateRows ?? (container.rows === 1 ? 'minmax(0, 1fr)' : `repeat(${container.rows}, minmax(0, 1fr))`),
|
gridTemplateRows: container.templateRows ?? (container.rows === 1
|
||||||
|
? 'minmax(0, 1fr)'
|
||||||
|
: `repeat(${container.rows}, minmax(0, 1fr))`),
|
||||||
minHeight: '0',
|
minHeight: '0',
|
||||||
minWidth: '0'
|
minWidth: '0'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getLayoutItemStyles(key: string): Record<string, string> {
|
getLayoutItemStyles(key: string): Record<string, string> {
|
||||||
const defaults = createDefaultThemeLayout();
|
const defaults = createDefaultThemeDocument();
|
||||||
const layoutEntry = this.activeThemeInternal().layout[key] ?? defaults[key];
|
const layoutEntry = this.activeThemeInternal().layout[key] ?? defaults.layout[key];
|
||||||
|
|
||||||
if (!layoutEntry) {
|
if (!layoutEntry) {
|
||||||
return {};
|
return {};
|
||||||
@@ -488,7 +391,11 @@ export class ThemeService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
updateStructuredDraft(mutator: (draft: ThemeDocument) => void, applyImmediately: boolean, successMessage: string): void {
|
updateStructuredDraft(
|
||||||
|
mutator: (draft: ThemeDocument) => void,
|
||||||
|
applyImmediately: boolean,
|
||||||
|
successMessage: string
|
||||||
|
): void {
|
||||||
if (!this.draftIsValidInternal()) {
|
if (!this.draftIsValidInternal()) {
|
||||||
this.setStatusMessage('Fix JSON errors before using the structured theme tools.');
|
this.setStatusMessage('Fix JSON errors before using the structured theme tools.');
|
||||||
return;
|
return;
|
||||||
@@ -531,30 +438,9 @@ export class ThemeService {
|
|||||||
saveActiveThemeText(text);
|
saveActiveThemeText(text);
|
||||||
saveDraftThemeText(text);
|
saveDraftThemeText(text);
|
||||||
this.syncAnimationStylesheet();
|
this.syncAnimationStylesheet();
|
||||||
this.syncCssStylesheet();
|
|
||||||
this.setStatusMessage(successMessage);
|
this.setStatusMessage(successMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
private composeDraftThemeWithCss(css: string): ThemeDocument | null {
|
|
||||||
if (!this.draftIsValidInternal()) {
|
|
||||||
this.setStatusMessage('Fix JSON errors before applying CSS over the theme draft.');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const theme = {
|
|
||||||
...structuredClone(this.draftThemeInternal()),
|
|
||||||
css
|
|
||||||
};
|
|
||||||
const result = validateThemeDocument(theme);
|
|
||||||
|
|
||||||
if (!result.valid || !result.value) {
|
|
||||||
this.setStatusMessage(result.errors[0] ?? 'The CSS-only theme could not be applied.');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseAndValidateTheme(text: string, label: string) {
|
private parseAndValidateTheme(text: string, label: string) {
|
||||||
try {
|
try {
|
||||||
return validateThemeDocument(JSON.parse(text) as unknown);
|
return validateThemeDocument(JSON.parse(text) as unknown);
|
||||||
@@ -579,7 +465,9 @@ export class ThemeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const [tokenName, tokenValue] of Object.entries(theme.tokens.radii)) {
|
for (const [tokenName, tokenValue] of Object.entries(theme.tokens.radii)) {
|
||||||
const cssVariableName = tokenName === 'radius' ? '--radius' : `--theme-radius-${toKebabCase(tokenName)}`;
|
const cssVariableName = tokenName === 'radius'
|
||||||
|
? '--radius'
|
||||||
|
: `--theme-radius-${toKebabCase(tokenName)}`;
|
||||||
|
|
||||||
styles[cssVariableName] = tokenValue;
|
styles[cssVariableName] = tokenValue;
|
||||||
}
|
}
|
||||||
@@ -607,18 +495,6 @@ export class ThemeService {
|
|||||||
this.animationStyleElement.textContent = css;
|
this.animationStyleElement.textContent = css;
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncCssStylesheet(): void {
|
|
||||||
const css = this.activeThemeInternal().css.trim();
|
|
||||||
|
|
||||||
if (!this.cssStyleElement) {
|
|
||||||
this.cssStyleElement = this.documentRef.createElement('style');
|
|
||||||
this.cssStyleElement.setAttribute('data-toju-theme-css', 'true');
|
|
||||||
this.documentRef.head.appendChild(this.cssStyleElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.cssStyleElement.textContent = css;
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildAnimationRule(className: string, definition: ThemeAnimationDefinition): string {
|
private buildAnimationRule(className: string, definition: ThemeAnimationDefinition): string {
|
||||||
const animationClass = `.${className}`;
|
const animationClass = `.${className}`;
|
||||||
const declarationLines = [
|
const declarationLines = [
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ import {
|
|||||||
} from '../logic/theme-schema.logic';
|
} from '../logic/theme-schema.logic';
|
||||||
|
|
||||||
function formatExample(example: string | number): string {
|
function formatExample(example: string | number): string {
|
||||||
return typeof example === 'number' ? `${example}` : JSON.stringify(example);
|
return typeof example === 'number'
|
||||||
|
? `${example}`
|
||||||
|
: JSON.stringify(example);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLayoutKeysForContainer(containerKey: string): string[] {
|
function getLayoutKeysForContainer(containerKey: string): string[] {
|
||||||
return THEME_REGISTRY.filter((entry) => entry.container === containerKey && entry.layoutEditable).map((entry) => entry.key);
|
return THEME_REGISTRY
|
||||||
|
.filter((entry) => entry.container === containerKey && entry.layoutEditable)
|
||||||
|
.map((entry) => entry.key);
|
||||||
}
|
}
|
||||||
|
|
||||||
function describeCapabilities(entry: (typeof THEME_REGISTRY)[number]): string {
|
function describeCapabilities(entry: (typeof THEME_REGISTRY)[number]): string {
|
||||||
@@ -22,7 +26,9 @@ function describeCapabilities(entry: (typeof THEME_REGISTRY)[number]): string {
|
|||||||
entry.supportsIcon ? 'icon' : null
|
entry.supportsIcon ? 'icon' : null
|
||||||
].filter((value): value is string => value !== null);
|
].filter((value): value is string => value !== null);
|
||||||
|
|
||||||
return capabilities.length > 0 ? capabilities.join(', ') : 'visual style overrides only';
|
return capabilities.length > 0
|
||||||
|
? capabilities.join(', ')
|
||||||
|
: 'visual style overrides only';
|
||||||
}
|
}
|
||||||
|
|
||||||
function describeLayoutContainer(container: (typeof THEME_LAYOUT_CONTAINERS)[number]): string {
|
function describeLayoutContainer(container: (typeof THEME_LAYOUT_CONTAINERS)[number]): string {
|
||||||
@@ -50,16 +56,15 @@ function describeThemeEntry(entry: (typeof THEME_REGISTRY)[number]): string {
|
|||||||
const colorTokenKeys = Object.keys(DEFAULT_THEME_DOCUMENT.tokens.colors);
|
const colorTokenKeys = Object.keys(DEFAULT_THEME_DOCUMENT.tokens.colors);
|
||||||
const radiusTokenKeys = Object.keys(DEFAULT_THEME_DOCUMENT.tokens.radii);
|
const radiusTokenKeys = Object.keys(DEFAULT_THEME_DOCUMENT.tokens.radii);
|
||||||
const effectTokenKeys = Object.keys(DEFAULT_THEME_DOCUMENT.tokens.effects);
|
const effectTokenKeys = Object.keys(DEFAULT_THEME_DOCUMENT.tokens.effects);
|
||||||
const layoutEditableKeys = THEME_REGISTRY.filter((entry) => entry.layoutEditable).map((entry) => entry.key);
|
const layoutEditableKeys = THEME_REGISTRY
|
||||||
const layoutContainerKeys = THEME_LAYOUT_CONTAINERS.map((container) => container.key);
|
.filter((entry) => entry.layoutEditable)
|
||||||
const layoutContainerUnion = layoutContainerKeys.map((key) => `"${key}"`).join(' | ');
|
.map((entry) => entry.key);
|
||||||
const guideTemplateDocument = {
|
const guideTemplateDocument = {
|
||||||
meta: {
|
meta: {
|
||||||
name: 'Theme Name',
|
name: 'Theme Name',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
description: 'Short mood and material direction.'
|
description: 'Short mood and material direction.'
|
||||||
},
|
},
|
||||||
css: '',
|
|
||||||
tokens: {
|
tokens: {
|
||||||
colors: {
|
colors: {
|
||||||
background: '224 28% 7%',
|
background: '224 28% 7%',
|
||||||
@@ -97,9 +102,6 @@ const guideTemplateDocument = {
|
|||||||
},
|
},
|
||||||
chatRoomMainPanel: {
|
chatRoomMainPanel: {
|
||||||
backgroundColor: 'hsl(var(--panel-background) / 0.82)',
|
backgroundColor: 'hsl(var(--panel-background) / 0.82)',
|
||||||
backgroundImage: '/assets/themes/paper-noise.png',
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'center',
|
|
||||||
borderRadius: 'var(--theme-radius-surface)',
|
borderRadius: 'var(--theme-radius-surface)',
|
||||||
boxShadow: 'var(--theme-effect-panel-shadow)'
|
boxShadow: 'var(--theme-effect-panel-shadow)'
|
||||||
}
|
}
|
||||||
@@ -117,13 +119,12 @@ export const THEME_LLM_GUIDE = [
|
|||||||
'- Return JSON only when asked to generate a theme. Do not wrap the result in Markdown fences or add commentary.',
|
'- Return JSON only when asked to generate a theme. Do not wrap the result in Markdown fences or add commentary.',
|
||||||
'',
|
'',
|
||||||
'Core rules',
|
'Core rules',
|
||||||
'- Supported top-level keys are meta, css, tokens, layout, elements, and animations. Omit sections that do not need overrides.',
|
'- Keep the top-level keys exactly: meta, tokens, layout, elements, animations.',
|
||||||
'- Use strict JSON with double-quoted keys, no comments, and no trailing commas.',
|
'- Use strict JSON with double-quoted keys, no comments, and no trailing commas.',
|
||||||
'- Omitted layout keys use the app default placement. Omitted element keys apply no Theme Studio visual override.',
|
'- Omitted optional keys inherit from the built-in default theme, so leave out anything you are not intentionally changing.',
|
||||||
'- Do not invent new top-level sections, layout containers, or element style properties.',
|
'- Do not invent new top-level sections, layout containers, or element style properties.',
|
||||||
'- links must be absolute http or https URLs.',
|
'- links must be absolute http or https URLs.',
|
||||||
'- animationClass must be a safe CSS class token and should match an entry in animations or an existing class already shipped by the app.',
|
'- animationClass must be a safe CSS class token and should match an entry in animations or an existing class already shipped by the app.',
|
||||||
'- css is optional raw CSS applied after the JSON theme is active. Keep selectors scoped to app/theme surfaces when possible.',
|
|
||||||
'- layout.grid values must be integers. x and y are zero-based. w and h must be greater than 0.',
|
'- layout.grid values must be integers. x and y are zero-based. w and h must be greater than 0.',
|
||||||
'- opacity must be a number between 0 and 1.',
|
'- opacity must be a number between 0 and 1.',
|
||||||
'',
|
'',
|
||||||
@@ -138,8 +139,6 @@ export const THEME_LLM_GUIDE = [
|
|||||||
'- tokens.colors entries become CSS variables like --background or --surface-highlight-alt.',
|
'- tokens.colors entries become CSS variables like --background or --surface-highlight-alt.',
|
||||||
'- Color token values should usually be raw HSL channels such as "224 28% 7%". The shell wraps many built-in color tokens with hsl(var(--token)).',
|
'- Color token values should usually be raw HSL channels such as "224 28% 7%". The shell wraps many built-in color tokens with hsl(var(--token)).',
|
||||||
'- You may add extra color tokens if you also reference them with CSS variables in element overrides.',
|
'- You may add extra color tokens if you also reference them with CSS variables in element overrides.',
|
||||||
'- elements.<key>.backgroundImage accepts a CSS background-image value, a local image path, or an http/https image URL.',
|
|
||||||
'- Use backgroundSize and backgroundPosition with backgroundImage when the image needs cover/center behavior.',
|
|
||||||
'- tokens.spacing entries become --theme-spacing-<kebab-case-key>.',
|
'- tokens.spacing entries become --theme-spacing-<kebab-case-key>.',
|
||||||
'- tokens.radii.radius maps to --radius. Other radius keys become --theme-radius-<kebab-case-key>.',
|
'- tokens.radii.radius maps to --radius. Other radius keys become --theme-radius-<kebab-case-key>.',
|
||||||
'- tokens.effects entries become --theme-effect-<kebab-case-key>.',
|
'- tokens.effects entries become --theme-effect-<kebab-case-key>.',
|
||||||
@@ -149,9 +148,8 @@ export const THEME_LLM_GUIDE = [
|
|||||||
'',
|
'',
|
||||||
'Top-level schema reference',
|
'Top-level schema reference',
|
||||||
'- meta: { name: string, version: string, description?: string }',
|
'- meta: { name: string, version: string, description?: string }',
|
||||||
'- css?: string',
|
|
||||||
'- tokens: { colors: Record<string, string>, spacing: Record<string, string>, radii: Record<string, string>, effects: Record<string, string> }',
|
'- tokens: { colors: Record<string, string>, spacing: Record<string, string>, radii: Record<string, string>, effects: Record<string, string> }',
|
||||||
`- layout: Record<string, { container: ${layoutContainerUnion}, grid: { x: number, y: number, w: number, h: number } }>`,
|
'- layout: Record<string, { container: "appShell" | "roomLayout", grid: { x: number, y: number, w: number, h: number } }>',
|
||||||
'- elements: Record<string, ThemeElementStyles>',
|
'- elements: Record<string, ThemeElementStyles>',
|
||||||
'- animations: Record<string, { duration?, easing?, delay?, iterationCount?, fillMode?, direction?, keyframes? }>',
|
'- animations: Record<string, { duration?, easing?, delay?, iterationCount?, fillMode?, direction?, keyframes? }>',
|
||||||
'',
|
'',
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user