feat: Add user metadata changing display name and description with sync
All checks were successful
Queue Release Build / prepare (push) Successful in 28s
Deploy Web Apps / deploy (push) Successful in 5m2s
Queue Release Build / build-windows (push) Successful in 16m44s
Queue Release Build / build-linux (push) Successful in 27m12s
Queue Release Build / finalize (push) Successful in 22s

This commit is contained in:
2026-04-17 22:04:18 +02:00
parent 3ba8a2c9eb
commit bd21568726
41 changed files with 1176 additions and 191 deletions

View File

@@ -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',

View File

@@ -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<PersistentClient> {
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<void> {
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<void> {
await retryTransientNavigation(() => client.page.goto(roomUrl, { waitUntil: 'domcontentloaded' }));
@@ -332,18 +520,73 @@ async function waitForRoomReady(page: Page): Promise<void> {
await expect(page.locator('app-rooms-side-panel').last()).toBeVisible({ timeout: 15_000 });
}
async function waitForConnectedPeerCount(page: Page, count: number, timeout = 30_000): Promise<void> {
await page.waitForFunction((expectedCount) => {
const connections = (window as {
__rtcConnections?: RTCPeerConnection[];
}).__rtcConnections ?? [];
return connections.filter((connection) => connection.connectionState === 'connected').length >= expectedCount;
}, count, { timeout });
}
async function 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<void> {
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<void> {
await expect(getUserRow(page, displayName)).toBeVisible({ timeout: 20_000 });
}
async function expectProfileCardDetails(page: Page, profile: ProfileMetadata): Promise<void> {
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<void> {
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<void> {
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<void> {
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)}`;
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<string, unknown>, 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<string, unknown>, 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,

View File

@@ -105,6 +105,8 @@ export interface UserPayload {
oderId?: string;
username?: string;
displayName?: string;
description?: string;
profileUpdatedAt?: number;
avatarUrl?: string;
avatarHash?: string;
avatarMime?: string;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserProfileMetadata1000000000007 implements MigrationInterface {
name = 'AddUserProfileMetadata1000000000007';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
// SQLite column removal requires table rebuilds. Keep rollback no-op.
}
}

View File

@@ -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);
});
});

View File

@@ -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<void> {
@@ -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);

View File

@@ -6,6 +6,8 @@ export interface ConnectedUser {
serverIds: Set<string>;
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

View File

@@ -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,

View File

@@ -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/<username>/profile/profile.<ext>` 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.

View File

@@ -1,8 +1,8 @@
<div
class="fixed inset-0 z-[112] bg-black/70 backdrop-blur-sm"
(click)="cancelled.emit()"
(keydown.enter)="cancelled.emit()"
(keydown.space)="cancelled.emit()"
(click)="cancelled.emit(undefined)"
(keydown.enter)="cancelled.emit(undefined)"
(keydown.space)="cancelled.emit(undefined)"
role="button"
tabindex="0"
aria-label="Close profile image editor"
@@ -11,7 +11,6 @@
<div class="fixed inset-0 z-[113] flex items-center justify-center p-4 pointer-events-none">
<div
class="pointer-events-auto flex max-h-[calc(100vh-2rem)] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-2xl"
(click)="$event.stopPropagation()"
role="dialog"
aria-modal="true"
tabindex="-1"
@@ -135,7 +134,7 @@
<button
type="button"
class="rounded-lg bg-secondary px-4 py-2 text-sm text-foreground transition-colors hover:bg-secondary/80"
(click)="cancelled.emit()"
(click)="cancelled.emit(undefined)"
[disabled]="processing()"
>
Cancel

View File

@@ -27,7 +27,7 @@ import {
export class ProfileAvatarEditorComponent {
readonly source = input.required<EditableProfileAvatarSource>();
readonly cancelled = output<void>();
readonly cancelled = output<undefined>();
readonly confirmed = output<ProcessedProfileAvatar>();
readonly frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE;
@@ -53,7 +53,7 @@ export class ProfileAvatarEditorComponent {
@HostListener('document:keydown.escape')
onEscape(): void {
if (!this.processing()) {
this.cancelled.emit();
this.cancelled.emit(undefined);
}
}

View File

@@ -1,13 +1,7 @@
import { Injectable, inject } from '@angular/core';
import {
Overlay,
OverlayRef
} from '@angular/cdk/overlay';
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import {
EditableProfileAvatarSource,
ProcessedProfileAvatar
} from '../../domain/profile-avatar.models';
import { EditableProfileAvatarSource, ProcessedProfileAvatar } from '../../domain/profile-avatar.models';
import { ProfileAvatarEditorComponent } from './profile-avatar-editor.component';
export const PROFILE_AVATAR_EDITOR_OVERLAY_CLASS = 'profile-avatar-editor-overlay-pane';
@@ -25,7 +19,9 @@ export class ProfileAvatarEditorService {
const overlayRef = this.overlay.create({
disposeOnNavigation: true,
panelClass: PROFILE_AVATAR_EDITOR_OVERLAY_CLASS,
positionStrategy: this.overlay.position().global().centerHorizontally().centerVertically(),
positionStrategy: this.overlay.position().global()
.centerHorizontally()
.centerVertically(),
scrollStrategy: this.overlay.scrollStrategies.block()
});
@@ -55,7 +51,6 @@ export class ProfileAvatarEditorService {
overlayRef.dispose();
resolve(result);
};
const cancelSub = componentRef.instance.cancelled.subscribe(() => finish(null));
const confirmSub = componentRef.instance.confirmed.subscribe((avatar) => finish(avatar));
const detachSub = overlayRef.detachments().subscribe(() => finish(null));

View File

@@ -2,6 +2,6 @@ export * from './domain/profile-avatar.models';
export { ProfileAvatarFacade } from './application/services/profile-avatar.facade';
export { ProfileAvatarEditorComponent } from './feature/profile-avatar-editor/profile-avatar-editor.component';
export {
PROFILE_AVATAR_EDITOR_OVERLAY_CLASS,
ProfileAvatarEditorService
PROFILE_AVATAR_EDITOR_OVERLAY_CLASS,
ProfileAvatarEditorService
} from './feature/profile-avatar-editor/profile-avatar-editor.service';

View File

@@ -1,7 +1,5 @@
import {
isAnimatedGif,
isAnimatedWebp
} from './profile-avatar-image.service';
/* eslint-disable @stylistic/js/array-element-newline */
import { isAnimatedGif, isAnimatedWebp } from './profile-avatar-image.service';
describe('profile-avatar image animation detection', () => {
it('detects animated gifs with multiple frames', () => {

View File

@@ -1,10 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { User } from '../../../../shared-kernel';
import {
ProcessedProfileAvatar,
resolveProfileAvatarStorageFileName
} from '../../domain/profile-avatar.models';
import type { User } from '../../../../shared-kernel';
import { resolveProfileAvatarStorageFileName, type ProcessedProfileAvatar } from '../../domain/profile-avatar.models';
const LEGACY_PROFILE_FILE_NAMES = [
'profile.webp',

View File

@@ -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,

View File

@@ -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.

View File

@@ -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);
}
/**

View File

@@ -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. */

View File

@@ -30,6 +30,10 @@ export class SignalingTransportHandler<TMessage> {
return this.lastIdentifyCredentials?.displayName || DEFAULT_DISPLAY_NAME;
}
getIdentifyDescription(): string | undefined {
return this.lastIdentifyCredentials?.description;
}
getConnectedSignalingManagers(): ConnectedSignalingManager[] {
return this.dependencies.signalingCoordinator.getConnectedSignalingManagers();
}
@@ -160,12 +164,27 @@ export class SignalingTransportHandler<TMessage> {
return true;
}
identify(oderId: string, displayName: string, signalUrl?: string): void {
identify(
oderId: string,
displayName: string,
signalUrl?: string,
profile?: Pick<IdentifyCredentials, 'description' | 'profileUpdatedAt'>
): 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 +192,8 @@ export class SignalingTransportHandler<TMessage> {
type: SIGNALING_TYPE_IDENTIFY,
oderId,
displayName: normalizedDisplayName,
description: normalizedDescription,
profileUpdatedAt: normalizedProfileUpdatedAt,
connectionScope: signalUrl
});
@@ -190,6 +211,8 @@ export class SignalingTransportHandler<TMessage> {
type: SIGNALING_TYPE_IDENTIFY,
oderId,
displayName: normalizedDisplayName,
description: normalizedDescription,
profileUpdatedAt: normalizedProfileUpdatedAt,
connectionScope: managerSignalUrl
});
}

View File

@@ -54,6 +54,8 @@ export interface ChatEventBase {
deletedBy?: string;
oderId?: string;
displayName?: string;
description?: string;
profileUpdatedAt?: number;
emoji?: string;
reason?: string;
settings?: Partial<RoomSettings>;
@@ -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;
}

View File

@@ -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;

View File

@@ -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"
>
<div class="h-24 rounded-t-lg bg-gradient-to-r from-primary/30 to-primary/10"></div>
@let profileUser = user();
@let isEditable = editable();
@let activeField = editingField();
@let statusColor = currentStatusColor();
@let statusLabel = currentStatusLabel();
<div class="h-20 rounded-t-lg bg-gradient-to-r from-primary/30 to-primary/10"></div>
<div class="relative px-4">
<div class="-mt-9">
@if (editable()) {
<button
#avatarInputButton
type="button"
class="group relative rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
(click)="pickAvatar(avatarInput)"
>
<app-user-avatar
[name]="user().displayName"
[avatarUrl]="user().avatarUrl"
size="xl"
[status]="user().status"
[showStatusBadge]="true"
ringClass="ring-4 ring-card"
/>
<span class="pointer-events-none absolute inset-0 rounded-full bg-black/0 transition-colors group-hover:bg-black/15"></span>
</button>
<input
#avatarInput
type="file"
class="hidden"
[accept]="avatarAccept"
(change)="onAvatarSelected($event)"
/>
} @else {
<div class="-mt-8">
<button
type="button"
class="rounded-full"
(click)="pickAvatar(avatarInput)"
>
<app-user-avatar
[name]="user().displayName"
[avatarUrl]="user().avatarUrl"
[name]="profileUser.displayName"
[avatarUrl]="profileUser.avatarUrl"
size="xl"
[status]="user().status"
[status]="profileUser.status"
[showStatusBadge]="true"
ringClass="ring-4 ring-card"
/>
}
</button>
<input
#avatarInput
type="file"
class="hidden"
[accept]="avatarAccept"
(change)="onAvatarSelected($event)"
/>
</div>
</div>
<div class="px-5 pb-4 pt-3">
<p class="truncate text-base font-semibold text-foreground">{{ user().displayName }}</p>
<p class="truncate text-sm text-muted-foreground">{{ user().username }}</p>
<div class="px-4 pb-3 pt-2.5">
@if (isEditable) {
<div class="space-y-2">
<div>
@if (activeField === 'displayName') {
<input
type="text"
class="w-full rounded-md border border-border bg-background/70 px-2 py-1.5 text-base font-semibold text-foreground outline-none focus:border-primary/70"
@if (editable()) {
<p class="mt-2 text-xs text-muted-foreground">Click avatar to upload and crop a profile picture.</p>
[value]="displayNameDraft()"
(input)="onDisplayNameInput($event)"
(blur)="finishEdit('displayName')"
/>
} @else {
<button
type="button"
class="block w-full py-0.5 text-left text-base font-semibold text-foreground"
(click)="startEdit('displayName')"
>
{{ profileUser.displayName }}
</button>
}
<p class="truncate text-sm text-muted-foreground">{{ profileUser.username }}</p>
</div>
<div>
@if (activeField === 'description') {
<textarea
rows="3"
class="w-full resize-none rounded-md border border-border bg-background/70 px-2 py-2 text-sm leading-5 text-foreground outline-none focus:border-primary/70"
[value]="descriptionDraft()"
placeholder="Add a description"
(input)="onDescriptionInput($event)"
(blur)="finishEdit('description')"
></textarea>
} @else {
<button
type="button"
class="block w-full py-1 text-left text-sm leading-5"
(click)="startEdit('description')"
>
@if (profileUser.description) {
<span class="whitespace-pre-line text-muted-foreground">{{ profileUser.description }}</span>
} @else {
<span class="text-muted-foreground/70">Add a description</span>
}
</button>
}
</div>
</div>
} @else {
<p class="truncate text-base font-semibold text-foreground">{{ profileUser.displayName }}</p>
<p class="truncate text-sm text-muted-foreground">{{ profileUser.username }}</p>
@if (profileUser.description) {
<p class="mt-2 whitespace-pre-line text-sm leading-5 text-muted-foreground">{{ profileUser.description }}</p>
}
}
@if (avatarError()) {
<div class="mt-3 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-red-200">
<div class="mt-2.5 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-red-200">
{{ avatarError() }}
</div>
}
@if (editable()) {
<div class="relative mt-3">
@if (isEditable) {
<div class="relative mt-2.5">
<button
type="button"
class="flex w-full items-center gap-2 rounded-md border border-border px-2.5 py-1.5 text-xs transition-colors hover:bg-secondary/60"
@@ -66,9 +112,9 @@
>
<span
class="h-2 w-2 rounded-full"
[class]="currentStatusColor()"
[class]="statusColor"
></span>
<span class="flex-1 text-left text-foreground">{{ currentStatusLabel() }}</span>
<span class="flex-1 text-left text-foreground">{{ statusLabel }}</span>
<ng-icon
name="lucideChevronDown"
class="h-3 w-3 text-muted-foreground"
@@ -97,9 +143,9 @@
<div class="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
<span
class="h-2 w-2 rounded-full"
[class]="currentStatusColor()"
[class]="statusColor"
></span>
<span>{{ currentStatusLabel() }}</span>
<span>{{ statusLabel }}</span>
</div>
}
</div>

View File

@@ -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<string | null>(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;
}
}

View File

@@ -5,15 +5,14 @@ import {
createEffect,
ofType
} from '@ngrx/effects';
import { Action } from '@ngrx/store';
import { Store } from '@ngrx/store';
import { Store, type Action } from '@ngrx/store';
import { EMPTY } from 'rxjs';
import {
mergeMap,
tap,
withLatestFrom
} from 'rxjs/operators';
import {
import type {
ChatEvent,
Room,
RoomMember,
@@ -394,7 +393,28 @@ export class RoomMembersSyncEffects {
);
}
return this.createRoomMemberUpdateActions(room, members);
const actions = this.createRoomMemberUpdateActions(room, members);
const currentUserId = currentUser?.oderId || currentUser?.id;
for (const member of members) {
const memberId = member.oderId || member.id;
if (!member.avatarUrl || !memberId || memberId === currentUserId) {
continue;
}
actions.push(UsersActions.upsertRemoteUserAvatar({
user: {
id: member.id,
oderId: memberId,
username: member.username,
displayName: member.displayName,
avatarUrl: member.avatarUrl
}
}));
}
return actions;
}
private handleMemberLeave(

View File

@@ -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<RoomMember, 'displayName' | 'description' | 'profileUpdatedAt'>,
incomingMember: Pick<RoomMember, 'displayName' | 'description' | 'profileUpdatedAt'>,
preferIncomingFallback: boolean
): Pick<RoomMember, 'displayName' | 'description' | 'profileUpdatedAt'> {
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<RoomMember, 'avatarUrl' | 'avatarHash' | 'avatarMime' | 'avatarUpdatedAt'>,
incomingMember: Pick<RoomMember, 'avatarUrl' | 'avatarHash' | 'avatarMime' | 'avatarUpdatedAt'>,
@@ -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,

View File

@@ -1,6 +1,6 @@
import { Store } from '@ngrx/store';
import { firstValueFrom } from 'rxjs';
import { Room, User } from '../../shared-kernel';
import type { Room, User } from '../../shared-kernel';
import {
type RoomSignalSource,
type ServerSourceSelector,
@@ -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);

View File

@@ -5,7 +5,7 @@ import {
createEffect,
ofType
} from '@ngrx/effects';
import { Action, Store } from '@ngrx/store';
import { Store, type Action } from '@ngrx/store';
import {
of,
from,
@@ -30,7 +30,7 @@ import {
import { RealtimeSessionFacade } from '../../core/realtime';
import { DatabaseService } from '../../infrastructure/persistence';
import { resolveRoomPermission } from '../../domains/access-control';
import {
import type {
ChatEvent,
Room,
RoomSettings,
@@ -50,9 +50,9 @@ import {
resolveRoom,
sanitizeRoomSnapshot,
normalizeIncomingBans,
getPersistedCurrentUserId,
RoomPresenceSignalingMessage
getPersistedCurrentUserId
} from './rooms.helpers';
import type { RoomPresenceSignalingMessage } from './rooms.helpers';
/**
* NgRx effects for real-time state synchronisation: signaling presence
@@ -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]
})
})

View File

@@ -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;
}

View File

@@ -1,8 +1,5 @@
import { User } from '../../shared-kernel';
import {
shouldApplyAvatarTransfer,
shouldRequestAvatarData
} from './user-avatar.effects';
import { shouldApplyAvatarTransfer, shouldRequestAvatarData } from './user-avatar.effects';
function createUser(overrides: Partial<User> = {}): User {
return {

View File

@@ -1,10 +1,11 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core';
import {
Actions,
createEffect,
ofType
} from '@ngrx/effects';
import { Action, Store } from '@ngrx/store';
import { Store, type Action } from '@ngrx/store';
import {
EMPTY,
from,
@@ -17,26 +18,22 @@ import {
} from 'rxjs/operators';
import { ProfileAvatarFacade } from '../../domains/profile-avatar';
import {
ChatEvent,
P2P_BASE64_CHUNK_SIZE_BYTES,
User,
decodeBase64,
iterateBlobChunks
} from '../../shared-kernel';
import type { ChatEvent, User } from '../../shared-kernel';
import { RealtimeSessionFacade } from '../../core/realtime';
import { DatabaseService } from '../../infrastructure/persistence';
import { UsersActions } from './users.actions';
import {
selectAllUsers,
selectCurrentUser
} from './users.selectors';
import { selectAllUsers, selectCurrentUser } from './users.selectors';
import { selectCurrentRoom, selectSavedRooms } from '../rooms/rooms.selectors';
import { RoomsActions } from '../rooms/rooms.actions';
import { findRoomMember } from '../rooms/room-members.helpers';
interface PendingAvatarTransfer {
displayName: string;
mime: string;
mime?: string;
oderId: string;
total: number;
updatedAt: number;
@@ -46,6 +43,17 @@ interface PendingAvatarTransfer {
}
type AvatarVersionState = Pick<User, 'avatarUrl' | 'avatarHash' | 'avatarUpdatedAt'> | undefined;
type RoomProfileState = Pick<User,
| 'id'
| 'oderId'
| 'displayName'
| 'description'
| 'profileUpdatedAt'
| 'avatarUrl'
| 'avatarHash'
| 'avatarMime'
| 'avatarUpdatedAt'
>;
function shouldAcceptAvatarPayload(
existingUser: AvatarVersionState,
@@ -69,9 +77,13 @@ function shouldAcceptAvatarPayload(
return !!incomingHash && incomingHash !== existingUser.avatarHash;
}
function hasSyncableUserData(user: Pick<User, 'avatarUpdatedAt' | 'profileUpdatedAt'> | null | undefined): boolean {
return (user?.avatarUpdatedAt ?? 0) > 0;
}
export function shouldRequestAvatarData(
existingUser: AvatarVersionState,
incomingAvatar: Pick<ChatEvent, 'avatarHash' | 'avatarUpdatedAt'>
incomingAvatar: Pick<ChatEvent, 'avatarHash' | 'avatarUpdatedAt' | 'profileUpdatedAt'>
): boolean {
return shouldAcceptAvatarPayload(existingUser, incomingAvatar.avatarUpdatedAt ?? 0, incomingAvatar.avatarHash);
}
@@ -114,29 +126,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 +172,36 @@ 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
: action.type === UsersActions.updateCurrentUserProfile.type
? (currentUser ? {
...currentUser,
...action.profile
} : null)
: (currentUser ? {
...currentUser,
...action.avatar
} : null);
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 || !hasSyncableUserData(currentUser)) {
return;
}
@@ -184,7 +216,7 @@ export class UserAvatarEffects {
this.webrtc.onPeerConnected.pipe(
withLatestFrom(this.store.select(selectCurrentUser)),
tap(([peerId, currentUser]) => {
if (!currentUser?.avatarUpdatedAt) {
if (!currentUser || !hasSyncableUserData(currentUser)) {
return;
}
@@ -210,7 +242,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,14 +254,11 @@ export class UserAvatarEffects {
)
);
private buildAvatarSummary(user: Pick<User, 'oderId' | 'id' | 'username' | 'displayName' | 'avatarHash' | 'avatarMime' | 'avatarUpdatedAt'>): ChatEvent {
private buildAvatarSummary(user: Pick<User, 'oderId' | 'id' | 'avatarHash' | 'avatarUpdatedAt'>): ChatEvent {
return {
type: 'user-avatar-summary',
oderId: user.oderId || user.id,
username: user.username,
displayName: user.displayName,
avatarHash: user.avatarHash,
avatarMime: user.avatarMime,
avatarUpdatedAt: user.avatarUpdatedAt || 0
};
}
@@ -256,15 +285,34 @@ 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 || !hasSyncableUserData(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: [],
displayName: event.displayName || 'User',
mime: event.avatarMime,
oderId: event.oderId,
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;
}
@@ -306,36 +354,55 @@ export class UserAvatarEffects {
);
}
private async buildRemoteAvatarAction(transfer: PendingAvatarTransfer, allUsers: User[]): Promise<Action | null> {
const existingUser = allUsers.find((user) => user.id === transfer.oderId || user.oderId === transfer.oderId);
private async buildRemoteAvatarAction(
transfer: PendingAvatarTransfer,
allUsers: User[]
): Promise<Action | null> {
const existingUser = allUsers.find(
(user) => user.id === transfer.oderId || user.oderId === transfer.oderId
);
if (!shouldApplyAvatarTransfer(existingUser, transfer)) {
return null;
}
const blob = new Blob(transfer.chunks.map((chunk) => this.decodeBase64ToArrayBuffer(chunk!)), { type: transfer.mime });
const dataUrl = await this.readBlobAsDataUrl(blob);
const base64Chunks = transfer.chunks.filter(
(chunk): chunk is string => typeof chunk === 'string'
);
if (transfer.total > 0 && base64Chunks.length !== transfer.total) {
return null;
}
const dataUrl = transfer.total > 0
? await this.readBlobAsDataUrl(new Blob(
base64Chunks.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',
avatarUrl: dataUrl,
avatarHash: transfer.hash,
avatarMime: transfer.mime,
avatarUpdatedAt: transfer.updatedAt
avatarUpdatedAt: transfer.updatedAt || undefined
}
});
}
private buildRoomAvatarActions(
avatarOwner: Pick<User, 'id' | 'oderId' | 'avatarUrl' | 'avatarHash' | 'avatarMime' | 'avatarUpdatedAt'>,
private buildRoomProfileActions(
avatarOwner: RoomProfileState,
currentRoom: ReturnType<typeof selectCurrentRoom['projector']> | null,
savedRooms: ReturnType<typeof selectSavedRooms['projector']>
): Action[] {
const rooms = [currentRoom, ...savedRooms.filter((room) => room.id !== currentRoom?.id)].filter((room): room is NonNullable<typeof currentRoom> => !!room);
const rooms = [currentRoom, ...savedRooms.filter((room) => room.id !== currentRoom?.id)].filter(
(room): room is NonNullable<typeof currentRoom> => !!room
);
const roomActions: Action[] = [];
const avatarOwnerId = avatarOwner.oderId || avatarOwner.id;
@@ -353,6 +420,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,13 +440,11 @@ export class UserAvatarEffects {
}
private async sendAvatarToPeer(targetPeerId: string, user: User): Promise<void> {
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',
@@ -384,11 +452,15 @@ export class UserAvatarEffects {
username: user.username,
displayName: user.displayName,
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',

View File

@@ -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', () => {

View File

@@ -39,7 +39,13 @@ export const UsersActions = createActionGroup({
'Kick User': props<{ userId: string; roomId?: string }>(),
'Kick User Success': props<{ userId: string; roomId: string }>(),
'Ban User': props<{ userId: string; roomId?: string; displayName?: string; reason?: string; expiresAt?: number }>(),
'Ban User': props<{
userId: string;
roomId?: string;
displayName?: string;
reason?: string;
expiresAt?: number;
}>(),
'Ban User Success': props<{ userId: string; roomId: string; ban: BanEntry }>(),
'Unban User': props<{ roomId: string; oderId: string }>(),
'Unban User Success': props<{ oderId: string }>(),
@@ -61,7 +67,34 @@ export const UsersActions = createActionGroup({
'Set Manual Status': props<{ status: UserStatus | null }>(),
'Update Remote User Status': props<{ userId: string; status: UserStatus }>(),
'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 } }>()
'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;
description?: string;
profileUpdatedAt?: number;
avatarUrl?: string;
avatarHash?: string;
avatarMime?: string;
avatarUpdatedAt?: number;
};
}>()
}
});

View File

@@ -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 }

View File

@@ -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<ProfileFields> | undefined,
incomingValue: Partial<ProfileFields>,
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;

View File

@@ -3,6 +3,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./out-tsc/spec",
"types": [
"vitest/globals"