From e1ac1d1bc09f5fcc695d707a59cd07b018ed7fd7 Mon Sep 17 00:00:00 2001 From: Myx Date: Wed, 29 Apr 2026 18:54:08 +0200 Subject: [PATCH] feat: server image --- e2e/tests/chat/server-icon-sync.spec.ts | 465 ++++++++++++++++++ .../cqrs/commands/handlers/upsertServer.ts | 2 + server/src/cqrs/mappers.ts | 2 + server/src/cqrs/types.ts | 2 + server/src/entities/ServerEntity.ts | 6 + .../migrations/1000000000009-ServerIcons.ts | 31 ++ server/src/migrations/index.ts | 4 +- server/src/routes/servers.ts | 6 +- server/src/websocket/handler.ts | 318 +++++++----- server/src/websocket/types.ts | 2 + .../domain/models/server-directory.model.ts | 2 + .../server-search.component.html | 12 +- .../server-search/server-search.component.ts | 181 ++++--- .../rooms-side-panel.component.html | 12 +- .../servers-rail/servers-rail.component.html | 4 +- .../servers-rail/servers-rail.component.ts | 43 +- .../server-settings.component.html | 101 +++- .../server-settings.component.ts | 73 ++- .../settings-modal.component.html | 4 +- .../settings-modal.component.ts | 16 + .../signaling/signaling-message-handler.ts | 57 +-- .../app/store/rooms/room-settings.effects.ts | 46 +- .../store/rooms/room-state-sync.effects.ts | 408 +++++++-------- toju-app/src/app/store/rooms/rooms.actions.ts | 1 + toju-app/src/app/store/rooms/rooms.effects.ts | 12 + toju-app/src/app/store/rooms/rooms.helpers.ts | 55 +-- toju-app/src/app/store/rooms/rooms.reducer.ts | 90 ++-- 27 files changed, 1340 insertions(+), 615 deletions(-) create mode 100644 e2e/tests/chat/server-icon-sync.spec.ts create mode 100644 server/src/migrations/1000000000009-ServerIcons.ts diff --git a/e2e/tests/chat/server-icon-sync.spec.ts b/e2e/tests/chat/server-icon-sync.spec.ts new file mode 100644 index 0000000..d01376a --- /dev/null +++ b/e2e/tests/chat/server-icon-sync.spec.ts @@ -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 { + 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 { + await closePersistentClient(client); + + const session = await launchPersistentSession(client.userDataDir, testServerPort); + + client.context = session.context; + client.page = session.page; +} + +async function closePersistentClient(client: PersistentClient): Promise { + 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 { + 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 { + await new ServerSearchPage(page).joinServerFromSearch(serverName); + await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 }); +} + +async function openRoomAfterRestart(client: PersistentClient, roomUrl: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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) => { + if (server['name'] !== serverName) { + return server; + } + + const { icon: _icon, ...serverWithoutIcon } = server; + + return serverWithoutIcon; + }) + } + }); + }); +} + +async function waitForRoomReady(page: Page): Promise { + 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 { + 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(navigate: () => Promise, attempts = 4): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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)}`; +} diff --git a/server/src/cqrs/commands/handlers/upsertServer.ts b/server/src/cqrs/commands/handlers/upsertServer.ts index f95c8aa..9a6a697 100644 --- a/server/src/cqrs/commands/handlers/upsertServer.ts +++ b/server/src/cqrs/commands/handlers/upsertServer.ts @@ -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 diff --git a/server/src/cqrs/mappers.ts b/server/src/cqrs/mappers.ts index 3b4a852..48e6302 100644 --- a/server/src/cqrs/mappers.ts +++ b/server/src/cqrs/mappers.ts @@ -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, diff --git a/server/src/cqrs/types.ts b/server/src/cqrs/types.ts index c1ee5ac..e4f82ef 100644 --- a/server/src/cqrs/types.ts +++ b/server/src/cqrs/types.ts @@ -86,6 +86,8 @@ export interface ServerPayload { isPrivate: boolean; maxUsers: number; currentUsers: number; + icon?: string; + iconUpdatedAt?: number; slowModeInterval?: number; tags: string[]; channels: ServerChannelPayload[]; diff --git a/server/src/entities/ServerEntity.ts b/server/src/entities/ServerEntity.ts index 23665c4..af7b7ed 100644 --- a/server/src/entities/ServerEntity.ts +++ b/server/src/entities/ServerEntity.ts @@ -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; diff --git a/server/src/migrations/1000000000009-ServerIcons.ts b/server/src/migrations/1000000000009-ServerIcons.ts new file mode 100644 index 0000000..79bbb19 --- /dev/null +++ b/server/src/migrations/1000000000009-ServerIcons.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ServerIcons1000000000009 implements MigrationInterface { + name = 'ServerIcons1000000000009'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } +} \ No newline at end of file diff --git a/server/src/migrations/index.ts b/server/src/migrations/index.ts index c3a58c5..1545a3d 100644 --- a/server/src/migrations/index.ts +++ b/server/src/migrations/index.ts @@ -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 ]; diff --git a/server/src/routes/servers.ts b/server/src/routes/servers.ts index b3318a7..f44edf6 100644 --- a/server/src/routes/servers.ts +++ b/server/src/routes/servers.ts @@ -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(), diff --git a/server/src/websocket/handler.ts b/server/src/websocket/handler.ts index 51ab38b..c6d1f30 100644 --- a/server/src/websocket/handler.ts +++ b/server/src/websocket/handler.ts @@ -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 { 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 { 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(); + + 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 { 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 { 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; diff --git a/server/src/websocket/types.ts b/server/src/websocket/types.ts index 3e40c66..d6494ef 100644 --- a/server/src/websocket/types.ts +++ b/server/src/websocket/types.ts @@ -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; /** Timestamp of the last pong or client message received (used to detect dead connections). */ lastPong: number; } diff --git a/toju-app/src/app/domains/server-directory/domain/models/server-directory.model.ts b/toju-app/src/app/domains/server-directory/domain/models/server-directory.model.ts index 8082268..61ccd04 100644 --- a/toju-app/src/app/domains/server-directory/domain/models/server-directory.model.ts +++ b/toju-app/src/app/domains/server-directory/domain/models/server-directory.model.ts @@ -18,6 +18,8 @@ export interface ServerInfo { ownerPublicKey?: string; userCount: number; maxUsers: number; + icon?: string; + iconUpdatedAt?: number; hasPassword?: boolean; isPrivate: boolean; tags?: string[]; diff --git a/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.html b/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.html index 8b29190..43f32f2 100644 --- a/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.html +++ b/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.html @@ -101,8 +101,16 @@ (dblclick)="openServerCard(server)" >
-
- {{ server.name[0] || '?' }} +
+ @if (server.icon) { + + } @else { + {{ server.name[0] || '?' }} + }
diff --git a/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.ts b/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.ts index 2128cf8..3308c23 100644 --- a/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.ts +++ b/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.ts @@ -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(); 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 { 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 { + 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 { 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); diff --git a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html index 4d8f728..5dab837 100644 --- a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html +++ b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html @@ -6,8 +6,16 @@ > @if (panelMode() === 'channels') {
-
- {{ currentRoom()?.name?.charAt(0)?.toUpperCase() || '#' }} +
+ @if (currentRoom()?.icon) { + + } @else { + {{ currentRoom()?.name?.charAt(0)?.toUpperCase() || '#' }} + }
diff --git a/toju-app/src/app/features/servers/servers-rail/servers-rail.component.html b/toju-app/src/app/features/servers/servers-rail/servers-rail.component.html index 431d9ba..6ffafc9 100644 --- a/toju-app/src/app/features/servers/servers-rail/servers-rail.component.html +++ b/toju-app/src/app/features/servers/servers-rail/servers-rail.component.html @@ -43,8 +43,8 @@
@if (room.icon) { } @else { diff --git a/toju-app/src/app/features/servers/servers-rail/servers-rail.component.ts b/toju-app/src/app/features/servers/servers-rail/servers-rail.component.ts index 045e857..c586f53 100644 --- a/toju-app/src/app/features/servers/servers-rail/servers-rail.component.ts +++ b/toju-app/src/app/features/servers/servers-rail/servers-rail.component.ts @@ -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); diff --git a/toju-app/src/app/features/settings/settings-modal/server-settings/server-settings.component.html b/toju-app/src/app/features/settings/settings-modal/server-settings/server-settings.component.html index 076f7fa..14c24dc 100644 --- a/toju-app/src/app/features/settings/settings-modal/server-settings/server-settings.component.html +++ b/toju-app/src/app/features/settings/settings-modal/server-settings/server-settings.component.html @@ -3,11 +3,74 @@

Room Settings

@if (!isAdmin()) { -

- You are viewing this server's settings as a non-admin. Only the server owner can make changes. -

+

You are viewing this server's details without server-management permission.

}
+
+
+
+ @if (serverData()?.icon) { + + } @else { + + } +
+ +
+

Server Image

+

Synced to members and shown in server discovery.

+ @if (iconError()) { +

{{ iconError() }}

+ } +
+ + @if (canManageIcon()) { +
+ + + + @if (serverData()?.icon) { + + } +
+ } +
+
+
diff --git a/toju-app/src/app/features/settings/settings-modal/server-settings/server-settings.component.ts b/toju-app/src/app/features/settings/settings-modal/server-settings/server-settings.component.ts index d986fad..6924e9c 100644 --- a/toju-app/src/app/features/settings/settings-modal/server-settings/server-settings.component.ts +++ b/toju-app/src/app/features/settings/settings-modal/server-settings/server-settings.component.ts @@ -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(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(null); saveSuccess = signal(null); private saveTimeout: ReturnType | 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); diff --git a/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html b/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html index 23cd6b2..8405810 100644 --- a/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html +++ b/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html @@ -316,7 +316,9 @@ @case ('server') { } @case ('serverPlugins') { diff --git a/toju-app/src/app/features/settings/settings-modal/settings-modal.component.ts b/toju-app/src/app/features/settings/settings-modal/settings-modal.component.ts index 6762dca..8460777 100644 --- a/toju-app/src/app/features/settings/settings-modal/settings-modal.component.ts +++ b/toju-app/src/app/features/settings/settings-modal/settings-modal.component.ts @@ -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; diff --git a/toju-app/src/app/infrastructure/realtime/signaling/signaling-message-handler.ts b/toju-app/src/app/infrastructure/realtime/signaling/signaling-message-handler.ts index 72963b9..5ac9808 100644 --- a/toju-app/src/app/infrastructure/realtime/signaling/signaling-message-handler.ts +++ b/toju-app/src/app/infrastructure/realtime/signaling/signaling-message-handler.ts @@ -34,6 +34,9 @@ export type IncomingSignalingMessage = Omit, '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(); - 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; } diff --git a/toju-app/src/app/store/rooms/room-settings.effects.ts b/toju-app/src/app/store/rooms/room-settings.effects.ts index ba46661..3043075 100644 --- a/toju-app/src/app/store/rooms/room-settings.effects.ts +++ b/toju-app/src/app/store/rooms/room-settings.effects.ts @@ -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 = { 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 })); }) diff --git a/toju-app/src/app/store/rooms/room-state-sync.effects.ts b/toju-app/src/app/store/rooms/room-state-sync.effects.ts index fd399b3..6bb2656 100644 --- a/toju-app/src/app/store/rooms/room-state-sync.effects.ts +++ b/toju-app/src/app/store/rooms/room-state-sync.effects.ts @@ -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(); + private pendingServerIconRequestsByPeer = new Map>(); /** * 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 | 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 | 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 | undefined, - currentUserVoiceState: Partial | undefined - ): boolean { - return !!voiceState?.isConnected - && !!currentUserVoiceState?.isConnected - && !!voiceState.roomId - && !!voiceState.serverId - && voiceState.roomId === currentUserVoiceState.roomId - && voiceState.serverId === currentUserVoiceState.serverId; + private isSameVoiceRoom(voiceState: Partial | undefined, currentUserVoiceState: Partial | 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 | 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 + const actions: ( + | ReturnType | ReturnType - | ReturnType)[] = [ + | ReturnType + )[] = [ 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 | 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 | undefined; const incomingRoom = event.room as Partial | 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 = { 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(); + + 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])); }) diff --git a/toju-app/src/app/store/rooms/rooms.actions.ts b/toju-app/src/app/store/rooms/rooms.actions.ts index 0e7a7b6..6f1d293 100644 --- a/toju-app/src/app/store/rooms/rooms.actions.ts +++ b/toju-app/src/app/store/rooms/rooms.actions.ts @@ -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(), diff --git a/toju-app/src/app/store/rooms/rooms.effects.ts b/toju-app/src/app/store/rooms/rooms.effects.ts index ad17235..e8f5d6f 100644 --- a/toju-app/src/app/store/rooms/rooms.effects.ts +++ b/toju-app/src/app/store/rooms/rooms.effects.ts @@ -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, diff --git a/toju-app/src/app/store/rooms/rooms.helpers.ts b/toju-app/src/app/store/rooms/rooms.helpers.ts index edf20fa..b0fdaa7 100644 --- a/toju-app/src/app/store/rooms/rooms.helpers.ts +++ b/toju-app/src/app/store/rooms/rooms.helpers.ts @@ -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 = {} -) { +export function buildSignalingUser(data: { oderId: string; displayName?: string; status?: string }, extras: Record = {}) { 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 { 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): Partial { 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): Partial { } 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; } diff --git a/toju-app/src/app/store/rooms/rooms.reducer.ts b/toju-app/src/app/store/rooms/rooms.reducer.ts index 542cb5e..02bf2b6 100644 --- a/toju-app/src/app/store/rooms/rooms.reducer.ts +++ b/toju-app/src/app/store/rooms/rooms.reducer.ts @@ -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,