From 180333dc3540234f1f5d8eb72423ce4660c7cbf8 Mon Sep 17 00:00:00 2001 From: Myx Date: Fri, 17 Apr 2026 22:04:18 +0200 Subject: [PATCH] feat: Add user metadata changing display name and description with sync --- e2e/playwright.config.ts | 2 +- e2e/tests/chat/profile-avatar-sync.spec.ts | 239 ++++++++++++++++++ electron/cqrs/commands/handlers/saveUser.ts | 2 + electron/cqrs/mappers.ts | 2 + electron/cqrs/relations.ts | 28 +- electron/cqrs/types.ts | 2 + electron/entities/RoomMemberEntity.ts | 6 + electron/entities/UserEntity.ts | 6 + .../1000000000007-AddUserProfileMetadata.ts | 16 ++ server/src/websocket/handler-status.spec.ts | 68 ++++- server/src/websocket/handler.ts | 32 ++- server/src/websocket/types.ts | 2 + .../src/app/domains/profile-avatar/README.md | 14 +- .../rooms-side-panel.component.ts | 2 + .../src/app/infrastructure/realtime/README.md | 2 +- .../realtime/realtime-session.service.ts | 9 +- .../infrastructure/realtime/realtime.types.ts | 4 + .../signaling/signaling-transport-handler.ts | 25 +- toju-app/src/app/shared-kernel/chat-events.ts | 8 +- toju-app/src/app/shared-kernel/user.models.ts | 4 + .../profile-card/profile-card.component.html | 132 ++++++---- .../profile-card/profile-card.component.ts | 87 +++++++ .../rooms/room-members-sync.effects.spec.ts | 103 ++++++++ .../store/rooms/room-members-sync.effects.ts | 40 ++- .../app/store/rooms/room-members.helpers.ts | 62 ++++- .../store/rooms/room-signaling-connection.ts | 7 +- .../store/rooms/room-state-sync.effects.ts | 6 + toju-app/src/app/store/rooms/rooms.helpers.ts | 6 +- .../store/users/user-avatar.effects.spec.ts | 24 ++ .../app/store/users/user-avatar.effects.ts | 177 +++++++++---- .../store/users/users-status.reducer.spec.ts | 68 +++++ toju-app/src/app/store/users/users.actions.ts | 3 +- toju-app/src/app/store/users/users.effects.ts | 11 +- toju-app/src/app/store/users/users.reducer.ts | 85 ++++++- 34 files changed, 1165 insertions(+), 119 deletions(-) create mode 100644 electron/migrations/1000000000007-AddUserProfileMetadata.ts create mode 100644 toju-app/src/app/store/rooms/room-members-sync.effects.spec.ts 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..b7df0af 100644 --- a/e2e/tests/chat/profile-avatar-sync.spec.ts +++ b/e2e/tests/chat/profile-avatar-sync.spec.ts @@ -14,6 +14,7 @@ import { 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,6 +41,11 @@ 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([ @@ -100,6 +106,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 +134,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 +187,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 +358,8 @@ async function launchPersistentSession( const page = context.pages()[0] ?? await context.newPage(); + await installWebRTCTracking(page); + return { context, page }; } @@ -288,6 +428,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,6 +509,48 @@ 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(); @@ -344,6 +563,18 @@ async function expectUserRowVisible(page: Page, displayName: string): 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 +631,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'); 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..3f68f0c 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,19 @@ 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 +362,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 +376,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 +393,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 +800,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 +952,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..f3820a6 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,64 @@ describe('server websocket handler - user_joined includes status', () => { } }); }); + +describe('server websocket handler - profile metadata in presence messages', () => { + beforeEach(() => { + connectedUsers.clear(); + }); + + 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..671c817 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 })); } @@ -67,6 +89,12 @@ 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})`); @@ -108,6 +136,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/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/features/room/rooms-side-panel/rooms-side-panel.component.ts b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts index e0e6ed3..720008d 100644 --- a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts +++ b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts @@ -199,6 +199,8 @@ export class RoomsSidePanelComponent { oderId: member.oderId || member.id, username: member.username, displayName: member.displayName, + description: member.description, + profileUpdatedAt: member.profileUpdatedAt, avatarUrl: member.avatarUrl, status: 'disconnected', role: member.role, diff --git a/toju-app/src/app/infrastructure/realtime/README.md b/toju-app/src/app/infrastructure/realtime/README.md index 1b01915..3f29e69 100644 --- a/toju-app/src/app/infrastructure/realtime/README.md +++ b/toju-app/src/app/infrastructure/realtime/README.md @@ -232,7 +232,7 @@ A single ordered data channel carries all peer-to-peer messages: chat events, at Back-pressure is handled with a high-water mark (4 MB) and low-water mark (1 MB). `sendToPeerBuffered()` waits for the buffer to drain before sending, which matters during file transfers. -Profile avatar sync follows attachment-style chunk transport plus server-icon-style version handshakes: sender announces `avatarUpdatedAt`, receiver requests only when remote version is newer, then sender streams ordered base64 chunks over buffered sends. +Profile avatar sync follows attachment-style chunk transport plus server-icon-style version handshakes: sender announces avatar/profile versions, receiver requests only when either remote version is newer, then sender streams ordered base64 chunks when avatar bytes are needed and still uses the same full-event path for profile-only updates. Every 5 seconds a PING message is sent to each peer. The peer responds with PONG carrying the original timestamp, and the round-trip latency is stored in a signal. diff --git a/toju-app/src/app/infrastructure/realtime/realtime-session.service.ts b/toju-app/src/app/infrastructure/realtime/realtime-session.service.ts index 87ea97d..b6931cd 100644 --- a/toju-app/src/app/infrastructure/realtime/realtime-session.service.ts +++ b/toju-app/src/app/infrastructure/realtime/realtime-session.service.ts @@ -328,8 +328,13 @@ export class WebRTCService implements OnDestroy { * @param oderId - The user's unique order/peer ID. * @param displayName - The user's display name. */ - identify(oderId: string, displayName: string, signalUrl?: string): void { - this.signalingTransportHandler.identify(oderId, displayName, signalUrl); + identify( + oderId: string, + displayName: string, + signalUrl?: string, + profile?: { description?: string; profileUpdatedAt?: number } + ): void { + this.signalingTransportHandler.identify(oderId, displayName, signalUrl, profile); } /** diff --git a/toju-app/src/app/infrastructure/realtime/realtime.types.ts b/toju-app/src/app/infrastructure/realtime/realtime.types.ts index 17ceb67..865c79d 100644 --- a/toju-app/src/app/infrastructure/realtime/realtime.types.ts +++ b/toju-app/src/app/infrastructure/realtime/realtime.types.ts @@ -36,6 +36,10 @@ export interface IdentifyCredentials { oderId: string; /** The user's display name shown to other peers. */ displayName: string; + /** Optional profile description advertised via signaling identity. */ + description?: string; + /** Monotonic profile version for late-join reconciliation. */ + profileUpdatedAt?: number; } /** Last-joined server info, used for reconnection. */ diff --git a/toju-app/src/app/infrastructure/realtime/signaling/signaling-transport-handler.ts b/toju-app/src/app/infrastructure/realtime/signaling/signaling-transport-handler.ts index d79fd4c..d8a504d 100644 --- a/toju-app/src/app/infrastructure/realtime/signaling/signaling-transport-handler.ts +++ b/toju-app/src/app/infrastructure/realtime/signaling/signaling-transport-handler.ts @@ -30,6 +30,10 @@ export class SignalingTransportHandler { return this.lastIdentifyCredentials?.displayName || DEFAULT_DISPLAY_NAME; } + getIdentifyDescription(): string | undefined { + return this.lastIdentifyCredentials?.description; + } + getConnectedSignalingManagers(): ConnectedSignalingManager[] { return this.dependencies.signalingCoordinator.getConnectedSignalingManagers(); } @@ -160,12 +164,25 @@ export class SignalingTransportHandler { return true; } - identify(oderId: string, displayName: string, signalUrl?: string): void { + identify( + oderId: string, + displayName: string, + signalUrl?: string, + profile?: Pick + ): void { const normalizedDisplayName = displayName.trim() || DEFAULT_DISPLAY_NAME; + const normalizedDescription = typeof profile?.description === 'string' + ? (profile.description.trim() || undefined) + : undefined; + const normalizedProfileUpdatedAt = typeof profile?.profileUpdatedAt === 'number' && Number.isFinite(profile.profileUpdatedAt) && profile.profileUpdatedAt > 0 + ? profile.profileUpdatedAt + : undefined; this.lastIdentifyCredentials = { oderId, - displayName: normalizedDisplayName + displayName: normalizedDisplayName, + description: normalizedDescription, + profileUpdatedAt: normalizedProfileUpdatedAt }; if (signalUrl) { @@ -173,6 +190,8 @@ export class SignalingTransportHandler { type: SIGNALING_TYPE_IDENTIFY, oderId, displayName: normalizedDisplayName, + description: normalizedDescription, + profileUpdatedAt: normalizedProfileUpdatedAt, connectionScope: signalUrl }); @@ -190,6 +209,8 @@ export class SignalingTransportHandler { type: SIGNALING_TYPE_IDENTIFY, oderId, displayName: normalizedDisplayName, + description: normalizedDescription, + profileUpdatedAt: normalizedProfileUpdatedAt, connectionScope: managerSignalUrl }); } diff --git a/toju-app/src/app/shared-kernel/chat-events.ts b/toju-app/src/app/shared-kernel/chat-events.ts index 6778fb5..2ea6e1e 100644 --- a/toju-app/src/app/shared-kernel/chat-events.ts +++ b/toju-app/src/app/shared-kernel/chat-events.ts @@ -54,6 +54,8 @@ export interface ChatEventBase { deletedBy?: string; oderId?: string; displayName?: string; + description?: string; + profileUpdatedAt?: number; emoji?: string; reason?: string; settings?: Partial; @@ -273,6 +275,8 @@ export interface UserAvatarSummaryEvent extends ChatEventBase { oderId: string; username?: string; displayName?: string; + description?: string; + profileUpdatedAt?: number; avatarHash?: string; avatarMime?: string; avatarUpdatedAt: number; @@ -288,8 +292,10 @@ export interface UserAvatarFullEvent extends ChatEventBase { oderId: string; username?: string; displayName?: string; + description?: string; + profileUpdatedAt?: number; avatarHash?: string; - avatarMime: string; + avatarMime?: string; avatarUpdatedAt: number; total: number; } diff --git a/toju-app/src/app/shared-kernel/user.models.ts b/toju-app/src/app/shared-kernel/user.models.ts index a0911b0..9aefa1b 100644 --- a/toju-app/src/app/shared-kernel/user.models.ts +++ b/toju-app/src/app/shared-kernel/user.models.ts @@ -13,6 +13,8 @@ export interface User { oderId: string; username: string; displayName: string; + description?: string; + profileUpdatedAt?: number; avatarUrl?: string; avatarHash?: string; avatarMime?: string; @@ -35,6 +37,8 @@ export interface RoomMember { oderId?: string; username: string; displayName: string; + description?: string; + profileUpdatedAt?: number; avatarUrl?: string; avatarHash?: string; avatarMime?: string; diff --git a/toju-app/src/app/shared/components/profile-card/profile-card.component.html b/toju-app/src/app/shared/components/profile-card/profile-card.component.html index e616f0e..54d2eb7 100644 --- a/toju-app/src/app/shared/components/profile-card/profile-card.component.html +++ b/toju-app/src/app/shared/components/profile-card/profile-card.component.html @@ -2,63 +2,109 @@ class="w-72 rounded-lg border border-border bg-card shadow-xl" style="animation: profile-card-in 120ms cubic-bezier(0.2, 0, 0, 1) both" > -
+ @let profileUser = user(); + @let isEditable = editable(); + @let activeField = editingField(); + @let statusColor = currentStatusColor(); + @let statusLabel = currentStatusLabel(); + +
-
- @if (editable()) { - - - } @else { +
+ +
-
-

{{ user().displayName }}

-

{{ user().username }}

+
+ @if (isEditable) { +
+
+ @if (activeField === 'displayName') { + + } @else { + + } - @if (editable()) { -

Click avatar to upload and crop a profile picture.

+

{{ profileUser.username }}

+
+ +
+ @if (activeField === 'description') { + + } @else { + + } +
+
+ } @else { +

{{ profileUser.displayName }}

+

{{ profileUser.username }}

+ + @if (profileUser.description) { +

{{ profileUser.description }}

+ } } @if (avatarError()) { -
+
{{ avatarError() }}
} - @if (editable()) { -
+ @if (isEditable) { +
}
diff --git a/toju-app/src/app/shared/components/profile-card/profile-card.component.ts b/toju-app/src/app/shared/components/profile-card/profile-card.component.ts index c9c7b98..416af8d 100644 --- a/toju-app/src/app/shared/components/profile-card/profile-card.component.ts +++ b/toju-app/src/app/shared/components/profile-card/profile-card.component.ts @@ -1,5 +1,6 @@ import { Component, + effect, inject, signal } from '@angular/core'; @@ -37,6 +38,9 @@ export class ProfileCardComponent { readonly avatarAccept = PROFILE_AVATAR_ACCEPT_ATTRIBUTE; readonly avatarError = signal(null); readonly avatarSaving = signal(false); + readonly editingField = signal<'displayName' | 'description' | null>(null); + readonly displayNameDraft = signal(''); + readonly descriptionDraft = signal(''); readonly statusOptions: { value: UserStatus | null; label: string; color: string }[] = [ { value: null, label: 'Online', color: 'bg-green-500' }, @@ -49,6 +53,19 @@ export class ProfileCardComponent { private readonly store = inject(Store); private readonly profileAvatar = inject(ProfileAvatarFacade); private readonly profileAvatarEditor = inject(ProfileAvatarEditorService); + private readonly syncProfileDrafts = effect(() => { + const user = this.user(); + const editingField = this.editingField(); + + if (editingField !== 'displayName') { + this.displayNameDraft.set(user.displayName || ''); + } + + if (editingField !== 'description') { + this.descriptionDraft.set(user.description || ''); + } + + }, { allowSignalWrites: true }); currentStatusColor(): string { switch (this.user().status) { @@ -81,6 +98,31 @@ export class ProfileCardComponent { this.showStatusMenu.set(false); } + onDisplayNameInput(event: Event): void { + this.displayNameDraft.set((event.target as HTMLInputElement).value); + } + + onDescriptionInput(event: Event): void { + this.descriptionDraft.set((event.target as HTMLTextAreaElement).value); + } + + startEdit(field: 'displayName' | 'description'): void { + if (!this.editable() || this.editingField() === field) { + return; + } + + this.editingField.set(field); + } + + finishEdit(field: 'displayName' | 'description'): void { + if (this.editingField() !== field) { + return; + } + + this.commitProfileDrafts(); + this.editingField.set(null); + } + pickAvatar(fileInput: HTMLInputElement): void { if (!this.editable() || this.avatarSaving()) { return; @@ -147,4 +189,49 @@ export class ProfileCardComponent { this.avatarSaving.set(false); } } + + private commitProfileDrafts(): void { + if (!this.editable()) { + return; + } + + const displayName = this.normalizeDisplayName(this.displayNameDraft()); + + if (!displayName) { + this.displayNameDraft.set(this.user().displayName || ''); + return; + } + + const user = this.user(); + const description = this.normalizeDescription(this.descriptionDraft()); + + if ( + displayName === this.normalizeDisplayName(user.displayName) + && description === this.normalizeDescription(user.description) + ) { + return; + } + + const profile = { + displayName, + description, + profileUpdatedAt: Date.now() + }; + + this.store.dispatch(UsersActions.updateCurrentUserProfile({ profile })); + this.user.update((user) => ({ + ...user, + ...profile + })); + } + + private normalizeDisplayName(value: string | undefined): string { + return value?.trim().replace(/\s+/g, ' ') || ''; + } + + private normalizeDescription(value: string | undefined): string | undefined { + const normalized = value?.trim(); + + return normalized || undefined; + } } diff --git a/toju-app/src/app/store/rooms/room-members-sync.effects.spec.ts b/toju-app/src/app/store/rooms/room-members-sync.effects.spec.ts new file mode 100644 index 0000000..e59190d --- /dev/null +++ b/toju-app/src/app/store/rooms/room-members-sync.effects.spec.ts @@ -0,0 +1,103 @@ +import { RoomMember, User } from '../../shared-kernel'; +import { UsersActions } from '../users/users.actions'; +import { buildRosterAvatarBackfillActions } from './room-members-sync.effects'; + +function createMember(overrides: Partial = {}): RoomMember { + return { + id: 'user-1', + oderId: 'oder-1', + username: 'alice', + displayName: 'Alice', + role: 'member', + joinedAt: Date.now(), + lastSeenAt: Date.now(), + ...overrides + }; +} + +function createCurrentUser(overrides: Partial = {}): User { + return { + id: 'current-user', + oderId: 'current-oder', + username: 'current', + displayName: 'Current User', + status: 'online', + role: 'member', + joinedAt: Date.now(), + ...overrides + }; +} + +describe('room member roster avatar backfill', () => { + it('creates avatar upsert actions from roster members with avatar data', () => { + const actions = buildRosterAvatarBackfillActions([ + createMember({ + avatarUrl: 'data:image/gif;base64,abc', + avatarHash: 'hash-1', + avatarMime: 'image/gif', + avatarUpdatedAt: 123 + }) + ], null); + + expect(actions).toEqual([ + UsersActions.upsertRemoteUserAvatar({ + user: { + id: 'user-1', + oderId: 'oder-1', + username: 'alice', + displayName: 'Alice', + description: undefined, + profileUpdatedAt: undefined, + avatarUrl: 'data:image/gif;base64,abc', + avatarHash: 'hash-1', + avatarMime: 'image/gif', + avatarUpdatedAt: 123 + } + }) + ]); + }); + + it('creates avatar upsert actions from roster members with only profile metadata', () => { + const actions = buildRosterAvatarBackfillActions([ + createMember({ + description: 'Synced from roster', + profileUpdatedAt: 456 + }) + ], null); + + expect(actions).toEqual([ + UsersActions.upsertRemoteUserAvatar({ + user: { + id: 'user-1', + oderId: 'oder-1', + username: 'alice', + displayName: 'Alice', + description: 'Synced from roster', + profileUpdatedAt: 456, + avatarUrl: undefined, + avatarHash: undefined, + avatarMime: undefined, + avatarUpdatedAt: undefined + } + }) + ]); + }); + + it('skips the current user and members without syncable data', () => { + const currentUser = createCurrentUser(); + const actions = buildRosterAvatarBackfillActions([ + createMember({ + id: currentUser.id, + oderId: currentUser.oderId + }), + createMember({ + id: 'user-2', + oderId: 'oder-2', + username: 'bob', + displayName: 'Bob' + }) + ], currentUser); + + expect(actions).toEqual([]); + }); +}); \ No newline at end of file diff --git a/toju-app/src/app/store/rooms/room-members-sync.effects.ts b/toju-app/src/app/store/rooms/room-members-sync.effects.ts index 9a19b74..d297813 100644 --- a/toju-app/src/app/store/rooms/room-members-sync.effects.ts +++ b/toju-app/src/app/store/rooms/room-members-sync.effects.ts @@ -37,6 +37,41 @@ import { upsertRoomMember } from './room-members.helpers'; +export function buildRosterAvatarBackfillActions( + members: RoomMember[], + currentUser: Pick | null | undefined +): ReturnType[] { + const currentUserId = currentUser?.oderId || currentUser?.id; + + return members.flatMap((member) => { + const memberId = member.oderId || member.id; + const hasProfileData = !!member.avatarUrl + || !!member.avatarHash + || !!member.avatarUpdatedAt + || !!member.profileUpdatedAt + || typeof member.description === 'string'; + + if (!memberId || memberId === currentUserId || !hasProfileData) { + return []; + } + + return [UsersActions.upsertRemoteUserAvatar({ + user: { + id: member.id, + oderId: memberId, + username: member.username, + displayName: member.displayName, + description: member.description, + profileUpdatedAt: member.profileUpdatedAt, + avatarUrl: member.avatarUrl, + avatarHash: member.avatarHash, + avatarMime: member.avatarMime, + avatarUpdatedAt: member.avatarUpdatedAt + } + })]; + }); +} + @Injectable() export class RoomMembersSyncEffects { private readonly actions$ = inject(Actions); @@ -394,7 +429,10 @@ export class RoomMembersSyncEffects { ); } - return this.createRoomMemberUpdateActions(room, members); + return [ + ...this.createRoomMemberUpdateActions(room, members), + ...buildRosterAvatarBackfillActions(members, currentUser) + ]; } private handleMemberLeave( diff --git a/toju-app/src/app/store/rooms/room-members.helpers.ts b/toju-app/src/app/store/rooms/room-members.helpers.ts index 9fab191..b5a8507 100644 --- a/toju-app/src/app/store/rooms/room-members.helpers.ts +++ b/toju-app/src/app/store/rooms/room-members.helpers.ts @@ -36,6 +36,51 @@ function normalizeAvatarUpdatedAt(value: unknown): number | undefined { : undefined; } +function normalizeProfileUpdatedAt(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? value + : undefined; +} + +function normalizeDescription(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const normalized = value.trim(); + + return normalized || undefined; +} + +function hasOwnProperty(object: object, key: string): boolean { + return Object.prototype.hasOwnProperty.call(object, key); +} + +function mergeProfileFields( + existingMember: Pick, + incomingMember: Pick, + preferIncomingFallback: boolean +): Pick { + const existingUpdatedAt = existingMember.profileUpdatedAt ?? 0; + const incomingUpdatedAt = incomingMember.profileUpdatedAt ?? 0; + const preferIncoming = incomingUpdatedAt === existingUpdatedAt + ? preferIncomingFallback + : incomingUpdatedAt > existingUpdatedAt; + const incomingHasDescription = hasOwnProperty(incomingMember, 'description'); + const incomingDescription = normalizeDescription(incomingMember.description); + const existingDescription = normalizeDescription(existingMember.description); + + return { + displayName: preferIncoming + ? (incomingMember.displayName || existingMember.displayName) + : (existingMember.displayName || incomingMember.displayName), + description: preferIncoming + ? (incomingHasDescription ? incomingDescription : existingDescription) + : existingDescription, + profileUpdatedAt: Math.max(existingUpdatedAt, incomingUpdatedAt) || undefined + }; +} + function mergeAvatarFields( existingMember: Pick, incomingMember: Pick, @@ -73,12 +118,12 @@ function normalizeMember(member: RoomMember, now = Date.now()): RoomMember { typeof member.joinedAt === 'number' && Number.isFinite(member.joinedAt) ? member.joinedAt : lastSeenAt; - - return { + const nextMember: RoomMember = { id: member.id || key, oderId: member.oderId || undefined, username: member.username || fallbackUsername(member), displayName: fallbackDisplayName(member), + profileUpdatedAt: normalizeProfileUpdatedAt(member.profileUpdatedAt), avatarUrl: member.avatarUrl || undefined, avatarHash: member.avatarHash || undefined, avatarMime: member.avatarMime || undefined, @@ -88,6 +133,12 @@ function normalizeMember(member: RoomMember, now = Date.now()): RoomMember { joinedAt, lastSeenAt }; + + if (hasOwnProperty(member, 'description')) { + nextMember.description = normalizeDescription(member.description); + } + + return nextMember; } function compareMembers(firstMember: RoomMember, secondMember: RoomMember): number { @@ -128,6 +179,7 @@ function mergeMembers( const normalizedExisting = normalizeMember(existingMember, now); const preferIncoming = normalizedIncoming.lastSeenAt >= normalizedExisting.lastSeenAt; + const profileFields = mergeProfileFields(normalizedExisting, normalizedIncoming, preferIncoming); const avatarFields = mergeAvatarFields(normalizedExisting, normalizedIncoming, preferIncoming); return { @@ -136,9 +188,7 @@ function mergeMembers( username: preferIncoming ? (normalizedIncoming.username || normalizedExisting.username) : (normalizedExisting.username || normalizedIncoming.username), - displayName: preferIncoming - ? (normalizedIncoming.displayName || normalizedExisting.displayName) - : (normalizedExisting.displayName || normalizedIncoming.displayName), + ...profileFields, ...avatarFields, role: mergeRole(normalizedExisting.role, normalizedIncoming.role, preferIncoming), roleIds: preferIncoming @@ -177,6 +227,8 @@ export function roomMemberFromUser( oderId: user.oderId || undefined, username: user.username || '', displayName: user.displayName || user.username || 'User', + description: user.description, + profileUpdatedAt: user.profileUpdatedAt, avatarUrl: user.avatarUrl, avatarHash: user.avatarHash, avatarMime: user.avatarMime, diff --git a/toju-app/src/app/store/rooms/room-signaling-connection.ts b/toju-app/src/app/store/rooms/room-signaling-connection.ts index 6e15d64..6cd33c9 100644 --- a/toju-app/src/app/store/rooms/room-signaling-connection.ts +++ b/toju-app/src/app/store/rooms/room-signaling-connection.ts @@ -353,6 +353,8 @@ export class RoomSignalingConnection { const wsUrl = this.serverDirectory.getWebSocketUrl(selector); const oderId = resolvedOderId || user?.oderId || this.webrtc.peerId(); const displayName = resolveUserDisplayName(user); + const description = user?.description; + const profileUpdatedAt = user?.profileUpdatedAt; const sameSignalRooms = this.getRoomsForSignalingUrl(this.includeRoom(savedRooms, room), wsUrl); const backgroundRooms = sameSignalRooms.filter((candidate) => candidate.id !== room.id); const joinCurrentEndpointRooms = () => { @@ -361,7 +363,10 @@ export class RoomSignalingConnection { } this.webrtc.setCurrentServer(room.id); - this.webrtc.identify(oderId, displayName, wsUrl); + this.webrtc.identify(oderId, displayName, wsUrl, { + description, + profileUpdatedAt + }); for (const backgroundRoom of backgroundRooms) { this.webrtc.joinRoom(backgroundRoom.id, oderId, wsUrl); 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 db25c2a..50b38df 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 @@ -113,6 +113,8 @@ export class RoomStateSyncEffects { .map((user) => buildSignalingUser(user, { ...buildKnownUserExtras(room, user.oderId), + description: user.description, + profileUpdatedAt: user.profileUpdatedAt, presenceServerIds: [signalingMessage.serverId], ...(user.status ? { status: user.status } : {}) }) @@ -141,12 +143,16 @@ export class RoomStateSyncEffects { const joinedUser = { oderId: signalingMessage.oderId, displayName: signalingMessage.displayName, + description: signalingMessage.description, + profileUpdatedAt: signalingMessage.profileUpdatedAt, status: signalingMessage.status }; const actions: Action[] = [ UsersActions.userJoined({ user: buildSignalingUser(joinedUser, { ...buildKnownUserExtras(room, joinedUser.oderId), + description: joinedUser.description, + profileUpdatedAt: joinedUser.profileUpdatedAt, presenceServerIds: [signalingMessage.serverId] }) }) diff --git a/toju-app/src/app/store/rooms/rooms.helpers.ts b/toju-app/src/app/store/rooms/rooms.helpers.ts index ba0ec50..a770436 100644 --- a/toju-app/src/app/store/rooms/rooms.helpers.ts +++ b/toju-app/src/app/store/rooms/rooms.helpers.ts @@ -48,6 +48,8 @@ export function buildKnownUserExtras(room: Room | null, identifier: string): Rec return { username: knownMember.username, + description: knownMember.description, + profileUpdatedAt: knownMember.profileUpdatedAt, avatarUrl: knownMember.avatarUrl, avatarHash: knownMember.avatarHash, avatarMime: knownMember.avatarMime, @@ -194,8 +196,10 @@ export interface RoomPresenceSignalingMessage { reason?: string; serverId?: string; serverIds?: string[]; - users?: { oderId: string; displayName: string; status?: string }[]; + users?: { oderId: string; displayName: string; description?: string; profileUpdatedAt?: number; status?: string }[]; oderId?: string; displayName?: string; + description?: string; + profileUpdatedAt?: number; status?: string; } diff --git a/toju-app/src/app/store/users/user-avatar.effects.spec.ts b/toju-app/src/app/store/users/user-avatar.effects.spec.ts index e521ad3..a8a4ad3 100644 --- a/toju-app/src/app/store/users/user-avatar.effects.spec.ts +++ b/toju-app/src/app/store/users/user-avatar.effects.spec.ts @@ -58,6 +58,17 @@ describe('user avatar sync helpers', () => { })).toBe(false); }); + it('requests profile data when the remote profile version is newer', () => { + const existingUser = createUser({ + profileUpdatedAt: 100 + }); + + expect(shouldRequestAvatarData(existingUser, { + avatarUpdatedAt: 0, + profileUpdatedAt: 200 + })).toBe(true); + }); + it('applies equal-version transfers when the local payload is missing', () => { const existingUser = createUser({ avatarHash: 'hash-1', @@ -70,6 +81,19 @@ describe('user avatar sync helpers', () => { })).toBe(true); }); + it('applies transfers when the remote profile version is newer', () => { + const existingUser = createUser({ + displayName: 'Alice', + profileUpdatedAt: 100 + }); + + expect(shouldApplyAvatarTransfer(existingUser, { + hash: undefined, + profileUpdatedAt: 250, + updatedAt: 0 + })).toBe(true); + }); + it('rejects older avatar transfers', () => { const existingUser = createUser({ avatarUrl: 'data:image/gif;base64,current', diff --git a/toju-app/src/app/store/users/user-avatar.effects.ts b/toju-app/src/app/store/users/user-avatar.effects.ts index d4a0eee..0478db0 100644 --- a/toju-app/src/app/store/users/user-avatar.effects.ts +++ b/toju-app/src/app/store/users/user-avatar.effects.ts @@ -36,8 +36,10 @@ import { findRoomMember } from '../rooms/room-members.helpers'; interface PendingAvatarTransfer { displayName: string; - mime: string; + description?: string; + mime?: string; oderId: string; + profileUpdatedAt?: number; total: number; updatedAt: number; username: string; @@ -45,7 +47,7 @@ interface PendingAvatarTransfer { hash?: string; } -type AvatarVersionState = Pick | undefined; +type AvatarVersionState = Pick | undefined; function shouldAcceptAvatarPayload( existingUser: AvatarVersionState, @@ -69,18 +71,51 @@ function shouldAcceptAvatarPayload( return !!incomingHash && incomingHash !== existingUser.avatarHash; } +function shouldAcceptProfilePayload( + existingUser: AvatarVersionState, + incomingUpdatedAt: number +): boolean { + const localUpdatedAt = existingUser?.profileUpdatedAt ?? 0; + + if (incomingUpdatedAt > localUpdatedAt) { + return true; + } + + if (incomingUpdatedAt < localUpdatedAt || incomingUpdatedAt === 0) { + return false; + } + + return !existingUser?.displayName?.trim(); +} + +function shouldAcceptUserPayload( + existingUser: AvatarVersionState, + incoming: Pick +): boolean { + return shouldAcceptAvatarPayload(existingUser, incoming.avatarUpdatedAt ?? 0, incoming.avatarHash) + || shouldAcceptProfilePayload(existingUser, incoming.profileUpdatedAt ?? 0); +} + +function hasSyncableProfileData(user: Pick | null | undefined): boolean { + return (user?.avatarUpdatedAt ?? 0) > 0 || (user?.profileUpdatedAt ?? 0) > 0; +} + export function shouldRequestAvatarData( existingUser: AvatarVersionState, - incomingAvatar: Pick + incomingAvatar: Pick ): boolean { - return shouldAcceptAvatarPayload(existingUser, incomingAvatar.avatarUpdatedAt ?? 0, incomingAvatar.avatarHash); + return shouldAcceptUserPayload(existingUser, incomingAvatar); } export function shouldApplyAvatarTransfer( existingUser: AvatarVersionState, - transfer: Pick + transfer: Pick ): boolean { - return shouldAcceptAvatarPayload(existingUser, transfer.updatedAt, transfer.hash); + return shouldAcceptUserPayload(existingUser, { + avatarHash: transfer.hash, + avatarUpdatedAt: transfer.updatedAt, + profileUpdatedAt: transfer.profileUpdatedAt + }); } @Injectable() @@ -114,29 +149,41 @@ export class UserAvatarEffects { withLatestFrom(this.store.select(selectAllUsers)), tap(([{ user }, allUsers]) => { const mergedUser = allUsers.find((entry) => entry.id === user.id || entry.oderId === user.oderId); - const avatarUrl = mergedUser?.avatarUrl ?? user.avatarUrl; + const userToPersist = mergedUser ?? { + id: user.id, + oderId: user.oderId, + username: user.username, + displayName: user.displayName, + description: user.description, + profileUpdatedAt: user.profileUpdatedAt, + avatarUrl: user.avatarUrl, + avatarHash: user.avatarHash, + avatarMime: user.avatarMime, + avatarUpdatedAt: user.avatarUpdatedAt, + status: 'offline' as const, + role: 'member' as const, + joinedAt: Date.now() + }; - if (!avatarUrl) { + this.db.saveUser(userToPersist); + + if (!user.avatarUrl) { return; } - if (mergedUser) { - this.db.saveUser(mergedUser); - } - void this.avatars.persistAvatarDataUrl({ - id: mergedUser?.id || user.id, - username: mergedUser?.username || user.username, - displayName: mergedUser?.displayName || user.displayName - }, avatarUrl); + id: userToPersist.id, + username: userToPersist.username, + displayName: userToPersist.displayName + }, user.avatarUrl); }) ), { dispatch: false } ); - syncRoomMemberAvatars$ = createEffect(() => + syncRoomMemberProfiles$ = createEffect(() => this.actions$.pipe( - ofType(UsersActions.updateCurrentUserAvatar, UsersActions.upsertRemoteUserAvatar), + ofType(UsersActions.updateCurrentUserAvatar, UsersActions.updateCurrentUserProfile, UsersActions.upsertRemoteUserAvatar), withLatestFrom( this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom), @@ -148,28 +195,28 @@ export class UserAvatarEffects { currentRoom, savedRooms ]) => { - const avatarOwner = action.type === UsersActions.updateCurrentUserAvatar.type - ? currentUser - : ('user' in action ? action.user : null); + const avatarOwner = action.type === UsersActions.upsertRemoteUserAvatar.type + ? action.user + : currentUser; if (!avatarOwner) { return EMPTY; } - const actions = this.buildRoomAvatarActions(avatarOwner, currentRoom, savedRooms); + const actions = this.buildRoomProfileActions(avatarOwner, currentRoom, savedRooms); return actions.length > 0 ? actions : EMPTY; }) ) ); - broadcastCurrentAvatarSummary$ = createEffect( + broadcastCurrentProfileSummary$ = createEffect( () => this.actions$.pipe( - ofType(UsersActions.updateCurrentUserAvatar), + ofType(UsersActions.updateCurrentUserAvatar, UsersActions.updateCurrentUserProfile), withLatestFrom(this.store.select(selectCurrentUser)), tap(([, currentUser]) => { - if (!currentUser?.avatarUpdatedAt) { + if (!currentUser || !hasSyncableProfileData(currentUser)) { return; } @@ -184,7 +231,7 @@ export class UserAvatarEffects { this.webrtc.onPeerConnected.pipe( withLatestFrom(this.store.select(selectCurrentUser)), tap(([peerId, currentUser]) => { - if (!currentUser?.avatarUpdatedAt) { + if (!currentUser || !hasSyncableProfileData(currentUser)) { return; } @@ -210,7 +257,7 @@ export class UserAvatarEffects { return this.handleAvatarRequest(event, currentUser ?? null); case 'user-avatar-full': - return this.handleAvatarFull(event); + return this.handleAvatarFull(event, allUsers); case 'user-avatar-chunk': return this.handleAvatarChunk(event, allUsers); @@ -222,12 +269,14 @@ export class UserAvatarEffects { ) ); - private buildAvatarSummary(user: Pick): ChatEvent { + private buildAvatarSummary(user: Pick): ChatEvent { return { type: 'user-avatar-summary', oderId: user.oderId || user.id, username: user.username, displayName: user.displayName, + description: user.description, + profileUpdatedAt: user.profileUpdatedAt, avatarHash: user.avatarHash, avatarMime: user.avatarMime, avatarUpdatedAt: user.avatarUpdatedAt || 0 @@ -235,7 +284,7 @@ export class UserAvatarEffects { } private handleAvatarSummary(event: ChatEvent, allUsers: User[]) { - if (!event.fromPeerId || !event.oderId || !event.avatarUpdatedAt) { + if (!event.fromPeerId || !event.oderId || (!event.avatarUpdatedAt && !event.profileUpdatedAt)) { return EMPTY; } @@ -256,23 +305,46 @@ export class UserAvatarEffects { private handleAvatarRequest(event: ChatEvent, currentUser: User | null) { const currentUserKey = currentUser?.oderId || currentUser?.id; - if (!event.fromPeerId || !currentUser || !currentUserKey || event.oderId !== currentUserKey || !currentUser.avatarUrl) { + if (!event.fromPeerId || !currentUser || !currentUserKey || event.oderId !== currentUserKey || !hasSyncableProfileData(currentUser)) { return EMPTY; } return from(this.sendAvatarToPeer(event.fromPeerId, currentUser)).pipe(mergeMap(() => EMPTY)); } - private handleAvatarFull(event: ChatEvent) { - if (!event.oderId || !event.avatarMime || typeof event.total !== 'number' || event.total < 1) { + private handleAvatarFull(event: ChatEvent, allUsers: User[]) { + if (!event.oderId || typeof event.total !== 'number' || event.total < 0) { + return EMPTY; + } + + if (event.total === 0) { + return from(this.buildRemoteAvatarAction({ + chunks: [], + description: event.description, + displayName: event.displayName || 'User', + mime: event.avatarMime, + oderId: event.oderId, + profileUpdatedAt: event.profileUpdatedAt, + total: 0, + updatedAt: event.avatarUpdatedAt || 0, + username: event.username || (event.displayName || 'User').toLowerCase().replace(/\s+/g, '_'), + hash: event.avatarHash + }, allUsers)).pipe( + mergeMap((action) => action ? of(action) : EMPTY) + ); + } + + if (!event.avatarMime) { return EMPTY; } this.pendingTransfers.set(event.oderId, { chunks: new Array(event.total), + description: event.description, displayName: event.displayName || 'User', mime: event.avatarMime, oderId: event.oderId, + profileUpdatedAt: event.profileUpdatedAt, total: event.total, updatedAt: event.avatarUpdatedAt || Date.now(), username: event.username || (event.displayName || 'User').toLowerCase().replace(/\s+/g, '_'), @@ -313,25 +385,31 @@ export class UserAvatarEffects { return null; } - const blob = new Blob(transfer.chunks.map((chunk) => this.decodeBase64ToArrayBuffer(chunk!)), { type: transfer.mime }); - const dataUrl = await this.readBlobAsDataUrl(blob); + const dataUrl = transfer.total > 0 + ? await this.readBlobAsDataUrl(new Blob( + transfer.chunks.map((chunk) => this.decodeBase64ToArrayBuffer(chunk!)), + { type: transfer.mime || 'image/webp' } + )) + : undefined; return UsersActions.upsertRemoteUserAvatar({ user: { id: existingUser?.id || transfer.oderId, oderId: existingUser?.oderId || transfer.oderId, username: existingUser?.username || transfer.username, - displayName: existingUser?.displayName || transfer.displayName, + displayName: transfer.displayName || existingUser?.displayName || 'User', + description: transfer.description, + profileUpdatedAt: transfer.profileUpdatedAt, avatarUrl: dataUrl, avatarHash: transfer.hash, avatarMime: transfer.mime, - avatarUpdatedAt: transfer.updatedAt + avatarUpdatedAt: transfer.updatedAt || undefined } }); } - private buildRoomAvatarActions( - avatarOwner: Pick, + private buildRoomProfileActions( + avatarOwner: Pick, currentRoom: ReturnType | null, savedRooms: ReturnType ): Action[] { @@ -353,6 +431,9 @@ export class UserAvatarEffects { return { ...roomMember, + displayName: avatarOwner.displayName, + description: avatarOwner.description, + profileUpdatedAt: avatarOwner.profileUpdatedAt, avatarUrl: avatarOwner.avatarUrl, avatarHash: avatarOwner.avatarHash, avatarMime: avatarOwner.avatarMime, @@ -370,25 +451,29 @@ export class UserAvatarEffects { } private async sendAvatarToPeer(targetPeerId: string, user: User): Promise { - if (!user.avatarUrl) { - return; - } - - const blob = await this.dataUrlToBlob(user.avatarUrl, user.avatarMime || 'image/webp'); - const total = Math.ceil(blob.size / P2P_BASE64_CHUNK_SIZE_BYTES); const userKey = user.oderId || user.id; + const blob = user.avatarUrl + ? await this.dataUrlToBlob(user.avatarUrl, user.avatarMime || 'image/webp') + : null; + const total = blob ? Math.ceil(blob.size / P2P_BASE64_CHUNK_SIZE_BYTES) : 0; this.webrtc.sendToPeer(targetPeerId, { type: 'user-avatar-full', oderId: userKey, username: user.username, displayName: user.displayName, + description: user.description, + profileUpdatedAt: user.profileUpdatedAt, avatarHash: user.avatarHash, - avatarMime: user.avatarMime || blob.type || 'image/webp', - avatarUpdatedAt: user.avatarUpdatedAt || Date.now(), + avatarMime: blob ? (user.avatarMime || blob.type || 'image/webp') : undefined, + avatarUpdatedAt: user.avatarUpdatedAt || 0, total }); + if (!blob) { + return; + } + for await (const chunk of iterateBlobChunks(blob, P2P_BASE64_CHUNK_SIZE_BYTES)) { await this.webrtc.sendToPeerBuffered(targetPeerId, { type: 'user-avatar-chunk', diff --git a/toju-app/src/app/store/users/users-status.reducer.spec.ts b/toju-app/src/app/store/users/users-status.reducer.spec.ts index a70fd06..c4a3211 100644 --- a/toju-app/src/app/store/users/users-status.reducer.spec.ts +++ b/toju-app/src/app/store/users/users-status.reducer.spec.ts @@ -140,6 +140,74 @@ describe('users reducer - status', () => { expect(state.entities['remote-1']?.avatarHash).toBe('hash-newer'); expect(state.entities['remote-1']?.avatarUpdatedAt).toBe(200); }); + + it('updates the current user profile metadata', () => { + const state = usersReducer(baseState, UsersActions.updateCurrentUserProfile({ + profile: { + displayName: 'Updated User', + description: 'New description', + profileUpdatedAt: 4567 + } + })); + + expect(state.entities['user-1']?.displayName).toBe('Updated User'); + expect(state.entities['user-1']?.description).toBe('New description'); + expect(state.entities['user-1']?.profileUpdatedAt).toBe(4567); + }); + + it('keeps newer remote profile text when stale profile data arrives later', () => { + const withRemote = usersReducer( + baseState, + UsersActions.upsertRemoteUserAvatar({ + user: { + id: 'remote-1', + oderId: 'oder-remote-1', + username: 'remote', + displayName: 'Remote Newer', + description: 'Newest bio', + profileUpdatedAt: 300 + } + }) + ); + const state = usersReducer( + withRemote, + UsersActions.upsertRemoteUserAvatar({ + user: { + id: 'remote-1', + oderId: 'oder-remote-1', + username: 'remote', + displayName: 'Remote Older', + description: 'Old bio', + profileUpdatedAt: 100 + } + }) + ); + + expect(state.entities['remote-1']?.displayName).toBe('Remote Newer'); + expect(state.entities['remote-1']?.description).toBe('Newest bio'); + expect(state.entities['remote-1']?.profileUpdatedAt).toBe(300); + }); + + it('allows remote profile-only sync updates without avatar bytes', () => { + const state = usersReducer( + baseState, + UsersActions.upsertRemoteUserAvatar({ + user: { + id: 'remote-2', + oderId: 'oder-remote-2', + username: 'remote2', + displayName: 'Remote Profile', + description: 'Profile only sync', + profileUpdatedAt: 700 + } + }) + ); + + expect(state.entities['remote-2']?.displayName).toBe('Remote Profile'); + expect(state.entities['remote-2']?.description).toBe('Profile only sync'); + expect(state.entities['remote-2']?.profileUpdatedAt).toBe(700); + expect(state.entities['remote-2']?.avatarUrl).toBeUndefined(); + }); }); describe('presence-aware user with status', () => { diff --git a/toju-app/src/app/store/users/users.actions.ts b/toju-app/src/app/store/users/users.actions.ts index 246f3b9..36f42c3 100644 --- a/toju-app/src/app/store/users/users.actions.ts +++ b/toju-app/src/app/store/users/users.actions.ts @@ -61,7 +61,8 @@ export const UsersActions = createActionGroup({ 'Set Manual Status': props<{ status: UserStatus | null }>(), 'Update Remote User Status': props<{ userId: string; status: UserStatus }>(), + 'Update Current User Profile': props<{ profile: { displayName: string; description?: string; profileUpdatedAt: number } }>(), 'Update Current User Avatar': props<{ avatar: { avatarUrl: string; avatarHash: string; avatarMime: string; avatarUpdatedAt: number } }>(), - 'Upsert Remote User Avatar': props<{ user: { id: string; oderId: string; username: string; displayName: string; avatarUrl: string; avatarHash?: string; avatarMime?: string; avatarUpdatedAt?: number } }>() + 'Upsert Remote User Avatar': props<{ user: { id: string; oderId: string; username: string; displayName: string; description?: string; profileUpdatedAt?: number; avatarUrl?: string; avatarHash?: string; avatarMime?: string; avatarUpdatedAt?: number } }>() } }); diff --git a/toju-app/src/app/store/users/users.effects.ts b/toju-app/src/app/store/users/users.effects.ts index 5ad813e..aecda71 100644 --- a/toju-app/src/app/store/users/users.effects.ts +++ b/toju-app/src/app/store/users/users.effects.ts @@ -429,7 +429,8 @@ export class UsersEffects { ofType( UsersActions.setCurrentUser, UsersActions.loadCurrentUserSuccess, - UsersActions.updateCurrentUser + UsersActions.updateCurrentUser, + UsersActions.updateCurrentUserProfile ), withLatestFrom(this.store.select(selectCurrentUser)), tap(([, user]) => { @@ -449,14 +450,18 @@ export class UsersEffects { this.actions$.pipe( ofType( UsersActions.setCurrentUser, - UsersActions.loadCurrentUserSuccess + UsersActions.loadCurrentUserSuccess, + UsersActions.updateCurrentUserProfile ), withLatestFrom(this.store.select(selectCurrentUser)), tap(([, user]) => { if (!user) return; - this.webrtc.identify(user.oderId || user.id, this.resolveDisplayName(user)); + this.webrtc.identify(user.oderId || user.id, this.resolveDisplayName(user), undefined, { + description: user.description, + profileUpdatedAt: user.profileUpdatedAt + }); }) ), { dispatch: false } diff --git a/toju-app/src/app/store/users/users.reducer.ts b/toju-app/src/app/store/users/users.reducer.ts index 36f1bb8..c8b4ea0 100644 --- a/toju-app/src/app/store/users/users.reducer.ts +++ b/toju-app/src/app/store/users/users.reducer.ts @@ -37,6 +37,69 @@ interface AvatarFields { avatarUpdatedAt?: number; } +interface ProfileFields { + displayName: string; + description?: string; + profileUpdatedAt?: number; +} + +function hasOwnProperty(object: object, key: string): boolean { + return Object.prototype.hasOwnProperty.call(object, key); +} + +function normalizeProfileUpdatedAt(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? value + : undefined; +} + +function normalizeDisplayName(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const normalized = value.trim().replace(/\s+/g, ' '); + + return normalized || undefined; +} + +function normalizeDescription(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const normalized = value.trim(); + + return normalized || undefined; +} + +function mergeProfileFields( + existingValue: Partial | undefined, + incomingValue: Partial, + preferIncomingFallback = true +): ProfileFields { + const existingUpdatedAt = normalizeProfileUpdatedAt(existingValue?.profileUpdatedAt) ?? 0; + const incomingUpdatedAt = normalizeProfileUpdatedAt(incomingValue.profileUpdatedAt) ?? 0; + const preferIncoming = incomingUpdatedAt === existingUpdatedAt + ? preferIncomingFallback + : incomingUpdatedAt > existingUpdatedAt; + const existingDisplayName = normalizeDisplayName(existingValue?.displayName); + const incomingDisplayName = normalizeDisplayName(incomingValue.displayName); + const existingDescription = normalizeDescription(existingValue?.description); + const incomingHasDescription = hasOwnProperty(incomingValue, 'description'); + const incomingDescription = normalizeDescription(incomingValue.description); + + return { + displayName: preferIncoming + ? (incomingDisplayName || existingDisplayName || 'User') + : (existingDisplayName || incomingDisplayName || 'User'), + description: preferIncoming + ? (incomingHasDescription ? incomingDescription : existingDescription) + : existingDescription, + profileUpdatedAt: Math.max(existingUpdatedAt, incomingUpdatedAt) || undefined + }; +} + function mergeAvatarFields( existingValue: AvatarFields | undefined, incomingValue: AvatarFields, @@ -112,10 +175,12 @@ function buildPresenceAwareUser(existingUser: User | undefined, incomingUser: Us ? incomingUser.status : (existingUser?.status && existingUser.status !== 'offline' ? existingUser.status : 'online')) : 'offline'; + const profileFields = mergeProfileFields(existingUser, incomingUser, true); return { ...existingUser, ...incomingUser, + ...profileFields, ...mergeAvatarFields(existingUser, incomingUser, true), presenceServerIds, isOnline, @@ -128,17 +193,21 @@ function buildAvatarUser(existingUser: User | undefined, incomingUser: { oderId: string; username: string; displayName: string; - avatarUrl: string; + description?: string; + profileUpdatedAt?: number; + avatarUrl?: string; avatarHash?: string; avatarMime?: string; avatarUpdatedAt?: number; }): User { + const profileFields = mergeProfileFields(existingUser, incomingUser, true); + return { ...existingUser, id: incomingUser.id, oderId: incomingUser.oderId, username: incomingUser.username || existingUser?.username || 'user', - displayName: incomingUser.displayName || existingUser?.displayName || 'User', + ...profileFields, status: existingUser?.status ?? 'offline', role: existingUser?.role ?? 'member', joinedAt: existingUser?.joinedAt ?? Date.now(), @@ -230,6 +299,18 @@ export const usersReducer = createReducer( state ); }), + on(UsersActions.updateCurrentUserProfile, (state, { profile }) => { + if (!state.currentUserId) + return state; + + return usersAdapter.updateOne( + { + id: state.currentUserId, + changes: mergeProfileFields(state.entities[state.currentUserId], profile, true) + }, + state + ); + }), on(UsersActions.updateCurrentUserAvatar, (state, { avatar }) => { if (!state.currentUserId) return state;