Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53389ed3ad | |||
| 3858beb28e | |||
| 1b91eacb5b | |||
| 11c2588e45 | |||
| bc2fa7de22 |
@@ -1,6 +1,7 @@
|
||||
import { type Page, type Locator } from '@playwright/test';
|
||||
|
||||
export class LoginPage {
|
||||
readonly form: Locator;
|
||||
readonly usernameInput: Locator;
|
||||
readonly passwordInput: Locator;
|
||||
readonly serverSelect: Locator;
|
||||
@@ -9,12 +10,13 @@ export class LoginPage {
|
||||
readonly registerLink: Locator;
|
||||
|
||||
constructor(private page: Page) {
|
||||
this.form = page.locator('#login-username').locator('xpath=ancestor::div[contains(@class, "space-y-3")]').first();
|
||||
this.usernameInput = page.locator('#login-username');
|
||||
this.passwordInput = page.locator('#login-password');
|
||||
this.serverSelect = page.locator('#login-server');
|
||||
this.submitButton = page.getByRole('button', { name: 'Login' });
|
||||
this.submitButton = this.form.getByRole('button', { name: 'Login' });
|
||||
this.errorText = page.locator('.text-destructive');
|
||||
this.registerLink = page.getByRole('button', { name: 'Register' });
|
||||
this.registerLink = this.form.getByRole('button', { name: 'Register' });
|
||||
}
|
||||
|
||||
async goto() {
|
||||
|
||||
@@ -43,8 +43,11 @@ export class RegisterPage {
|
||||
|
||||
async register(username: string, displayName: string, password: string) {
|
||||
await this.usernameInput.fill(username);
|
||||
await expect(this.usernameInput).toHaveValue(username);
|
||||
await this.displayNameInput.fill(displayName);
|
||||
await expect(this.displayNameInput).toHaveValue(displayName);
|
||||
await this.passwordInput.fill(password);
|
||||
await expect(this.passwordInput).toHaveValue(password);
|
||||
await this.submitButton.click();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export class ServerSearchPage {
|
||||
readonly dialogCancelButton: Locator;
|
||||
|
||||
constructor(private page: Page) {
|
||||
this.searchInput = page.getByPlaceholder('Search servers...');
|
||||
this.searchInput = page.getByPlaceholder('Search servers and users...');
|
||||
this.railCreateServerButton = page.locator('button[title="Create Server"]');
|
||||
this.searchCreateServerButton = page.getByRole('button', { name: 'Create New Server' });
|
||||
this.createServerButton = this.searchCreateServerButton;
|
||||
@@ -80,6 +80,11 @@ export class ServerSearchPage {
|
||||
}
|
||||
|
||||
async joinServerFromSearch(name: string) {
|
||||
await this.page.locator('button', { hasText: name }).click();
|
||||
await this.searchInput.fill(name);
|
||||
|
||||
const serverCard = this.page.locator('div[title]', { hasText: name }).first();
|
||||
|
||||
await expect(serverCard).toBeVisible({ timeout: 15_000 });
|
||||
await serverCard.dblclick();
|
||||
}
|
||||
}
|
||||
|
||||
269
e2e/tests/auth/user-session-data-isolation.spec.ts
Normal file
269
e2e/tests/auth/user-session-data-isolation.spec.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
chromium,
|
||||
type BrowserContext,
|
||||
type Page
|
||||
} from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/multi-client';
|
||||
import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint';
|
||||
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
||||
import { LoginPage } from '../../pages/login.page';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
|
||||
interface TestUser {
|
||||
username: string;
|
||||
displayName: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface PersistentClient {
|
||||
context: BrowserContext;
|
||||
page: Page;
|
||||
userDataDir: string;
|
||||
}
|
||||
|
||||
const CLIENT_LAUNCH_ARGS = [
|
||||
'--use-fake-device-for-media-stream',
|
||||
'--use-fake-ui-for-media-stream'
|
||||
];
|
||||
|
||||
test.describe('User session data isolation', () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test('preserves a user saved rooms and local history across app restarts', async ({ testServer }) => {
|
||||
const suffix = uniqueName('persist');
|
||||
const userDataDir = await mkdtemp(join(tmpdir(), 'metoyou-auth-persist-'));
|
||||
const alice: TestUser = {
|
||||
username: `alice_${suffix}`,
|
||||
displayName: 'Alice',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
const aliceServerName = `Alice Session Server ${suffix}`;
|
||||
const aliceMessage = `Alice persisted message ${suffix}`;
|
||||
let client: PersistentClient | null = null;
|
||||
|
||||
try {
|
||||
client = await launchPersistentClient(userDataDir, testServer.port);
|
||||
|
||||
await test.step('Alice registers and creates local chat history', async () => {
|
||||
await registerUser(client.page, alice);
|
||||
await createServerAndSendMessage(client.page, aliceServerName, aliceMessage);
|
||||
});
|
||||
|
||||
await test.step('Alice sees the same saved room and message after a full restart', async () => {
|
||||
await restartPersistentClient(client, testServer.port);
|
||||
await openApp(client.page);
|
||||
await expect(client.page).not.toHaveURL(/\/login/, { timeout: 15_000 });
|
||||
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
|
||||
});
|
||||
} finally {
|
||||
await closePersistentClient(client);
|
||||
await rm(userDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('gives a new user a blank slate and restores only that user local data after account switches', async ({ testServer }) => {
|
||||
const suffix = uniqueName('isolation');
|
||||
const userDataDir = await mkdtemp(join(tmpdir(), 'metoyou-auth-isolation-'));
|
||||
const alice: TestUser = {
|
||||
username: `alice_${suffix}`,
|
||||
displayName: 'Alice',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
const bob: TestUser = {
|
||||
username: `bob_${suffix}`,
|
||||
displayName: 'Bob',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
const aliceServerName = `Alice Private Server ${suffix}`;
|
||||
const bobServerName = `Bob Private Server ${suffix}`;
|
||||
const aliceMessage = `Alice history ${suffix}`;
|
||||
const bobMessage = `Bob history ${suffix}`;
|
||||
let client: PersistentClient | null = null;
|
||||
|
||||
try {
|
||||
client = await launchPersistentClient(userDataDir, testServer.port);
|
||||
|
||||
await test.step('Alice creates persisted local data and verifies it survives a restart', async () => {
|
||||
await registerUser(client.page, alice);
|
||||
await createServerAndSendMessage(client.page, aliceServerName, aliceMessage);
|
||||
|
||||
await restartPersistentClient(client, testServer.port);
|
||||
await openApp(client.page);
|
||||
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
|
||||
});
|
||||
|
||||
await test.step('Bob starts from a blank slate in the same browser profile', async () => {
|
||||
await logoutUser(client.page);
|
||||
await registerUser(client.page, bob);
|
||||
await expectBlankSlate(client.page, [aliceServerName]);
|
||||
});
|
||||
|
||||
await test.step('Bob gets only his own saved room and history after a restart', async () => {
|
||||
await createServerAndSendMessage(client.page, bobServerName, bobMessage);
|
||||
|
||||
await restartPersistentClient(client, testServer.port);
|
||||
await openApp(client.page);
|
||||
await expectSavedRoomAndHistory(client.page, bobServerName, bobMessage);
|
||||
await expectSavedRoomHidden(client.page, aliceServerName);
|
||||
});
|
||||
|
||||
await test.step('When Alice logs back in she sees only Alice local data, not Bob data', async () => {
|
||||
await logoutUser(client.page);
|
||||
await restartPersistentClient(client, testServer.port);
|
||||
await loginUser(client.page, alice);
|
||||
|
||||
await expectSavedRoomVisible(client.page, aliceServerName);
|
||||
await expectSavedRoomHidden(client.page, bobServerName);
|
||||
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
|
||||
});
|
||||
} finally {
|
||||
await closePersistentClient(client);
|
||||
await rm(userDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function launchPersistentClient(userDataDir: string, testServerPort: number): Promise<PersistentClient> {
|
||||
const context = await chromium.launchPersistentContext(userDataDir, {
|
||||
args: CLIENT_LAUNCH_ARGS,
|
||||
baseURL: 'http://localhost:4200',
|
||||
permissions: ['microphone', 'camera']
|
||||
});
|
||||
|
||||
await installTestServerEndpoint(context, testServerPort);
|
||||
|
||||
const page = context.pages()[0] ?? await context.newPage();
|
||||
|
||||
return {
|
||||
context,
|
||||
page,
|
||||
userDataDir
|
||||
};
|
||||
}
|
||||
|
||||
async function restartPersistentClient(client: PersistentClient, testServerPort: number): Promise<void> {
|
||||
await client.context.close();
|
||||
|
||||
const restartedClient = await launchPersistentClient(client.userDataDir, testServerPort);
|
||||
|
||||
client.context = restartedClient.context;
|
||||
client.page = restartedClient.page;
|
||||
}
|
||||
|
||||
async function closePersistentClient(client: PersistentClient | null): Promise<void> {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
|
||||
await client.context.close().catch(() => {});
|
||||
}
|
||||
|
||||
async function openApp(page: Page): Promise<void> {
|
||||
await retryTransientNavigation(() => page.goto('/', { waitUntil: 'domcontentloaded' }));
|
||||
}
|
||||
|
||||
async function registerUser(page: Page, user: TestUser): Promise<void> {
|
||||
const registerPage = new RegisterPage(page);
|
||||
|
||||
await retryTransientNavigation(() => registerPage.goto());
|
||||
await registerPage.register(user.username, user.displayName, user.password);
|
||||
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function loginUser(page: Page, user: TestUser): Promise<void> {
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
await retryTransientNavigation(() => loginPage.goto());
|
||||
await loginPage.login(user.username, user.password);
|
||||
await expect(page).toHaveURL(/\/(search|room)(\/|$)/, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function logoutUser(page: Page): Promise<void> {
|
||||
const menuButton = page.getByRole('button', { name: 'Menu' });
|
||||
const logoutButton = page.getByRole('button', { name: 'Logout' });
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
await expect(menuButton).toBeVisible({ timeout: 10_000 });
|
||||
await menuButton.click();
|
||||
await expect(logoutButton).toBeVisible({ timeout: 10_000 });
|
||||
await logoutButton.click();
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
|
||||
await expect(loginPage.usernameInput).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function createServerAndSendMessage(page: Page, serverName: string, messageText: string): Promise<void> {
|
||||
const searchPage = new ServerSearchPage(page);
|
||||
const messagesPage = new ChatMessagesPage(page);
|
||||
|
||||
await searchPage.createServer(serverName, {
|
||||
description: `User session isolation coverage for ${serverName}`
|
||||
});
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
|
||||
await messagesPage.sendMessage(messageText);
|
||||
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function expectSavedRoomAndHistory(page: Page, roomName: string, messageText: string): Promise<void> {
|
||||
const roomButton = getSavedRoomButton(page, roomName);
|
||||
const messagesPage = new ChatMessagesPage(page);
|
||||
|
||||
await expect(roomButton).toBeVisible({ timeout: 20_000 });
|
||||
await roomButton.click();
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise<void> {
|
||||
const searchPage = new ServerSearchPage(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
|
||||
await expect(searchPage.createServerButton).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
for (const roomName of hiddenRoomNames) {
|
||||
await expectSavedRoomHidden(page, roomName);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectSavedRoomVisible(page: Page, roomName: string): Promise<void> {
|
||||
await expect(getSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function expectSavedRoomHidden(page: Page, roomName: string): Promise<void> {
|
||||
await expect(getSavedRoomButton(page, roomName)).toHaveCount(0);
|
||||
}
|
||||
|
||||
function getSavedRoomButton(page: Page, roomName: string) {
|
||||
return page.locator(`button[title="${roomName}"]`).first();
|
||||
}
|
||||
|
||||
async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
return await navigate();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const isTransientNavigationError = message.includes('ERR_EMPTY_RESPONSE') || message.includes('ERR_CONNECTION_RESET');
|
||||
|
||||
if (!isTransientNavigationError || attempt === attempts) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new Error(`Navigation failed after ${attempts} attempts`);
|
||||
}
|
||||
|
||||
function uniqueName(prefix: string): string {
|
||||
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
@@ -18,6 +18,85 @@ const DELETED_MESSAGE_CONTENT = '[Message deleted]';
|
||||
test.describe('Chat messaging features', () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test('shows per-server channel lists on first saved-server click', async ({ createClient }) => {
|
||||
const scenario = await createSingleClientChatScenario(createClient);
|
||||
const alphaServerName = `Alpha Server ${uniqueName('rail')}`;
|
||||
const betaServerName = `Beta Server ${uniqueName('rail')}`;
|
||||
const alphaChannelName = uniqueName('alpha-updates');
|
||||
const betaChannelName = uniqueName('beta-plans');
|
||||
const channelsPanel = scenario.room.channelsSidePanel;
|
||||
|
||||
await test.step('Create first saved server with a unique text channel', async () => {
|
||||
await createServerAndOpenRoom(scenario.search, scenario.client.page, alphaServerName, 'Rail switch alpha server');
|
||||
await scenario.room.ensureTextChannelExists(alphaChannelName);
|
||||
await expect(
|
||||
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`)
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Create second saved server with a different text channel', async () => {
|
||||
await createServerAndOpenRoom(scenario.search, scenario.client.page, betaServerName, 'Rail switch beta server');
|
||||
await scenario.room.ensureTextChannelExists(betaChannelName);
|
||||
await expect(
|
||||
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`)
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Opening first server once restores only its channels', async () => {
|
||||
await openSavedRoomByName(scenario.client.page, alphaServerName);
|
||||
|
||||
await expect(
|
||||
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`)
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await expect(
|
||||
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`)
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step('Opening second server once restores only its channels', async () => {
|
||||
await openSavedRoomByName(scenario.client.page, betaServerName);
|
||||
|
||||
await expect(
|
||||
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`)
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await expect(
|
||||
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`)
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('shows local room history on first saved-server click', async ({ createClient }) => {
|
||||
const scenario = await createSingleClientChatScenario(createClient);
|
||||
const alphaServerName = `History Alpha ${uniqueName('rail')}`;
|
||||
const betaServerName = `History Beta ${uniqueName('rail')}`;
|
||||
const alphaMessage = `Alpha history message ${uniqueName('msg')}`;
|
||||
const betaMessage = `Beta history message ${uniqueName('msg')}`;
|
||||
|
||||
await test.step('Create first server and send a local message', async () => {
|
||||
await createServerAndOpenRoom(scenario.search, scenario.client.page, alphaServerName, 'Rail history alpha server');
|
||||
await scenario.messages.sendMessage(alphaMessage);
|
||||
await expect(scenario.messages.getMessageItemByText(alphaMessage)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Create second server and send a different local message', async () => {
|
||||
await createServerAndOpenRoom(scenario.search, scenario.client.page, betaServerName, 'Rail history beta server');
|
||||
await scenario.messages.sendMessage(betaMessage);
|
||||
await expect(scenario.messages.getMessageItemByText(betaMessage)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Opening first server once restores its history immediately', async () => {
|
||||
await openSavedRoomByName(scenario.client.page, alphaServerName);
|
||||
await expect(scenario.messages.getMessageItemByText(alphaMessage)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Opening second server once restores its history immediately', async () => {
|
||||
await openSavedRoomByName(scenario.client.page, betaServerName);
|
||||
await expect(scenario.messages.getMessageItemByText(betaMessage)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test('syncs messages in a newly created text channel', async ({ createClient }) => {
|
||||
const scenario = await createChatScenario(createClient);
|
||||
const channelName = uniqueName('updates');
|
||||
@@ -143,6 +222,43 @@ interface ChatScenario {
|
||||
bobMessages: ChatMessagesPage;
|
||||
}
|
||||
|
||||
interface SingleClientChatScenario {
|
||||
client: Client;
|
||||
messages: ChatMessagesPage;
|
||||
room: ChatRoomPage;
|
||||
search: ServerSearchPage;
|
||||
}
|
||||
|
||||
async function createSingleClientChatScenario(createClient: () => Promise<Client>): Promise<SingleClientChatScenario> {
|
||||
const suffix = uniqueName('solo');
|
||||
const client = await createClient();
|
||||
const credentials = {
|
||||
username: `solo_${suffix}`,
|
||||
displayName: 'Solo',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
|
||||
await installChatFeatureMocks(client.page);
|
||||
|
||||
const registerPage = new RegisterPage(client.page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(
|
||||
credentials.username,
|
||||
credentials.displayName,
|
||||
credentials.password
|
||||
);
|
||||
|
||||
await expect(client.page).toHaveURL(/\/search/, { timeout: 15_000 });
|
||||
|
||||
return {
|
||||
client,
|
||||
messages: new ChatMessagesPage(client.page),
|
||||
room: new ChatRoomPage(client.page),
|
||||
search: new ServerSearchPage(client.page)
|
||||
};
|
||||
}
|
||||
|
||||
async function createChatScenario(createClient: () => Promise<Client>): Promise<ChatScenario> {
|
||||
const suffix = uniqueName('chat');
|
||||
const serverName = `Chat Server ${suffix}`;
|
||||
@@ -192,11 +308,8 @@ async function createChatScenario(createClient: () => Promise<Client>): Promise<
|
||||
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
|
||||
const bobSearchPage = new ServerSearchPage(bob.page);
|
||||
const serverCard = bob.page.locator('button', { hasText: serverName }).first();
|
||||
|
||||
await bobSearchPage.searchInput.fill(serverName);
|
||||
await expect(serverCard).toBeVisible({ timeout: 15_000 });
|
||||
await serverCard.click();
|
||||
await bobSearchPage.joinServerFromSearch(serverName);
|
||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
|
||||
const aliceRoom = new ChatRoomPage(alice.page);
|
||||
@@ -217,6 +330,52 @@ async function createChatScenario(createClient: () => Promise<Client>): Promise<
|
||||
};
|
||||
}
|
||||
|
||||
async function createServerAndOpenRoom(
|
||||
searchPage: ServerSearchPage,
|
||||
page: Page,
|
||||
serverName: string,
|
||||
description: string
|
||||
): Promise<void> {
|
||||
await searchPage.createServer(serverName, { description });
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
await waitForCurrentRoomName(page, serverName);
|
||||
}
|
||||
|
||||
async function openSavedRoomByName(page: Page, roomName: string): Promise<void> {
|
||||
const roomButton = page.locator(`button[title="${roomName}"]`);
|
||||
|
||||
await expect(roomButton).toBeVisible({ timeout: 20_000 });
|
||||
await roomButton.click();
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
|
||||
await waitForCurrentRoomName(page, roomName);
|
||||
}
|
||||
|
||||
async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
(expectedRoomName) => {
|
||||
interface RoomShape { name?: string }
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
const host = document.querySelector('app-rooms-side-panel');
|
||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
|
||||
|
||||
return currentRoom?.name === expectedRoomName;
|
||||
},
|
||||
roomName,
|
||||
{ timeout }
|
||||
);
|
||||
}
|
||||
|
||||
async function installChatFeatureMocks(page: Page): Promise<void> {
|
||||
await page.route('**/api/klipy/config', async (route) => {
|
||||
await route.fulfill({
|
||||
|
||||
114
e2e/tests/chat/dm-flow.spec.ts
Normal file
114
e2e/tests/chat/dm-flow.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
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)}`;
|
||||
}
|
||||
260
e2e/tests/chat/notifications.spec.ts
Normal file
260
e2e/tests/chat/notifications.spec.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import {
|
||||
expect,
|
||||
type Locator,
|
||||
type Page
|
||||
} from '@playwright/test';
|
||||
import { test, type Client } from '../../fixtures/multi-client';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
||||
|
||||
interface DesktopNotificationRecord {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
interface NotificationScenario {
|
||||
alice: Client;
|
||||
bob: Client;
|
||||
aliceRoom: ChatRoomPage;
|
||||
bobRoom: ChatRoomPage;
|
||||
bobMessages: ChatMessagesPage;
|
||||
serverName: string;
|
||||
channelName: string;
|
||||
}
|
||||
|
||||
test.describe('Chat notifications', () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test('shows desktop notifications and unread badges for inactive channels', async ({ createClient }) => {
|
||||
const scenario = await createNotificationScenario(createClient);
|
||||
const message = `Background notification ${uniqueName('msg')}`;
|
||||
|
||||
await test.step('Bob sends a message to Alice\'s inactive channel', async () => {
|
||||
await clearDesktopNotifications(scenario.alice.page);
|
||||
await scenario.bobRoom.joinTextChannel(scenario.channelName);
|
||||
await scenario.bobMessages.sendMessage(message);
|
||||
});
|
||||
|
||||
await test.step('Alice receives a desktop notification with the channel preview', async () => {
|
||||
const notification = await waitForDesktopNotification(scenario.alice.page);
|
||||
|
||||
expect(notification).toEqual({
|
||||
title: `${scenario.serverName} · #${scenario.channelName}`,
|
||||
body: `Bob: ${message}`
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Alice sees unread badges for the room and the inactive channel', async () => {
|
||||
await expect(getUnreadBadge(getSavedRoomButton(scenario.alice.page, scenario.serverName))).toHaveText('1', { timeout: 20_000 });
|
||||
await expect(getUnreadBadge(getTextChannelButton(scenario.alice.page, scenario.channelName))).toHaveText('1', { timeout: 20_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps unread badges visible when a muted channel suppresses desktop popups', async ({ createClient }) => {
|
||||
const scenario = await createNotificationScenario(createClient);
|
||||
const message = `Muted notification ${uniqueName('msg')}`;
|
||||
|
||||
await test.step('Alice mutes the inactive text channel', async () => {
|
||||
await muteTextChannel(scenario.alice.page, scenario.channelName);
|
||||
await clearDesktopNotifications(scenario.alice.page);
|
||||
});
|
||||
|
||||
await test.step('Bob sends a message into the muted channel', async () => {
|
||||
await scenario.bobRoom.joinTextChannel(scenario.channelName);
|
||||
await scenario.bobMessages.sendMessage(message);
|
||||
});
|
||||
|
||||
await test.step('Alice still sees unread badges for the room and channel', async () => {
|
||||
await expect(getUnreadBadge(getSavedRoomButton(scenario.alice.page, scenario.serverName))).toHaveText('1', { timeout: 20_000 });
|
||||
await expect(getUnreadBadge(getTextChannelButton(scenario.alice.page, scenario.channelName))).toHaveText('1', { timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Alice does not get a muted desktop popup', async () => {
|
||||
const notificationAppeared = await waitForAnyDesktopNotification(scenario.alice.page, 1_500);
|
||||
|
||||
expect(notificationAppeared).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function createNotificationScenario(createClient: () => Promise<Client>): Promise<NotificationScenario> {
|
||||
const suffix = uniqueName('notify');
|
||||
const serverName = `Notifications Server ${suffix}`;
|
||||
const channelName = uniqueName('updates');
|
||||
const aliceCredentials = {
|
||||
username: `alice_${suffix}`,
|
||||
displayName: 'Alice',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
const bobCredentials = {
|
||||
username: `bob_${suffix}`,
|
||||
displayName: 'Bob',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
const alice = await createClient();
|
||||
const bob = await createClient();
|
||||
|
||||
await installDesktopNotificationSpy(alice.page);
|
||||
|
||||
await registerUser(alice.page, aliceCredentials.username, aliceCredentials.displayName, aliceCredentials.password);
|
||||
await registerUser(bob.page, bobCredentials.username, bobCredentials.displayName, bobCredentials.password);
|
||||
|
||||
const aliceSearch = new ServerSearchPage(alice.page);
|
||||
|
||||
await aliceSearch.createServer(serverName, {
|
||||
description: 'E2E notification coverage server'
|
||||
});
|
||||
|
||||
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
|
||||
const bobSearch = new ServerSearchPage(bob.page);
|
||||
|
||||
await bobSearch.joinServerFromSearch(serverName);
|
||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
|
||||
const aliceRoom = new ChatRoomPage(alice.page);
|
||||
const bobRoom = new ChatRoomPage(bob.page);
|
||||
const aliceMessages = new ChatMessagesPage(alice.page);
|
||||
const bobMessages = new ChatMessagesPage(bob.page);
|
||||
|
||||
await aliceMessages.waitForReady();
|
||||
await bobMessages.waitForReady();
|
||||
await aliceRoom.ensureTextChannelExists(channelName);
|
||||
await expect(getTextChannelButton(alice.page, channelName)).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
return {
|
||||
alice,
|
||||
bob,
|
||||
aliceRoom,
|
||||
bobRoom,
|
||||
bobMessages,
|
||||
serverName,
|
||||
channelName
|
||||
};
|
||||
}
|
||||
|
||||
async function registerUser(page: Page, username: string, displayName: string, password: string): Promise<void> {
|
||||
const registerPage = new RegisterPage(page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(username, displayName, password);
|
||||
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function installDesktopNotificationSpy(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const notifications: DesktopNotificationRecord[] = [];
|
||||
|
||||
class MockNotification {
|
||||
static permission = 'granted';
|
||||
|
||||
onclick: (() => void) | null = null;
|
||||
|
||||
constructor(title: string, options?: NotificationOptions) {
|
||||
notifications.push({
|
||||
title,
|
||||
body: options?.body ?? ''
|
||||
});
|
||||
}
|
||||
|
||||
static async requestPermission(): Promise<NotificationPermission> {
|
||||
return 'granted';
|
||||
}
|
||||
|
||||
close(): void {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(window, '__desktopNotifications', {
|
||||
value: notifications,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'Notification', {
|
||||
value: MockNotification,
|
||||
configurable: true,
|
||||
writable: true
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function clearDesktopNotifications(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
(window as WindowWithDesktopNotifications).__desktopNotifications.length = 0;
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForDesktopNotification(page: Page): Promise<DesktopNotificationRecord> {
|
||||
await expect.poll(
|
||||
async () => (await readDesktopNotifications(page)).length,
|
||||
{
|
||||
timeout: 20_000,
|
||||
message: 'Expected a desktop notification to be emitted'
|
||||
}
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
const notifications = await readDesktopNotifications(page);
|
||||
|
||||
return notifications[notifications.length - 1];
|
||||
}
|
||||
|
||||
async function waitForAnyDesktopNotification(page: Page, timeout: number): Promise<boolean> {
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
() => (window as WindowWithDesktopNotifications).__desktopNotifications.length > 0,
|
||||
undefined,
|
||||
{ timeout }
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'TimeoutError') {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function readDesktopNotifications(page: Page): Promise<DesktopNotificationRecord[]> {
|
||||
return page.evaluate(() => {
|
||||
return [...(window as WindowWithDesktopNotifications).__desktopNotifications];
|
||||
});
|
||||
}
|
||||
|
||||
async function muteTextChannel(page: Page, channelName: string): Promise<void> {
|
||||
const channelButton = getTextChannelButton(page, channelName);
|
||||
const contextMenu = page.locator('app-context-menu');
|
||||
|
||||
await expect(channelButton).toBeVisible({ timeout: 20_000 });
|
||||
await channelButton.click({ button: 'right' });
|
||||
await expect(contextMenu.getByRole('button', { name: 'Mute Notifications' })).toBeVisible({ timeout: 10_000 });
|
||||
await contextMenu.getByRole('button', { name: 'Mute Notifications' }).click();
|
||||
await expect(contextMenu).toHaveCount(0);
|
||||
}
|
||||
|
||||
function getSavedRoomButton(page: Page, roomName: string): Locator {
|
||||
return page.locator(`button[title="${roomName}"]`).first();
|
||||
}
|
||||
|
||||
function getTextChannelButton(page: Page, channelName: string): Locator {
|
||||
return page.locator('app-rooms-side-panel').first()
|
||||
.locator(`button[data-channel-type="text"][data-channel-name="${channelName}"]`)
|
||||
.first();
|
||||
}
|
||||
|
||||
function getUnreadBadge(container: Locator): Locator {
|
||||
return container.locator('span.rounded-full').first();
|
||||
}
|
||||
|
||||
function uniqueName(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
||||
.slice(2, 8)}`;
|
||||
}
|
||||
|
||||
interface WindowWithDesktopNotifications extends Window {
|
||||
__desktopNotifications: DesktopNotificationRecord[];
|
||||
}
|
||||
@@ -384,11 +384,8 @@ async function registerUser(client: PersistentClient): Promise<void> {
|
||||
|
||||
async function joinServerFromSearch(page: Page, serverName: string): Promise<void> {
|
||||
const searchPage = new ServerSearchPage(page);
|
||||
const serverCard = page.locator('button', { hasText: serverName }).first();
|
||||
|
||||
await searchPage.searchInput.fill(serverName);
|
||||
await expect(serverCard).toBeVisible({ timeout: 15_000 });
|
||||
await serverCard.click();
|
||||
await searchPage.joinServerFromSearch(serverName);
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -56,12 +56,7 @@ async function setupServerWithBothUsers(
|
||||
// Bob joins server
|
||||
const bobSearch = new ServerSearchPage(bob.page);
|
||||
|
||||
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 bobSearch.joinServerFromSearch(SERVER_NAME);
|
||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ test.describe('Connectivity warning', () => {
|
||||
|
||||
await register.goto();
|
||||
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
|
||||
await expect(alice.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(alice.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
await test.step('Register Bob', async () => {
|
||||
@@ -96,7 +96,7 @@ test.describe('Connectivity warning', () => {
|
||||
|
||||
await register.goto();
|
||||
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
|
||||
await expect(bob.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(bob.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
await test.step('Register Charlie', async () => {
|
||||
@@ -104,7 +104,7 @@ test.describe('Connectivity warning', () => {
|
||||
|
||||
await register.goto();
|
||||
await register.register(`charlie_${suffix}`, 'Charlie', 'TestPass123!');
|
||||
await expect(charlie.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(charlie.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
// ── Create server and have everyone join ──
|
||||
@@ -117,22 +117,14 @@ test.describe('Connectivity warning', () => {
|
||||
await test.step('Bob joins the server', async () => {
|
||||
const search = new ServerSearchPage(bob.page);
|
||||
|
||||
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 search.joinServerFromSearch(serverName);
|
||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
await test.step('Charlie joins the server', async () => {
|
||||
const search = new ServerSearchPage(charlie.page);
|
||||
|
||||
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 search.joinServerFromSearch(serverName);
|
||||
await expect(charlie.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
|
||||
@@ -9,10 +9,11 @@ test.describe('ICE server settings', () => {
|
||||
|
||||
await register.goto();
|
||||
await register.register(`user_${suffix}`, 'IceTestUser', 'TestPass123!');
|
||||
await expect(page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTitle('Settings').click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole('button', { name: 'Network' })).toBeVisible({ timeout: 10_000 });
|
||||
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 }) => {
|
||||
@@ -101,7 +102,7 @@ test.describe('ICE server settings', () => {
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.getByTitle('Settings').click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole('button', { name: 'Network' })).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByRole('button', { name: 'Network' }).click();
|
||||
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.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
|
||||
await expect(alice.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(alice.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
await test.step('Register Bob', async () => {
|
||||
@@ -97,7 +97,7 @@ test.describe('STUN/TURN fallback behaviour', () => {
|
||||
|
||||
await register.goto();
|
||||
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
|
||||
await expect(bob.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(bob.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
await test.step('Alice creates a server', async () => {
|
||||
@@ -109,11 +109,7 @@ test.describe('STUN/TURN fallback behaviour', () => {
|
||||
await test.step('Bob joins Alice server', async () => {
|
||||
const search = new ServerSearchPage(bob.page);
|
||||
|
||||
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 search.joinServerFromSearch(serverName);
|
||||
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> {
|
||||
const searchInput = page.getByPlaceholder('Search servers...');
|
||||
const searchInput = page.getByPlaceholder('Search servers and users...');
|
||||
|
||||
if (await searchInput.isVisible().catch(() => false)) {
|
||||
return;
|
||||
@@ -567,15 +567,15 @@ async function openSearchView(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
|
||||
const searchInput = page.getByPlaceholder('Search servers...');
|
||||
const searchInput = page.getByPlaceholder('Search servers and users...');
|
||||
|
||||
await expect(searchInput).toBeVisible({ timeout: 20_000 });
|
||||
await searchInput.fill(roomName);
|
||||
|
||||
const roomCard = page.locator('button', { hasText: roomName }).first();
|
||||
const roomCard = page.locator('div[title]', { hasText: roomName }).first();
|
||||
|
||||
await expect(roomCard).toBeVisible({ timeout: 20_000 });
|
||||
await roomCard.click();
|
||||
await roomCard.dblclick();
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
|
||||
await waitForCurrentRoomName(page, roomName);
|
||||
|
||||
@@ -319,7 +319,7 @@ async function installDeterministicVoiceSettings(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function openSearchView(page: Page): Promise<void> {
|
||||
const searchInput = page.getByPlaceholder('Search servers...');
|
||||
const searchInput = page.getByPlaceholder('Search servers and users...');
|
||||
|
||||
if (await searchInput.isVisible().catch(() => false)) {
|
||||
return;
|
||||
@@ -330,15 +330,15 @@ async function openSearchView(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
|
||||
const searchInput = page.getByPlaceholder('Search servers...');
|
||||
const searchInput = page.getByPlaceholder('Search servers and users...');
|
||||
|
||||
await expect(searchInput).toBeVisible({ timeout: 20_000 });
|
||||
await searchInput.fill(roomName);
|
||||
|
||||
const roomCard = page.locator('button', { hasText: roomName }).first();
|
||||
const roomCard = page.locator('div[title]', { hasText: roomName }).first();
|
||||
|
||||
await expect(roomCard).toBeVisible({ timeout: 20_000 });
|
||||
await roomCard.click();
|
||||
await roomCard.dblclick();
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
|
||||
await waitForCurrentRoomName(page, roomName);
|
||||
|
||||
@@ -96,14 +96,7 @@ test.describe('Full user journey: register -> server -> voice chat', () => {
|
||||
await test.step('Bob finds and joins the server', async () => {
|
||||
const searchPage = new ServerSearchPage(bob.page);
|
||||
|
||||
// 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();
|
||||
await searchPage.joinServerFromSearch(SERVER_NAME);
|
||||
|
||||
// Bob should be in the room now
|
||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
|
||||
@@ -16,6 +16,7 @@ Electron main-process package for MetoYou / Toju. This directory owns desktop bo
|
||||
| --- | --- |
|
||||
| `main.ts` | Electron app bootstrap and process entry point |
|
||||
| `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 |
|
||||
| `ipc/` | Renderer-invoked IPC handlers |
|
||||
| `cqrs/` | Local database command/query handlers and mappings |
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createWindow, getMainWindow } from '../window/create-window';
|
||||
const CUSTOM_PROTOCOL = 'toju';
|
||||
const DEEP_LINK_PREFIX = `${CUSTOM_PROTOCOL}://`;
|
||||
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;
|
||||
|
||||
@@ -95,6 +96,12 @@ export function initializeDeepLinkHandling(): boolean {
|
||||
}
|
||||
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
if (resolveDevSingleInstanceExitCode() != null && argv.includes(DEV_RELOAD_EXISTING_ARG)) {
|
||||
app.relaunch();
|
||||
app.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
focusMainWindow();
|
||||
|
||||
const deepLink = extractDeepLink(argv);
|
||||
|
||||
9
electron/cqrs/queries/handlers/getCurrentUserId.ts
Normal file
9
electron/cqrs/queries/handlers/getCurrentUserId.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { MetaEntity } from '../../../entities';
|
||||
|
||||
export async function handleGetCurrentUserId(dataSource: DataSource): Promise<string | null> {
|
||||
const metaRepo = dataSource.getRepository(MetaEntity);
|
||||
const metaRow = await metaRepo.findOne({ where: { key: 'currentUserId' } });
|
||||
|
||||
return metaRow?.value?.trim() || null;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
GetMessageByIdQuery,
|
||||
GetReactionsForMessageQuery,
|
||||
GetUserQuery,
|
||||
GetCurrentUserIdQuery,
|
||||
GetRoomQuery,
|
||||
GetBansForRoomQuery,
|
||||
IsUserBannedQuery,
|
||||
@@ -19,6 +20,7 @@ import { handleGetMessageById } from './handlers/getMessageById';
|
||||
import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage';
|
||||
import { handleGetUser } from './handlers/getUser';
|
||||
import { handleGetCurrentUser } from './handlers/getCurrentUser';
|
||||
import { handleGetCurrentUserId } from './handlers/getCurrentUserId';
|
||||
import { handleGetUsersByRoom } from './handlers/getUsersByRoom';
|
||||
import { handleGetRoom } from './handlers/getRoom';
|
||||
import { handleGetAllRooms } from './handlers/getAllRooms';
|
||||
@@ -34,6 +36,7 @@ export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey,
|
||||
[QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource),
|
||||
[QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource),
|
||||
[QueryType.GetCurrentUser]: () => handleGetCurrentUser(dataSource),
|
||||
[QueryType.GetCurrentUserId]: () => handleGetCurrentUserId(dataSource),
|
||||
[QueryType.GetUsersByRoom]: () => handleGetUsersByRoom(dataSource),
|
||||
[QueryType.GetRoom]: (query) => handleGetRoom(query as GetRoomQuery, dataSource),
|
||||
[QueryType.GetAllRooms]: () => handleGetAllRooms(dataSource),
|
||||
|
||||
@@ -27,6 +27,7 @@ export const QueryType = {
|
||||
GetReactionsForMessage: 'get-reactions-for-message',
|
||||
GetUser: 'get-user',
|
||||
GetCurrentUser: 'get-current-user',
|
||||
GetCurrentUserId: 'get-current-user-id',
|
||||
GetUsersByRoom: 'get-users-by-room',
|
||||
GetRoom: 'get-room',
|
||||
GetAllRooms: 'get-all-rooms',
|
||||
@@ -214,6 +215,7 @@ export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; pa
|
||||
export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } }
|
||||
export interface GetUserQuery { type: typeof QueryType.GetUser; payload: { userId: string } }
|
||||
export interface GetCurrentUserQuery { type: typeof QueryType.GetCurrentUser; payload: Record<string, never> }
|
||||
export interface GetCurrentUserIdQuery { type: typeof QueryType.GetCurrentUserId; payload: Record<string, never> }
|
||||
export interface GetUsersByRoomQuery { type: typeof QueryType.GetUsersByRoom; payload: { roomId: string } }
|
||||
export interface GetRoomQuery { type: typeof QueryType.GetRoom; payload: { roomId: string } }
|
||||
export interface GetAllRoomsQuery { type: typeof QueryType.GetAllRooms; payload: Record<string, never> }
|
||||
@@ -229,6 +231,7 @@ export type Query =
|
||||
| GetReactionsForMessageQuery
|
||||
| GetUserQuery
|
||||
| GetCurrentUserQuery
|
||||
| GetCurrentUserIdQuery
|
||||
| GetUsersByRoomQuery
|
||||
| GetRoomQuery
|
||||
| GetAllRoomsQuery
|
||||
|
||||
229
electron/data-archive.ts
Normal file
229
electron/data-archive.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
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;
|
||||
}
|
||||
257
electron/data-management.ts
Normal file
257
electron/data-management.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
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,6 +49,13 @@ import {
|
||||
readSavedTheme,
|
||||
writeSavedTheme
|
||||
} from '../theme-library';
|
||||
import {
|
||||
eraseUserData,
|
||||
exportUserData,
|
||||
importUserData,
|
||||
openCurrentDataFolder
|
||||
} from '../data-management';
|
||||
import { listRunningProcessNames } from '../process-list';
|
||||
|
||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||
const FILE_CLIPBOARD_FORMATS = [
|
||||
@@ -314,6 +321,8 @@ export function setupSystemHandlers(): void {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-running-process-names', async () => await listRunningProcessNames());
|
||||
|
||||
ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => {
|
||||
return await prepareLinuxScreenShareAudioRouting();
|
||||
});
|
||||
@@ -335,6 +344,10 @@ export function setupSystemHandlers(): void {
|
||||
});
|
||||
|
||||
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('list-saved-themes', async () => await listSavedThemes());
|
||||
ipcMain.handle('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName));
|
||||
|
||||
@@ -109,6 +109,24 @@ export interface SavedThemeFileDescriptor {
|
||||
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 {
|
||||
if (process.platform !== 'linux') {
|
||||
return 'N/A';
|
||||
@@ -149,6 +167,7 @@ export interface ElectronAPI {
|
||||
|
||||
openExternal: (url: string) => Promise<boolean>;
|
||||
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
|
||||
getRunningProcessNames: () => Promise<string[]>;
|
||||
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
|
||||
@@ -157,6 +176,10 @@ export interface ElectronAPI {
|
||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||
getAppDataPath: () => Promise<string>;
|
||||
openCurrentDataFolder: () => Promise<boolean>;
|
||||
exportUserData: () => Promise<ExportUserDataResult>;
|
||||
importUserData: () => Promise<ImportUserDataResult>;
|
||||
eraseUserData: () => Promise<EraseUserDataResult>;
|
||||
getSavedThemesPath: () => Promise<string>;
|
||||
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
||||
readSavedTheme: (fileName: string) => Promise<string>;
|
||||
@@ -230,6 +253,7 @@ const electronAPI: ElectronAPI = {
|
||||
|
||||
openExternal: (url) => ipcRenderer.invoke('open-external', url),
|
||||
getSources: () => ipcRenderer.invoke('get-sources'),
|
||||
getRunningProcessNames: () => ipcRenderer.invoke('get-running-process-names'),
|
||||
prepareLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('prepare-linux-screen-share-audio-routing'),
|
||||
activateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('activate-linux-screen-share-audio-routing'),
|
||||
deactivateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('deactivate-linux-screen-share-audio-routing'),
|
||||
@@ -265,6 +289,10 @@ const electronAPI: ElectronAPI = {
|
||||
};
|
||||
},
|
||||
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'),
|
||||
listSavedThemes: () => ipcRenderer.invoke('list-saved-themes'),
|
||||
readSavedTheme: (fileName) => ipcRenderer.invoke('read-saved-theme', fileName),
|
||||
|
||||
85
electron/process-list.ts
Normal file
85
electron/process-list.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
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,8 +15,10 @@
|
||||
"@angular/platform-browser": "^21.0.0",
|
||||
"@angular/router": "^21.0.0",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/lint": "^6.9.5",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.41.0",
|
||||
@@ -2731,6 +2733,19 @@
|
||||
"@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": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
|
||||
@@ -5791,6 +5806,17 @@
|
||||
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
|
||||
"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": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||
|
||||
@@ -65,8 +65,10 @@
|
||||
"@angular/platform-browser": "^21.0.0",
|
||||
"@angular/router": "^21.0.0",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/lint": "^6.9.5",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.41.0",
|
||||
|
||||
@@ -18,7 +18,10 @@ Node/TypeScript signaling server for MetoYou / Toju. This package owns the publi
|
||||
|
||||
- The server loads the repository-root `.env` file on startup.
|
||||
- `SSL` can override the effective HTTP protocol, and `PORT` can override the effective port.
|
||||
- `data/variables.json` is normalized on startup and stores `klipyApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`.
|
||||
- `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`.
|
||||
- `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.
|
||||
- When HTTPS is enabled, certificates are read from the repository `.certs/` directory.
|
||||
|
||||
## Structure
|
||||
|
||||
Binary file not shown.
@@ -12,6 +12,7 @@ export interface LinkPreviewConfig {
|
||||
|
||||
export interface ServerVariablesConfig {
|
||||
klipyApiKey: string;
|
||||
rawgApiKey: string;
|
||||
releaseManifestUrl: string;
|
||||
serverPort: number;
|
||||
serverProtocol: ServerHttpProtocol;
|
||||
@@ -31,6 +32,10 @@ function normalizeKlipyApiKey(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeRawgApiKey(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeReleaseManifestUrl(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
@@ -139,6 +144,7 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
||||
const normalized = {
|
||||
...remainingParsed,
|
||||
klipyApiKey: normalizeKlipyApiKey(remainingParsed.klipyApiKey),
|
||||
rawgApiKey: normalizeRawgApiKey(remainingParsed.rawgApiKey),
|
||||
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl),
|
||||
serverPort: normalizeServerPort(remainingParsed.serverPort),
|
||||
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
||||
@@ -153,6 +159,7 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
||||
|
||||
return {
|
||||
klipyApiKey: normalized.klipyApiKey,
|
||||
rawgApiKey: normalized.rawgApiKey,
|
||||
releaseManifestUrl: normalized.releaseManifestUrl,
|
||||
serverPort: normalized.serverPort,
|
||||
serverProtocol: normalized.serverProtocol,
|
||||
@@ -169,6 +176,14 @@ export function getKlipyApiKey(): string {
|
||||
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 {
|
||||
return getKlipyApiKey().length > 0;
|
||||
}
|
||||
|
||||
@@ -14,10 +14,25 @@ import {
|
||||
JoinRequestEntity,
|
||||
ServerMembershipEntity,
|
||||
ServerInviteEntity,
|
||||
ServerBanEntity
|
||||
ServerBanEntity,
|
||||
GameMatchMissEntity
|
||||
} from '../entities';
|
||||
import { serverMigrations } from '../migrations';
|
||||
import { findExistingPath, resolveRuntimePath } from '../runtime-paths';
|
||||
import {
|
||||
findExistingPath,
|
||||
isPackagedRuntime,
|
||||
resolvePersistentDataPath,
|
||||
resolveRuntimePath
|
||||
} from '../runtime-paths';
|
||||
|
||||
const LEGACY_PACKAGED_DB_FILE = path.join(resolveRuntimePath('data'), 'metoyou.sqlite');
|
||||
const LEGACY_PACKAGED_DB_BACKUP = LEGACY_PACKAGED_DB_FILE + '.bak';
|
||||
|
||||
function resolveDefaultDbFile(): string {
|
||||
return isPackagedRuntime()
|
||||
? resolvePersistentDataPath('metoyou.sqlite')
|
||||
: LEGACY_PACKAGED_DB_FILE;
|
||||
}
|
||||
|
||||
function resolveDbFile(): string {
|
||||
const envPath = process.env.DB_PATH;
|
||||
@@ -26,7 +41,7 @@ function resolveDbFile(): string {
|
||||
return path.resolve(envPath);
|
||||
}
|
||||
|
||||
return path.join(resolveRuntimePath('data'), 'metoyou.sqlite');
|
||||
return resolveDefaultDbFile();
|
||||
}
|
||||
|
||||
const DB_FILE = resolveDbFile();
|
||||
@@ -37,6 +52,55 @@ const SQLITE_MAGIC = 'SQLite format 3\0';
|
||||
|
||||
let applicationDataSource: DataSource | undefined;
|
||||
|
||||
function restoreFromBackup(reason: string): Uint8Array | undefined {
|
||||
if (!fs.existsSync(DB_BACKUP)) {
|
||||
console.error(`[DB] ${reason}. No backup available - starting with a fresh database`);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const backup = new Uint8Array(fs.readFileSync(DB_BACKUP));
|
||||
|
||||
if (!isValidSqlite(backup)) {
|
||||
console.error(`[DB] ${reason}. Backup is also invalid - starting with a fresh database`);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
fs.copyFileSync(DB_BACKUP, DB_FILE);
|
||||
console.warn('[DB] Restored database from backup', DB_BACKUP);
|
||||
|
||||
return backup;
|
||||
}
|
||||
|
||||
async function migrateLegacyPackagedDatabase(): Promise<void> {
|
||||
if (process.env.DB_PATH || !isPackagedRuntime() || path.resolve(DB_FILE) === path.resolve(LEGACY_PACKAGED_DB_FILE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let migrated = false;
|
||||
|
||||
if (!fs.existsSync(DB_FILE)) {
|
||||
if (fs.existsSync(LEGACY_PACKAGED_DB_FILE)) {
|
||||
await fsp.copyFile(LEGACY_PACKAGED_DB_FILE, DB_FILE);
|
||||
migrated = true;
|
||||
} else if (fs.existsSync(LEGACY_PACKAGED_DB_BACKUP)) {
|
||||
await fsp.copyFile(LEGACY_PACKAGED_DB_BACKUP, DB_FILE);
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(DB_BACKUP) && fs.existsSync(LEGACY_PACKAGED_DB_BACKUP)) {
|
||||
await fsp.copyFile(LEGACY_PACKAGED_DB_BACKUP, DB_BACKUP);
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (migrated) {
|
||||
console.log('[DB] Migrated packaged database files to:', DATA_DIR);
|
||||
console.log('[DB] Legacy packaged database location was:', LEGACY_PACKAGED_DB_FILE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when `data` looks like a valid SQLite file
|
||||
* (correct header magic and at least one complete page).
|
||||
@@ -56,8 +120,11 @@ function isValidSqlite(data: Uint8Array): boolean {
|
||||
* restore the backup before the server loads the database.
|
||||
*/
|
||||
function safeguardDbFile(): Uint8Array | undefined {
|
||||
if (!fs.existsSync(DB_FILE))
|
||||
return undefined;
|
||||
if (!fs.existsSync(DB_FILE)) {
|
||||
console.warn(`[DB] ${DB_FILE} is missing - checking backup`);
|
||||
|
||||
return restoreFromBackup('Database file missing');
|
||||
}
|
||||
|
||||
const data = new Uint8Array(fs.readFileSync(DB_FILE));
|
||||
|
||||
@@ -72,22 +139,7 @@ function safeguardDbFile(): Uint8Array | undefined {
|
||||
// The main file is corrupt or empty.
|
||||
console.warn(`[DB] ${DB_FILE} appears corrupt (${data.length} bytes) - checking backup`);
|
||||
|
||||
if (fs.existsSync(DB_BACKUP)) {
|
||||
const backup = new Uint8Array(fs.readFileSync(DB_BACKUP));
|
||||
|
||||
if (isValidSqlite(backup)) {
|
||||
fs.copyFileSync(DB_BACKUP, DB_FILE);
|
||||
console.warn('[DB] Restored database from backup', DB_BACKUP);
|
||||
|
||||
return backup;
|
||||
}
|
||||
|
||||
console.error('[DB] Backup is also invalid - starting with a fresh database');
|
||||
} else {
|
||||
console.error('[DB] No backup available - starting with a fresh database');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return restoreFromBackup(`Database file is invalid (${data.length} bytes)`);
|
||||
}
|
||||
|
||||
function resolveSqlJsConfig(): { locateFile: (file: string) => string } {
|
||||
@@ -132,6 +184,8 @@ export async function initDatabase(): Promise<void> {
|
||||
if (!fs.existsSync(DATA_DIR))
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
await migrateLegacyPackagedDatabase();
|
||||
|
||||
const database = safeguardDbFile();
|
||||
|
||||
try {
|
||||
@@ -149,7 +203,8 @@ export async function initDatabase(): Promise<void> {
|
||||
JoinRequestEntity,
|
||||
ServerMembershipEntity,
|
||||
ServerInviteEntity,
|
||||
ServerBanEntity
|
||||
ServerBanEntity,
|
||||
GameMatchMissEntity
|
||||
],
|
||||
migrations: serverMigrations,
|
||||
synchronize: process.env.DB_SYNCHRONIZE === 'true',
|
||||
|
||||
22
server/src/entities/GameMatchMissEntity.ts
Normal file
22
server/src/entities/GameMatchMissEntity.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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,3 +9,4 @@ export { JoinRequestEntity } from './JoinRequestEntity';
|
||||
export { ServerMembershipEntity } from './ServerMembershipEntity';
|
||||
export { ServerInviteEntity } from './ServerInviteEntity';
|
||||
export { ServerBanEntity } from './ServerBanEntity';
|
||||
export { GameMatchMissEntity } from './GameMatchMissEntity';
|
||||
|
||||
24
server/src/migrations/1000000000006-GameMatchMisses.ts
Normal file
24
server/src/migrations/1000000000006-GameMatchMisses.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
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,6 +4,7 @@ import { ServerChannels1000000000002 } from './1000000000002-ServerChannels';
|
||||
import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLegacyVoiceChannels';
|
||||
import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays';
|
||||
import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl';
|
||||
import { GameMatchMisses1000000000006 } from './1000000000006-GameMatchMisses';
|
||||
|
||||
export const serverMigrations = [
|
||||
InitialSchema1000000000000,
|
||||
@@ -11,5 +12,6 @@ export const serverMigrations = [
|
||||
ServerChannels1000000000002,
|
||||
RepairLegacyVoiceChannels1000000000003,
|
||||
NormalizeServerArrays1000000000004,
|
||||
ServerRoleAccessControl1000000000005
|
||||
ServerRoleAccessControl1000000000005,
|
||||
GameMatchMisses1000000000006
|
||||
];
|
||||
|
||||
17
server/src/routes/games.ts
Normal file
17
server/src/routes/games.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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,6 +2,7 @@ import { Express } from 'express';
|
||||
import healthRouter from './health';
|
||||
import klipyRouter from './klipy';
|
||||
import linkMetadataRouter from './link-metadata';
|
||||
import gamesRouter from './games';
|
||||
import proxyRouter from './proxy';
|
||||
import usersRouter from './users';
|
||||
import serversRouter from './servers';
|
||||
@@ -12,6 +13,7 @@ export function registerRoutes(app: Express): void {
|
||||
app.use('/api', healthRouter);
|
||||
app.use('/api', klipyRouter);
|
||||
app.use('/api', linkMetadataRouter);
|
||||
app.use('/api/games', gamesRouter);
|
||||
app.use('/api', proxyRouter);
|
||||
app.use('/api/users', usersRouter);
|
||||
app.use('/api/servers', serversRouter);
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
ServerAccessError,
|
||||
kickServerUser,
|
||||
ensureServerMembership,
|
||||
unbanServerUser
|
||||
unbanServerUser,
|
||||
countServerMemberships
|
||||
} from '../services/server-access.service';
|
||||
import {
|
||||
buildAppInviteUrl,
|
||||
@@ -78,6 +79,7 @@ function normalizeServerChannels(value: unknown): ServerChannelPayload[] {
|
||||
|
||||
async function enrichServer(server: ServerPayload, sourceUrl?: string) {
|
||||
const owner = await getUserById(server.ownerId);
|
||||
const userCount = await countServerMemberships(server.id);
|
||||
const { passwordHash, ...publicServer } = server;
|
||||
|
||||
return {
|
||||
@@ -85,7 +87,8 @@ async function enrichServer(server: ServerPayload, sourceUrl?: string) {
|
||||
hasPassword: server.hasPassword ?? !!passwordHash,
|
||||
ownerName: owner?.displayName,
|
||||
sourceUrl,
|
||||
userCount: server.currentUsers
|
||||
currentUsers: userCount,
|
||||
userCount
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
const PACKAGED_DATA_DIRECTORY_NAME = 'MetoYou Server';
|
||||
|
||||
type PackagedProcess = NodeJS.Process & { pkg?: unknown };
|
||||
|
||||
function uniquePaths(paths: string[]): string[] {
|
||||
@@ -21,6 +24,33 @@ export function resolveRuntimePath(...segments: string[]): string {
|
||||
return path.join(getRuntimeBaseDir(), ...segments);
|
||||
}
|
||||
|
||||
function resolvePackagedDataDirectory(): string {
|
||||
const homeDirectory = os.homedir();
|
||||
|
||||
switch (process.platform) {
|
||||
case 'win32':
|
||||
return path.join(
|
||||
process.env.APPDATA || path.join(homeDirectory, 'AppData', 'Roaming'),
|
||||
PACKAGED_DATA_DIRECTORY_NAME
|
||||
);
|
||||
case 'darwin':
|
||||
return path.join(homeDirectory, 'Library', 'Application Support', PACKAGED_DATA_DIRECTORY_NAME);
|
||||
default:
|
||||
return path.join(
|
||||
process.env.XDG_DATA_HOME || path.join(homeDirectory, '.local', 'share'),
|
||||
PACKAGED_DATA_DIRECTORY_NAME
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePersistentDataPath(...segments: string[]): string {
|
||||
if (!isPackagedRuntime()) {
|
||||
return resolveRuntimePath(...segments);
|
||||
}
|
||||
|
||||
return path.join(resolvePackagedDataDirectory(), ...segments);
|
||||
}
|
||||
|
||||
export function resolveProjectRootPath(...segments: string[]): string {
|
||||
return path.resolve(__dirname, '..', '..', ...segments);
|
||||
}
|
||||
|
||||
591
server/src/services/game-matching.service.ts
Normal file
591
server/src/services/game-matching.service.ts
Normal file
@@ -0,0 +1,591 @@
|
||||
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,6 +130,10 @@ export async function findServerMembership(serverId: string, userId: string): Pr
|
||||
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> {
|
||||
const repo = getMembershipRepository();
|
||||
const now = Date.now();
|
||||
|
||||
@@ -67,6 +67,14 @@ describe('server websocket handler - status_update', () => {
|
||||
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 () => {
|
||||
const user = createConnectedUser('conn-1', 'user-1');
|
||||
|
||||
|
||||
@@ -71,25 +71,6 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
|
||||
const previousDescription = user.description;
|
||||
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.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
||||
|
||||
@@ -293,7 +274,13 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
||||
if (!user)
|
||||
return;
|
||||
|
||||
user.lastPong = Date.now();
|
||||
connectedUsers.set(connectionId, user);
|
||||
|
||||
switch (message.type) {
|
||||
case 'keepalive':
|
||||
break;
|
||||
|
||||
case 'identify':
|
||||
handleIdentify(user, message, connectionId);
|
||||
break;
|
||||
|
||||
@@ -17,6 +17,6 @@ export interface ConnectedUser {
|
||||
connectionScope?: string;
|
||||
/** User availability status (online, away, busy, offline). */
|
||||
status?: 'online' | 'away' | 'busy' | 'offline';
|
||||
/** Timestamp of the last pong received (used to detect dead connections). */
|
||||
/** Timestamp of the last pong or client message received (used to detect dead connections). */
|
||||
lastPong: number;
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "2.2MB",
|
||||
"maximumError": "2.35MB"
|
||||
"maximumError": "2.38MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @if (!isThemeStudioFullscreen()) {
|
||||
} @if (!isThemeStudioFullscreen() && !isDirectMessageRoute()) {
|
||||
<app-floating-voice-controls />
|
||||
}
|
||||
<app-settings-modal />
|
||||
|
||||
@@ -34,6 +34,16 @@ export const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
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',
|
||||
loadComponent: () =>
|
||||
|
||||
@@ -34,6 +34,7 @@ import { ExternalLinkService } from './core/platform';
|
||||
import { SettingsModalService } from './core/services/settings-modal.service';
|
||||
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.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 { TitleBarComponent } from './features/shell/title-bar/title-bar.component';
|
||||
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
|
||||
@@ -44,7 +45,8 @@ import { NativeContextMenuComponent } from './features/shell/native-context-menu
|
||||
import { UsersActions } from './store/users/users.actions';
|
||||
import { RoomsActions } from './store/rooms/rooms.actions';
|
||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||
import { ROOM_URL_PATTERN, STORAGE_KEY_CURRENT_USER_ID } from './core/constants';
|
||||
import { ROOM_URL_PATTERN } from './core/constants';
|
||||
import { clearStoredCurrentUserId, getStoredCurrentUserId } from './core/storage/current-user-storage';
|
||||
import {
|
||||
ThemeNodeDirective,
|
||||
ThemePickerOverlayComponent,
|
||||
@@ -94,10 +96,12 @@ export class App implements OnInit, OnDestroy {
|
||||
readonly externalLinks = inject(ExternalLinkService);
|
||||
readonly electronBridge = inject(ElectronBridgeService);
|
||||
readonly userStatus = inject(UserStatusService);
|
||||
readonly gameActivity = inject(GameActivityService);
|
||||
readonly dismissedDesktopUpdateNoticeKey = signal<string | null>(null);
|
||||
readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null);
|
||||
readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null);
|
||||
readonly isDraggingThemeStudioControls = signal(false);
|
||||
readonly currentRouteUrl = signal(this.getCurrentRouteUrl());
|
||||
|
||||
readonly appShellLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('appShell'));
|
||||
readonly serversRailLayoutStyles = computed(() => this.theme.getLayoutItemStyles('serversRail'));
|
||||
@@ -111,6 +115,7 @@ export class App implements OnInit, OnDestroy {
|
||||
return this.settingsModal.activePage() === 'theme'
|
||||
&& this.settingsModal.themeStudioMinimized();
|
||||
});
|
||||
readonly isDirectMessageRoute = computed(() => this.getRoutePath(this.currentRouteUrl()).startsWith('/dm'));
|
||||
readonly desktopUpdateNoticeKey = computed(() => {
|
||||
const updateState = this.desktopUpdateState();
|
||||
|
||||
@@ -219,8 +224,19 @@ export class App implements OnInit, OnDestroy {
|
||||
|
||||
void this.desktopUpdates.initialize();
|
||||
|
||||
let currentUserId = getStoredCurrentUserId();
|
||||
|
||||
await this.databaseService.initialize();
|
||||
|
||||
if (currentUserId) {
|
||||
const persistedUserId = await this.databaseService.getCurrentUserId();
|
||||
|
||||
if (persistedUserId !== currentUserId) {
|
||||
clearStoredCurrentUserId();
|
||||
currentUserId = null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const apiBase = this.servers.getApiBaseUrl();
|
||||
|
||||
@@ -231,31 +247,29 @@ export class App implements OnInit, OnDestroy {
|
||||
|
||||
await this.setupDesktopDeepLinks();
|
||||
|
||||
this.store.dispatch(UsersActions.loadCurrentUser());
|
||||
|
||||
this.userStatus.start();
|
||||
|
||||
this.store.dispatch(RoomsActions.loadRooms());
|
||||
|
||||
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
|
||||
this.gameActivity.start();
|
||||
const currentUrl = this.getCurrentRouteUrl();
|
||||
|
||||
if (!currentUserId) {
|
||||
if (!this.isPublicRoute(this.router.url)) {
|
||||
if (!this.isPublicRoute(currentUrl)) {
|
||||
this.router.navigate(['/login'], {
|
||||
queryParams: {
|
||||
returnUrl: this.router.url
|
||||
returnUrl: currentUrl
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
const current = this.router.url;
|
||||
this.store.dispatch(UsersActions.loadCurrentUser());
|
||||
this.store.dispatch(RoomsActions.loadRooms());
|
||||
|
||||
const generalSettings = loadGeneralSettingsFromStorage();
|
||||
const lastViewedChat = loadLastViewedChatFromStorage(currentUserId);
|
||||
|
||||
if (
|
||||
generalSettings.reopenLastViewedChat
|
||||
&& lastViewedChat
|
||||
&& (current === '/' || current === '/search')
|
||||
&& (currentUrl === '/' || currentUrl === '/search')
|
||||
) {
|
||||
this.router.navigate(['/room', lastViewedChat.roomId], { replaceUrl: true }).catch(() => {});
|
||||
}
|
||||
@@ -264,6 +278,8 @@ export class App implements OnInit, OnDestroy {
|
||||
this.router.events.subscribe((evt) => {
|
||||
if (evt instanceof NavigationEnd) {
|
||||
const url = evt.urlAfterRedirects || evt.url;
|
||||
|
||||
this.currentRouteUrl.set(url);
|
||||
const roomMatch = url.match(ROOM_URL_PATTERN);
|
||||
const currentRoomId = roomMatch ? roomMatch[1] : null;
|
||||
|
||||
@@ -388,9 +404,31 @@ export class App implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private isPublicRoute(url: string): boolean {
|
||||
return url === '/login' ||
|
||||
url === '/register' ||
|
||||
url.startsWith('/invite/');
|
||||
const path = this.getRoutePath(url);
|
||||
|
||||
return path === '/login' ||
|
||||
path === '/register' ||
|
||||
path.startsWith('/invite/');
|
||||
}
|
||||
|
||||
private getCurrentRouteUrl(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return this.router.url;
|
||||
}
|
||||
|
||||
const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
|
||||
return currentUrl || this.router.url;
|
||||
}
|
||||
|
||||
private getRoutePath(url: string): string {
|
||||
if (!url) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
const [path] = url.split(/[?#]/, 1);
|
||||
|
||||
return path || '/';
|
||||
}
|
||||
|
||||
private parseDesktopInviteUrl(url: string): { inviteId: string; sourceUrl: string } | null {
|
||||
|
||||
@@ -124,6 +124,24 @@ export interface SavedThemeFileDescriptor {
|
||||
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 {
|
||||
type: string;
|
||||
payload: unknown;
|
||||
@@ -157,6 +175,7 @@ export interface ElectronApi {
|
||||
closeWindow: () => void;
|
||||
openExternal: (url: string) => Promise<boolean>;
|
||||
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
|
||||
getRunningProcessNames: () => Promise<string[]>;
|
||||
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
|
||||
@@ -165,6 +184,10 @@ export interface ElectronApi {
|
||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||
getAppDataPath: () => Promise<string>;
|
||||
openCurrentDataFolder: () => Promise<boolean>;
|
||||
exportUserData: () => Promise<ExportUserDataResult>;
|
||||
importUserData: () => Promise<ImportUserDataResult>;
|
||||
eraseUserData: () => Promise<EraseUserDataResult>;
|
||||
getSavedThemesPath: () => Promise<string>;
|
||||
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
||||
readSavedTheme: (fileName: string) => Promise<string>;
|
||||
|
||||
@@ -7,6 +7,7 @@ export type SettingsPage =
|
||||
| 'notifications'
|
||||
| 'voice'
|
||||
| 'updates'
|
||||
| 'data'
|
||||
| 'debugging'
|
||||
| 'server'
|
||||
| 'members'
|
||||
|
||||
59
toju-app/src/app/core/storage/current-user-storage.ts
Normal file
59
toju-app/src/app/core/storage/current-user-storage.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../constants';
|
||||
|
||||
const METOYOU_STORAGE_PREFIX = 'metoyou_';
|
||||
|
||||
function normaliseStorageUserId(userId?: string | null): string | null {
|
||||
const trimmedUserId = userId?.trim();
|
||||
|
||||
return trimmedUserId || null;
|
||||
}
|
||||
|
||||
export function getStoredCurrentUserId(): string | null {
|
||||
try {
|
||||
const raw = normaliseStorageUserId(localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID));
|
||||
|
||||
return raw || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getUserScopedStorageKey(baseKey: string, userId?: string | null): string {
|
||||
const scopedUserId = userId === undefined
|
||||
? getStoredCurrentUserId()
|
||||
: normaliseStorageUserId(userId);
|
||||
|
||||
return scopedUserId
|
||||
? `${baseKey}__${encodeURIComponent(scopedUserId)}`
|
||||
: baseKey;
|
||||
}
|
||||
|
||||
export function setStoredCurrentUserId(userId: string): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, userId);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function clearStoredCurrentUserId(): void {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY_CURRENT_USER_ID);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function clearStoredLocalAppData(): void {
|
||||
try {
|
||||
const keysToRemove: string[] = [];
|
||||
|
||||
for (let index = 0; index < localStorage.length; index += 1) {
|
||||
const key = localStorage.key(index);
|
||||
|
||||
if (key?.startsWith(METOYOU_STORAGE_PREFIX)) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of keysToRemove) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ infrastructure adapters and UI.
|
||||
| **access-control** | Role, permission, ban matching, moderation, and room access rules | `normalizeRoomAccessControl()`, `resolveRoomPermission()`, `hasRoomBanForUser()` |
|
||||
| **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` |
|
||||
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
|
||||
| **direct-message** | One-to-one WebRTC messages, offline queueing, delivery state, and friends | `DirectMessageService`, `FriendService` |
|
||||
| **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` |
|
||||
| **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` |
|
||||
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
|
||||
@@ -28,6 +30,7 @@ The larger domains also keep longer design notes in their own folders:
|
||||
- [access-control/README.md](access-control/README.md)
|
||||
- [authentication/README.md](authentication/README.md)
|
||||
- [chat/README.md](chat/README.md)
|
||||
- [direct-message/README.md](direct-message/README.md)
|
||||
- [notifications/README.md](notifications/README.md)
|
||||
- [profile-avatar/README.md](profile-avatar/README.md)
|
||||
- [screen-share/README.md](screen-share/README.md)
|
||||
|
||||
@@ -24,7 +24,7 @@ authentication/
|
||||
|
||||
## Service overview
|
||||
|
||||
`AuthenticationService` resolves the API base URL from `ServerDirectoryFacade`, then makes POST requests for login and registration. It does not hold session state itself; after a successful login the calling component stores `currentUserId` in localStorage and dispatches `UsersActions.setCurrentUser` into the NgRx store.
|
||||
`AuthenticationService` resolves the API base URL from `ServerDirectoryFacade`, then makes POST requests for login and registration. It does not hold session state itself; after a successful login the calling component dispatches `UsersActions.authenticateUser`, and the users effects prepare the local persistence boundary before exposing the new user in the NgRx store.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
@@ -58,6 +58,7 @@ sequenceDiagram
|
||||
participant SD as ServerDirectoryFacade
|
||||
participant API as Server API
|
||||
participant Store as NgRx Store
|
||||
participant Effects as UsersEffects
|
||||
|
||||
User->>Login: Submit credentials
|
||||
Login->>Auth: login(username, password)
|
||||
@@ -66,13 +67,15 @@ sequenceDiagram
|
||||
Auth->>API: POST /api/auth/login
|
||||
API-->>Auth: { userId, displayName }
|
||||
Auth-->>Login: success
|
||||
Login->>Store: UsersActions.setCurrentUser
|
||||
Login->>Login: localStorage.setItem(currentUserId)
|
||||
Login->>Store: UsersActions.authenticateUser
|
||||
Store->>Effects: prepare persisted user scope
|
||||
Effects->>Store: reset stale room/user/message state
|
||||
Effects->>Store: UsersActions.setCurrentUser
|
||||
```
|
||||
|
||||
## Registration flow
|
||||
|
||||
Registration follows the same pattern but posts to `/api/auth/register` with an additional `displayName` field. On success the user is treated as logged in and the same store dispatch happens.
|
||||
Registration follows the same pattern but posts to `/api/auth/register` with an additional `displayName` field. On success the user is treated as logged in and the same authenticated-user transition runs, switching the browser persistence layer to that user's local scope before the app reloads rooms and user state.
|
||||
|
||||
## User bar
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import { AuthenticationService } from '../../application/services/authentication
|
||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
@@ -70,9 +69,7 @@ export class LoginComponent {
|
||||
joinedAt: Date.now()
|
||||
};
|
||||
|
||||
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
|
||||
|
||||
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
||||
this.store.dispatch(UsersActions.authenticateUser({ user }));
|
||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||
|
||||
if (returnUrl?.startsWith('/')) {
|
||||
|
||||
@@ -15,7 +15,6 @@ import { AuthenticationService } from '../../application/services/authentication
|
||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants';
|
||||
|
||||
@Component({
|
||||
selector: 'app-register',
|
||||
@@ -72,9 +71,7 @@ export class RegisterComponent {
|
||||
joinedAt: Date.now()
|
||||
};
|
||||
|
||||
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
|
||||
|
||||
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
||||
this.store.dispatch(UsersActions.authenticateUser({ user }));
|
||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||
|
||||
if (returnUrl?.startsWith('/')) {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
@@ -13,7 +10,11 @@ import {
|
||||
throwError
|
||||
} from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||
import {
|
||||
ServerDirectoryFacade,
|
||||
type RoomSignalSourceInput,
|
||||
type ServerSourceSelector
|
||||
} from '../../../server-directory';
|
||||
|
||||
export interface KlipyGif {
|
||||
id: string;
|
||||
@@ -37,51 +38,47 @@ export interface KlipyGifSearchResponse {
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 24;
|
||||
const KLIPY_CUSTOMER_ID_STORAGE_KEY = 'metoyou_klipy_customer_id';
|
||||
const DEFAULT_AVAILABILITY_KEY = 'default';
|
||||
|
||||
interface KlipyAvailabilityState {
|
||||
enabled: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class KlipyService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||
private readonly availabilityState = signal({
|
||||
enabled: false,
|
||||
loading: true
|
||||
});
|
||||
private lastAvailabilityKey = '';
|
||||
private readonly availabilityByKey = signal<Record<string, KlipyAvailabilityState>>({});
|
||||
|
||||
readonly isEnabled = computed(() => this.availabilityState().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();
|
||||
});
|
||||
isEnabled(source?: RoomSignalSourceInput | null): boolean {
|
||||
return this.getAvailabilityState(source).enabled;
|
||||
}
|
||||
|
||||
async refreshAvailability(): Promise<void> {
|
||||
this.availabilityState.set({ enabled: false,
|
||||
isLoading(source?: RoomSignalSourceInput | null): boolean {
|
||||
return this.getAvailabilityState(source).loading;
|
||||
}
|
||||
|
||||
async refreshAvailability(source?: RoomSignalSourceInput | null): Promise<void> {
|
||||
const selector = this.getSourceSelector(source);
|
||||
const key = this.getAvailabilityKey(selector);
|
||||
|
||||
this.setAvailabilityState(key, { enabled: false,
|
||||
loading: true });
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.get<KlipyAvailabilityResponse>(
|
||||
`${this.serverDirectory.getApiBaseUrl()}/klipy/config`
|
||||
`${this.serverDirectory.getApiBaseUrl(selector)}/klipy/config`
|
||||
)
|
||||
);
|
||||
|
||||
this.availabilityState.set({
|
||||
this.setAvailabilityState(key, {
|
||||
enabled: response.enabled === true,
|
||||
loading: false
|
||||
});
|
||||
} catch {
|
||||
this.availabilityState.set({ enabled: false,
|
||||
this.setAvailabilityState(key, { enabled: false,
|
||||
loading: false });
|
||||
}
|
||||
}
|
||||
@@ -89,8 +86,11 @@ export class KlipyService {
|
||||
searchGifs(
|
||||
query: string,
|
||||
page = 1,
|
||||
perPage = DEFAULT_PAGE_SIZE
|
||||
perPage = DEFAULT_PAGE_SIZE,
|
||||
source?: RoomSignalSourceInput | null
|
||||
): Observable<KlipyGifSearchResponse> {
|
||||
const selector = this.getSourceSelector(source);
|
||||
|
||||
let params = new HttpParams()
|
||||
.set('page', String(Math.max(1, Math.floor(page))))
|
||||
.set('per_page', String(Math.max(1, Math.floor(perPage))))
|
||||
@@ -109,7 +109,7 @@ export class KlipyService {
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<KlipyGifSearchResponse>(`${this.serverDirectory.getApiBaseUrl()}/klipy/gifs`, { params })
|
||||
.get<KlipyGifSearchResponse>(`${this.serverDirectory.getApiBaseUrl(selector)}/klipy/gifs`, { params })
|
||||
.pipe(
|
||||
map((response) => ({
|
||||
enabled: response.enabled !== false,
|
||||
@@ -138,7 +138,7 @@ export class KlipyService {
|
||||
return this.normalizeMediaUrl(url);
|
||||
}
|
||||
|
||||
buildImageProxyUrl(url: string): string {
|
||||
buildImageProxyUrl(url: string, source?: RoomSignalSourceInput | null): string {
|
||||
const trimmed = this.normalizeMediaUrl(url);
|
||||
|
||||
if (!trimmed)
|
||||
@@ -147,7 +147,36 @@ export class KlipyService {
|
||||
if (!/^https?:\/\//i.test(trimmed))
|
||||
return trimmed;
|
||||
|
||||
return `${this.serverDirectory.getApiBaseUrl()}/image-proxy?url=${encodeURIComponent(trimmed)}`;
|
||||
return `${this.serverDirectory.getApiBaseUrl(this.getSourceSelector(source))}/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 {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { KlipyService } from '../application/services/klipy.service';
|
||||
import type { RoomSignalSourceInput } from '../../server-directory';
|
||||
|
||||
@Directive({
|
||||
selector: 'img[appChatImageProxyFallback]',
|
||||
@@ -15,6 +16,7 @@ import { KlipyService } from '../application/services/klipy.service';
|
||||
})
|
||||
export class ChatImageProxyFallbackDirective {
|
||||
readonly sourceUrl = input('', { alias: 'appChatImageProxyFallback' });
|
||||
readonly signalSource = input<RoomSignalSourceInput | null>(null);
|
||||
|
||||
private readonly klipy = inject(KlipyService);
|
||||
private readonly renderedSource = signal('');
|
||||
@@ -38,7 +40,7 @@ export class ChatImageProxyFallbackDirective {
|
||||
return;
|
||||
}
|
||||
|
||||
const proxyUrl = this.klipy.buildImageProxyUrl(this.sourceUrl());
|
||||
const proxyUrl = this.klipy.buildImageProxyUrl(this.sourceUrl(), this.signalSource());
|
||||
|
||||
if (!proxyUrl || proxyUrl === this.renderedSource()) {
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<div class="chat-layout relative h-full">
|
||||
<div
|
||||
appThemeNode="chatSurface"
|
||||
class="chat-layout relative h-full"
|
||||
>
|
||||
<app-chat-message-list
|
||||
[allMessages]="allMessages()"
|
||||
[channelMessages]="channelMessages()"
|
||||
@@ -19,10 +22,15 @@
|
||||
(embedRemoved)="handleEmbedRemoved($event)"
|
||||
/>
|
||||
|
||||
<div class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10">
|
||||
<div
|
||||
appThemeNode="chatComposerBar"
|
||||
class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10"
|
||||
>
|
||||
<app-chat-message-composer
|
||||
[replyTo]="replyTo()"
|
||||
[showKlipyGifPicker]="showKlipyGifPicker()"
|
||||
[klipyEnabled]="klipyEnabled()"
|
||||
[klipySignalSource]="currentRoom()"
|
||||
(messageSubmitted)="handleMessageSubmitted($event)"
|
||||
(typingStarted)="handleTypingStarted()"
|
||||
(replyCleared)="clearReply()"
|
||||
@@ -45,11 +53,13 @@
|
||||
|
||||
<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]="klipyGifPickerAnchorRight()"
|
||||
>
|
||||
<app-klipy-gif-picker
|
||||
[signalSource]="currentRoom()"
|
||||
(gifSelected)="handleKlipyGifSelected($event)"
|
||||
(closed)="closeKlipyGifPicker()"
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
HostListener,
|
||||
ViewChild,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
@@ -11,7 +12,7 @@ import { Store } from '@ngrx/store';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { Attachment, AttachmentFacade } from '../../../attachment';
|
||||
import { KlipyGif } from '../../application/services/klipy.service';
|
||||
import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
|
||||
import { MessagesActions } from '../../../../store/messages/messages.actions';
|
||||
import {
|
||||
selectAllMessages,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors';
|
||||
import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
||||
import { Message } from '../../../../shared-kernel';
|
||||
import { ThemeNodeDirective } from '../../../theme';
|
||||
import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component';
|
||||
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
|
||||
import { ChatMessageListComponent } from './components/message-list/chat-message-list.component';
|
||||
@@ -42,7 +44,8 @@ import {
|
||||
ChatMessageComposerComponent,
|
||||
KlipyGifPickerComponent,
|
||||
ChatMessageListComponent,
|
||||
ChatMessageOverlaysComponent
|
||||
ChatMessageOverlaysComponent,
|
||||
ThemeNodeDirective
|
||||
],
|
||||
templateUrl: './chat-messages.component.html',
|
||||
styleUrl: './chat-messages.component.scss'
|
||||
@@ -54,10 +57,11 @@ export class ChatMessagesComponent {
|
||||
private readonly store = inject(Store);
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
private readonly attachmentsSvc = inject(AttachmentFacade);
|
||||
private readonly klipy = inject(KlipyService);
|
||||
|
||||
readonly allMessages = this.store.selectSignal(selectAllMessages);
|
||||
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
readonly loading = this.store.selectSignal(selectMessagesLoading);
|
||||
readonly syncing = this.store.selectSignal(selectMessagesSyncing);
|
||||
@@ -68,16 +72,11 @@ export class ChatMessagesComponent {
|
||||
const channelId = this.activeChannelId();
|
||||
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(() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`);
|
||||
readonly klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom()));
|
||||
readonly composerBottomPadding = signal(140);
|
||||
readonly klipyGifPickerAnchorRight = signal(16);
|
||||
readonly replyTo = signal<Message | null>(null);
|
||||
@@ -85,6 +84,12 @@ export class ChatMessagesComponent {
|
||||
readonly lightboxAttachment = signal<Attachment | null>(null);
|
||||
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
void this.klipy.refreshAvailability(this.currentRoom());
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener('window:resize')
|
||||
onWindowResize(): void {
|
||||
if (this.showKlipyGifPicker()) {
|
||||
@@ -167,9 +172,7 @@ export class ChatMessagesComponent {
|
||||
if (!message || !currentUserId)
|
||||
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) {
|
||||
this.store.dispatch(
|
||||
@@ -234,9 +237,7 @@ export class ChatMessagesComponent {
|
||||
const minRight = 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 {
|
||||
@@ -281,10 +282,7 @@ export class ChatMessagesComponent {
|
||||
|
||||
if (blob) {
|
||||
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)
|
||||
return;
|
||||
@@ -407,12 +405,7 @@ export class ChatMessagesComponent {
|
||||
|
||||
const message = [...this.channelMessages()]
|
||||
.reverse()
|
||||
.find(
|
||||
(entry) =>
|
||||
entry.senderId === currentUserId &&
|
||||
entry.content === content &&
|
||||
!entry.isDeleted
|
||||
);
|
||||
.find((entry) => entry.senderId === currentUserId && entry.content === content && !entry.isDeleted);
|
||||
|
||||
if (!message) {
|
||||
setTimeout(() => this.attachFilesToLastOwnMessage(content, pendingFiles), 150);
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<!-- eslint-disable @angular-eslint/template/button-has-type -->
|
||||
<div #composerRoot>
|
||||
<div
|
||||
#composerRoot
|
||||
appThemeNode="chatComposerBar"
|
||||
>
|
||||
@if (replyTo()) {
|
||||
<div class="pointer-events-auto flex items-center gap-2 bg-secondary/50 px-4 py-2">
|
||||
<div
|
||||
appThemeNode="chatComposerReplyBar"
|
||||
class="pointer-events-auto flex items-center gap-2 bg-secondary/50 px-4 py-2"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideReply"
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
@@ -31,6 +37,7 @@
|
||||
(mouseleave)="onToolbarMouseLeave()"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<button
|
||||
@@ -124,6 +131,7 @@
|
||||
|
||||
<div class="border-border p-4">
|
||||
<div
|
||||
appThemeNode="chatComposerInput"
|
||||
class="chat-input-wrapper relative"
|
||||
(mouseenter)="inputHovered.set(true)"
|
||||
(mouseleave)="inputHovered.set(false)"
|
||||
@@ -133,7 +141,7 @@
|
||||
(drop)="onDrop($event)"
|
||||
>
|
||||
<div class="absolute bottom-3 right-3 z-10 flex items-center gap-2 m-0.5">
|
||||
@if (klipy.isEnabled()) {
|
||||
@if (klipyEnabled()) {
|
||||
<button
|
||||
#klipyTrigger
|
||||
type="button"
|
||||
@@ -156,6 +164,7 @@
|
||||
}
|
||||
|
||||
<button
|
||||
appThemeNode="chatComposerSendButton"
|
||||
type="button"
|
||||
(click)="sendMessage()"
|
||||
[disabled]="!messageContent.trim() && pendingFiles.length === 0 && !pendingKlipyGif()"
|
||||
@@ -172,6 +181,7 @@
|
||||
|
||||
<textarea
|
||||
#messageInputRef
|
||||
[attr.data-testid]="textareaTestId()"
|
||||
rows="1"
|
||||
[(ngModel)]="messageContent"
|
||||
(focus)="onInputFocus()"
|
||||
@@ -189,8 +199,8 @@
|
||||
[class.border-primary]="dragActive()"
|
||||
[class.chat-textarea-expanded]="textareaExpanded()"
|
||||
[class.ctrl-resize]="ctrlHeld()"
|
||||
[class.pr-16]="!klipy.isEnabled()"
|
||||
[class.pr-40]="klipy.isEnabled()"
|
||||
[class.pr-16]="!klipyEnabled()"
|
||||
[class.pr-40]="klipyEnabled()"
|
||||
></textarea>
|
||||
|
||||
@if (dragActive()) {
|
||||
@@ -207,6 +217,7 @@
|
||||
<div class="relative h-12 w-12 overflow-hidden rounded-lg bg-secondary/80">
|
||||
<img
|
||||
[appChatImageProxyFallback]="pendingKlipyGif()!.previewUrl || pendingKlipyGif()!.url"
|
||||
[signalSource]="klipySignalSource()"
|
||||
[alt]="pendingKlipyGif()!.title || 'KLIPY GIF'"
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
|
||||
@@ -23,6 +23,8 @@ import type { ClipboardFilePayload } from '../../../../../../core/platform/elect
|
||||
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
|
||||
import { KlipyGif, KlipyService } from '../../../../application/services/klipy.service';
|
||||
import { Message } from '../../../../../../shared-kernel';
|
||||
import { ThemeNodeDirective } from '../../../../../theme';
|
||||
import type { RoomSignalSourceInput } from '../../../../../server-directory';
|
||||
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
|
||||
import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component';
|
||||
import { ChatMarkdownService } from '../../services/chat-markdown.service';
|
||||
@@ -42,7 +44,8 @@ const DEFAULT_TEXTAREA_HEIGHT = 62;
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
ChatImageProxyFallbackDirective,
|
||||
TypingIndicatorComponent
|
||||
TypingIndicatorComponent,
|
||||
ThemeNodeDirective
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
@@ -66,6 +69,9 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
|
||||
readonly replyTo = input<Message | null>(null);
|
||||
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 typingStarted = output();
|
||||
@@ -73,7 +79,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
readonly heightChanged = output<number>();
|
||||
readonly klipyGifPickerToggleRequested = output();
|
||||
|
||||
readonly klipy = inject(KlipyService);
|
||||
private readonly klipy = inject(KlipyService);
|
||||
private readonly markdown = inject(ChatMarkdownService);
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
@@ -207,7 +213,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
toggleKlipyGifPicker(): void {
|
||||
if (!this.klipy.isEnabled())
|
||||
if (!this.klipyEnabled())
|
||||
return;
|
||||
|
||||
this.klipyGifPickerToggleRequested.emit();
|
||||
@@ -411,11 +417,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
|
||||
}
|
||||
|
||||
private hasPotentialFilePayload(
|
||||
dataTransfer: DataTransfer | null,
|
||||
treatMissingTypesAsPotentialFile = true
|
||||
): boolean {
|
||||
|
||||
private hasPotentialFilePayload(dataTransfer: DataTransfer | null, treatMissingTypesAsPotentialFile = true): boolean {
|
||||
if (!dataTransfer)
|
||||
return false;
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
@let msg = message();
|
||||
@let attachmentsList = attachmentViewModels();
|
||||
<div
|
||||
appThemeNode="chatMessageBubble"
|
||||
[attr.data-message-id]="msg.id"
|
||||
class="group relative flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30"
|
||||
[class.opacity-50]="msg.isDeleted"
|
||||
>
|
||||
<div
|
||||
appThemeNode="chatMessageAvatar"
|
||||
class="flex-shrink-0 cursor-pointer"
|
||||
(click)="openSenderProfileCard($event); $event.stopPropagation()"
|
||||
>
|
||||
@@ -17,7 +19,10 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div
|
||||
appThemeNode="chatMessageContent"
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
@if (msg.replyToId) {
|
||||
@let reply = repliedMessage();
|
||||
<div
|
||||
@@ -150,7 +155,10 @@
|
||||
</div>
|
||||
</div>
|
||||
} @else if ((att.receivedBytes || 0) > 0) {
|
||||
<div class="max-w-xs rounded-md border border-border bg-secondary/40 p-3">
|
||||
<div
|
||||
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 h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-primary/10">
|
||||
<ng-icon
|
||||
@@ -172,7 +180,10 @@
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="max-w-xs rounded-md border border-dashed border-border bg-secondary/20 p-4">
|
||||
<div
|
||||
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 h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-muted">
|
||||
<ng-icon
|
||||
@@ -220,7 +231,10 @@
|
||||
/>
|
||||
}
|
||||
} @else if ((att.receivedBytes || 0) > 0) {
|
||||
<div class="max-w-xl rounded-md border border-border bg-secondary/40 p-3">
|
||||
<div
|
||||
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="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
|
||||
@@ -247,7 +261,10 @@
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="max-w-xl rounded-md border border-dashed border-border bg-secondary/20 p-4">
|
||||
<div
|
||||
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="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium text-foreground">{{ att.filename }}</div>
|
||||
@@ -271,7 +288,10 @@
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="rounded-md border border-border bg-secondary/40 p-2">
|
||||
<div
|
||||
appThemeNode="chatAttachmentCard"
|
||||
class="rounded-md border border-border bg-secondary/40 p-2"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
|
||||
@@ -339,6 +359,7 @@
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
@for (reaction of getGroupedReactions(); track reaction.emoji) {
|
||||
<button
|
||||
appThemeNode="chatReactionPill"
|
||||
(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.ring-1]="reaction.hasCurrentUser"
|
||||
@@ -354,6 +375,7 @@
|
||||
|
||||
@if (!msg.isDeleted) {
|
||||
<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"
|
||||
>
|
||||
<div class="relative">
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
Message,
|
||||
User
|
||||
} from '../../../../../../shared-kernel';
|
||||
import { ThemeNodeDirective } from '../../../../../theme';
|
||||
|
||||
import {
|
||||
ChatAudioPlayerComponent,
|
||||
@@ -96,7 +97,8 @@ interface ChatMessageAttachmentViewModel extends Attachment {
|
||||
ChatVideoPlayerComponent,
|
||||
ChatMessageMarkdownComponent,
|
||||
ChatLinkEmbedComponent,
|
||||
UserAvatarComponent
|
||||
UserAvatarComponent,
|
||||
ThemeNodeDirective
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
@@ -150,7 +152,8 @@ export class ChatMessageItemComponent {
|
||||
const msg = this.message();
|
||||
const found = this.userLookup().get(msg.senderId);
|
||||
|
||||
return found ?? {
|
||||
return (
|
||||
found ?? {
|
||||
id: msg.senderId,
|
||||
oderId: msg.senderId,
|
||||
username: msg.senderName,
|
||||
@@ -158,7 +161,8 @@ export class ChatMessageItemComponent {
|
||||
status: 'disconnected',
|
||||
role: 'member',
|
||||
joinedAt: 0
|
||||
};
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
editContent = '';
|
||||
@@ -175,9 +179,7 @@ export class ChatMessageItemComponent {
|
||||
readonly attachmentViewModels = computed<ChatMessageAttachmentViewModel[]>(() => {
|
||||
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(() => {
|
||||
const version = this.attachmentsSvc.updated();
|
||||
@@ -320,8 +322,7 @@ export class ChatMessageItemComponent {
|
||||
hour: '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));
|
||||
|
||||
if (dayDiff === 0)
|
||||
@@ -331,11 +332,7 @@ export class ChatMessageItemComponent {
|
||||
return 'Yesterday ' + time;
|
||||
|
||||
if (dayDiff < 7) {
|
||||
return (
|
||||
date.toLocaleDateString([], { weekday: 'short' }) +
|
||||
' ' +
|
||||
time
|
||||
);
|
||||
return date.toLocaleDateString([], { weekday: 'short' }) + ' ' + time;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -402,10 +399,7 @@ export class ChatMessageItemComponent {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -418,9 +412,7 @@ export class ChatMessageItemComponent {
|
||||
: '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 {
|
||||
@@ -484,8 +476,7 @@ export class ChatMessageItemComponent {
|
||||
private buildAttachmentViewModel(attachment: Attachment): ChatMessageAttachmentViewModel {
|
||||
const isVideo = this.isVideoAttachment(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 {
|
||||
...attachment,
|
||||
@@ -493,8 +484,12 @@ export class ChatMessageItemComponent {
|
||||
isUploader: this.isUploader(attachment),
|
||||
isVideo,
|
||||
mediaActionLabel: requiresMediaDownloadAcceptance
|
||||
? attachment.requestError ? 'Retry download' : 'Accept download'
|
||||
: attachment.requestError ? 'Retry' : 'Request',
|
||||
? attachment.requestError
|
||||
? 'Retry download'
|
||||
: 'Accept download'
|
||||
: attachment.requestError
|
||||
? 'Retry'
|
||||
: 'Request',
|
||||
mediaStatusText: attachment.requestError
|
||||
? attachment.requestError
|
||||
: requiresMediaDownloadAcceptance
|
||||
@@ -504,15 +499,11 @@ export class ChatMessageItemComponent {
|
||||
: isVideo
|
||||
? 'Waiting for video 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 {
|
||||
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,5 +1,6 @@
|
||||
<div
|
||||
#messagesContainer
|
||||
appThemeNode="chatMessageList"
|
||||
class="absolute inset-0 space-y-4 overflow-y-auto p-4"
|
||||
[style.padding-bottom.px]="bottomPadding()"
|
||||
(scroll)="onScroll()"
|
||||
@@ -39,7 +40,10 @@
|
||||
|
||||
@for (message of messages(); track message.id; let index = $index) {
|
||||
@if (dateSeparatorLabels().get(index); as separatorLabel) {
|
||||
<div class="flex items-center gap-3 py-1">
|
||||
<div
|
||||
appThemeNode="chatDateSeparator"
|
||||
class="flex items-center gap-3 py-1"
|
||||
>
|
||||
<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">
|
||||
{{ separatorLabel }}
|
||||
@@ -70,7 +74,10 @@
|
||||
|
||||
@if (showNewMessagesBar()) {
|
||||
<div class="pointer-events-none sticky bottom-4 flex justify-center">
|
||||
<div class="pointer-events-auto flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2 shadow">
|
||||
<div
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
ChatMessageReplyEvent
|
||||
} from '../../models/chat-messages.model';
|
||||
import { selectAllUsers } from '../../../../../../store/users/users.selectors';
|
||||
import { ThemeNodeDirective } from '../../../../../theme';
|
||||
import { ChatMessageItemComponent } from '../message-item/chat-message-item.component';
|
||||
|
||||
interface PrismGlobal {
|
||||
@@ -41,7 +42,11 @@ declare global {
|
||||
@Component({
|
||||
selector: 'app-chat-message-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ChatMessageItemComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ChatMessageItemComponent,
|
||||
ThemeNodeDirective
|
||||
],
|
||||
templateUrl: './chat-message-list.component.html',
|
||||
host: {
|
||||
style: 'display: contents;'
|
||||
@@ -66,6 +71,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
readonly isAdmin = input(false);
|
||||
readonly bottomPadding = input(120);
|
||||
readonly conversationKey = input.required<string>();
|
||||
readonly userLookupOverrides = input<User[]>([]);
|
||||
|
||||
readonly replyRequested = output<ChatMessageReplyEvent>();
|
||||
readonly deleteRequested = output<ChatMessageDeleteEvent>();
|
||||
@@ -93,9 +99,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
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(() => {
|
||||
const labels = new Map<number, string>();
|
||||
@@ -126,6 +130,14 @@ 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;
|
||||
});
|
||||
|
||||
@@ -156,8 +168,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const distanceFromBottom =
|
||||
element.scrollHeight - element.scrollTop - element.clientHeight;
|
||||
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
|
||||
const newMessages = currentCount > this.lastMessageCount;
|
||||
|
||||
if (newMessages) {
|
||||
@@ -219,8 +230,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
if (!element || this.isAutoScrolling)
|
||||
return;
|
||||
|
||||
const distanceFromBottom =
|
||||
element.scrollHeight - element.scrollTop - element.clientHeight;
|
||||
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
|
||||
const shouldStickToBottom = distanceFromBottom <= 300;
|
||||
|
||||
if (shouldStickToBottom) {
|
||||
@@ -377,11 +387,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
}
|
||||
|
||||
if (this.boundOnImageLoad && this.messagesContainer) {
|
||||
this.messagesContainer.nativeElement.removeEventListener(
|
||||
'load',
|
||||
this.boundOnImageLoad,
|
||||
true
|
||||
);
|
||||
this.messagesContainer.nativeElement.removeEventListener('load', this.boundOnImageLoad, true);
|
||||
|
||||
this.boundOnImageLoad = null;
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
>
|
||||
<img
|
||||
[appChatImageProxyFallback]="gif.previewUrl || gif.url"
|
||||
[signalSource]="signalSource()"
|
||||
[alt]="gif.title || 'KLIPY GIF'"
|
||||
class="h-full w-full object-contain p-1.5 transition-transform duration-200 group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
OnInit,
|
||||
ViewChild,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
lucideX
|
||||
} from '@ng-icons/lucide';
|
||||
import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
|
||||
import type { RoomSignalSourceInput } from '../../../server-directory';
|
||||
import { ChatImageProxyFallbackDirective } from '../chat-image-proxy-fallback.directive';
|
||||
|
||||
const KLIPY_CARD_MIN_WIDTH = 140;
|
||||
@@ -48,6 +50,8 @@ const KLIPY_CARD_FALLBACK_SIZE = 160;
|
||||
templateUrl: './klipy-gif-picker.component.html'
|
||||
})
|
||||
export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
readonly signalSource = input<RoomSignalSourceInput | null>(null);
|
||||
|
||||
readonly gifSelected = output<KlipyGif>();
|
||||
readonly closed = output<undefined>();
|
||||
|
||||
@@ -128,7 +132,7 @@ export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.klipy.searchGifs(this.searchQuery, this.currentPage)
|
||||
this.klipy.searchGifs(this.searchQuery, this.currentPage, undefined, this.signalSource())
|
||||
);
|
||||
|
||||
if (requestId !== this.requestId)
|
||||
|
||||
@@ -19,11 +19,10 @@
|
||||
@for (user of onlineUsers(); track user.id) {
|
||||
<div
|
||||
class="group relative flex items-center gap-3 p-2 rounded-lg hover:bg-secondary/50 transition-colors cursor-pointer"
|
||||
(click)="toggleUserMenu(user.id)"
|
||||
(keydown.enter)="toggleUserMenu(user.id)"
|
||||
(keydown.space)="toggleUserMenu(user.id)"
|
||||
(keyup.enter)="toggleUserMenu(user.id)"
|
||||
(keyup.space)="toggleUserMenu(user.id)"
|
||||
[attr.data-testid]="'user-card-' + (user.oderId || user.id)"
|
||||
(click)="openDirectMessage(user)"
|
||||
(keydown.enter)="openDirectMessage(user)"
|
||||
(keydown.space)="openDirectMessage(user)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
@@ -70,6 +69,19 @@
|
||||
|
||||
<!-- Voice/Screen Status -->
|
||||
<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) {
|
||||
<ng-icon
|
||||
name="lucideMic"
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Router } from '@angular/router';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideMic,
|
||||
@@ -19,7 +20,8 @@ import {
|
||||
lucideBan,
|
||||
lucideUserX,
|
||||
lucideVolume2,
|
||||
lucideVolumeX
|
||||
lucideVolumeX,
|
||||
lucideMessageCircle
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
} from '../../../../store/users/users.selectors';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../../shared';
|
||||
import { DirectMessageService } from '../../../direct-message';
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-list',
|
||||
@@ -52,7 +55,8 @@ import { UserAvatarComponent, ConfirmDialogComponent } from '../../../../shared'
|
||||
lucideBan,
|
||||
lucideUserX,
|
||||
lucideVolume2,
|
||||
lucideVolumeX
|
||||
lucideVolumeX,
|
||||
lucideMessageCircle
|
||||
})
|
||||
],
|
||||
templateUrl: './user-list.component.html'
|
||||
@@ -62,6 +66,8 @@ import { UserAvatarComponent, ConfirmDialogComponent } from '../../../../shared'
|
||||
*/
|
||||
export class UserListComponent {
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
private directMessages = inject(DirectMessageService);
|
||||
|
||||
onlineUsers = this.store.selectSignal(selectOnlineUsers) as import('@angular/core').Signal<User[]>;
|
||||
voiceUsers = computed(() => this.onlineUsers().filter((user: User) => !!user.voiceState?.isConnected));
|
||||
@@ -84,6 +90,16 @@ export class UserListComponent {
|
||||
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). */
|
||||
muteUser(user: User): void {
|
||||
if (user.voiceState?.isMutedByAdmin) {
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
export * from './application/services/klipy.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-sync.rules';
|
||||
export { ChatMarkdownService } from './feature/chat-messages/services/chat-markdown.service';
|
||||
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 { 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';
|
||||
|
||||
41
toju-app/src/app/domains/direct-message/README.md
Normal file
41
toju-app/src/app/domains/direct-message/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,69 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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()
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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()
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/* 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();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<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>
|
||||
@@ -0,0 +1,457 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<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>
|
||||
@@ -0,0 +1,57 @@
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<!-- 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>
|
||||
@@ -0,0 +1,31 @@
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<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>
|
||||
@@ -0,0 +1,184 @@
|
||||
/* 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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<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>
|
||||
@@ -0,0 +1,31 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<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>
|
||||
@@ -0,0 +1,128 @@
|
||||
/* 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));
|
||||
}
|
||||
}
|
||||
6
toju-app/src/app/domains/direct-message/index.ts
Normal file
6
toju-app/src/app/domains/direct-message/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
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';
|
||||
@@ -0,0 +1,58 @@
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
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()
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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(':');
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user