1 Commits

Author SHA1 Message Date
Myx
1656b8a17f fix: multiple bug fixes
All checks were successful
Queue Release Build / prepare (push) Successful in 15s
Deploy Web Apps / deploy (push) Successful in 6m54s
Queue Release Build / build-windows (push) Successful in 16m6s
Queue Release Build / build-linux (push) Successful in 30m58s
Queue Release Build / finalize (push) Successful in 44s
isolated users, db backup, weird disconnect issues for long voice sessions,
2026-04-24 22:19:57 +02:00
150 changed files with 1156 additions and 9435 deletions

View File

@@ -22,7 +22,7 @@ export class ServerSearchPage {
readonly dialogCancelButton: Locator;
constructor(private page: Page) {
this.searchInput = page.getByPlaceholder('Search servers and users...');
this.searchInput = page.getByPlaceholder('Search servers...');
this.railCreateServerButton = page.locator('button[title="Create Server"]');
this.searchCreateServerButton = page.getByRole('button', { name: 'Create New Server' });
this.createServerButton = this.searchCreateServerButton;
@@ -80,11 +80,6 @@ export class ServerSearchPage {
}
async joinServerFromSearch(name: string) {
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();
await this.page.locator('button', { hasText: name }).click();
}
}

View File

@@ -44,11 +44,9 @@ test.describe('Chat messaging features', () => {
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);
@@ -56,11 +54,9 @@ test.describe('Chat messaging features', () => {
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);
@@ -308,8 +304,11 @@ 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.joinServerFromSearch(serverName);
await bobSearchPage.searchInput.fill(serverName);
await expect(serverCard).toBeVisible({ timeout: 15_000 });
await serverCard.click();
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
const aliceRoom = new ChatRoomPage(alice.page);

View File

@@ -1,114 +0,0 @@
import { type Page } from '@playwright/test';
import {
test,
expect,
type Client
} from '../../fixtures/multi-client';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatMessagesPage } from '../../pages/chat-messages.page';
test.describe('Direct message flow', () => {
test.describe.configure({ timeout: 180_000 });
test('opens a DM from a user card and queues messages while offline', async ({ createClient }) => {
const scenario = await createDmScenario(createClient);
const offlineMessage = `Offline DM ${uniqueName('msg')}`;
await test.step('Alice opens Bob from the room user list', async () => {
const bobUserCard = scenario.alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first();
await expect(bobUserCard).toBeVisible({ timeout: 20_000 });
await bobUserCard.getByRole('button', { name: 'Message Bob' }).click();
await expect(scenario.alice.page).toHaveURL(/\/dm\//, { timeout: 15_000 });
await expect(scenario.alice.page.getByRole('heading', { name: 'Bob' })).toBeVisible({ timeout: 10_000 });
});
await test.step('Offline send persists locally as queued', async () => {
await scenario.alice.page.evaluate(() => window.simulateOffline?.());
await scenario.alice.page.getByTestId('dm-input').fill(offlineMessage);
await scenario.alice.page.getByTestId('dm-input').press('Enter');
await expect(scenario.alice.page.locator('app-dm-chat').getByText(offlineMessage)).toBeVisible({ timeout: 10_000 });
await expect(scenario.alice.page.getByTestId('message-status').last()).toContainText('QUEUED');
});
});
test('shows friend and message actions on the search people list', async ({ createClient }) => {
const scenario = await createDmScenario(createClient);
await scenario.alice.page.goto('/search', { waitUntil: 'domcontentloaded' });
await expect(scenario.alice.page).toHaveURL(/\/search/, { timeout: 20_000 });
await expect(scenario.alice.page.locator('app-server-search')).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.locator('app-user-search-list')).toBeVisible({ timeout: 20_000 });
const bobPeopleCard = scenario.alice.page
.locator('app-user-search-list [data-testid$="-' + scenario.bobUserId + '"]', { hasText: 'Bob' })
.first();
await expect(bobPeopleCard).toBeVisible({ timeout: 15_000 });
const friendButton = bobPeopleCard.locator(`[data-testid="friend-button-${scenario.bobUserId}"]`);
const messageButton = bobPeopleCard.getByRole('button', { name: 'Message Bob' });
await expect(friendButton).toBeAttached({ timeout: 15_000 });
await expect(messageButton).toBeAttached({ timeout: 15_000 });
});
});
interface DmScenario {
alice: Client;
bob: Client;
bobUserId: string;
aliceSearch: ServerSearchPage;
}
async function createDmScenario(createClient: () => Promise<Client>): Promise<DmScenario> {
const suffix = uniqueName('dm');
const serverName = `DM Server ${suffix}`;
const alice = await createClient();
const bob = await createClient();
await registerUser(alice.page, `alice_${suffix}`, 'Alice');
await registerUser(bob.page, `bob_${suffix}`, 'Bob');
const aliceSearch = new ServerSearchPage(alice.page);
await aliceSearch.createServer(serverName, { description: 'E2E direct message discovery server' });
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
await new ChatMessagesPage(alice.page).waitForReady();
const bobSearch = new ServerSearchPage(bob.page);
await bobSearch.joinServerFromSearch(serverName);
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
await new ChatMessagesPage(bob.page).waitForReady();
const bobRoomCard = alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first();
await expect(bobRoomCard).toBeVisible({ timeout: 20_000 });
const bobUserCardTestId = await bobRoomCard.getAttribute('data-testid');
const bobUserId = bobUserCardTestId?.replace('room-user-card-', '');
if (!bobUserId) {
throw new Error('Expected Bob room user card to expose a stable test id.');
}
return {
alice,
bob,
bobUserId,
aliceSearch
};
}
async function registerUser(page: Page, username: string, displayName: string): Promise<void> {
const registerPage = new RegisterPage(page);
await registerPage.goto();
await registerPage.register(username, displayName, 'TestPass123!');
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
}

View File

@@ -3,7 +3,10 @@ import {
type Locator,
type Page
} from '@playwright/test';
import { test, type Client } from '../../fixtures/multi-client';
import {
test,
type Client
} from '../../fixtures/multi-client';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatRoomPage } from '../../pages/chat-room.page';
@@ -106,12 +109,14 @@ async function createNotificationScenario(createClient: () => Promise<Client>):
await aliceSearch.createServer(serverName, {
description: 'E2E notification coverage server'
});
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
const bobSearch = new ServerSearchPage(bob.page);
const serverCard = bob.page.locator('button', { hasText: serverName }).first();
await bobSearch.joinServerFromSearch(serverName);
await bobSearch.searchInput.fill(serverName);
await expect(serverCard).toBeVisible({ timeout: 15_000 });
await serverCard.click();
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
const aliceRoom = new ChatRoomPage(alice.page);
@@ -150,6 +155,10 @@ async function installDesktopNotificationSpy(page: Page): Promise<void> {
class MockNotification {
static permission = 'granted';
static async requestPermission(): Promise<NotificationPermission> {
return 'granted';
}
onclick: (() => void) | null = null;
constructor(title: string, options?: NotificationOptions) {
@@ -159,10 +168,6 @@ async function installDesktopNotificationSpy(page: Page): Promise<void> {
});
}
static async requestPermission(): Promise<NotificationPermission> {
return 'granted';
}
close(): void {
return;
}
@@ -251,8 +256,7 @@ function getUnreadBadge(container: Locator): Locator {
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
interface WindowWithDesktopNotifications extends Window {

View File

@@ -384,8 +384,11 @@ 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.joinServerFromSearch(serverName);
await searchPage.searchInput.fill(serverName);
await expect(serverCard).toBeVisible({ timeout: 15_000 });
await serverCard.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
}

View File

@@ -56,7 +56,12 @@ async function setupServerWithBothUsers(
// Bob joins server
const bobSearch = new ServerSearchPage(bob.page);
await bobSearch.joinServerFromSearch(SERVER_NAME);
await bobSearch.searchInput.fill(SERVER_NAME);
const serverCard = bob.page.locator('button', { hasText: SERVER_NAME }).first();
await expect(serverCard).toBeVisible({ timeout: 10_000 });
await serverCard.click();
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
}

View File

@@ -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 and users...')).toBeVisible({ timeout: 30_000 });
await expect(alice.page.getByPlaceholder('Search servers...')).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 and users...')).toBeVisible({ timeout: 30_000 });
await expect(bob.page.getByPlaceholder('Search servers...')).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 and users...')).toBeVisible({ timeout: 30_000 });
await expect(charlie.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
});
// ── Create server and have everyone join ──
@@ -117,14 +117,22 @@ test.describe('Connectivity warning', () => {
await test.step('Bob joins the server', async () => {
const search = new ServerSearchPage(bob.page);
await search.joinServerFromSearch(serverName);
await search.searchInput.fill(serverName);
const card = bob.page.locator('button', { hasText: serverName }).first();
await expect(card).toBeVisible({ timeout: 15_000 });
await card.click();
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
});
await test.step('Charlie joins the server', async () => {
const search = new ServerSearchPage(charlie.page);
await search.joinServerFromSearch(serverName);
await search.searchInput.fill(serverName);
const card = charlie.page.locator('button', { hasText: serverName }).first();
await expect(card).toBeVisible({ timeout: 15_000 });
await card.click();
await expect(charlie.page).toHaveURL(/\/room\//, { timeout: 15_000 });
});

View File

@@ -9,11 +9,10 @@ test.describe('ICE server settings', () => {
await register.goto();
await register.register(`user_${suffix}`, 'IceTestUser', 'TestPass123!');
await expect(page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
await expect(page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
await page.getByTitle('Settings').click();
await expect(page.getByRole('button', { name: 'Network' })).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Network' }).click();
await expect(page.getByTestId('ice-server-settings')).toBeVisible({ timeout: 10_000 });
}
test('allows adding, removing, and reordering ICE servers', async ({ createClient }) => {
@@ -102,7 +101,7 @@ test.describe('ICE server settings', () => {
await page.reload({ waitUntil: 'domcontentloaded' });
await page.getByTitle('Settings').click();
await expect(page.getByRole('button', { name: 'Network' })).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Network' }).click();
await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 10_000 });
});

View File

@@ -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 and users...')).toBeVisible({ timeout: 30_000 });
await expect(alice.page.getByPlaceholder('Search servers...')).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 and users...')).toBeVisible({ timeout: 30_000 });
await expect(bob.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
});
await test.step('Alice creates a server', async () => {
@@ -109,7 +109,11 @@ test.describe('STUN/TURN fallback behaviour', () => {
await test.step('Bob joins Alice server', async () => {
const search = new ServerSearchPage(bob.page);
await search.joinServerFromSearch(serverName);
await search.searchInput.fill(serverName);
const serverCard = bob.page.locator('button', { hasText: serverName }).first();
await expect(serverCard).toBeVisible({ timeout: 15_000 });
await serverCard.click();
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
});

View File

@@ -556,7 +556,7 @@ async function installDeterministicVoiceSettings(page: Page): Promise<void> {
}
async function openSearchView(page: Page): Promise<void> {
const searchInput = page.getByPlaceholder('Search servers and users...');
const searchInput = page.getByPlaceholder('Search servers...');
if (await searchInput.isVisible().catch(() => false)) {
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 and users...');
const searchInput = page.getByPlaceholder('Search servers...');
await expect(searchInput).toBeVisible({ timeout: 20_000 });
await searchInput.fill(roomName);
const roomCard = page.locator('div[title]', { hasText: roomName }).first();
const roomCard = page.locator('button', { hasText: roomName }).first();
await expect(roomCard).toBeVisible({ timeout: 20_000 });
await roomCard.dblclick();
await roomCard.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);

View File

@@ -319,7 +319,7 @@ async function installDeterministicVoiceSettings(page: Page): Promise<void> {
}
async function openSearchView(page: Page): Promise<void> {
const searchInput = page.getByPlaceholder('Search servers and users...');
const searchInput = page.getByPlaceholder('Search servers...');
if (await searchInput.isVisible().catch(() => false)) {
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 and users...');
const searchInput = page.getByPlaceholder('Search servers...');
await expect(searchInput).toBeVisible({ timeout: 20_000 });
await searchInput.fill(roomName);
const roomCard = page.locator('div[title]', { hasText: roomName }).first();
const roomCard = page.locator('button', { hasText: roomName }).first();
await expect(roomCard).toBeVisible({ timeout: 20_000 });
await roomCard.dblclick();
await roomCard.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);

View File

@@ -96,7 +96,14 @@ 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);
await searchPage.joinServerFromSearch(SERVER_NAME);
// Search for the server
await searchPage.searchInput.fill(SERVER_NAME);
// Wait for search results and click the server
const serverCard = bob.page.locator('button', { hasText: SERVER_NAME }).first();
await expect(serverCard).toBeVisible({ timeout: 10_000 });
await serverCard.click();
// Bob should be in the room now
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });

View File

@@ -16,7 +16,6 @@ Electron main-process package for MetoYou / Toju. This directory owns desktop bo
| --- | --- |
| `main.ts` | Electron app bootstrap and process entry point |
| `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 |
@@ -29,4 +28,4 @@ Electron main-process package for MetoYou / Toju. This directory owns desktop bo
- When adding a renderer-facing capability, update the Electron implementation, `preload.ts`, and the renderer bridge in `toju-app/` together.
- Treat `dist/electron/` and `dist-electron/` as generated output.
- See [AGENTS.md](AGENTS.md) for package-level editing rules.
- See [AGENTS.md](AGENTS.md) for package-level editing rules.

View File

@@ -5,7 +5,6 @@ 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;
@@ -96,12 +95,6 @@ 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);

View File

@@ -1,229 +0,0 @@
import * as fsp from 'fs/promises';
import * as path from 'path';
export interface ZipArchiveEntry {
data: Buffer;
path: string;
}
interface CentralDirectoryEntry {
compressedSize: number;
crc: number;
data: Buffer;
localHeaderOffset: number;
name: Buffer;
uncompressedSize: number;
}
const ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
const ZIP_CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50;
const ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
const ZIP_UTF8_FLAG = 0x0800;
const ZIP_STORE_METHOD = 0;
const ZIP_VERSION = 20;
const MAX_UINT32 = 0xffffffff;
const crcTable = buildCrcTable();
export function createZipArchive(entries: ZipArchiveEntry[]): Buffer {
const localParts: Buffer[] = [];
const centralEntries: CentralDirectoryEntry[] = [];
let offset = 0;
for (const entry of entries) {
const normalizedPath = normalizeZipPath(entry.path);
const name = Buffer.from(normalizedPath, 'utf8');
const data = entry.data;
if (name.length > 0xffff || data.length > MAX_UINT32 || offset > MAX_UINT32) {
throw new Error('Data archive is too large for the portable ZIP format.');
}
const crc = crc32(data);
const localHeader = Buffer.alloc(30);
localHeader.writeUInt32LE(ZIP_LOCAL_FILE_HEADER_SIGNATURE, 0);
localHeader.writeUInt16LE(ZIP_VERSION, 4);
localHeader.writeUInt16LE(ZIP_UTF8_FLAG, 6);
localHeader.writeUInt16LE(ZIP_STORE_METHOD, 8);
localHeader.writeUInt16LE(0, 10);
localHeader.writeUInt16LE(0, 12);
localHeader.writeUInt32LE(crc, 14);
localHeader.writeUInt32LE(data.length, 18);
localHeader.writeUInt32LE(data.length, 22);
localHeader.writeUInt16LE(name.length, 26);
localHeader.writeUInt16LE(0, 28);
localParts.push(localHeader, name, data);
centralEntries.push({
compressedSize: data.length,
crc,
data,
localHeaderOffset: offset,
name,
uncompressedSize: data.length
});
offset += localHeader.length + name.length + data.length;
}
const centralDirectoryOffset = offset;
const centralParts = centralEntries.map((entry) => {
const header = Buffer.alloc(46);
header.writeUInt32LE(ZIP_CENTRAL_DIRECTORY_SIGNATURE, 0);
header.writeUInt16LE(ZIP_VERSION, 4);
header.writeUInt16LE(ZIP_VERSION, 6);
header.writeUInt16LE(ZIP_UTF8_FLAG, 8);
header.writeUInt16LE(ZIP_STORE_METHOD, 10);
header.writeUInt16LE(0, 12);
header.writeUInt16LE(0, 14);
header.writeUInt32LE(entry.crc, 16);
header.writeUInt32LE(entry.compressedSize, 20);
header.writeUInt32LE(entry.uncompressedSize, 24);
header.writeUInt16LE(entry.name.length, 28);
header.writeUInt16LE(0, 30);
header.writeUInt16LE(0, 32);
header.writeUInt16LE(0, 34);
header.writeUInt16LE(0, 36);
header.writeUInt32LE(0, 38);
header.writeUInt32LE(entry.localHeaderOffset, 42);
offset += header.length + entry.name.length;
return Buffer.concat([header, entry.name]);
});
const centralDirectorySize = offset - centralDirectoryOffset;
if (centralEntries.length > 0xffff || centralDirectoryOffset > MAX_UINT32 || centralDirectorySize > MAX_UINT32) {
throw new Error('Data archive is too large for the portable ZIP format.');
}
const end = Buffer.alloc(22);
end.writeUInt32LE(ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE, 0);
end.writeUInt16LE(0, 4);
end.writeUInt16LE(0, 6);
end.writeUInt16LE(centralEntries.length, 8);
end.writeUInt16LE(centralEntries.length, 10);
end.writeUInt32LE(centralDirectorySize, 12);
end.writeUInt32LE(centralDirectoryOffset, 16);
end.writeUInt16LE(0, 20);
return Buffer.concat([...localParts, ...centralParts, end]);
}
export function readZipArchive(data: Buffer): ZipArchiveEntry[] {
const endOffset = findEndOfCentralDirectory(data);
if (endOffset < 0) {
throw new Error('The selected file is not a supported data archive.');
}
const entryCount = data.readUInt16LE(endOffset + 10);
const centralDirectoryOffset = data.readUInt32LE(endOffset + 16);
const entries: ZipArchiveEntry[] = [];
let offset = centralDirectoryOffset;
for (let index = 0; index < entryCount; index += 1) {
if (data.readUInt32LE(offset) !== ZIP_CENTRAL_DIRECTORY_SIGNATURE) {
throw new Error('The data archive directory is invalid.');
}
const method = data.readUInt16LE(offset + 10);
const compressedSize = data.readUInt32LE(offset + 20);
const uncompressedSize = data.readUInt32LE(offset + 24);
const nameLength = data.readUInt16LE(offset + 28);
const extraLength = data.readUInt16LE(offset + 30);
const commentLength = data.readUInt16LE(offset + 32);
const localHeaderOffset = data.readUInt32LE(offset + 42);
const entryPath = normalizeZipPath(data.subarray(offset + 46, offset + 46 + nameLength).toString('utf8'));
if (method !== ZIP_STORE_METHOD || compressedSize !== uncompressedSize) {
throw new Error('Compressed data archives are not supported by this build.');
}
if (data.readUInt32LE(localHeaderOffset) !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) {
throw new Error('The data archive contains an invalid file entry.');
}
const localNameLength = data.readUInt16LE(localHeaderOffset + 26);
const localExtraLength = data.readUInt16LE(localHeaderOffset + 28);
const dataOffset = localHeaderOffset + 30 + localNameLength + localExtraLength;
entries.push({
data: Buffer.from(data.subarray(dataOffset, dataOffset + compressedSize)),
path: entryPath
});
offset += 46 + nameLength + extraLength + commentLength;
}
return entries;
}
export async function extractZipEntries(entries: ZipArchiveEntry[], destinationPath: string): Promise<void> {
const destinationRoot = path.resolve(destinationPath);
for (const entry of entries) {
const targetPath = path.resolve(destinationRoot, entry.path);
if (!targetPath.startsWith(destinationRoot + path.sep) && targetPath !== destinationRoot) {
throw new Error('The data archive contains an unsafe path.');
}
await fsp.mkdir(path.dirname(targetPath), { recursive: true });
await fsp.writeFile(targetPath, entry.data);
}
}
function findEndOfCentralDirectory(data: Buffer): number {
const minimumOffset = Math.max(0, data.length - 0xffff - 22);
for (let offset = data.length - 22; offset >= minimumOffset; offset -= 1) {
if (data.readUInt32LE(offset) === ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE) {
return offset;
}
}
return -1;
}
function normalizeZipPath(value: string): string {
const normalized = value.replace(/\\/g, '/').replace(/^\/+/, '');
if (!normalized || normalized.split('/').some((part) => part === '..' || part === '')) {
throw new Error('The data archive contains an unsafe path.');
}
return normalized;
}
function buildCrcTable(): number[] {
const table: number[] = [];
for (let index = 0; index < 256; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = (value & 1) !== 0
? 0xedb88320 ^ (value >>> 1)
: value >>> 1;
}
table[index] = value >>> 0;
}
return table;
}
function crc32(data: Buffer): number {
let crc = 0xffffffff;
for (const byte of data) {
crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}

View File

@@ -1,257 +0,0 @@
import {
app,
dialog,
shell
} from 'electron';
import * as fsp from 'fs/promises';
import * as path from 'path';
import { destroyDatabase, initializeDatabase } from './db/database';
import {
createZipArchive,
extractZipEntries,
readZipArchive,
type ZipArchiveEntry
} from './data-archive';
export interface ExportUserDataResult {
cancelled: boolean;
exported: boolean;
filePath?: string;
}
export interface ImportUserDataResult {
backupPath?: string;
cancelled: boolean;
imported: boolean;
restartRequired: boolean;
}
export interface EraseUserDataResult {
erased: boolean;
restartRequired: boolean;
}
const ARCHIVE_MANIFEST_PATH = 'metoyou-data-manifest.json';
const ARCHIVE_DATA_PREFIX = 'data/';
const BACKUP_DIRECTORY_NAME = 'metoyou-data-backups';
export async function openCurrentDataFolder(): Promise<boolean> {
const error = await shell.openPath(app.getPath('userData'));
return error.length === 0;
}
export async function exportUserData(): Promise<ExportUserDataResult> {
const dataPath = app.getPath('userData');
const defaultFileName = `metoyou-data-${new Date().toISOString().slice(0, 10)}.dat`;
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath: path.join(app.getPath('documents'), defaultFileName),
filters: [
{ extensions: ['dat'], name: 'MetoYou data archive' }
],
title: 'Export MetoYou data'
});
if (canceled || !filePath) {
return { cancelled: true, exported: false };
}
const entries: ZipArchiveEntry[] = [
{
data: Buffer.from(JSON.stringify({
appVersion: app.getVersion(),
exportedAt: new Date().toISOString(),
format: 'metoyou-user-data',
version: 1
}, null, 2)),
path: ARCHIVE_MANIFEST_PATH
}
];
for (const file of await collectDataFiles(dataPath)) {
const relativePath = toArchivePath(path.relative(dataPath, file));
entries.push({
data: await fsp.readFile(file),
path: `${ARCHIVE_DATA_PREFIX}${relativePath}`
});
}
await fsp.writeFile(ensureDatExtension(filePath), createZipArchive(entries));
return {
cancelled: false,
exported: true,
filePath: ensureDatExtension(filePath)
};
}
export async function importUserData(): Promise<ImportUserDataResult> {
const { canceled, filePaths } = await dialog.showOpenDialog({
filters: [
{ extensions: ['dat', 'zip'], name: 'MetoYou data archive' }
],
properties: ['openFile'],
title: 'Import MetoYou data'
});
if (canceled || filePaths.length === 0) {
return {
cancelled: true,
imported: false,
restartRequired: false
};
}
const archiveEntries = readZipArchive(await fsp.readFile(filePaths[0]));
validateArchiveManifest(archiveEntries);
const importRoot = path.join(app.getPath('temp'), `metoyou-import-${Date.now()}`);
const importDataPath = path.join(importRoot, 'data');
try {
await extractZipEntries(
archiveEntries
.filter((entry) => entry.path.startsWith(ARCHIVE_DATA_PREFIX))
.map((entry) => ({
data: entry.data,
path: entry.path.slice(ARCHIVE_DATA_PREFIX.length)
})),
importDataPath
);
await destroyDatabase();
const backupPath = await moveCurrentDataAside();
await copyDirectory(importDataPath, app.getPath('userData'));
await initializeDatabase();
return {
backupPath,
cancelled: false,
imported: true,
restartRequired: true
};
} catch (error) {
await initializeDatabase().catch(() => {});
throw error;
} finally {
await fsp.rm(importRoot, { force: true, recursive: true }).catch(() => {});
}
}
export async function eraseUserData(): Promise<EraseUserDataResult> {
const dataPath = app.getPath('userData');
await destroyDatabase();
for (const entry of await fsp.readdir(dataPath, { withFileTypes: true }).catch(() => [])) {
await fsp.rm(path.join(dataPath, entry.name), { force: true, recursive: true });
}
await fsp.mkdir(dataPath, { recursive: true });
await initializeDatabase();
return {
erased: true,
restartRequired: true
};
}
async function collectDataFiles(directoryPath: string): Promise<string[]> {
const files: string[] = [];
const entries = await fsp.readdir(directoryPath, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
if (entry.name === BACKUP_DIRECTORY_NAME) {
continue;
}
const entryPath = path.join(directoryPath, entry.name);
if (entry.isDirectory()) {
files.push(...await collectDataFiles(entryPath));
} else if (entry.isFile()) {
files.push(entryPath);
}
}
return files;
}
async function moveCurrentDataAside(): Promise<string | undefined> {
const dataPath = app.getPath('userData');
const backupRoot = path.join(dataPath, BACKUP_DIRECTORY_NAME);
const backupPath = path.join(backupRoot, `before-import-${new Date().toISOString().replace(/[:.]/g, '-')}`);
const entries = await fsp.readdir(dataPath, { withFileTypes: true }).catch(() => []);
await fsp.mkdir(backupPath, { recursive: true });
let movedAny = false;
for (const entry of entries) {
if (entry.name === BACKUP_DIRECTORY_NAME) {
continue;
}
const sourcePath = path.join(dataPath, entry.name);
const targetPath = path.join(backupPath, entry.name);
await fsp.mkdir(path.dirname(targetPath), { recursive: true });
await fsp.rename(sourcePath, targetPath).catch(async () => {
await copyPath(sourcePath, targetPath);
await fsp.rm(sourcePath, { force: true, recursive: true });
});
movedAny = true;
}
return movedAny ? backupPath : undefined;
}
async function copyDirectory(sourcePath: string, targetPath: string): Promise<void> {
await fsp.mkdir(targetPath, { recursive: true });
for (const entry of await fsp.readdir(sourcePath, { withFileTypes: true }).catch(() => [])) {
await copyPath(path.join(sourcePath, entry.name), path.join(targetPath, entry.name));
}
}
async function copyPath(sourcePath: string, targetPath: string): Promise<void> {
const stats = await fsp.stat(sourcePath);
if (stats.isDirectory()) {
await copyDirectory(sourcePath, targetPath);
return;
}
if (stats.isFile()) {
await fsp.mkdir(path.dirname(targetPath), { recursive: true });
await fsp.copyFile(sourcePath, targetPath);
}
}
function validateArchiveManifest(entries: ZipArchiveEntry[]): void {
const manifest = entries.find((entry) => entry.path === ARCHIVE_MANIFEST_PATH);
if (!manifest) {
throw new Error('The selected file is missing a MetoYou data manifest.');
}
const parsed = JSON.parse(manifest.data.toString('utf8')) as { format?: string; version?: number };
if (parsed.format !== 'metoyou-user-data' || parsed.version !== 1) {
throw new Error('The selected file uses an unsupported data archive format.');
}
}
function ensureDatExtension(filePath: string): string {
return path.extname(filePath).toLowerCase() === '.dat'
? filePath
: `${filePath}.dat`;
}
function toArchivePath(filePath: string): string {
return filePath.split(path.sep).join('/');
}

View File

@@ -49,13 +49,6 @@ 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 = [
@@ -321,8 +314,6 @@ export function setupSystemHandlers(): void {
}
});
ipcMain.handle('get-running-process-names', async () => await listRunningProcessNames());
ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => {
return await prepareLinuxScreenShareAudioRouting();
});
@@ -344,10 +335,6 @@ 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));

View File

@@ -109,24 +109,6 @@ 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';
@@ -167,7 +149,6 @@ 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>;
@@ -176,10 +157,6 @@ 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>;
@@ -253,7 +230,6 @@ 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'),
@@ -289,10 +265,6 @@ 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),

View File

@@ -1,85 +0,0 @@
import { execFile } from 'child_process';
import * as path from 'path';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
const MAX_PROCESS_NAMES = 512;
export async function listRunningProcessNames(): Promise<string[]> {
if (process.platform === 'win32') {
return normalizeProcessNames(await listWindowsProcessNames());
}
if (process.platform === 'linux') {
return normalizeProcessNames(await listLinuxProcessNames());
}
return [];
}
async function listLinuxProcessNames(): Promise<string[]> {
const { stdout } = await execFileAsync('ps', ['-eo', 'comm='], {
maxBuffer: 1024 * 1024,
timeout: 5_000
});
return stdout.split('\n');
}
async function listWindowsProcessNames(): Promise<string[]> {
const { stdout } = await execFileAsync('tasklist', [
'/FO',
'CSV',
'/NH'
], {
maxBuffer: 1024 * 1024,
timeout: 5_000,
windowsHide: true
});
return stdout
.split(/\r?\n/)
.map((line) => parseCsvFirstColumn(line));
}
function parseCsvFirstColumn(line: string): string {
const trimmed = line.trim();
if (!trimmed) {
return '';
}
if (!trimmed.startsWith('"')) {
return trimmed.split(',')[0] ?? '';
}
const endQuoteIndex = trimmed.indexOf('"', 1);
return endQuoteIndex > 1 ? trimmed.slice(1, endQuoteIndex) : '';
}
function normalizeProcessNames(names: string[]): string[] {
const normalized = new Set<string>();
for (const rawName of names) {
const name = normalizeProcessName(rawName);
if (name) {
normalized.add(name);
}
}
return Array.from(normalized)
.sort()
.slice(0, MAX_PROCESS_NAMES);
}
function normalizeProcessName(rawName: string): string {
const baseName = path.basename(rawName.trim()).trim();
if (!baseName || baseName.length > 96) {
return '';
}
return baseName;
}

26
package-lock.json generated
View File

@@ -15,10 +15,8 @@
"@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",
@@ -2733,19 +2731,6 @@
"@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",
@@ -5806,17 +5791,6 @@
"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",

View File

@@ -65,10 +65,8 @@
"@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",

View File

@@ -19,8 +19,7 @@ Node/TypeScript signaling server for MetoYou / Toju. This package owns the publi
- The server loads the repository-root `.env` file on startup.
- `SSL` can override the effective HTTP protocol, and `PORT` can override the effective port.
- `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.
- `data/variables.json` is normalized on startup and stores `klipyApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`.
- 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.
@@ -40,4 +39,4 @@ Node/TypeScript signaling server for MetoYou / Toju. This package owns the publi
## Notes
- `dist/` and `../dist-server/` are generated output.
- See [AGENTS.md](AGENTS.md) for package-specific editing guidance.
- See [AGENTS.md](AGENTS.md) for package-specific editing guidance.

View File

@@ -12,7 +12,6 @@ export interface LinkPreviewConfig {
export interface ServerVariablesConfig {
klipyApiKey: string;
rawgApiKey: string;
releaseManifestUrl: string;
serverPort: number;
serverProtocol: ServerHttpProtocol;
@@ -32,10 +31,6 @@ 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() : '';
}
@@ -144,7 +139,6 @@ 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),
@@ -159,7 +153,6 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
return {
klipyApiKey: normalized.klipyApiKey,
rawgApiKey: normalized.rawgApiKey,
releaseManifestUrl: normalized.releaseManifestUrl,
serverPort: normalized.serverPort,
serverProtocol: normalized.serverProtocol,
@@ -176,14 +169,6 @@ 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;
}

View File

@@ -14,8 +14,7 @@ import {
JoinRequestEntity,
ServerMembershipEntity,
ServerInviteEntity,
ServerBanEntity,
GameMatchMissEntity
ServerBanEntity
} from '../entities';
import { serverMigrations } from '../migrations';
import {
@@ -203,8 +202,7 @@ export async function initDatabase(): Promise<void> {
JoinRequestEntity,
ServerMembershipEntity,
ServerInviteEntity,
ServerBanEntity,
GameMatchMissEntity
ServerBanEntity
],
migrations: serverMigrations,
synchronize: process.env.DB_SYNCHRONIZE === 'true',

View File

@@ -1,22 +0,0 @@
import {
Column,
Entity,
Index,
PrimaryColumn
} from 'typeorm';
@Entity('game_match_misses')
export class GameMatchMissEntity {
@PrimaryColumn('text')
processKey!: string;
@Column('text')
processName!: string;
@Column('integer')
missedAt!: number;
@Index()
@Column('integer')
expiresAt!: number;
}

View File

@@ -9,4 +9,3 @@ export { JoinRequestEntity } from './JoinRequestEntity';
export { ServerMembershipEntity } from './ServerMembershipEntity';
export { ServerInviteEntity } from './ServerInviteEntity';
export { ServerBanEntity } from './ServerBanEntity';
export { GameMatchMissEntity } from './GameMatchMissEntity';

View File

@@ -1,24 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class GameMatchMisses1000000000006 implements MigrationInterface {
name = 'GameMatchMisses1000000000006';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "game_match_misses" (
"processKey" TEXT PRIMARY KEY NOT NULL,
"processName" TEXT NOT NULL,
"missedAt" INTEGER NOT NULL,
"expiresAt" INTEGER NOT NULL
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_game_match_misses_expiresAt"
ON "game_match_misses" ("expiresAt")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "game_match_misses"`);
}
}

View File

@@ -4,7 +4,6 @@ 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,
@@ -12,6 +11,5 @@ export const serverMigrations = [
ServerChannels1000000000002,
RepairLegacyVoiceChannels1000000000003,
NormalizeServerArrays1000000000004,
ServerRoleAccessControl1000000000005,
GameMatchMisses1000000000006
ServerRoleAccessControl1000000000005
];

View File

@@ -1,17 +0,0 @@
import { Router } from 'express';
import { matchRunningGames } from '../services/game-matching.service';
const router = Router();
router.post('/match', async (req, res) => {
try {
const result = await matchRunningGames(req.body?.processes, req.body?.userId ?? req.ip);
res.json(result);
} catch (error) {
console.error('[Games] Failed to match running games', error);
res.status(500).json({ error: 'Failed to match running games' });
}
});
export default router;

View File

@@ -2,7 +2,6 @@ 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';
@@ -13,7 +12,6 @@ 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);

View File

@@ -19,8 +19,7 @@ import {
ServerAccessError,
kickServerUser,
ensureServerMembership,
unbanServerUser,
countServerMemberships
unbanServerUser
} from '../services/server-access.service';
import {
buildAppInviteUrl,
@@ -79,7 +78,6 @@ 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 {
@@ -87,8 +85,7 @@ async function enrichServer(server: ServerPayload, sourceUrl?: string) {
hasPassword: server.hasPassword ?? !!passwordHash,
ownerName: owner?.displayName,
sourceUrl,
currentUsers: userCount,
userCount
userCount: server.currentUsers
};
}

View File

@@ -1,591 +0,0 @@
import { getRawgApiKey } from '../config/variables';
import { getDataSource } from '../db/database';
import { GameMatchMissEntity } from '../entities';
export interface MatchedGame {
id: string;
name: string;
iconUrl?: string;
store?: GameStoreLink;
processName: string;
}
export interface GameStoreLink {
id?: string;
name: string;
slug?: string;
domain?: string;
url: string;
}
interface CacheEntry {
expiresAt: number;
game: Omit<MatchedGame, 'processName'> | null;
}
interface RawgSearchResponse {
results?: RawgGameResult[];
}
interface RawgGameResult {
id?: number;
name?: string;
background_image?: string | null;
slug?: string;
stores?: RawgStoreEntry[] | null;
}
interface RawgStoreEntry {
url?: string | null;
store?: RawgStore | null;
}
interface RawgStore {
id?: number;
name?: string;
slug?: string;
domain?: string | null;
}
interface CandidateProcess {
processName: string;
score: number;
}
interface GameMatchResult {
games: MatchedGame[];
rateLimited?: boolean;
}
interface RawgLookupBudget {
used: number;
windowStartedAt: number;
}
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const PERSISTED_MISS_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const RAWG_LOOKUP_WINDOW_MS = 60 * 60 * 1000;
const RAWG_SEARCH_TIMEOUT_MS = 4_000;
const MAX_INCOMING_PROCESSES = 256;
const MAX_CANDIDATE_PROCESSES = 24;
const MAX_UNCACHED_LOOKUPS_PER_REQUEST = 4;
const MAX_RAWG_LOOKUPS_PER_USER_PER_WINDOW = 8;
const RAWG_SEARCH_URL = 'https://api.rawg.io/api/games';
const MIN_SEARCH_QUERY_LENGTH = 4;
const IGNORED_PROCESS_NAMES = new Set([
'agent',
'bash',
'baloorunner',
'chrome',
'code',
'conhost',
'cursor',
'csrss',
'dbus-daemon',
'discord',
'dwm',
'electron',
'explorer',
'firefox',
'gameoverlayui',
'gamemoded',
'gamescopereaper',
'gnome-shell',
'init',
'kernel_task',
'metoyou',
'nvidia-settings',
'node',
'npm',
'obs',
'powershell',
'pulseaudio',
'services',
'steam',
'steamwebhelper',
'system',
'systemd',
'taskhostw',
'wininit',
'winlogon',
'xorg'
]);
const IGNORED_PROCESS_PATTERNS = [
new RegExp('(^|\\s)(agent|browser|daemon|desktop|helper|indexer|launcher|monitor|renderer|runner)(\\s|$)'),
new RegExp('(^|\\s)(service|settings|shell|tray|updater|utility|watcher|worker)(\\s|$)'),
new RegExp('(^|\\s)(audio|bluetooth|clipboard|crash|dbus|file|gpu|input|network|notification)(\\s|$)'),
new RegExp('(^|\\s)(portal|proxy|screen|session|sync|system|tracker|web|window)(\\s|$)'),
/^(appimage|at-spi|baloo|dconf|gvfs|ibus|kde|kworker)/,
/^(pipewire|plasmashell|pulseaudio|xdg|xwayland|zeitgeist)/,
/(helper|service|daemon|runner|tracker|portal|updater|worker)$/
];
const STORE_SEARCH_URL_BUILDERS: Record<string, (query: string) => string> = {
steam: (query) => `https://store.steampowered.com/search/?term=${query}`,
'epic-games': (query) => `https://store.epicgames.com/en-US/browse?q=${query}`,
gog: (query) => `https://www.gog.com/en/games?query=${query}`,
itch: (query) => `https://itch.io/search?q=${query}`,
'xbox-store': (query) => `https://www.xbox.com/search?q=${query}`,
'playstation-store': (query) => `https://store.playstation.com/search/${query}`,
nintendo: (query) => `https://www.nintendo.com/search/#q=${query}`,
'apple-appstore': (query) => `https://apps.apple.com/us/search?term=${query}`,
'google-play': (query) => `https://play.google.com/store/search?q=${query}&c=apps`
};
const STORE_SEARCH_ALIASES = new Map<string, string>([
['steam', 'steam'],
['store.steampowered.com', 'steam'],
['epic-games', 'epic-games'],
['store.epicgames.com', 'epic-games'],
['gog', 'gog'],
['www.gog.com', 'gog'],
['gog.com', 'gog'],
['itch', 'itch'],
['itch.io', 'itch'],
['xbox-store', 'xbox-store'],
['www.xbox.com', 'xbox-store'],
['xbox.com', 'xbox-store'],
['playstation-store', 'playstation-store'],
['store.playstation.com', 'playstation-store'],
['nintendo', 'nintendo'],
['www.nintendo.com', 'nintendo'],
['nintendo.com', 'nintendo'],
['apple-appstore', 'apple-appstore'],
['apps.apple.com', 'apple-appstore'],
['google-play', 'google-play'],
['play.google.com', 'google-play']
]);
const STORE_PRIORITY = new Map<string, number>([
['steam', 0],
['gog', 10],
['epic-games', 20],
['itch', 30],
['xbox-store', 80],
['playstation-store', 90]
]);
const cache = new Map<string, CacheEntry>();
const rawgLookupBudgets = new Map<string, RawgLookupBudget>();
export async function matchRunningGames(
processNames: unknown,
requester: unknown = 'anonymous'
): Promise<GameMatchResult> {
const candidates = normalizeProcessList(processNames).slice(0, MAX_CANDIDATE_PROCESSES);
const matches: MatchedGame[] = [];
const seenGameIds = new Set<string>();
const requesterKey = normalizeRequesterKey(requester);
const persistedMisses = await loadPersistedMissKeys(candidates.map((candidate) => candidate.processName));
let uncachedLookups = 0;
let rateLimited = false;
for (const { processName } of candidates) {
const cacheKey = normalizeCacheKey(processName);
const cached = getCachedGame(cacheKey);
if (cached !== undefined) {
appendMatch(matches, seenGameIds, processName, cached);
continue;
}
if (persistedMisses.has(cacheKey)) {
setCachedGame(cacheKey, null);
continue;
}
if (uncachedLookups >= MAX_UNCACHED_LOOKUPS_PER_REQUEST) {
rateLimited = true;
continue;
}
if (!tryConsumeRawgLookup(requesterKey)) {
rateLimited = true;
continue;
}
uncachedLookups += 1;
const game = await resolveRawgGame(processName);
setCachedGame(cacheKey, game);
if (!game) {
await rememberPersistedMiss(cacheKey, processName);
}
appendMatch(matches, seenGameIds, processName, game);
}
return {
games: matches,
rateLimited: rateLimited || undefined
};
}
function normalizeProcessList(value: unknown): CandidateProcess[] {
if (!Array.isArray(value)) {
return [];
}
const processes = new Map<string, CandidateProcess>();
for (const entry of value.slice(0, MAX_INCOMING_PROCESSES)) {
const processName = normalizeProcessName(entry);
if (processName) {
const cacheKey = normalizeCacheKey(processName);
if (!processes.has(cacheKey)) {
processes.set(cacheKey, {
processName,
score: scoreCandidateProcess(String(entry), processName)
});
}
}
}
return Array.from(processes.values())
.sort((left, right) => right.score - left.score || left.processName.localeCompare(right.processName));
}
function normalizeProcessName(value: unknown): string {
if (typeof value !== 'string') {
return '';
}
const normalized = value
.trim()
.replace(/\.exe$/i, '')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const cacheKey = normalizeCacheKey(normalized);
if (normalized.length < 3 || normalized.length > 96 || shouldIgnoreProcessName(cacheKey)) {
return '';
}
return normalized;
}
function shouldIgnoreProcessName(cacheKey: string): boolean {
return IGNORED_PROCESS_NAMES.has(cacheKey)
|| IGNORED_PROCESS_PATTERNS.some((pattern) => pattern.test(cacheKey));
}
function normalizeRequesterKey(value: unknown): string {
if (typeof value !== 'string') {
return 'anonymous';
}
const normalized = value.trim().toLowerCase();
return normalized || 'anonymous';
}
function tryConsumeRawgLookup(requesterKey: string): boolean {
const now = Date.now();
const existing = rawgLookupBudgets.get(requesterKey);
if (!existing || existing.windowStartedAt + RAWG_LOOKUP_WINDOW_MS <= now) {
rawgLookupBudgets.set(requesterKey, {
used: 1,
windowStartedAt: now
});
return true;
}
if (existing.used >= MAX_RAWG_LOOKUPS_PER_USER_PER_WINDOW) {
return false;
}
existing.used += 1;
return true;
}
function scoreCandidateProcess(rawValue: string, processName: string): number {
let score = 0;
if (/\.exe$/i.test(rawValue.trim())) {
score += 12;
}
if (/[A-Z]/.test(processName) && /[a-z]/.test(processName)) {
score += 4;
}
if (/\d/.test(processName)) {
score += 1;
}
if (processName.length >= 5 && processName.length <= 32) {
score += 2;
}
if (processName.includes(' ')) {
score -= 2;
}
return score;
}
function normalizeCacheKey(value: string): string {
return value.trim()
.toLowerCase()
.replace(/\s+/g, ' ');
}
function getCachedGame(cacheKey: string): Omit<MatchedGame, 'processName'> | null | undefined {
const cached = cache.get(cacheKey);
if (!cached) {
return undefined;
}
if (cached.expiresAt <= Date.now()) {
cache.delete(cacheKey);
return undefined;
}
return cached.game;
}
function setCachedGame(cacheKey: string, game: Omit<MatchedGame, 'processName'> | null): void {
cache.set(cacheKey, {
expiresAt: Date.now() + CACHE_TTL_MS,
game
});
}
async function loadPersistedMissKeys(processNames: string[]): Promise<Set<string>> {
const cacheKeys = Array.from(new Set(processNames.map((name) => normalizeCacheKey(name))));
if (cacheKeys.length === 0) {
return new Set();
}
try {
const repository = getDataSource().getRepository(GameMatchMissEntity);
const now = Date.now();
await repository.createQueryBuilder()
.delete()
.where('expiresAt <= :now', { now })
.execute();
const rows = await repository.createQueryBuilder('miss')
.select('miss.processKey')
.where('miss.processKey IN (:...cacheKeys)', { cacheKeys })
.andWhere('miss.expiresAt > :now', { now })
.getMany();
return new Set(rows.map((row) => row.processKey));
} catch {
return new Set();
}
}
async function rememberPersistedMiss(cacheKey: string, processName: string): Promise<void> {
try {
const now = Date.now();
await getDataSource().getRepository(GameMatchMissEntity)
.save({
processKey: cacheKey,
processName,
missedAt: now,
expiresAt: now + PERSISTED_MISS_TTL_MS
});
} catch {
return;
}
}
async function resolveRawgGame(processName: string): Promise<Omit<MatchedGame, 'processName'> | null> {
const apiKey = getRawgApiKey();
if (!apiKey) {
return null;
}
const query = buildSearchQuery(processName);
if (!query) {
return null;
}
const url = new URL(RAWG_SEARCH_URL);
url.searchParams.set('key', apiKey);
url.searchParams.set('search', query);
url.searchParams.set('search_precise', 'true');
url.searchParams.set('exclude_additions', 'true');
url.searchParams.set('page_size', '1');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), RAWG_SEARCH_TIMEOUT_MS);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
return null;
}
const body = await response.json() as RawgSearchResponse;
const result = body.results?.[0];
if (!isAcceptableRawgMatch(query, result)) {
return null;
}
return {
id: String(result.id),
name: result.name.trim(),
iconUrl: result.background_image || undefined,
store: selectPreferredStore(result, result.name.trim())
};
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
function selectPreferredStore(result: RawgGameResult, gameName: string): GameStoreLink | undefined {
const stores = Array.isArray(result.stores) ? result.stores : [];
const usableStores = stores
.map((entry) => buildStoreLink(entry, gameName))
.filter((store): store is GameStoreLink => !!store);
return usableStores.sort((left, right) => getStorePriority(left) - getStorePriority(right))[0];
}
function getStorePriority(store: GameStoreLink): number {
const storeKey = STORE_SEARCH_ALIASES.get(store.slug ?? '')
?? STORE_SEARCH_ALIASES.get(store.domain ?? '')
?? store.name.trim().toLowerCase();
return STORE_PRIORITY.get(storeKey) ?? 50;
}
function buildStoreLink(entry: RawgStoreEntry, gameName: string): GameStoreLink | undefined {
const store = entry.store;
if (!store || typeof store.name !== 'string' || !store.name.trim()) {
return undefined;
}
const slug = typeof store.slug === 'string' && store.slug.trim()
? store.slug.trim().toLowerCase()
: undefined;
const domain = typeof store.domain === 'string' && store.domain.trim()
? store.domain.trim()
.replace(/^https?:\/\//i, '')
.replace(/\/$/, '')
: undefined;
const url = normalizeExternalUrl(entry.url) ?? buildStoreSearchUrl(slug, domain, gameName);
if (!url) {
return undefined;
}
return {
id: typeof store.id === 'number' ? String(store.id) : undefined,
name: store.name.trim(),
slug,
domain,
url
};
}
function normalizeExternalUrl(value: unknown): string | undefined {
if (typeof value !== 'string' || !value.trim()) {
return undefined;
}
const trimmed = value.trim();
return trimmed.startsWith('http://') || trimmed.startsWith('https://')
? trimmed
: undefined;
}
function buildStoreSearchUrl(slug: string | undefined, domain: string | undefined, gameName: string): string | undefined {
const query = encodeURIComponent(gameName);
const storeKey = STORE_SEARCH_ALIASES.get(slug ?? '') ?? STORE_SEARCH_ALIASES.get(domain ?? '');
const buildUrl = storeKey ? STORE_SEARCH_URL_BUILDERS[storeKey] : undefined;
return buildUrl?.(query) ?? (domain ? `https://${domain}` : undefined);
}
function buildSearchQuery(processName: string): string {
const query = processName
.replace(/\.exe$/i, '')
.replace(/\b(x64|x86|win64|win32|linux|shipping|client|launcher|game)\b/gi, ' ')
.replace(/\s+/g, ' ')
.trim();
return query.length >= MIN_SEARCH_QUERY_LENGTH ? query : '';
}
function isAcceptableRawgMatch(
query: string,
result: RawgGameResult | undefined
): result is Required<Pick<RawgGameResult, 'id' | 'name'>> & RawgGameResult {
if (!result || typeof result.id !== 'number' || typeof result.name !== 'string' || !result.name.trim()) {
return false;
}
const queryKey = normalizeComparableText(query);
const nameKey = normalizeComparableText(result.name);
const slugKey = normalizeComparableText(result.slug ?? '');
const queryTokens = tokenizeComparableText(queryKey);
const nameTokens = tokenizeComparableText(nameKey);
const slugTokens = tokenizeComparableText(slugKey);
if (queryKey.length < MIN_SEARCH_QUERY_LENGTH || queryTokens.length === 0) {
return false;
}
if (queryKey === nameKey || queryKey === slugKey) {
return true;
}
if (queryTokens.length === 1) {
const [queryToken] = queryTokens;
return queryToken.length >= 5
&& (nameTokens.includes(queryToken) || slugTokens.includes(queryToken));
}
return queryTokens.every((token) => nameTokens.includes(token) || slugTokens.includes(token));
}
function normalizeComparableText(value: string): string {
return value.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
function tokenizeComparableText(value: string): string[] {
return value.split(' ')
.filter((token) => token.length >= 2);
}
function appendMatch(
matches: MatchedGame[],
seenGameIds: Set<string>,
processName: string,
game: Omit<MatchedGame, 'processName'> | null
): void {
if (!game || seenGameIds.has(game.id)) {
return;
}
seenGameIds.add(game.id);
matches.push({
...game,
processName
});
}

View File

@@ -130,10 +130,6 @@ 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();

View File

@@ -67,14 +67,6 @@ 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');

View File

@@ -71,6 +71,25 @@ 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));
@@ -274,13 +293,7 @@ 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;

View File

@@ -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 or client message received (used to detect dead connections). */
/** Timestamp of the last pong received (used to detect dead connections). */
lastPong: number;
}

View File

@@ -97,7 +97,7 @@
{
"type": "initial",
"maximumWarning": "2.2MB",
"maximumError": "2.38MB"
"maximumError": "2.35MB"
},
{
"type": "anyComponentStyle",

View File

@@ -145,7 +145,7 @@
</button>
</div>
</div>
} @if (!isThemeStudioFullscreen() && !isDirectMessageRoute()) {
} @if (!isThemeStudioFullscreen()) {
<app-floating-voice-controls />
}
<app-settings-modal />

View File

@@ -34,16 +34,6 @@ 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: () =>

View File

@@ -34,7 +34,6 @@ 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';
@@ -46,7 +45,10 @@ 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 } from './core/constants';
import { clearStoredCurrentUserId, getStoredCurrentUserId } from './core/storage/current-user-storage';
import {
clearStoredCurrentUserId,
getStoredCurrentUserId
} from './core/storage/current-user-storage';
import {
ThemeNodeDirective,
ThemePickerOverlayComponent,
@@ -96,12 +98,10 @@ 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'));
@@ -115,7 +115,6 @@ 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();
@@ -248,7 +247,6 @@ export class App implements OnInit, OnDestroy {
await this.setupDesktopDeepLinks();
this.userStatus.start();
this.gameActivity.start();
const currentUrl = this.getCurrentRouteUrl();
if (!currentUserId) {
@@ -278,8 +276,6 @@ 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;

View File

@@ -124,24 +124,6 @@ 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;
@@ -175,7 +157,6 @@ 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>;
@@ -184,10 +165,6 @@ 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>;

View File

@@ -7,7 +7,6 @@ export type SettingsPage =
| 'notifications'
| 'voice'
| 'updates'
| 'data'
| 'debugging'
| 'server'
| 'members'

View File

@@ -12,8 +12,6 @@ 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` |
@@ -30,7 +28,6 @@ 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)

View File

@@ -1,5 +1,8 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Injectable,
computed,
effect,
inject,
signal
} from '@angular/core';
@@ -10,11 +13,7 @@ import {
throwError
} from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import {
ServerDirectoryFacade,
type RoomSignalSourceInput,
type ServerSourceSelector
} from '../../../server-directory';
import { ServerDirectoryFacade } from '../../../server-directory';
export interface KlipyGif {
id: string;
@@ -38,47 +37,51 @@ 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 availabilityByKey = signal<Record<string, KlipyAvailabilityState>>({});
private readonly availabilityState = signal({
enabled: false,
loading: true
});
private lastAvailabilityKey = '';
isEnabled(source?: RoomSignalSourceInput | null): boolean {
return this.getAvailabilityState(source).enabled;
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();
});
}
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,
async refreshAvailability(): Promise<void> {
this.availabilityState.set({ enabled: false,
loading: true });
try {
const response = await firstValueFrom(
this.http.get<KlipyAvailabilityResponse>(
`${this.serverDirectory.getApiBaseUrl(selector)}/klipy/config`
`${this.serverDirectory.getApiBaseUrl()}/klipy/config`
)
);
this.setAvailabilityState(key, {
this.availabilityState.set({
enabled: response.enabled === true,
loading: false
});
} catch {
this.setAvailabilityState(key, { enabled: false,
this.availabilityState.set({ enabled: false,
loading: false });
}
}
@@ -86,11 +89,8 @@ export class KlipyService {
searchGifs(
query: string,
page = 1,
perPage = DEFAULT_PAGE_SIZE,
source?: RoomSignalSourceInput | null
perPage = DEFAULT_PAGE_SIZE
): 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(selector)}/klipy/gifs`, { params })
.get<KlipyGifSearchResponse>(`${this.serverDirectory.getApiBaseUrl()}/klipy/gifs`, { params })
.pipe(
map((response) => ({
enabled: response.enabled !== false,
@@ -138,7 +138,7 @@ export class KlipyService {
return this.normalizeMediaUrl(url);
}
buildImageProxyUrl(url: string, source?: RoomSignalSourceInput | null): string {
buildImageProxyUrl(url: string): string {
const trimmed = this.normalizeMediaUrl(url);
if (!trimmed)
@@ -147,36 +147,7 @@ export class KlipyService {
if (!/^https?:\/\//i.test(trimmed))
return 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;
return `${this.serverDirectory.getApiBaseUrl()}/image-proxy?url=${encodeURIComponent(trimmed)}`;
}
private getPreferredLocale(): string | null {

View File

@@ -8,7 +8,6 @@ import {
signal
} from '@angular/core';
import { KlipyService } from '../application/services/klipy.service';
import type { RoomSignalSourceInput } from '../../server-directory';
@Directive({
selector: 'img[appChatImageProxyFallback]',
@@ -16,7 +15,6 @@ import type { RoomSignalSourceInput } from '../../server-directory';
})
export class ChatImageProxyFallbackDirective {
readonly sourceUrl = input('', { alias: 'appChatImageProxyFallback' });
readonly signalSource = input<RoomSignalSourceInput | null>(null);
private readonly klipy = inject(KlipyService);
private readonly renderedSource = signal('');
@@ -40,7 +38,7 @@ export class ChatImageProxyFallbackDirective {
return;
}
const proxyUrl = this.klipy.buildImageProxyUrl(this.sourceUrl(), this.signalSource());
const proxyUrl = this.klipy.buildImageProxyUrl(this.sourceUrl());
if (!proxyUrl || proxyUrl === this.renderedSource()) {
return;

View File

@@ -1,7 +1,4 @@
<div
appThemeNode="chatSurface"
class="chat-layout relative h-full"
>
<div class="chat-layout relative h-full">
<app-chat-message-list
[allMessages]="allMessages()"
[channelMessages]="channelMessages()"
@@ -22,15 +19,10 @@
(embedRemoved)="handleEmbedRemoved($event)"
/>
<div
appThemeNode="chatComposerBar"
class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10"
>
<div 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()"
@@ -53,13 +45,11 @@
<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()"
/>

View File

@@ -4,7 +4,6 @@ import {
HostListener,
ViewChild,
computed,
effect,
inject,
signal
} from '@angular/core';
@@ -12,7 +11,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, KlipyService } from '../../application/services/klipy.service';
import { KlipyGif } from '../../application/services/klipy.service';
import { MessagesActions } from '../../../../store/messages/messages.actions';
import {
selectAllMessages,
@@ -22,7 +21,6 @@ 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';
@@ -44,8 +42,7 @@ import {
ChatMessageComposerComponent,
KlipyGifPickerComponent,
ChatMessageListComponent,
ChatMessageOverlaysComponent,
ThemeNodeDirective
ChatMessageOverlaysComponent
],
templateUrl: './chat-messages.component.html',
styleUrl: './chat-messages.component.scss'
@@ -57,11 +54,10 @@ 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);
readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
readonly loading = this.store.selectSignal(selectMessagesLoading);
readonly syncing = this.store.selectSignal(selectMessagesSyncing);
@@ -72,11 +68,16 @@ 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 klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom()));
readonly conversationKey = computed(
() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`
);
readonly composerBottomPadding = signal(140);
readonly klipyGifPickerAnchorRight = signal(16);
readonly replyTo = signal<Message | null>(null);
@@ -84,12 +85,6 @@ 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()) {
@@ -172,7 +167,9 @@ 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(
@@ -237,7 +234,9 @@ 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 {
@@ -282,7 +281,10 @@ 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;
@@ -405,7 +407,12 @@ 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);

View File

@@ -1,13 +1,7 @@
<!-- eslint-disable @angular-eslint/template/button-has-type -->
<div
#composerRoot
appThemeNode="chatComposerBar"
>
<div #composerRoot>
@if (replyTo()) {
<div
appThemeNode="chatComposerReplyBar"
class="pointer-events-auto flex items-center gap-2 bg-secondary/50 px-4 py-2"
>
<div 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"
@@ -37,7 +31,6 @@
(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
@@ -131,7 +124,6 @@
<div class="border-border p-4">
<div
appThemeNode="chatComposerInput"
class="chat-input-wrapper relative"
(mouseenter)="inputHovered.set(true)"
(mouseleave)="inputHovered.set(false)"
@@ -141,7 +133,7 @@
(drop)="onDrop($event)"
>
<div class="absolute bottom-3 right-3 z-10 flex items-center gap-2 m-0.5">
@if (klipyEnabled()) {
@if (klipy.isEnabled()) {
<button
#klipyTrigger
type="button"
@@ -164,7 +156,6 @@
}
<button
appThemeNode="chatComposerSendButton"
type="button"
(click)="sendMessage()"
[disabled]="!messageContent.trim() && pendingFiles.length === 0 && !pendingKlipyGif()"
@@ -181,7 +172,6 @@
<textarea
#messageInputRef
[attr.data-testid]="textareaTestId()"
rows="1"
[(ngModel)]="messageContent"
(focus)="onInputFocus()"
@@ -199,8 +189,8 @@
[class.border-primary]="dragActive()"
[class.chat-textarea-expanded]="textareaExpanded()"
[class.ctrl-resize]="ctrlHeld()"
[class.pr-16]="!klipyEnabled()"
[class.pr-40]="klipyEnabled()"
[class.pr-16]="!klipy.isEnabled()"
[class.pr-40]="klipy.isEnabled()"
></textarea>
@if (dragActive()) {
@@ -217,7 +207,6 @@
<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"

View File

@@ -23,8 +23,6 @@ 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';
@@ -44,8 +42,7 @@ const DEFAULT_TEXTAREA_HEIGHT = 62;
FormsModule,
NgIcon,
ChatImageProxyFallbackDirective,
TypingIndicatorComponent,
ThemeNodeDirective
TypingIndicatorComponent
],
viewProviders: [
provideIcons({
@@ -69,9 +66,6 @@ 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();
@@ -79,7 +73,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
readonly heightChanged = output<number>();
readonly klipyGifPickerToggleRequested = output();
private readonly klipy = inject(KlipyService);
readonly klipy = inject(KlipyService);
private readonly markdown = inject(ChatMarkdownService);
private readonly electronBridge = inject(ElectronBridgeService);
@@ -213,7 +207,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
}
toggleKlipyGifPicker(): void {
if (!this.klipyEnabled())
if (!this.klipy.isEnabled())
return;
this.klipyGifPickerToggleRequested.emit();
@@ -417,7 +411,11 @@ 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;

View File

@@ -2,13 +2,11 @@
@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()"
>
@@ -19,10 +17,7 @@
/>
</div>
<div
appThemeNode="chatMessageContent"
class="min-w-0 flex-1"
>
<div class="min-w-0 flex-1">
@if (msg.replyToId) {
@let reply = repliedMessage();
<div
@@ -155,10 +150,7 @@
</div>
</div>
} @else if ((att.receivedBytes || 0) > 0) {
<div
appThemeNode="chatAttachmentCard"
class="max-w-xs rounded-md border border-border bg-secondary/40 p-3"
>
<div 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
@@ -180,10 +172,7 @@
</div>
</div>
} @else {
<div
appThemeNode="chatAttachmentCard"
class="max-w-xs rounded-md border border-dashed border-border bg-secondary/20 p-4"
>
<div 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
@@ -231,10 +220,7 @@
/>
}
} @else if ((att.receivedBytes || 0) > 0) {
<div
appThemeNode="chatAttachmentCard"
class="max-w-xl rounded-md border border-border bg-secondary/40 p-3"
>
<div 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>
@@ -261,10 +247,7 @@
</div>
</div>
} @else {
<div
appThemeNode="chatAttachmentCard"
class="max-w-xl rounded-md border border-dashed border-border bg-secondary/20 p-4"
>
<div 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>
@@ -288,10 +271,7 @@
</div>
}
} @else {
<div
appThemeNode="chatAttachmentCard"
class="rounded-md border border-border bg-secondary/40 p-2"
>
<div 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>
@@ -359,7 +339,6 @@
<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"
@@ -375,7 +354,6 @@
@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">

View File

@@ -37,7 +37,6 @@ import {
Message,
User
} from '../../../../../../shared-kernel';
import { ThemeNodeDirective } from '../../../../../theme';
import {
ChatAudioPlayerComponent,
@@ -97,8 +96,7 @@ interface ChatMessageAttachmentViewModel extends Attachment {
ChatVideoPlayerComponent,
ChatMessageMarkdownComponent,
ChatLinkEmbedComponent,
UserAvatarComponent,
ThemeNodeDirective
UserAvatarComponent
],
viewProviders: [
provideIcons({
@@ -152,17 +150,15 @@ export class ChatMessageItemComponent {
const msg = this.message();
const found = this.userLookup().get(msg.senderId);
return (
found ?? {
id: msg.senderId,
oderId: msg.senderId,
username: msg.senderName,
displayName: msg.senderName,
status: 'disconnected',
role: 'member',
joinedAt: 0
}
);
return found ?? {
id: msg.senderId,
oderId: msg.senderId,
username: msg.senderName,
displayName: msg.senderName,
status: 'disconnected',
role: 'member',
joinedAt: 0
};
});
editContent = '';
@@ -179,7 +175,9 @@ 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();
@@ -322,7 +320,8 @@ 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)
@@ -332,7 +331,11 @@ export class ChatMessageItemComponent {
return 'Yesterday ' + time;
if (dayDiff < 7) {
return date.toLocaleDateString([], { weekday: 'short' }) + ' ' + time;
return (
date.toLocaleDateString([], { weekday: 'short' }) +
' ' +
time
);
}
return (
@@ -399,7 +402,10 @@ 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 {
@@ -412,7 +418,9 @@ 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 {
@@ -476,7 +484,8 @@ 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,
@@ -484,12 +493,8 @@ 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
@@ -499,11 +504,15 @@ 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);
}
}

View File

@@ -1,6 +1,5 @@
<div
#messagesContainer
appThemeNode="chatMessageList"
class="absolute inset-0 space-y-4 overflow-y-auto p-4"
[style.padding-bottom.px]="bottomPadding()"
(scroll)="onScroll()"
@@ -40,10 +39,7 @@
@for (message of messages(); track message.id; let index = $index) {
@if (dateSeparatorLabels().get(index); as separatorLabel) {
<div
appThemeNode="chatDateSeparator"
class="flex items-center gap-3 py-1"
>
<div 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 }}
@@ -74,10 +70,7 @@
@if (showNewMessagesBar()) {
<div class="pointer-events-none sticky bottom-4 flex justify-center">
<div
appThemeNode="chatNewMessagesBar"
class="pointer-events-auto flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2 shadow"
>
<div 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"

View File

@@ -26,7 +26,6 @@ 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 {
@@ -42,11 +41,7 @@ declare global {
@Component({
selector: 'app-chat-message-list',
standalone: true,
imports: [
CommonModule,
ChatMessageItemComponent,
ThemeNodeDirective
],
imports: [CommonModule, ChatMessageItemComponent],
templateUrl: './chat-message-list.component.html',
host: {
style: 'display: contents;'
@@ -71,7 +66,6 @@ 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>();
@@ -99,7 +93,9 @@ 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>();
@@ -130,14 +126,6 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
}
}
for (const user of this.userLookupOverrides()) {
lookup.set(user.id, user);
if (user.oderId && user.oderId !== user.id) {
lookup.set(user.oderId, user);
}
}
return lookup;
});
@@ -168,7 +156,8 @@ 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) {
@@ -230,7 +219,8 @@ 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) {
@@ -387,7 +377,11 @@ 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;
}

View File

@@ -93,7 +93,6 @@
>
<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"

View File

@@ -8,7 +8,6 @@ import {
OnInit,
ViewChild,
inject,
input,
output,
signal
} from '@angular/core';
@@ -22,7 +21,6 @@ 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;
@@ -50,8 +48,6 @@ 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>();
@@ -132,7 +128,7 @@ export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy
try {
const response = await firstValueFrom(
this.klipy.searchGifs(this.searchQuery, this.currentPage, undefined, this.signalSource())
this.klipy.searchGifs(this.searchQuery, this.currentPage)
);
if (requestId !== this.requestId)

View File

@@ -19,10 +19,11 @@
@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"
[attr.data-testid]="'user-card-' + (user.oderId || user.id)"
(click)="openDirectMessage(user)"
(keydown.enter)="openDirectMessage(user)"
(keydown.space)="openDirectMessage(user)"
(click)="toggleUserMenu(user.id)"
(keydown.enter)="toggleUserMenu(user.id)"
(keydown.space)="toggleUserMenu(user.id)"
(keyup.enter)="toggleUserMenu(user.id)"
(keyup.space)="toggleUserMenu(user.id)"
role="button"
tabindex="0"
>
@@ -69,19 +70,6 @@
<!-- 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"

View File

@@ -8,7 +8,6 @@ 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,
@@ -20,8 +19,7 @@ import {
lucideBan,
lucideUserX,
lucideVolume2,
lucideVolumeX,
lucideMessageCircle
lucideVolumeX
} from '@ng-icons/lucide';
import { UsersActions } from '../../../../store/users/users.actions';
@@ -32,7 +30,6 @@ 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',
@@ -55,8 +52,7 @@ import { DirectMessageService } from '../../../direct-message';
lucideBan,
lucideUserX,
lucideVolume2,
lucideVolumeX,
lucideMessageCircle
lucideVolumeX
})
],
templateUrl: './user-list.component.html'
@@ -66,8 +62,6 @@ import { DirectMessageService } from '../../../direct-message';
*/
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));
@@ -90,16 +84,6 @@ 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) {

View File

@@ -1,23 +1,8 @@
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';

View File

@@ -1,41 +0,0 @@
# Direct Message Domain
Direct messages provide local, offline-safe one-to-one messaging over the existing WebRTC data channel.
## Structure
```
direct-message/
├── application/services/ DirectMessageService, OfflineMessageQueueService, FriendService, PeerDeliveryService
├── domain/ Direct message models and status-transition rules
├── infrastructure/ User-scoped local repositories
└── feature/ DM rail, chat view, message rows, user search, friend button
```
## Flow
1. `DirectMessageService.sendMessage()` stores the message locally with `QUEUED`.
2. `PeerDeliveryService` tries to send a `direct-message` P2P event to the recipient's current peer id.
3. If the peer is connected, the sender advances to `SENT`; otherwise the message id remains in `OfflineMessageQueueService`.
4. The recipient persists the message as `DELIVERED` and sends a `direct-message-status` event back.
5. Opening the conversation marks incoming messages as `ACKNOWLEDGED` and emits a status event.
Status transitions are monotonic, so a stale `SENT` event cannot overwrite `DELIVERED` or `ACKNOWLEDGED`.
## Chat View
The DM view reuses the chat domain's shared message list, composer, overlays, markdown renderer, link embeds, media players, and attachment controls. Direct-message records are mapped into the shared `Message` shape at the feature boundary so PMs keep the same date separators, replies, editing, deletion, reactions, image lightbox, audio playback, and video playback as server text channels.
Message edits, deletions, and reaction changes are stored locally and mirrored to the peer with `direct-message-mutation` events. Delivery state remains direct-message-owned and is exposed separately from the visible shared chat row UI.
## GIFs
The DM composer reuses the chat domain's KLIPY integration. Availability and GIF search go through the configured signal server API, and selected GIFs are sent as markdown image messages so the same proxy-fallback image rendering path is used in DMs and server chat.
## Avatars
Conversation participants keep avatar/profile metadata captured from user cards or room membership. When a PM is opened and the peer avatar is missing, the view asks the peer for the existing profile-avatar sync payload so downloaded user icons can be filled in without adding a DM-specific avatar transport.
## Persistence
Repositories are user-scoped and stored locally under `metoyou_direct_message_*` keys. The storage is intentionally domain-owned so browser and Electron runtimes share the same renderer API without changing the existing chat-message database tables.

View File

@@ -1,69 +0,0 @@
import {
advanceDirectMessageStatus,
createDirectConversation,
getDirectConversationId,
updateMessageStatusInConversation,
upsertDirectMessage
} from '../../domain/logic/direct-message.logic';
import type {
DirectMessage,
DirectMessageParticipant
} from '../../domain/models/direct-message.model';
const alice: DirectMessageParticipant = {
userId: 'alice',
username: 'alice',
displayName: 'Alice'
};
const bob: DirectMessageParticipant = {
userId: 'bob',
username: 'bob',
displayName: 'Bob'
};
describe('DirectMessageService domain flow', () => {
it('should create conversation', () => {
const conversation = createDirectConversation(alice, bob, 10);
expect(conversation.id).toBe(getDirectConversationId('alice', 'bob'));
expect(conversation.participants).toEqual(['alice', 'bob']);
expect(conversation.unreadCount).toBe(0);
});
it('should send message', () => {
const conversation = createDirectConversation(alice, bob, 10);
const queuedMessage = createMessage('message-1', 'QUEUED');
const withQueuedMessage = upsertDirectMessage(conversation, queuedMessage, false);
const withSentMessage = updateMessageStatusInConversation(withQueuedMessage, queuedMessage.id, 'SENT');
expect(withSentMessage.messages[0].status).toBe('SENT');
});
it('should queue message when offline', () => {
const conversation = createDirectConversation(alice, bob, 10);
const queuedMessage = createMessage('message-1', 'QUEUED');
const updatedConversation = upsertDirectMessage(conversation, queuedMessage, false);
expect(updatedConversation.messages[0].status).toBe('QUEUED');
});
it('should update status correctly', () => {
expect(advanceDirectMessageStatus('QUEUED', 'SENT')).toBe('SENT');
expect(advanceDirectMessageStatus('SENT', 'DELIVERED')).toBe('DELIVERED');
expect(advanceDirectMessageStatus('DELIVERED', 'SENT')).toBe('DELIVERED');
expect(advanceDirectMessageStatus('DELIVERED', 'ACKNOWLEDGED')).toBe('ACKNOWLEDGED');
});
});
function createMessage(id: string, status: DirectMessage['status']): DirectMessage {
return {
id,
conversationId: getDirectConversationId('alice', 'bob'),
senderId: 'alice',
recipientId: 'bob',
content: 'Hello',
timestamp: 20,
status
};
}

View File

@@ -1,566 +0,0 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Injectable,
computed,
effect,
inject,
signal
} from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { v4 as uuidv4 } from 'uuid';
import { DirectMessageRepository } from '../../infrastructure/direct-message.repository';
import { OfflineMessageQueueService } from './offline-message-queue.service';
import { PeerDeliveryService } from './peer-delivery.service';
import {
advanceDirectMessageStatus,
createDirectConversation,
getDirectConversationId,
updateMessageStatusInConversation,
upsertDirectMessage
} from '../../domain/logic/direct-message.logic';
import {
DirectMessage,
DirectMessageConversation,
DirectMessageEventPayload,
DirectMessageMutationEventPayload,
DirectMessageStatus,
DirectMessageStatusEventPayload,
toDirectMessageParticipant
} from '../../domain/models/direct-message.model';
import type {
ChatEvent,
Reaction,
User
} from '../../../../shared-kernel';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
@Injectable({ providedIn: 'root' })
export class DirectMessageService {
private readonly repository = inject(DirectMessageRepository);
private readonly offlineQueue = inject(OfflineMessageQueueService);
private readonly delivery = inject(PeerDeliveryService);
private readonly store = inject(Store);
private readonly router = inject(Router);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private readonly conversationsSignal = signal<DirectMessageConversation[]>([]);
private readonly selectedConversationIdSignal = signal<string | null>(null);
private loadedOwnerId: string | null = null;
readonly conversations = computed(() => [...this.conversationsSignal()].sort(
(firstConversation, secondConversation) => secondConversation.lastMessageAt - firstConversation.lastMessageAt
));
readonly selectedConversationId = this.selectedConversationIdSignal.asReadonly();
readonly selectedConversation = computed(() => {
const selectedId = this.selectedConversationIdSignal();
return selectedId
? this.conversationsSignal().find((conversation) => conversation.id === selectedId) ?? null
: null;
});
readonly totalUnreadCount = computed(() => this.conversationsSignal().reduce(
(total, conversation) => total + conversation.unreadCount,
0
));
constructor() {
effect(() => {
const ownerId = this.getCurrentUserId();
void this.loadForOwner(ownerId);
});
this.delivery.directMessageEvents$.subscribe((event) => {
void this.handlePeerEvent(event);
});
this.delivery.peerConnected$.subscribe(() => {
void this.retryPending();
});
this.delivery.networkRestored$.subscribe(() => {
void this.retryPending();
});
}
async createConversation(user: User): Promise<DirectMessageConversation> {
const currentUser = this.requireCurrentUser();
const ownerId = this.getCurrentUserIdOrThrow();
await this.loadForOwner(ownerId);
const currentParticipant = toDirectMessageParticipant(currentUser);
const peerParticipant = toDirectMessageParticipant(user);
const conversationId = getDirectConversationId(currentParticipant.userId, peerParticipant.userId);
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId);
if (existingConversation) {
this.selectedConversationIdSignal.set(existingConversation.id);
return existingConversation;
}
const conversation = createDirectConversation(currentParticipant, peerParticipant, Date.now());
await this.persistConversation(ownerId, conversation);
this.selectedConversationIdSignal.set(conversation.id);
return conversation;
}
async openConversation(conversationId: string): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow();
await this.loadForOwner(ownerId);
this.selectedConversationIdSignal.set(conversationId);
await this.markRead(conversationId);
}
closeConversationView(conversationId?: string | null): void {
if (!conversationId || this.selectedConversationIdSignal() === conversationId) {
this.selectedConversationIdSignal.set(null);
}
}
async forgetConversation(conversationId: string): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow();
const conversation = await this.repository.getConversation(ownerId, conversationId);
if (!conversation) {
return;
}
await this.repository.deleteConversation(ownerId, conversationId);
for (const message of conversation.messages) {
await this.offlineQueue.markDelivered(ownerId, message.id);
}
this.conversationsSignal.update((conversations) => conversations.filter((entry) => entry.id !== conversationId));
if (this.selectedConversationIdSignal() === conversationId) {
this.selectedConversationIdSignal.set(null);
}
}
async sendMessage(conversationId: string, content: string, replyToId?: string): Promise<DirectMessage> {
const normalizedContent = content.trim();
if (!normalizedContent) {
throw new Error('Cannot send an empty direct message.');
}
const currentUser = this.requireCurrentUser();
const ownerId = this.getCurrentUserIdOrThrow();
const conversation = await this.requireConversation(ownerId, conversationId);
const senderId = currentUser.oderId || currentUser.id;
const recipientId = conversation.participants.find((participantId) => participantId !== senderId);
if (!recipientId) {
throw new Error('Direct message conversation has no recipient.');
}
const message: DirectMessage = {
id: uuidv4(),
conversationId,
senderId,
recipientId,
content: normalizedContent,
timestamp: Date.now(),
status: 'QUEUED',
reactions: [],
isDeleted: false,
replyToId
};
await this.persistConversation(ownerId, upsertDirectMessage(conversation, message, false));
await this.attemptDelivery(ownerId, message);
return message;
}
async editMessage(conversationId: string, messageId: string, content: string): Promise<void> {
const normalizedContent = content.trim();
if (!normalizedContent) {
return;
}
await this.applyAndSendMutation(conversationId, {
conversationId,
messageId,
type: 'edit',
content: normalizedContent,
editedAt: Date.now(),
updatedAt: Date.now()
});
}
async deleteMessage(conversationId: string, messageId: string): Promise<void> {
await this.applyAndSendMutation(conversationId, {
conversationId,
messageId,
type: 'delete',
updatedAt: Date.now()
});
}
async addReaction(conversationId: string, messageId: string, emoji: string): Promise<void> {
const userId = this.getCurrentUserIdOrThrow();
const reaction: Reaction = {
id: uuidv4(),
messageId,
oderId: userId,
userId,
emoji,
timestamp: Date.now()
};
await this.applyAndSendMutation(conversationId, {
conversationId,
messageId,
type: 'reaction-add',
reaction,
updatedAt: reaction.timestamp
});
}
async toggleReaction(conversationId: string, messageId: string, emoji: string): Promise<void> {
const userId = this.getCurrentUserIdOrThrow();
const conversation = await this.requireConversation(userId, conversationId);
const message = conversation.messages.find((entry) => entry.id === messageId);
const existingReaction = message?.reactions?.find((reaction) =>
reaction.emoji === emoji && (reaction.userId === userId || reaction.oderId === userId)
);
if (existingReaction) {
await this.applyAndSendMutation(conversationId, {
conversationId,
messageId,
type: 'reaction-remove',
oderId: userId,
emoji,
updatedAt: Date.now()
});
return;
}
await this.addReaction(conversationId, messageId, emoji);
}
requestPeerAvatarSync(conversationId: string): void {
const currentUserId = this.getCurrentUserId();
const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId);
const peerId = conversation?.participants.find((participantId) => participantId !== currentUserId);
if (peerId) {
this.delivery.requestUserAvatar(peerId);
}
}
currentUserId(): string | null {
return this.getCurrentUserId();
}
async updateStatus(messageId: string, status: DirectMessageStatus): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow();
const conversation = this.conversationsSignal().find((entry) => entry.messages.some((message) => message.id === messageId));
if (!conversation) {
return;
}
await this.persistConversation(ownerId, updateMessageStatusInConversation(conversation, messageId, status));
}
async receiveMessage(message: DirectMessage, sender: User): Promise<void> {
await this.handleIncomingMessage({
message,
sender: toDirectMessageParticipant(sender)
});
}
async markRead(conversationId: string): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow();
const currentUserId = this.getCurrentUserIdOrThrow();
const conversation = await this.requireConversation(ownerId, conversationId);
const updatedConversation = { ...conversation, unreadCount: 0 };
await this.persistConversation(ownerId, updatedConversation);
await this.repository.markRead(ownerId, conversationId);
for (const message of updatedConversation.messages) {
if (message.recipientId !== currentUserId || message.status === 'ACKNOWLEDGED') {
continue;
}
const nextStatus = advanceDirectMessageStatus(message.status, 'ACKNOWLEDGED');
if (nextStatus !== message.status) {
await this.persistConversation(ownerId, updateMessageStatusInConversation(updatedConversation, message.id, nextStatus));
}
this.sendStatusUpdate(message.senderId, {
conversationId,
messageId: message.id,
status: 'ACKNOWLEDGED',
updatedAt: Date.now()
});
}
}
async retryPending(): Promise<void> {
const ownerId = this.getCurrentUserId();
if (!ownerId) {
return;
}
await this.loadForOwner(ownerId);
const pendingMessageIds = await this.offlineQueue.retryPending(ownerId);
const messages = this.conversationsSignal().flatMap((conversation) => conversation.messages);
for (const messageId of pendingMessageIds) {
const message = messages.find((entry) => entry.id === messageId);
if (message) {
await this.attemptDelivery(ownerId, message);
}
}
}
private async handlePeerEvent(event: ChatEvent): Promise<void> {
if (event.type === 'direct-message' && event.directMessage) {
await this.handleIncomingMessage(event.directMessage);
return;
}
if (event.type === 'direct-message-status' && event.directMessageStatus) {
await this.handleIncomingStatus(event.directMessageStatus);
return;
}
if (event.type === 'direct-message-mutation' && event.directMessageMutation) {
await this.handleIncomingMutation(event.directMessageMutation);
}
}
private async handleIncomingMessage(payload: DirectMessageEventPayload): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow();
const currentUser = this.requireCurrentUser();
const currentParticipant = toDirectMessageParticipant(currentUser);
const sender = payload.sender;
const conversationId = payload.message.conversationId
|| getDirectConversationId(currentParticipant.userId, sender.userId);
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId)
?? createDirectConversation(currentParticipant, sender, payload.message.timestamp);
const incomingMessage: DirectMessage = {
...payload.message,
conversationId,
status: advanceDirectMessageStatus(payload.message.status, 'DELIVERED')
};
const shouldIncrementUnread = !this.isConversationVisible(conversationId);
await this.persistConversation(ownerId, upsertDirectMessage(existingConversation, incomingMessage, shouldIncrementUnread));
this.sendStatusUpdate(incomingMessage.senderId, {
conversationId,
messageId: incomingMessage.id,
status: 'DELIVERED',
updatedAt: Date.now()
});
if (!shouldIncrementUnread) {
await this.markRead(conversationId);
}
}
private async handleIncomingStatus(payload: DirectMessageStatusEventPayload): Promise<void> {
await this.updateStatus(payload.messageId, payload.status);
if (payload.status === 'DELIVERED' || payload.status === 'ACKNOWLEDGED') {
await this.offlineQueue.markDelivered(this.getCurrentUserIdOrThrow(), payload.messageId);
}
}
private isConversationVisible(conversationId: string): boolean {
const currentUrl = this.router.url.split(/[?#]/, 1)[0];
if (!currentUrl.startsWith('/dm/')) {
return false;
}
try {
return decodeURIComponent(currentUrl.slice('/dm/'.length)) === conversationId;
} catch {
return currentUrl.slice('/dm/'.length) === conversationId;
}
}
private async handleIncomingMutation(payload: DirectMessageMutationEventPayload): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow();
const conversation = await this.requireConversation(ownerId, payload.conversationId);
await this.persistConversation(ownerId, this.applyMutation(conversation, payload));
}
private async applyAndSendMutation(
conversationId: string,
payload: DirectMessageMutationEventPayload
): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow();
const conversation = await this.requireConversation(ownerId, conversationId);
const updatedConversation = this.applyMutation(conversation, payload);
const recipientId = conversation.participants.find((participantId) => participantId !== ownerId);
await this.persistConversation(ownerId, updatedConversation);
if (recipientId) {
this.delivery.sendViaWebRTC(recipientId, {
type: 'direct-message-mutation',
directMessageMutation: payload
});
}
}
private applyMutation(
conversation: DirectMessageConversation,
payload: DirectMessageMutationEventPayload
): DirectMessageConversation {
const messages = conversation.messages.map((message) => {
if (message.id !== payload.messageId) {
return message;
}
if (payload.type === 'edit' && payload.content) {
return {
...message,
content: payload.content,
editedAt: payload.editedAt ?? payload.updatedAt,
isDeleted: false
};
}
if (payload.type === 'delete') {
return {
...message,
content: '',
isDeleted: true,
editedAt: payload.updatedAt
};
}
if (payload.type === 'reaction-add' && payload.reaction) {
const reactions = (message.reactions ?? []).filter((reaction) =>
!(reaction.emoji === payload.reaction?.emoji && reaction.userId === payload.reaction.userId)
);
return {
...message,
reactions: [...reactions, payload.reaction]
};
}
if (payload.type === 'reaction-remove' && payload.oderId && payload.emoji) {
return {
...message,
reactions: (message.reactions ?? []).filter((reaction) =>
!(reaction.emoji === payload.emoji && (reaction.userId === payload.oderId || reaction.oderId === payload.oderId))
)
};
}
return message;
});
return { ...conversation, messages };
}
private async attemptDelivery(ownerId: string, message: DirectMessage): Promise<void> {
const currentUser = this.requireCurrentUser();
const sent = this.delivery.sendViaWebRTC(message.recipientId, {
type: 'direct-message',
directMessage: {
message,
sender: toDirectMessageParticipant(currentUser)
}
});
if (!sent) {
await this.offlineQueue.enqueue(ownerId, message.id);
return;
}
await this.offlineQueue.markDelivered(ownerId, message.id);
await this.updateStatus(message.id, 'SENT');
}
private sendStatusUpdate(recipientId: string, payload: DirectMessageStatusEventPayload): void {
this.delivery.handleAck(recipientId, {
type: 'direct-message-status',
directMessageStatus: payload
});
}
private async loadForOwner(ownerId: string | null): Promise<void> {
if (!ownerId) {
this.loadedOwnerId = null;
this.conversationsSignal.set([]);
return;
}
if (this.loadedOwnerId === ownerId) {
return;
}
this.loadedOwnerId = ownerId;
this.conversationsSignal.set(await this.repository.loadConversations(ownerId));
}
private async persistConversation(ownerId: string, conversation: DirectMessageConversation): Promise<void> {
await this.repository.saveConversation(ownerId, conversation);
this.conversationsSignal.update((conversations) => {
const nextConversations = conversations.filter((entry) => entry.id !== conversation.id);
nextConversations.push(conversation);
return nextConversations;
});
}
private async requireConversation(ownerId: string, conversationId: string): Promise<DirectMessageConversation> {
await this.loadForOwner(ownerId);
const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId)
?? await this.repository.getConversation(ownerId, conversationId);
if (!conversation) {
throw new Error('Direct message conversation not found.');
}
return conversation;
}
private requireCurrentUser(): User {
const currentUser = this.currentUser();
if (!currentUser) {
throw new Error('Cannot use direct messages without a current user.');
}
return currentUser;
}
private getCurrentUserId(): string | null {
const user = this.currentUser();
return user?.oderId || user?.id || null;
}
private getCurrentUserIdOrThrow(): string {
const ownerId = this.getCurrentUserId();
if (!ownerId) {
throw new Error('Cannot use direct messages without a current user.');
}
return ownerId;
}
}

View File

@@ -1,41 +0,0 @@
import { FriendRepository } from '../../infrastructure/friend.repository';
describe('FriendService storage contract', () => {
let repository: FriendRepository;
beforeEach(() => {
installLocalStorageMock();
repository = new FriendRepository();
});
it('should add friend', async () => {
await repository.addFriend('alice', { userId: 'bob', addedAt: 10 });
expect(await repository.loadFriends('alice')).toEqual([{ userId: 'bob', addedAt: 10 }]);
});
it('should remove friend', async () => {
await repository.addFriend('alice', { userId: 'bob', addedAt: 10 });
await repository.removeFriend('alice', 'bob');
expect(await repository.loadFriends('alice')).toEqual([]);
});
it('should persist friends', async () => {
await repository.addFriend('alice', { userId: 'bob', addedAt: 10 });
const reloadedRepository = new FriendRepository();
expect(await reloadedRepository.loadFriends('alice')).toEqual([{ userId: 'bob', addedAt: 10 }]);
});
});
function installLocalStorageMock(): void {
const values = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
clear: () => values.clear()
});
}

View File

@@ -1,85 +0,0 @@
import {
Injectable,
computed,
effect,
inject,
signal
} from '@angular/core';
import { Store } from '@ngrx/store';
import { FriendRepository } from '../../infrastructure/friend.repository';
import type { Friend } from '../../domain/models/direct-message.model';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
@Injectable({ providedIn: 'root' })
export class FriendService {
private readonly repository = inject(FriendRepository);
private readonly store = inject(Store);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private readonly friendsSignal = signal<Friend[]>([]);
private loadedOwnerId: string | null = null;
readonly friends = this.friendsSignal.asReadonly();
readonly friendIds = computed(() => new Set(this.friendsSignal().map((friend) => friend.userId)));
constructor() {
effect(() => {
const ownerId = this.currentUser()?.oderId || this.currentUser()?.id || null;
void this.loadForOwner(ownerId);
});
}
async addFriend(userId: string): Promise<void> {
const ownerId = await this.requireOwnerId();
const friend: Friend = { userId, addedAt: Date.now() };
await this.repository.addFriend(ownerId, friend);
await this.loadForOwner(ownerId, true);
}
async removeFriend(userId: string): Promise<void> {
const ownerId = await this.requireOwnerId();
await this.repository.removeFriend(ownerId, userId);
await this.loadForOwner(ownerId, true);
}
isFriend(userId: string): boolean {
return this.friendIds().has(userId);
}
async toggleFriend(userId: string): Promise<void> {
if (this.isFriend(userId)) {
await this.removeFriend(userId);
return;
}
await this.addFriend(userId);
}
private async loadForOwner(ownerId: string | null, force = false): Promise<void> {
if (!ownerId) {
this.loadedOwnerId = null;
this.friendsSignal.set([]);
return;
}
if (!force && this.loadedOwnerId === ownerId) {
return;
}
this.loadedOwnerId = ownerId;
this.friendsSignal.set(await this.repository.loadFriends(ownerId));
}
private async requireOwnerId(): Promise<string> {
const ownerId = this.currentUser()?.oderId || this.currentUser()?.id;
if (!ownerId) {
throw new Error('Cannot manage friends without a current user.');
}
await this.loadForOwner(ownerId);
return ownerId;
}
}

View File

@@ -1,23 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { OfflineQueueRepository } from '../../infrastructure/offline-queue.repository';
@Injectable({ providedIn: 'root' })
export class OfflineMessageQueueService {
private readonly repository = inject(OfflineQueueRepository);
enqueue(ownerId: string, messageId: string): Promise<void> {
return this.repository.enqueue(ownerId, messageId);
}
retryPending(ownerId: string): Promise<string[]> {
return this.repository.load(ownerId);
}
markDelivered(ownerId: string, messageId: string): Promise<void> {
return this.repository.remove(ownerId, messageId);
}
clear(ownerId: string): Promise<void> {
return this.repository.clear(ownerId);
}
}

View File

@@ -1,42 +0,0 @@
import { OfflineQueueRepository } from '../../infrastructure/offline-queue.repository';
describe('OfflineMessageQueueService storage contract', () => {
let repository: OfflineQueueRepository;
beforeEach(() => {
installLocalStorageMock();
repository = new OfflineQueueRepository();
});
it('should enqueue messages', async () => {
await repository.enqueue('alice', 'message-1');
await repository.enqueue('alice', 'message-1');
expect(await repository.load('alice')).toEqual(['message-1']);
});
it('should retry on reconnect', async () => {
await repository.enqueue('alice', 'message-1');
await repository.enqueue('alice', 'message-2');
expect(await repository.load('alice')).toEqual(['message-1', 'message-2']);
});
it('should clear delivered messages', async () => {
await repository.enqueue('alice', 'message-1');
await repository.remove('alice', 'message-1');
expect(await repository.load('alice')).toEqual([]);
});
});
function installLocalStorageMock(): void {
const values = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
clear: () => values.clear()
});
}

View File

@@ -1,105 +0,0 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import {
Subject,
filter,
type Observable
} from 'rxjs';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { selectAllUsers } from '../../../../store/users/users.selectors';
import type { ChatEvent, User } from '../../../../shared-kernel';
@Injectable({ providedIn: 'root' })
export class PeerDeliveryService {
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly store = inject(Store);
private readonly users = this.store.selectSignal(selectAllUsers);
private readonly networkRestoredSubject = new Subject<void>();
readonly directMessageEvents$: Observable<ChatEvent> = this.webrtc.onMessageReceived.pipe(
filter((event) => event.type === 'direct-message' || event.type === 'direct-message-status' || event.type === 'direct-message-mutation')
);
readonly peerConnected$ = this.webrtc.onPeerConnected;
readonly networkRestored$ = this.networkRestoredSubject.asObservable();
constructor() {
this.installNetworkTestHooks();
}
sendViaWebRTC(recipientId: string, event: ChatEvent): boolean {
if (this.isOfflineOverrideEnabled()) {
return false;
}
const peerId = this.resolvePeerId(recipientId);
if (!peerId) {
return false;
}
this.webrtc.sendToPeer(peerId, event);
return true;
}
handleAck(recipientId: string, event: ChatEvent): boolean {
return this.sendViaWebRTC(recipientId, event);
}
requestUserAvatar(recipientId: string): boolean {
return this.sendViaWebRTC(recipientId, {
type: 'user-avatar-request',
oderId: recipientId
});
}
syncOnReconnect(onReconnect: () => void): void {
this.peerConnected$.subscribe(() => onReconnect());
}
private resolvePeerId(recipientId: string): string | null {
const connectedPeerIds = new Set(this.webrtc.getConnectedPeers());
if (connectedPeerIds.has(recipientId)) {
return recipientId;
}
const user = this.users().find((candidate: User) =>
candidate.id === recipientId || candidate.oderId === recipientId || candidate.peerId === recipientId
);
const candidates = [
user?.oderId,
user?.peerId,
user?.id
].filter((candidate): candidate is string => !!candidate);
return candidates.find((candidate) => connectedPeerIds.has(candidate)) ?? null;
}
private isOfflineOverrideEnabled(): boolean {
return typeof window !== 'undefined'
&& !!(window as Window & { metoyouDmNetworkOffline?: boolean }).metoyouDmNetworkOffline;
}
private installNetworkTestHooks(): void {
if (typeof window === 'undefined') {
return;
}
const testWindow = window as Window & {
simulateOffline?: () => void;
simulateOnline?: () => void;
metoyouDmNetworkOffline?: boolean;
};
testWindow.simulateOffline = () => {
testWindow.metoyouDmNetworkOffline = true;
};
testWindow.simulateOnline = () => {
testWindow.metoyouDmNetworkOffline = false;
this.networkRestoredSubject.next();
};
}
}

View File

@@ -1,91 +0,0 @@
import type {
DirectMessage,
DirectMessageConversation,
DirectMessageParticipant,
DirectMessageStatus
} from '../models/direct-message.model';
const STATUS_ORDER: Record<DirectMessageStatus, number> = {
QUEUED: 0,
SENT: 1,
DELIVERED: 2,
ACKNOWLEDGED: 3
};
export function getDirectConversationId(firstUserId: string, secondUserId: string): string {
return `dm-${[firstUserId, secondUserId]
.map((userId) => encodeURIComponent(userId.trim()))
.sort()
.join('--')}`;
}
export function advanceDirectMessageStatus(
currentStatus: DirectMessageStatus,
incomingStatus: DirectMessageStatus
): DirectMessageStatus {
return STATUS_ORDER[incomingStatus] > STATUS_ORDER[currentStatus]
? incomingStatus
: currentStatus;
}
export function createDirectConversation(
currentUser: DirectMessageParticipant,
peer: DirectMessageParticipant,
now: number
): DirectMessageConversation {
const participants = [currentUser.userId, peer.userId].sort();
return {
id: getDirectConversationId(currentUser.userId, peer.userId),
participants,
participantProfiles: {
[currentUser.userId]: currentUser,
[peer.userId]: peer
},
messages: [],
lastMessageAt: now,
unreadCount: 0
};
}
export function upsertDirectMessage(
conversation: DirectMessageConversation,
message: DirectMessage,
incrementUnread: boolean
): DirectMessageConversation {
const existingIndex = conversation.messages.findIndex((entry) => entry.id === message.id);
const messages = [...conversation.messages];
if (existingIndex >= 0) {
const existing = messages[existingIndex];
messages[existingIndex] = {
...existing,
...message,
status: advanceDirectMessageStatus(existing.status, message.status)
};
} else {
messages.push(message);
}
messages.sort((firstMessage, secondMessage) => firstMessage.timestamp - secondMessage.timestamp);
return {
...conversation,
messages,
lastMessageAt: Math.max(conversation.lastMessageAt, message.timestamp),
unreadCount: incrementUnread ? conversation.unreadCount + 1 : conversation.unreadCount
};
}
export function updateMessageStatusInConversation(
conversation: DirectMessageConversation,
messageId: string,
status: DirectMessageStatus
): DirectMessageConversation {
const messages = conversation.messages.map((message) => message.id === messageId
? { ...message, status: advanceDirectMessageStatus(message.status, status) }
: message);
return { ...conversation, messages };
}

View File

@@ -1,39 +0,0 @@
import type { User } from '../../../../shared-kernel';
import type { DirectMessage, DirectMessageParticipant } from '../../../../shared-kernel';
export type {
DirectMessage,
DirectMessageEventPayload,
DirectMessageMutationEventPayload,
DirectMessageParticipant,
DirectMessageStatus,
DirectMessageStatusEventPayload
} from '../../../../shared-kernel';
export interface DirectMessageConversation {
id: string;
participants: string[];
participantProfiles: Record<string, DirectMessageParticipant>;
messages: DirectMessage[];
lastMessageAt: number;
unreadCount: number;
}
export interface Friend {
userId: string;
addedAt: number;
}
export function toDirectMessageParticipant(user: User): DirectMessageParticipant {
return {
userId: user.oderId || user.id,
username: user.username,
displayName: user.displayName || user.username,
description: user.description,
avatarUrl: user.avatarUrl,
avatarHash: user.avatarHash,
avatarMime: user.avatarMime,
avatarUpdatedAt: user.avatarUpdatedAt,
profileUpdatedAt: user.profileUpdatedAt
};
}

View File

@@ -1,112 +0,0 @@
<section
appThemeNode="dmChatSurface"
class="chat-layout relative h-full bg-background"
>
<header
appThemeNode="dmChatHeader"
class="flex h-14 shrink-0 items-center gap-3 border-b border-border px-4"
>
<app-user-avatar
[name]="peerName()"
[avatarUrl]="peerUser()?.avatarUrl"
[status]="peerUser()?.status"
[showStatusBadge]="true"
size="md"
/>
<div class="min-w-0">
<h1 class="truncate text-base font-semibold text-foreground">{{ peerName() }}</h1>
<p class="text-xs text-muted-foreground">Direct Message</p>
</div>
</header>
@if (conversation()) {
<div
appThemeNode="dmMessageRegion"
class="absolute inset-x-0 bottom-0 top-14"
>
<app-chat-message-list
[allMessages]="chatMessages()"
[channelMessages]="chatMessages()"
[loading]="false"
[syncing]="false"
[currentUserId]="currentUserId()"
[isAdmin]="false"
[bottomPadding]="composerBottomPadding()"
[conversationKey]="conversationKey()"
[userLookupOverrides]="participantUsers()"
(replyRequested)="setReplyTo($event)"
(deleteRequested)="handleDeleteRequested($event)"
(editSaved)="handleEditSaved($event)"
(reactionAdded)="handleReactionAdded($event)"
(reactionToggled)="handleReactionToggled($event)"
(downloadRequested)="downloadAttachment($event)"
(imageOpened)="openLightbox($event)"
(imageContextMenuRequested)="openImageContextMenu($event)"
(embedRemoved)="handleEmbedRemoved($event)"
/>
@for (messageStatus of messageStatuses(); track messageStatus.id) {
<span
data-testid="message-status"
class="sr-only"
>{{ messageStatus.status }}</span
>
}
</div>
<div
appThemeNode="chatComposerBar"
class="chat-bottom-bar absolute bottom-0 left-0 right-2 z-10 bg-background/85 backdrop-blur-md"
>
<app-chat-message-composer
[replyTo]="replyTo()"
[showKlipyGifPicker]="showGifPicker()"
[klipyEnabled]="klipyEnabled()"
[klipySignalSource]="null"
[textareaTestId]="'dm-input'"
(messageSubmitted)="handleMessageSubmitted($event)"
(replyCleared)="clearReply()"
(heightChanged)="composerBottomPadding.set($event + 20)"
(klipyGifPickerToggleRequested)="toggleGifPicker()"
/>
</div>
@if (showGifPicker()) {
<div
class="fixed inset-0 z-[89]"
tabindex="0"
role="button"
aria-label="Close GIF picker"
(click)="closeGifPicker()"
(keydown.enter)="closeGifPicker()"
(keydown.space)="closeGifPicker()"
></div>
<div class="pointer-events-none fixed inset-0 z-[90]">
<div
appThemeNode="chatGifPickerSurface"
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
[style.bottom.px]="composerBottomPadding() + 8"
[style.right.px]="gifPickerAnchorRight()"
>
<app-klipy-gif-picker
(gifSelected)="handleGifSelected($event)"
(closed)="closeGifPicker()"
/>
</div>
</div>
}
<app-chat-message-overlays
[lightboxAttachment]="lightboxAttachment()"
[imageContextMenu]="imageContextMenu()"
(lightboxClosed)="closeLightbox()"
(contextMenuClosed)="closeImageContextMenu()"
(downloadRequested)="downloadAttachment($event)"
(copyRequested)="copyImageToClipboard($event)"
(imageContextMenuRequested)="openImageContextMenu($event)"
/>
} @else {
<div class="flex flex-1 items-center justify-center px-6 text-sm text-muted-foreground">Select a direct message from the rail.</div>
}
</section>

View File

@@ -1,457 +0,0 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
effect,
HostListener,
inject,
signal,
ViewChild
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { UserAvatarComponent } from '../../../../shared';
import { Attachment, AttachmentFacade } from '../../../attachment';
import { ThemeNodeDirective } from '../../../theme';
import { DirectMessageService } from '../../application/services/direct-message.service';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import {
ChatMessageComposerSubmitEvent,
ChatMessageComposerComponent,
ChatMessageDeleteEvent,
ChatMessageEditEvent,
ChatMessageImageContextMenuEvent,
ChatMessageListComponent,
ChatMessageOverlaysComponent,
ChatMessageReactionEvent,
ChatMessageReplyEvent,
hasDedicatedChatEmbed,
KlipyGif,
KlipyGifPickerComponent,
KlipyService,
LinkMetadataService,
type ChatMessageEmbedRemoveEvent
} from '../../../chat';
import type {
DirectMessageStatus,
LinkMetadata,
Message,
User
} from '../../../../shared-kernel';
interface DmStatusLabel {
id: string;
status: DirectMessageStatus;
}
@Component({
selector: 'app-dm-chat',
standalone: true,
imports: [
CommonModule,
ChatMessageComposerComponent,
ChatMessageListComponent,
ChatMessageOverlaysComponent,
KlipyGifPickerComponent,
ThemeNodeDirective,
UserAvatarComponent
],
templateUrl: './dm-chat.component.html',
host: {
class: 'block h-full'
}
})
export class DmChatComponent {
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
private readonly route = inject(ActivatedRoute);
private readonly store = inject(Store);
private readonly electronBridge = inject(ElectronBridgeService);
private readonly attachments = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService);
private readonly linkMetadata = inject(LinkMetadataService);
readonly directMessages = inject(DirectMessageService);
readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly allUsers = this.store.selectSignal(selectAllUsers);
readonly showGifPicker = signal(false);
readonly composerBottomPadding = signal(140);
readonly gifPickerAnchorRight = signal(16);
readonly linkMetadataByMessageId = signal<Record<string, LinkMetadata[]>>({});
readonly replyTo = signal<Message | null>(null);
readonly lightboxAttachment = signal<Attachment | null>(null);
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
initialValue: this.route.snapshot.paramMap.get('conversationId')
});
readonly currentUserId = computed(() => this.currentUser()?.oderId || this.currentUser()?.id || '');
readonly conversation = this.directMessages.selectedConversation;
readonly klipyEnabled = computed(() => this.klipy.isEnabled(null));
readonly conversationKey = computed(() => this.conversation()?.id ?? 'dm:none');
readonly peerUser = computed(() => {
const conversation = this.conversation();
return conversation ? this.peerUserFor(conversation) : null;
});
readonly participantUsers = computed<User[]>(() => {
const conversation = this.conversation();
const knownUsers = this.allUsers();
if (!conversation) {
return [];
}
return conversation.participants.map((participantId) => {
const knownUser = knownUsers.find((user) => user.id === participantId || user.oderId === participantId);
const participant = conversation.participantProfiles[participantId];
return (
knownUser ?? {
id: participantId,
oderId: participantId,
username: participant?.username || participant?.displayName || participantId,
displayName: participant?.displayName || participant?.username || participantId,
description: participant?.description,
profileUpdatedAt: participant?.profileUpdatedAt,
avatarUrl: participant?.avatarUrl,
avatarHash: participant?.avatarHash,
avatarMime: participant?.avatarMime,
avatarUpdatedAt: participant?.avatarUpdatedAt,
status: 'disconnected',
role: 'member',
joinedAt: 0
}
);
});
});
readonly messageStatuses = computed<DmStatusLabel[]>(() => {
const conversation = this.conversation();
const currentUserId = this.currentUserId();
if (!conversation || !currentUserId) {
return [];
}
return conversation.messages
.filter((message) => message.senderId === currentUserId)
.map((message) => ({
id: message.id,
status: message.status
}));
});
readonly chatMessages = computed<Message[]>(() => {
const conversation = this.conversation();
const metadataByMessageId = this.linkMetadataByMessageId();
if (!conversation) {
return [];
}
return conversation.messages.map((message) => {
const participant = conversation.participantProfiles[message.senderId];
const knownUser = this.participantUsers().find((user) => user.id === message.senderId || user.oderId === message.senderId);
return {
id: message.id,
roomId: conversation.id,
channelId: 'direct-message',
senderId: message.senderId,
senderName: knownUser?.displayName || participant?.displayName || (message.senderId === this.currentUserId() ? 'You' : message.senderId),
content: message.content,
timestamp: message.timestamp,
editedAt: message.editedAt,
reactions: message.reactions ?? [],
isDeleted: !!message.isDeleted,
replyToId: message.replyToId,
linkMetadata: metadataByMessageId[message.id]
};
});
});
readonly peerName = computed(() => {
const conversation = this.conversation();
const currentUserId = this.currentUserId();
const peerId = conversation?.participants.find((participantId) => participantId !== currentUserId);
return peerId ? conversation?.participantProfiles[peerId]?.displayName || peerId : 'Direct Message';
});
constructor() {
effect(() => {
const conversationId = this.routeConversationId();
if (conversationId) {
void this.directMessages.openConversation(conversationId);
}
});
effect(() => {
void this.routeConversationId();
void this.klipy.refreshAvailability(null);
});
effect(() => {
void this.refreshLinkMetadata(this.chatMessages());
});
effect(() => {
const conversation = this.conversation();
const peerUser = this.peerUser();
if (conversation && !peerUser?.avatarUrl) {
this.directMessages.requestPeerAvatarSync(conversation.id);
}
});
}
@HostListener('window:resize')
onWindowResize(): void {
if (this.showGifPicker()) {
this.syncGifPickerAnchor();
}
}
handleMessageSubmitted(event: ChatMessageComposerSubmitEvent): void {
const conversation = this.conversation();
if (!conversation || (!event.content.trim() && event.pendingFiles.length === 0)) {
return;
}
const content = event.content.trim() || event.pendingFiles.map((file) => file.name).join('\n');
void this.directMessages.sendMessage(conversation.id, content, this.replyTo()?.id).then((message) => {
this.replyTo.set(null);
if (event.pendingFiles.length > 0) {
this.attachments.publishAttachments(message.id, event.pendingFiles, this.currentUserId() || undefined);
}
});
}
setReplyTo(message: ChatMessageReplyEvent): void {
this.replyTo.set(message);
}
clearReply(): void {
this.replyTo.set(null);
}
handleEditSaved(event: ChatMessageEditEvent): void {
const conversation = this.conversation();
if (conversation) {
void this.directMessages.editMessage(conversation.id, event.messageId, event.content);
}
}
handleDeleteRequested(message: ChatMessageDeleteEvent): void {
const conversation = this.conversation();
if (conversation && message.senderId === this.currentUserId()) {
void this.directMessages.deleteMessage(conversation.id, message.id);
}
}
handleReactionAdded(event: ChatMessageReactionEvent): void {
const conversation = this.conversation();
if (conversation) {
void this.directMessages.addReaction(conversation.id, event.messageId, event.emoji);
}
}
handleReactionToggled(event: ChatMessageReactionEvent): void {
const conversation = this.conversation();
if (conversation) {
void this.directMessages.toggleReaction(conversation.id, event.messageId, event.emoji);
}
}
toggleGifPicker(): void {
if (!this.klipyEnabled()) {
return;
}
this.showGifPicker.update((visible) => !visible);
if (this.showGifPicker()) {
requestAnimationFrame(() => this.syncGifPickerAnchor());
}
}
closeGifPicker(): void {
this.showGifPicker.set(false);
}
handleGifSelected(gif: KlipyGif): void {
this.closeGifPicker();
this.composer?.handleKlipyGifSelected(gif);
}
handleEmbedRemoved(event: ChatMessageEmbedRemoveEvent): void {
this.linkMetadataByMessageId.update((metadataByMessageId) => ({
...metadataByMessageId,
[event.messageId]: (metadataByMessageId[event.messageId] ?? []).filter((metadata) => metadata.url !== event.url)
}));
}
openLightbox(attachment: Attachment): void {
if (attachment.available && attachment.objectUrl) {
this.lightboxAttachment.set(attachment);
}
}
closeLightbox(): void {
this.lightboxAttachment.set(null);
}
openImageContextMenu(event: ChatMessageImageContextMenuEvent): void {
this.imageContextMenu.set(event);
}
closeImageContextMenu(): void {
this.imageContextMenu.set(null);
}
async downloadAttachment(attachment: Attachment): Promise<void> {
if (!attachment.available || !attachment.objectUrl) {
return;
}
const electronApi = this.electronBridge.getApi();
if (electronApi) {
const blob = await this.getAttachmentBlob(attachment);
if (blob) {
try {
const result = await electronApi.saveFileAs(attachment.filename, await this.blobToBase64(blob));
if (result.saved || result.cancelled) {
return;
}
} catch {
/* fall back to browser download */
}
}
}
const link = document.createElement('a');
link.href = attachment.objectUrl;
link.download = attachment.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
async copyImageToClipboard(attachment: Attachment): Promise<void> {
this.closeImageContextMenu();
if (!attachment.objectUrl) {
return;
}
try {
const response = await fetch(attachment.objectUrl);
const blob = await response.blob();
await navigator.clipboard.write([new ClipboardItem({ [blob.type || 'image/png']: blob })]);
} catch {
/* ignore */
}
}
private syncGifPickerAnchor(): void {
const triggerRect = this.composer?.getKlipyTriggerRect();
if (!triggerRect) {
this.gifPickerAnchorRight.set(16);
return;
}
const viewportWidth = window.innerWidth;
const popupWidth = viewportWidth >= 1280 ? 52 * 16 : viewportWidth >= 768 ? 42 * 16 : 34 * 16;
const preferredRight = viewportWidth - triggerRect.right;
const minRight = 16;
const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16);
this.gifPickerAnchorRight.set(Math.min(Math.max(Math.round(preferredRight), minRight), maxRight));
}
private async refreshLinkMetadata(messages: Message[]): Promise<void> {
const metadataByMessageId = this.linkMetadataByMessageId();
for (const message of messages) {
if (metadataByMessageId[message.id]?.length) {
continue;
}
const urls = this.linkMetadata.extractUrls(message.content).filter((url) => !hasDedicatedChatEmbed(url));
if (urls.length === 0) {
continue;
}
const metadata = (await this.linkMetadata.fetchAllMetadata(urls)).filter((entry) => !entry.failed);
if (metadata.length === 0) {
continue;
}
this.linkMetadataByMessageId.update((currentMetadata) => ({
...currentMetadata,
[message.id]: metadata
}));
}
}
private async getAttachmentBlob(attachment: Attachment): Promise<Blob | null> {
if (!attachment.objectUrl) {
return null;
}
try {
const response = await fetch(attachment.objectUrl);
return await response.blob();
} catch {
return null;
}
}
private blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('Failed to encode attachment'));
return;
}
const [, base64 = ''] = reader.result.split(',', 2);
resolve(base64);
};
reader.onerror = () => reject(reader.error ?? new Error('Failed to read attachment'));
reader.readAsDataURL(blob);
});
}
private peerUserFor(conversation: NonNullable<ReturnType<typeof this.conversation>>): User | null {
const currentUserId = this.currentUserId();
const peerId = conversation.participants.find((participantId) => participantId !== currentUserId);
if (!peerId) {
return null;
}
return this.participantUsers().find((user) => user.id === peerId || user.oderId === peerId) ?? null;
}
}

View File

@@ -1,47 +0,0 @@
<div
class="group flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30"
[class.flex-row-reverse]="isOutgoing()"
>
<div class="grid h-9 w-9 flex-shrink-0 place-items-center rounded-full bg-secondary text-xs font-semibold text-foreground">
{{ isOutgoing() ? 'You'[0] : message().senderId[0] || '?' }}
</div>
<div
class="min-w-0 max-w-3xl flex-1"
[class.text-right]="isOutgoing()"
>
<div
class="mb-0.5 flex items-baseline gap-2"
[class.justify-end]="isOutgoing()"
>
<span class="text-sm font-semibold text-foreground">{{ isOutgoing() ? 'You' : message().senderId }}</span>
<span class="text-xs text-muted-foreground">{{ message().timestamp | date: 'shortTime' }}</span>
</div>
@if (requiresRichMarkdown(message().content)) {
<div class="mt-1 inline-block max-w-full rounded-lg bg-card px-3 py-2 text-left text-sm text-foreground">
<app-chat-message-markdown [content]="message().content" />
</div>
} @else {
<p
class="mt-1 inline-block max-w-full whitespace-pre-wrap break-words rounded-lg bg-card px-3 py-2 text-left text-sm leading-5 text-foreground"
>
{{ message().content }}
</p>
}
@if (isOutgoing()) {
<span
data-testid="message-status"
class="mt-1 inline-flex items-center gap-1 text-[10px] font-semibold uppercase text-muted-foreground"
>
<ng-icon
[name]="statusIcon(message().status)"
class="h-3 w-3"
[class.fill-current]="message().status === 'ACKNOWLEDGED'"
/>
{{ message().status }}
</span>
}
</div>
</div>

View File

@@ -1,57 +0,0 @@
import {
Component,
computed,
input
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideCheck,
lucideCheckCheck,
lucideClock3
} from '@ng-icons/lucide';
import { ChatMessageMarkdownComponent } from '../../../chat';
import type { DirectMessage } from '../../domain/models/direct-message.model';
const RICH_MARKDOWN_PATTERNS = [
/!\[[^\]]*\]\([^\s)]+\)/,
/https?:\/\/[^\s)]+?\.(?:png|jpe?g|gif|webp|svg)(?:\?[^\s)]*)?/i,
/\[[^\]]+\]\([^\s)]+\)/
];
@Component({
selector: 'app-dm-message',
standalone: true,
imports: [
CommonModule,
NgIcon,
ChatMessageMarkdownComponent
],
viewProviders: [provideIcons({ lucideCheck, lucideCheckCheck, lucideClock3 })],
templateUrl: './dm-message.component.html'
})
export class DmMessageComponent {
readonly message = input.required<DirectMessage>();
readonly currentUserId = input.required<string>();
readonly isOutgoing = computed(() => this.message().senderId === this.currentUserId());
requiresRichMarkdown(content: string): boolean {
return RICH_MARKDOWN_PATTERNS.some((pattern) => pattern.test(content));
}
statusIcon(status: DirectMessage['status']): string {
if (status === 'QUEUED') {
return 'lucideClock3';
}
if (status === 'SENT') {
return 'lucideCheck';
}
return 'lucideCheckCheck';
}
}

View File

@@ -1,74 +0,0 @@
<!-- eslint-disable @angular-eslint/template/prefer-ngsrc -->
<div class="mt-2 flex w-full flex-col items-center gap-2 border-b border-border/70 pb-2">
<div class="group/server relative flex w-full justify-center">
<button
type="button"
class="relative z-10 flex h-10 w-10 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent text-muted-foreground transition-[border-radius,box-shadow,background-color,color] duration-100 hover:rounded-lg hover:bg-card hover:text-foreground"
title="Direct Messages"
aria-label="Direct Messages"
[ngClass]="isOnDirectMessages() ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10 text-foreground' : 'rounded-xl bg-card'"
[attr.aria-current]="isOnDirectMessages() ? 'page' : null"
(click)="openDirectMessages()"
>
<ng-icon
name="lucideMessageCircle"
class="h-4 w-4"
/>
@if (directMessages.totalUnreadCount() > 0) {
<span class="dm-rail-slide-in absolute -right-1 -top-1 h-3 w-3 rounded-full bg-amber-400 ring-2 ring-card"></span>
}
</button>
</div>
@for (item of railItems(); track item.id) {
<div class="group/server relative flex w-full justify-center">
<button
type="button"
class="relative z-10 flex h-10 w-10 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent transition-[border-radius,box-shadow,background-color] duration-100 hover:rounded-lg hover:bg-card"
[class.dm-rail-slide-in]="!item.isExiting"
[class.dm-rail-slide-out]="item.isExiting"
[class.pointer-events-none]="item.isExiting"
[ngClass]="isSelectedItem(item) ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10' : 'rounded-xl bg-card'"
[title]="item.label"
[attr.aria-current]="isSelectedItem(item) ? 'page' : null"
(click)="openItem(item)"
>
<div class="h-full w-full overflow-hidden rounded-[inherit]">
@if (item.avatarUrl) {
<img
[src]="item.avatarUrl"
[alt]="item.label"
class="h-full w-full object-cover"
/>
} @else {
<div
class="flex h-full w-full items-center justify-center bg-secondary transition-colors"
[class.bg-primary/15]="isSelectedItem(item)"
>
<span
class="text-sm font-semibold text-muted-foreground transition-colors"
[class.text-foreground]="isSelectedItem(item)"
>{{ initial(item.label) }}</span
>
</div>
}
</div>
<span
class="absolute -bottom-1 -right-1 grid h-4 w-4 place-items-center rounded-full bg-secondary text-muted-foreground shadow-sm ring-2 ring-card"
>
<ng-icon
name="lucideUser"
class="h-2.5 w-2.5"
/>
</span>
@if (!item.isExiting && item.unreadCount > 0) {
<span class="absolute -right-1 -top-1 min-w-5 rounded-full bg-amber-400 px-1.5 py-0.5 text-[10px] font-semibold text-black shadow-sm">
{{ formatUnreadCount(item.unreadCount) }}
</span>
}
</button>
</div>
}
</div>

View File

@@ -1,31 +0,0 @@
@keyframes dm-rail-slide-in {
0% {
opacity: 0;
transform: translateX(-0.5rem) scale(0.94);
}
100% {
opacity: 1;
transform: translateX(0) scale(1);
}
}
@keyframes dm-rail-slide-out {
0% {
opacity: 1;
transform: translateX(0) scale(1);
}
100% {
opacity: 0;
transform: translateX(-0.5rem) scale(0.94);
}
}
.dm-rail-slide-in {
animation: dm-rail-slide-in 140ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.dm-rail-slide-out {
animation: dm-rail-slide-out 140ms cubic-bezier(0.4, 0, 1, 1) both;
}

View File

@@ -1,230 +0,0 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
effect,
inject,
OnDestroy,
signal
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideMessageCircle, lucideUser } from '@ng-icons/lucide';
import { filter, map } from 'rxjs';
import { DirectMessageService } from '../../application/services/direct-message.service';
import { FriendService } from '../../application/services/friend.service';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import type { DirectMessageConversation } from '../../domain/models/direct-message.model';
import type { User } from '../../../../shared-kernel';
interface DmRailItem {
id: string;
label: string;
avatarUrl?: string;
conversation: DirectMessageConversation | null;
isExiting: boolean;
user: User | null;
unreadCount: number;
}
const EXIT_ANIMATION_MS = 160;
@Component({
selector: 'app-dm-rail',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [provideIcons({ lucideMessageCircle, lucideUser })],
templateUrl: './dm-rail.component.html',
styleUrl: './dm-rail.component.scss'
})
export class DmRailComponent implements OnDestroy {
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly exitTimers = new Map<string, ReturnType<typeof setTimeout>>();
readonly directMessages = inject(DirectMessageService);
readonly friends = inject(FriendService);
readonly users = this.store.selectSignal(selectAllUsers);
readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly currentUserId = computed(() => this.currentUser()?.oderId || this.currentUser()?.id || '');
readonly activeConversationId = toSignal(
this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
map((navigationEvent) => this.getConversationIdFromUrl(navigationEvent.urlAfterRedirects))
),
{ initialValue: this.getConversationIdFromUrl(this.router.url) }
);
readonly friendUsers = computed(() => this.users().filter((user) =>
this.friends.isFriend(user.oderId || user.id) && (user.oderId || user.id) !== this.currentUserId()
));
readonly railItems = signal<DmRailItem[]>([]);
readonly unreadRailItems = computed<DmRailItem[]>(() => {
const currentUserId = this.currentUserId();
const items = new Map<string, DmRailItem>();
for (const conversation of this.directMessages.conversations()) {
const peerId = conversation.participants.find((participantId) => participantId !== currentUserId);
if (!peerId) {
continue;
}
const knownUser = this.users().find((user) => user.id === peerId || user.oderId === peerId) ?? null;
const profile = conversation.participantProfiles[peerId];
items.set(peerId, {
id: peerId,
label: knownUser?.displayName || profile?.displayName || peerId,
avatarUrl: knownUser?.avatarUrl || profile?.avatarUrl,
conversation,
isExiting: false,
user: knownUser,
unreadCount: conversation.unreadCount
});
}
for (const user of this.friendUsers()) {
const userId = user.oderId || user.id;
if (items.has(userId)) {
continue;
}
items.set(userId, {
id: userId,
label: user.displayName || user.username,
avatarUrl: user.avatarUrl,
conversation: null,
isExiting: false,
user,
unreadCount: 0
});
}
return Array.from(items.values()).filter((item) => item.unreadCount > 0);
});
readonly isOnDirectMessages = toSignal(
this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/dm'))
),
{ initialValue: this.router.url.startsWith('/dm') }
);
constructor() {
effect(() => {
const unreadItems = this.unreadRailItems();
queueMicrotask(() => this.syncRailItems(unreadItems));
});
}
ngOnDestroy(): void {
for (const timer of this.exitTimers.values()) {
clearTimeout(timer);
}
this.exitTimers.clear();
}
async openConversation(conversation: DirectMessageConversation): Promise<void> {
await this.router.navigate(['/dm', conversation.id]);
}
async openFriend(user: User): Promise<void> {
const conversation = await this.directMessages.createConversation(user);
await this.router.navigate(['/dm', conversation.id]);
}
async openItem(item: DmRailItem): Promise<void> {
if (item.conversation) {
await this.openConversation(item.conversation);
return;
}
if (item.user) {
await this.openFriend(item.user);
}
}
openDirectMessages(): void {
void this.router.navigate(['/dm']);
}
titleFor(conversation: DirectMessageConversation): string {
const peerId = conversation.participants.find((participantId) => participantId !== this.currentUserId());
return peerId ? conversation.participantProfiles[peerId]?.displayName || peerId : 'DM';
}
initial(label: string): string {
return label.trim()[0]?.toUpperCase() || '?';
}
conversationForFriend(user: User): DirectMessageConversation | null {
const userId = user.oderId || user.id;
return this.directMessages.conversations().find((conversation) => conversation.participants.includes(userId)) ?? null;
}
isSelectedConversation(conversation: DirectMessageConversation): boolean {
return this.activeConversationId() === conversation.id;
}
isSelectedFriend(user: User): boolean {
const conversation = this.conversationForFriend(user);
return !!conversation && this.isSelectedConversation(conversation);
}
isSelectedItem(item: DmRailItem): boolean {
return !!item.conversation && this.isSelectedConversation(item.conversation);
}
formatUnreadCount(count: number): string {
return count > 99 ? '99+' : String(count);
}
private getConversationIdFromUrl(url: string): string | null {
const match = /^\/dm\/([^/?#]+)/.exec(url);
return match ? decodeURIComponent(match[1]) : null;
}
private syncRailItems(unreadItems: DmRailItem[]): void {
const unreadById = new Map(unreadItems.map((item) => [item.id, item]));
const currentItems = this.railItems();
const nextItems: DmRailItem[] = [];
for (const item of unreadItems) {
const timer = this.exitTimers.get(item.id);
if (timer) {
clearTimeout(timer);
this.exitTimers.delete(item.id);
}
nextItems.push({ ...item, isExiting: false });
}
for (const item of currentItems) {
if (unreadById.has(item.id)) {
continue;
}
nextItems.push({ ...item, isExiting: true });
if (!this.exitTimers.has(item.id)) {
this.exitTimers.set(item.id, setTimeout(() => {
this.exitTimers.delete(item.id);
this.railItems.update((items) => items.filter((entry) => entry.id !== item.id));
}, EXIT_ANIMATION_MS));
}
}
this.railItems.set(nextItems);
}
}

View File

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

View File

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

View File

@@ -1,14 +0,0 @@
<button
type="button"
[attr.data-testid]="'friend-button-' + userId()"
class="grid h-8 w-8 place-items-center rounded-md border border-border bg-secondary text-foreground transition-colors hover:bg-secondary/80"
[attr.aria-pressed]="isFriend()"
[attr.aria-label]="isFriend() ? 'Remove friend' : 'Add friend'"
[title]="isFriend() ? 'Remove friend' : 'Add friend'"
(click)="toggle($event)"
>
<ng-icon
[name]="isFriend() ? 'lucideUserCheck' : 'lucideUserPlus'"
class="h-4 w-4"
/>
</button>

View File

@@ -1,31 +0,0 @@
import {
Component,
computed,
inject,
input
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideUserCheck, lucideUserPlus } from '@ng-icons/lucide';
import { FriendService } from '../../application/services/friend.service';
import type { User } from '../../../../shared-kernel';
@Component({
selector: 'app-friend-button',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [provideIcons({ lucideUserCheck, lucideUserPlus })],
templateUrl: './friend-button.component.html'
})
export class FriendButtonComponent {
private readonly friends = inject(FriendService);
readonly user = input.required<User>();
readonly userId = computed(() => this.user().oderId || this.user().id);
readonly isFriend = computed(() => this.friends.isFriend(this.userId()));
toggle(event: Event): void {
event.stopPropagation();
void this.friends.toggleFriend(this.userId());
}
}

View File

@@ -1,119 +0,0 @@
<section class="min-h-full p-3">
<div class="mb-2 flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-foreground">People</h3>
<span class="text-xs text-muted-foreground">{{ matchingUsers().length }}</span>
</div>
@if (friendResults().length > 0) {
<div class="mb-3">
<div class="mb-1 flex items-center justify-between">
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Friends</h4>
<span class="text-xs text-muted-foreground">{{ friendResults().length }}</span>
</div>
<div class="space-y-1.5">
@for (user of friendResults(); track user.id) {
<div
class="group flex items-center gap-2 rounded-lg border border-emerald-500/25 bg-emerald-500/10 p-2"
[attr.data-testid]="'friend-card-' + userKey(user)"
>
<app-user-avatar
[avatarUrl]="user.avatarUrl"
[name]="user.displayName"
[showStatusBadge]="true"
[status]="user.status"
size="sm"
/>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-foreground">{{ user.displayName }}</p>
<p class="truncate text-xs text-muted-foreground">{{ user.username }}</p>
</div>
<div
class="pointer-events-none flex scale-95 shrink-0 items-center gap-2 opacity-0 transition-[opacity,transform] duration-75 ease-out group-hover:pointer-events-auto group-hover:scale-100 group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:scale-100 group-focus-within:opacity-100"
>
<app-friend-button [user]="user" />
<button
type="button"
[attr.data-testid]="'message-friend-' + userKey(user)"
class="grid h-8 w-8 place-items-center rounded-md bg-primary text-primary-foreground transition-colors hover:bg-primary/90"
[attr.aria-label]="'Message ' + user.displayName"
[title]="'Message ' + user.displayName"
(click)="messageUser(user)"
>
<ng-icon
name="lucideMessageCircle"
class="h-4 w-4"
/>
</button>
</div>
</div>
}
</div>
</div>
}
@if (friendResults().length > 0) {
<div class="mb-1 flex items-center justify-between">
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Others</h4>
<span class="text-xs text-muted-foreground">{{ results().length }}</span>
</div>
}
@if (matchingUsers().length === 0) {
<div class="flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-3 text-sm text-muted-foreground">
<ng-icon
name="lucideSearch"
class="h-4 w-4"
/>
No users found
</div>
} @else {
<div class="space-y-1.5">
@for (user of results(); track user.id) {
<div
class="group flex items-center gap-2 rounded-lg border border-border bg-card p-2 transition-colors hover:bg-card/80"
[attr.data-testid]="'user-card-' + userKey(user)"
>
<app-user-avatar
[avatarUrl]="user.avatarUrl"
[name]="user.displayName"
[showStatusBadge]="true"
[status]="user.status"
size="sm"
/>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="truncate text-sm font-semibold text-foreground">{{ user.displayName }}</p>
</div>
<p class="truncate text-xs text-muted-foreground">{{ user.username }}</p>
@if (user.description) {
<p class="line-clamp-1 text-xs text-muted-foreground">{{ user.description }}</p>
}
</div>
<div
class="pointer-events-none flex scale-95 shrink-0 items-center gap-2 opacity-0 transition-[opacity,transform] duration-75 ease-out group-hover:pointer-events-auto group-hover:scale-100 group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:scale-100 group-focus-within:opacity-100"
>
<app-friend-button [user]="user" />
<button
type="button"
[attr.data-testid]="'message-user-' + userKey(user)"
class="grid h-8 w-8 place-items-center rounded-md bg-primary text-primary-foreground transition-colors hover:bg-primary/90"
[attr.aria-label]="'Message ' + user.displayName"
[title]="'Message ' + user.displayName"
(click)="messageUser(user)"
>
<ng-icon
name="lucideMessageCircle"
class="h-4 w-4"
/>
</button>
</div>
</div>
}
</div>
}
</section>

View File

@@ -1,128 +0,0 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
inject,
input
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideMessageCircle, lucideSearch } from '@ng-icons/lucide';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
import { UserAvatarComponent } from '../../../../shared';
import { DirectMessageService } from '../../application/services/direct-message.service';
import { FriendService } from '../../application/services/friend.service';
import { FriendButtonComponent } from '../friend-button/friend-button.component';
import type { User } from '../../../../shared-kernel';
@Component({
selector: 'app-user-search-list',
standalone: true,
imports: [
CommonModule,
NgIcon,
UserAvatarComponent,
FriendButtonComponent
],
viewProviders: [provideIcons({ lucideMessageCircle, lucideSearch })],
templateUrl: './user-search-list.component.html'
})
export class UserSearchListComponent {
private readonly store = inject(Store);
private readonly router = inject(Router);
private readonly directMessages = inject(DirectMessageService);
readonly friends = inject(FriendService);
readonly searchQuery = input('');
readonly users = this.store.selectSignal(selectAllUsers);
readonly savedRooms = this.store.selectSignal(selectSavedRooms);
readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly discoveredUsers = computed(() => {
const usersById = new Map<string, User>();
for (const user of this.users()) {
usersById.set(user.oderId || user.id, user);
}
for (const room of this.savedRooms()) {
for (const member of room.members ?? []) {
const userId = member.oderId || member.id;
if (usersById.has(userId)) {
continue;
}
usersById.set(userId, {
id: member.id,
oderId: userId,
username: member.username,
displayName: member.displayName,
description: member.description,
avatarUrl: member.avatarUrl,
profileUpdatedAt: member.profileUpdatedAt,
role: member.role,
joinedAt: member.joinedAt,
status: 'disconnected'
});
}
}
return Array.from(usersById.values());
});
readonly matchingUsers = computed(() => {
const query = this.normalizedSearchQuery();
const currentUserId = this.currentUserKey();
return this.discoveredUsers()
.filter((user) => this.userKey(user) !== currentUserId)
.filter((user) => this.matchesQuery(user, query))
.slice(0, 24);
});
readonly friendResults = computed(() => this.matchingUsers().filter((user) => this.friends.isFriend(this.userKey(user))));
readonly results = computed(() => {
const friendIds = this.friends.friendIds();
return this.matchingUsers().filter((user) => !friendIds.has(this.userKey(user)));
});
async messageUser(user: User): Promise<void> {
const conversation = await this.directMessages.createConversation(user);
await this.router.navigate(['/dm', conversation.id]);
}
userKey(user: User): string {
return user.oderId || user.id;
}
initial(label: string): string {
return label.trim()[0]?.toUpperCase() || '?';
}
private currentUserKey(): string {
const currentUser = this.currentUser();
return currentUser ? this.userKey(currentUser) : '';
}
private normalizedSearchQuery(): string {
return this.searchQuery().trim()
.toLowerCase();
}
private matchesQuery(user: User, query: string): boolean {
if (!query) {
return true;
}
return [
user.displayName,
user.username,
user.description
]
.filter((value): value is string => !!value)
.some((value) => value.toLowerCase().includes(query));
}
}

View File

@@ -1,6 +0,0 @@
export * from './application/services/direct-message.service';
export * from './application/services/friend.service';
export * from './application/services/offline-message-queue.service';
export * from './application/services/peer-delivery.service';
export * from './domain/models/direct-message.model';
export * from './domain/logic/direct-message.logic';

View File

@@ -1,58 +0,0 @@
import { Injectable } from '@angular/core';
import type { DirectMessageConversation } from '../domain/models/direct-message.model';
const STORAGE_PREFIX = 'metoyou_direct_message_conversations';
@Injectable({ providedIn: 'root' })
export class DirectMessageRepository {
async loadConversations(ownerId: string): Promise<DirectMessageConversation[]> {
return this.read(ownerId);
}
async saveConversation(ownerId: string, conversation: DirectMessageConversation): Promise<void> {
const conversations = this.read(ownerId).filter((entry) => entry.id !== conversation.id);
conversations.push(conversation);
this.write(ownerId, conversations);
}
async getConversation(ownerId: string, conversationId: string): Promise<DirectMessageConversation | null> {
return this.read(ownerId).find((conversation) => conversation.id === conversationId) ?? null;
}
async deleteConversation(ownerId: string, conversationId: string): Promise<void> {
this.write(ownerId, this.read(ownerId).filter((conversation) => conversation.id !== conversationId));
}
async markRead(ownerId: string, conversationId: string): Promise<void> {
const conversations = this.read(ownerId).map((conversation) =>
conversation.id === conversationId ? { ...conversation, unreadCount: 0 } : conversation
);
this.write(ownerId, conversations);
}
private read(ownerId: string): DirectMessageConversation[] {
const rawValue = localStorage.getItem(this.key(ownerId));
if (!rawValue) {
return [];
}
try {
const parsed = JSON.parse(rawValue) as DirectMessageConversation[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
private write(ownerId: string, conversations: DirectMessageConversation[]): void {
localStorage.setItem(this.key(ownerId), JSON.stringify(conversations));
}
private key(ownerId: string): string {
return `${STORAGE_PREFIX}:${ownerId}`;
}
}

View File

@@ -1,49 +0,0 @@
import { Injectable } from '@angular/core';
import type { Friend } from '../domain/models/direct-message.model';
const STORAGE_PREFIX = 'metoyou_friends';
@Injectable({ providedIn: 'root' })
export class FriendRepository {
async loadFriends(ownerId: string): Promise<Friend[]> {
return this.read(ownerId);
}
async addFriend(ownerId: string, friend: Friend): Promise<void> {
const friends = this.read(ownerId).filter((entry) => entry.userId !== friend.userId);
friends.push(friend);
this.write(ownerId, friends);
}
async removeFriend(ownerId: string, userId: string): Promise<void> {
this.write(
ownerId,
this.read(ownerId).filter((entry) => entry.userId !== userId)
);
}
private read(ownerId: string): Friend[] {
const rawValue = localStorage.getItem(this.key(ownerId));
if (!rawValue) {
return [];
}
try {
const parsed = JSON.parse(rawValue) as Friend[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
private write(ownerId: string, friends: Friend[]): void {
localStorage.setItem(this.key(ownerId), JSON.stringify(friends));
}
private key(ownerId: string): string {
return `${STORAGE_PREFIX}:${ownerId}`;
}
}

View File

@@ -1,49 +0,0 @@
import { Injectable } from '@angular/core';
const STORAGE_PREFIX = 'metoyou_direct_message_queue';
@Injectable({ providedIn: 'root' })
export class OfflineQueueRepository {
async load(ownerId: string): Promise<string[]> {
return this.read(ownerId);
}
async enqueue(ownerId: string, messageId: string): Promise<void> {
this.write(ownerId, Array.from(new Set([...this.read(ownerId), messageId])));
}
async remove(ownerId: string, messageId: string): Promise<void> {
this.write(
ownerId,
this.read(ownerId).filter((entry) => entry !== messageId)
);
}
async clear(ownerId: string): Promise<void> {
this.write(ownerId, []);
}
private read(ownerId: string): string[] {
const rawValue = localStorage.getItem(this.key(ownerId));
if (!rawValue) {
return [];
}
try {
const parsed = JSON.parse(rawValue) as string[];
return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === 'string') : [];
} catch {
return [];
}
}
private write(ownerId: string, messageIds: string[]): void {
localStorage.setItem(this.key(ownerId), JSON.stringify(messageIds));
}
private key(ownerId: string): string {
return `${STORAGE_PREFIX}:${ownerId}`;
}
}

View File

@@ -1,261 +0,0 @@
import {
Injector,
NgZone,
runInInjectionContext,
signal
} from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Store } from '@ngrx/store';
import { Subject, of } from 'rxjs';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { ServerDirectoryFacade } from '../../server-directory';
import { UsersActions } from '../../../store/users/users.actions';
import { selectAllUsers, selectCurrentUser } from '../../../store/users/users.selectors';
import type {
ChatEvent,
GameActivity,
GameMatchResponse,
MatchedGame,
User
} from '../../../shared-kernel';
import { GameActivityService } from './game-activity.service';
const alice = createUser('alice-id', 'alice-oder', 'Alice');
const bob = createUser('bob-id', 'bob-oder', 'Bob');
const carol = createUser('carol-id', 'carol-oder', 'Carol');
let contexts: ServiceContext[] = [];
describe('GameActivityService sync', () => {
beforeEach(() => {
contexts = [];
installLocalStorageMock();
});
afterEach(() => {
for (const context of contexts) {
context.service.ngOnDestroy();
}
});
it('subscribes to incoming activity on browser clients without local process scanning', () => {
const context = createServiceContext({
currentUser: bob,
allUsers: [alice, bob],
electronApi: null
});
context.service.start();
context.incomingMessages.next({
type: 'game-activity',
fromPeerId: alice.oderId,
oderId: alice.oderId,
displayName: alice.displayName,
gameActivity: createActivity('game-1', 'Deep Rock Galactic')
} as ChatEvent);
expect(context.store.dispatch).toHaveBeenCalledWith(UsersActions.updateGameActivity({
userId: alice.id,
gameActivity: createActivity('game-1', 'Deep Rock Galactic')
}));
});
it('broadcasts local activity changes to peers already online', async () => {
const matchedGame = createMatchedGame('game-2', 'Stardew Valley', 'StardewValley.exe');
const context = createServiceContext({
currentUser: alice,
allUsers: [alice, bob],
processNames: ['StardewValley.exe'],
gameMatchResponse: { games: [matchedGame] }
});
context.service.start();
await vi.waitFor(() => expect(context.realtime.broadcastMessage).toHaveBeenCalledWith(expect.objectContaining({
type: 'game-activity',
oderId: alice.oderId,
displayName: alice.displayName,
gameActivity: expect.objectContaining({
id: matchedGame.id,
name: matchedGame.name,
iconUrl: matchedGame.iconUrl,
store: matchedGame.store
})
})));
});
it('sends current activity directly to peers that connect after the status was set', async () => {
const matchedGame = createMatchedGame('game-3', 'Hades', 'Hades.exe');
const context = createServiceContext({
currentUser: alice,
allUsers: [
alice,
bob,
carol
],
processNames: ['Hades.exe'],
gameMatchResponse: { games: [matchedGame] }
});
context.service.start();
await vi.waitFor(() => expect(context.realtime.broadcastMessage).toHaveBeenCalled());
context.realtime.sendToPeer.mockClear();
context.peerConnected.next(carol.oderId);
expect(context.realtime.sendToPeer).toHaveBeenCalledWith(carol.oderId, expect.objectContaining({
type: 'game-activity',
oderId: alice.oderId,
displayName: alice.displayName,
gameActivity: expect.objectContaining({
id: matchedGame.id,
name: matchedGame.name,
iconUrl: matchedGame.iconUrl,
store: matchedGame.store
})
}));
});
});
interface ServiceContextOptions {
currentUser: User;
allUsers: User[];
electronApi?: { getRunningProcessNames: () => Promise<string[]> } | null;
processNames?: string[];
gameMatchResponse?: GameMatchResponse;
}
interface ServiceContext {
incomingMessages: Subject<ChatEvent>;
peerConnected: Subject<string>;
realtime: {
broadcastMessage: ReturnType<typeof vi.fn>;
sendToPeer: ReturnType<typeof vi.fn>;
};
service: GameActivityService;
store: {
dispatch: ReturnType<typeof vi.fn>;
};
}
function createServiceContext(options: ServiceContextOptions): ServiceContext {
const currentUser = signal<User | null>(options.currentUser);
const allUsers = signal<User[]>(options.allUsers);
const incomingMessages = new Subject<ChatEvent>();
const peerConnected = new Subject<string>();
const realtime = {
onMessageReceived: incomingMessages.asObservable(),
onPeerConnected: peerConnected.asObservable(),
broadcastMessage: vi.fn(),
sendToPeer: vi.fn()
};
const store = {
dispatch: vi.fn(),
selectSignal: vi.fn((selector: unknown) => {
if (selector === selectCurrentUser) {
return currentUser;
}
if (selector === selectAllUsers) {
return allUsers;
}
throw new Error('Unexpected selector requested by GameActivityService test.');
})
};
const electronApi = options.electronApi === undefined
? { getRunningProcessNames: vi.fn(async () => options.processNames ?? []) }
: options.electronApi;
const injector = Injector.create({
providers: [
{
provide: ElectronBridgeService,
useValue: { getApi: () => electronApi }
},
{
provide: HttpClient,
useValue: {
post: vi.fn(() => of(options.gameMatchResponse ?? { games: [] }))
}
},
{
provide: NgZone,
useValue: {
run: (fn: () => void) => fn(),
runOutsideAngular: (fn: () => void) => fn()
}
},
{
provide: RealtimeSessionFacade,
useValue: realtime
},
{
provide: ServerDirectoryFacade,
useValue: { getApiBaseUrl: () => 'http://localhost:3001/api' }
},
{
provide: Store,
useValue: store
}
]
});
const service = runInInjectionContext(injector, () => new GameActivityService());
const context = {
incomingMessages,
peerConnected,
realtime,
service,
store
};
contexts.push(context);
return context;
}
function createUser(id: string, oderId: string, displayName: string): User {
return {
id,
oderId,
username: displayName.toLowerCase(),
displayName,
status: 'online',
role: 'member',
joinedAt: 1
};
}
function createActivity(id: string, name: string): GameActivity {
return {
id,
name,
startedAt: 1_000
};
}
function createMatchedGame(id: string, name: string, processName: string): MatchedGame {
return {
id,
name,
iconUrl: `https://img.example.test/${id}.jpg`,
store: {
name: 'Steam',
slug: 'steam',
url: `https://store.steampowered.com/search/?term=${encodeURIComponent(name)}`
},
processName
};
}
function installLocalStorageMock(): void {
const values = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
clear: () => values.clear()
});
}

View File

@@ -1,581 +0,0 @@
import {
Injectable,
NgZone,
OnDestroy,
inject
} from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Store } from '@ngrx/store';
import { Subscription, firstValueFrom } from 'rxjs';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { ServerDirectoryFacade } from '../../server-directory';
import { UsersActions } from '../../../store/users/users.actions';
import { selectAllUsers, selectCurrentUser } from '../../../store/users/users.selectors';
import type {
ChatEvent,
GameActivity,
GameStoreLink,
GameMatchResponse,
MatchedGame,
User
} from '../../../shared-kernel';
const DEFAULT_SCAN_INTERVAL_MS = 10_000;
const MIN_SCAN_INTERVAL_MS = 5_000;
const MAX_SCAN_INTERVAL_MS = 60_000;
const MAX_PROCESS_NAMES_PER_REQUEST = 256;
const MAX_CANDIDATE_PROCESSES_PER_REQUEST = 12;
const POSITIVE_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const NEGATIVE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const MAX_LOCAL_CACHE_ENTRIES = 128;
const SCAN_INTERVAL_STORAGE_KEY = 'metoyou_game_scan_interval_ms';
const GAME_MATCH_CACHE_STORAGE_KEY = 'metoyou_game_match_cache_v1';
interface CachedGameMatch {
expiresAt: number;
game: MatchedGame | null;
}
interface CandidateProcess {
processName: string;
score: number;
}
const IGNORED_PROCESS_NAMES = new Set([
'agent',
'bash',
'baloorunner',
'chrome',
'code',
'conhost',
'cursor',
'csrss',
'dbus daemon',
'discord',
'dwm',
'electron',
'explorer',
'firefox',
'gameoverlayui',
'gamemoded',
'gamescopereaper',
'gnome shell',
'metoyou',
'node',
'npm',
'powershell',
'pulseaudio',
'steam',
'steamwebhelper',
'systemd',
'taskhostw',
'wininit',
'winlogon',
'xorg'
]);
const IGNORED_PROCESS_PATTERNS = [
new RegExp('(^|\\s)(agent|browser|daemon|desktop|helper|indexer|launcher|monitor|renderer|runner)(\\s|$)'),
new RegExp('(^|\\s)(service|settings|shell|tray|updater|utility|watcher|worker)(\\s|$)'),
new RegExp('(^|\\s)(audio|bluetooth|clipboard|crash|dbus|file|gpu|input|network|notification)(\\s|$)'),
new RegExp('(^|\\s)(portal|proxy|screen|session|sync|system|tracker|web|window)(\\s|$)'),
/^(appimage|at spi|baloo|dconf|gvfs|ibus|kde|kworker)/,
/^(pipewire|plasmashell|pulseaudio|xdg|xwayland|zeitgeist)/,
/(helper|service|daemon|runner|tracker|portal|updater|worker)$/
];
@Injectable({ providedIn: 'root' })
export class GameActivityService implements OnDestroy {
private readonly electron = inject(ElectronBridgeService);
private readonly http = inject(HttpClient);
private readonly ngZone = inject(NgZone);
private readonly serverDirectory = inject(ServerDirectoryFacade);
private readonly store = inject(Store);
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private readonly allUsers = this.store.selectSignal(selectAllUsers);
private readonly subscriptions = new Subscription();
private scanTimer: ReturnType<typeof setInterval> | null = null;
private lastProcessHash = '';
private currentActivity: GameActivity | null = null;
private scanInFlight = false;
private started = false;
start(): void {
if (this.started) {
return;
}
this.started = true;
this.subscriptions.add(
this.webrtc.onMessageReceived.subscribe((event) => this.handlePeerEvent(event))
);
this.subscriptions.add(
this.webrtc.onPeerConnected.subscribe((peerId) => this.sendCurrentActivityToPeer(peerId))
);
const api = this.electron.getApi();
if (!api?.getRunningProcessNames) {
return;
}
this.ngZone.runOutsideAngular(() => {
this.scanTimer = setInterval(() => {
void this.scanRunningProcesses();
}, this.getScanIntervalMs());
});
void this.scanRunningProcesses();
}
ngOnDestroy(): void {
this.stop();
}
private stop(): void {
if (this.scanTimer) {
clearInterval(this.scanTimer);
this.scanTimer = null;
}
this.subscriptions.unsubscribe();
this.started = false;
}
private async scanRunningProcesses(): Promise<void> {
if (this.scanInFlight || !this.currentUser()) {
return;
}
const api = this.electron.getApi();
if (!api?.getRunningProcessNames) {
return;
}
this.scanInFlight = true;
try {
const processNames = (await api.getRunningProcessNames()).slice(0, MAX_PROCESS_NAMES_PER_REQUEST);
const processHash = this.buildProcessHash(processNames);
if (processHash === this.lastProcessHash) {
return;
}
this.lastProcessHash = processHash;
const matchedGame = await this.matchRunningGame(processNames);
this.ngZone.run(() => this.applyMatchedGame(matchedGame));
} catch {
return;
} finally {
this.scanInFlight = false;
}
}
private async matchRunningGame(processes: string[]): Promise<MatchedGame | null> {
const candidates = this.selectCandidateProcesses(processes);
const cachedGame = this.findCachedGame(candidates);
if (cachedGame !== undefined) {
return cachedGame;
}
const unknownCandidates = candidates
.filter((candidate) => !this.hasFreshCacheEntry(candidate.processName))
.slice(0, MAX_CANDIDATE_PROCESSES_PER_REQUEST);
if (unknownCandidates.length === 0) {
return null;
}
const apiBase = this.serverDirectory.getApiBaseUrl();
const currentUser = this.currentUser();
const response = await firstValueFrom(
this.http.post<GameMatchResponse>(`${apiBase}/games/match`, {
processes: unknownCandidates.map((candidate) => candidate.processName),
userId: currentUser?.id ?? currentUser?.oderId
})
);
this.storeMatchResponse(unknownCandidates, response);
return response.games[0] ?? null;
}
private selectCandidateProcesses(processes: string[]): CandidateProcess[] {
const candidates = new Map<string, CandidateProcess>();
for (const processName of processes.slice(0, MAX_PROCESS_NAMES_PER_REQUEST)) {
const normalized = this.normalizeProcessName(processName);
if (!normalized) {
continue;
}
const cacheKey = this.normalizeCacheKey(normalized);
const existing = candidates.get(cacheKey);
const candidate = {
processName,
score: this.scoreCandidateProcess(processName, normalized)
};
if (!existing || candidate.score > existing.score) {
candidates.set(cacheKey, candidate);
}
}
return Array.from(candidates.values())
.sort((left, right) => right.score - left.score || left.processName.localeCompare(right.processName));
}
private normalizeProcessName(value: string): string {
const normalized = value.trim()
.replace(/\.exe$/i, '')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const cacheKey = this.normalizeCacheKey(normalized);
if (normalized.length < 4 || normalized.length > 96 || this.shouldIgnoreProcessName(cacheKey)) {
return '';
}
return normalized;
}
private shouldIgnoreProcessName(cacheKey: string): boolean {
return IGNORED_PROCESS_NAMES.has(cacheKey)
|| IGNORED_PROCESS_PATTERNS.some((pattern) => pattern.test(cacheKey));
}
private scoreCandidateProcess(rawValue: string, normalized: string): number {
let score = 0;
if (/\.exe$/i.test(rawValue.trim())) {
score += 12;
}
if (/[A-Z]/.test(normalized) && /[a-z]/.test(normalized)) {
score += 4;
}
if (/\d/.test(normalized)) {
score += 1;
}
if (normalized.length >= 5 && normalized.length <= 32) {
score += 2;
}
if (normalized.includes(' ')) {
score -= 2;
}
return score;
}
private findCachedGame(candidates: CandidateProcess[]): MatchedGame | null | undefined {
if (candidates.length === 0) {
return null;
}
let hasCachedMissForEveryCandidate = true;
for (const candidate of candidates) {
const cached = this.getCachedMatch(candidate.processName);
if (cached === undefined) {
hasCachedMissForEveryCandidate = false;
continue;
}
if (cached) {
return cached;
}
}
return hasCachedMissForEveryCandidate ? null : undefined;
}
private storeMatchResponse(candidates: CandidateProcess[], response: GameMatchResponse): void {
for (const game of response.games) {
this.setCachedMatch(game.processName, game, POSITIVE_CACHE_TTL_MS);
}
if (response.rateLimited) {
return;
}
const matchedProcessKeys = new Set(response.games.map((game) => this.normalizeCacheKey(game.processName)));
for (const candidate of candidates) {
if (!matchedProcessKeys.has(this.normalizeCacheKey(candidate.processName))) {
this.setCachedMatch(candidate.processName, null, NEGATIVE_CACHE_TTL_MS);
}
}
}
private hasFreshCacheEntry(processName: string): boolean {
return this.getCachedMatch(processName) !== undefined;
}
private getCachedMatch(processName: string): MatchedGame | null | undefined {
const cache = this.readMatchCache();
const cacheKey = this.normalizeCacheKey(processName);
const cached = cache[cacheKey];
if (!cached) {
return undefined;
}
if (cached.expiresAt <= Date.now()) {
this.writeMatchCache(Object.fromEntries(
Object.entries(cache).filter(([key]) => key !== cacheKey)
));
return undefined;
}
return cached.game;
}
private setCachedMatch(processName: string, game: MatchedGame | null, ttlMs: number): void {
const cache = this.readMatchCache();
cache[this.normalizeCacheKey(processName)] = {
expiresAt: Date.now() + ttlMs,
game
};
this.writeMatchCache(cache);
}
private readMatchCache(): Record<string, CachedGameMatch> {
try {
const parsed = JSON.parse(localStorage.getItem(GAME_MATCH_CACHE_STORAGE_KEY) ?? '{}') as unknown;
return this.normalizeMatchCache(parsed);
} catch {
return {};
}
}
private normalizeMatchCache(value: unknown): Record<string, CachedGameMatch> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
const cache: Record<string, CachedGameMatch> = {};
for (const [key, entry] of Object.entries(value)) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
continue;
}
const cached = entry as Partial<CachedGameMatch>;
if (typeof cached.expiresAt === 'number') {
cache[key] = {
expiresAt: cached.expiresAt,
game: this.normalizeCachedGame(cached.game)
};
}
}
return cache;
}
private normalizeCachedGame(value: unknown): MatchedGame | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const game = value as Partial<MatchedGame>;
if (typeof game.id !== 'string' || typeof game.name !== 'string' || typeof game.processName !== 'string') {
return null;
}
return {
id: game.id,
name: game.name,
iconUrl: typeof game.iconUrl === 'string' ? game.iconUrl : undefined,
store: this.normalizeGameStore(game.store),
processName: game.processName
};
}
private normalizeGameStore(value: unknown): GameStoreLink | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
const store = value as Partial<GameStoreLink>;
if (typeof store.name !== 'string' || typeof store.url !== 'string' || !this.isExternalUrl(store.url)) {
return undefined;
}
return {
id: typeof store.id === 'string' ? store.id : undefined,
name: store.name,
slug: typeof store.slug === 'string' ? store.slug : undefined,
domain: typeof store.domain === 'string' ? store.domain : undefined,
url: store.url
};
}
private writeMatchCache(cache: Record<string, CachedGameMatch>): void {
const entries = Object.entries(cache)
.filter(([, entry]) => entry.expiresAt > Date.now())
.sort((left, right) => right[1].expiresAt - left[1].expiresAt)
.slice(0, MAX_LOCAL_CACHE_ENTRIES);
localStorage.setItem(GAME_MATCH_CACHE_STORAGE_KEY, JSON.stringify(Object.fromEntries(entries)));
}
private normalizeCacheKey(value: string): string {
return value.trim()
.replace(/\.exe$/i, '')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
private applyMatchedGame(game: MatchedGame | null): void {
if (!game) {
this.setCurrentActivity(null);
return;
}
const previous = this.currentActivity;
const activity: GameActivity = {
id: game.id,
name: game.name,
iconUrl: game.iconUrl,
store: game.store,
startedAt: previous?.id === game.id ? previous.startedAt : Date.now()
};
this.setCurrentActivity(activity);
}
private setCurrentActivity(activity: GameActivity | null): void {
if (this.isSameActivity(this.currentActivity, activity)) {
return;
}
this.currentActivity = activity;
const user = this.currentUser();
if (user) {
this.store.dispatch(UsersActions.updateGameActivity({
userId: user.id,
gameActivity: activity
}));
}
this.webrtc.broadcastMessage({
type: 'game-activity',
oderId: user?.oderId || user?.id,
displayName: user?.displayName || 'User',
gameActivity: activity
});
}
private handlePeerEvent(event: ChatEvent): void {
if (event.type !== 'game-activity') {
return;
}
const peerIdentifier = event.fromPeerId ?? event.oderId;
if (!peerIdentifier) {
return;
}
const currentUser = this.currentUser();
if (peerIdentifier === currentUser?.id || peerIdentifier === currentUser?.oderId) {
return;
}
const user = this.findUser(peerIdentifier);
if (!user) {
return;
}
this.store.dispatch(UsersActions.updateGameActivity({
userId: user.id,
gameActivity: this.normalizeIncomingActivity(event.gameActivity)
}));
}
private sendCurrentActivityToPeer(peerId: string): void {
const user = this.currentUser();
if (!user) {
return;
}
this.webrtc.sendToPeer(peerId, {
type: 'game-activity',
oderId: user.oderId || user.id,
displayName: user.displayName || 'User',
gameActivity: this.currentActivity
});
}
private findUser(identifier: string): User | null {
return this.allUsers().find((user) => user.id === identifier || user.oderId === identifier) ?? null;
}
private normalizeIncomingActivity(value: GameActivity | null | undefined): GameActivity | null {
if (!value || typeof value.id !== 'string' || typeof value.name !== 'string' || typeof value.startedAt !== 'number') {
return null;
}
return {
id: value.id,
name: value.name,
iconUrl: typeof value.iconUrl === 'string' ? value.iconUrl : undefined,
store: this.normalizeGameStore(value.store),
startedAt: value.startedAt
};
}
private isSameActivity(previous: GameActivity | null, next: GameActivity | null): boolean {
return previous?.id === next?.id
&& previous?.name === next?.name
&& previous?.iconUrl === next?.iconUrl
&& previous?.store?.url === next?.store?.url
&& previous?.startedAt === next?.startedAt;
}
private isExternalUrl(value: string): boolean {
return value.startsWith('http://') || value.startsWith('https://');
}
private buildProcessHash(processNames: string[]): string {
return processNames.map((name) => name.trim().toLowerCase())
.sort()
.join('|');
}
private getScanIntervalMs(): number {
const storedValue = Number.parseInt(localStorage.getItem(SCAN_INTERVAL_STORAGE_KEY) ?? '', 10);
const interval = Number.isFinite(storedValue) ? storedValue : DEFAULT_SCAN_INTERVAL_MS;
return Math.min(Math.max(interval, MIN_SCAN_INTERVAL_MS), MAX_SCAN_INTERVAL_MS);
}
}

View File

@@ -1,14 +0,0 @@
export function formatGameActivityElapsed(startedAt: number, now = Date.now()): string {
const elapsedSeconds = Math.max(0, Math.floor((now - startedAt) / 1000));
const hours = Math.floor(elapsedSeconds / 3600);
const minutes = Math.floor((elapsedSeconds % 3600) / 60);
const seconds = elapsedSeconds % 60;
return [
hours,
minutes,
seconds
]
.map((value) => value.toString().padStart(2, '0'))
.join(':');
}

View File

@@ -1,3 +0,0 @@
import type { GameActivity } from '../../../shared-kernel';
export type CurrentGameActivity = GameActivity | null;

View File

@@ -1,3 +0,0 @@
export * from './application/game-activity.service';
export * from './domain/game-activity.models';
export * from './domain/game-activity-time';

View File

@@ -1,7 +1,10 @@
import { Injector, runInInjectionContext } from '@angular/core';
import { environment } from '../../../../../environments/environment';
import type { ServerEndpoint } from '../../domain/models/server-directory.model';
import * as serverDirectoryStorageKeys from '../../infrastructure/constants/server-directory.infrastructure.constants';
import {
DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY,
SERVER_ENDPOINTS_STORAGE_KEY
} from '../../infrastructure/constants/server-directory.infrastructure.constants';
import { ServerEndpointStorageService } from '../../infrastructure/services/server-endpoint-storage.service';
import { ServerEndpointStateService } from './server-endpoint-state.service';
@@ -46,7 +49,7 @@ function getConfiguredDefaultServer(key: string): { key?: string; name?: string;
}
function seedStoredEndpoints(endpoints: ServerEndpoint[]): void {
localStorage.setItem(serverDirectoryStorageKeys.SERVER_ENDPOINTS_STORAGE_KEY, JSON.stringify(endpoints));
localStorage.setItem(SERVER_ENDPOINTS_STORAGE_KEY, JSON.stringify(endpoints));
}
function createService(): ServerEndpointStateService {
@@ -63,16 +66,6 @@ function createService(): ServerEndpointStateService {
return runInInjectionContext(injector, () => new ServerEndpointStateService());
}
function getRequiredDefaultEndpoint(service: ServerEndpointStateService, defaultKey: string | undefined): ServerEndpoint {
const endpoint = service.servers().find((candidate) => candidate.defaultKey === defaultKey);
if (!endpoint) {
throw new Error(`Expected default endpoint for key: ${defaultKey ?? 'unknown'}`);
}
return endpoint;
}
describe('ServerEndpointStateService', () => {
beforeEach(() => {
localStorage.clear();
@@ -113,11 +106,7 @@ describe('ServerEndpointStateService', () => {
status: 'unknown'
}
]);
localStorage.setItem(
serverDirectoryStorageKeys.DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY,
JSON.stringify([defaultServer.key])
);
localStorage.setItem(DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY, JSON.stringify([defaultServer.key]));
const service = createService();
const endpoint = service.servers().find((candidate) => candidate.defaultKey === defaultServer.key);
@@ -125,61 +114,21 @@ describe('ServerEndpointStateService', () => {
expect(endpoint?.isActive).toBe(false);
});
it('keeps configured default endpoints active even when stored as incompatible unless the user disabled them', () => {
const defaultServer = getConfiguredDefaultServer('toju-primary');
seedStoredEndpoints([
{
id: 'default-server',
name: 'Stored Default',
url: defaultServer.url ?? '',
isActive: false,
isDefault: true,
defaultKey: defaultServer.key,
status: 'incompatible'
}
]);
const service = createService();
const endpoint = service.servers().find((candidate) => candidate.defaultKey === defaultServer.key);
expect(endpoint?.isActive).toBe(true);
expect(endpoint?.status).toBe('incompatible');
});
it('does not deactivate configured default endpoints when compatibility checks fail', () => {
const defaultServer = getConfiguredDefaultServer('toju-primary');
const service = createService();
const endpoint = getRequiredDefaultEndpoint(service, defaultServer.key);
service.updateServerStatus(endpoint.id, 'incompatible');
expect(service.servers().find((candidate) => candidate.id === endpoint.id)?.isActive).toBe(true);
});
it('resolves legacy https source URLs to the local http default endpoint on the same host', () => {
const defaultServer = getConfiguredDefaultServer('default');
const service = createService();
const legacyHttpsUrl = defaultServer.url?.replace(/^http:\/\//, 'https://') ?? '';
expect(service.findServerByUrl(legacyHttpsUrl)?.url).toBe(defaultServer.url);
});
it('persists turning a configured default endpoint off and back on', () => {
const defaultServer = getConfiguredDefaultServer('toju-primary');
const service = createService();
const endpoint = getRequiredDefaultEndpoint(service, defaultServer.key);
const endpoint = service.servers().find((candidate) => candidate.defaultKey === defaultServer.key);
service.deactivateServer(endpoint.id);
expect(endpoint).toBeDefined();
expect(service.servers().find((candidate) => candidate.id === endpoint.id)?.isActive).toBe(false);
expect(JSON.parse(
localStorage.getItem(serverDirectoryStorageKeys.DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY) ?? '[]'
)).toContain(defaultServer.key);
service.deactivateServer(endpoint!.id);
service.setActiveServer(endpoint.id);
expect(service.servers().find((candidate) => candidate.id === endpoint!.id)?.isActive).toBe(false);
expect(JSON.parse(localStorage.getItem(DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY) ?? '[]')).toContain(defaultServer.key);
expect(service.servers().find((candidate) => candidate.id === endpoint.id)?.isActive).toBe(true);
expect(localStorage.getItem(serverDirectoryStorageKeys.DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY)).toBeNull();
service.setActiveServer(endpoint!.id);
expect(service.servers().find((candidate) => candidate.id === endpoint!.id)?.isActive).toBe(true);
expect(localStorage.getItem(DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY)).toBeNull();
});
});
});

View File

@@ -117,9 +117,8 @@ export class ServerEndpointStateService {
findServerByUrl(url: string): ServerEndpoint | undefined {
const sanitisedUrl = this.sanitiseUrl(url);
const exactEndpoint = this._servers().find((endpoint) => this.sanitiseUrl(endpoint.url) === sanitisedUrl);
return exactEndpoint ?? this.findHttpEndpointForHttpsUrl(sanitisedUrl);
return this._servers().find((endpoint) => this.sanitiseUrl(endpoint.url) === sanitisedUrl);
}
resolveCanonicalEndpoint(endpoint: ServerEndpoint | null | undefined): ServerEndpoint | null {
@@ -240,7 +239,7 @@ export class ServerEndpointStateService {
instanceId: versions?.serverInstanceId ?? endpoint.instanceId,
status,
latency,
isActive: status === 'incompatible' && !endpoint.isDefault ? false : endpoint.isActive,
isActive: status === 'incompatible' ? false : endpoint.isActive,
serverVersion: versions?.serverVersion ?? endpoint.serverVersion,
clientVersion: versions?.clientVersion ?? endpoint.clientVersion
};
@@ -295,7 +294,7 @@ export class ServerEndpointStateService {
...endpoint,
name: matchedDefault.name,
url: matchedDefault.url,
isActive: this.isDefaultEndpointActive(matchedDefault.defaultKey, disabledDefaultKeys),
isActive: this.isDefaultEndpointActive(matchedDefault.defaultKey, endpoint.status ?? 'unknown', disabledDefaultKeys),
isDefault: true,
defaultKey: matchedDefault.defaultKey,
status: endpoint.status ?? 'unknown'
@@ -320,7 +319,7 @@ export class ServerEndpointStateService {
reconciled.push({
...defaultEndpoint,
id: uuidv4(),
isActive: this.isDefaultEndpointActive(defaultEndpoint.defaultKey, disabledDefaultKeys)
isActive: this.isDefaultEndpointActive(defaultEndpoint.defaultKey, defaultEndpoint.status, disabledDefaultKeys)
});
}
}
@@ -372,7 +371,6 @@ export class ServerEndpointStateService {
private clearDisabledDefaultEndpointKeys(endpoints: ServerEndpoint[]): void {
const disabledDefaultKeys = this.storage.loadDisabledDefaultEndpointKeys();
let didChange = false;
for (const endpoint of endpoints) {
@@ -394,9 +392,10 @@ export class ServerEndpointStateService {
private isDefaultEndpointActive(
defaultKey: string,
status: ServerEndpoint['status'],
disabledDefaultKeys: Set<string>
): boolean {
return !disabledDefaultKeys.has(defaultKey);
return status !== 'incompatible' && !disabledDefaultKeys.has(defaultKey);
}
private saveEndpoints(): void {
@@ -448,28 +447,4 @@ export class ServerEndpointStateService {
return false;
}
}
private findHttpEndpointForHttpsUrl(url: string): ServerEndpoint | undefined {
const requestedUrl = this.parseUrl(url);
if (requestedUrl?.protocol !== 'https:') {
return undefined;
}
return this._servers().find((endpoint) => {
const endpointUrl = this.parseUrl(endpoint.url);
return endpointUrl?.protocol === 'http:'
&& endpointUrl.hostname === requestedUrl.hostname
&& endpointUrl.port === requestedUrl.port;
});
}
private parseUrl(url: string): URL | null {
try {
return new URL(url);
} catch {
return null;
}
}
}

View File

@@ -1,57 +1,16 @@
<div class="flex h-full min-h-0 flex-col">
<div class="border-b border-border px-3 py-3">
<div class="flex flex-col gap-2 md:flex-row md:items-center">
<div class="relative min-w-0 flex-1">
<ng-icon
name="lucideSearch"
class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<input
type="text"
aria-label="Search people and servers"
class="h-10 w-full rounded-lg border border-border bg-secondary py-2 pl-10 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Search servers and users..."
[(ngModel)]="searchQuery"
(ngModelChange)="onSearchChange($event)"
/>
</div>
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
aria-label="Create New Server"
class="inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
(click)="openCreateDialog()"
>
<ng-icon
name="lucidePlus"
class="h-4 w-4"
/>
Create
</button>
<button
type="button"
class="grid h-10 w-10 place-items-center rounded-lg border border-border bg-secondary transition-colors hover:bg-secondary/80"
title="Settings"
(click)="openSettings()"
>
<ng-icon
name="lucideSettings"
class="h-5 w-5 text-muted-foreground"
/>
</button>
</div>
</div>
@if (savedRooms().length > 0) {
<div class="mt-2 flex items-center gap-2 overflow-x-auto pb-1">
<span class="shrink-0 text-xs font-medium text-muted-foreground">My Servers</span>
<div class="flex flex-col h-full">
<!-- My Servers -->
<div class="p-4 border-b border-border">
<h3 class="font-semibold text-foreground mb-2">My Servers</h3>
@if (savedRooms().length === 0) {
<p class="text-sm text-muted-foreground">No joined servers yet</p>
} @else {
<div class="flex flex-wrap gap-2">
@for (room of savedRooms(); track room.id) {
<button
type="button"
class="shrink-0 rounded-md border border-border bg-card px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
(click)="joinSavedRoom(room)"
type="button"
class="px-3 py-1.5 text-xs rounded-full bg-secondary hover:bg-secondary/80 border border-border text-foreground"
>
{{ room.name }}
</button>
@@ -59,169 +18,160 @@
</div>
}
</div>
<div class="grid min-h-0 flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[minmax(300px,380px)_1fr]">
<app-user-search-list
class="min-h-0 overflow-y-auto border-b border-border lg:border-b-0 lg:border-r"
[searchQuery]="searchQuery"
/>
<section class="min-h-0 overflow-y-auto">
<div class="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-background/95 px-3 py-2 backdrop-blur">
<div>
<h3 class="text-sm font-semibold text-foreground">Servers</h3>
<p class="text-xs text-muted-foreground">{{ searchResults().length }} found</p>
</div>
<!-- Search Header -->
<div class="p-4 border-b border-border">
<div class="flex items-center gap-2">
<div class="relative flex-1">
<ng-icon
name="lucideSearch"
class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4"
/>
<input
type="text"
[(ngModel)]="searchQuery"
(ngModelChange)="onSearchChange($event)"
placeholder="Search servers..."
class="w-full pl-10 pr-4 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<button
(click)="openSettings()"
type="button"
class="grid h-9 w-9 place-items-center rounded-lg border border-border bg-secondary transition-colors hover:bg-secondary/80"
title="Settings"
>
<ng-icon
name="lucideSettings"
class="w-5 h-5 text-muted-foreground"
/>
</button>
</div>
</div>
@if (isSearching()) {
<div class="flex items-center justify-center py-8">
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
</div>
} @else if (searchResults().length === 0) {
<div class="flex flex-col items-center justify-center px-4 py-10 text-muted-foreground">
<ng-icon
name="lucideSearch"
class="mb-3 h-10 w-10 opacity-50"
/>
<p class="text-sm font-medium">No servers found</p>
</div>
} @else {
<div class="space-y-2 p-3">
@for (server of searchResults(); track server.id) {
<div
class="group w-full cursor-pointer rounded-lg border bg-card p-3 text-left transition-colors"
[class.border-border]="!isServerMarkedBanned(server)"
[class.hover:border-primary/50]="!isServerMarkedBanned(server)"
[class.hover:bg-card/80]="!isServerMarkedBanned(server)"
[class.border-destructive/40]="isServerMarkedBanned(server)"
[class.bg-destructive/5]="isServerMarkedBanned(server)"
[class.hover:border-destructive/60]="isServerMarkedBanned(server)"
[title]="isJoinedServer(server) ? 'Double-click to open ' + server.name : 'Double-click to join ' + server.name"
(dblclick)="openServerCard(server)"
>
<div class="flex min-w-0 items-start gap-3">
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-secondary text-sm font-semibold text-foreground">
{{ server.name[0] || '?' }}
</div>
<!-- Create Server Button -->
<div class="p-4 border-b border-border">
<button
(click)="openCreateDialog()"
type="button"
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
>
<ng-icon
name="lucidePlus"
class="w-4 h-4"
/>
Create New Server
</button>
</div>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<h3
class="truncate text-sm font-semibold transition-colors"
[class.text-foreground]="!isServerMarkedBanned(server)"
[class.group-hover:text-primary]="!isServerMarkedBanned(server)"
[class.text-destructive]="isServerMarkedBanned(server)"
<!-- Search Results -->
<div class="flex-1 overflow-y-auto">
@if (isSearching()) {
<div class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
} @else if (searchResults().length === 0) {
<div class="flex flex-col items-center justify-center py-12 text-muted-foreground">
<ng-icon
name="lucideSearch"
class="w-12 h-12 mb-4 opacity-50"
/>
<p class="text-lg">No servers found</p>
<p class="text-sm">Try a different search or create your own</p>
</div>
} @else {
<div class="p-4 space-y-3">
@for (server of searchResults(); track server.id) {
<button
(click)="joinServer(server)"
type="button"
class="w-full p-4 bg-card rounded-lg border transition-all text-left group"
[class.border-border]="!isServerMarkedBanned(server)"
[class.hover:border-primary/50]="!isServerMarkedBanned(server)"
[class.hover:bg-card/80]="!isServerMarkedBanned(server)"
[class.border-destructive/40]="isServerMarkedBanned(server)"
[class.bg-destructive/5]="isServerMarkedBanned(server)"
[class.hover:border-destructive/60]="isServerMarkedBanned(server)"
[attr.aria-label]="isServerMarkedBanned(server) ? 'Banned server' : 'Join server'"
>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<h3
class="font-semibold transition-colors"
[class.text-foreground]="!isServerMarkedBanned(server)"
[class.group-hover:text-primary]="!isServerMarkedBanned(server)"
[class.text-destructive]="isServerMarkedBanned(server)"
>
{{ server.name }}
</h3>
@if (isServerMarkedBanned(server)) {
<ng-icon
name="lucideLock"
class="w-4 h-4 text-destructive"
/>
<span class="inline-flex items-center rounded-full bg-destructive/10 px-2 py-0.5 text-[11px] font-medium text-destructive"
>Banned</span
>
{{ server.name }}
</h3>
@if (isServerMarkedBanned(server)) {
<span
class="inline-flex items-center gap-1 rounded-full bg-destructive/10 px-2 py-0.5 text-[11px] font-medium text-destructive"
>
<ng-icon
name="lucideLock"
class="h-3 w-3"
/>
Banned
</span>
} @else if (server.isPrivate) {
<span
class="inline-flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
>
<ng-icon
name="lucideLock"
class="h-3 w-3"
/>
Private
</span>
} @else if (server.hasPassword) {
<span
class="inline-flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
>
<ng-icon
name="lucideLock"
class="h-3 w-3"
/>
Password
</span>
} @else {
<ng-icon
name="lucideGlobe"
class="h-4 w-4 text-muted-foreground"
/>
}
</div>
@if (server.description) {
<p class="mt-1 line-clamp-1 text-xs text-muted-foreground">{{ server.description }}</p>
}
<div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span class="inline-flex items-center gap-1">
<ng-icon
name="lucideUsers"
class="h-3.5 w-3.5"
/>
{{ getServerUserCount(server) }}/{{ getServerCapacityLabel(server) }}
</span>
@if (server.topic) {
<span class="truncate">{{ server.topic }}</span>
}
<span class="truncate">Owner: {{ getServerOwnerLabel(server) }}</span>
<span class="truncate">{{ server.sourceName || server.hostName || 'Unknown' }}</span>
</div>
</div>
<div class="relative shrink-0">
@if (isJoinedServer(server)) {
<div
class="flex items-center overflow-hidden rounded-md border border-emerald-500/30 bg-emerald-500/10 text-xs font-semibold text-emerald-500"
} @else if (server.isPrivate) {
<ng-icon
name="lucideLock"
class="w-4 h-4 text-muted-foreground"
/>
<span class="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
>Private</span
>
} @else if (server.hasPassword) {
<ng-icon
name="lucideLock"
class="w-4 h-4 text-muted-foreground"
/>
<span class="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
>Password</span
>
<span class="px-2.5 py-1.5">Joined</span>
<button
type="button"
class="grid h-8 w-8 place-items-center border-l border-emerald-500/20 transition-colors hover:bg-emerald-500/15"
[attr.aria-label]="'Server actions for ' + server.name"
(click)="toggleJoinedServerMenu($event, server)"
>
<ng-icon
name="lucideChevronDown"
class="h-4 w-4"
/>
</button>
</div>
@if (joinedServerMenuId() === server.id) {
<div class="absolute right-0 top-full z-20 mt-1 w-36 rounded-md border border-border bg-card py-1 shadow-lg">
<button
type="button"
class="w-full px-3 py-2 text-left text-xs font-medium text-destructive transition-colors hover:bg-destructive/10"
(click)="openLeaveDialog($event, server)"
>
Leave
</button>
</div>
}
} @else {
<button
type="button"
class="pointer-events-none scale-95 rounded-md bg-primary px-2.5 py-1.5 text-xs font-semibold text-primary-foreground opacity-0 transition-[opacity,transform] duration-75 ease-out hover:scale-100 hover:opacity-100 group-hover:pointer-events-auto group-hover:scale-100 group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:scale-100 group-focus-within:opacity-100"
[attr.aria-label]="'Join ' + server.name"
(click)="joinServer(server)"
>
<span class="sr-only">{{ server.name }}</span>
Join
</button>
<ng-icon
name="lucideGlobe"
class="w-4 h-4 text-muted-foreground"
/>
}
</div>
@if (server.description) {
<p class="text-sm text-muted-foreground mt-1 line-clamp-2">
{{ server.description }}
</p>
}
@if (server.topic) {
<span class="inline-block mt-2 px-2 py-0.5 text-xs bg-secondary rounded-full text-muted-foreground">
{{ server.topic }}
</span>
}
</div>
<div class="flex items-center gap-1 text-muted-foreground text-sm ml-4">
<ng-icon
name="lucideUsers"
class="w-4 h-4"
/>
<span>{{ getServerUserCount(server) }}/{{ getServerCapacityLabel(server) }}</span>
</div>
</div>
}
</div>
}
</section>
<div class="mt-3 space-y-1 text-xs">
<div class="text-muted-foreground">
Users: <span class="text-foreground/80">{{ getServerUserCount(server) }}/{{ getServerCapacityLabel(server) }}</span>
</div>
<div class="text-muted-foreground">
Listed by: <span class="text-foreground/80">{{ server.sourceName || server.hostName || 'Unknown' }}</span>
</div>
<div class="text-muted-foreground">
Owner: <span class="text-foreground/80">{{ server.ownerName || server.ownerId || 'Unknown' }}</span>
</div>
@if (server.hasPassword && !server.isPrivate && !isServerMarkedBanned(server)) {
<div class="text-muted-foreground">Access: <span class="text-foreground/80">Password required</span></div>
}
</div>
</button>
}
</div>
}
</div>
@if (joinErrorMessage() || error()) {
@@ -231,15 +181,6 @@
}
</div>
@if (leaveDialogRoom()) {
<app-leave-server-dialog
[room]="leaveDialogRoom()!"
[currentUser]="currentUser() ?? null"
(confirmed)="confirmLeaveServer($event)"
(cancelled)="closeLeaveDialog()"
/>
}
@if (showBannedDialog()) {
<app-confirm-dialog
title="Banned"

View File

@@ -23,8 +23,7 @@ import {
lucideLock,
lucideGlobe,
lucidePlus,
lucideSettings,
lucideChevronDown
lucideSettings
} from '@ng-icons/lucide';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
@@ -40,13 +39,8 @@ import { DatabaseService } from '../../../../infrastructure/persistence';
import { type ServerInfo } from '../../domain/models/server-directory.model';
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import {
ConfirmDialogComponent,
LeaveServerDialogComponent,
type LeaveServerDialogResult
} from '../../../../shared';
import { ConfirmDialogComponent } from '../../../../shared';
import { hasRoomBanForUser } from '../../../access-control';
import { UserSearchListComponent } from '../../../direct-message/feature/user-search-list/user-search-list.component';
@Component({
selector: 'app-server-search',
@@ -55,9 +49,7 @@ import { UserSearchListComponent } from '../../../direct-message/feature/user-se
CommonModule,
FormsModule,
NgIcon,
ConfirmDialogComponent,
LeaveServerDialogComponent,
UserSearchListComponent
ConfirmDialogComponent
],
viewProviders: [
provideIcons({
@@ -66,8 +58,7 @@ import { UserSearchListComponent } from '../../../direct-message/feature/user-se
lucideLock,
lucideGlobe,
lucidePlus,
lucideSettings,
lucideChevronDown
lucideSettings
})
],
templateUrl: './server-search.component.html'
@@ -100,8 +91,6 @@ export class ServerSearchComponent implements OnInit {
joinPassword = signal('');
joinPasswordError = signal<string | null>(null);
joinErrorMessage = signal<string | null>(null);
joinedServerMenuId = signal<string | null>(null);
leaveDialogRoom = signal<Room | null>(null);
// Create dialog state
showCreateDialog = signal(false);
@@ -128,7 +117,7 @@ export class ServerSearchComponent implements OnInit {
this.store.dispatch(RoomsActions.loadRooms());
// Setup debounced search
this.searchSubject.pipe(debounceTime(120), distinctUntilChanged()).subscribe((query) => {
this.searchSubject.pipe(debounceTime(300), distinctUntilChanged()).subscribe((query) => {
this.store.dispatch(RoomsActions.searchServers({ query }));
});
}
@@ -201,66 +190,7 @@ export class ServerSearchComponent implements OnInit {
/** Join a previously saved room by converting it to a ServerInfo payload. */
joinSavedRoom(room: Room): void {
this.openJoinedRoom(room);
}
openServerCard(server: ServerInfo): void {
const joinedRoom = this.joinedRoomForServer(server);
if (joinedRoom) {
this.openJoinedRoom(joinedRoom);
return;
}
void this.joinServer(server);
}
joinedRoomForServer(server: ServerInfo): Room | null {
return this.savedRooms().find((room) => room.id === server.id) ?? null;
}
isJoinedServer(server: ServerInfo): boolean {
return !!this.joinedRoomForServer(server);
}
toggleJoinedServerMenu(event: Event, server: ServerInfo): void {
event.stopPropagation();
this.joinedServerMenuId.update((currentId) => currentId === server.id ? null : server.id);
}
closeJoinedServerMenu(): void {
this.joinedServerMenuId.set(null);
}
openLeaveDialog(event: Event, server: ServerInfo): void {
event.stopPropagation();
const room = this.joinedRoomForServer(server);
if (!room) {
return;
}
this.joinedServerMenuId.set(null);
this.leaveDialogRoom.set(room);
}
closeLeaveDialog(): void {
this.leaveDialogRoom.set(null);
}
confirmLeaveServer(result: LeaveServerDialogResult): void {
const room = this.leaveDialogRoom();
if (!room) {
return;
}
this.store.dispatch(RoomsActions.forgetRoom({
roomId: room.id,
nextOwnerKey: result.nextOwnerKey
}));
this.leaveDialogRoom.set(null);
void this.joinServer(this.toServerInfo(room));
}
closeBannedDialog(): void {
@@ -301,21 +231,6 @@ export class ServerSearchComponent implements OnInit {
return server.maxUsers > 0 ? String(server.maxUsers) : '∞';
}
getServerOwnerLabel(server: ServerInfo): string {
const joinedRoom = this.joinedRoomForServer(server);
const ownerKey = server.ownerId || joinedRoom?.hostId || '';
const ownerMember = joinedRoom?.members?.find((member) =>
member.id === ownerKey || member.oderId === ownerKey
);
return server.ownerName || ownerMember?.displayName || server.ownerId || joinedRoom?.hostId || 'Unknown owner';
}
private openJoinedRoom(room: Room): void {
this.joinedServerMenuId.set(null);
this.store.dispatch(RoomsActions.viewServer({ room }));
}
private toServerInfo(room: Room): ServerInfo {
return {
id: room.id,

View File

@@ -4,15 +4,10 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import {
Observable,
forkJoin,
merge,
of,
throwError
} from 'rxjs';
import {
catchError,
map,
scan
} from 'rxjs/operators';
import { catchError, map } from 'rxjs/operators';
import {
ChannelPermissionOverride,
type Channel,
@@ -36,19 +31,6 @@ import type {
} from '../../domain/models/server-directory.model';
import type { RoomSignalSourceInput } from '../../domain/logic/room-signal-source.logic';
interface ServerLookupError {
status?: number;
error?: {
errorCode?: unknown;
};
}
function isServerNotFoundError(error: unknown): boolean {
const lookupError = error as ServerLookupError;
return lookupError?.status === 404 && lookupError.error?.errorCode === 'SERVER_NOT_FOUND';
}
@Injectable({ providedIn: 'root' })
export class ServerDirectoryApiService {
private readonly http = inject(HttpClient);
@@ -107,10 +89,6 @@ export class ServerDirectoryApiService {
return this.http.get<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`).pipe(
map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))),
catchError((error) => {
if (isServerNotFoundError(error)) {
return of(null);
}
console.error('Failed to get server:', error);
return of(null);
})
@@ -321,8 +299,9 @@ export class ServerDirectoryApiService {
return this.searchSingleEndpoint(query, this.getApiBaseUrl(), this.endpointState.activeServer());
}
return merge(...onlineEndpoints.map((endpoint) => this.searchSingleEndpoint(query, `${endpoint.url}/api`, endpoint))).pipe(
scan((servers, endpointServers) => this.deduplicateById([...servers, ...endpointServers]), [] as ServerInfo[])
return forkJoin(onlineEndpoints.map((endpoint) => this.searchSingleEndpoint(query, `${endpoint.url}/api`, endpoint))).pipe(
map((resultArrays) => resultArrays.flat()),
map((servers) => this.deduplicateById(servers))
);
}

View File

@@ -59,11 +59,6 @@ export class ElementPickerService {
this.hoveredKey.set(null);
this.isPicking.set(false);
if (this.resumePage === 'theme') {
this.modal.openThemeStudio();
return;
}
if (this.resumePage) {
this.modal.open(this.resumePage);
}
@@ -129,6 +124,8 @@ export class ElementPickerService {
const key = themedElement?.dataset['themeKey'] ?? null;
const definition = this.registry.getDefinition(key);
return definition?.pickerVisible ? key : null;
return definition?.pickerVisible
? key
: null;
}
}

View File

@@ -9,7 +9,7 @@ import {
ThemeGridEditorItem,
ThemeGridRect
} from '../../domain/models/theme.model';
import { createDefaultThemeDocument, createDefaultThemeLayout } from '../../domain/logic/theme-defaults.logic';
import { createDefaultThemeDocument } from '../../domain/logic/theme-defaults.logic';
import { ThemeRegistryService } from './theme-registry.service';
import { ThemeService } from './theme.service';
@@ -26,46 +26,37 @@ export class LayoutSyncService {
itemsForContainer(containerKey: ThemeContainerKey): ThemeGridEditorItem[] {
const draftTheme = this.theme.draftTheme();
const defaults = createDefaultThemeLayout();
const defaults = createDefaultThemeDocument();
return this.registry
.entries()
return this.registry.entries()
.filter((entry) => entry.layoutEditable && entry.container === containerKey)
.map((entry) => ({
key: entry.key,
label: entry.label,
description: entry.description,
grid: draftTheme.layout[entry.key]?.grid ?? defaults[entry.key].grid
grid: draftTheme.layout[entry.key]?.grid ?? defaults.layout[entry.key].grid
}));
}
updateGrid(key: string, grid: ThemeGridRect): void {
this.theme.ensureLayoutEntry(key);
this.theme.updateStructuredDraft(
(draft: ReturnType<typeof createDefaultThemeDocument>) => {
draft.layout[key] = {
...draft.layout[key],
grid
};
},
true,
`${key} layout updated.`
);
this.theme.updateStructuredDraft((draft: ReturnType<typeof createDefaultThemeDocument>) => {
draft.layout[key] = {
...draft.layout[key],
grid
};
}, true, `${key} layout updated.`);
}
resetContainer(containerKey: ThemeContainerKey): void {
const defaults = createDefaultThemeLayout();
const defaults = createDefaultThemeDocument();
this.theme.updateStructuredDraft(
(draft: ReturnType<typeof createDefaultThemeDocument>) => {
for (const entry of this.registry.entries()) {
if (entry.container === containerKey && entry.layoutEditable) {
draft.layout[entry.key] = defaults[entry.key];
}
this.theme.updateStructuredDraft((draft: ReturnType<typeof createDefaultThemeDocument>) => {
for (const entry of this.registry.entries()) {
if (entry.container === containerKey && entry.layoutEditable) {
draft.layout[entry.key] = defaults.layout[entry.key];
}
},
true,
`${containerKey} restored to its default layout.`
);
}
}, true, `${containerKey} restored to its default layout.`);
}
}

View File

@@ -1,489 +0,0 @@
import { DOCUMENT } from '@angular/common';
import { EnvironmentInjector, createEnvironmentInjector } from '@angular/core';
import { DEFAULT_THEME_JSON, createDefaultThemeDocument } from '../../domain/logic/theme-defaults.logic';
import { validateThemeDocument } from '../../domain/logic/theme-validation.logic';
import { ThemeService } from './theme.service';
describe('ThemeService theme application', () => {
let injector: EnvironmentInjector;
let styleElements: TestStyleElement[];
let service: ThemeService;
beforeEach(() => {
installLocalStorageMock();
styleElements = [];
injector = createEnvironmentInjector([
ThemeService,
{
provide: DOCUMENT,
useValue: createDocumentStub(styleElements)
}
]);
service = injector.get(ThemeService);
service.initialize();
});
afterEach(() => {
injector.destroy();
vi.unstubAllGlobals();
});
it('uses the compact Toju dark theme as the built-in default JSON', () => {
expect(JSON.parse(DEFAULT_THEME_JSON) as unknown).toEqual({
meta: {
name: 'Toju Default Dark',
version: '2.0.0',
description: 'Built-in dark glass theme for the full Toju app shell.'
},
tokens: {
colors: {
background: '224 28% 7%',
foreground: '210 40% 96%',
card: '224 25% 10%',
cardForeground: '210 40% 96%',
popover: '224 26% 9%',
popoverForeground: '210 40% 96%',
primary: '193 95% 68%',
primaryForeground: '222 47% 11%',
secondary: '223 19% 16%',
secondaryForeground: '210 40% 96%',
muted: '223 18% 14%',
mutedForeground: '215 20% 70%',
accent: '218 22% 18%',
accentForeground: '210 40% 98%',
destructive: '0 72% 55%',
destructiveForeground: '0 0% 100%',
border: '222 18% 22%',
input: '222 18% 22%',
ring: '193 95% 68%',
railBackground: '226 33% 8%',
workspaceBackground: '224 30% 9%',
panelBackground: '224 24% 11%',
panelBackgroundAlt: '222 22% 13%',
titleBarBackground: '226 34% 7%',
surfaceHighlight: '193 95% 68%',
surfaceHighlightAlt: '261 82% 72%'
},
spacing: {},
radii: {
radius: '0.875rem',
surface: '1.35rem',
pill: '999px'
},
effects: {
panelShadow: '0 24px 60px rgba(0, 0, 0, 0.42)',
softShadow: '0 14px 36px rgba(0, 0, 0, 0.28)',
glassBlur: 'blur(18px) saturate(135%)'
}
},
layout: {
serversRail: {
container: 'appShell',
grid: { x: 0, y: 0, w: 1, h: 1 }
},
appWorkspace: {
container: 'appShell',
grid: { x: 1, y: 0, w: 19, h: 1 }
},
chatRoomChannelsPanel: {
container: 'roomLayout',
grid: { x: 0, y: 0, w: 4, h: 12 }
},
chatRoomMainPanel: {
container: 'roomLayout',
grid: { x: 4, y: 0, w: 12, h: 12 }
},
chatRoomMembersPanel: {
container: 'roomLayout',
grid: { x: 16, y: 0, w: 4, h: 12 }
}
}
});
});
it('applies a JSON theme with tokens, layout, backgrounds, effects, metadata, and animations', () => {
const theme = createCompleteThemeDocument();
const loaded = service.loadThemeText(JSON.stringify(theme), 'apply', 'Theme applied.', 'complete JSON theme');
expect(loaded).toBe(true);
expect(service.activeThemeName()).toBe('Complete Theme Fixture');
expect(service.getTextOverride('titleBar')).toBe('MetoYou Lab');
expect(service.getIcon('titleBar')).toBe('MT');
expect(service.getLink('titleBar')).toBe('https://example.com/theme');
expect(service.getAnimationClass('titleBar')).toBe('themePulse');
expect(service.getHostStyles('appRoot')).toMatchObject({
'--background': '210 22% 8%',
'--theme-spacing-panel-gap': '14px',
'--radius': '1rem',
'--theme-radius-surface': '1.25rem',
'--theme-effect-glass-blur': 'blur(20px) saturate(140%)'
});
expect(service.getHostStyles('titleBar')).toMatchObject({
width: 'min(100%, 64rem)',
height: '4rem',
minWidth: '18rem',
minHeight: '3rem',
maxWidth: '72rem',
maxHeight: '5rem',
position: 'sticky',
top: '0',
right: '0',
bottom: 'auto',
left: '0',
padding: '0.75rem 1rem',
margin: '0 auto',
border: '1px solid hsl(var(--border) / 0.75)',
borderRadius: '1rem',
backgroundColor: 'rgba(7, 11, 20, 0.88)',
color: '#f8fafc',
backgroundImage: 'linear-gradient(90deg, rgba(14, 165, 233, 0.28), transparent), url("/themes/city.png")',
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
boxShadow: '0 18px 50px rgba(0, 0, 0, 0.35)',
backdropFilter: 'var(--theme-effect-glass-blur)',
opacity: '0.82'
});
expect(service.getLayoutContainerStyles('dmLayout')).toMatchObject({
display: 'grid',
gridTemplateColumns: 'repeat(4, 4.25rem) repeat(16, minmax(0, 1fr))'
});
expect(service.getLayoutItemStyles('dmChatPanel')).toMatchObject({
gridColumn: '7 / span 14',
gridRow: '2 / span 10'
});
const animationStylesheet = styleElements.find((styleElement) => styleElement.textContent?.includes('@keyframes themePulse'));
expect(animationStylesheet?.textContent).toContain('@keyframes themePulse');
expect(animationStylesheet?.textContent).toContain('animation-direction: alternate-reverse;');
expect(animationStylesheet?.textContent).toContain('transform: scale(1);');
});
it('applies raw CSS from the theme JSON', () => {
const loaded = service.loadThemeText(
JSON.stringify({
meta: {
name: 'CSS Theme',
version: '1.0.0'
},
css: '.theme-css-probe { color: hsl(var(--primary)); }'
}),
'apply',
'Theme applied.',
'CSS JSON theme'
);
expect(loaded).toBe(true);
expect(styleElements.some((styleElement) => styleElement.textContent === '.theme-css-probe { color: hsl(var(--primary)); }')).toBe(true);
expect(service.activeThemeText()).toContain('"css"');
});
it('applies a CSS-only theme over the default JSON theme', () => {
const applied = service.applyCssOnlyTheme('.css-only-theme { background: hsl(var(--background)); }');
expect(applied).toBe(true);
expect(service.activeThemeName()).toBe('Toju Default Dark');
expect(service.getLayoutItemStyles('dmChatPanel')).toMatchObject({
gridColumn: '5 / span 16',
gridRow: '1 / span 12'
});
expect(service.getHostStyles('appRoot')).toMatchObject({
'--background': '224 28% 7%'
});
expect(styleElements.some((styleElement) => styleElement.textContent === '.css-only-theme { background: hsl(var(--background)); }')).toBe(true);
});
it('applies CSS over the current JSON draft theme', () => {
const draftTheme = createDefaultThemeDocument();
draftTheme.meta = {
name: 'Draft Base Theme',
version: '1.0.0'
};
draftTheme.tokens.colors['background'] = '200 50% 10%';
draftTheme.layout['dmChatPanel'] = {
container: 'dmLayout',
grid: { x: 6, y: 0, w: 14, h: 12 }
};
const loadedDraft = service.loadThemeText(JSON.stringify(draftTheme), 'draft', 'Draft loaded.', 'draft JSON theme');
const applied = service.applyCssOnlyTheme('.draft-base-theme { color: hsl(var(--foreground)); }');
expect(loadedDraft).toBe(true);
expect(applied).toBe(true);
expect(service.activeThemeName()).toBe('Draft Base Theme');
expect(service.getHostStyles('appRoot')).toMatchObject({
'--background': '200 50% 10%'
});
expect(service.getLayoutItemStyles('dmChatPanel')).toMatchObject({
gridColumn: '7 / span 14',
gridRow: '1 / span 12'
});
expect(styleElements.some((styleElement) => styleElement.textContent === '.draft-base-theme { color: hsl(var(--foreground)); }')).toBe(true);
});
it('builds exportable JSON with CSS over the current draft without applying it', () => {
const draftTheme = createDefaultThemeDocument();
draftTheme.meta = {
name: 'Export Base Theme',
version: '1.0.0'
};
draftTheme.tokens.colors['background'] = '180 40% 12%';
const loadedDraft = service.loadThemeText(JSON.stringify(draftTheme), 'draft', 'Draft loaded.', 'draft JSON theme');
const exportText = service.buildDraftTextWithCss('.export-base-theme { color: hsl(var(--foreground)); }');
expect(loadedDraft).toBe(true);
expect(exportText).not.toBeNull();
expect(JSON.parse(exportText ?? '{}')).toMatchObject({
meta: {
name: 'Export Base Theme'
},
css: '.export-base-theme { color: hsl(var(--foreground)); }',
tokens: {
colors: {
background: '180 40% 12%'
}
}
});
expect(service.activeThemeName()).toBe('Toju Default Dark');
});
it('validates the dedicated DM workspace layout container', () => {
const theme = createDefaultThemeDocument();
theme.layout['dmConversationsPanel'] = {
container: 'dmLayout',
grid: { x: 0, y: 0, w: 5, h: 12 }
};
theme.layout['dmChatPanel'] = {
container: 'dmLayout',
grid: { x: 5, y: 0, w: 15, h: 12 }
};
const result = validateThemeDocument(theme);
expect(result.valid).toBe(true);
expect(result.value?.layout['dmChatPanel'].container).toBe('dmLayout');
});
it('allows compact JSON themes with omitted element sections', () => {
const loaded = service.loadThemeText(
JSON.stringify({
meta: {
name: 'Compact Theme',
version: '1.0.0'
},
elements: {
titleBar: {
backgroundImage: '/themes/city.png',
backgroundSize: 'cover'
}
}
}),
'apply',
'Theme applied.',
'compact JSON theme'
);
expect(loaded).toBe(true);
expect(service.getHostStyles('titleBar')).toMatchObject({
backgroundImage: 'url("/themes/city.png")',
backgroundSize: 'cover'
});
expect(service.getHostStyles('voiceWorkspace')).toEqual({});
expect(service.activeThemeText()).not.toContain('voiceWorkspace');
});
it('omits empty element stubs when formatting themes', () => {
const loaded = service.loadThemeText(
JSON.stringify({
meta: {
name: 'Theme With Empty Elements',
version: '1.0.0'
},
elements: {
titleBar: {},
voiceWorkspace: {}
}
}),
'apply',
'Theme applied.',
'theme with empty elements'
);
expect(loaded).toBe(true);
expect(service.activeThemeText()).not.toContain('"elements"');
});
it('keeps only non-empty element overrides when formatting themes', () => {
const loaded = service.loadThemeText(
JSON.stringify({
meta: {
name: 'Theme With Mixed Elements',
version: '1.0.0'
},
elements: {
titleBar: {},
chatRoomMainPanel: {
backgroundImage: '/themes/city.png'
}
}
}),
'apply',
'Theme applied.',
'theme with mixed elements'
);
const activeTheme = JSON.parse(service.activeThemeText()) as { elements?: Record<string, unknown> };
expect(loaded).toBe(true);
expect(activeTheme.elements).toEqual({
chatRoomMainPanel: {
backgroundImage: '/themes/city.png'
}
});
});
it('allows removing the entire elements section', () => {
const loaded = service.loadThemeText(
JSON.stringify({
meta: {
name: 'No Elements Theme',
version: '1.0.0'
}
}),
'apply',
'Theme applied.',
'compact JSON theme'
);
expect(loaded).toBe(true);
expect(service.getHostStyles('titleBar')).toEqual({});
expect(service.activeThemeText()).not.toContain('"elements"');
});
});
interface TestStyleElement {
textContent: string | null;
setAttribute(name: string, value: string): void;
}
function createCompleteThemeDocument() {
const theme = createDefaultThemeDocument();
theme.meta = {
name: 'Complete Theme Fixture',
version: '9.9.9',
description: 'Exercises every supported applied theme surface.'
};
theme.tokens.colors['background'] = '210 22% 8%';
theme.tokens.spacing['panelGap'] = '14px';
theme.tokens.radii['radius'] = '1rem';
theme.tokens.radii['surface'] = '1.25rem';
theme.tokens.effects['glassBlur'] = 'blur(20px) saturate(140%)';
theme.layout['dmChatPanel'] = {
container: 'dmLayout',
grid: { x: 6, y: 1, w: 14, h: 10 }
};
theme.elements['titleBar'] = {
width: 'min(100%, 64rem)',
height: '4rem',
minWidth: '18rem',
minHeight: '3rem',
maxWidth: '72rem',
maxHeight: '5rem',
position: 'sticky',
top: '0',
right: '0',
bottom: 'auto',
left: '0',
opacity: 0.82,
padding: '0.75rem 1rem',
margin: '0 auto',
border: '1px solid hsl(var(--border) / 0.75)',
borderRadius: '1rem',
backgroundColor: 'rgba(7, 11, 20, 0.88)',
color: '#f8fafc',
backgroundImage: '/themes/city.png',
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
gradient: 'linear-gradient(90deg, rgba(14, 165, 233, 0.28), transparent)',
boxShadow: '0 18px 50px rgba(0, 0, 0, 0.35)',
backdropFilter: 'var(--theme-effect-glass-blur)',
icon: 'MT',
textOverride: 'MetoYou Lab',
link: 'https://example.com/theme',
animationClass: 'themePulse'
};
theme.animations['themePulse'] = {
duration: '600ms',
easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
delay: '50ms',
iterationCount: 'infinite',
fillMode: 'both',
direction: 'alternate-reverse',
keyframes: {
from: {
opacity: 0,
transform: 'scale(0.98)'
},
to: {
opacity: 1,
transform: 'scale(1)'
}
}
};
return theme;
}
function createDocumentStub(styleElements: TestStyleElement[]): Document {
return {
createElement: () => {
const styleElement: TestStyleElement = {
textContent: null,
setAttribute: () => undefined
};
return styleElement;
},
head: {
appendChild: (styleElement: TestStyleElement) => {
styleElements.push(styleElement);
return styleElement;
}
}
} as unknown as Document;
}
function installLocalStorageMock(): void {
const values = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
clear: () => values.clear()
});
}

View File

@@ -17,7 +17,6 @@ import {
import {
DEFAULT_THEME_JSON,
createDefaultThemeDocument,
createDefaultThemeLayout,
isLegacyDefaultThemeDocument
} from '../../domain/logic/theme-defaults.logic';
import { createAnimationStarterDefinition } from '../../domain/logic/theme-schema.logic';
@@ -36,68 +35,14 @@ function toKebabCase(value: string): string {
.toLowerCase();
}
function isEmptyRecord(value: Record<string, unknown>): boolean {
return Object.keys(value).length === 0;
}
function hasTokenOverrides(document: ThemeDocument): boolean {
return (
!isEmptyRecord(document.tokens.colors) ||
!isEmptyRecord(document.tokens.spacing) ||
!isEmptyRecord(document.tokens.radii) ||
!isEmptyRecord(document.tokens.effects)
);
}
function compactThemeElements(elements: ThemeDocument['elements']): ThemeDocument['elements'] {
return Object.fromEntries(Object.entries(elements).filter(([_key, styles]) => !isEmptyRecord(styles as Record<string, unknown>)));
}
function stringifyTheme(document: ThemeDocument): string {
const jsonDocument: Partial<ThemeDocument> = {
meta: document.meta
};
const compactElements = compactThemeElements(document.elements);
if (document.css.trim().length > 0) {
jsonDocument.css = document.css;
}
if (hasTokenOverrides(document)) {
jsonDocument.tokens = document.tokens;
}
if (!isEmptyRecord(document.layout)) {
jsonDocument.layout = document.layout;
}
if (!isEmptyRecord(compactElements)) {
jsonDocument.elements = compactElements;
}
if (!isEmptyRecord(document.animations)) {
jsonDocument.animations = document.animations;
}
return JSON.stringify(jsonDocument, null, 2);
}
function looksLikeImageReference(value: string): boolean {
return value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/') || value.startsWith('./') || value.startsWith('../');
}
function normalizeBackgroundImageLayer(value: string): string {
const trimmedValue = value.trim();
if (!looksLikeImageReference(trimmedValue)) {
return trimmedValue;
}
return `url("${trimmedValue.replace(/"/g, '\\"')}")`;
return JSON.stringify(document, null, 2);
}
function resolveBuiltInDefaultMigration(document: ThemeDocument): ThemeDocument {
return isLegacyDefaultThemeDocument(document) ? createDefaultThemeDocument() : document;
return isLegacyDefaultThemeDocument(document)
? createDefaultThemeDocument()
: document;
}
const hostStylePropertyKeys = [
@@ -151,7 +96,6 @@ export class ThemeService {
private initialized = false;
private statusTimeoutId: ReturnType<typeof setTimeout> | null = null;
private animationStyleElement: HTMLStyleElement | null = null;
private cssStyleElement: HTMLStyleElement | null = null;
constructor() {
this.activeTheme = this.activeThemeInternal.asReadonly();
@@ -215,7 +159,6 @@ export class ThemeService {
}
this.syncAnimationStylesheet();
this.syncCssStylesheet();
}
updateDraftText(text: string): void {
@@ -260,26 +203,12 @@ export class ThemeService {
return true;
}
buildDraftTextWithCss(css: string): string | null {
const theme = this.composeDraftThemeWithCss(css);
return theme ? stringifyTheme(theme) : null;
}
applyCssOnlyTheme(css: string): boolean {
const theme = this.composeDraftThemeWithCss(css);
if (!theme) {
return false;
}
const formatted = stringifyTheme(theme);
this.commitTheme(theme, formatted, 'CSS applied over the JSON theme.');
return true;
}
loadThemeText(text: string, mode: 'draft' | 'apply', successMessage: string, sourceLabel = 'theme'): boolean {
loadThemeText(
text: string,
mode: 'draft' | 'apply',
successMessage: string,
sourceLabel = 'theme'
): boolean {
const result = this.parseAndValidateTheme(text, sourceLabel);
if (!result.valid || !result.value) {
@@ -321,8 +250,9 @@ export class ThemeService {
saveActiveThemeText(defaultText);
saveDraftThemeText(defaultText);
this.syncAnimationStylesheet();
this.syncCssStylesheet();
this.setStatusMessage(reason === 'shortcut' ? 'Theme reset to the default preset by shortcut.' : 'Theme reset to the default preset.');
this.setStatusMessage(reason === 'shortcut'
? 'Theme reset to the default preset by shortcut.'
: 'Theme reset to the default preset.');
}
handleGlobalShortcut(event: KeyboardEvent): boolean {
@@ -338,71 +268,41 @@ export class ThemeService {
}
ensureElementEntry(key: string): void {
if (!this.draftIsValidInternal()) {
this.setStatusMessage('Fix JSON errors before using the structured theme tools.');
return;
}
const draftJson = JSON.parse(this.draftTextInternal()) as Record<string, unknown>;
const existingElements = draftJson['elements'];
const elements =
existingElements && typeof existingElements === 'object' && !Array.isArray(existingElements)
? { ...(existingElements as Record<string, unknown>) }
: {};
elements[key] = elements[key] ?? {};
draftJson['elements'] = elements;
const result = validateThemeDocument(draftJson);
if (!result.valid || !result.value) {
this.setStatusMessage('The structured change could not be validated.');
return;
}
const formatted = JSON.stringify(draftJson, null, 2);
this.draftThemeInternal.set(result.value);
this.draftTextInternal.set(formatted);
this.draftIsValidInternal.set(true);
this.draftErrorsInternal.set([]);
saveDraftThemeText(formatted);
this.setStatusMessage(`Prepared ${key} in the theme draft.`);
this.updateStructuredDraft((draft) => {
draft.elements[key] = draft.elements[key] ?? {};
}, false, `Prepared ${key} in the theme draft.`);
}
ensureLayoutEntry(key: string): void {
this.updateStructuredDraft(
(draft) => {
const defaults = createDefaultThemeLayout();
this.updateStructuredDraft((draft) => {
const defaults = createDefaultThemeDocument();
draft.layout[key] = draft.layout[key] ?? defaults[key];
},
false,
`Prepared ${key} layout in the theme draft.`
);
draft.layout[key] = draft.layout[key] ?? defaults.layout[key];
}, false, `Prepared ${key} layout in the theme draft.`);
}
setElementStyle(key: string, property: ThemeElementStyleProperty, value: string | number, applyImmediately = true): void {
this.updateStructuredDraft(
(draft) => {
draft.elements[key] = {
...draft.elements[key],
[property]: value
};
},
applyImmediately,
`${key} updated.`
);
setElementStyle(
key: string,
property: ThemeElementStyleProperty,
value: string | number,
applyImmediately = true
): void {
this.updateStructuredDraft((draft) => {
draft.elements[key] = {
...draft.elements[key],
[property]: value
};
}, applyImmediately, `${key} updated.`);
}
setAnimation(key: string, definition: ThemeAnimationDefinition = createAnimationStarterDefinition(), applyImmediately = true): void {
this.updateStructuredDraft(
(draft) => {
draft.animations[key] = definition;
},
applyImmediately,
`Animation ${key} updated.`
);
setAnimation(
key: string,
definition: ThemeAnimationDefinition = createAnimationStarterDefinition(),
applyImmediately = true
): void {
this.updateStructuredDraft((draft) => {
draft.animations[key] = definition;
}, applyImmediately, `Animation ${key} updated.`);
}
getHostStyles(key: string): Record<string, string> {
@@ -414,8 +314,7 @@ export class ThemeService {
}
const backgroundLayers = [elementTheme.gradient, elementTheme.backgroundImage]
.filter((layer): layer is string => typeof layer === 'string' && layer.trim().length > 0)
.map((layer) => normalizeBackgroundImageLayer(layer));
.filter((layer): layer is string => typeof layer === 'string' && layer.trim().length > 0);
if (backgroundLayers.length > 0) {
styles['backgroundImage'] = backgroundLayers.join(', ');
@@ -439,7 +338,9 @@ export class ThemeService {
getAnimationClass(key: string): string | null {
const animationClass = this.activeThemeInternal().elements[key]?.animationClass?.trim();
return animationClass && animationClass.length > 0 ? animationClass : null;
return animationClass && animationClass.length > 0
? animationClass
: null;
}
getLink(key: string): string | null {
@@ -466,15 +367,17 @@ export class ThemeService {
return {
display: 'grid',
gridTemplateColumns: container.templateColumns ?? `repeat(${container.columns}, minmax(0, 1fr))`,
gridTemplateRows: container.templateRows ?? (container.rows === 1 ? 'minmax(0, 1fr)' : `repeat(${container.rows}, minmax(0, 1fr))`),
gridTemplateRows: container.templateRows ?? (container.rows === 1
? 'minmax(0, 1fr)'
: `repeat(${container.rows}, minmax(0, 1fr))`),
minHeight: '0',
minWidth: '0'
};
}
getLayoutItemStyles(key: string): Record<string, string> {
const defaults = createDefaultThemeLayout();
const layoutEntry = this.activeThemeInternal().layout[key] ?? defaults[key];
const defaults = createDefaultThemeDocument();
const layoutEntry = this.activeThemeInternal().layout[key] ?? defaults.layout[key];
if (!layoutEntry) {
return {};
@@ -488,7 +391,11 @@ export class ThemeService {
};
}
updateStructuredDraft(mutator: (draft: ThemeDocument) => void, applyImmediately: boolean, successMessage: string): void {
updateStructuredDraft(
mutator: (draft: ThemeDocument) => void,
applyImmediately: boolean,
successMessage: string
): void {
if (!this.draftIsValidInternal()) {
this.setStatusMessage('Fix JSON errors before using the structured theme tools.');
return;
@@ -531,30 +438,9 @@ export class ThemeService {
saveActiveThemeText(text);
saveDraftThemeText(text);
this.syncAnimationStylesheet();
this.syncCssStylesheet();
this.setStatusMessage(successMessage);
}
private composeDraftThemeWithCss(css: string): ThemeDocument | null {
if (!this.draftIsValidInternal()) {
this.setStatusMessage('Fix JSON errors before applying CSS over the theme draft.');
return null;
}
const theme = {
...structuredClone(this.draftThemeInternal()),
css
};
const result = validateThemeDocument(theme);
if (!result.valid || !result.value) {
this.setStatusMessage(result.errors[0] ?? 'The CSS-only theme could not be applied.');
return null;
}
return result.value;
}
private parseAndValidateTheme(text: string, label: string) {
try {
return validateThemeDocument(JSON.parse(text) as unknown);
@@ -579,7 +465,9 @@ export class ThemeService {
}
for (const [tokenName, tokenValue] of Object.entries(theme.tokens.radii)) {
const cssVariableName = tokenName === 'radius' ? '--radius' : `--theme-radius-${toKebabCase(tokenName)}`;
const cssVariableName = tokenName === 'radius'
? '--radius'
: `--theme-radius-${toKebabCase(tokenName)}`;
styles[cssVariableName] = tokenValue;
}
@@ -607,18 +495,6 @@ export class ThemeService {
this.animationStyleElement.textContent = css;
}
private syncCssStylesheet(): void {
const css = this.activeThemeInternal().css.trim();
if (!this.cssStyleElement) {
this.cssStyleElement = this.documentRef.createElement('style');
this.cssStyleElement.setAttribute('data-toju-theme-css', 'true');
this.documentRef.head.appendChild(this.cssStyleElement);
}
this.cssStyleElement.textContent = css;
}
private buildAnimationRule(className: string, definition: ThemeAnimationDefinition): string {
const animationClass = `.${className}`;
const declarationLines = [

View File

@@ -7,11 +7,15 @@ import {
} from '../logic/theme-schema.logic';
function formatExample(example: string | number): string {
return typeof example === 'number' ? `${example}` : JSON.stringify(example);
return typeof example === 'number'
? `${example}`
: JSON.stringify(example);
}
function getLayoutKeysForContainer(containerKey: string): string[] {
return THEME_REGISTRY.filter((entry) => entry.container === containerKey && entry.layoutEditable).map((entry) => entry.key);
return THEME_REGISTRY
.filter((entry) => entry.container === containerKey && entry.layoutEditable)
.map((entry) => entry.key);
}
function describeCapabilities(entry: (typeof THEME_REGISTRY)[number]): string {
@@ -22,7 +26,9 @@ function describeCapabilities(entry: (typeof THEME_REGISTRY)[number]): string {
entry.supportsIcon ? 'icon' : null
].filter((value): value is string => value !== null);
return capabilities.length > 0 ? capabilities.join(', ') : 'visual style overrides only';
return capabilities.length > 0
? capabilities.join(', ')
: 'visual style overrides only';
}
function describeLayoutContainer(container: (typeof THEME_LAYOUT_CONTAINERS)[number]): string {
@@ -50,16 +56,15 @@ function describeThemeEntry(entry: (typeof THEME_REGISTRY)[number]): string {
const colorTokenKeys = Object.keys(DEFAULT_THEME_DOCUMENT.tokens.colors);
const radiusTokenKeys = Object.keys(DEFAULT_THEME_DOCUMENT.tokens.radii);
const effectTokenKeys = Object.keys(DEFAULT_THEME_DOCUMENT.tokens.effects);
const layoutEditableKeys = THEME_REGISTRY.filter((entry) => entry.layoutEditable).map((entry) => entry.key);
const layoutContainerKeys = THEME_LAYOUT_CONTAINERS.map((container) => container.key);
const layoutContainerUnion = layoutContainerKeys.map((key) => `"${key}"`).join(' | ');
const layoutEditableKeys = THEME_REGISTRY
.filter((entry) => entry.layoutEditable)
.map((entry) => entry.key);
const guideTemplateDocument = {
meta: {
name: 'Theme Name',
version: '1.0.0',
description: 'Short mood and material direction.'
},
css: '',
tokens: {
colors: {
background: '224 28% 7%',
@@ -97,9 +102,6 @@ const guideTemplateDocument = {
},
chatRoomMainPanel: {
backgroundColor: 'hsl(var(--panel-background) / 0.82)',
backgroundImage: '/assets/themes/paper-noise.png',
backgroundSize: 'cover',
backgroundPosition: 'center',
borderRadius: 'var(--theme-radius-surface)',
boxShadow: 'var(--theme-effect-panel-shadow)'
}
@@ -117,13 +119,12 @@ export const THEME_LLM_GUIDE = [
'- Return JSON only when asked to generate a theme. Do not wrap the result in Markdown fences or add commentary.',
'',
'Core rules',
'- Supported top-level keys are meta, css, tokens, layout, elements, and animations. Omit sections that do not need overrides.',
'- Keep the top-level keys exactly: meta, tokens, layout, elements, animations.',
'- Use strict JSON with double-quoted keys, no comments, and no trailing commas.',
'- Omitted layout keys use the app default placement. Omitted element keys apply no Theme Studio visual override.',
'- Omitted optional keys inherit from the built-in default theme, so leave out anything you are not intentionally changing.',
'- Do not invent new top-level sections, layout containers, or element style properties.',
'- links must be absolute http or https URLs.',
'- animationClass must be a safe CSS class token and should match an entry in animations or an existing class already shipped by the app.',
'- css is optional raw CSS applied after the JSON theme is active. Keep selectors scoped to app/theme surfaces when possible.',
'- layout.grid values must be integers. x and y are zero-based. w and h must be greater than 0.',
'- opacity must be a number between 0 and 1.',
'',
@@ -138,8 +139,6 @@ export const THEME_LLM_GUIDE = [
'- tokens.colors entries become CSS variables like --background or --surface-highlight-alt.',
'- Color token values should usually be raw HSL channels such as "224 28% 7%". The shell wraps many built-in color tokens with hsl(var(--token)).',
'- You may add extra color tokens if you also reference them with CSS variables in element overrides.',
'- elements.<key>.backgroundImage accepts a CSS background-image value, a local image path, or an http/https image URL.',
'- Use backgroundSize and backgroundPosition with backgroundImage when the image needs cover/center behavior.',
'- tokens.spacing entries become --theme-spacing-<kebab-case-key>.',
'- tokens.radii.radius maps to --radius. Other radius keys become --theme-radius-<kebab-case-key>.',
'- tokens.effects entries become --theme-effect-<kebab-case-key>.',
@@ -149,9 +148,8 @@ export const THEME_LLM_GUIDE = [
'',
'Top-level schema reference',
'- meta: { name: string, version: string, description?: string }',
'- css?: string',
'- tokens: { colors: Record<string, string>, spacing: Record<string, string>, radii: Record<string, string>, effects: Record<string, string> }',
`- layout: Record<string, { container: ${layoutContainerUnion}, grid: { x: number, y: number, w: number, h: number } }>`,
'- layout: Record<string, { container: "appShell" | "roomLayout", grid: { x: number, y: number, w: number, h: number } }>',
'- elements: Record<string, ThemeElementStyles>',
'- animations: Record<string, { duration?, easing?, delay?, iterationCount?, fillMode?, direction?, keyframes? }>',
'',

Some files were not shown because too many files have changed in this diff Show More