feat: server image

This commit is contained in:
2026-04-29 18:54:08 +02:00
parent 3d81c34159
commit e1ac1d1bc0
27 changed files with 1340 additions and 615 deletions

View 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)}`;
}

View File

@@ -18,6 +18,8 @@ export async function handleUpsertServer(command: UpsertServerCommand, dataSourc
isPrivate: server.isPrivate ? 1 : 0, isPrivate: server.isPrivate ? 1 : 0,
maxUsers: server.maxUsers, maxUsers: server.maxUsers,
currentUsers: server.currentUsers, currentUsers: server.currentUsers,
icon: server.icon ?? null,
iconUpdatedAt: server.iconUpdatedAt ?? 0,
slowModeInterval: server.slowModeInterval ?? 0, slowModeInterval: server.slowModeInterval ?? 0,
createdAt: server.createdAt, createdAt: server.createdAt,
lastSeen: server.lastSeen lastSeen: server.lastSeen

View File

@@ -47,6 +47,8 @@ export function rowToServer(
isPrivate: !!row.isPrivate, isPrivate: !!row.isPrivate,
maxUsers: row.maxUsers, maxUsers: row.maxUsers,
currentUsers: row.currentUsers, currentUsers: row.currentUsers,
icon: row.icon ?? undefined,
iconUpdatedAt: row.iconUpdatedAt || undefined,
slowModeInterval: relationPayload.slowModeInterval, slowModeInterval: relationPayload.slowModeInterval,
tags: relationPayload.tags, tags: relationPayload.tags,
channels: relationPayload.channels, channels: relationPayload.channels,

View File

@@ -86,6 +86,8 @@ export interface ServerPayload {
isPrivate: boolean; isPrivate: boolean;
maxUsers: number; maxUsers: number;
currentUsers: number; currentUsers: number;
icon?: string;
iconUpdatedAt?: number;
slowModeInterval?: number; slowModeInterval?: number;
tags: string[]; tags: string[];
channels: ServerChannelPayload[]; channels: ServerChannelPayload[];

View File

@@ -33,6 +33,12 @@ export class ServerEntity {
@Column('integer', { default: 0 }) @Column('integer', { default: 0 })
currentUsers!: number; currentUsers!: number;
@Column('text', { nullable: true })
icon!: string | null;
@Column('integer', { default: 0 })
iconUpdatedAt!: number;
@Column('integer', { default: 0 }) @Column('integer', { default: 0 })
slowModeInterval!: number; slowModeInterval!: number;

View 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"`);
}
}

View File

