feat: server image
This commit is contained in:
465
e2e/tests/chat/server-icon-sync.spec.ts
Normal file
465
e2e/tests/chat/server-icon-sync.spec.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { chromium, type BrowserContext, type Locator, type Page, type Route } from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/multi-client';
|
||||
import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint';
|
||||
import { installWebRTCTracking } from '../../helpers/webrtc-helpers';
|
||||
import { LoginPage } from '../../pages/login.page';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
||||
|
||||
interface TestUser {
|
||||
displayName: string;
|
||||
password: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface ImageUploadPayload {
|
||||
buffer: Buffer;
|
||||
dataUrl: string;
|
||||
mimeType: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface PersistentClient {
|
||||
context: BrowserContext;
|
||||
page: Page;
|
||||
user: TestUser;
|
||||
userDataDir: string;
|
||||
}
|
||||
|
||||
const STATIC_GIF_BASE64 = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
|
||||
const GIF_FRAME_MARKER = Buffer.from([0x21, 0xf9, 0x04]);
|
||||
const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'];
|
||||
const SERVER_ICON_SYNC_TIMEOUT_MS = 45_000;
|
||||
|
||||
test.describe('Server icon sync', () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test('loads the chat-server image for online, late-joining, restarted, and discovery users', async ({ testServer }) => {
|
||||
const suffix = uniqueName('server-icon');
|
||||
const serverName = `Icon Sync Server ${suffix}`;
|
||||
const icon = buildGifUpload('server-icon');
|
||||
const aliceUser: TestUser = {
|
||||
username: `alice_${suffix}`,
|
||||
displayName: 'Alice',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
const bobUser: TestUser = {
|
||||
username: `bob_${suffix}`,
|
||||
displayName: 'Bob',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
const carolUser: TestUser = {
|
||||
username: `carol_${suffix}`,
|
||||
displayName: 'Carol',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
const daveUser: TestUser = {
|
||||
username: `dave_${suffix}`,
|
||||
displayName: 'Dave',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
const clients: PersistentClient[] = [];
|
||||
|
||||
try {
|
||||
const alice = await createPersistentClient(aliceUser, testServer.port);
|
||||
const bob = await createPersistentClient(bobUser, testServer.port);
|
||||
|
||||
clients.push(alice, bob);
|
||||
|
||||
await test.step('Alice creates a server and Bob joins before the icon changes', async () => {
|
||||
await registerUser(alice);
|
||||
await registerUser(bob);
|
||||
|
||||
await new ServerSearchPage(alice.page).createServer(serverName, {
|
||||
description: 'Server icon synchronization E2E coverage'
|
||||
});
|
||||
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
|
||||
await joinServerFromSearch(bob.page, serverName);
|
||||
await waitForRoomReady(alice.page);
|
||||
await waitForRoomReady(bob.page);
|
||||
await waitForConnectedPeerCount(alice.page, 1);
|
||||
await waitForConnectedPeerCount(bob.page, 1);
|
||||
});
|
||||
|
||||
const roomUrl = alice.page.url();
|
||||
|
||||
await test.step('Alice uploads a server icon and sees it in every owner-facing place', async () => {
|
||||
await uploadServerIconFromSettings(alice.page, serverName, icon);
|
||||
|
||||
await expectServerSettingsIcon(alice.page, serverName, icon.dataUrl);
|
||||
await closeSettingsModal(alice.page);
|
||||
await expectRoomHeaderIcon(alice.page, serverName, icon.dataUrl);
|
||||
await expectRailIcon(alice.page, serverName, icon.dataUrl);
|
||||
});
|
||||
|
||||
await test.step('Bob was online during the change and receives the icon live', async () => {
|
||||
await expectRoomHeaderIcon(bob.page, serverName, icon.dataUrl);
|
||||
await expectRailIcon(bob.page, serverName, icon.dataUrl);
|
||||
});
|
||||
|
||||
const carol = await createPersistentClient(carolUser, testServer.port);
|
||||
|
||||
clients.push(carol);
|
||||
|
||||
await test.step('Carol joins after the change and loads the existing server icon', async () => {
|
||||
await registerUser(carol);
|
||||
await joinServerFromSearch(carol.page, serverName);
|
||||
await waitForRoomReady(carol.page);
|
||||
await waitForConnectedPeerCount(alice.page, 2);
|
||||
|
||||
await expectRoomHeaderIcon(carol.page, serverName, icon.dataUrl);
|
||||
await expectRailIcon(carol.page, serverName, icon.dataUrl);
|
||||
});
|
||||
|
||||
await test.step('Bob keeps the server icon after a full app restart', async () => {
|
||||
await restartPersistentClient(bob, testServer.port);
|
||||
await openRoomAfterRestart(bob, roomUrl);
|
||||
|
||||
await expectRoomHeaderIcon(bob.page, serverName, icon.dataUrl);
|
||||
await expectRailIcon(bob.page, serverName, icon.dataUrl);
|
||||
});
|
||||
|
||||
const dave = await createPersistentClient(daveUser, testServer.port);
|
||||
|
||||
clients.push(dave);
|
||||
|
||||
await test.step('Dave has not joined, but discovery loads the icon through a temporary peer sync', async () => {
|
||||
await registerUser(dave);
|
||||
await stripServerIconFromDirectorySearch(dave.page, serverName);
|
||||
await dave.page.goto('/search', { waitUntil: 'domcontentloaded' });
|
||||
await new ServerSearchPage(dave.page).searchInput.fill(serverName);
|
||||
|
||||
await expectSearchResultIcon(dave.page, serverName, icon.dataUrl);
|
||||
await expect(dave.page).toHaveURL(/\/search/);
|
||||
});
|
||||
} finally {
|
||||
await Promise.all(
|
||||
clients.map(async (client) => {
|
||||
await closePersistentClient(client);
|
||||
await rm(client.userDataDir, { recursive: true, force: true });
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function createPersistentClient(user: TestUser, testServerPort: number): Promise<PersistentClient> {
|
||||
const userDataDir = await mkdtemp(join(tmpdir(), 'metoyou-server-icon-e2e-'));
|
||||
const session = await launchPersistentSession(userDataDir, testServerPort);
|
||||
|
||||
return {
|
||||
context: session.context,
|
||||
page: session.page,
|
||||
user,
|
||||
userDataDir
|
||||
};
|
||||
}
|
||||
|
||||
async function restartPersistentClient(client: PersistentClient, testServerPort: number): Promise<void> {
|
||||
await closePersistentClient(client);
|
||||
|
||||
const session = await launchPersistentSession(client.userDataDir, testServerPort);
|
||||
|
||||
client.context = session.context;
|
||||
client.page = session.page;
|
||||
}
|
||||
|
||||
async function closePersistentClient(client: PersistentClient): Promise<void> {
|
||||
try {
|
||||
await client.context.close();
|
||||
} catch {
|
||||
// Ignore repeated cleanup attempts during finally.
|
||||
}
|
||||
}
|
||||
|
||||
async function launchPersistentSession(userDataDir: string, testServerPort: number): Promise<{ context: BrowserContext; page: Page }> {
|
||||
const context = await chromium.launchPersistentContext(userDataDir, {
|
||||
args: CLIENT_LAUNCH_ARGS,
|
||||
baseURL: 'http://localhost:4200',
|
||||
permissions: ['microphone', 'camera']
|
||||
});
|
||||
|
||||
await installTestServerEndpoint(context, testServerPort);
|
||||
|
||||
const page = context.pages()[0] ?? (await context.newPage());
|
||||
|
||||
await installWebRTCTracking(page);
|
||||
|
||||
return { context, page };
|
||||
}
|
||||
|
||||
async function registerUser(client: PersistentClient): Promise<void> {
|
||||
const registerPage = new RegisterPage(client.page);
|
||||
|
||||
await retryTransientNavigation(() => registerPage.goto());
|
||||
await registerPage.register(client.user.username, client.user.displayName, client.user.password);
|
||||
await expect(client.page).toHaveURL(/\/search/, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function joinServerFromSearch(page: Page, serverName: string): Promise<void> {
|
||||
await new ServerSearchPage(page).joinServerFromSearch(serverName);
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function openRoomAfterRestart(client: PersistentClient, roomUrl: string): Promise<void> {
|
||||
await retryTransientNavigation(() => client.page.goto(roomUrl, { waitUntil: 'domcontentloaded' }));
|
||||
|
||||
if (client.page.url().includes('/login')) {
|
||||
const loginPage = new LoginPage(client.page);
|
||||
|
||||
await loginPage.login(client.user.username, client.user.password);
|
||||
await expect(client.page).toHaveURL(/\/(search|room)\//, { timeout: 15_000 });
|
||||
await client.page.goto(roomUrl, { waitUntil: 'domcontentloaded' });
|
||||
}
|
||||
|
||||
await waitForRoomReady(client.page);
|
||||
}
|
||||
|
||||
async function uploadServerIconFromSettings(page: Page, serverName: string, icon: ImageUploadPayload): Promise<void> {
|
||||
await openServerSettings(page, serverName);
|
||||
|
||||
const fileInput = page.locator('#server-icon-upload');
|
||||
|
||||
await expect(fileInput).toBeAttached({ timeout: 10_000 });
|
||||
await fileInput.setInputFiles({
|
||||
name: icon.name,
|
||||
mimeType: icon.mimeType,
|
||||
buffer: icon.buffer
|
||||
});
|
||||
}
|
||||
|
||||
async function openServerSettings(page: Page, serverName: string): Promise<void> {
|
||||
await page.locator('app-title-bar button[title="Menu"]').click();
|
||||
|
||||
const titleBarMenu = page.locator('app-title-bar .absolute.right-0.top-full').first();
|
||||
|
||||
await expect(titleBarMenu).toBeVisible({ timeout: 5_000 });
|
||||
await titleBarMenu.getByRole('button', { name: 'Settings' }).click();
|
||||
|
||||
const dialog = page.locator('app-settings-modal');
|
||||
const serverSettingsTitle = dialog.getByRole('heading', { name: 'Server Settings' });
|
||||
|
||||
try {
|
||||
await expect(serverSettingsTitle).toBeVisible({ timeout: 2_000 });
|
||||
} catch {
|
||||
await openSettingsModalThroughAngularDevMode(page);
|
||||
await expect(serverSettingsTitle).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
const serverSelect = dialog.locator('select').first();
|
||||
|
||||
if ((await serverSelect.count()) > 0) {
|
||||
await expect(serverSelect).toContainText(serverName, { timeout: 10_000 });
|
||||
}
|
||||
|
||||
await dialog.getByRole('button', { name: 'Server', exact: true }).click();
|
||||
await expect(page.locator('app-server-settings')).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function openSettingsModalThroughAngularDevMode(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
type SettingsModalComponentHandle = {
|
||||
modal?: {
|
||||
open: (page: string) => void;
|
||||
};
|
||||
};
|
||||
type AngularDebugApi = {
|
||||
getComponent: (element: Element) => SettingsModalComponentHandle;
|
||||
applyChanges?: (component: SettingsModalComponentHandle) => void;
|
||||
};
|
||||
|
||||
const host = document.querySelector('app-settings-modal');
|
||||
const debugApi = (window as Window & { ng?: AngularDebugApi }).ng;
|
||||
const component = host && debugApi?.getComponent(host);
|
||||
|
||||
if (!component?.modal?.open) {
|
||||
throw new Error('Angular debug API could not open settings modal');
|
||||
}
|
||||
|
||||
component.modal.open('server');
|
||||
debugApi.applyChanges?.(component);
|
||||
});
|
||||
}
|
||||
|
||||
async function closeSettingsModal(page: Page): Promise<void> {
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('app-settings-modal').getByRole('heading', { name: 'Settings', exact: true })).not.toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function stripServerIconFromDirectorySearch(page: Page, serverName: string): Promise<void> {
|
||||
await page.route('**/api/servers**', async (route: Route) => {
|
||||
const response = await route.fetch();
|
||||
const contentType = response.headers()['content-type'] ?? '';
|
||||
|
||||
if (!contentType.includes('application/json')) {
|
||||
await route.fulfill({ response });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await response.json();
|
||||
|
||||
if (!body || !Array.isArray(body.servers)) {
|
||||
await route.fulfill({ response, json: body });
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
response,
|
||||
json: {
|
||||
...body,
|
||||
servers: body.servers.map((server: Record<string, unknown>) => {
|
||||
if (server['name'] !== serverName) {
|
||||
return server;
|
||||
}
|
||||
|
||||
const { icon: _icon, ...serverWithoutIcon } = server;
|
||||
|
||||
return serverWithoutIcon;
|
||||
})
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForRoomReady(page: Page): Promise<void> {
|
||||
const messagesPage = new ChatMessagesPage(page);
|
||||
|
||||
await messagesPage.waitForReady();
|
||||
await expect(page.locator('app-rooms-side-panel').last()).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function waitForConnectedPeerCount(page: Page, count: number, timeout = 30_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
(expectedCount) => {
|
||||
const connections =
|
||||
(
|
||||
window as {
|
||||
__rtcConnections?: RTCPeerConnection[];
|
||||
}
|
||||
).__rtcConnections ?? [];
|
||||
|
||||
return connections.filter((connection) => connection.connectionState === 'connected').length >= expectedCount;
|
||||
},
|
||||
count,
|
||||
{ timeout }
|
||||
);
|
||||
}
|
||||
|
||||
async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
try {
|
||||
return await navigate();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const isTransientNavigationError = message.includes('ERR_EMPTY_RESPONSE') || message.includes('ERR_CONNECTION_RESET');
|
||||
|
||||
if (!isTransientNavigationError || attempt === attempts) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error(`Navigation failed after ${attempts} attempts`);
|
||||
}
|
||||
|
||||
async function expectServerSettingsIcon(page: Page, serverName: string, expectedDataUrl: string): Promise<void> {
|
||||
const settingsPanel = page.locator('app-server-settings');
|
||||
const image = settingsPanel.locator(`img[alt="${serverName} icon"]`).first();
|
||||
|
||||
await expectImageLoadedWithSrc(image, expectedDataUrl, 'settings server icon');
|
||||
}
|
||||
|
||||
async function expectRoomHeaderIcon(page: Page, serverName: string, expectedDataUrl: string): Promise<void> {
|
||||
const channelsPanel = page.locator('app-rooms-side-panel').first();
|
||||
const image = channelsPanel.locator(`img[alt="${serverName} icon"]`).first();
|
||||
|
||||
await expectImageLoadedWithSrc(image, expectedDataUrl, 'room header server icon');
|
||||
}
|
||||
|
||||
async function expectRailIcon(page: Page, serverName: string, expectedDataUrl: string): Promise<void> {
|
||||
const image = page.locator(`app-servers-rail img[alt="${serverName} icon"]`).first();
|
||||
|
||||
await expectImageLoadedWithSrc(image, expectedDataUrl, 'servers rail icon');
|
||||
}
|
||||
|
||||
async function expectSearchResultIcon(page: Page, serverName: string, expectedDataUrl: string): Promise<void> {
|
||||
const serverCard = page.locator('app-server-search div[title]', { hasText: serverName }).first();
|
||||
const image = serverCard.locator(`img[alt="${serverName} icon"]`).first();
|
||||
|
||||
await expect(serverCard).toBeVisible({ timeout: 20_000 });
|
||||
await expectImageLoadedWithSrc(image, expectedDataUrl, 'search result server icon');
|
||||
}
|
||||
|
||||
async function expectImageLoadedWithSrc(image: Locator, expectedDataUrl: string, label: string): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
if ((await image.count()) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return image.getAttribute('src');
|
||||
},
|
||||
{
|
||||
timeout: SERVER_ICON_SYNC_TIMEOUT_MS,
|
||||
message: `${label} src should update`
|
||||
}
|
||||
)
|
||||
.toBe(expectedDataUrl);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
if ((await image.count()) === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return image.evaluate((element) => {
|
||||
const img = element as HTMLImageElement;
|
||||
|
||||
return img.complete && img.naturalWidth > 0 && img.naturalHeight > 0;
|
||||
});
|
||||
},
|
||||
{
|
||||
timeout: SERVER_ICON_SYNC_TIMEOUT_MS,
|
||||
message: `${label} should load`
|
||||
}
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
function buildGifUpload(label: string): ImageUploadPayload {
|
||||
const baseGif = Buffer.from(STATIC_GIF_BASE64, 'base64');
|
||||
const frameStart = baseGif.indexOf(GIF_FRAME_MARKER);
|
||||
|
||||
if (frameStart < 0) {
|
||||
throw new Error('Failed to locate GIF frame marker for server icon payload');
|
||||
}
|
||||
|
||||
const header = baseGif.subarray(0, frameStart);
|
||||
const frame = baseGif.subarray(frameStart, baseGif.length - 1);
|
||||
const commentData = Buffer.from(label, 'ascii');
|
||||
const commentExtension = Buffer.concat([Buffer.from([0x21, 0xfe, commentData.length]), commentData, Buffer.from([0x00])]);
|
||||
const buffer = Buffer.concat([header, commentExtension, frame, Buffer.from([0x3b])]);
|
||||
const base64 = buffer.toString('base64');
|
||||
|
||||
return {
|
||||
buffer,
|
||||
dataUrl: `data:image/gif;base64,${base64}`,
|
||||
mimeType: 'image/gif',
|
||||
name: `server-icon-${label}.gif`
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueName(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export async function handleUpsertServer(command: UpsertServerCommand, dataSourc
|
||||
isPrivate: server.isPrivate ? 1 : 0,
|
||||
maxUsers: server.maxUsers,
|
||||
currentUsers: server.currentUsers,
|
||||
icon: server.icon ?? null,
|
||||
iconUpdatedAt: server.iconUpdatedAt ?? 0,
|
||||
slowModeInterval: server.slowModeInterval ?? 0,
|
||||
createdAt: server.createdAt,
|
||||
lastSeen: server.lastSeen
|
||||
|
||||
@@ -47,6 +47,8 @@ export function rowToServer(
|
||||
isPrivate: !!row.isPrivate,
|
||||
maxUsers: row.maxUsers,
|
||||
currentUsers: row.currentUsers,
|
||||
icon: row.icon ?? undefined,
|
||||
iconUpdatedAt: row.iconUpdatedAt || undefined,
|
||||
slowModeInterval: relationPayload.slowModeInterval,
|
||||
tags: relationPayload.tags,
|
||||
channels: relationPayload.channels,
|
||||
|
||||
@@ -86,6 +86,8 @@ export interface ServerPayload {
|
||||
isPrivate: boolean;
|
||||
maxUsers: number;
|
||||
currentUsers: number;
|
||||
icon?: string;
|
||||
iconUpdatedAt?: number;
|
||||
slowModeInterval?: number;
|
||||
tags: string[];
|
||||
channels: ServerChannelPayload[];
|
||||
|
||||
@@ -33,6 +33,12 @@ export class ServerEntity {
|
||||
@Column('integer', { default: 0 })
|
||||
currentUsers!: number;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
icon!: string | null;
|
||||
|
||||
@Column('integer', { default: 0 })
|
||||
iconUpdatedAt!: number;
|
||||
|
||||
@Column('integer', { default: 0 })
|
||||
slowModeInterval!: number;
|
||||
|
||||
|
||||
31
server/src/migrations/1000000000009-ServerIcons.ts
Normal file
31
server/src/migrations/1000000000009-ServerIcons.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ServerIcons1000000000009 implements MigrationInterface {
|
||||
name = 'ServerIcons1000000000009';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "icon" TEXT`);
|
||||
await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "iconUpdatedAt" INTEGER NOT NULL DEFAULT 0`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE TABLE "servers_without_icons" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"ownerId" TEXT NOT NULL,
|
||||
"ownerPublicKey" TEXT NOT NULL,
|
||||
"passwordHash" TEXT,
|
||||
"isPrivate" INTEGER NOT NULL DEFAULT 0,
|
||||
"maxUsers" INTEGER NOT NULL DEFAULT 0,
|
||||
"currentUsers" INTEGER NOT NULL DEFAULT 0,
|
||||
"slowModeInterval" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" INTEGER NOT NULL,
|
||||
"lastSeen" INTEGER NOT NULL
|
||||
)`);
|
||||
await queryRunner.query(`INSERT INTO "servers_without_icons" ("id", "name", "description", "ownerId", "ownerPublicKey", "passwordHash", "isPrivate", "maxUsers", "currentUsers", "slowModeInterval", "createdAt", "lastSeen")
|
||||
SELECT "id", "name", "description", "ownerId", "ownerPublicKey", "passwordHash", "isPrivate", "maxUsers", "currentUsers", "slowModeInterval", "createdAt", "lastSeen" FROM "servers"`);
|
||||
await queryRunner.query(`DROP TABLE "servers"`);
|
||||
await queryRunner.query(`ALTER TABLE "servers_without_icons" RENAME TO "servers"`);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRole
|
||||
import { GameMatchMisses1000000000006 } from './1000000000006-GameMatchMisses';
|
||||
import { PluginSupport1000000000007 } from './1000000000007-PluginSupport';
|
||||
import { ServerPluginInstallMetadata1000000000008 } from './1000000000008-ServerPluginInstallMetadata';
|
||||
import { ServerIcons1000000000009 } from './1000000000009-ServerIcons';
|
||||
|
||||
export const serverMigrations = [
|
||||
InitialSchema1000000000000,
|
||||
@@ -17,5 +18,6 @@ export const serverMigrations = [
|
||||
ServerRoleAccessControl1000000000005,
|
||||
GameMatchMisses1000000000006,
|
||||
PluginSupport1000000000007,
|
||||
ServerPluginInstallMetadata1000000000008
|
||||
ServerPluginInstallMetadata1000000000008,
|
||||
ServerIcons1000000000009
|
||||
];
|
||||
|
||||
@@ -166,7 +166,9 @@ router.post('/', async (req, res) => {
|
||||
maxUsers,
|
||||
password,
|
||||
tags,
|
||||
channels
|
||||
channels,
|
||||
icon,
|
||||
iconUpdatedAt
|
||||
} = req.body;
|
||||
|
||||
if (!name || !ownerId || !ownerPublicKey)
|
||||
@@ -184,6 +186,8 @@ router.post('/', async (req, res) => {
|
||||
isPrivate: isPrivate ?? false,
|
||||
maxUsers: maxUsers ?? 0,
|
||||
currentUsers: 0,
|
||||
icon: typeof icon === 'string' ? icon : undefined,
|
||||
iconUpdatedAt: typeof iconUpdatedAt === 'number' ? iconUpdatedAt : undefined,
|
||||
tags: tags ?? [],
|
||||
channels: normalizeServerChannels(channels),
|
||||
createdAt: Date.now(),
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
import { connectedUsers } from './state';
|
||||
import { ConnectedUser } from './types';
|
||||
import {
|
||||
broadcastToServer,
|
||||
findUserByOderId,
|
||||
getServerIdsForOderId,
|
||||
getUniqueUsersInServer,
|
||||
isOderIdConnectedToServer
|
||||
} from './broadcast';
|
||||
import { broadcastToServer, findUserByOderId, getServerIdsForOderId, getUniqueUsersInServer, isOderIdConnectedToServer } from './broadcast';
|
||||
import { authorizeWebSocketJoin } from '../services/server-access.service';
|
||||
import {
|
||||
getPluginRequirementsSnapshot,
|
||||
PluginSupportError,
|
||||
validatePluginEventEnvelope
|
||||
} from '../services/plugin-support.service';
|
||||
import { getPluginRequirementsSnapshot, PluginSupportError, validatePluginEventEnvelope } from '../services/plugin-support.service';
|
||||
|
||||
interface WsMessage {
|
||||
[key: string]: unknown;
|
||||
@@ -36,9 +26,7 @@ function normalizeDescription(value: unknown): string | undefined {
|
||||
}
|
||||
|
||||
function normalizeProfileUpdatedAt(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||
? value
|
||||
: undefined;
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function readMessageId(value: unknown): string | undefined {
|
||||
@@ -57,7 +45,8 @@ function readMessageId(value: unknown): string | undefined {
|
||||
|
||||
function sendPluginError(user: ConnectedUser, error: unknown, message: WsMessage): void {
|
||||
if (error instanceof PluginSupportError) {
|
||||
user.ws.send(JSON.stringify({
|
||||
user.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'plugin_error',
|
||||
serverId: typeof message['serverId'] === 'string' ? message['serverId'] : undefined,
|
||||
pluginId: typeof message['pluginId'] === 'string' ? message['pluginId'] : undefined,
|
||||
@@ -65,23 +54,25 @@ function sendPluginError(user: ConnectedUser, error: unknown, message: WsMessage
|
||||
eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined,
|
||||
code: error.code,
|
||||
message: error.message
|
||||
}));
|
||||
})
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Unhandled plugin websocket error:', error);
|
||||
user.ws.send(JSON.stringify({
|
||||
user.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'plugin_error',
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Internal server error'
|
||||
}));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Sends the current user list for a given server to a single connected user. */
|
||||
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
||||
const users = getUniqueUsersInServer(serverId, user.oderId)
|
||||
.map(cu => ({
|
||||
const users = getUniqueUsersInServer(serverId, user.oderId).map((cu) => ({
|
||||
oderId: cu.oderId,
|
||||
displayName: normalizeDisplayName(cu.displayName),
|
||||
description: cu.description,
|
||||
@@ -96,11 +87,13 @@ async function sendPluginRequirements(user: ConnectedUser, serverId: string): Pr
|
||||
try {
|
||||
const snapshot = await getPluginRequirementsSnapshot(serverId);
|
||||
|
||||
user.ws.send(JSON.stringify({
|
||||
user.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'plugin_requirements',
|
||||
serverId,
|
||||
snapshot
|
||||
}));
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
sendPluginError(user, error, { type: 'plugin_requirements', serverId });
|
||||
}
|
||||
@@ -128,16 +121,14 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
||||
|
||||
if (
|
||||
user.displayName === previousDisplayName
|
||||
&& user.description === previousDescription
|
||||
&& user.profileUpdatedAt === previousProfileUpdatedAt
|
||||
) {
|
||||
if (user.displayName === previousDisplayName && user.description === previousDescription && user.profileUpdatedAt === previousProfileUpdatedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const serverId of user.serverIds) {
|
||||
broadcastToServer(serverId, {
|
||||
broadcastToServer(
|
||||
serverId,
|
||||
{
|
||||
type: 'user_joined',
|
||||
oderId: user.oderId,
|
||||
displayName: normalizeDisplayName(user.displayName),
|
||||
@@ -145,24 +136,27 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
|
||||
profileUpdatedAt: user.profileUpdatedAt,
|
||||
status: user.status ?? 'online',
|
||||
serverId
|
||||
}, user.oderId);
|
||||
},
|
||||
user.oderId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||
const sid = readMessageId(message['serverId']);
|
||||
|
||||
if (!sid)
|
||||
return;
|
||||
if (!sid) return;
|
||||
|
||||
const authorization = await authorizeWebSocketJoin(sid, user.oderId);
|
||||
|
||||
if (!authorization.allowed) {
|
||||
user.ws.send(JSON.stringify({
|
||||
user.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'access_denied',
|
||||
serverId: sid,
|
||||
reason: authorization.reason
|
||||
}));
|
||||
})
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -174,15 +168,17 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
||||
user.viewedServerId = sid;
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(
|
||||
`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) joined server ${sid} `
|
||||
+ `(newConnection=${isNewConnectionMembership}, newIdentity=${isNewIdentityMembership})`
|
||||
`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) joined server ${sid} ` +
|
||||
`(newConnection=${isNewConnectionMembership}, newIdentity=${isNewIdentityMembership})`
|
||||
);
|
||||
|
||||
sendServerUsers(user, sid);
|
||||
await sendPluginRequirements(user, sid);
|
||||
|
||||
if (isNewIdentityMembership) {
|
||||
broadcastToServer(sid, {
|
||||
broadcastToServer(
|
||||
sid,
|
||||
{
|
||||
type: 'user_joined',
|
||||
oderId: user.oderId,
|
||||
displayName: normalizeDisplayName(user.displayName),
|
||||
@@ -190,15 +186,16 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
||||
profileUpdatedAt: user.profileUpdatedAt,
|
||||
status: user.status ?? 'online',
|
||||
serverId: sid
|
||||
}, user.oderId);
|
||||
},
|
||||
user.oderId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||
const viewSid = readMessageId(message['serverId']);
|
||||
|
||||
if (!viewSid)
|
||||
return;
|
||||
if (!viewSid) return;
|
||||
|
||||
if (!user.serverIds.has(viewSid)) {
|
||||
return;
|
||||
@@ -215,13 +212,11 @@ async function handleViewServer(user: ConnectedUser, message: WsMessage, connect
|
||||
function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
const leaveSid = readMessageId(message['serverId']) ?? user.viewedServerId;
|
||||
|
||||
if (!leaveSid)
|
||||
return;
|
||||
if (!leaveSid) return;
|
||||
|
||||
user.serverIds.delete(leaveSid);
|
||||
|
||||
if (user.viewedServerId === leaveSid)
|
||||
user.viewedServerId = undefined;
|
||||
if (user.viewedServerId === leaveSid) user.viewedServerId = undefined;
|
||||
|
||||
connectedUsers.set(connectionId, user);
|
||||
|
||||
@@ -231,13 +226,17 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
|
||||
return;
|
||||
}
|
||||
|
||||
broadcastToServer(leaveSid, {
|
||||
broadcastToServer(
|
||||
leaveSid,
|
||||
{
|
||||
type: 'user_left',
|
||||
oderId: user.oderId,
|
||||
displayName: normalizeDisplayName(user.displayName),
|
||||
serverId: leaveSid,
|
||||
serverIds: remainingServerIds
|
||||
}, user.oderId);
|
||||
},
|
||||
user.oderId
|
||||
);
|
||||
}
|
||||
|
||||
function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void {
|
||||
@@ -253,7 +252,7 @@ function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void {
|
||||
} else {
|
||||
console.log(
|
||||
`Target user ${targetUserId} not found. Connected users:`,
|
||||
Array.from(connectedUsers.values()).map(cu => ({ oderId: cu.oderId, displayName: cu.displayName }))
|
||||
Array.from(connectedUsers.values()).map((cu) => ({ oderId: cu.oderId, displayName: cu.displayName }))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -275,54 +274,95 @@ function handleChatMessage(user: ConnectedUser, message: WsMessage): void {
|
||||
|
||||
function handleTyping(user: ConnectedUser, message: WsMessage): void {
|
||||
const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
|
||||
const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim()
|
||||
? message['channelId'].trim()
|
||||
: 'general';
|
||||
const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim() ? message['channelId'].trim() : 'general';
|
||||
|
||||
if (typingSid && user.serverIds.has(typingSid)) {
|
||||
broadcastToServer(typingSid, {
|
||||
broadcastToServer(
|
||||
typingSid,
|
||||
{
|
||||
type: 'user_typing',
|
||||
serverId: typingSid,
|
||||
channelId,
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName
|
||||
}, user.oderId);
|
||||
},
|
||||
user.oderId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const VALID_STATUSES = new Set([
|
||||
'online',
|
||||
'away',
|
||||
'busy',
|
||||
'offline'
|
||||
]);
|
||||
const VALID_STATUSES = new Set(['online', 'away', 'busy', 'offline']);
|
||||
|
||||
function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
const status = typeof message['status'] === 'string' ? message['status'] : undefined;
|
||||
|
||||
if (!status || !VALID_STATUSES.has(status))
|
||||
return;
|
||||
if (!status || !VALID_STATUSES.has(status)) return;
|
||||
|
||||
user.status = status as ConnectedUser['status'];
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) status -> ${status}`);
|
||||
|
||||
for (const serverId of user.serverIds) {
|
||||
broadcastToServer(serverId, {
|
||||
broadcastToServer(
|
||||
serverId,
|
||||
{
|
||||
type: 'status_update',
|
||||
oderId: user.oderId,
|
||||
status
|
||||
}, user.oderId);
|
||||
},
|
||||
user.oderId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleServerIconAvailable(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
const serverId = readMessageId(message['serverId']);
|
||||
const iconUpdatedAt = typeof message['iconUpdatedAt'] === 'number' && Number.isFinite(message['iconUpdatedAt']) ? message['iconUpdatedAt'] : 0;
|
||||
|
||||
if (!serverId || iconUpdatedAt <= 0 || !user.serverIds.has(serverId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const availableIcons = user.serverIconUpdatedAtByServerId ?? new Map<string, number>();
|
||||
|
||||
availableIcons.set(serverId, iconUpdatedAt);
|
||||
user.serverIconUpdatedAtByServerId = availableIcons;
|
||||
connectedUsers.set(connectionId, user);
|
||||
}
|
||||
|
||||
function handleServerIconSyncRequest(user: ConnectedUser, message: WsMessage): void {
|
||||
const serverId = readMessageId(message['serverId']);
|
||||
const localUpdatedAt = typeof message['iconUpdatedAt'] === 'number' && Number.isFinite(message['iconUpdatedAt']) ? message['iconUpdatedAt'] : 0;
|
||||
|
||||
if (!serverId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const users = getUniqueUsersInServer(serverId, user.oderId)
|
||||
.filter((candidate) => (candidate.serverIconUpdatedAtByServerId?.get(serverId) ?? 0) > localUpdatedAt)
|
||||
.map((candidate) => ({
|
||||
oderId: candidate.oderId,
|
||||
displayName: normalizeDisplayName(candidate.displayName),
|
||||
description: candidate.description,
|
||||
profileUpdatedAt: candidate.profileUpdatedAt,
|
||||
status: candidate.status ?? 'online'
|
||||
}));
|
||||
|
||||
if (users.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
user.ws.send(JSON.stringify({ type: 'server_icon_sync_peers', serverId, users }));
|
||||
}
|
||||
|
||||
async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promise<void> {
|
||||
const serverId = readMessageId(message['serverId']) ?? user.viewedServerId;
|
||||
const pluginId = readMessageId(message['pluginId']);
|
||||
const eventName = readMessageId(message['eventName']);
|
||||
|
||||
if (!serverId || !pluginId || !eventName || !user.serverIds.has(serverId)) {
|
||||
user.ws.send(JSON.stringify({
|
||||
user.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'plugin_error',
|
||||
serverId,
|
||||
pluginId,
|
||||
@@ -330,7 +370,8 @@ async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promi
|
||||
eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined,
|
||||
code: 'INVALID_PLUGIN_EVENT',
|
||||
message: 'Plugin event is missing required fields or server membership'
|
||||
}));
|
||||
})
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -346,7 +387,9 @@ async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promi
|
||||
sourcePluginUserId: typeof message['sourcePluginUserId'] === 'string' ? message['sourcePluginUserId'] : undefined
|
||||
});
|
||||
|
||||
broadcastToServer(serverId, {
|
||||
broadcastToServer(
|
||||
serverId,
|
||||
{
|
||||
type: 'plugin_event',
|
||||
serverId,
|
||||
pluginId,
|
||||
@@ -356,7 +399,9 @@ async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promi
|
||||
sourcePluginUserId: typeof message['sourcePluginUserId'] === 'string' ? message['sourcePluginUserId'] : undefined,
|
||||
sourceUserId: user.oderId,
|
||||
emittedAt: Date.now()
|
||||
}, user.oderId);
|
||||
},
|
||||
user.oderId
|
||||
);
|
||||
} catch (error) {
|
||||
sendPluginError(user, error, message);
|
||||
}
|
||||
@@ -365,8 +410,7 @@ async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promi
|
||||
export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
|
||||
const user = connectedUsers.get(connectionId);
|
||||
|
||||
if (!user)
|
||||
return;
|
||||
if (!user) return;
|
||||
|
||||
user.lastPong = Date.now();
|
||||
connectedUsers.set(connectionId, user);
|
||||
@@ -394,6 +438,8 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
||||
case 'offer':
|
||||
case 'answer':
|
||||
case 'ice_candidate':
|
||||
case 'server_icon_peer_request':
|
||||
case 'server_icon_peer_data':
|
||||
forwardRtcMessage(user, message);
|
||||
break;
|
||||
|
||||
@@ -409,6 +455,14 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
||||
handleStatusUpdate(user, message, connectionId);
|
||||
break;
|
||||
|
||||
case 'server_icon_available':
|
||||
handleServerIconAvailable(user, message, connectionId);
|
||||
break;
|
||||
|
||||
case 'server_icon_sync_request':
|
||||
handleServerIconSyncRequest(user, message);
|
||||
break;
|
||||
|
||||
case 'plugin_event':
|
||||
await handlePluginEvent(user, message);
|
||||
break;
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface ConnectedUser {
|
||||
connectionScope?: string;
|
||||
/** User availability status (online, away, busy, offline). */
|
||||
status?: 'online' | 'away' | 'busy' | 'offline';
|
||||
/** Latest server icon timestamp this connection can provide over P2P. */
|
||||
serverIconUpdatedAtByServerId?: Map<string, number>;
|
||||
/** Timestamp of the last pong or client message received (used to detect dead connections). */
|
||||
lastPong: number;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface ServerInfo {
|
||||
ownerPublicKey?: string;
|
||||
userCount: number;
|
||||
maxUsers: number;
|
||||
icon?: string;
|
||||
iconUpdatedAt?: number;
|
||||
hasPassword?: boolean;
|
||||
isPrivate: boolean;
|
||||
tags?: string[];
|
||||
|
||||
@@ -101,8 +101,16 @@
|
||||
(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">
|
||||
<div class="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-lg bg-secondary text-sm font-semibold text-foreground">
|
||||
@if (server.icon) {
|
||||
<img
|
||||
[src]="server.icon"
|
||||
[alt]="server.name + ' icon'"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
} @else {
|
||||
{{ server.name[0] || '?' }}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
|
||||
@@ -1,64 +1,30 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
effect,
|
||||
inject,
|
||||
OnInit,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { Component, effect, inject, OnInit, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import {
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
firstValueFrom,
|
||||
Subject
|
||||
} from 'rxjs';
|
||||
import { debounceTime, distinctUntilChanged, firstValueFrom, Subject } from 'rxjs';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideSearch,
|
||||
lucideUsers,
|
||||
lucideLock,
|
||||
lucideGlobe,
|
||||
lucidePlus,
|
||||
lucideSettings,
|
||||
lucideChevronDown
|
||||
} from '@ng-icons/lucide';
|
||||
import { lucideSearch, lucideUsers, lucideLock, lucideGlobe, lucidePlus, lucideSettings, lucideChevronDown } from '@ng-icons/lucide';
|
||||
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
import {
|
||||
selectSearchResults,
|
||||
selectIsSearching,
|
||||
selectRoomsError,
|
||||
selectSavedRooms
|
||||
} from '../../../../store/rooms/rooms.selectors';
|
||||
import { selectSearchResults, selectIsSearching, selectRoomsError, selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
||||
import { Room, User } from '../../../../shared-kernel';
|
||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||
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, LeaveServerDialogComponent, type LeaveServerDialogResult } from '../../../../shared';
|
||||
import { hasRoomBanForUser } from '../../../access-control';
|
||||
import { UserSearchListComponent } from '../../../direct-message/feature/user-search-list/user-search-list.component';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
|
||||
@Component({
|
||||
selector: 'app-server-search',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
ConfirmDialogComponent,
|
||||
LeaveServerDialogComponent,
|
||||
UserSearchListComponent
|
||||
],
|
||||
imports: [CommonModule, FormsModule, NgIcon, ConfirmDialogComponent, LeaveServerDialogComponent, UserSearchListComponent],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideSearch,
|
||||
@@ -82,6 +48,7 @@ export class ServerSearchComponent implements OnInit {
|
||||
private settingsModal = inject(SettingsModalService);
|
||||
private db = inject(DatabaseService);
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
private searchSubject = new Subject<string>();
|
||||
private banLookupRequestVersion = 0;
|
||||
|
||||
@@ -118,6 +85,7 @@ export class ServerSearchComponent implements OnInit {
|
||||
const currentUser = this.currentUser();
|
||||
|
||||
void this.refreshBannedLookup(servers, currentUser ?? null);
|
||||
void this.requestMissingServerIcons(servers, currentUser ?? null);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -170,8 +138,7 @@ export class ServerSearchComponent implements OnInit {
|
||||
|
||||
/** Submit the new server creation form and dispatch the create action. */
|
||||
createServer(): void {
|
||||
if (!this.newServerName())
|
||||
return;
|
||||
if (!this.newServerName()) return;
|
||||
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
|
||||
@@ -225,7 +192,7 @@ export class ServerSearchComponent implements OnInit {
|
||||
|
||||
toggleJoinedServerMenu(event: Event, server: ServerInfo): void {
|
||||
event.stopPropagation();
|
||||
this.joinedServerMenuId.update((currentId) => currentId === server.id ? null : server.id);
|
||||
this.joinedServerMenuId.update((currentId) => (currentId === server.id ? null : server.id));
|
||||
}
|
||||
|
||||
closeJoinedServerMenu(): void {
|
||||
@@ -255,10 +222,12 @@ export class ServerSearchComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.dispatch(RoomsActions.forgetRoom({
|
||||
this.store.dispatch(
|
||||
RoomsActions.forgetRoom({
|
||||
roomId: room.id,
|
||||
nextOwnerKey: result.nextOwnerKey
|
||||
}));
|
||||
})
|
||||
);
|
||||
|
||||
this.leaveDialogRoom.set(null);
|
||||
}
|
||||
@@ -278,8 +247,7 @@ export class ServerSearchComponent implements OnInit {
|
||||
async confirmPasswordJoin(): Promise<void> {
|
||||
const server = this.passwordPromptServer();
|
||||
|
||||
if (!server)
|
||||
return;
|
||||
if (!server) return;
|
||||
|
||||
await this.attemptJoinServer(server, this.joinPassword());
|
||||
}
|
||||
@@ -291,8 +259,7 @@ export class ServerSearchComponent implements OnInit {
|
||||
getServerUserCount(server: ServerInfo): number {
|
||||
const candidate = server as ServerInfo & { currentUsers?: number };
|
||||
|
||||
if (typeof server.userCount === 'number')
|
||||
return server.userCount;
|
||||
if (typeof server.userCount === 'number') return server.userCount;
|
||||
|
||||
return typeof candidate.currentUsers === 'number' ? candidate.currentUsers : 0;
|
||||
}
|
||||
@@ -304,9 +271,7 @@ export class ServerSearchComponent implements OnInit {
|
||||
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
|
||||
);
|
||||
const ownerMember = joinedRoom?.members?.find((member) => member.id === ownerKey || member.oderId === ownerKey);
|
||||
|
||||
return server.ownerName || ownerMember?.displayName || server.ownerId || joinedRoom?.hostId || 'Unknown owner';
|
||||
}
|
||||
@@ -324,6 +289,8 @@ export class ServerSearchComponent implements OnInit {
|
||||
hostName: room.hostId || 'Unknown',
|
||||
userCount: room.userCount ?? 0,
|
||||
maxUsers: room.maxUsers ?? 50,
|
||||
icon: room.icon,
|
||||
iconUpdatedAt: room.iconUpdatedAt,
|
||||
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
|
||||
isPrivate: room.isPrivate,
|
||||
channels: room.channels,
|
||||
@@ -348,32 +315,37 @@ export class ServerSearchComponent implements OnInit {
|
||||
this.joinPasswordError.set(null);
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(this.serverDirectory.requestJoin({
|
||||
const response = await firstValueFrom(
|
||||
this.serverDirectory.requestJoin(
|
||||
{
|
||||
roomId: server.id,
|
||||
userId: currentUserId,
|
||||
userPublicKey: currentUser?.oderId || currentUserId,
|
||||
displayName: currentUser?.displayName || 'Anonymous',
|
||||
password: password?.trim() || undefined
|
||||
}, {
|
||||
},
|
||||
{
|
||||
sourceId: server.sourceId,
|
||||
sourceUrl: server.sourceUrl
|
||||
}));
|
||||
const resolvedSource = this.serverDirectory.normaliseRoomSignalSource({
|
||||
}
|
||||
)
|
||||
);
|
||||
const resolvedSource = this.serverDirectory.normaliseRoomSignalSource(
|
||||
{
|
||||
sourceId: response.server.sourceId ?? server.sourceId,
|
||||
sourceName: response.server.sourceName ?? server.sourceName,
|
||||
sourceUrl: response.server.sourceUrl ?? server.sourceUrl,
|
||||
signalingUrl: response.signalingUrl,
|
||||
fallbackName: response.server.sourceName ?? server.sourceName ?? server.name
|
||||
}, {
|
||||
},
|
||||
{
|
||||
ensureEndpoint: true
|
||||
});
|
||||
}
|
||||
);
|
||||
const resolvedServer = {
|
||||
...server,
|
||||
...response.server,
|
||||
channels:
|
||||
Array.isArray(response.server.channels) && response.server.channels.length > 0
|
||||
? response.server.channels
|
||||
: server.channels,
|
||||
channels: Array.isArray(response.server.channels) && response.server.channels.length > 0 ? response.server.channels : server.channels,
|
||||
...resolvedSource,
|
||||
signalingUrl: response.signalingUrl
|
||||
};
|
||||
@@ -409,6 +381,53 @@ export class ServerSearchComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
private async requestMissingServerIcons(servers: ServerInfo[], currentUser: User | null): Promise<void> {
|
||||
if (!currentUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const server of servers) {
|
||||
if (server.icon) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const selector = this.serverDirectory.buildRoomSignalSelector(
|
||||
{
|
||||
sourceId: server.sourceId,
|
||||
sourceName: server.sourceName,
|
||||
sourceUrl: server.sourceUrl,
|
||||
fallbackName: server.sourceName ?? server.name
|
||||
},
|
||||
{
|
||||
ensureEndpoint: !!server.sourceUrl
|
||||
}
|
||||
);
|
||||
|
||||
if (!selector) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const wsUrl = this.serverDirectory.getWebSocketUrl(selector);
|
||||
|
||||
try {
|
||||
await firstValueFrom(this.webrtc.connectToSignalingServer(wsUrl));
|
||||
this.webrtc.identify(currentUser.oderId || currentUser.id, currentUser.displayName || 'User', wsUrl, {
|
||||
description: currentUser.description,
|
||||
profileUpdatedAt: currentUser.profileUpdatedAt
|
||||
});
|
||||
this.webrtc.joinRoom(server.id, currentUser.oderId || currentUser.id, wsUrl);
|
||||
this.webrtc.sendRawMessage({
|
||||
type: 'server_icon_sync_request',
|
||||
serverId: server.id,
|
||||
iconUpdatedAt: 0
|
||||
});
|
||||
window.setTimeout(() => this.webrtc.leaveRoom(server.id), 15_000);
|
||||
} catch {
|
||||
/* discovery icons are best-effort */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshBannedLookup(servers: ServerInfo[], currentUser: User | null): Promise<void> {
|
||||
const requestVersion = ++this.banLookupRequestVersion;
|
||||
|
||||
@@ -427,8 +446,7 @@ export class ServerSearchComponent implements OnInit {
|
||||
})
|
||||
);
|
||||
|
||||
if (requestVersion !== this.banLookupRequestVersion)
|
||||
return;
|
||||
if (requestVersion !== this.banLookupRequestVersion) return;
|
||||
|
||||
this.bannedServerLookup.set(Object.fromEntries(entries));
|
||||
}
|
||||
@@ -437,8 +455,7 @@ export class ServerSearchComponent implements OnInit {
|
||||
const currentUser = this.currentUser();
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
|
||||
if (!currentUser && !currentUserId)
|
||||
return false;
|
||||
if (!currentUser && !currentUserId) return false;
|
||||
|
||||
const bans = await this.db.getBansForRoom(server.id);
|
||||
|
||||
|
||||
@@ -6,8 +6,16 @@
|
||||
>
|
||||
@if (panelMode() === 'channels') {
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="grid h-9 w-9 place-items-center rounded-md bg-secondary text-sm font-semibold text-foreground">
|
||||
<div class="grid h-9 w-9 place-items-center overflow-hidden rounded-md bg-secondary text-sm font-semibold text-foreground">
|
||||
@if (currentRoom()?.icon) {
|
||||
<img
|
||||
[src]="currentRoom()!.icon"
|
||||
[alt]="currentRoom()!.name + ' icon'"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
} @else {
|
||||
{{ currentRoom()?.name?.charAt(0)?.toUpperCase() || '#' }}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
|
||||
@@ -43,8 +43,8 @@
|
||||
<div class="h-full w-full overflow-hidden rounded-[inherit]">
|
||||
@if (room.icon) {
|
||||
<img
|
||||
[ngSrc]="room.icon"
|
||||
[alt]="room.name"
|
||||
[src]="room.icon"
|
||||
[alt]="room.name + ' icon'"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
} @else {
|
||||
|
||||
@@ -1,31 +1,13 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
Type,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { Component, DestroyRef, Type, computed, effect, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { CommonModule, NgOptimizedImage } from '@angular/common';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucidePlus } from '@ng-icons/lucide';
|
||||
import {
|
||||
EMPTY,
|
||||
Subject,
|
||||
catchError,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
from,
|
||||
map,
|
||||
switchMap,
|
||||
tap
|
||||
} from 'rxjs';
|
||||
import { EMPTY, Subject, catchError, filter, firstValueFrom, from, map, switchMap, tap } from 'rxjs';
|
||||
|
||||
import { Room, User } from '../../../shared-kernel';
|
||||
import { UserBarComponent } from '../../../domains/authentication/feature/user-bar/user-bar.component';
|
||||
@@ -38,11 +20,7 @@ import { NotificationsFacade } from '../../../domains/notifications';
|
||||
import { type ServerInfo, ServerDirectoryFacade } from '../../../domains/server-directory';
|
||||
import { ThemeNodeDirective } from '../../../domains/theme';
|
||||
import { hasRoomBanForUser } from '../../../domains/access-control';
|
||||
import {
|
||||
ConfirmDialogComponent,
|
||||
ContextMenuComponent,
|
||||
LeaveServerDialogComponent
|
||||
} from '../../../shared';
|
||||
import { ConfirmDialogComponent, ContextMenuComponent, LeaveServerDialogComponent } from '../../../shared';
|
||||
|
||||
@Component({
|
||||
selector: 'app-servers-rail',
|
||||
@@ -54,7 +32,6 @@ import {
|
||||
ConfirmDialogComponent,
|
||||
ContextMenuComponent,
|
||||
LeaveServerDialogComponent,
|
||||
NgOptimizedImage,
|
||||
ThemeNodeDirective,
|
||||
UserBarComponent
|
||||
],
|
||||
@@ -166,8 +143,7 @@ export class ServersRailComponent {
|
||||
}
|
||||
|
||||
initial(name?: string): string {
|
||||
if (!name)
|
||||
return '?';
|
||||
if (!name) return '?';
|
||||
|
||||
const ch = name.trim()[0]?.toUpperCase();
|
||||
|
||||
@@ -219,8 +195,7 @@ export class ServersRailComponent {
|
||||
confirmPasswordJoin(): void {
|
||||
const room = this.passwordPromptRoom();
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
if (!room) return;
|
||||
|
||||
this.joinPasswordError.set(null);
|
||||
this.savedRoomJoinRequests.next({ room, password: this.joinPassword() });
|
||||
@@ -260,8 +235,7 @@ export class ServersRailComponent {
|
||||
confirmLeave(result: { nextOwnerKey?: string }): void {
|
||||
const ctx = this.contextRoom();
|
||||
|
||||
if (!ctx)
|
||||
return;
|
||||
if (!ctx) return;
|
||||
|
||||
const isCurrentRoom = this.currentRoom()?.id === ctx.id;
|
||||
|
||||
@@ -364,8 +338,7 @@ export class ServersRailComponent {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
const currentUser = this.currentUser();
|
||||
|
||||
if (!currentUserId)
|
||||
return EMPTY;
|
||||
if (!currentUserId) return EMPTY;
|
||||
|
||||
this.joinPasswordError.set(null);
|
||||
|
||||
|
||||
@@ -3,11 +3,74 @@
|
||||
<section>
|
||||
<h4 class="text-sm font-semibold text-foreground mb-3">Room Settings</h4>
|
||||
@if (!isAdmin()) {
|
||||
<p class="text-xs text-muted-foreground mb-3">
|
||||
You are viewing this server's settings as a non-admin. Only the server owner can make changes.
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground mb-3">You are viewing this server's details without server-management permission.</p>
|
||||
}
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-lg border border-border bg-secondary/40 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="grid h-14 w-14 shrink-0 place-items-center overflow-hidden rounded-lg bg-secondary text-base font-semibold text-foreground">
|
||||
@if (serverData()?.icon) {
|
||||
<img
|
||||
[src]="serverData()!.icon"
|
||||
[alt]="serverData()!.name + ' icon'"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
} @else {
|
||||
<ng-icon
|
||||
name="lucideImage"
|
||||
class="h-6 w-6 text-muted-foreground"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-foreground">Server Image</p>
|
||||
<p class="text-xs text-muted-foreground">Synced to members and shown in server discovery.</p>
|
||||
@if (iconError()) {
|
||||
<p class="mt-1 text-xs text-destructive">{{ iconError() }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (canManageIcon()) {
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<label
|
||||
for="server-icon-upload"
|
||||
class="grid h-9 w-9 cursor-pointer place-items-center rounded-lg border border-border bg-card text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
title="Upload image"
|
||||
aria-label="Upload server image"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideUpload"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
id="server-icon-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="sr-only"
|
||||
(change)="onServerIconSelected($event)"
|
||||
/>
|
||||
|
||||
@if (serverData()?.icon) {
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-9 w-9 place-items-center rounded-lg border border-border bg-card text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
title="Remove image"
|
||||
aria-label="Remove server image"
|
||||
(click)="removeServerIcon()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="room-name"
|
||||
@@ -191,6 +254,7 @@
|
||||
{{ saveSuccess() === 'server' ? 'Saved!' : 'Save Settings' }}
|
||||
</button>
|
||||
|
||||
@if (canDeleteServer()) {
|
||||
<!-- Danger Zone -->
|
||||
<div class="pt-4 border-t border-border">
|
||||
<h4 class="text-sm font-medium text-destructive mb-3">Danger Zone</h4>
|
||||
@@ -207,6 +271,7 @@
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation (sub-modal) -->
|
||||
|
||||
@@ -12,9 +12,12 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import {
|
||||
lucideCheck,
|
||||
lucideImage,
|
||||
lucideTrash2,
|
||||
lucideLock,
|
||||
lucideUnlock
|
||||
lucideUnlock,
|
||||
lucideUpload,
|
||||
lucideX
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { Room } from '../../../../shared-kernel';
|
||||
@@ -34,9 +37,12 @@ import { SettingsModalService } from '../../../../core/services/settings-modal.s
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideCheck,
|
||||
lucideImage,
|
||||
lucideTrash2,
|
||||
lucideLock,
|
||||
lucideUnlock
|
||||
lucideUnlock,
|
||||
lucideUpload,
|
||||
lucideX
|
||||
})
|
||||
],
|
||||
templateUrl: './server-settings.component.html'
|
||||
@@ -49,6 +55,10 @@ export class ServerSettingsComponent {
|
||||
server = input<Room | null>(null);
|
||||
/** Whether the current user is admin of this server. */
|
||||
isAdmin = input(false);
|
||||
/** Whether the current user can manage this server's icon. */
|
||||
canManageIcon = input(false);
|
||||
/** Whether the current user can delete this server. */
|
||||
canDeleteServer = input(false);
|
||||
|
||||
roomName = '';
|
||||
roomDescription = '';
|
||||
@@ -59,6 +69,7 @@ export class ServerSettingsComponent {
|
||||
roomPassword = '';
|
||||
maxUsers = 0;
|
||||
showDeleteConfirm = signal(false);
|
||||
iconError = signal<string | null>(null);
|
||||
|
||||
saveSuccess = signal<string | null>(null);
|
||||
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -170,6 +181,64 @@ export class ServerSettingsComponent {
|
||||
this.modal.navigate('network');
|
||||
}
|
||||
|
||||
onServerIconSelected(event: Event): void {
|
||||
const inputElement = event.target as HTMLInputElement;
|
||||
const file = inputElement.files?.[0];
|
||||
|
||||
inputElement.value = '';
|
||||
|
||||
if (!file || !this.canManageIcon()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
this.iconError.set('Choose an image file.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 512 * 1024) {
|
||||
this.iconError.set('Choose an image smaller than 512 KB.');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
const room = this.server();
|
||||
const icon = typeof reader.result === 'string' ? reader.result : '';
|
||||
|
||||
if (!room || !icon) {
|
||||
this.iconError.set('Could not read that image.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.iconError.set(null);
|
||||
this.store.dispatch(RoomsActions.updateServerIcon({
|
||||
roomId: room.id,
|
||||
icon
|
||||
}));
|
||||
this.showSaveSuccess('icon');
|
||||
};
|
||||
|
||||
reader.onerror = () => this.iconError.set('Could not read that image.');
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
removeServerIcon(): void {
|
||||
const room = this.server();
|
||||
|
||||
if (!room || !this.canManageIcon()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.iconError.set(null);
|
||||
this.store.dispatch(RoomsActions.updateServerIcon({
|
||||
roomId: room.id,
|
||||
icon: ''
|
||||
}));
|
||||
this.showSaveSuccess('icon');
|
||||
}
|
||||
|
||||
private showSaveSuccess(key: string): void {
|
||||
this.saveSuccess.set(key);
|
||||
|
||||
|
||||
@@ -316,7 +316,9 @@
|
||||
@case ('server') {
|
||||
<app-server-settings
|
||||
[server]="selectedServer()"
|
||||
[isAdmin]="isSelectedServerOwner()"
|
||||
[isAdmin]="canManageSelectedServerSettings()"
|
||||
[canManageIcon]="canManageSelectedServerIcon()"
|
||||
[canDeleteServer]="isSelectedServerOwner()"
|
||||
/>
|
||||
}
|
||||
@case ('serverPlugins') {
|
||||
|
||||
@@ -165,6 +165,7 @@ export class SettingsModalComponent {
|
||||
resolveRoomPermission(viewedRoom, user, 'manageServer') ||
|
||||
resolveRoomPermission(viewedRoom, user, 'manageRoles') ||
|
||||
resolveRoomPermission(viewedRoom, user, 'manageChannels') ||
|
||||
resolveRoomPermission(viewedRoom, user, 'manageIcon') ||
|
||||
resolveRoomPermission(viewedRoom, user, 'manageBans') ||
|
||||
resolveRoomPermission(viewedRoom, user, 'kickMembers') ||
|
||||
resolveRoomPermission(viewedRoom, user, 'banMembers')
|
||||
@@ -208,6 +209,7 @@ export class SettingsModalComponent {
|
||||
resolveRoomPermission(server, user, 'manageServer') ||
|
||||
resolveRoomPermission(server, user, 'manageRoles') ||
|
||||
resolveRoomPermission(server, user, 'manageChannels') ||
|
||||
resolveRoomPermission(server, user, 'manageIcon') ||
|
||||
resolveRoomPermission(server, user, 'manageBans') ||
|
||||
resolveRoomPermission(server, user, 'kickMembers') ||
|
||||
resolveRoomPermission(server, user, 'banMembers'))
|
||||
@@ -252,6 +254,20 @@ export class SettingsModalComponent {
|
||||
return this.selectedServerRole() === 'host';
|
||||
});
|
||||
|
||||
canManageSelectedServerSettings = computed(() => {
|
||||
const server = this.selectedServer();
|
||||
const user = this.currentUser();
|
||||
|
||||
return !!server && !!user && (resolveLegacyRole(server, user) === 'host' || resolveRoomPermission(server, user, 'manageServer'));
|
||||
});
|
||||
|
||||
canManageSelectedServerIcon = computed(() => {
|
||||
const server = this.selectedServer();
|
||||
const user = this.currentUser();
|
||||
|
||||
return !!server && !!user && (resolveLegacyRole(server, user) === 'host' || resolveRoomPermission(server, user, 'manageIcon'));
|
||||
});
|
||||
|
||||
isSelectedServerCurrent = computed(() => {
|
||||
const selectedServerId = this.selectedServerId();
|
||||
const currentRoomId = this.currentRoom()?.id ?? null;
|
||||
|
||||
@@ -34,6 +34,9 @@ export type IncomingSignalingMessage = Omit<Partial<SignalingMessage>, 'type' |
|
||||
users?: SignalingUserSummary[];
|
||||
displayName?: string;
|
||||
fromUserId?: string;
|
||||
icon?: string;
|
||||
iconUpdatedAt?: number;
|
||||
targetUserId?: string;
|
||||
};
|
||||
|
||||
interface IncomingSignalingMessageHandlerDependencies {
|
||||
@@ -60,9 +63,7 @@ export class IncomingSignalingMessageHandler {
|
||||
/** Tracks when we first started waiting for a remote-initiated offer from each peer. */
|
||||
private readonly nonInitiatorWaitStart = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
private readonly dependencies: IncomingSignalingMessageHandlerDependencies
|
||||
) {}
|
||||
constructor(private readonly dependencies: IncomingSignalingMessageHandlerDependencies) {}
|
||||
|
||||
handleMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
||||
this.dependencies.logger.info('Signaling message', {
|
||||
@@ -76,6 +77,7 @@ export class IncomingSignalingMessageHandler {
|
||||
return;
|
||||
|
||||
case SIGNALING_TYPE_SERVER_USERS:
|
||||
case 'server_icon_sync_peers':
|
||||
this.handleServerUsersSignalingMessage(message, signalUrl);
|
||||
return;
|
||||
|
||||
@@ -138,11 +140,9 @@ export class IncomingSignalingMessageHandler {
|
||||
}
|
||||
|
||||
for (const user of users) {
|
||||
if (!user.oderId)
|
||||
continue;
|
||||
if (!user.oderId) continue;
|
||||
|
||||
if (localOderId && user.oderId === localOderId)
|
||||
continue;
|
||||
if (localOderId && user.oderId === localOderId) continue;
|
||||
|
||||
this.clearUserJoinedFallbackOffer(user.oderId);
|
||||
|
||||
@@ -295,9 +295,9 @@ export class IncomingSignalingMessageHandler {
|
||||
|
||||
const hasRemainingSharedServers = Array.isArray(message.serverIds)
|
||||
? this.dependencies.signalingCoordinator.replacePeerSharedServers(message.oderId, signalUrl, message.serverIds)
|
||||
: (message.serverId
|
||||
: message.serverId
|
||||
? this.dependencies.signalingCoordinator.untrackPeerFromServer(message.oderId, signalUrl, message.serverId)
|
||||
: false);
|
||||
: false;
|
||||
|
||||
if (!hasRemainingSharedServers) {
|
||||
this.dependencies.peerManager.removePeer(message.oderId);
|
||||
@@ -310,11 +310,9 @@ export class IncomingSignalingMessageHandler {
|
||||
const fromUserId = message.fromUserId;
|
||||
const sdp = message.payload?.sdp;
|
||||
|
||||
if (!fromUserId || !sdp)
|
||||
return;
|
||||
if (!fromUserId || !sdp) return;
|
||||
|
||||
if (fromUserId === this.dependencies.getLocalOderId())
|
||||
return;
|
||||
if (fromUserId === this.dependencies.getLocalOderId()) return;
|
||||
|
||||
this.clearUserJoinedFallbackOffer(fromUserId);
|
||||
this.nonInitiatorWaitStart.delete(fromUserId);
|
||||
@@ -334,11 +332,9 @@ export class IncomingSignalingMessageHandler {
|
||||
const fromUserId = message.fromUserId;
|
||||
const sdp = message.payload?.sdp;
|
||||
|
||||
if (!fromUserId || !sdp)
|
||||
return;
|
||||
if (!fromUserId || !sdp) return;
|
||||
|
||||
if (fromUserId === this.dependencies.getLocalOderId())
|
||||
return;
|
||||
if (fromUserId === this.dependencies.getLocalOderId()) return;
|
||||
|
||||
this.clearUserJoinedFallbackOffer(fromUserId);
|
||||
|
||||
@@ -350,11 +346,9 @@ export class IncomingSignalingMessageHandler {
|
||||
const fromUserId = message.fromUserId;
|
||||
const candidate = message.payload?.candidate;
|
||||
|
||||
if (!fromUserId || !candidate)
|
||||
return;
|
||||
if (!fromUserId || !candidate) return;
|
||||
|
||||
if (fromUserId === this.dependencies.getLocalOderId())
|
||||
return;
|
||||
if (fromUserId === this.dependencies.getLocalOderId()) return;
|
||||
|
||||
this.clearUserJoinedFallbackOffer(fromUserId);
|
||||
|
||||
@@ -513,18 +507,15 @@ export class IncomingSignalingMessageHandler {
|
||||
}
|
||||
|
||||
private shouldInitiatePeer(peerId: string, localOderId: string | null = this.dependencies.getLocalOderId()): boolean {
|
||||
if (!localOderId)
|
||||
return false;
|
||||
if (!localOderId) return false;
|
||||
|
||||
if (peerId === localOderId)
|
||||
return false;
|
||||
if (peerId === localOderId) return false;
|
||||
|
||||
return localOderId < peerId;
|
||||
}
|
||||
|
||||
private hasActivePeerConnection(peer: PeerData | undefined): boolean {
|
||||
if (!peer)
|
||||
return false;
|
||||
if (!peer) return false;
|
||||
|
||||
const connectionState = peer.connection?.connectionState;
|
||||
|
||||
@@ -532,13 +523,11 @@ export class IncomingSignalingMessageHandler {
|
||||
}
|
||||
|
||||
private isPeerConnectionNegotiating(peer: PeerData | undefined): boolean {
|
||||
if (!peer || this.hasActivePeerConnection(peer))
|
||||
return false;
|
||||
if (!peer || this.hasActivePeerConnection(peer)) return false;
|
||||
|
||||
const connectionState = peer.connection?.connectionState;
|
||||
|
||||
if (connectionState === 'closed' || connectionState === 'failed')
|
||||
return false;
|
||||
if (connectionState === 'closed' || connectionState === 'failed') return false;
|
||||
|
||||
const signalingState = peer.connection?.signalingState;
|
||||
const ageMs = Date.now() - peer.createdAt;
|
||||
@@ -546,13 +535,11 @@ export class IncomingSignalingMessageHandler {
|
||||
// If a local offer (or pranswer) has already been sent, the peer is actively
|
||||
// negotiating with the remote side. Use a much longer grace period so that
|
||||
// a slow signaling round-trip does not trigger a premature teardown.
|
||||
if (signalingState === 'have-local-offer' || signalingState === 'have-local-pranswer')
|
||||
return ageMs < PEER_NEGOTIATION_OFFER_SENT_GRACE_MS;
|
||||
if (signalingState === 'have-local-offer' || signalingState === 'have-local-pranswer') return ageMs < PEER_NEGOTIATION_OFFER_SENT_GRACE_MS;
|
||||
|
||||
// ICE negotiation in progress (offer/answer exchange already complete, candidates being checked).
|
||||
// TURN relay can take 5-15 s on high-latency networks, so use the same extended grace.
|
||||
if (connectionState === 'connecting')
|
||||
return ageMs < PEER_NEGOTIATION_OFFER_SENT_GRACE_MS;
|
||||
if (connectionState === 'connecting') return ageMs < PEER_NEGOTIATION_OFFER_SENT_GRACE_MS;
|
||||
|
||||
return ageMs < PEER_NEGOTIATION_GRACE_MS;
|
||||
}
|
||||
|
||||
@@ -308,18 +308,29 @@ export class RoomSettingsEffects {
|
||||
updateServerIcon$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.updateServerIcon),
|
||||
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
||||
withLatestFrom(
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom),
|
||||
this.store.select(selectSavedRooms)
|
||||
),
|
||||
mergeMap(([
|
||||
{ roomId, icon },
|
||||
currentUser,
|
||||
currentRoom
|
||||
currentRoom,
|
||||
savedRooms
|
||||
]) => {
|
||||
if (!currentUser || !currentRoom || currentRoom.id !== roomId) {
|
||||
return of(RoomsActions.updateServerIconFailure({ error: 'Not in room' }));
|
||||
if (!currentUser) {
|
||||
return of(RoomsActions.updateServerIconFailure({ error: 'Not logged in' }));
|
||||
}
|
||||
|
||||
const isOwner = currentRoom.hostId === currentUser.id;
|
||||
const canByRole = resolveRoomPermission(currentRoom, currentUser, 'manageIcon');
|
||||
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
||||
|
||||
if (!room) {
|
||||
return of(RoomsActions.updateServerIconFailure({ error: 'Room not found' }));
|
||||
}
|
||||
|
||||
const isOwner = room.hostId === currentUser.id || room.hostId === currentUser.oderId;
|
||||
const canByRole = resolveRoomPermission(room, currentUser, 'manageIcon');
|
||||
|
||||
if (!isOwner && !canByRole) {
|
||||
return of(RoomsActions.updateServerIconFailure({ error: 'Permission denied' }));
|
||||
@@ -329,15 +340,32 @@ export class RoomSettingsEffects {
|
||||
const changes: Partial<Room> = { icon,
|
||||
iconUpdatedAt };
|
||||
|
||||
this.db.updateRoom(roomId, changes);
|
||||
this.db.updateRoom(room.id, changes);
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'server-icon-update',
|
||||
roomId,
|
||||
roomId: room.id,
|
||||
icon,
|
||||
iconUpdatedAt
|
||||
});
|
||||
this.webrtc.sendRawMessage({
|
||||
type: 'server_icon_available',
|
||||
serverId: room.id,
|
||||
iconUpdatedAt
|
||||
});
|
||||
|
||||
return of(RoomsActions.updateServerIconSuccess({ roomId,
|
||||
this.serverDirectory.updateServer(room.id, {
|
||||
currentOwnerId: currentUser.id,
|
||||
actingRole: isOwner ? 'host' : undefined,
|
||||
icon,
|
||||
iconUpdatedAt
|
||||
}, {
|
||||
sourceId: room.sourceId,
|
||||
sourceUrl: room.sourceUrl
|
||||
}).subscribe({
|
||||
error: () => {}
|
||||
});
|
||||
|
||||
return of(RoomsActions.updateServerIconSuccess({ roomId: room.id,
|
||||
icon,
|
||||
iconUpdatedAt }));
|
||||
})
|
||||
|
||||
@@ -1,44 +1,17 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {
|
||||
Actions,
|
||||
createEffect,
|
||||
ofType
|
||||
} from '@ngrx/effects';
|
||||
import { Actions, createEffect, ofType } from '@ngrx/effects';
|
||||
import { Store, type Action } from '@ngrx/store';
|
||||
import {
|
||||
of,
|
||||
from,
|
||||
EMPTY
|
||||
} from 'rxjs';
|
||||
import {
|
||||
map,
|
||||
mergeMap,
|
||||
withLatestFrom,
|
||||
tap,
|
||||
switchMap,
|
||||
catchError
|
||||
} from 'rxjs/operators';
|
||||
import { of, from, EMPTY } from 'rxjs';
|
||||
import { map, mergeMap, withLatestFrom, tap, switchMap, catchError } from 'rxjs/operators';
|
||||
import { RoomsActions } from './rooms.actions';
|
||||
import { UsersActions } from '../users/users.actions';
|
||||
import { selectCurrentUser, selectAllUsers } from '../users/users.selectors';
|
||||
import {
|
||||
selectActiveChannelId,
|
||||
selectCurrentRoom,
|
||||
selectSavedRooms
|
||||
} from './rooms.selectors';
|
||||
import { selectActiveChannelId, selectCurrentRoom, selectSavedRooms } from './rooms.selectors';
|
||||
import { RealtimeSessionFacade } from '../../core/realtime';
|
||||
import { DatabaseService } from '../../infrastructure/persistence';
|
||||
import { resolveRoomPermission } from '../../domains/access-control';
|
||||
import type {
|
||||
ChatEvent,
|
||||
Room,
|
||||
RoomSettings,
|
||||
RoomPermissions,
|
||||
BanEntry,
|
||||
User,
|
||||
VoiceState
|
||||
} from '../../shared-kernel';
|
||||
import type { ChatEvent, Room, RoomSettings, RoomPermissions, BanEntry, User, VoiceState } from '../../shared-kernel';
|
||||
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
|
||||
import { hasRoomBanForUser } from '../../domains/access-control';
|
||||
import { RECONNECT_SOUND_GRACE_MS } from '../../core/constants';
|
||||
@@ -55,6 +28,8 @@ import {
|
||||
} from './rooms.helpers';
|
||||
import type { RoomPresenceSignalingMessage } from './rooms.helpers';
|
||||
|
||||
const SERVER_ICON_SYNC_REQUEST_DELAYS_MS = [1_500, 3_000, 5_000, 8_000];
|
||||
|
||||
/**
|
||||
* NgRx effects for real-time state synchronisation: signaling presence
|
||||
* events (server_users, user_joined, user_left, access_denied), P2P
|
||||
@@ -75,6 +50,7 @@ export class RoomStateSyncEffects {
|
||||
* preventing false join/leave sounds during state refreshes.
|
||||
*/
|
||||
private knownVoiceUsers = new Set<string>();
|
||||
private pendingServerIconRequestsByPeer = new Map<string, Set<string>>();
|
||||
/**
|
||||
* When a user leaves (e.g. socket drops), record the timestamp so
|
||||
* that a rapid re-join (reconnect) does not trigger a false
|
||||
@@ -87,17 +63,8 @@ export class RoomStateSyncEffects {
|
||||
/** Handles WebRTC signaling events for user presence (join, leave, server_users). */
|
||||
signalingMessages$ = createEffect(() =>
|
||||
this.webrtc.onSignalingMessage.pipe(
|
||||
withLatestFrom(
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom),
|
||||
this.store.select(selectSavedRooms)
|
||||
),
|
||||
mergeMap(([
|
||||
message,
|
||||
currentUser,
|
||||
currentRoom,
|
||||
savedRooms
|
||||
]) => {
|
||||
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom), this.store.select(selectSavedRooms)),
|
||||
mergeMap(([message, currentUser, currentRoom, savedRooms]) => {
|
||||
const signalingMessage: RoomPresenceSignalingMessage = message;
|
||||
const myId = currentUser?.oderId || currentUser?.id;
|
||||
const viewedServerId = currentRoom?.id;
|
||||
@@ -106,8 +73,7 @@ export class RoomStateSyncEffects {
|
||||
|
||||
switch (signalingMessage.type) {
|
||||
case 'server_users': {
|
||||
if (!Array.isArray(signalingMessage.users) || !signalingMessage.serverId)
|
||||
return EMPTY;
|
||||
if (!Array.isArray(signalingMessage.users) || !signalingMessage.serverId) return EMPTY;
|
||||
|
||||
const syncedUsers = signalingMessage.users
|
||||
.filter((user) => user.oderId !== myId)
|
||||
@@ -136,11 +102,9 @@ export class RoomStateSyncEffects {
|
||||
}
|
||||
|
||||
case 'user_joined': {
|
||||
if (!signalingMessage.serverId || signalingMessage.oderId === myId)
|
||||
return EMPTY;
|
||||
if (!signalingMessage.serverId || signalingMessage.oderId === myId) return EMPTY;
|
||||
|
||||
if (!signalingMessage.oderId)
|
||||
return EMPTY;
|
||||
if (!signalingMessage.oderId) return EMPTY;
|
||||
|
||||
const joinedUser = {
|
||||
oderId: signalingMessage.oderId,
|
||||
@@ -168,12 +132,9 @@ export class RoomStateSyncEffects {
|
||||
}
|
||||
|
||||
case 'user_left': {
|
||||
if (!signalingMessage.oderId)
|
||||
return EMPTY;
|
||||
if (!signalingMessage.oderId) return EMPTY;
|
||||
|
||||
const remainingServerIds = Array.isArray(signalingMessage.serverIds)
|
||||
? signalingMessage.serverIds
|
||||
: undefined;
|
||||
const remainingServerIds = Array.isArray(signalingMessage.serverIds) ? signalingMessage.serverIds : undefined;
|
||||
|
||||
if (!remainingServerIds || remainingServerIds.length === 0) {
|
||||
if (this.knownVoiceUsers.has(signalingMessage.oderId)) {
|
||||
@@ -199,24 +160,15 @@ export class RoomStateSyncEffects {
|
||||
}
|
||||
|
||||
case 'status_update': {
|
||||
if (!signalingMessage.oderId || !signalingMessage.status)
|
||||
return EMPTY;
|
||||
if (!signalingMessage.oderId || !signalingMessage.status) return EMPTY;
|
||||
|
||||
const validStatuses = [
|
||||
'online',
|
||||
'away',
|
||||
'busy',
|
||||
'offline'
|
||||
];
|
||||
const validStatuses = ['online', 'away', 'busy', 'offline'];
|
||||
|
||||
if (!validStatuses.includes(signalingMessage.status))
|
||||
return EMPTY;
|
||||
if (!validStatuses.includes(signalingMessage.status)) return EMPTY;
|
||||
|
||||
// 'offline' from the server means the user chose Invisible;
|
||||
// display them as disconnected to other users.
|
||||
const mappedStatus = signalingMessage.status === 'offline'
|
||||
? 'disconnected'
|
||||
: signalingMessage.status as 'online' | 'away' | 'busy';
|
||||
const mappedStatus = signalingMessage.status === 'offline' ? 'disconnected' : (signalingMessage.status as 'online' | 'away' | 'busy');
|
||||
|
||||
return [
|
||||
UsersActions.updateRemoteUserStatus({
|
||||
@@ -227,21 +179,75 @@ export class RoomStateSyncEffects {
|
||||
}
|
||||
|
||||
case 'access_denied': {
|
||||
if (isWrongServer(signalingMessage.serverId, viewedServerId))
|
||||
return EMPTY;
|
||||
if (isWrongServer(signalingMessage.serverId, viewedServerId)) return EMPTY;
|
||||
|
||||
if (signalingMessage.reason !== 'SERVER_NOT_FOUND')
|
||||
return EMPTY;
|
||||
if (signalingMessage.reason !== 'SERVER_NOT_FOUND') return EMPTY;
|
||||
|
||||
// When multiple signal URLs are configured, the room may already
|
||||
// be successfully joined on a different signal server. Only show
|
||||
// the reconnect notice when the room is not reachable at all.
|
||||
if (signalingMessage.serverId && this.webrtc.hasJoinedServer(signalingMessage.serverId))
|
||||
return EMPTY;
|
||||
if (signalingMessage.serverId && this.webrtc.hasJoinedServer(signalingMessage.serverId)) return EMPTY;
|
||||
|
||||
return [RoomsActions.setSignalServerReconnecting({ isReconnecting: true })];
|
||||
}
|
||||
|
||||
case 'server_icon_sync_peers': {
|
||||
if (!signalingMessage.serverId || !Array.isArray(signalingMessage.users)) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
const serverId = signalingMessage.serverId;
|
||||
|
||||
for (const user of signalingMessage.users) {
|
||||
if (!user.oderId || user.oderId === myId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.queueServerIconSyncRequest(user.oderId, serverId);
|
||||
this.webrtc.sendRawMessage({
|
||||
type: 'server_icon_peer_request',
|
||||
targetUserId: user.oderId,
|
||||
serverId
|
||||
});
|
||||
}
|
||||
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
case 'server_icon_peer_request': {
|
||||
const serverId = signalingMessage.serverId;
|
||||
const targetUserId = signalingMessage.fromUserId;
|
||||
const room = resolveRoom(serverId, currentRoom, savedRooms);
|
||||
|
||||
if (!serverId || !targetUserId || !room?.icon) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
this.webrtc.sendRawMessage({
|
||||
type: 'server_icon_peer_data',
|
||||
targetUserId,
|
||||
serverId,
|
||||
icon: room.icon,
|
||||
iconUpdatedAt: room.iconUpdatedAt || 0
|
||||
});
|
||||
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
case 'server_icon_peer_data': {
|
||||
if (!signalingMessage.serverId || typeof signalingMessage.icon !== 'string') {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
return of(
|
||||
RoomsActions.receiveSearchServerIcon({
|
||||
roomId: signalingMessage.serverId,
|
||||
icon: signalingMessage.icon,
|
||||
iconUpdatedAt: signalingMessage.iconUpdatedAt || Date.now()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return EMPTY;
|
||||
}
|
||||
@@ -257,8 +263,7 @@ export class RoomStateSyncEffects {
|
||||
this.webrtc.onPeerConnected.pipe(
|
||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||
tap(([peerId, room]) => {
|
||||
if (!room)
|
||||
return;
|
||||
if (!room) return;
|
||||
|
||||
this.webrtc.sendToPeer(peerId, {
|
||||
type: 'server-state-request',
|
||||
@@ -273,12 +278,16 @@ export class RoomStateSyncEffects {
|
||||
roomEntryServerStateSync$ = createEffect(
|
||||
() =>
|
||||
this.actions$.pipe(
|
||||
ofType(
|
||||
RoomsActions.createRoomSuccess,
|
||||
RoomsActions.joinRoomSuccess,
|
||||
RoomsActions.viewServerSuccess
|
||||
),
|
||||
ofType(RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
|
||||
tap(({ room }) => {
|
||||
if (room.iconUpdatedAt) {
|
||||
this.webrtc.sendRawMessage({
|
||||
type: 'server_icon_available',
|
||||
serverId: room.id,
|
||||
iconUpdatedAt: room.iconUpdatedAt
|
||||
});
|
||||
}
|
||||
|
||||
for (const peerId of this.webrtc.getConnectedPeers()) {
|
||||
try {
|
||||
this.webrtc.sendToPeer(peerId, {
|
||||
@@ -304,14 +313,7 @@ export class RoomStateSyncEffects {
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectActiveChannelId)
|
||||
),
|
||||
mergeMap(([
|
||||
event,
|
||||
currentRoom,
|
||||
savedRooms,
|
||||
allUsers,
|
||||
currentUser,
|
||||
activeChannelId
|
||||
]) => {
|
||||
mergeMap(([event, currentRoom, savedRooms, allUsers, currentUser, activeChannelId]) => {
|
||||
switch (event.type) {
|
||||
case 'voice-state':
|
||||
return this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'voice');
|
||||
@@ -351,8 +353,7 @@ export class RoomStateSyncEffects {
|
||||
this.webrtc.onPeerConnected.pipe(
|
||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||
tap(([_peerId, room]) => {
|
||||
if (!room)
|
||||
return;
|
||||
if (!room) return;
|
||||
|
||||
const iconUpdatedAt = room.iconUpdatedAt || 0;
|
||||
|
||||
@@ -366,18 +367,29 @@ export class RoomStateSyncEffects {
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
/** Sends queued discovery icon requests as soon as a temporary peer channel opens. */
|
||||
peerConnectedDiscoveryIconSync$ = createEffect(
|
||||
() =>
|
||||
this.webrtc.onPeerConnected.pipe(
|
||||
tap((peerId) => {
|
||||
const serverIds = this.pendingServerIconRequestsByPeer.get(peerId);
|
||||
|
||||
if (!serverIds) return;
|
||||
|
||||
for (const serverId of serverIds) {
|
||||
this.sendServerIconSyncRequest(peerId, serverId);
|
||||
}
|
||||
})
|
||||
),
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
// ── Voice / Screen / Camera handlers ───────────────────────────
|
||||
|
||||
private handleVoiceOrScreenState(
|
||||
event: ChatEvent,
|
||||
allUsers: User[],
|
||||
currentUser: User | null,
|
||||
kind: 'voice' | 'screen' | 'camera'
|
||||
) {
|
||||
private handleVoiceOrScreenState(event: ChatEvent, allUsers: User[], currentUser: User | null, kind: 'voice' | 'screen' | 'camera') {
|
||||
const userId: string | undefined = event.fromPeerId ?? event.oderId;
|
||||
|
||||
if (!userId)
|
||||
return EMPTY;
|
||||
if (!userId) return EMPTY;
|
||||
|
||||
const existingUser = allUsers.find((user) => user.id === userId || user.oderId === userId);
|
||||
const userExists = !!existingUser;
|
||||
@@ -385,14 +397,13 @@ export class RoomStateSyncEffects {
|
||||
if (kind === 'voice') {
|
||||
const vs = event.voiceState as Partial<VoiceState> | undefined;
|
||||
|
||||
if (!vs)
|
||||
return EMPTY;
|
||||
if (!vs) return EMPTY;
|
||||
|
||||
const presenceRefreshAction = vs.serverId && !existingUser?.presenceServerIds?.includes(vs.serverId)
|
||||
const presenceRefreshAction =
|
||||
vs.serverId && !existingUser?.presenceServerIds?.includes(vs.serverId)
|
||||
? UsersActions.userJoined({
|
||||
user: buildSignalingUser(
|
||||
{ oderId: userId,
|
||||
displayName: event.displayName || existingUser?.displayName || 'User' },
|
||||
{ oderId: userId, displayName: event.displayName || existingUser?.displayName || 'User' },
|
||||
{ presenceServerIds: [vs.serverId] }
|
||||
)
|
||||
})
|
||||
@@ -427,8 +438,7 @@ export class RoomStateSyncEffects {
|
||||
return of(
|
||||
UsersActions.userJoined({
|
||||
user: buildSignalingUser(
|
||||
{ oderId: userId,
|
||||
displayName: event.displayName || 'User' },
|
||||
{ oderId: userId, displayName: event.displayName || 'User' },
|
||||
{
|
||||
presenceServerIds: vs.serverId ? [vs.serverId] : undefined,
|
||||
voiceState: {
|
||||
@@ -453,8 +463,7 @@ export class RoomStateSyncEffects {
|
||||
actions.push(presenceRefreshAction);
|
||||
}
|
||||
|
||||
actions.push(UsersActions.updateVoiceState({ userId,
|
||||
voiceState: vs }));
|
||||
actions.push(UsersActions.updateVoiceState({ userId, voiceState: vs }));
|
||||
|
||||
return actions;
|
||||
}
|
||||
@@ -462,17 +471,12 @@ export class RoomStateSyncEffects {
|
||||
if (kind === 'screen') {
|
||||
const isSharing = event.isScreenSharing as boolean | undefined;
|
||||
|
||||
if (isSharing === undefined)
|
||||
return EMPTY;
|
||||
if (isSharing === undefined) return EMPTY;
|
||||
|
||||
if (!userExists) {
|
||||
return of(
|
||||
UsersActions.userJoined({
|
||||
user: buildSignalingUser(
|
||||
{ oderId: userId,
|
||||
displayName: event.displayName || 'User' },
|
||||
{ screenShareState: { isSharing } }
|
||||
)
|
||||
user: buildSignalingUser({ oderId: userId, displayName: event.displayName || 'User' }, { screenShareState: { isSharing } })
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -487,17 +491,12 @@ export class RoomStateSyncEffects {
|
||||
|
||||
const isCameraEnabled = event.isCameraEnabled as boolean | undefined;
|
||||
|
||||
if (isCameraEnabled === undefined)
|
||||
return EMPTY;
|
||||
if (isCameraEnabled === undefined) return EMPTY;
|
||||
|
||||
if (!userExists) {
|
||||
return of(
|
||||
UsersActions.userJoined({
|
||||
user: buildSignalingUser(
|
||||
{ oderId: userId,
|
||||
displayName: event.displayName || 'User' },
|
||||
{ cameraState: { isEnabled: isCameraEnabled } }
|
||||
)
|
||||
user: buildSignalingUser({ oderId: userId, displayName: event.displayName || 'User' }, { cameraState: { isEnabled: isCameraEnabled } })
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -510,12 +509,7 @@ export class RoomStateSyncEffects {
|
||||
);
|
||||
}
|
||||
|
||||
private handleVoiceChannelMove(
|
||||
event: ChatEvent,
|
||||
currentRoom: Room | null,
|
||||
savedRooms: Room[],
|
||||
currentUser: User | null
|
||||
) {
|
||||
private handleVoiceChannelMove(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[], currentUser: User | null) {
|
||||
const targetUserId = typeof event.targetUserId === 'string' ? event.targetUserId : null;
|
||||
const serverId = typeof event.roomId === 'string' ? event.roomId : currentUser?.voiceState?.serverId;
|
||||
const nextVoiceState = event.voiceState as Partial<VoiceState> | undefined;
|
||||
@@ -566,22 +560,23 @@ export class RoomStateSyncEffects {
|
||||
voiceState: updatedVoiceState
|
||||
});
|
||||
|
||||
return of(UsersActions.updateVoiceState({
|
||||
return of(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: currentUser.id,
|
||||
voiceState: updatedVoiceState
|
||||
}));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private isSameVoiceRoom(
|
||||
voiceState: Partial<VoiceState> | undefined,
|
||||
currentUserVoiceState: Partial<VoiceState> | undefined
|
||||
): boolean {
|
||||
return !!voiceState?.isConnected
|
||||
&& !!currentUserVoiceState?.isConnected
|
||||
&& !!voiceState.roomId
|
||||
&& !!voiceState.serverId
|
||||
&& voiceState.roomId === currentUserVoiceState.roomId
|
||||
&& voiceState.serverId === currentUserVoiceState.serverId;
|
||||
private isSameVoiceRoom(voiceState: Partial<VoiceState> | undefined, currentUserVoiceState: Partial<VoiceState> | undefined): boolean {
|
||||
return (
|
||||
!!voiceState?.isConnected &&
|
||||
!!currentUserVoiceState?.isConnected &&
|
||||
!!voiceState.roomId &&
|
||||
!!voiceState.serverId &&
|
||||
voiceState.roomId === currentUserVoiceState.roomId &&
|
||||
voiceState.serverId === currentUserVoiceState.serverId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -614,8 +609,7 @@ export class RoomStateSyncEffects {
|
||||
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
||||
const fromPeerId = event.fromPeerId;
|
||||
|
||||
if (!room || !fromPeerId)
|
||||
return EMPTY;
|
||||
if (!room || !fromPeerId) return EMPTY;
|
||||
|
||||
return from(this.db.getBansForRoom(room.id)).pipe(
|
||||
tap((bans) => {
|
||||
@@ -630,18 +624,12 @@ export class RoomStateSyncEffects {
|
||||
);
|
||||
}
|
||||
|
||||
private handleServerStateFull(
|
||||
event: ChatEvent,
|
||||
currentRoom: Room | null,
|
||||
savedRooms: Room[],
|
||||
currentUser: { id: string; oderId: string } | null
|
||||
) {
|
||||
private handleServerStateFull(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[], currentUser: { id: string; oderId: string } | null) {
|
||||
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
||||
const incomingRoom = event.room as Partial<Room> | undefined;
|
||||
|
||||
if (!room || !incomingRoom)
|
||||
return EMPTY;
|
||||
if (!room || !incomingRoom) return EMPTY;
|
||||
|
||||
const roomChanges = {
|
||||
...sanitizeRoomSnapshot(incomingRoom),
|
||||
@@ -651,19 +639,17 @@ export class RoomStateSyncEffects {
|
||||
|
||||
return this.syncBansToLocalRoom(room.id, bans).pipe(
|
||||
mergeMap(() => {
|
||||
const actions: (ReturnType<typeof RoomsActions.updateRoom>
|
||||
const actions: (
|
||||
| ReturnType<typeof RoomsActions.updateRoom>
|
||||
| ReturnType<typeof UsersActions.loadBansSuccess>
|
||||
| ReturnType<typeof RoomsActions.forgetRoom>)[] = [
|
||||
| ReturnType<typeof RoomsActions.forgetRoom>
|
||||
)[] = [
|
||||
RoomsActions.updateRoom({
|
||||
roomId: room.id,
|
||||
changes: roomChanges
|
||||
})
|
||||
];
|
||||
const isCurrentUserBanned = hasRoomBanForUser(
|
||||
bans,
|
||||
currentUser,
|
||||
getPersistedCurrentUserId()
|
||||
);
|
||||
const isCurrentUserBanned = hasRoomBanForUser(bans, currentUser, getPersistedCurrentUserId());
|
||||
|
||||
if (currentRoom?.id === room.id) {
|
||||
actions.push(UsersActions.loadBansSuccess({ bans }));
|
||||
@@ -684,8 +670,7 @@ export class RoomStateSyncEffects {
|
||||
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
||||
const settings = event.settings as Partial<RoomSettings> | undefined;
|
||||
|
||||
if (!room || !settings)
|
||||
return EMPTY;
|
||||
if (!room || !settings) return EMPTY;
|
||||
|
||||
return of(
|
||||
RoomsActions.updateRoom({
|
||||
@@ -699,7 +684,9 @@ export class RoomStateSyncEffects {
|
||||
hasPassword:
|
||||
typeof settings.hasPassword === 'boolean'
|
||||
? settings.hasPassword
|
||||
: (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password),
|
||||
: typeof room.hasPassword === 'boolean'
|
||||
? room.hasPassword
|
||||
: !!room.password,
|
||||
maxUsers: settings.maxUsers ?? room.maxUsers
|
||||
}
|
||||
})
|
||||
@@ -712,17 +699,13 @@ export class RoomStateSyncEffects {
|
||||
const permissions = event.permissions as Partial<RoomPermissions> | undefined;
|
||||
const incomingRoom = event.room as Partial<Room> | undefined;
|
||||
|
||||
if (!room || (!permissions && !incomingRoom))
|
||||
return EMPTY;
|
||||
if (!room || (!permissions && !incomingRoom)) return EMPTY;
|
||||
|
||||
return of(
|
||||
RoomsActions.updateRoom({
|
||||
roomId: room.id,
|
||||
changes: {
|
||||
permissions: permissions
|
||||
? { ...(room.permissions || {}),
|
||||
...permissions } as RoomPermissions
|
||||
: room.permissions,
|
||||
permissions: permissions ? ({ ...(room.permissions || {}), ...permissions } as RoomPermissions) : room.permissions,
|
||||
roles: Array.isArray(incomingRoom?.roles) ? incomingRoom.roles : room.roles,
|
||||
roleAssignments: Array.isArray(incomingRoom?.roleAssignments) ? incomingRoom.roleAssignments : room.roleAssignments,
|
||||
channelPermissions: Array.isArray(incomingRoom?.channelPermissions) ? incomingRoom.channelPermissions : room.channelPermissions,
|
||||
@@ -732,12 +715,7 @@ export class RoomStateSyncEffects {
|
||||
);
|
||||
}
|
||||
|
||||
private handleChannelsUpdate(
|
||||
event: ChatEvent,
|
||||
currentRoom: Room | null,
|
||||
savedRooms: Room[],
|
||||
activeChannelId: string
|
||||
): Action[] {
|
||||
private handleChannelsUpdate(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[], activeChannelId: string): Action[] {
|
||||
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
||||
const channels = Array.isArray(event.channels) ? event.channels : null;
|
||||
@@ -754,8 +732,7 @@ export class RoomStateSyncEffects {
|
||||
];
|
||||
|
||||
if (!channels.some((channel) => channel.id === activeChannelId)) {
|
||||
const fallbackChannelId = channels.find((channel) => channel.type === 'text')?.id
|
||||
?? 'general';
|
||||
const fallbackChannelId = channels.find((channel) => channel.type === 'text')?.id ?? 'general';
|
||||
|
||||
actions.push(RoomsActions.selectChannel({ channelId: fallbackChannelId }));
|
||||
}
|
||||
@@ -769,8 +746,7 @@ export class RoomStateSyncEffects {
|
||||
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
||||
|
||||
if (!room)
|
||||
return EMPTY;
|
||||
if (!room) return EMPTY;
|
||||
|
||||
const remoteUpdated = event.iconUpdatedAt || 0;
|
||||
const localUpdated = room.iconUpdatedAt || 0;
|
||||
@@ -789,8 +765,7 @@ export class RoomStateSyncEffects {
|
||||
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
||||
|
||||
if (!room)
|
||||
return EMPTY;
|
||||
if (!room) return EMPTY;
|
||||
|
||||
if (event.fromPeerId) {
|
||||
this.webrtc.sendToPeer(event.fromPeerId, {
|
||||
@@ -809,20 +784,17 @@ export class RoomStateSyncEffects {
|
||||
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
||||
const senderId = event.fromPeerId;
|
||||
|
||||
if (!room || typeof event.icon !== 'string' || !senderId)
|
||||
return EMPTY;
|
||||
if (!room || typeof event.icon !== 'string' || !senderId) return this.handleSearchResultIconData(event, roomId);
|
||||
|
||||
return this.store.select(selectAllUsers).pipe(
|
||||
map((users) => users.find((user) => user.id === senderId)),
|
||||
mergeMap((sender) => {
|
||||
if (!sender)
|
||||
return EMPTY;
|
||||
if (!sender) return EMPTY;
|
||||
|
||||
const isOwner = room.hostId === sender.id;
|
||||
const canByRole = resolveRoomPermission(room, sender, 'manageIcon');
|
||||
|
||||
if (!isOwner && !canByRole)
|
||||
return EMPTY;
|
||||
if (!isOwner && !canByRole) return EMPTY;
|
||||
|
||||
const updates: Partial<Room> = {
|
||||
icon: event.icon,
|
||||
@@ -830,23 +802,63 @@ export class RoomStateSyncEffects {
|
||||
};
|
||||
|
||||
this.db.updateRoom(room.id, updates);
|
||||
return of(RoomsActions.updateRoom({ roomId: room.id,
|
||||
changes: updates }));
|
||||
this.webrtc.sendRawMessage({
|
||||
type: 'server_icon_available',
|
||||
serverId: room.id,
|
||||
iconUpdatedAt: updates.iconUpdatedAt
|
||||
});
|
||||
return of(RoomsActions.updateRoom({ roomId: room.id, changes: updates }));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private handleSearchResultIconData(event: ChatEvent, roomId: string | undefined) {
|
||||
if (!roomId || typeof event.icon !== 'string') {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
const iconUpdatedAt = event.iconUpdatedAt || Date.now();
|
||||
|
||||
return of(
|
||||
RoomsActions.receiveSearchServerIcon({
|
||||
roomId,
|
||||
icon: event.icon,
|
||||
iconUpdatedAt
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private queueServerIconSyncRequest(peerId: string, serverId: string): void {
|
||||
const pendingServerIds = this.pendingServerIconRequestsByPeer.get(peerId) ?? new Set<string>();
|
||||
|
||||
pendingServerIds.add(serverId);
|
||||
this.pendingServerIconRequestsByPeer.set(peerId, pendingServerIds);
|
||||
this.scheduleServerIconSyncRequests(peerId, serverId);
|
||||
}
|
||||
|
||||
private scheduleServerIconSyncRequests(peerId: string, serverId: string): void {
|
||||
for (const delayMs of SERVER_ICON_SYNC_REQUEST_DELAYS_MS) {
|
||||
setTimeout(() => {
|
||||
this.sendServerIconSyncRequest(peerId, serverId);
|
||||
}, delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
private sendServerIconSyncRequest(peerId: string, serverId: string): void {
|
||||
this.webrtc.sendToPeer(peerId, {
|
||||
type: 'server-icon-request',
|
||||
roomId: serverId
|
||||
});
|
||||
}
|
||||
|
||||
// ── Internal helpers ───────────────────────────────────────────
|
||||
|
||||
private syncBansToLocalRoom(roomId: string, bans: BanEntry[]) {
|
||||
return from(this.db.getBansForRoom(roomId)).pipe(
|
||||
switchMap((localBans) => {
|
||||
const nextIds = new Set(bans.map((ban) => ban.oderId));
|
||||
const removals = localBans
|
||||
.filter((ban) => !nextIds.has(ban.oderId))
|
||||
.map((ban) => this.db.removeBan(ban.oderId));
|
||||
const saves = bans.map((ban) => this.db.saveBan({ ...ban,
|
||||
roomId }));
|
||||
const removals = localBans.filter((ban) => !nextIds.has(ban.oderId)).map((ban) => this.db.removeBan(ban.oderId));
|
||||
const saves = bans.map((ban) => this.db.saveBan({ ...ban, roomId }));
|
||||
|
||||
return from(Promise.all([...removals, ...saves]));
|
||||
})
|
||||
|
||||
@@ -72,6 +72,7 @@ export const RoomsActions = createActionGroup({
|
||||
'Update Server Icon': props<{ roomId: string; icon: string }>(),
|
||||
'Update Server Icon Success': props<{ roomId: string; icon: string; iconUpdatedAt: number }>(),
|
||||
'Update Server Icon Failure': props<{ error: string }>(),
|
||||
'Receive Search Server Icon': props<{ roomId: string; icon: string; iconUpdatedAt: number }>(),
|
||||
|
||||
'Set Current Room': props<{ room: Room }>(),
|
||||
'Clear Current Room': emptyProps(),
|
||||
|
||||
@@ -229,6 +229,8 @@ export class RoomsEffects {
|
||||
isPrivate: room.isPrivate,
|
||||
userCount: 1,
|
||||
maxUsers: room.maxUsers || 50,
|
||||
icon: room.icon,
|
||||
iconUpdatedAt: room.iconUpdatedAt,
|
||||
tags: [],
|
||||
channels: room.channels ?? defaultChannels()
|
||||
}, endpoint ? {
|
||||
@@ -288,6 +290,8 @@ export class RoomsEffects {
|
||||
const resolvedRoom: Room = {
|
||||
...room,
|
||||
isPrivate: typeof serverInfo?.isPrivate === 'boolean' ? serverInfo.isPrivate : room.isPrivate,
|
||||
icon: serverInfo?.icon ?? room.icon,
|
||||
iconUpdatedAt: serverInfo?.iconUpdatedAt ?? room.iconUpdatedAt,
|
||||
channels: resolveRoomChannels(room.channels, serverInfo?.channels),
|
||||
slowModeInterval: serverInfo?.slowModeInterval ?? room.slowModeInterval,
|
||||
roles: serverInfo?.roles ?? room.roles,
|
||||
@@ -309,6 +313,8 @@ export class RoomsEffects {
|
||||
roles: resolvedRoom.roles,
|
||||
roleAssignments: resolvedRoom.roleAssignments,
|
||||
channelPermissions: resolvedRoom.channelPermissions,
|
||||
icon: resolvedRoom.icon,
|
||||
iconUpdatedAt: resolvedRoom.iconUpdatedAt,
|
||||
hasPassword: resolvedRoom.hasPassword,
|
||||
isPrivate: resolvedRoom.isPrivate
|
||||
});
|
||||
@@ -337,6 +343,8 @@ export class RoomsEffects {
|
||||
createdAt: Date.now(),
|
||||
userCount: 1,
|
||||
maxUsers: 50,
|
||||
icon: serverInfo.icon,
|
||||
iconUpdatedAt: serverInfo.iconUpdatedAt,
|
||||
channels: resolveRoomChannels(undefined, serverInfo.channels),
|
||||
slowModeInterval: serverInfo.slowModeInterval,
|
||||
roles: serverInfo.roles,
|
||||
@@ -372,6 +380,8 @@ export class RoomsEffects {
|
||||
createdAt: serverData.createdAt || Date.now(),
|
||||
userCount: serverData.userCount,
|
||||
maxUsers: serverData.maxUsers,
|
||||
icon: serverData.icon,
|
||||
iconUpdatedAt: serverData.iconUpdatedAt,
|
||||
channels: resolveRoomChannels(undefined, serverData.channels),
|
||||
slowModeInterval: serverData.slowModeInterval,
|
||||
roles: serverData.roles,
|
||||
@@ -557,6 +567,8 @@ export class RoomsEffects {
|
||||
hasPassword: !!serverData.hasPassword,
|
||||
isPrivate: serverData.isPrivate,
|
||||
maxUsers: serverData.maxUsers,
|
||||
icon: serverData.icon ?? room.icon,
|
||||
iconUpdatedAt: serverData.iconUpdatedAt ?? room.iconUpdatedAt,
|
||||
channels: resolveRoomChannels(room.channels, serverData.channels),
|
||||
slowModeInterval: serverData.slowModeInterval ?? room.slowModeInterval,
|
||||
roles: serverData.roles ?? room.roles,
|
||||
|
||||
@@ -1,30 +1,18 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
Room,
|
||||
BanEntry,
|
||||
User
|
||||
} from '../../shared-kernel';
|
||||
import { Room, BanEntry, User } from '../../shared-kernel';
|
||||
import { resolveLegacyRole, resolveRoomPermission } from '../../domains/access-control';
|
||||
import { findRoomMember } from './room-members.helpers';
|
||||
import { ROOM_URL_PATTERN } from '../../core/constants';
|
||||
|
||||
/** Build a minimal User object from signaling payload. */
|
||||
export function buildSignalingUser(
|
||||
data: { oderId: string; displayName?: string; status?: string },
|
||||
extras: Record<string, unknown> = {}
|
||||
) {
|
||||
export function buildSignalingUser(data: { oderId: string; displayName?: string; status?: string }, extras: Record<string, unknown> = {}) {
|
||||
const displayName = data.displayName?.trim() || 'User';
|
||||
const rawStatus = ([
|
||||
'online',
|
||||
'away',
|
||||
'busy',
|
||||
'offline'
|
||||
] as const).includes(data.status as 'online')
|
||||
? data.status as 'online' | 'away' | 'busy' | 'offline'
|
||||
const rawStatus = (['online', 'away', 'busy', 'offline'] as const).includes(data.status as 'online')
|
||||
? (data.status as 'online' | 'away' | 'busy' | 'offline')
|
||||
: 'online';
|
||||
// 'offline' from the server means the user chose Invisible;
|
||||
// display them as disconnected to other users.
|
||||
const status = rawStatus === 'offline' ? 'disconnected' as const : rawStatus;
|
||||
const status = rawStatus === 'offline' ? ('disconnected' as const) : rawStatus;
|
||||
|
||||
return {
|
||||
oderId: data.oderId,
|
||||
@@ -43,8 +31,7 @@ export function buildSignalingUser(
|
||||
export function buildKnownUserExtras(room: Room | null, identifier: string): Record<string, unknown> {
|
||||
const knownMember = room ? findRoomMember(room.members ?? [], identifier) : undefined;
|
||||
|
||||
if (!knownMember)
|
||||
return {};
|
||||
if (!knownMember) return {};
|
||||
|
||||
return {
|
||||
username: knownMember.username,
|
||||
@@ -60,10 +47,7 @@ export function buildKnownUserExtras(room: Room | null, identifier: string): Rec
|
||||
}
|
||||
|
||||
/** Returns true when the message's server ID does not match the viewed server. */
|
||||
export function isWrongServer(
|
||||
msgServerId: string | undefined,
|
||||
viewedServerId: string | undefined
|
||||
): boolean {
|
||||
export function isWrongServer(msgServerId: string | undefined, viewedServerId: string | undefined): boolean {
|
||||
return !!(msgServerId && viewedServerId && msgServerId !== viewedServerId);
|
||||
}
|
||||
|
||||
@@ -110,9 +94,7 @@ export function reconcileRoomSnapshotChannels(
|
||||
}
|
||||
|
||||
if (hasPersistedChannels(cachedChannels) && hasPersistedChannels(incomingChannels)) {
|
||||
return incomingChannels.length >= cachedChannels.length
|
||||
? incomingChannels
|
||||
: cachedChannels;
|
||||
return incomingChannels.length >= cachedChannels.length ? incomingChannels : cachedChannels;
|
||||
}
|
||||
|
||||
if (hasPersistedChannels(incomingChannels)) {
|
||||
@@ -122,10 +104,7 @@ export function reconcileRoomSnapshotChannels(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveTextChannelId(
|
||||
channels: Room['channels'] | undefined,
|
||||
preferredChannelId?: string | null
|
||||
): string | null {
|
||||
export function resolveTextChannelId(channels: Room['channels'] | undefined, preferredChannelId?: string | null): string | null {
|
||||
const textChannels = (channels ?? []).filter((channel) => channel.type === 'text');
|
||||
|
||||
if (preferredChannelId && textChannels.some((channel) => channel.id === preferredChannelId)) {
|
||||
@@ -136,11 +115,9 @@ export function resolveTextChannelId(
|
||||
}
|
||||
|
||||
export function resolveRoom(roomId: string | undefined, currentRoom: Room | null, savedRooms: Room[]): Room | null {
|
||||
if (!roomId)
|
||||
return currentRoom;
|
||||
if (!roomId) return currentRoom;
|
||||
|
||||
if (currentRoom?.id === roomId)
|
||||
return currentRoom;
|
||||
if (currentRoom?.id === roomId) return currentRoom;
|
||||
|
||||
return savedRooms.find((room) => room.id === roomId) ?? null;
|
||||
}
|
||||
@@ -152,9 +129,7 @@ export function sanitizeRoomSnapshot(room: Partial<Room>): Partial<Room> {
|
||||
topic: typeof room.topic === 'string' ? room.topic : undefined,
|
||||
hostId: typeof room.hostId === 'string' ? room.hostId : undefined,
|
||||
hasPassword:
|
||||
typeof room.hasPassword === 'boolean'
|
||||
? room.hasPassword
|
||||
: (typeof room.password === 'string' ? room.password.trim().length > 0 : undefined),
|
||||
typeof room.hasPassword === 'boolean' ? room.hasPassword : typeof room.password === 'string' ? room.password.trim().length > 0 : undefined,
|
||||
isPrivate: typeof room.isPrivate === 'boolean' ? room.isPrivate : undefined,
|
||||
maxUsers: typeof room.maxUsers === 'number' ? room.maxUsers : undefined,
|
||||
icon: typeof room.icon === 'string' ? room.icon : undefined,
|
||||
@@ -173,8 +148,7 @@ export function sanitizeRoomSnapshot(room: Partial<Room>): Partial<Room> {
|
||||
}
|
||||
|
||||
export function normalizeIncomingBans(roomId: string, bans: unknown): BanEntry[] {
|
||||
if (!Array.isArray(bans))
|
||||
return [];
|
||||
if (!Array.isArray(bans)) return [];
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
@@ -225,6 +199,9 @@ export interface RoomPresenceSignalingMessage {
|
||||
oderId?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
fromUserId?: string;
|
||||
icon?: string;
|
||||
iconUpdatedAt?: number;
|
||||
profileUpdatedAt?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,7 @@ import { normalizeRoomAccessControl } from '../../domains/access-control';
|
||||
import { type ServerInfo } from '../../domains/server-directory';
|
||||
import { RoomsActions } from './rooms.actions';
|
||||
import { defaultChannels } from './room-channels.defaults';
|
||||
import {
|
||||
isChannelNameTaken,
|
||||
normalizeChannelName,
|
||||
normalizeRoomChannels
|
||||
} from './room-channels.rules';
|
||||
import { isChannelNameTaken, normalizeChannelName, normalizeRoomChannels } from './room-channels.rules';
|
||||
import { pruneRoomMembers } from './room-members.helpers';
|
||||
|
||||
/** Deduplicate rooms by id, keeping the last occurrence */
|
||||
@@ -35,9 +31,7 @@ function enrichRoom(room: Room): Room {
|
||||
function resolveActiveTextChannelId(channels: Room['channels'], currentActiveChannelId: string): string {
|
||||
const textChannels = (channels ?? []).filter((channel) => channel.type === 'text');
|
||||
|
||||
return textChannels.some((channel) => channel.id === currentActiveChannelId)
|
||||
? currentActiveChannelId
|
||||
: (textChannels[0]?.id ?? 'general');
|
||||
return textChannels.some((channel) => channel.id === currentActiveChannelId) ? currentActiveChannelId : (textChannels[0]?.id ?? 'general');
|
||||
}
|
||||
|
||||
function getDefaultTextChannelId(room: Room): string {
|
||||
@@ -47,7 +41,7 @@ function getDefaultTextChannelId(room: Room): string {
|
||||
/** Upsert a room into a saved-rooms list (add or replace by id) */
|
||||
function upsertRoom(savedRooms: Room[], room: Room): Room[] {
|
||||
const normalizedRoom = enrichRoom(room);
|
||||
const idx = savedRooms.findIndex(existingRoom => existingRoom.id === room.id);
|
||||
const idx = savedRooms.findIndex((existingRoom) => existingRoom.id === room.id);
|
||||
|
||||
if (idx >= 0) {
|
||||
const updated = [...savedRooms];
|
||||
@@ -250,8 +244,7 @@ export const roomsReducer = createReducer(
|
||||
})),
|
||||
|
||||
on(RoomsActions.updateRoomSettingsSuccess, (state, { roomId, settings }) => {
|
||||
const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId)
|
||||
|| (state.currentRoom?.id === roomId ? state.currentRoom : null);
|
||||
const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId) || (state.currentRoom?.id === roomId ? state.currentRoom : null);
|
||||
|
||||
if (!baseRoom) {
|
||||
return {
|
||||
@@ -270,9 +263,9 @@ export const roomsReducer = createReducer(
|
||||
hasPassword:
|
||||
typeof settings.hasPassword === 'boolean'
|
||||
? settings.hasPassword
|
||||
: (typeof settings.password === 'string'
|
||||
: typeof settings.password === 'string'
|
||||
? settings.password.trim().length > 0
|
||||
: baseRoom.hasPassword),
|
||||
: baseRoom.hasPassword,
|
||||
maxUsers: settings.maxUsers
|
||||
});
|
||||
|
||||
@@ -330,33 +323,28 @@ export const roomsReducer = createReducer(
|
||||
|
||||
// Update room
|
||||
on(RoomsActions.updateRoom, (state, { roomId, changes }) => {
|
||||
const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId)
|
||||
|| (state.currentRoom?.id === roomId ? state.currentRoom : null);
|
||||
const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId) || (state.currentRoom?.id === roomId ? state.currentRoom : null);
|
||||
|
||||
if (!baseRoom)
|
||||
return state;
|
||||
if (!baseRoom) return state;
|
||||
|
||||
const updatedRoom = enrichRoom({ ...baseRoom,
|
||||
...changes });
|
||||
const updatedRoom = enrichRoom({ ...baseRoom, ...changes });
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentRoom: state.currentRoom?.id === roomId ? updatedRoom : state.currentRoom,
|
||||
savedRooms: upsertRoom(state.savedRooms, updatedRoom),
|
||||
activeChannelId: state.currentRoom?.id === roomId
|
||||
? resolveActiveTextChannelId(updatedRoom.channels, state.activeChannelId)
|
||||
: state.activeChannelId
|
||||
activeChannelId:
|
||||
state.currentRoom?.id === roomId ? resolveActiveTextChannelId(updatedRoom.channels, state.activeChannelId) : state.activeChannelId
|
||||
};
|
||||
}),
|
||||
|
||||
// Update server icon success
|
||||
on(RoomsActions.updateServerIconSuccess, (state, { roomId, icon, iconUpdatedAt }) => {
|
||||
if (state.currentRoom?.id !== roomId)
|
||||
return state;
|
||||
const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId) || (state.currentRoom?.id === roomId ? state.currentRoom : null);
|
||||
|
||||
const updatedRoom = enrichRoom({ ...state.currentRoom,
|
||||
icon,
|
||||
iconUpdatedAt });
|
||||
if (!baseRoom) return state;
|
||||
|
||||
const updatedRoom = enrichRoom({ ...baseRoom, icon, iconUpdatedAt });
|
||||
|
||||
return {
|
||||
...state,
|
||||
@@ -365,13 +353,18 @@ export const roomsReducer = createReducer(
|
||||
};
|
||||
}),
|
||||
|
||||
on(RoomsActions.receiveSearchServerIcon, (state, { roomId, icon, iconUpdatedAt }) => ({
|
||||
...state,
|
||||
searchResults: state.searchResults.map((server) =>
|
||||
server.id === roomId && (!server.icon || (server.iconUpdatedAt ?? 0) < iconUpdatedAt) ? { ...server, icon, iconUpdatedAt } : server
|
||||
)
|
||||
})),
|
||||
|
||||
// Receive room update
|
||||
on(RoomsActions.receiveRoomUpdate, (state, { room }) => {
|
||||
if (!state.currentRoom)
|
||||
return state;
|
||||
if (!state.currentRoom) return state;
|
||||
|
||||
const updatedRoom = enrichRoom({ ...state.currentRoom,
|
||||
...room });
|
||||
const updatedRoom = enrichRoom({ ...state.currentRoom, ...room });
|
||||
|
||||
return {
|
||||
...state,
|
||||
@@ -410,27 +403,17 @@ export const roomsReducer = createReducer(
|
||||
})),
|
||||
|
||||
on(RoomsActions.addChannel, (state, { channel }) => {
|
||||
if (!state.currentRoom)
|
||||
return state;
|
||||
if (!state.currentRoom) return state;
|
||||
|
||||
const existing = state.currentRoom.channels || defaultChannels();
|
||||
const normalizedName = normalizeChannelName(channel.name);
|
||||
|
||||
if (
|
||||
!normalizedName
|
||||
|| existing.some((entry) => entry.id === channel.id)
|
||||
|| isChannelNameTaken(existing, normalizedName, channel.type)
|
||||
) {
|
||||
if (!normalizedName || existing.some((entry) => entry.id === channel.id) || isChannelNameTaken(existing, normalizedName, channel.type)) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const updatedChannels = [
|
||||
...existing,
|
||||
{ ...channel,
|
||||
name: normalizedName }
|
||||
];
|
||||
const updatedRoom = { ...state.currentRoom,
|
||||
channels: updatedChannels };
|
||||
const updatedChannels = [...existing, { ...channel, name: normalizedName }];
|
||||
const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
|
||||
|
||||
return {
|
||||
...state,
|
||||
@@ -441,13 +424,11 @@ export const roomsReducer = createReducer(
|
||||
}),
|
||||
|
||||
on(RoomsActions.removeChannel, (state, { channelId }) => {
|
||||
if (!state.currentRoom)
|
||||
return state;
|
||||
if (!state.currentRoom) return state;
|
||||
|
||||
const existing = state.currentRoom.channels || defaultChannels();
|
||||
const updatedChannels = existing.filter(channel => channel.id !== channelId);
|
||||
const updatedRoom = { ...state.currentRoom,
|
||||
channels: updatedChannels };
|
||||
const updatedChannels = existing.filter((channel) => channel.id !== channelId);
|
||||
const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
|
||||
|
||||
return {
|
||||
...state,
|
||||
@@ -458,8 +439,7 @@ export const roomsReducer = createReducer(
|
||||
}),
|
||||
|
||||
on(RoomsActions.renameChannel, (state, { channelId, name }) => {
|
||||
if (!state.currentRoom)
|
||||
return state;
|
||||
if (!state.currentRoom) return state;
|
||||
|
||||
const existing = state.currentRoom.channels || defaultChannels();
|
||||
const normalizedName = normalizeChannelName(name);
|
||||
@@ -469,10 +449,8 @@ export const roomsReducer = createReducer(
|
||||
return state;
|
||||
}
|
||||
|
||||
const updatedChannels = existing.map(channel => channel.id === channelId ? { ...channel,
|
||||
name: normalizedName } : channel);
|
||||
const updatedRoom = { ...state.currentRoom,
|
||||
channels: updatedChannels };
|
||||
const updatedChannels = existing.map((channel) => (channel.id === channelId ? { ...channel, name: normalizedName } : channel));
|
||||
const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
|
||||
|
||||
return {
|
||||
...state,
|
||||
|
||||
Reference in New Issue
Block a user