Plugins #14

Merged
myxelium merged 9 commits from Plugins into main 2026-04-29 23:18:22 +00:00
27 changed files with 1340 additions and 615 deletions
Showing only changes of commit e1ac1d1bc0 - Show all commits

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,
maxUsers: server.maxUsers,
currentUsers: server.currentUsers,
icon: server.icon ?? null,
iconUpdatedAt: server.iconUpdatedAt ?? 0,
slowModeInterval: server.slowModeInterval ?? 0,
createdAt: server.createdAt,
lastSeen: server.lastSeen

View File

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

View File

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

View File

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

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 { PluginSupport1000000000007 } from './1000000000007-PluginSupport';
import { ServerPluginInstallMetadata1000000000008 } from './1000000000008-ServerPluginInstallMetadata';
import { ServerIcons1000000000009 } from './1000000000009-ServerIcons';
export const serverMigrations = [
InitialSchema1000000000000,
@@ -17,5 +18,6 @@ export const serverMigrations = [
ServerRoleAccessControl1000000000005,
GameMatchMisses1000000000006,
PluginSupport1000000000007,
ServerPluginInstallMetadata1000000000008
ServerPluginInstallMetadata1000000000008,
ServerIcons1000000000009
];

View File

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

View File

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

View File

@@ -17,6 +17,8 @@ export interface ConnectedUser {
connectionScope?: string;
/** User availability status (online, away, busy, offline). */
status?: 'online' | 'away' | 'busy' | 'offline';
/** Latest server icon timestamp this connection can provide over P2P. */
serverIconUpdatedAtByServerId?: Map<string, number>;
/** Timestamp of the last pong or client message received (used to detect dead connections). */
lastPong: number;
}

View File

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

View File

@@ -101,8 +101,16 @@
(dblclick)="openServerCard(server)"
>
<div class="flex min-w-0 items-start gap-3">
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-secondary text-sm font-semibold text-foreground">
{{ server.name[0] || '?' }}
<div class="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-lg bg-secondary text-sm font-semibold text-foreground">
@if (server.icon) {
<img
[src]="server.icon"
[alt]="server.name + ' icon'"
class="h-full w-full object-cover"
/>
} @else {
{{ server.name[0] || '?' }}
}
</div>
<div class="min-w-0 flex-1">

View File

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

View File

@@ -6,8 +6,16 @@
>
@if (panelMode() === 'channels') {
<div class="flex items-center gap-3">
<div class="grid h-9 w-9 place-items-center rounded-md bg-secondary text-sm font-semibold text-foreground">
{{ currentRoom()?.name?.charAt(0)?.toUpperCase() || '#' }}
<div class="grid h-9 w-9 place-items-center overflow-hidden rounded-md bg-secondary text-sm font-semibold text-foreground">
@if (currentRoom()?.icon) {
<img
[src]="currentRoom()!.icon"
[alt]="currentRoom()!.name + ' icon'"
class="h-full w-full object-cover"
/>
} @else {
{{ currentRoom()?.name?.charAt(0)?.toUpperCase() || '#' }}
}
</div>
<div class="min-w-0 flex-1">

View File

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

View File

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

View File

@@ -3,11 +3,74 @@
<section>
<h4 class="text-sm font-semibold text-foreground mb-3">Room Settings</h4>
@if (!isAdmin()) {
<p class="text-xs text-muted-foreground mb-3">
You are viewing this server's settings as a non-admin. Only the server owner can make changes.
</p>
<p class="text-xs text-muted-foreground mb-3">You are viewing this server's details without server-management permission.</p>
}
<div class="space-y-4">
<div class="rounded-lg border border-border bg-secondary/40 p-4">
<div class="flex items-center gap-3">
<div class="grid h-14 w-14 shrink-0 place-items-center overflow-hidden rounded-lg bg-secondary text-base font-semibold text-foreground">
@if (serverData()?.icon) {
<img
[src]="serverData()!.icon"
[alt]="serverData()!.name + ' icon'"
class="h-full w-full object-cover"
/>
} @else {
<ng-icon
name="lucideImage"
class="h-6 w-6 text-muted-foreground"
/>
}
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-foreground">Server Image</p>
<p class="text-xs text-muted-foreground">Synced to members and shown in server discovery.</p>
@if (iconError()) {
<p class="mt-1 text-xs text-destructive">{{ iconError() }}</p>
}
</div>
@if (canManageIcon()) {
<div class="flex shrink-0 items-center gap-2">
<label
for="server-icon-upload"
class="grid h-9 w-9 cursor-pointer place-items-center rounded-lg border border-border bg-card text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Upload image"
aria-label="Upload server image"
>
<ng-icon
name="lucideUpload"
class="h-4 w-4"
/>
</label>
<input
id="server-icon-upload"
type="file"
accept="image/*"
class="sr-only"
(change)="onServerIconSelected($event)"
/>
@if (serverData()?.icon) {
<button
type="button"
class="grid h-9 w-9 place-items-center rounded-lg border border-border bg-card text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Remove image"
aria-label="Remove server image"
(click)="removeServerIcon()"
>
<ng-icon
name="lucideX"
class="h-4 w-4"
/>
</button>
}
</div>
}
</div>
</div>
<div>
<label
for="room-name"
@@ -191,21 +254,23 @@
{{ saveSuccess() === 'server' ? 'Saved!' : 'Save Settings' }}
</button>
<!-- Danger Zone -->
<div class="pt-4 border-t border-border">
<h4 class="text-sm font-medium text-destructive mb-3">Danger Zone</h4>
<button
(click)="confirmDeleteRoom()"
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"
class="w-4 h-4"
/>
Delete Room
</button>
</div>
@if (canDeleteServer()) {
<!-- Danger Zone -->
<div class="pt-4 border-t border-border">
<h4 class="text-sm font-medium text-destructive mb-3">Danger Zone</h4>
<button
(click)="confirmDeleteRoom()"
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"
class="w-4 h-4"
/>
Delete Room
</button>
</div>
}
}
</div>

View File

@@ -12,9 +12,12 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
import { Store } from '@ngrx/store';
import {
lucideCheck,
lucideImage,
lucideTrash2,
lucideLock,
lucideUnlock
lucideUnlock,
lucideUpload,
lucideX
} from '@ng-icons/lucide';
import { Room } from '../../../../shared-kernel';
@@ -34,9 +37,12 @@ import { SettingsModalService } from '../../../../core/services/settings-modal.s
viewProviders: [
provideIcons({
lucideCheck,
lucideImage,
lucideTrash2,
lucideLock,
lucideUnlock
lucideUnlock,
lucideUpload,
lucideX
})
],
templateUrl: './server-settings.component.html'
@@ -49,6 +55,10 @@ export class ServerSettingsComponent {
server = input<Room | null>(null);
/** Whether the current user is admin of this server. */
isAdmin = input(false);
/** Whether the current user can manage this server's icon. */
canManageIcon = input(false);
/** Whether the current user can delete this server. */
canDeleteServer = input(false);
roomName = '';
roomDescription = '';
@@ -59,6 +69,7 @@ export class ServerSettingsComponent {
roomPassword = '';
maxUsers = 0;
showDeleteConfirm = signal(false);
iconError = signal<string | null>(null);
saveSuccess = signal<string | null>(null);
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -170,6 +181,64 @@ export class ServerSettingsComponent {
this.modal.navigate('network');
}
onServerIconSelected(event: Event): void {
const inputElement = event.target as HTMLInputElement;
const file = inputElement.files?.[0];
inputElement.value = '';
if (!file || !this.canManageIcon()) {
return;
}
if (!file.type.startsWith('image/')) {
this.iconError.set('Choose an image file.');
return;
}
if (file.size > 512 * 1024) {
this.iconError.set('Choose an image smaller than 512 KB.');
return;
}
const reader = new FileReader();
reader.onload = () => {
const room = this.server();
const icon = typeof reader.result === 'string' ? reader.result : '';
if (!room || !icon) {
this.iconError.set('Could not read that image.');
return;
}
this.iconError.set(null);
this.store.dispatch(RoomsActions.updateServerIcon({
roomId: room.id,
icon
}));
this.showSaveSuccess('icon');
};
reader.onerror = () => this.iconError.set('Could not read that image.');
reader.readAsDataURL(file);
}
removeServerIcon(): void {
const room = this.server();
if (!room || !this.canManageIcon()) {
return;
}
this.iconError.set(null);
this.store.dispatch(RoomsActions.updateServerIcon({
roomId: room.id,
icon: ''
}));
this.showSaveSuccess('icon');
}
private showSaveSuccess(key: string): void {
this.saveSuccess.set(key);

View File

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

View File

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

View File

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

View File

@@ -308,18 +308,29 @@ export class RoomSettingsEffects {
updateServerIcon$ = createEffect(() =>
this.actions$.pipe(
ofType(RoomsActions.updateServerIcon),
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
withLatestFrom(
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom),
this.store.select(selectSavedRooms)
),
mergeMap(([
{ roomId, icon },
currentUser,
currentRoom
currentRoom,
savedRooms
]) => {
if (!currentUser || !currentRoom || currentRoom.id !== roomId) {
return of(RoomsActions.updateServerIconFailure({ error: 'Not in room' }));
if (!currentUser) {
return of(RoomsActions.updateServerIconFailure({ error: 'Not logged in' }));
}
const isOwner = currentRoom.hostId === currentUser.id;
const canByRole = resolveRoomPermission(currentRoom, currentUser, 'manageIcon');
const room = resolveRoom(roomId, currentRoom, savedRooms);
if (!room) {
return of(RoomsActions.updateServerIconFailure({ error: 'Room not found' }));
}
const isOwner = room.hostId === currentUser.id || room.hostId === currentUser.oderId;
const canByRole = resolveRoomPermission(room, currentUser, 'manageIcon');
if (!isOwner && !canByRole) {
return of(RoomsActions.updateServerIconFailure({ error: 'Permission denied' }));
@@ -329,15 +340,32 @@ export class RoomSettingsEffects {
const changes: Partial<Room> = { icon,
iconUpdatedAt };
this.db.updateRoom(roomId, changes);
this.db.updateRoom(room.id, changes);
this.webrtc.broadcastMessage({
type: 'server-icon-update',
roomId,
roomId: room.id,
icon,
iconUpdatedAt
});
this.webrtc.sendRawMessage({
type: 'server_icon_available',
serverId: room.id,
iconUpdatedAt
});
return of(RoomsActions.updateServerIconSuccess({ roomId,
this.serverDirectory.updateServer(room.id, {
currentOwnerId: currentUser.id,
actingRole: isOwner ? 'host' : undefined,
icon,
iconUpdatedAt
}, {
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
}).subscribe({
error: () => {}
});
return of(RoomsActions.updateServerIconSuccess({ roomId: room.id,
icon,
iconUpdatedAt }));
})

View File

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

View File

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

View File

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

View File

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

View File

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