@@ -7,6 +7,7 @@ import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRole
import { GameMatchMisses1000000000006 } from './1000000000006-GameMatchMisses'; import { GameMatchMisses1000000000006 } from './1000000000006-GameMatchMisses';
import { PluginSupport1000000000007 } from './1000000000007-PluginSupport'; import { PluginSupport1000000000007 } from './1000000000007-PluginSupport';
import { ServerPluginInstallMetadata1000000000008 } from './1000000000008-ServerPluginInstallMetadata'; import { ServerPluginInstallMetadata1000000000008 } from './1000000000008-ServerPluginInstallMetadata';
import { ServerIcons1000000000009 } from './1000000000009-ServerIcons';
export const serverMigrations = [ export const serverMigrations = [
InitialSchema1000000000000, InitialSchema1000000000000,
@@ -17,5 +18,6 @@ export const serverMigrations = [
ServerRoleAccessControl1000000000005, ServerRoleAccessControl1000000000005,
GameMatchMisses1000000000006, GameMatchMisses1000000000006,
PluginSupport1000000000007, PluginSupport1000000000007,
ServerPluginInstallMetadata1000000000008 ServerPluginInstallMetadata1000000000008,
ServerIcons1000000000009
]; ];

View File

@@ -166,7 +166,9 @@ router.post('/', async (req, res) => {
maxUsers, maxUsers,
password, password,
tags, tags,
channels channels,
icon,
iconUpdatedAt
} = req.body; } = req.body;
if (!name || !ownerId || !ownerPublicKey) if (!name || !ownerId || !ownerPublicKey)
@@ -184,6 +186,8 @@ router.post('/', async (req, res) => {
isPrivate: isPrivate ?? false, isPrivate: isPrivate ?? false,
maxUsers: maxUsers ?? 0, maxUsers: maxUsers ?? 0,
currentUsers: 0, currentUsers: 0,
icon: typeof icon === 'string' ? icon : undefined,
iconUpdatedAt: typeof iconUpdatedAt === 'number' ? iconUpdatedAt : undefined,
tags: tags ?? [], tags: tags ?? [],
channels: normalizeServerChannels(channels), channels: normalizeServerChannels(channels),
createdAt: Date.now(), createdAt: Date.now(),

View File

@@ -1,18 +1,8 @@
import { connectedUsers } from './state'; import { connectedUsers } from './state';
import { ConnectedUser } from './types'; import { ConnectedUser } from './types';
import { import { broadcastToServer, findUserByOderId, getServerIdsForOderId, getUniqueUsersInServer, isOderIdConnectedToServer } from './broadcast';
broadcastToServer,
findUserByOderId,
getServerIdsForOderId,
getUniqueUsersInServer,
isOderIdConnectedToServer
} from './broadcast';
import { authorizeWebSocketJoin } from '../services/server-access.service'; import { authorizeWebSocketJoin } from '../services/server-access.service';
import { import { getPluginRequirementsSnapshot, PluginSupportError, validatePluginEventEnvelope } from '../services/plugin-support.service';
getPluginRequirementsSnapshot,
PluginSupportError,
validatePluginEventEnvelope
} from '../services/plugin-support.service';
interface WsMessage { interface WsMessage {
[key: string]: unknown; [key: string]: unknown;
@@ -36,9 +26,7 @@ function normalizeDescription(value: unknown): string | undefined {
} }
function normalizeProfileUpdatedAt(value: unknown): number | undefined { function normalizeProfileUpdatedAt(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) && value > 0 return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
? value
: undefined;
} }
function readMessageId(value: unknown): string | undefined { function readMessageId(value: unknown): string | undefined {
@@ -57,37 +45,40 @@ function readMessageId(value: unknown): string | undefined {
function sendPluginError(user: ConnectedUser, error: unknown, message: WsMessage): void { function sendPluginError(user: ConnectedUser, error: unknown, message: WsMessage): void {
if (error instanceof PluginSupportError) { if (error instanceof PluginSupportError) {
user.ws.send(JSON.stringify({ user.ws.send(
type: 'plugin_error', JSON.stringify({
serverId: typeof message['serverId'] === 'string' ? message['serverId'] : undefined, type: 'plugin_error',
pluginId: typeof message['pluginId'] === 'string' ? message['pluginId'] : undefined, serverId: typeof message['serverId'] === 'string' ? message['serverId'] : undefined,
eventName: typeof message['eventName'] === 'string' ? message['eventName'] : undefined, pluginId: typeof message['pluginId'] === 'string' ? message['pluginId'] : undefined,
eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined, eventName: typeof message['eventName'] === 'string' ? message['eventName'] : undefined,
code: error.code, eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined,
message: error.message code: error.code,
})); message: error.message
})
);
return; return;
} }
console.error('Unhandled plugin websocket error:', error); console.error('Unhandled plugin websocket error:', error);
user.ws.send(JSON.stringify({ user.ws.send(
type: 'plugin_error', JSON.stringify({
code: 'INTERNAL_ERROR', type: 'plugin_error',
message: 'Internal server error' code: 'INTERNAL_ERROR',
})); message: 'Internal server error'
})
);
} }
/** Sends the current user list for a given server to a single connected user. */ /** Sends the current user list for a given server to a single connected user. */
function sendServerUsers(user: ConnectedUser, serverId: string): void { function sendServerUsers(user: ConnectedUser, serverId: string): void {
const users = getUniqueUsersInServer(serverId, user.oderId) const users = getUniqueUsersInServer(serverId, user.oderId).map((cu) => ({
.map(cu => ({ oderId: cu.oderId,
oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName),
displayName: normalizeDisplayName(cu.displayName), description: cu.description,
description: cu.description, profileUpdatedAt: cu.profileUpdatedAt,
profileUpdatedAt: cu.profileUpdatedAt, status: cu.status ?? 'online'
status: cu.status ?? 'online' }));
}));
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users })); user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
} }
@@ -96,11 +87,13 @@ async function sendPluginRequirements(user: ConnectedUser, serverId: string): Pr
try { try {
const snapshot = await getPluginRequirementsSnapshot(serverId); const snapshot = await getPluginRequirementsSnapshot(serverId);
user.ws.send(JSON.stringify({ user.ws.send(
type: 'plugin_requirements', JSON.stringify({
serverId, type: 'plugin_requirements',
snapshot serverId,
})); snapshot
})
);
} catch (error) { } catch (error) {
sendPluginError(user, error, { type: 'plugin_requirements', serverId }); sendPluginError(user, error, { type: 'plugin_requirements', serverId });
} }
@@ -128,41 +121,42 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
connectedUsers.set(connectionId, user); connectedUsers.set(connectionId, user);
console.log(`User identified: ${user.displayName} (${user.oderId})`); console.log(`User identified: ${user.displayName} (${user.oderId})`);
if ( if (user.displayName === previousDisplayName && user.description === previousDescription && user.profileUpdatedAt === previousProfileUpdatedAt) {
user.displayName === previousDisplayName
&& user.description === previousDescription
&& user.profileUpdatedAt === previousProfileUpdatedAt
) {
return; return;
} }
for (const serverId of user.serverIds) { for (const serverId of user.serverIds) {
broadcastToServer(serverId, { broadcastToServer(
type: 'user_joined', serverId,
oderId: user.oderId, {
displayName: normalizeDisplayName(user.displayName), type: 'user_joined',
description: user.description, oderId: user.oderId,
profileUpdatedAt: user.profileUpdatedAt, displayName: normalizeDisplayName(user.displayName),
status: user.status ?? 'online', description: user.description,
serverId profileUpdatedAt: user.profileUpdatedAt,
}, user.oderId); status: user.status ?? 'online',
serverId
},
user.oderId
);
} }
} }
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> { async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
const sid = readMessageId(message['serverId']); const sid = readMessageId(message['serverId']);
if (!sid) if (!sid) return;
return;
const authorization = await authorizeWebSocketJoin(sid, user.oderId); const authorization = await authorizeWebSocketJoin(sid, user.oderId);
if (!authorization.allowed) { if (!authorization.allowed) {
user.ws.send(JSON.stringify({ user.ws.send(
type: 'access_denied', JSON.stringify({
serverId: sid, type: 'access_denied',
reason: authorization.reason serverId: sid,
})); reason: authorization.reason
})
);
return; return;
} }
@@ -174,31 +168,34 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
user.viewedServerId = sid; user.viewedServerId = sid;
connectedUsers.set(connectionId, user); connectedUsers.set(connectionId, user);
console.log( console.log(
`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) joined server ${sid} ` `User ${normalizeDisplayName(user.displayName)} (${user.oderId}) joined server ${sid} ` +
+ `(newConnection=${isNewConnectionMembership}, newIdentity=${isNewIdentityMembership})` `(newConnection=${isNewConnectionMembership}, newIdentity=${isNewIdentityMembership})`
); );
sendServerUsers(user, sid); sendServerUsers(user, sid);
await sendPluginRequirements(user, sid); await sendPluginRequirements(user, sid);
if (isNewIdentityMembership) { if (isNewIdentityMembership) {
broadcastToServer(sid, { broadcastToServer(
type: 'user_joined', sid,
oderId: user.oderId, {
displayName: normalizeDisplayName(user.displayName), type: 'user_joined',
description: user.description, oderId: user.oderId,
profileUpdatedAt: user.profileUpdatedAt, displayName: normalizeDisplayName(user.displayName),
status: user.status ?? 'online', description: user.description,
serverId: sid profileUpdatedAt: user.profileUpdatedAt,
}, user.oderId); status: user.status ?? 'online',
serverId: sid
},
user.oderId
);
} }
} }
async function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> { async function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
const viewSid = readMessageId(message['serverId']); const viewSid = readMessageId(message['serverId']);
if (!viewSid) if (!viewSid) return;
return;
if (!user.serverIds.has(viewSid)) { if (!user.serverIds.has(viewSid)) {
return; return;
@@ -215,13 +212,11 @@ async function handleViewServer(user: ConnectedUser, message: WsMessage, connect
function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void { function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
const leaveSid = readMessageId(message['serverId']) ?? user.viewedServerId; const leaveSid = readMessageId(message['serverId']) ?? user.viewedServerId;
if (!leaveSid) if (!leaveSid) return;
return;
user.serverIds.delete(leaveSid); user.serverIds.delete(leaveSid);
if (user.viewedServerId === leaveSid) if (user.viewedServerId === leaveSid) user.viewedServerId = undefined;
user.viewedServerId = undefined;
connectedUsers.set(connectionId, user); connectedUsers.set(connectionId, user);
@@ -231,13 +226,17 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
return; return;
} }
broadcastToServer(leaveSid, { broadcastToServer(
type: 'user_left', leaveSid,
oderId: user.oderId, {
displayName: normalizeDisplayName(user.displayName), type: 'user_left',
serverId: leaveSid, oderId: user.oderId,
serverIds: remainingServerIds displayName: normalizeDisplayName(user.displayName),
}, user.oderId); serverId: leaveSid,
serverIds: remainingServerIds
},
user.oderId
);
} }
function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void { function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void {
@@ -253,7 +252,7 @@ function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void {
} else { } else {
console.log( console.log(
`Target user ${targetUserId} not found. Connected users:`, `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,62 +274,104 @@ function handleChatMessage(user: ConnectedUser, message: WsMessage): void {
function handleTyping(user: ConnectedUser, message: WsMessage): void { function handleTyping(user: ConnectedUser, message: WsMessage): void {
const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId; const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim() const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim() ? message['channelId'].trim() : 'general';
? message['channelId'].trim()
: 'general';
if (typingSid && user.serverIds.has(typingSid)) { if (typingSid && user.serverIds.has(typingSid)) {
broadcastToServer(typingSid, { broadcastToServer(
type: 'user_typing', typingSid,
serverId: typingSid, {
channelId, type: 'user_typing',
oderId: user.oderId, serverId: typingSid,
displayName: user.displayName channelId,
}, user.oderId); oderId: user.oderId,
displayName: user.displayName
},
user.oderId
);
} }
} }
const VALID_STATUSES = new Set([ const VALID_STATUSES = new Set(['online', 'away', 'busy', 'offline']);
'online',
'away',
'busy',
'offline'
]);
function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionId: string): void { function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionId: string): void {
const status = typeof message['status'] === 'string' ? message['status'] : undefined; const status = typeof message['status'] === 'string' ? message['status'] : undefined;
if (!status || !VALID_STATUSES.has(status)) if (!status || !VALID_STATUSES.has(status)) return;
return;
user.status = status as ConnectedUser['status']; user.status = status as ConnectedUser['status'];
connectedUsers.set(connectionId, user); connectedUsers.set(connectionId, user);
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) status -> ${status}`); console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) status -> ${status}`);
for (const serverId of user.serverIds) { for (const serverId of user.serverIds) {
broadcastToServer(serverId, { broadcastToServer(
type: 'status_update', serverId,
oderId: user.oderId, {
status type: 'status_update',
}, user.oderId); oderId: user.oderId,
status
},
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> { async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promise<void> {
const serverId = readMessageId(message['serverId']) ?? user.viewedServerId; const serverId = readMessageId(message['serverId']) ?? user.viewedServerId;
const pluginId = readMessageId(message['pluginId']); const pluginId = readMessageId(message['pluginId']);
const eventName = readMessageId(message['eventName']); const eventName = readMessageId(message['eventName']);
if (!serverId || !pluginId || !eventName || !user.serverIds.has(serverId)) { if (!serverId || !pluginId || !eventName || !user.serverIds.has(serverId)) {
user.ws.send(JSON.stringify({ user.ws.send(
type: 'plugin_error', JSON.stringify({
serverId, type: 'plugin_error',
pluginId, serverId,
eventName, pluginId,
eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined, eventName,
code: 'INVALID_PLUGIN_EVENT', eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined,
message: 'Plugin event is missing required fields or server membership' code: 'INVALID_PLUGIN_EVENT',
})); message: 'Plugin event is missing required fields or server membership'
})
);
return; return;
} }
@@ -346,17 +387,21 @@ async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promi
sourcePluginUserId: typeof message['sourcePluginUserId'] === 'string' ? message['sourcePluginUserId'] : undefined sourcePluginUserId: typeof message['sourcePluginUserId'] === 'string' ? message['sourcePluginUserId'] : undefined
}); });
broadcastToServer(serverId, { broadcastToServer(
type: 'plugin_event',
serverId, serverId,
pluginId, {
eventName, type: 'plugin_event',
eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined, serverId,
payload: message['payload'], pluginId,
sourcePluginUserId: typeof message['sourcePluginUserId'] === 'string' ? message['sourcePluginUserId'] : undefined, eventName,
sourceUserId: user.oderId, eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined,
emittedAt: Date.now() payload: message['payload'],
}, user.oderId); sourcePluginUserId: typeof message['sourcePluginUserId'] === 'string' ? message['sourcePluginUserId'] : undefined,
sourceUserId: user.oderId,
emittedAt: Date.now()
},
user.oderId
);
} catch (error) { } catch (error) {
sendPluginError(user, error, message); 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> { export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
const user = connectedUsers.get(connectionId); const user = connectedUsers.get(connectionId);
if (!user) if (!user) return;
return;
user.lastPong = Date.now(); user.lastPong = Date.now();
connectedUsers.set(connectionId, user); connectedUsers.set(connectionId, user);
@@ -394,6 +438,8 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
case 'offer': case 'offer':
case 'answer': case 'answer':
case 'ice_candidate': case 'ice_candidate':
case 'server_icon_peer_request':
case 'server_icon_peer_data':
forwardRtcMessage(user, message); forwardRtcMessage(user, message);
break; break;
@@ -409,6 +455,14 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
handleStatusUpdate(user, message, connectionId); handleStatusUpdate(user, message, connectionId);
break; break;
case 'server_icon_available':
handleServerIconAvailable(user, message, connectionId);
break;
case 'server_icon_sync_request':
handleServerIconSyncRequest(user, message);
break;
case 'plugin_event': case 'plugin_event':
await handlePluginEvent(user, message); await handlePluginEvent(user, message);
break; break;

View File

@@ -17,6 +17,8 @@ export interface ConnectedUser {
connectionScope?: string; connectionScope?: string;
/** User availability status (online, away, busy, offline). */ /** User availability status (online, away, busy, offline). */
status?: 'online' | 'away' | 'busy' | 'offline'; status?: 'online' | 'away' | 'busy' | 'offline';
/** 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). */ /** Timestamp of the last pong or client message received (used to detect dead connections). */
lastPong: number; lastPong: number;
} }

View File

@@ -18,6 +18,8 @@ export interface ServerInfo {
ownerPublicKey?: string; ownerPublicKey?: string;
userCount: number; userCount: number;
maxUsers: number; maxUsers: number;
icon?: string;
iconUpdatedAt?: number;
hasPassword?: boolean; hasPassword?: boolean;
isPrivate: boolean; isPrivate: boolean;
tags?: string[]; tags?: string[];

View File

@@ -101,8 +101,16 @@
(dblclick)="openServerCard(server)" (dblclick)="openServerCard(server)"
> >
<div class="flex min-w-0 items-start gap-3"> <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">
{{ server.name[0] || '?' }} @if (server.icon) {
<img
[src]="server.icon"
[alt]="server.name + ' icon'"
class="h-full w-full object-cover"
/>
} @else {
{{ server.name[0] || '?' }}
}
</div> </div>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">

View File

@@ -1,64 +1,30 @@
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
import { import { Component, effect, inject, OnInit, signal } from '@angular/core';
Component,
effect,
inject,
OnInit,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { import { debounceTime, distinctUntilChanged, firstValueFrom, Subject } from 'rxjs';
debounceTime,
distinctUntilChanged,
firstValueFrom,
Subject
} from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { import { lucideSearch, lucideUsers, lucideLock, lucideGlobe, lucidePlus, lucideSettings, lucideChevronDown } from '@ng-icons/lucide';
lucideSearch,
lucideUsers,
lucideLock,
lucideGlobe,
lucidePlus,
lucideSettings,
lucideChevronDown
} from '@ng-icons/lucide';
import { RoomsActions } from '../../../../store/rooms/rooms.actions'; import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { import { selectSearchResults, selectIsSearching, selectRoomsError, selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
selectSearchResults,
selectIsSearching,
selectRoomsError,
selectSavedRooms
} from '../../../../store/rooms/rooms.selectors';
import { Room, User } from '../../../../shared-kernel'; import { Room, User } from '../../../../shared-kernel';
import { SettingsModalService } from '../../../../core/services/settings-modal.service'; import { SettingsModalService } from '../../../../core/services/settings-modal.service';
import { DatabaseService } from '../../../../infrastructure/persistence'; import { DatabaseService } from '../../../../infrastructure/persistence';
import { type ServerInfo } from '../../domain/models/server-directory.model'; import { type ServerInfo } from '../../domain/models/server-directory.model';
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade'; import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
import { selectCurrentUser } from '../../../../store/users/users.selectors'; import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { import { ConfirmDialogComponent, LeaveServerDialogComponent, type LeaveServerDialogResult } from '../../../../shared';
ConfirmDialogComponent,
LeaveServerDialogComponent,
type LeaveServerDialogResult
} from '../../../../shared';
import { hasRoomBanForUser } from '../../../access-control'; import { hasRoomBanForUser } from '../../../access-control';
import { UserSearchListComponent } from '../../../direct-message/feature/user-search-list/user-search-list.component'; import { UserSearchListComponent } from '../../../direct-message/feature/user-search-list/user-search-list.component';
import { RealtimeSessionFacade } from '../../../../core/realtime';
@Component({ @Component({
selector: 'app-server-search', selector: 'app-server-search',
standalone: true, standalone: true,
imports: [ imports: [CommonModule, FormsModule, NgIcon, ConfirmDialogComponent, LeaveServerDialogComponent, UserSearchListComponent],
CommonModule,
FormsModule,
NgIcon,
ConfirmDialogComponent,
LeaveServerDialogComponent,
UserSearchListComponent
],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideSearch, lucideSearch,
@@ -82,6 +48,7 @@ export class ServerSearchComponent implements OnInit {
private settingsModal = inject(SettingsModalService); private settingsModal = inject(SettingsModalService);
private db = inject(DatabaseService); private db = inject(DatabaseService);
private serverDirectory = inject(ServerDirectoryFacade); private serverDirectory = inject(ServerDirectoryFacade);
private webrtc = inject(RealtimeSessionFacade);
private searchSubject = new Subject<string>(); private searchSubject = new Subject<string>();
private banLookupRequestVersion = 0; private banLookupRequestVersion = 0;
@@ -118,6 +85,7 @@ export class ServerSearchComponent implements OnInit {
const currentUser = this.currentUser(); const currentUser = this.currentUser();
void this.refreshBannedLookup(servers, currentUser ?? null); 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. */ /** Submit the new server creation form and dispatch the create action. */
createServer(): void { createServer(): void {
if (!this.newServerName()) if (!this.newServerName()) return;
return;
const currentUserId = localStorage.getItem('metoyou_currentUserId'); const currentUserId = localStorage.getItem('metoyou_currentUserId');
@@ -225,7 +192,7 @@ export class ServerSearchComponent implements OnInit {
toggleJoinedServerMenu(event: Event, server: ServerInfo): void { toggleJoinedServerMenu(event: Event, server: ServerInfo): void {
event.stopPropagation(); event.stopPropagation();
this.joinedServerMenuId.update((currentId) => currentId === server.id ? null : server.id); this.joinedServerMenuId.update((currentId) => (currentId === server.id ? null : server.id));
} }
closeJoinedServerMenu(): void { closeJoinedServerMenu(): void {
@@ -255,10 +222,12 @@ export class ServerSearchComponent implements OnInit {
return; return;
} }
this.store.dispatch(RoomsActions.forgetRoom({ this.store.dispatch(
roomId: room.id, RoomsActions.forgetRoom({
nextOwnerKey: result.nextOwnerKey roomId: room.id,
})); nextOwnerKey: result.nextOwnerKey
})
);
this.leaveDialogRoom.set(null); this.leaveDialogRoom.set(null);
} }
@@ -278,8 +247,7 @@ export class ServerSearchComponent implements OnInit {
async confirmPasswordJoin(): Promise<void> { async confirmPasswordJoin(): Promise<void> {
const server = this.passwordPromptServer(); const server = this.passwordPromptServer();
if (!server) if (!server) return;
return;
await this.attemptJoinServer(server, this.joinPassword()); await this.attemptJoinServer(server, this.joinPassword());
} }
@@ -291,8 +259,7 @@ export class ServerSearchComponent implements OnInit {
getServerUserCount(server: ServerInfo): number { getServerUserCount(server: ServerInfo): number {
const candidate = server as ServerInfo & { currentUsers?: number }; const candidate = server as ServerInfo & { currentUsers?: number };
if (typeof server.userCount === 'number') if (typeof server.userCount === 'number') return server.userCount;
return server.userCount;
return typeof candidate.currentUsers === 'number' ? candidate.currentUsers : 0; return typeof candidate.currentUsers === 'number' ? candidate.currentUsers : 0;
} }
@@ -304,9 +271,7 @@ export class ServerSearchComponent implements OnInit {
getServerOwnerLabel(server: ServerInfo): string { getServerOwnerLabel(server: ServerInfo): string {
const joinedRoom = this.joinedRoomForServer(server); const joinedRoom = this.joinedRoomForServer(server);
const ownerKey = server.ownerId || joinedRoom?.hostId || ''; const ownerKey = server.ownerId || joinedRoom?.hostId || '';
const ownerMember = joinedRoom?.members?.find((member) => const ownerMember = joinedRoom?.members?.find((member) => member.id === ownerKey || member.oderId === ownerKey);
member.id === ownerKey || member.oderId === ownerKey
);
return server.ownerName || ownerMember?.displayName || server.ownerId || joinedRoom?.hostId || 'Unknown owner'; return server.ownerName || ownerMember?.displayName || server.ownerId || joinedRoom?.hostId || 'Unknown owner';
} }
@@ -324,6 +289,8 @@ export class ServerSearchComponent implements OnInit {
hostName: room.hostId || 'Unknown', hostName: room.hostId || 'Unknown',
userCount: room.userCount ?? 0, userCount: room.userCount ?? 0,
maxUsers: room.maxUsers ?? 50, maxUsers: room.maxUsers ?? 50,
icon: room.icon,
iconUpdatedAt: room.iconUpdatedAt,
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password, hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
isPrivate: room.isPrivate, isPrivate: room.isPrivate,
channels: room.channels, channels: room.channels,
@@ -348,32 +315,37 @@ export class ServerSearchComponent implements OnInit {
this.joinPasswordError.set(null); this.joinPasswordError.set(null);
try { try {
const response = await firstValueFrom(this.serverDirectory.requestJoin({ const response = await firstValueFrom(
roomId: server.id, this.serverDirectory.requestJoin(
userId: currentUserId, {
userPublicKey: currentUser?.oderId || currentUserId, roomId: server.id,
displayName: currentUser?.displayName || 'Anonymous', userId: currentUserId,
password: password?.trim() || undefined userPublicKey: currentUser?.oderId || currentUserId,
}, { displayName: currentUser?.displayName || 'Anonymous',
sourceId: server.sourceId, password: password?.trim() || undefined
sourceUrl: server.sourceUrl },
})); {
const resolvedSource = this.serverDirectory.normaliseRoomSignalSource({ sourceId: server.sourceId,
sourceId: response.server.sourceId ?? server.sourceId, sourceUrl: server.sourceUrl
sourceName: response.server.sourceName ?? server.sourceName, }
sourceUrl: response.server.sourceUrl ?? server.sourceUrl, )
signalingUrl: response.signalingUrl, );
fallbackName: response.server.sourceName ?? server.sourceName ?? server.name const resolvedSource = this.serverDirectory.normaliseRoomSignalSource(
}, { {
ensureEndpoint: true 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 = { const resolvedServer = {
...server, ...server,
...response.server, ...response.server,
channels: channels: Array.isArray(response.server.channels) && response.server.channels.length > 0 ? response.server.channels : server.channels,
Array.isArray(response.server.channels) && response.server.channels.length > 0
? response.server.channels
: server.channels,
...resolvedSource, ...resolvedSource,
signalingUrl: response.signalingUrl 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> { private async refreshBannedLookup(servers: ServerInfo[], currentUser: User | null): Promise<void> {
const requestVersion = ++this.banLookupRequestVersion; const requestVersion = ++this.banLookupRequestVersion;
@@ -427,8 +446,7 @@ export class ServerSearchComponent implements OnInit {
}) })
); );
if (requestVersion !== this.banLookupRequestVersion) if (requestVersion !== this.banLookupRequestVersion) return;
return;
this.bannedServerLookup.set(Object.fromEntries(entries)); this.bannedServerLookup.set(Object.fromEntries(entries));
} }
@@ -437,8 +455,7 @@ export class ServerSearchComponent implements OnInit {
const currentUser = this.currentUser(); const currentUser = this.currentUser();
const currentUserId = localStorage.getItem('metoyou_currentUserId'); const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUser && !currentUserId) if (!currentUser && !currentUserId) return false;
return false;
const bans = await this.db.getBansForRoom(server.id); const bans = await this.db.getBansForRoom(server.id);

View File

@@ -6,8 +6,16 @@
> >
@if (panelMode() === 'channels') { @if (panelMode() === 'channels') {
<div class="flex items-center gap-3"> <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">
{{ currentRoom()?.name?.charAt(0)?.toUpperCase() || '#' }} @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>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">

View File

@@ -43,8 +43,8 @@
<div class="h-full w-full overflow-hidden rounded-[inherit]"> <div class="h-full w-full overflow-hidden rounded-[inherit]">
@if (room.icon) { @if (room.icon) {
<img <img
[ngSrc]="room.icon" [src]="room.icon"
[alt]="room.name" [alt]="room.name + ' icon'"
class="h-full w-full object-cover" class="h-full w-full object-cover"
/> />
} @else { } @else {

View File

@@ -1,31 +1,13 @@
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
import { import { Component, DestroyRef, Type, computed, effect, inject, signal } from '@angular/core';
Component,
DestroyRef,
Type,
computed,
effect,
inject,
signal
} from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; 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 { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { NavigationEnd, Router } from '@angular/router'; import { NavigationEnd, Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePlus } from '@ng-icons/lucide'; import { lucidePlus } from '@ng-icons/lucide';
import { import { EMPTY, Subject, catchError, filter, firstValueFrom, from, map, switchMap, tap } from 'rxjs';
EMPTY,
Subject,
catchError,
filter,
firstValueFrom,
from,
map,
switchMap,
tap
} from 'rxjs';
import { Room, User } from '../../../shared-kernel'; import { Room, User } from '../../../shared-kernel';
import { UserBarComponent } from '../../../domains/authentication/feature/user-bar/user-bar.component'; 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 { type ServerInfo, ServerDirectoryFacade } from '../../../domains/server-directory';
import { ThemeNodeDirective } from '../../../domains/theme'; import { ThemeNodeDirective } from '../../../domains/theme';
import { hasRoomBanForUser } from '../../../domains/access-control'; import { hasRoomBanForUser } from '../../../domains/access-control';
import { import { ConfirmDialogComponent, ContextMenuComponent, LeaveServerDialogComponent } from '../../../shared';
ConfirmDialogComponent,
ContextMenuComponent,
LeaveServerDialogComponent
} from '../../../shared';
@Component({ @Component({
selector: 'app-servers-rail', selector: 'app-servers-rail',
@@ -54,7 +32,6 @@ import {
ConfirmDialogComponent, ConfirmDialogComponent,
ContextMenuComponent, ContextMenuComponent,
LeaveServerDialogComponent, LeaveServerDialogComponent,
NgOptimizedImage,
ThemeNodeDirective, ThemeNodeDirective,
UserBarComponent UserBarComponent
], ],
@@ -166,8 +143,7 @@ export class ServersRailComponent {
} }
initial(name?: string): string { initial(name?: string): string {
if (!name) if (!name) return '?';
return '?';
const ch = name.trim()[0]?.toUpperCase(); const ch = name.trim()[0]?.toUpperCase();
@@ -219,8 +195,7 @@ export class ServersRailComponent {
confirmPasswordJoin(): void { confirmPasswordJoin(): void {
const room = this.passwordPromptRoom(); const room = this.passwordPromptRoom();
if (!room) if (!room) return;
return;
this.joinPasswordError.set(null); this.joinPasswordError.set(null);
this.savedRoomJoinRequests.next({ room, password: this.joinPassword() }); this.savedRoomJoinRequests.next({ room, password: this.joinPassword() });
@@ -260,8 +235,7 @@ export class ServersRailComponent {
confirmLeave(result: { nextOwnerKey?: string }): void { confirmLeave(result: { nextOwnerKey?: string }): void {
const ctx = this.contextRoom(); const ctx = this.contextRoom();
if (!ctx) if (!ctx) return;
return;
const isCurrentRoom = this.currentRoom()?.id === ctx.id; const isCurrentRoom = this.currentRoom()?.id === ctx.id;
@@ -364,8 +338,7 @@ export class ServersRailComponent {
const currentUserId = localStorage.getItem('metoyou_currentUserId'); const currentUserId = localStorage.getItem('metoyou_currentUserId');
const currentUser = this.currentUser(); const currentUser = this.currentUser();
if (!currentUserId) if (!currentUserId) return EMPTY;
return EMPTY;
this.joinPasswordError.set(null); this.joinPasswordError.set(null);

View File

@@ -3,11 +3,74 @@
<section> <section>
<h4 class="text-sm font-semibold text-foreground mb-3">Room Settings</h4> <h4 class="text-sm font-semibold text-foreground mb-3">Room Settings</h4>
@if (!isAdmin()) { @if (!isAdmin()) {
<p class="text-xs text-muted-foreground mb-3"> <p class="text-xs text-muted-foreground mb-3">You are viewing this server's details without server-management permission.</p>
You are viewing this server's settings as a non-admin. Only the server owner can make changes.
</p>
} }
<div class="space-y-4"> <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> <div>
<label <label
for="room-name" for="room-name"
@@ -191,21 +254,23 @@
{{ saveSuccess() === 'server' ? 'Saved!' : 'Save Settings' }} {{ saveSuccess() === 'server' ? 'Saved!' : 'Save Settings' }}
</button> </button>
<!-- Danger Zone --> @if (canDeleteServer()) {
<div class="pt-4 border-t border-border"> <!-- Danger Zone -->
<h4 class="text-sm font-medium text-destructive mb-3">Danger Zone</h4> <div class="pt-4 border-t border-border">
<button <h4 class="text-sm font-medium text-destructive mb-3">Danger Zone</h4>
(click)="confirmDeleteRoom()" <button
type="button" (click)="confirmDeleteRoom()"
class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2 text-sm" type="button"
> class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2 text-sm"
<ng-icon >
name="lucideTrash2" <ng-icon
class="w-4 h-4" name="lucideTrash2"
/> class="w-4 h-4"
Delete Room />
</button> Delete Room
</div> </button>
</div>
}
} }
</div> </div>

View File

@@ -12,9 +12,12 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { import {
lucideCheck, lucideCheck,
lucideImage,
lucideTrash2, lucideTrash2,
lucideLock, lucideLock,
lucideUnlock lucideUnlock,
lucideUpload,
lucideX
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { Room } from '../../../../shared-kernel'; import { Room } from '../../../../shared-kernel';
@@ -34,9 +37,12 @@ import { SettingsModalService } from '../../../../core/services/settings-modal.s
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideCheck, lucideCheck,
lucideImage,
lucideTrash2, lucideTrash2,
lucideLock, lucideLock,
lucideUnlock lucideUnlock,
lucideUpload,
lucideX
}) })
], ],
templateUrl: './server-settings.component.html' templateUrl: './server-settings.component.html'
@@ -49,6 +55,10 @@ export class ServerSettingsComponent {
server = input<Room | null>(null); server = input<Room | null>(null);
/** Whether the current user is admin of this server. */ /** Whether the current user is admin of this server. */
isAdmin = input(false); 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 = ''; roomName = '';
roomDescription = ''; roomDescription = '';
@@ -59,6 +69,7 @@ export class ServerSettingsComponent {
roomPassword = ''; roomPassword = '';
maxUsers = 0; maxUsers = 0;
showDeleteConfirm = signal(false); showDeleteConfirm = signal(false);
iconError = signal<string | null>(null);
saveSuccess = signal<string | null>(null); saveSuccess = signal<string | null>(null);
private saveTimeout: ReturnType<typeof setTimeout> | null = null; private saveTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -170,6 +181,64 @@ export class ServerSettingsComponent {
this.modal.navigate('network'); 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 { private showSaveSuccess(key: string): void {
this.saveSuccess.set(key); this.saveSuccess.set(key);

View File

@@ -316,7 +316,9 @@
@case ('server') { @case ('server') {
<app-server-settings <app-server-settings
[server]="selectedServer()" [server]="selectedServer()"
[isAdmin]="isSelectedServerOwner()" [isAdmin]="canManageSelectedServerSettings()"
[canManageIcon]="canManageSelectedServerIcon()"
[canDeleteServer]="isSelectedServerOwner()"
/> />
} }
@case ('serverPlugins') { @case ('serverPlugins') {

View File

@@ -165,6 +165,7 @@ export class SettingsModalComponent {
resolveRoomPermission(viewedRoom, user, 'manageServer') || resolveRoomPermission(viewedRoom, user, 'manageServer') ||
resolveRoomPermission(viewedRoom, user, 'manageRoles') || resolveRoomPermission(viewedRoom, user, 'manageRoles') ||
resolveRoomPermission(viewedRoom, user, 'manageChannels') || resolveRoomPermission(viewedRoom, user, 'manageChannels') ||
resolveRoomPermission(viewedRoom, user, 'manageIcon') ||
resolveRoomPermission(viewedRoom, user, 'manageBans') || resolveRoomPermission(viewedRoom, user, 'manageBans') ||
resolveRoomPermission(viewedRoom, user, 'kickMembers') || resolveRoomPermission(viewedRoom, user, 'kickMembers') ||
resolveRoomPermission(viewedRoom, user, 'banMembers') resolveRoomPermission(viewedRoom, user, 'banMembers')
@@ -208,6 +209,7 @@ export class SettingsModalComponent {
resolveRoomPermission(server, user, 'manageServer') || resolveRoomPermission(server, user, 'manageServer') ||
resolveRoomPermission(server, user, 'manageRoles') || resolveRoomPermission(server, user, 'manageRoles') ||
resolveRoomPermission(server, user, 'manageChannels') || resolveRoomPermission(server, user, 'manageChannels') ||
resolveRoomPermission(server, user, 'manageIcon') ||
resolveRoomPermission(server, user, 'manageBans') || resolveRoomPermission(server, user, 'manageBans') ||
resolveRoomPermission(server, user, 'kickMembers') || resolveRoomPermission(server, user, 'kickMembers') ||
resolveRoomPermission(server, user, 'banMembers')) resolveRoomPermission(server, user, 'banMembers'))
@@ -252,6 +254,20 @@ export class SettingsModalComponent {
return this.selectedServerRole() === 'host'; 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(() => { isSelectedServerCurrent = computed(() => {
const selectedServerId = this.selectedServerId(); const selectedServerId = this.selectedServerId();
const currentRoomId = this.currentRoom()?.id ?? null; const currentRoomId = this.currentRoom()?.id ?? null;

View File

@@ -34,6 +34,9 @@ export type IncomingSignalingMessage = Omit<Partial<SignalingMessage>, 'type' |
users?: SignalingUserSummary[]; users?: SignalingUserSummary[];
displayName?: string; displayName?: string;
fromUserId?: string; fromUserId?: string;
icon?: string;
iconUpdatedAt?: number;
targetUserId?: string;
}; };
interface IncomingSignalingMessageHandlerDependencies { interface IncomingSignalingMessageHandlerDependencies {
@@ -60,9 +63,7 @@ export class IncomingSignalingMessageHandler {
/** Tracks when we first started waiting for a remote-initiated offer from each peer. */ /** Tracks when we first started waiting for a remote-initiated offer from each peer. */
private readonly nonInitiatorWaitStart = new Map<string, number>(); private readonly nonInitiatorWaitStart = new Map<string, number>();
constructor( constructor(private readonly dependencies: IncomingSignalingMessageHandlerDependencies) {}
private readonly dependencies: IncomingSignalingMessageHandlerDependencies
) {}
handleMessage(message: IncomingSignalingMessage, signalUrl: string): void { handleMessage(message: IncomingSignalingMessage, signalUrl: string): void {
this.dependencies.logger.info('Signaling message', { this.dependencies.logger.info('Signaling message', {
@@ -76,6 +77,7 @@ export class IncomingSignalingMessageHandler {
return; return;
case SIGNALING_TYPE_SERVER_USERS: case SIGNALING_TYPE_SERVER_USERS:
case 'server_icon_sync_peers':
this.handleServerUsersSignalingMessage(message, signalUrl); this.handleServerUsersSignalingMessage(message, signalUrl);
return; return;
@@ -138,11 +140,9 @@ export class IncomingSignalingMessageHandler {
} }
for (const user of users) { for (const user of users) {
if (!user.oderId) if (!user.oderId) continue;
continue;
if (localOderId && user.oderId === localOderId) if (localOderId && user.oderId === localOderId) continue;
continue;
this.clearUserJoinedFallbackOffer(user.oderId); this.clearUserJoinedFallbackOffer(user.oderId);
@@ -295,9 +295,9 @@ export class IncomingSignalingMessageHandler {
const hasRemainingSharedServers = Array.isArray(message.serverIds) const hasRemainingSharedServers = Array.isArray(message.serverIds)
? this.dependencies.signalingCoordinator.replacePeerSharedServers(message.oderId, signalUrl, message.serverIds) ? this.dependencies.signalingCoordinator.replacePeerSharedServers(message.oderId, signalUrl, message.serverIds)
: (message.serverId : message.serverId
? this.dependencies.signalingCoordinator.untrackPeerFromServer(message.oderId, signalUrl, message.serverId) ? this.dependencies.signalingCoordinator.untrackPeerFromServer(message.oderId, signalUrl, message.serverId)
: false); : false;
if (!hasRemainingSharedServers) { if (!hasRemainingSharedServers) {
this.dependencies.peerManager.removePeer(message.oderId); this.dependencies.peerManager.removePeer(message.oderId);
@@ -310,11 +310,9 @@ export class IncomingSignalingMessageHandler {
const fromUserId = message.fromUserId; const fromUserId = message.fromUserId;
const sdp = message.payload?.sdp; const sdp = message.payload?.sdp;
if (!fromUserId || !sdp) if (!fromUserId || !sdp) return;
return;
if (fromUserId === this.dependencies.getLocalOderId()) if (fromUserId === this.dependencies.getLocalOderId()) return;
return;
this.clearUserJoinedFallbackOffer(fromUserId); this.clearUserJoinedFallbackOffer(fromUserId);
this.nonInitiatorWaitStart.delete(fromUserId); this.nonInitiatorWaitStart.delete(fromUserId);
@@ -334,11 +332,9 @@ export class IncomingSignalingMessageHandler {
const fromUserId = message.fromUserId; const fromUserId = message.fromUserId;
const sdp = message.payload?.sdp; const sdp = message.payload?.sdp;
if (!fromUserId || !sdp) if (!fromUserId || !sdp) return;
return;
if (fromUserId === this.dependencies.getLocalOderId()) if (fromUserId === this.dependencies.getLocalOderId()) return;
return;
this.clearUserJoinedFallbackOffer(fromUserId); this.clearUserJoinedFallbackOffer(fromUserId);
@@ -350,11 +346,9 @@ export class IncomingSignalingMessageHandler {
const fromUserId = message.fromUserId; const fromUserId = message.fromUserId;
const candidate = message.payload?.candidate; const candidate = message.payload?.candidate;
if (!fromUserId || !candidate) if (!fromUserId || !candidate) return;
return;
if (fromUserId === this.dependencies.getLocalOderId()) if (fromUserId === this.dependencies.getLocalOderId()) return;
return;
this.clearUserJoinedFallbackOffer(fromUserId); this.clearUserJoinedFallbackOffer(fromUserId);
@@ -513,18 +507,15 @@ export class IncomingSignalingMessageHandler {
} }
private shouldInitiatePeer(peerId: string, localOderId: string | null = this.dependencies.getLocalOderId()): boolean { private shouldInitiatePeer(peerId: string, localOderId: string | null = this.dependencies.getLocalOderId()): boolean {
if (!localOderId) if (!localOderId) return false;
return false;
if (peerId === localOderId) if (peerId === localOderId) return false;
return false;
return localOderId < peerId; return localOderId < peerId;
} }
private hasActivePeerConnection(peer: PeerData | undefined): boolean { private hasActivePeerConnection(peer: PeerData | undefined): boolean {
if (!peer) if (!peer) return false;
return false;
const connectionState = peer.connection?.connectionState; const connectionState = peer.connection?.connectionState;
@@ -532,13 +523,11 @@ export class IncomingSignalingMessageHandler {
} }
private isPeerConnectionNegotiating(peer: PeerData | undefined): boolean { private isPeerConnectionNegotiating(peer: PeerData | undefined): boolean {
if (!peer || this.hasActivePeerConnection(peer)) if (!peer || this.hasActivePeerConnection(peer)) return false;
return false;
const connectionState = peer.connection?.connectionState; const connectionState = peer.connection?.connectionState;
if (connectionState === 'closed' || connectionState === 'failed') if (connectionState === 'closed' || connectionState === 'failed') return false;
return false;
const signalingState = peer.connection?.signalingState; const signalingState = peer.connection?.signalingState;
const ageMs = Date.now() - peer.createdAt; 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 // 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 // negotiating with the remote side. Use a much longer grace period so that
// a slow signaling round-trip does not trigger a premature teardown. // a slow signaling round-trip does not trigger a premature teardown.
if (signalingState === 'have-local-offer' || signalingState === 'have-local-pranswer') if (signalingState === 'have-local-offer' || signalingState === 'have-local-pranswer') return ageMs < PEER_NEGOTIATION_OFFER_SENT_GRACE_MS;
return ageMs < PEER_NEGOTIATION_OFFER_SENT_GRACE_MS;
// ICE negotiation in progress (offer/answer exchange already complete, candidates being checked). // 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. // TURN relay can take 5-15 s on high-latency networks, so use the same extended grace.
if (connectionState === 'connecting') if (connectionState === 'connecting') return ageMs < PEER_NEGOTIATION_OFFER_SENT_GRACE_MS;
return ageMs < PEER_NEGOTIATION_OFFER_SENT_GRACE_MS;
return ageMs < PEER_NEGOTIATION_GRACE_MS; return ageMs < PEER_NEGOTIATION_GRACE_MS;
} }

View File

@@ -308,18 +308,29 @@ export class RoomSettingsEffects {
updateServerIcon$ = createEffect(() => updateServerIcon$ = createEffect(() =>
this.actions$.pipe( this.actions$.pipe(
ofType(RoomsActions.updateServerIcon), 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(([ mergeMap(([
{ roomId, icon }, { roomId, icon },
currentUser, currentUser,
currentRoom currentRoom,
savedRooms
]) => { ]) => {
if (!currentUser || !currentRoom || currentRoom.id !== roomId) { if (!currentUser) {
return of(RoomsActions.updateServerIconFailure({ error: 'Not in room' })); return of(RoomsActions.updateServerIconFailure({ error: 'Not logged in' }));
} }
const isOwner = currentRoom.hostId === currentUser.id; const room = resolveRoom(roomId, currentRoom, savedRooms);
const canByRole = resolveRoomPermission(currentRoom, currentUser, 'manageIcon');
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) { if (!isOwner && !canByRole) {
return of(RoomsActions.updateServerIconFailure({ error: 'Permission denied' })); return of(RoomsActions.updateServerIconFailure({ error: 'Permission denied' }));
@@ -329,15 +340,32 @@ export class RoomSettingsEffects {
const changes: Partial<Room> = { icon, const changes: Partial<Room> = { icon,
iconUpdatedAt }; iconUpdatedAt };
this.db.updateRoom(roomId, changes); this.db.updateRoom(room.id, changes);
this.webrtc.broadcastMessage({ this.webrtc.broadcastMessage({
type: 'server-icon-update', type: 'server-icon-update',
roomId, roomId: room.id,
icon, icon,
iconUpdatedAt 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, icon,
iconUpdatedAt })); iconUpdatedAt }));
}) })

View File

@@ -1,44 +1,17 @@
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { import { Actions, createEffect, ofType } from '@ngrx/effects';
Actions,
createEffect,
ofType
} from '@ngrx/effects';
import { Store, type Action } from '@ngrx/store'; import { Store, type Action } from '@ngrx/store';
import { import { of, from, EMPTY } from 'rxjs';
of, import { map, mergeMap, withLatestFrom, tap, switchMap, catchError } from 'rxjs/operators';
from,
EMPTY
} from 'rxjs';
import {
map,
mergeMap,
withLatestFrom,
tap,
switchMap,
catchError
} from 'rxjs/operators';
import { RoomsActions } from './rooms.actions'; import { RoomsActions } from './rooms.actions';
import { UsersActions } from '../users/users.actions'; import { UsersActions } from '../users/users.actions';
import { selectCurrentUser, selectAllUsers } from '../users/users.selectors'; import { selectCurrentUser, selectAllUsers } from '../users/users.selectors';
import { import { selectActiveChannelId, selectCurrentRoom, selectSavedRooms } from './rooms.selectors';
selectActiveChannelId,
selectCurrentRoom,
selectSavedRooms
} from './rooms.selectors';
import { RealtimeSessionFacade } from '../../core/realtime'; import { RealtimeSessionFacade } from '../../core/realtime';
import { DatabaseService } from '../../infrastructure/persistence'; import { DatabaseService } from '../../infrastructure/persistence';
import { resolveRoomPermission } from '../../domains/access-control'; import { resolveRoomPermission } from '../../domains/access-control';
import type { import type { ChatEvent, Room, RoomSettings, RoomPermissions, BanEntry, User, VoiceState } from '../../shared-kernel';
ChatEvent,
Room,
RoomSettings,
RoomPermissions,
BanEntry,
User,
VoiceState
} from '../../shared-kernel';
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service'; import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
import { hasRoomBanForUser } from '../../domains/access-control'; import { hasRoomBanForUser } from '../../domains/access-control';
import { RECONNECT_SOUND_GRACE_MS } from '../../core/constants'; import { RECONNECT_SOUND_GRACE_MS } from '../../core/constants';
@@ -55,6 +28,8 @@ import {
} from './rooms.helpers'; } from './rooms.helpers';
import type { RoomPresenceSignalingMessage } 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 * NgRx effects for real-time state synchronisation: signaling presence
* events (server_users, user_joined, user_left, access_denied), P2P * 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. * preventing false join/leave sounds during state refreshes.
*/ */
private knownVoiceUsers = new Set<string>(); private knownVoiceUsers = new Set<string>();
private pendingServerIconRequestsByPeer = new Map<string, Set<string>>();
/** /**
* When a user leaves (e.g. socket drops), record the timestamp so * When a user leaves (e.g. socket drops), record the timestamp so
* that a rapid re-join (reconnect) does not trigger a false * 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). */ /** Handles WebRTC signaling events for user presence (join, leave, server_users). */
signalingMessages$ = createEffect(() => signalingMessages$ = createEffect(() =>
this.webrtc.onSignalingMessage.pipe( this.webrtc.onSignalingMessage.pipe(
withLatestFrom( withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom), this.store.select(selectSavedRooms)),
this.store.select(selectCurrentUser), mergeMap(([message, currentUser, currentRoom, savedRooms]) => {
this.store.select(selectCurrentRoom),
this.store.select(selectSavedRooms)
),
mergeMap(([
message,
currentUser,
currentRoom,
savedRooms
]) => {
const signalingMessage: RoomPresenceSignalingMessage = message; const signalingMessage: RoomPresenceSignalingMessage = message;
const myId = currentUser?.oderId || currentUser?.id; const myId = currentUser?.oderId || currentUser?.id;
const viewedServerId = currentRoom?.id; const viewedServerId = currentRoom?.id;
@@ -106,8 +73,7 @@ export class RoomStateSyncEffects {
switch (signalingMessage.type) { switch (signalingMessage.type) {
case 'server_users': { case 'server_users': {
if (!Array.isArray(signalingMessage.users) || !signalingMessage.serverId) if (!Array.isArray(signalingMessage.users) || !signalingMessage.serverId) return EMPTY;
return EMPTY;
const syncedUsers = signalingMessage.users const syncedUsers = signalingMessage.users
.filter((user) => user.oderId !== myId) .filter((user) => user.oderId !== myId)
@@ -136,11 +102,9 @@ export class RoomStateSyncEffects {
} }
case 'user_joined': { case 'user_joined': {
if (!signalingMessage.serverId || signalingMessage.oderId === myId) if (!signalingMessage.serverId || signalingMessage.oderId === myId) return EMPTY;
return EMPTY;
if (!signalingMessage.oderId) if (!signalingMessage.oderId) return EMPTY;
return EMPTY;
const joinedUser = { const joinedUser = {
oderId: signalingMessage.oderId, oderId: signalingMessage.oderId,
@@ -168,12 +132,9 @@ export class RoomStateSyncEffects {
} }
case 'user_left': { case 'user_left': {
if (!signalingMessage.oderId) if (!signalingMessage.oderId) return EMPTY;
return EMPTY;
const remainingServerIds = Array.isArray(signalingMessage.serverIds) const remainingServerIds = Array.isArray(signalingMessage.serverIds) ? signalingMessage.serverIds : undefined;
? signalingMessage.serverIds
: undefined;
if (!remainingServerIds || remainingServerIds.length === 0) { if (!remainingServerIds || remainingServerIds.length === 0) {
if (this.knownVoiceUsers.has(signalingMessage.oderId)) { if (this.knownVoiceUsers.has(signalingMessage.oderId)) {
@@ -199,24 +160,15 @@ export class RoomStateSyncEffects {
} }
case 'status_update': { case 'status_update': {
if (!signalingMessage.oderId || !signalingMessage.status) if (!signalingMessage.oderId || !signalingMessage.status) return EMPTY;
return EMPTY;
const validStatuses = [ const validStatuses = ['online', 'away', 'busy', 'offline'];
'online',
'away',
'busy',
'offline'
];
if (!validStatuses.includes(signalingMessage.status)) if (!validStatuses.includes(signalingMessage.status)) return EMPTY;
return EMPTY;
// 'offline' from the server means the user chose Invisible; // 'offline' from the server means the user chose Invisible;
// display them as disconnected to other users. // display them as disconnected to other users.
const mappedStatus = signalingMessage.status === 'offline' const mappedStatus = signalingMessage.status === 'offline' ? 'disconnected' : (signalingMessage.status as 'online' | 'away' | 'busy');
? 'disconnected'
: signalingMessage.status as 'online' | 'away' | 'busy';
return [ return [
UsersActions.updateRemoteUserStatus({ UsersActions.updateRemoteUserStatus({
@@ -227,21 +179,75 @@ export class RoomStateSyncEffects {
} }
case 'access_denied': { case 'access_denied': {
if (isWrongServer(signalingMessage.serverId, viewedServerId)) if (isWrongServer(signalingMessage.serverId, viewedServerId)) return EMPTY;
return EMPTY;
if (signalingMessage.reason !== 'SERVER_NOT_FOUND') if (signalingMessage.reason !== 'SERVER_NOT_FOUND') return EMPTY;
return EMPTY;
// When multiple signal URLs are configured, the room may already // When multiple signal URLs are configured, the room may already
// be successfully joined on a different signal server. Only show // be successfully joined on a different signal server. Only show
// the reconnect notice when the room is not reachable at all. // the reconnect notice when the room is not reachable at all.
if (signalingMessage.serverId && this.webrtc.hasJoinedServer(signalingMessage.serverId)) if (signalingMessage.serverId && this.webrtc.hasJoinedServer(signalingMessage.serverId)) return EMPTY;
return EMPTY;
return [RoomsActions.setSignalServerReconnecting({ isReconnecting: true })]; 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: default:
return EMPTY; return EMPTY;
} }
@@ -257,8 +263,7 @@ export class RoomStateSyncEffects {
this.webrtc.onPeerConnected.pipe( this.webrtc.onPeerConnected.pipe(
withLatestFrom(this.store.select(selectCurrentRoom)), withLatestFrom(this.store.select(selectCurrentRoom)),
tap(([peerId, room]) => { tap(([peerId, room]) => {
if (!room) if (!room) return;
return;
this.webrtc.sendToPeer(peerId, { this.webrtc.sendToPeer(peerId, {
type: 'server-state-request', type: 'server-state-request',
@@ -273,12 +278,16 @@ export class RoomStateSyncEffects {
roomEntryServerStateSync$ = createEffect( roomEntryServerStateSync$ = createEffect(
() => () =>
this.actions$.pipe( this.actions$.pipe(
ofType( ofType(RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
RoomsActions.createRoomSuccess,
RoomsActions.joinRoomSuccess,
RoomsActions.viewServerSuccess
),
tap(({ room }) => { 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()) { for (const peerId of this.webrtc.getConnectedPeers()) {
try { try {
this.webrtc.sendToPeer(peerId, { this.webrtc.sendToPeer(peerId, {
@@ -304,14 +313,7 @@ export class RoomStateSyncEffects {
this.store.select(selectCurrentUser), this.store.select(selectCurrentUser),
this.store.select(selectActiveChannelId) this.store.select(selectActiveChannelId)
), ),
mergeMap(([ mergeMap(([event, currentRoom, savedRooms, allUsers, currentUser, activeChannelId]) => {
event,
currentRoom,
savedRooms,
allUsers,
currentUser,
activeChannelId
]) => {
switch (event.type) { switch (event.type) {
case 'voice-state': case 'voice-state':
return this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'voice'); return this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'voice');
@@ -351,8 +353,7 @@ export class RoomStateSyncEffects {
this.webrtc.onPeerConnected.pipe( this.webrtc.onPeerConnected.pipe(
withLatestFrom(this.store.select(selectCurrentRoom)), withLatestFrom(this.store.select(selectCurrentRoom)),
tap(([_peerId, room]) => { tap(([_peerId, room]) => {
if (!room) if (!room) return;
return;
const iconUpdatedAt = room.iconUpdatedAt || 0; const iconUpdatedAt = room.iconUpdatedAt || 0;
@@ -366,18 +367,29 @@ export class RoomStateSyncEffects {
{ dispatch: false } { 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 ─────────────────────────── // ── Voice / Screen / Camera handlers ───────────────────────────
private handleVoiceOrScreenState( private handleVoiceOrScreenState(event: ChatEvent, allUsers: User[], currentUser: User | null, kind: 'voice' | 'screen' | 'camera') {
event: ChatEvent,
allUsers: User[],
currentUser: User | null,
kind: 'voice' | 'screen' | 'camera'
) {
const userId: string | undefined = event.fromPeerId ?? event.oderId; const userId: string | undefined = event.fromPeerId ?? event.oderId;
if (!userId) if (!userId) return EMPTY;
return EMPTY;
const existingUser = allUsers.find((user) => user.id === userId || user.oderId === userId); const existingUser = allUsers.find((user) => user.id === userId || user.oderId === userId);
const userExists = !!existingUser; const userExists = !!existingUser;
@@ -385,18 +397,17 @@ export class RoomStateSyncEffects {
if (kind === 'voice') { if (kind === 'voice') {
const vs = event.voiceState as Partial<VoiceState> | undefined; const vs = event.voiceState as Partial<VoiceState> | undefined;
if (!vs) if (!vs) return EMPTY;
return EMPTY;
const presenceRefreshAction = vs.serverId && !existingUser?.presenceServerIds?.includes(vs.serverId) const presenceRefreshAction =
? UsersActions.userJoined({ vs.serverId && !existingUser?.presenceServerIds?.includes(vs.serverId)
user: buildSignalingUser( ? UsersActions.userJoined({
{ oderId: userId, user: buildSignalingUser(
displayName: event.displayName || existingUser?.displayName || 'User' }, { oderId: userId, displayName: event.displayName || existingUser?.displayName || 'User' },
{ presenceServerIds: [vs.serverId] } { presenceServerIds: [vs.serverId] }
) )
}) })
: null; : null;
// Detect voice-connection transitions to play join/leave sounds. // Detect voice-connection transitions to play join/leave sounds.
const weAreInVoice = this.webrtc.isVoiceConnected(); const weAreInVoice = this.webrtc.isVoiceConnected();
const nowConnected = vs.isConnected ?? false; const nowConnected = vs.isConnected ?? false;
@@ -427,8 +438,7 @@ export class RoomStateSyncEffects {
return of( return of(
UsersActions.userJoined({ UsersActions.userJoined({
user: buildSignalingUser( user: buildSignalingUser(
{ oderId: userId, { oderId: userId, displayName: event.displayName || 'User' },
displayName: event.displayName || 'User' },
{ {
presenceServerIds: vs.serverId ? [vs.serverId] : undefined, presenceServerIds: vs.serverId ? [vs.serverId] : undefined,
voiceState: { voiceState: {
@@ -453,8 +463,7 @@ export class RoomStateSyncEffects {
actions.push(presenceRefreshAction); actions.push(presenceRefreshAction);
} }
actions.push(UsersActions.updateVoiceState({ userId, actions.push(UsersActions.updateVoiceState({ userId, voiceState: vs }));
voiceState: vs }));
return actions; return actions;
} }
@@ -462,17 +471,12 @@ export class RoomStateSyncEffects {
if (kind === 'screen') { if (kind === 'screen') {
const isSharing = event.isScreenSharing as boolean | undefined; const isSharing = event.isScreenSharing as boolean | undefined;
if (isSharing === undefined) if (isSharing === undefined) return EMPTY;
return EMPTY;
if (!userExists) { if (!userExists) {
return of( return of(
UsersActions.userJoined({ UsersActions.userJoined({
user: buildSignalingUser( user: buildSignalingUser({ oderId: userId, displayName: event.displayName || 'User' }, { screenShareState: { isSharing } })
{ oderId: userId,
displayName: event.displayName || 'User' },
{ screenShareState: { isSharing } }
)
}) })
); );
} }
@@ -487,17 +491,12 @@ export class RoomStateSyncEffects {
const isCameraEnabled = event.isCameraEnabled as boolean | undefined; const isCameraEnabled = event.isCameraEnabled as boolean | undefined;
if (isCameraEnabled === undefined) if (isCameraEnabled === undefined) return EMPTY;
return EMPTY;
if (!userExists) { if (!userExists) {
return of( return of(
UsersActions.userJoined({ UsersActions.userJoined({
user: buildSignalingUser( user: buildSignalingUser({ oderId: userId, displayName: event.displayName || 'User' }, { cameraState: { isEnabled: isCameraEnabled } })
{ oderId: userId,
displayName: event.displayName || 'User' },
{ cameraState: { isEnabled: isCameraEnabled } }
)
}) })
); );
} }
@@ -510,12 +509,7 @@ export class RoomStateSyncEffects {
); );
} }
private handleVoiceChannelMove( private handleVoiceChannelMove(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[], currentUser: User | null) {
event: ChatEvent,
currentRoom: Room | null,
savedRooms: Room[],
currentUser: User | null
) {
const targetUserId = typeof event.targetUserId === 'string' ? event.targetUserId : null; const targetUserId = typeof event.targetUserId === 'string' ? event.targetUserId : null;
const serverId = typeof event.roomId === 'string' ? event.roomId : currentUser?.voiceState?.serverId; const serverId = typeof event.roomId === 'string' ? event.roomId : currentUser?.voiceState?.serverId;
const nextVoiceState = event.voiceState as Partial<VoiceState> | undefined; const nextVoiceState = event.voiceState as Partial<VoiceState> | undefined;
@@ -566,22 +560,23 @@ export class RoomStateSyncEffects {
voiceState: updatedVoiceState voiceState: updatedVoiceState
}); });
return of(UsersActions.updateVoiceState({ return of(
userId: currentUser.id, UsersActions.updateVoiceState({
voiceState: updatedVoiceState userId: currentUser.id,
})); voiceState: updatedVoiceState
})
);
} }
private isSameVoiceRoom( private isSameVoiceRoom(voiceState: Partial<VoiceState> | undefined, currentUserVoiceState: Partial<VoiceState> | undefined): boolean {
voiceState: Partial<VoiceState> | undefined, return (
currentUserVoiceState: Partial<VoiceState> | undefined !!voiceState?.isConnected &&
): boolean { !!currentUserVoiceState?.isConnected &&
return !!voiceState?.isConnected !!voiceState.roomId &&
&& !!currentUserVoiceState?.isConnected !!voiceState.serverId &&
&& !!voiceState.roomId voiceState.roomId === currentUserVoiceState.roomId &&
&& !!voiceState.serverId voiceState.serverId === currentUserVoiceState.serverId
&& voiceState.roomId === currentUserVoiceState.roomId );
&& voiceState.serverId === currentUserVoiceState.serverId;
} }
/** /**
@@ -614,8 +609,7 @@ export class RoomStateSyncEffects {
const room = resolveRoom(roomId, currentRoom, savedRooms); const room = resolveRoom(roomId, currentRoom, savedRooms);
const fromPeerId = event.fromPeerId; const fromPeerId = event.fromPeerId;
if (!room || !fromPeerId) if (!room || !fromPeerId) return EMPTY;
return EMPTY;
return from(this.db.getBansForRoom(room.id)).pipe( return from(this.db.getBansForRoom(room.id)).pipe(
tap((bans) => { tap((bans) => {
@@ -630,18 +624,12 @@ export class RoomStateSyncEffects {
); );
} }
private handleServerStateFull( private handleServerStateFull(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[], currentUser: { id: string; oderId: string } | null) {
event: ChatEvent,
currentRoom: Room | null,
savedRooms: Room[],
currentUser: { id: string; oderId: string } | null
) {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = resolveRoom(roomId, currentRoom, savedRooms); const room = resolveRoom(roomId, currentRoom, savedRooms);
const incomingRoom = event.room as Partial<Room> | undefined; const incomingRoom = event.room as Partial<Room> | undefined;
if (!room || !incomingRoom) if (!room || !incomingRoom) return EMPTY;
return EMPTY;
const roomChanges = { const roomChanges = {
...sanitizeRoomSnapshot(incomingRoom), ...sanitizeRoomSnapshot(incomingRoom),
@@ -651,19 +639,17 @@ export class RoomStateSyncEffects {
return this.syncBansToLocalRoom(room.id, bans).pipe( return this.syncBansToLocalRoom(room.id, bans).pipe(
mergeMap(() => { mergeMap(() => {
const actions: (ReturnType<typeof RoomsActions.updateRoom> const actions: (
| ReturnType<typeof RoomsActions.updateRoom>
| ReturnType<typeof UsersActions.loadBansSuccess> | ReturnType<typeof UsersActions.loadBansSuccess>
| ReturnType<typeof RoomsActions.forgetRoom>)[] = [ | ReturnType<typeof RoomsActions.forgetRoom>
)[] = [
RoomsActions.updateRoom({ RoomsActions.updateRoom({
roomId: room.id, roomId: room.id,
changes: roomChanges changes: roomChanges
}) })
]; ];
const isCurrentUserBanned = hasRoomBanForUser( const isCurrentUserBanned = hasRoomBanForUser(bans, currentUser, getPersistedCurrentUserId());
bans,
currentUser,
getPersistedCurrentUserId()
);
if (currentRoom?.id === room.id) { if (currentRoom?.id === room.id) {
actions.push(UsersActions.loadBansSuccess({ bans })); actions.push(UsersActions.loadBansSuccess({ bans }));
@@ -684,8 +670,7 @@ export class RoomStateSyncEffects {
const room = resolveRoom(roomId, currentRoom, savedRooms); const room = resolveRoom(roomId, currentRoom, savedRooms);
const settings = event.settings as Partial<RoomSettings> | undefined; const settings = event.settings as Partial<RoomSettings> | undefined;
if (!room || !settings) if (!room || !settings) return EMPTY;
return EMPTY;
return of( return of(
RoomsActions.updateRoom({ RoomsActions.updateRoom({
@@ -699,7 +684,9 @@ export class RoomStateSyncEffects {
hasPassword: hasPassword:
typeof settings.hasPassword === 'boolean' typeof settings.hasPassword === 'boolean'
? settings.hasPassword ? settings.hasPassword
: (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password), : typeof room.hasPassword === 'boolean'
? room.hasPassword
: !!room.password,
maxUsers: settings.maxUsers ?? room.maxUsers maxUsers: settings.maxUsers ?? room.maxUsers
} }
}) })
@@ -712,17 +699,13 @@ export class RoomStateSyncEffects {
const permissions = event.permissions as Partial<RoomPermissions> | undefined; const permissions = event.permissions as Partial<RoomPermissions> | undefined;
const incomingRoom = event.room as Partial<Room> | undefined; const incomingRoom = event.room as Partial<Room> | undefined;
if (!room || (!permissions && !incomingRoom)) if (!room || (!permissions && !incomingRoom)) return EMPTY;
return EMPTY;
return of( return of(
RoomsActions.updateRoom({ RoomsActions.updateRoom({
roomId: room.id, roomId: room.id,
changes: { changes: {
permissions: permissions permissions: permissions ? ({ ...(room.permissions || {}), ...permissions } as RoomPermissions) : room.permissions,
? { ...(room.permissions || {}),
...permissions } as RoomPermissions
: room.permissions,
roles: Array.isArray(incomingRoom?.roles) ? incomingRoom.roles : room.roles, roles: Array.isArray(incomingRoom?.roles) ? incomingRoom.roles : room.roles,
roleAssignments: Array.isArray(incomingRoom?.roleAssignments) ? incomingRoom.roleAssignments : room.roleAssignments, roleAssignments: Array.isArray(incomingRoom?.roleAssignments) ? incomingRoom.roleAssignments : room.roleAssignments,
channelPermissions: Array.isArray(incomingRoom?.channelPermissions) ? incomingRoom.channelPermissions : room.channelPermissions, channelPermissions: Array.isArray(incomingRoom?.channelPermissions) ? incomingRoom.channelPermissions : room.channelPermissions,
@@ -732,12 +715,7 @@ export class RoomStateSyncEffects {
); );
} }
private handleChannelsUpdate( private handleChannelsUpdate(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[], activeChannelId: string): Action[] {
event: ChatEvent,
currentRoom: Room | null,
savedRooms: Room[],
activeChannelId: string
): Action[] {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = resolveRoom(roomId, currentRoom, savedRooms); const room = resolveRoom(roomId, currentRoom, savedRooms);
const channels = Array.isArray(event.channels) ? event.channels : null; const channels = Array.isArray(event.channels) ? event.channels : null;
@@ -754,8 +732,7 @@ export class RoomStateSyncEffects {
]; ];
if (!channels.some((channel) => channel.id === activeChannelId)) { if (!channels.some((channel) => channel.id === activeChannelId)) {
const fallbackChannelId = channels.find((channel) => channel.type === 'text')?.id const fallbackChannelId = channels.find((channel) => channel.type === 'text')?.id ?? 'general';
?? 'general';
actions.push(RoomsActions.selectChannel({ channelId: fallbackChannelId })); actions.push(RoomsActions.selectChannel({ channelId: fallbackChannelId }));
} }
@@ -769,8 +746,7 @@ export class RoomStateSyncEffects {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = resolveRoom(roomId, currentRoom, savedRooms); const room = resolveRoom(roomId, currentRoom, savedRooms);
if (!room) if (!room) return EMPTY;
return EMPTY;
const remoteUpdated = event.iconUpdatedAt || 0; const remoteUpdated = event.iconUpdatedAt || 0;
const localUpdated = room.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 roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = resolveRoom(roomId, currentRoom, savedRooms); const room = resolveRoom(roomId, currentRoom, savedRooms);
if (!room) if (!room) return EMPTY;
return EMPTY;
if (event.fromPeerId) { if (event.fromPeerId) {
this.webrtc.sendToPeer(event.fromPeerId, { this.webrtc.sendToPeer(event.fromPeerId, {
@@ -809,20 +784,17 @@ export class RoomStateSyncEffects {
const room = resolveRoom(roomId, currentRoom, savedRooms); const room = resolveRoom(roomId, currentRoom, savedRooms);
const senderId = event.fromPeerId; const senderId = event.fromPeerId;
if (!room || typeof event.icon !== 'string' || !senderId) if (!room || typeof event.icon !== 'string' || !senderId) return this.handleSearchResultIconData(event, roomId);
return EMPTY;
return this.store.select(selectAllUsers).pipe( return this.store.select(selectAllUsers).pipe(
map((users) => users.find((user) => user.id === senderId)), map((users) => users.find((user) => user.id === senderId)),
mergeMap((sender) => { mergeMap((sender) => {
if (!sender) if (!sender) return EMPTY;
return EMPTY;
const isOwner = room.hostId === sender.id; const isOwner = room.hostId === sender.id;
const canByRole = resolveRoomPermission(room, sender, 'manageIcon'); const canByRole = resolveRoomPermission(room, sender, 'manageIcon');
if (!isOwner && !canByRole) if (!isOwner && !canByRole) return EMPTY;
return EMPTY;
const updates: Partial<Room> = { const updates: Partial<Room> = {
icon: event.icon, icon: event.icon,
@@ -830,23 +802,63 @@ export class RoomStateSyncEffects {
}; };
this.db.updateRoom(room.id, updates); this.db.updateRoom(room.id, updates);
return of(RoomsActions.updateRoom({ roomId: room.id, this.webrtc.sendRawMessage({
changes: updates })); 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 ─────────────────────────────────────────── // ── Internal helpers ───────────────────────────────────────────
private syncBansToLocalRoom(roomId: string, bans: BanEntry[]) { private syncBansToLocalRoom(roomId: string, bans: BanEntry[]) {
return from(this.db.getBansForRoom(roomId)).pipe( return from(this.db.getBansForRoom(roomId)).pipe(
switchMap((localBans) => { switchMap((localBans) => {
const nextIds = new Set(bans.map((ban) => ban.oderId)); const nextIds = new Set(bans.map((ban) => ban.oderId));
const removals = localBans const removals = localBans.filter((ban) => !nextIds.has(ban.oderId)).map((ban) => this.db.removeBan(ban.oderId));
.filter((ban) => !nextIds.has(ban.oderId)) const saves = bans.map((ban) => this.db.saveBan({ ...ban, roomId }));
.map((ban) => this.db.removeBan(ban.oderId));
const saves = bans.map((ban) => this.db.saveBan({ ...ban,
roomId }));
return from(Promise.all([...removals, ...saves])); return from(Promise.all([...removals, ...saves]));
}) })

View File

@@ -72,6 +72,7 @@ export const RoomsActions = createActionGroup({
'Update Server Icon': props<{ roomId: string; icon: string }>(), 'Update Server Icon': props<{ roomId: string; icon: string }>(),
'Update Server Icon Success': props<{ roomId: string; icon: string; iconUpdatedAt: number }>(), 'Update Server Icon Success': props<{ roomId: string; icon: string; iconUpdatedAt: number }>(),
'Update Server Icon Failure': props<{ error: string }>(), 'Update Server Icon Failure': props<{ error: string }>(),
'Receive Search Server Icon': props<{ roomId: string; icon: string; iconUpdatedAt: number }>(),
'Set Current Room': props<{ room: Room }>(), 'Set Current Room': props<{ room: Room }>(),
'Clear Current Room': emptyProps(), 'Clear Current Room': emptyProps(),

View File

@@ -229,6 +229,8 @@ export class RoomsEffects {
isPrivate: room.isPrivate, isPrivate: room.isPrivate,
userCount: 1, userCount: 1,
maxUsers: room.maxUsers || 50, maxUsers: room.maxUsers || 50,
icon: room.icon,
iconUpdatedAt: room.iconUpdatedAt,
tags: [], tags: [],
channels: room.channels ?? defaultChannels() channels: room.channels ?? defaultChannels()
}, endpoint ? { }, endpoint ? {
@@ -288,6 +290,8 @@ export class RoomsEffects {
const resolvedRoom: Room = { const resolvedRoom: Room = {
...room, ...room,
isPrivate: typeof serverInfo?.isPrivate === 'boolean' ? serverInfo.isPrivate : room.isPrivate, 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), channels: resolveRoomChannels(room.channels, serverInfo?.channels),
slowModeInterval: serverInfo?.slowModeInterval ?? room.slowModeInterval, slowModeInterval: serverInfo?.slowModeInterval ?? room.slowModeInterval,
roles: serverInfo?.roles ?? room.roles, roles: serverInfo?.roles ?? room.roles,
@@ -309,6 +313,8 @@ export class RoomsEffects {
roles: resolvedRoom.roles, roles: resolvedRoom.roles,
roleAssignments: resolvedRoom.roleAssignments, roleAssignments: resolvedRoom.roleAssignments,
channelPermissions: resolvedRoom.channelPermissions, channelPermissions: resolvedRoom.channelPermissions,
icon: resolvedRoom.icon,
iconUpdatedAt: resolvedRoom.iconUpdatedAt,
hasPassword: resolvedRoom.hasPassword, hasPassword: resolvedRoom.hasPassword,
isPrivate: resolvedRoom.isPrivate isPrivate: resolvedRoom.isPrivate
}); });
@@ -337,6 +343,8 @@ export class RoomsEffects {
createdAt: Date.now(), createdAt: Date.now(),
userCount: 1, userCount: 1,
maxUsers: 50, maxUsers: 50,
icon: serverInfo.icon,
iconUpdatedAt: serverInfo.iconUpdatedAt,
channels: resolveRoomChannels(undefined, serverInfo.channels), channels: resolveRoomChannels(undefined, serverInfo.channels),
slowModeInterval: serverInfo.slowModeInterval, slowModeInterval: serverInfo.slowModeInterval,
roles: serverInfo.roles, roles: serverInfo.roles,
@@ -372,6 +380,8 @@ export class RoomsEffects {
createdAt: serverData.createdAt || Date.now(), createdAt: serverData.createdAt || Date.now(),
userCount: serverData.userCount, userCount: serverData.userCount,
maxUsers: serverData.maxUsers, maxUsers: serverData.maxUsers,
icon: serverData.icon,
iconUpdatedAt: serverData.iconUpdatedAt,
channels: resolveRoomChannels(undefined, serverData.channels), channels: resolveRoomChannels(undefined, serverData.channels),
slowModeInterval: serverData.slowModeInterval, slowModeInterval: serverData.slowModeInterval,
roles: serverData.roles, roles: serverData.roles,
@@ -557,6 +567,8 @@ export class RoomsEffects {
hasPassword: !!serverData.hasPassword, hasPassword: !!serverData.hasPassword,
isPrivate: serverData.isPrivate, isPrivate: serverData.isPrivate,
maxUsers: serverData.maxUsers, maxUsers: serverData.maxUsers,
icon: serverData.icon ?? room.icon,
iconUpdatedAt: serverData.iconUpdatedAt ?? room.iconUpdatedAt,
channels: resolveRoomChannels(room.channels, serverData.channels), channels: resolveRoomChannels(room.channels, serverData.channels),
slowModeInterval: serverData.slowModeInterval ?? room.slowModeInterval, slowModeInterval: serverData.slowModeInterval ?? room.slowModeInterval,
roles: serverData.roles ?? room.roles, roles: serverData.roles ?? room.roles,

View File

@@ -1,30 +1,18 @@
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { import { Room, BanEntry, User } from '../../shared-kernel';
Room,
BanEntry,
User
} from '../../shared-kernel';
import { resolveLegacyRole, resolveRoomPermission } from '../../domains/access-control'; import { resolveLegacyRole, resolveRoomPermission } from '../../domains/access-control';
import { findRoomMember } from './room-members.helpers'; import { findRoomMember } from './room-members.helpers';
import { ROOM_URL_PATTERN } from '../../core/constants'; import { ROOM_URL_PATTERN } from '../../core/constants';
/** Build a minimal User object from signaling payload. */ /** Build a minimal User object from signaling payload. */
export function buildSignalingUser( export function buildSignalingUser(data: { oderId: string; displayName?: string; status?: string }, extras: Record<string, unknown> = {}) {
data: { oderId: string; displayName?: string; status?: string },
extras: Record<string, unknown> = {}
) {
const displayName = data.displayName?.trim() || 'User'; const displayName = data.displayName?.trim() || 'User';
const rawStatus = ([ const rawStatus = (['online', 'away', 'busy', 'offline'] as const).includes(data.status as 'online')
'online', ? (data.status as 'online' | 'away' | 'busy' | 'offline')
'away',
'busy',
'offline'
] as const).includes(data.status as 'online')
? data.status as 'online' | 'away' | 'busy' | 'offline'
: 'online'; : 'online';
// 'offline' from the server means the user chose Invisible; // 'offline' from the server means the user chose Invisible;
// display them as disconnected to other users. // display them as disconnected to other users.
const status = rawStatus === 'offline' ? 'disconnected' as const : rawStatus; const status = rawStatus === 'offline' ? ('disconnected' as const) : rawStatus;
return { return {
oderId: data.oderId, oderId: data.oderId,
@@ -43,8 +31,7 @@ export function buildSignalingUser(
export function buildKnownUserExtras(room: Room | null, identifier: string): Record<string, unknown> { export function buildKnownUserExtras(room: Room | null, identifier: string): Record<string, unknown> {
const knownMember = room ? findRoomMember(room.members ?? [], identifier) : undefined; const knownMember = room ? findRoomMember(room.members ?? [], identifier) : undefined;
if (!knownMember) if (!knownMember) return {};
return {};
return { return {
username: knownMember.username, 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. */ /** Returns true when the message's server ID does not match the viewed server. */
export function isWrongServer( export function isWrongServer(msgServerId: string | undefined, viewedServerId: string | undefined): boolean {
msgServerId: string | undefined,
viewedServerId: string | undefined
): boolean {
return !!(msgServerId && viewedServerId && msgServerId !== viewedServerId); return !!(msgServerId && viewedServerId && msgServerId !== viewedServerId);
} }
@@ -110,9 +94,7 @@ export function reconcileRoomSnapshotChannels(
} }
if (hasPersistedChannels(cachedChannels) && hasPersistedChannels(incomingChannels)) { if (hasPersistedChannels(cachedChannels) && hasPersistedChannels(incomingChannels)) {
return incomingChannels.length >= cachedChannels.length return incomingChannels.length >= cachedChannels.length ? incomingChannels : cachedChannels;
? incomingChannels
: cachedChannels;
} }
if (hasPersistedChannels(incomingChannels)) { if (hasPersistedChannels(incomingChannels)) {
@@ -122,10 +104,7 @@ export function reconcileRoomSnapshotChannels(
return undefined; return undefined;
} }
export function resolveTextChannelId( export function resolveTextChannelId(channels: Room['channels'] | undefined, preferredChannelId?: string | null): string | null {
channels: Room['channels'] | undefined,
preferredChannelId?: string | null
): string | null {
const textChannels = (channels ?? []).filter((channel) => channel.type === 'text'); const textChannels = (channels ?? []).filter((channel) => channel.type === 'text');
if (preferredChannelId && textChannels.some((channel) => channel.id === preferredChannelId)) { 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 { export function resolveRoom(roomId: string | undefined, currentRoom: Room | null, savedRooms: Room[]): Room | null {
if (!roomId) if (!roomId) return currentRoom;
return currentRoom;
if (currentRoom?.id === roomId) if (currentRoom?.id === roomId) return currentRoom;
return currentRoom;
return savedRooms.find((room) => room.id === roomId) ?? null; 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, topic: typeof room.topic === 'string' ? room.topic : undefined,
hostId: typeof room.hostId === 'string' ? room.hostId : undefined, hostId: typeof room.hostId === 'string' ? room.hostId : undefined,
hasPassword: hasPassword:
typeof room.hasPassword === 'boolean' typeof room.hasPassword === 'boolean' ? room.hasPassword : typeof room.password === 'string' ? room.password.trim().length > 0 : undefined,
? room.hasPassword
: (typeof room.password === 'string' ? room.password.trim().length > 0 : undefined),
isPrivate: typeof room.isPrivate === 'boolean' ? room.isPrivate : undefined, isPrivate: typeof room.isPrivate === 'boolean' ? room.isPrivate : undefined,
maxUsers: typeof room.maxUsers === 'number' ? room.maxUsers : undefined, maxUsers: typeof room.maxUsers === 'number' ? room.maxUsers : undefined,
icon: typeof room.icon === 'string' ? room.icon : 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[] { export function normalizeIncomingBans(roomId: string, bans: unknown): BanEntry[] {
if (!Array.isArray(bans)) if (!Array.isArray(bans)) return [];
return [];
const now = Date.now(); const now = Date.now();
@@ -225,6 +199,9 @@ export interface RoomPresenceSignalingMessage {
oderId?: string; oderId?: string;
displayName?: string; displayName?: string;
description?: string; description?: string;
fromUserId?: string;
icon?: string;
iconUpdatedAt?: number;
profileUpdatedAt?: number; profileUpdatedAt?: number;
status?: string; status?: string;
} }

View File

@@ -4,11 +4,7 @@ import { normalizeRoomAccessControl } from '../../domains/access-control';
import { type ServerInfo } from '../../domains/server-directory'; import { type ServerInfo } from '../../domains/server-directory';
import { RoomsActions } from './rooms.actions'; import { RoomsActions } from './rooms.actions';
import { defaultChannels } from './room-channels.defaults'; import { defaultChannels } from './room-channels.defaults';
import { import { isChannelNameTaken, normalizeChannelName, normalizeRoomChannels } from './room-channels.rules';
isChannelNameTaken,
normalizeChannelName,
normalizeRoomChannels
} from './room-channels.rules';
import { pruneRoomMembers } from './room-members.helpers'; import { pruneRoomMembers } from './room-members.helpers';
/** Deduplicate rooms by id, keeping the last occurrence */ /** 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 { function resolveActiveTextChannelId(channels: Room['channels'], currentActiveChannelId: string): string {
const textChannels = (channels ?? []).filter((channel) => channel.type === 'text'); const textChannels = (channels ?? []).filter((channel) => channel.type === 'text');
return textChannels.some((channel) => channel.id === currentActiveChannelId) return textChannels.some((channel) => channel.id === currentActiveChannelId) ? currentActiveChannelId : (textChannels[0]?.id ?? 'general');
? currentActiveChannelId
: (textChannels[0]?.id ?? 'general');
} }
function getDefaultTextChannelId(room: Room): string { 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) */ /** Upsert a room into a saved-rooms list (add or replace by id) */
function upsertRoom(savedRooms: Room[], room: Room): Room[] { function upsertRoom(savedRooms: Room[], room: Room): Room[] {
const normalizedRoom = enrichRoom(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) { if (idx >= 0) {
const updated = [...savedRooms]; const updated = [...savedRooms];
@@ -250,8 +244,7 @@ export const roomsReducer = createReducer(
})), })),
on(RoomsActions.updateRoomSettingsSuccess, (state, { roomId, settings }) => { on(RoomsActions.updateRoomSettingsSuccess, (state, { roomId, settings }) => {
const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId) const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId) || (state.currentRoom?.id === roomId ? state.currentRoom : null);
|| (state.currentRoom?.id === roomId ? state.currentRoom : null);
if (!baseRoom) { if (!baseRoom) {
return { return {
@@ -270,9 +263,9 @@ export const roomsReducer = createReducer(
hasPassword: hasPassword:
typeof settings.hasPassword === 'boolean' typeof settings.hasPassword === 'boolean'
? settings.hasPassword ? settings.hasPassword
: (typeof settings.password === 'string' : typeof settings.password === 'string'
? settings.password.trim().length > 0 ? settings.password.trim().length > 0
: baseRoom.hasPassword), : baseRoom.hasPassword,
maxUsers: settings.maxUsers maxUsers: settings.maxUsers
}); });
@@ -330,33 +323,28 @@ export const roomsReducer = createReducer(
// Update room // Update room
on(RoomsActions.updateRoom, (state, { roomId, changes }) => { on(RoomsActions.updateRoom, (state, { roomId, changes }) => {
const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId) const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId) || (state.currentRoom?.id === roomId ? state.currentRoom : null);
|| (state.currentRoom?.id === roomId ? state.currentRoom : null);
if (!baseRoom) if (!baseRoom) return state;
return state;
const updatedRoom = enrichRoom({ ...baseRoom, const updatedRoom = enrichRoom({ ...baseRoom, ...changes });
...changes });
return { return {
...state, ...state,
currentRoom: state.currentRoom?.id === roomId ? updatedRoom : state.currentRoom, currentRoom: state.currentRoom?.id === roomId ? updatedRoom : state.currentRoom,
savedRooms: upsertRoom(state.savedRooms, updatedRoom), savedRooms: upsertRoom(state.savedRooms, updatedRoom),
activeChannelId: state.currentRoom?.id === roomId activeChannelId:
? resolveActiveTextChannelId(updatedRoom.channels, state.activeChannelId) state.currentRoom?.id === roomId ? resolveActiveTextChannelId(updatedRoom.channels, state.activeChannelId) : state.activeChannelId
: state.activeChannelId
}; };
}), }),
// Update server icon success // Update server icon success
on(RoomsActions.updateServerIconSuccess, (state, { roomId, icon, iconUpdatedAt }) => { on(RoomsActions.updateServerIconSuccess, (state, { roomId, icon, iconUpdatedAt }) => {
if (state.currentRoom?.id !== roomId) const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId) || (state.currentRoom?.id === roomId ? state.currentRoom : null);
return state;
const updatedRoom = enrichRoom({ ...state.currentRoom, if (!baseRoom) return state;
icon,
iconUpdatedAt }); const updatedRoom = enrichRoom({ ...baseRoom, icon, iconUpdatedAt });
return { return {
...state, ...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 // Receive room update
on(RoomsActions.receiveRoomUpdate, (state, { room }) => { on(RoomsActions.receiveRoomUpdate, (state, { room }) => {
if (!state.currentRoom) if (!state.currentRoom) return state;
return state;
const updatedRoom = enrichRoom({ ...state.currentRoom, const updatedRoom = enrichRoom({ ...state.currentRoom, ...room });
...room });
return { return {
...state, ...state,
@@ -410,27 +403,17 @@ export const roomsReducer = createReducer(
})), })),
on(RoomsActions.addChannel, (state, { channel }) => { on(RoomsActions.addChannel, (state, { channel }) => {
if (!state.currentRoom) if (!state.currentRoom) return state;
return state;
const existing = state.currentRoom.channels || defaultChannels(); const existing = state.currentRoom.channels || defaultChannels();
const normalizedName = normalizeChannelName(channel.name); const normalizedName = normalizeChannelName(channel.name);
if ( if (!normalizedName || existing.some((entry) => entry.id === channel.id) || isChannelNameTaken(existing, normalizedName, channel.type)) {
!normalizedName
|| existing.some((entry) => entry.id === channel.id)
|| isChannelNameTaken(existing, normalizedName, channel.type)
) {
return state; return state;
} }
const updatedChannels = [ const updatedChannels = [...existing, { ...channel, name: normalizedName }];
...existing, const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
{ ...channel,
name: normalizedName }
];
const updatedRoom = { ...state.currentRoom,
channels: updatedChannels };
return { return {
...state, ...state,
@@ -441,13 +424,11 @@ export const roomsReducer = createReducer(
}), }),
on(RoomsActions.removeChannel, (state, { channelId }) => { on(RoomsActions.removeChannel, (state, { channelId }) => {
if (!state.currentRoom) if (!state.currentRoom) return state;
return state;
const existing = state.currentRoom.channels || defaultChannels(); const existing = state.currentRoom.channels || defaultChannels();
const updatedChannels = existing.filter(channel => channel.id !== channelId); const updatedChannels = existing.filter((channel) => channel.id !== channelId);
const updatedRoom = { ...state.currentRoom, const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
channels: updatedChannels };
return { return {
...state, ...state,
@@ -458,8 +439,7 @@ export const roomsReducer = createReducer(
}), }),
on(RoomsActions.renameChannel, (state, { channelId, name }) => { on(RoomsActions.renameChannel, (state, { channelId, name }) => {
if (!state.currentRoom) if (!state.currentRoom) return state;
return state;
const existing = state.currentRoom.channels || defaultChannels(); const existing = state.currentRoom.channels || defaultChannels();
const normalizedName = normalizeChannelName(name); const normalizedName = normalizeChannelName(name);
@@ -469,10 +449,8 @@ export const roomsReducer = createReducer(
return state; return state;
} }
const updatedChannels = existing.map(channel => channel.id === channelId ? { ...channel, const updatedChannels = existing.map((channel) => (channel.id === channelId ? { ...channel, name: normalizedName } : channel));
name: normalizedName } : channel); const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
const updatedRoom = { ...state.currentRoom,
channels: updatedChannels };
return { return {
...state, ...state,