diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 0ae4a2c..3748b1d 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ expect: { timeout: 10_000 }, retries: process.env.CI ? 2 : 0, workers: 1, - reporter: [['html', { outputFolder: '../test-results/html-report' }], ['list']], + reporter: [['html', { outputFolder: '../test-results/html-report', open: 'never' }], ['list']], outputDir: '../test-results/artifacts', use: { baseURL: 'http://localhost:4200', diff --git a/e2e/tests/chat/profile-avatar-sync.spec.ts b/e2e/tests/chat/profile-avatar-sync.spec.ts index 37e6c92..ca32335 100644 --- a/e2e/tests/chat/profile-avatar-sync.spec.ts +++ b/e2e/tests/chat/profile-avatar-sync.spec.ts @@ -1,7 +1,4 @@ -import { - mkdtemp, - rm -} from 'node:fs/promises'; +import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { @@ -9,11 +6,9 @@ import { type BrowserContext, type Page } from '@playwright/test'; -import { - test, - expect -} from '../../fixtures/multi-client'; +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'; @@ -40,17 +35,39 @@ interface PersistentClient { userDataDir: string; } +interface ProfileMetadata { + description?: string; + displayName: string; +} + const STATIC_GIF_BASE64 = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=='; -const GIF_FRAME_MARKER = Buffer.from([0x21, 0xF9, 0x04]); -const NETSCAPE_LOOP_EXTENSION = Buffer.from([ - 0x21, 0xFF, 0x0B, - 0x4E, 0x45, 0x54, 0x53, 0x43, 0x41, 0x50, 0x45, 0x32, 0x2E, 0x30, - 0x03, 0x01, 0x00, 0x00, 0x00 +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 NETSCAPE_LOOP_EXTENSION = Buffer.from([ + 0x21, + 0xFF, + 0x0B, + 0x4E, + 0x45, + 0x54, + 0x53, + 0x43, + 0x41, + 0x50, + 0x45, + 0x32, + 0x2E, + 0x30, + 0x03, + 0x01, + 0x00, + 0x00, + 0x00 +]); +const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream']; const VOICE_CHANNEL = 'General'; test.describe('Profile avatar sync', () => { @@ -100,6 +117,8 @@ test.describe('Profile avatar sync', () => { await joinServerFromSearch(bob.page, serverName); await waitForRoomReady(alice.page); await waitForRoomReady(bob.page); + await waitForConnectedPeerCount(alice.page, 1); + await waitForConnectedPeerCount(bob.page, 1); await expectUserRowVisible(bob.page, aliceUser.displayName); }); @@ -126,6 +145,8 @@ test.describe('Profile avatar sync', () => { await registerUser(carol); await joinServerFromSearch(carol.page, serverName); await waitForRoomReady(carol.page); + await waitForConnectedPeerCount(alice.page, 2); + await waitForConnectedPeerCount(carol.page, 1); await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarA.dataUrl); }); @@ -177,6 +198,134 @@ test.describe('Profile avatar sync', () => { }); }); +test.describe('Profile metadata sync', () => { + test.describe.configure({ timeout: 240_000 }); + + test('syncs display name and description changes for online and late-joining users and persists after restart', async ({ testServer }) => { + const suffix = uniqueName('profile'); + const serverName = `Profile Sync Server ${suffix}`; + const messageText = `Profile sync message ${suffix}`; + const firstProfile: ProfileMetadata = { + displayName: `Alice One ${suffix}`, + description: `First synced profile description ${suffix}` + }; + const secondProfile: ProfileMetadata = { + displayName: `Alice Two ${suffix}`, + description: `Second synced profile description ${suffix}` + }; + 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 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 and Bob register, create a server, and join the same room', async () => { + await registerUser(alice); + await registerUser(bob); + + const aliceSearchPage = new ServerSearchPage(alice.page); + + await aliceSearchPage.createServer(serverName, { + description: 'Profile 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); + await expectUserRowVisible(bob.page, aliceUser.displayName); + }); + + const roomUrl = alice.page.url(); + + await test.step('Alice updates her profile while Bob is online and Bob sees it live', async () => { + await updateProfileFromRoomSidebar(alice.page, { + displayName: aliceUser.displayName + }, firstProfile); + + await expectUserRowVisible(alice.page, firstProfile.displayName); + await expectUserRowVisible(bob.page, firstProfile.displayName); + await expectProfileCardDetails(bob.page, firstProfile); + }); + + const carol = await createPersistentClient(carolUser, testServer.port); + + clients.push(carol); + + await test.step('Carol joins after the first change and sees the updated profile', async () => { + await registerUser(carol); + await joinServerFromSearch(carol.page, serverName); + await waitForRoomReady(carol.page); + await waitForConnectedPeerCount(alice.page, 2); + await waitForConnectedPeerCount(carol.page, 1); + + await expectUserRowVisible(carol.page, firstProfile.displayName); + await expectProfileCardDetails(carol.page, firstProfile); + }); + + await test.step('Alice changes her profile again and new chat messages use the latest display name', async () => { + await updateProfileFromRoomSidebar(alice.page, firstProfile, secondProfile); + + await expectUserRowVisible(alice.page, secondProfile.displayName); + await expectUserRowVisible(bob.page, secondProfile.displayName); + await expectUserRowVisible(carol.page, secondProfile.displayName); + await expectProfileCardDetails(bob.page, secondProfile); + await expectProfileCardDetails(carol.page, secondProfile); + + const aliceMessagesPage = new ChatMessagesPage(alice.page); + + await aliceMessagesPage.sendMessage(messageText); + + await expectChatMessageSenderName(alice.page, messageText, secondProfile.displayName); + await expectChatMessageSenderName(bob.page, messageText, secondProfile.displayName); + await expectChatMessageSenderName(carol.page, messageText, secondProfile.displayName); + }); + + await test.step('Bob, Carol, and Alice keep the latest profile after a full app restart', async () => { + await restartPersistentClient(bob, testServer.port); + await openRoomAfterRestart(bob, roomUrl); + await expectUserRowVisible(bob.page, secondProfile.displayName); + await expectProfileCardDetails(bob.page, secondProfile); + + await restartPersistentClient(carol, testServer.port); + await openRoomAfterRestart(carol, roomUrl); + await expectUserRowVisible(carol.page, secondProfile.displayName); + await expectProfileCardDetails(carol.page, secondProfile); + + await restartPersistentClient(alice, testServer.port); + await openRoomAfterRestart(alice, roomUrl); + await expectUserRowVisible(alice.page, secondProfile.displayName); + await expectProfileCardDetails(alice.page, secondProfile); + }); + } 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-avatar-e2e-')); const session = await launchPersistentSession(userDataDir, testServerPort); @@ -220,6 +369,8 @@ async function launchPersistentSession( const page = context.pages()[0] ?? await context.newPage(); + await installWebRTCTracking(page); + return { context, page }; } @@ -288,6 +439,43 @@ async function uploadAvatarFromRoomSidebar( await expect(applyButton).not.toBeVisible({ timeout: 10_000 }); } +async function updateProfileFromRoomSidebar( + page: Page, + currentProfile: ProfileMetadata, + nextProfile: ProfileMetadata +): Promise { + const profileCard = await openProfileCardFromUserRow(page, currentProfile.displayName); + const displayNameButton = profileCard.getByRole('button', { name: currentProfile.displayName, exact: true }); + + await expect(displayNameButton).toBeVisible({ timeout: 10_000 }); + await displayNameButton.click(); + + const displayNameInput = profileCard.locator('input[type="text"]').first(); + + await expect(displayNameInput).toBeVisible({ timeout: 10_000 }); + await displayNameInput.fill(nextProfile.displayName); + await displayNameInput.blur(); + + await expect(profileCard.locator('input[type="text"]')).toHaveCount(0, { timeout: 10_000 }); + + const currentDescriptionText = currentProfile.description || 'Add a description'; + + await profileCard.getByText(currentDescriptionText, { exact: true }).click(); + + const descriptionInput = profileCard.locator('textarea').first(); + + await expect(descriptionInput).toBeVisible({ timeout: 10_000 }); + await descriptionInput.fill(nextProfile.description || ''); + await descriptionInput.blur(); + + await expect(profileCard.locator('textarea')).toHaveCount(0, { timeout: 10_000 }); + await expect(profileCard.getByText(nextProfile.displayName, { exact: true })).toBeVisible({ timeout: 10_000 }); + + if (nextProfile.description) { + await expect(profileCard.getByText(nextProfile.description, { exact: true })).toBeVisible({ timeout: 10_000 }); + } +} + async function openRoomAfterRestart(client: PersistentClient, roomUrl: string): Promise { await retryTransientNavigation(() => client.page.goto(roomUrl, { waitUntil: 'domcontentloaded' })); @@ -332,18 +520,73 @@ async function waitForRoomReady(page: Page): Promise { 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 openProfileCardFromUserRow(page: Page, displayName: string) { + await closeProfileCard(page); + + const row = getUserRow(page, displayName); + + await expect(row).toBeVisible({ timeout: 20_000 }); + await row.click(); + + const profileCard = page.locator('app-profile-card'); + + await expect(profileCard).toBeVisible({ timeout: 10_000 }); + + return profileCard; +} + +async function closeProfileCard(page: Page): Promise { + const profileCard = page.locator('app-profile-card'); + + if (await profileCard.count() === 0) { + return; + } + + try { + await expect(profileCard).toBeVisible({ timeout: 1_000 }); + } catch { + return; + } + + await page.mouse.click(8, 8); + await expect(profileCard).toHaveCount(0, { timeout: 10_000 }); +} + function getUserRow(page: Page, displayName: string) { const usersSidePanel = page.locator('app-rooms-side-panel').last(); return usersSidePanel.locator('[role="button"]').filter({ has: page.getByText(displayName, { exact: true }) - }).first(); + }) + .first(); } async function expectUserRowVisible(page: Page, displayName: string): Promise { await expect(getUserRow(page, displayName)).toBeVisible({ timeout: 20_000 }); } +async function expectProfileCardDetails(page: Page, profile: ProfileMetadata): Promise { + const profileCard = await openProfileCardFromUserRow(page, profile.displayName); + + await expect(profileCard.getByText(profile.displayName, { exact: true })).toBeVisible({ timeout: 20_000 }); + + if (profile.description) { + await expect(profileCard.getByText(profile.description, { exact: true })).toBeVisible({ timeout: 20_000 }); + } + + await closeProfileCard(page); +} + async function expectSidebarAvatar(page: Page, displayName: string, expectedDataUrl: string): Promise { const row = getUserRow(page, displayName); @@ -400,6 +643,14 @@ async function expectChatMessageAvatar(page: Page, messageText: string, expected }).toBe(expectedDataUrl); } +async function expectChatMessageSenderName(page: Page, messageText: string, expectedDisplayName: string): Promise { + const messagesPage = new ChatMessagesPage(page); + const messageItem = messagesPage.getMessageItemByText(messageText); + + await expect(messageItem).toBeVisible({ timeout: 20_000 }); + await expect(messageItem.getByText(expectedDisplayName, { exact: true })).toBeVisible({ timeout: 20_000 }); +} + async function expectVoiceControlsAvatar(page: Page, expectedDataUrl: string): Promise { const voiceControls = page.locator('app-voice-controls'); @@ -431,7 +682,11 @@ function buildAnimatedGifUpload(label: string): AvatarUploadPayload { const frame = baseGif.subarray(frameStart, baseGif.length - 1); const commentData = Buffer.from(label, 'ascii'); const commentExtension = Buffer.concat([ - Buffer.from([0x21, 0xFE, commentData.length]), + Buffer.from([ + 0x21, + 0xFE, + commentData.length + ]), commentData, Buffer.from([0x00]) ]); @@ -454,5 +709,6 @@ function buildAnimatedGifUpload(label: string): AvatarUploadPayload { } function uniqueName(prefix: string): string { - return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + return `${prefix}-${Date.now()}-${Math.random().toString(36) + .slice(2, 8)}`; } diff --git a/electron/cqrs/commands/handlers/saveUser.ts b/electron/cqrs/commands/handlers/saveUser.ts index b39853e..dc04c4b 100644 --- a/electron/cqrs/commands/handlers/saveUser.ts +++ b/electron/cqrs/commands/handlers/saveUser.ts @@ -10,6 +10,8 @@ export async function handleSaveUser(command: SaveUserCommand, dataSource: DataS oderId: user.oderId ?? null, username: user.username ?? null, displayName: user.displayName ?? null, + description: user.description ?? null, + profileUpdatedAt: user.profileUpdatedAt ?? null, avatarUrl: user.avatarUrl ?? null, avatarHash: user.avatarHash ?? null, avatarMime: user.avatarMime ?? null, diff --git a/electron/cqrs/mappers.ts b/electron/cqrs/mappers.ts index 57644b3..92a866a 100644 --- a/electron/cqrs/mappers.ts +++ b/electron/cqrs/mappers.ts @@ -46,6 +46,8 @@ export function rowToUser(row: UserEntity) { oderId: row.oderId ?? '', username: row.username ?? '', displayName: row.displayName ?? '', + description: row.description ?? undefined, + profileUpdatedAt: row.profileUpdatedAt ?? undefined, avatarUrl: row.avatarUrl ?? undefined, avatarHash: row.avatarHash ?? undefined, avatarMime: row.avatarMime ?? undefined, diff --git a/electron/cqrs/relations.ts b/electron/cqrs/relations.ts index 0139461..94a6750 100644 --- a/electron/cqrs/relations.ts +++ b/electron/cqrs/relations.ts @@ -66,6 +66,8 @@ export interface RoomMemberRecord { oderId?: string; username: string; displayName: string; + description?: string; + profileUpdatedAt?: number; avatarUrl?: string; avatarHash?: string; avatarMime?: string; @@ -338,16 +340,18 @@ function normalizeRoomMember(rawMember: Record, now: number): R const { joinedAt, lastSeenAt } = resolveRoomMemberTimes(rawMember, now); const username = trimmedString(rawMember, 'username'); const displayName = trimmedString(rawMember, 'displayName'); + const description = trimmedString(rawMember, 'description'); + const profileUpdatedAt = isFiniteNumber(rawMember['profileUpdatedAt']) ? rawMember['profileUpdatedAt'] : undefined; const avatarUrl = trimmedString(rawMember, 'avatarUrl'); const avatarHash = trimmedString(rawMember, 'avatarHash'); const avatarMime = trimmedString(rawMember, 'avatarMime'); const avatarUpdatedAt = isFiniteNumber(rawMember['avatarUpdatedAt']) ? rawMember['avatarUpdatedAt'] : undefined; - - return { + const member: RoomMemberRecord = { id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, username: username || fallbackUsername({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, displayName }), displayName: displayName || fallbackDisplayName({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, username }), + profileUpdatedAt, avatarUrl: avatarUrl || undefined, avatarHash: avatarHash || undefined, avatarMime: avatarMime || undefined, @@ -357,6 +361,12 @@ function normalizeRoomMember(rawMember: Record, now: number): R joinedAt, lastSeenAt }; + + if (Object.prototype.hasOwnProperty.call(rawMember, 'description')) { + member.description = description || undefined; + } + + return member; } function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incomingMember: RoomMemberRecord): RoomMemberRecord { @@ -365,6 +375,11 @@ function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incoming } const preferIncoming = incomingMember.lastSeenAt >= existingMember.lastSeenAt; + const existingProfileUpdatedAt = existingMember.profileUpdatedAt ?? 0; + const incomingProfileUpdatedAt = incomingMember.profileUpdatedAt ?? 0; + const preferIncomingProfile = incomingProfileUpdatedAt === existingProfileUpdatedAt + ? preferIncoming + : incomingProfileUpdatedAt > existingProfileUpdatedAt; const existingAvatarUpdatedAt = existingMember.avatarUpdatedAt ?? 0; const incomingAvatarUpdatedAt = incomingMember.avatarUpdatedAt ?? 0; const preferIncomingAvatar = incomingAvatarUpdatedAt === existingAvatarUpdatedAt @@ -377,9 +392,13 @@ function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incoming username: preferIncoming ? (incomingMember.username || existingMember.username) : (existingMember.username || incomingMember.username), - displayName: preferIncoming + displayName: preferIncomingProfile ? (incomingMember.displayName || existingMember.displayName) : (existingMember.displayName || incomingMember.displayName), + description: preferIncomingProfile + ? (Object.prototype.hasOwnProperty.call(incomingMember, 'description') ? incomingMember.description : existingMember.description) + : existingMember.description, + profileUpdatedAt: Math.max(existingProfileUpdatedAt, incomingProfileUpdatedAt) || undefined, avatarUrl: preferIncomingAvatar ? (incomingMember.avatarUrl || existingMember.avatarUrl) : (existingMember.avatarUrl || incomingMember.avatarUrl), @@ -780,6 +799,8 @@ export async function replaceRoomRelations( oderId: member.oderId ?? null, username: member.username, displayName: member.displayName, + description: member.description ?? null, + profileUpdatedAt: member.profileUpdatedAt ?? null, avatarUrl: member.avatarUrl ?? null, avatarHash: member.avatarHash ?? null, avatarMime: member.avatarMime ?? null, @@ -930,6 +951,8 @@ export async function loadRoomRelationsMap( oderId: row.oderId ?? undefined, username: row.username, displayName: row.displayName, + description: row.description ?? undefined, + profileUpdatedAt: row.profileUpdatedAt ?? undefined, avatarUrl: row.avatarUrl ?? undefined, avatarHash: row.avatarHash ?? undefined, avatarMime: row.avatarMime ?? undefined, diff --git a/electron/cqrs/types.ts b/electron/cqrs/types.ts index 83cf792..e1e714b 100644 --- a/electron/cqrs/types.ts +++ b/electron/cqrs/types.ts @@ -105,6 +105,8 @@ export interface UserPayload { oderId?: string; username?: string; displayName?: string; + description?: string; + profileUpdatedAt?: number; avatarUrl?: string; avatarHash?: string; avatarMime?: string; diff --git a/electron/entities/RoomMemberEntity.ts b/electron/entities/RoomMemberEntity.ts index bb56784..f08a123 100644 --- a/electron/entities/RoomMemberEntity.ts +++ b/electron/entities/RoomMemberEntity.ts @@ -24,6 +24,12 @@ export class RoomMemberEntity { @Column('text') displayName!: string; + @Column('text', { nullable: true }) + description!: string | null; + + @Column('integer', { nullable: true }) + profileUpdatedAt!: number | null; + @Column('text', { nullable: true }) avatarUrl!: string | null; diff --git a/electron/entities/UserEntity.ts b/electron/entities/UserEntity.ts index 172d5e2..22e73b2 100644 --- a/electron/entities/UserEntity.ts +++ b/electron/entities/UserEntity.ts @@ -18,6 +18,12 @@ export class UserEntity { @Column('text', { nullable: true }) displayName!: string | null; + @Column('text', { nullable: true }) + description!: string | null; + + @Column('integer', { nullable: true }) + profileUpdatedAt!: number | null; + @Column('text', { nullable: true }) avatarUrl!: string | null; diff --git a/electron/migrations/1000000000007-AddUserProfileMetadata.ts b/electron/migrations/1000000000007-AddUserProfileMetadata.ts new file mode 100644 index 0000000..45a8b3a --- /dev/null +++ b/electron/migrations/1000000000007-AddUserProfileMetadata.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserProfileMetadata1000000000007 implements MigrationInterface { + name = 'AddUserProfileMetadata1000000000007'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "description" TEXT`); + await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "profileUpdatedAt" INTEGER`); + await queryRunner.query(`ALTER TABLE "room_members" ADD COLUMN "description" TEXT`); + await queryRunner.query(`ALTER TABLE "room_members" ADD COLUMN "profileUpdatedAt" INTEGER`); + } + + public async down(): Promise { + // SQLite column removal requires table rebuilds. Keep rollback no-op. + } +} \ No newline at end of file diff --git a/server/src/websocket/handler-status.spec.ts b/server/src/websocket/handler-status.spec.ts index 71ff2c9..903ed37 100644 --- a/server/src/websocket/handler-status.spec.ts +++ b/server/src/websocket/handler-status.spec.ts @@ -2,13 +2,18 @@ import { describe, it, expect, - beforeEach + beforeEach, + vi } from 'vitest'; import { connectedUsers } from './state'; import { handleWebSocketMessage } from './handler'; import { ConnectedUser } from './types'; import { WebSocket } from 'ws'; +vi.mock('../services/server-access.service', () => ({ + authorizeWebSocketJoin: vi.fn(async () => ({ allowed: true as const })) +})); + /** * Minimal mock WebSocket that records sent messages. */ @@ -197,3 +202,94 @@ describe('server websocket handler - user_joined includes status', () => { } }); }); + +describe('server websocket handler - profile metadata in presence messages', () => { + beforeEach(() => { + connectedUsers.clear(); + }); + + it('broadcasts updated profile metadata when an identified user changes it', async () => { + const alice = createConnectedUser('conn-1', 'user-1', { + displayName: 'Alice', + viewedServerId: 'server-1' + }); + const bob = createConnectedUser('conn-2', 'user-2', { + viewedServerId: 'server-1' + }); + + alice.serverIds.add('server-1'); + bob.serverIds.add('server-1'); + getSentMessagesStore(bob).sentMessages.length = 0; + + await handleWebSocketMessage('conn-1', { + type: 'identify', + oderId: 'user-1', + displayName: 'Alice Updated', + description: 'Updated bio', + profileUpdatedAt: 789 + }); + + const messages = getSentMessagesStore(bob).sentMessages.map((messageText: string) => JSON.parse(messageText)); + const joinMsg = messages.find((message: { type: string }) => message.type === 'user_joined'); + + expect(joinMsg?.displayName).toBe('Alice Updated'); + expect(joinMsg?.description).toBe('Updated bio'); + expect(joinMsg?.profileUpdatedAt).toBe(789); + expect(joinMsg?.serverId).toBe('server-1'); + }); + + it('includes description and profileUpdatedAt in server_users responses', async () => { + const alice = createConnectedUser('conn-1', 'user-1'); + const bob = createConnectedUser('conn-2', 'user-2'); + + alice.serverIds.add('server-1'); + bob.serverIds.add('server-1'); + + await handleWebSocketMessage('conn-1', { + type: 'identify', + oderId: 'user-1', + displayName: 'Alice', + description: 'Alice bio', + profileUpdatedAt: 123 + }); + + getSentMessagesStore(bob).sentMessages.length = 0; + + await handleWebSocketMessage('conn-2', { + type: 'view_server', + serverId: 'server-1' + }); + + const messages = getSentMessagesStore(bob).sentMessages.map((messageText: string) => JSON.parse(messageText)); + const serverUsersMsg = messages.find((message: { type: string }) => message.type === 'server_users'); + const aliceInList = serverUsersMsg?.users?.find((userEntry: { oderId: string }) => userEntry.oderId === 'user-1'); + + expect(aliceInList?.description).toBe('Alice bio'); + expect(aliceInList?.profileUpdatedAt).toBe(123); + }); + + it('includes description and profileUpdatedAt in user_joined broadcasts', async () => { + const bob = createConnectedUser('conn-2', 'user-2'); + + bob.serverIds.add('server-1'); + bob.viewedServerId = 'server-1'; + + createConnectedUser('conn-1', 'user-1', { + displayName: 'Alice', + description: 'Alice bio', + profileUpdatedAt: 456, + viewedServerId: 'server-1' + }); + + await handleWebSocketMessage('conn-1', { + type: 'join_server', + serverId: 'server-1' + }); + + const messages = getSentMessagesStore(bob).sentMessages.map((messageText: string) => JSON.parse(messageText)); + const joinMsg = messages.find((message: { type: string }) => message.type === 'user_joined'); + + expect(joinMsg?.description).toBe('Alice bio'); + expect(joinMsg?.profileUpdatedAt).toBe(456); + }); +}); diff --git a/server/src/websocket/handler.ts b/server/src/websocket/handler.ts index b2a4ab0..f691840 100644 --- a/server/src/websocket/handler.ts +++ b/server/src/websocket/handler.ts @@ -20,6 +20,22 @@ function normalizeDisplayName(value: unknown, fallback = 'User'): string { return normalized || fallback; } +function normalizeDescription(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const normalized = value.trim(); + + return normalized || undefined; +} + +function normalizeProfileUpdatedAt(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? value + : undefined; +} + function readMessageId(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined; @@ -37,7 +53,13 @@ function readMessageId(value: unknown): string | undefined { /** 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), status: cu.status ?? 'online' })); + .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 })); } @@ -45,6 +67,9 @@ function sendServerUsers(user: ConnectedUser, serverId: string): void { function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void { const newOderId = readMessageId(message['oderId']) ?? connectionId; const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined; + const previousDisplayName = normalizeDisplayName(user.displayName); + const previousDescription = user.description; + const previousProfileUpdatedAt = user.profileUpdatedAt; // Close stale connections from the same identity AND the same connection // scope so offer routing always targets the freshest socket (e.g. after @@ -67,9 +92,38 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s user.oderId = newOderId; user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName)); + + if (Object.prototype.hasOwnProperty.call(message, 'description')) { + user.description = normalizeDescription(message['description']); + } + + if (Object.prototype.hasOwnProperty.call(message, 'profileUpdatedAt')) { + user.profileUpdatedAt = normalizeProfileUpdatedAt(message['profileUpdatedAt']); + } + user.connectionScope = newScope; connectedUsers.set(connectionId, user); console.log(`User identified: ${user.displayName} (${user.oderId})`); + + 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); + } } async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise { @@ -108,6 +162,8 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect type: 'user_joined', oderId: user.oderId, displayName: normalizeDisplayName(user.displayName), + description: user.description, + profileUpdatedAt: user.profileUpdatedAt, status: user.status ?? 'online', serverId: sid }, user.oderId); diff --git a/server/src/websocket/types.ts b/server/src/websocket/types.ts index 176df34..f41437c 100644 --- a/server/src/websocket/types.ts +++ b/server/src/websocket/types.ts @@ -6,6 +6,8 @@ export interface ConnectedUser { serverIds: Set; viewedServerId?: string; displayName?: string; + description?: string; + profileUpdatedAt?: number; /** * Opaque scope string sent by the client (typically the signal URL it * connected through). Stale-connection eviction only targets connections diff --git a/toju-app/src/app/domains/authentication/feature/user-bar/user-bar.component.ts b/toju-app/src/app/domains/authentication/feature/user-bar/user-bar.component.ts index eff6e16..c0d7c50 100644 --- a/toju-app/src/app/domains/authentication/feature/user-bar/user-bar.component.ts +++ b/toju-app/src/app/domains/authentication/feature/user-bar/user-bar.component.ts @@ -11,7 +11,11 @@ import { UserAvatarComponent } from '../../../../shared'; @Component({ selector: 'app-user-bar', standalone: true, - imports: [CommonModule, NgIcon, UserAvatarComponent], + imports: [ + CommonModule, + NgIcon, + UserAvatarComponent + ], viewProviders: [ provideIcons({ lucideLogIn, diff --git a/toju-app/src/app/domains/profile-avatar/README.md b/toju-app/src/app/domains/profile-avatar/README.md index b4ded61..fe921a3 100644 --- a/toju-app/src/app/domains/profile-avatar/README.md +++ b/toju-app/src/app/domains/profile-avatar/README.md @@ -1,6 +1,6 @@ # Profile Avatar Domain -Owns local profile picture workflow: source validation, crop/zoom editor state, static 256x256 WebP rendering, animated avatar preservation, desktop file persistence, and P2P avatar sync metadata. +Owns local profile picture workflow plus peer-synced profile-card metadata: source validation, crop/zoom editor state, static 256x256 WebP rendering, animated avatar preservation, desktop file persistence, and P2P avatar/profile sync metadata. ## Responsibilities @@ -9,7 +9,9 @@ Owns local profile picture workflow: source validation, crop/zoom editor state, - Render static avatars to `256x256` WebP with client-side compression. - Preserve animated `.gif` and animated `.webp` uploads without flattening frames. - Persist desktop copy at `user//profile/profile.` under app data. +- Let the local user edit their profile-card display name and description. - Expose helpers used by store effects to keep avatar metadata (`avatarHash`, `avatarMime`, `avatarUpdatedAt`) consistent. +- Reuse the avatar summary/request/full handshake to sync profile text (`displayName`, `description`, `profileUpdatedAt`) alongside avatar state. ## Module map @@ -33,12 +35,14 @@ graph TD ## Flow 1. `ProfileCardComponent` opens file picker from editable avatar button. -2. `ProfileAvatarEditorComponent` previews exact crop using drag + zoom. -3. `ProfileAvatarImageService` renders static uploads to `256x256` WebP, but keeps animated GIF and WebP sources intact. -4. `ProfileAvatarStorageService` writes desktop copy when Electron is available. -5. `UserAvatarEffects` broadcasts avatar summary, answers requests, streams chunks, and persists received avatars locally. +2. `ProfileCardComponent` saves display-name and description edits through the users store. +3. `ProfileAvatarEditorComponent` previews exact crop using drag + zoom. +4. `ProfileAvatarImageService` renders static uploads to `256x256` WebP, but keeps animated GIF and WebP sources intact. +5. `ProfileAvatarStorageService` writes desktop copy when Electron is available. +6. `UserAvatarEffects` broadcasts avatar/profile summaries, answers requests, streams chunks when needed, and persists received profile state locally. ## Notes - Static uploads are normalized to WebP. Animated GIF and animated WebP uploads keep their original animation, mime type, and full-frame presentation. - `avatarUrl` stays local display data. Version conflict resolution uses `avatarUpdatedAt` and `avatarHash`. +- Profile text uses its own `profileUpdatedAt` version so display-name and description changes can sync without replacing a newer avatar. diff --git a/toju-app/src/app/domains/profile-avatar/feature/profile-avatar-editor/profile-avatar-editor.component.html b/toju-app/src/app/domains/profile-avatar/feature/profile-avatar-editor/profile-avatar-editor.component.html index 1f1cb97..a1f9bb5 100644 --- a/toju-app/src/app/domains/profile-avatar/feature/profile-avatar-editor/profile-avatar-editor.component.html +++ b/toju-app/src/app/domains/profile-avatar/feature/profile-avatar-editor/profile-avatar-editor.component.html @@ -1,8 +1,8 @@