feat: Add profile images

This commit is contained in:
2026-04-17 03:05:47 +02:00
parent 35b616fb77
commit 17738ec484
49 changed files with 2622 additions and 89 deletions

View File

@@ -29,7 +29,7 @@ export default defineConfig({
], ],
webServer: { webServer: {
command: 'cd ../toju-app && npx ng serve', command: 'cd ../toju-app && npx ng serve',
port: 4200, url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
timeout: 120_000 timeout: 120_000
} }

View File

@@ -0,0 +1,458 @@
import {
mkdtemp,
rm
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
chromium,
type BrowserContext,
type Page
} from '@playwright/test';
import {
test,
expect
} from '../../fixtures/multi-client';
import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint';
import { LoginPage } from '../../pages/login.page';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatMessagesPage } from '../../pages/chat-messages.page';
import { ChatRoomPage } from '../../pages/chat-room.page';
interface TestUser {
displayName: string;
password: string;
username: string;
}
interface AvatarUploadPayload {
buffer: Buffer;
dataUrl: string;
mimeType: string;
name: string;
}
interface PersistentClient {
context: BrowserContext;
page: Page;
user: TestUser;
userDataDir: string;
}
const STATIC_GIF_BASE64 = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
const GIF_FRAME_MARKER = Buffer.from([0x21, 0xF9, 0x04]);
const 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', () => {
test.describe.configure({ timeout: 240_000 });
test('syncs avatar changes for online and late-joining users and persists after restart', async ({ testServer }) => {
const suffix = uniqueName('avatar');
const serverName = `Avatar Sync Server ${suffix}`;
const messageText = `Avatar sync message ${suffix}`;
const avatarA = buildAnimatedGifUpload('alpha');
const avatarB = buildAnimatedGifUpload('beta');
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: 'Avatar 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 expectUserRowVisible(bob.page, aliceUser.displayName);
});
const roomUrl = alice.page.url();
await test.step('Alice uploads the first avatar while Bob is online and Bob sees it live', async () => {
await uploadAvatarFromRoomSidebar(alice.page, aliceUser.displayName, avatarA);
await expectSidebarAvatar(alice.page, aliceUser.displayName, avatarA.dataUrl);
await expectSidebarAvatar(bob.page, aliceUser.displayName, avatarA.dataUrl);
});
await test.step('Alice sees the updated avatar in voice controls', async () => {
await ensureVoiceChannelExists(alice.page, VOICE_CHANNEL);
await joinVoiceChannel(alice.page, VOICE_CHANNEL);
await expectVoiceControlsAvatar(alice.page, avatarA.dataUrl);
});
const carol = await createPersistentClient(carolUser, testServer.port);
clients.push(carol);
await test.step('Carol joins after the first change and sees the updated avatar', async () => {
await registerUser(carol);
await joinServerFromSearch(carol.page, serverName);
await waitForRoomReady(carol.page);
await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarA.dataUrl);
});
await test.step('Alice avatar is used in chat messages for everyone in the room', async () => {
const aliceMessagesPage = new ChatMessagesPage(alice.page);
await aliceMessagesPage.sendMessage(messageText);
await expectChatMessageAvatar(alice.page, messageText, avatarA.dataUrl);
await expectChatMessageAvatar(bob.page, messageText, avatarA.dataUrl);
await expectChatMessageAvatar(carol.page, messageText, avatarA.dataUrl);
});
await test.step('Alice changes the avatar again and all three users see the update in real time', async () => {
await uploadAvatarFromRoomSidebar(alice.page, aliceUser.displayName, avatarB);
await expectSidebarAvatar(alice.page, aliceUser.displayName, avatarB.dataUrl);
await expectSidebarAvatar(bob.page, aliceUser.displayName, avatarB.dataUrl);
await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarB.dataUrl);
await expectChatMessageAvatar(alice.page, messageText, avatarB.dataUrl);
await expectChatMessageAvatar(bob.page, messageText, avatarB.dataUrl);
await expectChatMessageAvatar(carol.page, messageText, avatarB.dataUrl);
await expectVoiceControlsAvatar(alice.page, avatarB.dataUrl);
});
await test.step('Bob, Carol, and Alice each keep the updated avatar after a full app restart', async () => {
await restartPersistentClient(bob, testServer.port);
await openRoomAfterRestart(bob, roomUrl);
await expectSidebarAvatar(bob.page, aliceUser.displayName, avatarB.dataUrl);
await expectChatMessageAvatar(bob.page, messageText, avatarB.dataUrl);
await restartPersistentClient(carol, testServer.port);
await openRoomAfterRestart(carol, roomUrl);
await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarB.dataUrl);
await expectChatMessageAvatar(carol.page, messageText, avatarB.dataUrl);
await restartPersistentClient(alice, testServer.port);
await openRoomAfterRestart(alice, roomUrl);
await expectSidebarAvatar(alice.page, aliceUser.displayName, avatarB.dataUrl);
await expectChatMessageAvatar(alice.page, messageText, avatarB.dataUrl);
});
} 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);
return {
context: session.context,
page: session.page,
user,
userDataDir
};
}
async function restartPersistentClient(client: PersistentClient, testServerPort: number): Promise<void> {
await closePersistentClient(client);
const session = await launchPersistentSession(client.userDataDir, testServerPort);
client.context = session.context;
client.page = session.page;
}
async function closePersistentClient(client: PersistentClient): Promise<void> {
try {
await client.context.close();
} catch {
// Ignore repeated cleanup attempts during finally.
}
}
async function launchPersistentSession(
userDataDir: string,
testServerPort: number
): Promise<{ context: BrowserContext; page: Page }> {
const context = await chromium.launchPersistentContext(userDataDir, {
args: CLIENT_LAUNCH_ARGS,
baseURL: 'http://localhost:4200',
permissions: ['microphone', 'camera']
});
await installTestServerEndpoint(context, testServerPort);
const page = context.pages()[0] ?? await context.newPage();
return { context, page };
}
async function registerUser(client: PersistentClient): Promise<void> {
const registerPage = new RegisterPage(client.page);
await retryTransientNavigation(() => registerPage.goto());
await registerPage.register(client.user.username, client.user.displayName, client.user.password);
await expect(client.page).toHaveURL(/\/search/, { timeout: 15_000 });
}
async function joinServerFromSearch(page: Page, serverName: string): Promise<void> {
const searchPage = new ServerSearchPage(page);
const serverCard = page.locator('button', { hasText: serverName }).first();
await searchPage.searchInput.fill(serverName);
await expect(serverCard).toBeVisible({ timeout: 15_000 });
await serverCard.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
}
async function ensureVoiceChannelExists(page: Page, channelName: string): Promise<void> {
const chatRoom = new ChatRoomPage(page);
const existingVoiceChannel = page.locator('app-rooms-side-panel').getByRole('button', { name: channelName, exact: true });
if (await existingVoiceChannel.count() > 0) {
return;
}
await chatRoom.openCreateVoiceChannelDialog();
await chatRoom.createChannel(channelName);
await expect(existingVoiceChannel).toBeVisible({ timeout: 10_000 });
}
async function joinVoiceChannel(page: Page, channelName: string): Promise<void> {
const chatRoom = new ChatRoomPage(page);
await chatRoom.joinVoiceChannel(channelName);
await expect(page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
}
async function uploadAvatarFromRoomSidebar(
page: Page,
displayName: string,
avatar: AvatarUploadPayload
): Promise<void> {
const currentUserRow = getUserRow(page, displayName);
const profileFileInput = page.locator('app-profile-card input[type="file"]');
const applyButton = page.getByRole('button', { name: 'Apply picture' });
await expect(currentUserRow).toBeVisible({ timeout: 15_000 });
if (await profileFileInput.count() === 0) {
await currentUserRow.click();
await expect(profileFileInput).toBeAttached({ timeout: 10_000 });
}
await profileFileInput.setInputFiles({
name: avatar.name,
mimeType: avatar.mimeType,
buffer: avatar.buffer
});
await expect(applyButton).toBeVisible({ timeout: 10_000 });
await applyButton.click();
await expect(applyButton).not.toBeVisible({ timeout: 10_000 });
}
async function openRoomAfterRestart(client: PersistentClient, roomUrl: string): Promise<void> {
await retryTransientNavigation(() => client.page.goto(roomUrl, { waitUntil: 'domcontentloaded' }));
if (client.page.url().includes('/login')) {
const loginPage = new LoginPage(client.page);
await loginPage.login(client.user.username, client.user.password);
await expect(client.page).toHaveURL(/\/(search|room)\//, { timeout: 15_000 });
await client.page.goto(roomUrl, { waitUntil: 'domcontentloaded' });
}
await waitForRoomReady(client.page);
}
async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await navigate();
} catch (error) {
lastError = error;
const message = error instanceof Error ? error.message : String(error);
const isTransientNavigationError = message.includes('ERR_EMPTY_RESPONSE') || message.includes('ERR_CONNECTION_RESET');
if (!isTransientNavigationError || attempt === attempts) {
throw error;
}
}
}
throw lastError instanceof Error
? lastError
: new Error(`Navigation failed after ${attempts} attempts`);
}
async function waitForRoomReady(page: Page): Promise<void> {
const messagesPage = new ChatMessagesPage(page);
await messagesPage.waitForReady();
await expect(page.locator('app-rooms-side-panel').last()).toBeVisible({ timeout: 15_000 });
}
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();
}
async function expectUserRowVisible(page: Page, displayName: string): Promise<void> {
await expect(getUserRow(page, displayName)).toBeVisible({ timeout: 20_000 });
}
async function expectSidebarAvatar(page: Page, displayName: string, expectedDataUrl: string): Promise<void> {
const row = getUserRow(page, displayName);
await expect(row).toBeVisible({ timeout: 20_000 });
await expect.poll(async () => {
const image = row.locator('img').first();
if (await image.count() === 0) {
return null;
}
return image.getAttribute('src');
}, {
timeout: 20_000,
message: `${displayName} avatar src should update`
}).toBe(expectedDataUrl);
await expect.poll(async () => {
const image = row.locator('img').first();
if (await image.count() === 0) {
return false;
}
return image.evaluate((element) => {
const img = element as HTMLImageElement;
return img.complete && img.naturalWidth > 0 && img.naturalHeight > 0;
});
}, {
timeout: 20_000,
message: `${displayName} avatar image should load`
}).toBe(true);
}
async function expectChatMessageAvatar(page: Page, messageText: string, expectedDataUrl: string): Promise<void> {
const messagesPage = new ChatMessagesPage(page);
const messageItem = messagesPage.getMessageItemByText(messageText);
await expect(messageItem).toBeVisible({ timeout: 20_000 });
await expect.poll(async () => {
const image = messageItem.locator('app-user-avatar img').first();
if (await image.count() === 0) {
return null;
}
return image.getAttribute('src');
}, {
timeout: 20_000,
message: `Chat message avatar for "${messageText}" should update`
}).toBe(expectedDataUrl);
}
async function expectVoiceControlsAvatar(page: Page, expectedDataUrl: string): Promise<void> {
const voiceControls = page.locator('app-voice-controls');
await expect(voiceControls).toBeVisible({ timeout: 20_000 });
await expect.poll(async () => {
const image = voiceControls.locator('app-user-avatar img').first();
if (await image.count() === 0) {
return null;
}
return image.getAttribute('src');
}, {
timeout: 20_000,
message: 'Voice controls avatar should update'
}).toBe(expectedDataUrl);
}
function buildAnimatedGifUpload(label: string): AvatarUploadPayload {
const baseGif = Buffer.from(STATIC_GIF_BASE64, 'base64');
const frameStart = baseGif.indexOf(GIF_FRAME_MARKER);
if (frameStart < 0) {
throw new Error('Failed to locate GIF frame marker for animated avatar payload');
}
const header = baseGif.subarray(0, frameStart);
const frame = baseGif.subarray(frameStart, baseGif.length - 1);
const commentData = Buffer.from(label, 'ascii');
const commentExtension = Buffer.concat([
Buffer.from([0x21, 0xFE, commentData.length]),
commentData,
Buffer.from([0x00])
]);
const buffer = Buffer.concat([
header,
NETSCAPE_LOOP_EXTENSION,
commentExtension,
frame,
frame,
Buffer.from([0x3B])
]);
const base64 = buffer.toString('base64');
return {
buffer,
dataUrl: `data:image/gif;base64,${base64}`,
mimeType: 'image/gif',
name: `animated-avatar-${label}.gif`
};
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}

View File

@@ -11,6 +11,9 @@ export async function handleSaveUser(command: SaveUserCommand, dataSource: DataS
username: user.username ?? null, username: user.username ?? null,
displayName: user.displayName ?? null, displayName: user.displayName ?? null,
avatarUrl: user.avatarUrl ?? null, avatarUrl: user.avatarUrl ?? null,
avatarHash: user.avatarHash ?? null,
avatarMime: user.avatarMime ?? null,
avatarUpdatedAt: user.avatarUpdatedAt ?? null,
status: user.status ?? null, status: user.status ?? null,
role: user.role ?? null, role: user.role ?? null,
joinedAt: user.joinedAt ?? null, joinedAt: user.joinedAt ?? null,

View File

@@ -47,6 +47,9 @@ export function rowToUser(row: UserEntity) {
username: row.username ?? '', username: row.username ?? '',
displayName: row.displayName ?? '', displayName: row.displayName ?? '',
avatarUrl: row.avatarUrl ?? undefined, avatarUrl: row.avatarUrl ?? undefined,
avatarHash: row.avatarHash ?? undefined,
avatarMime: row.avatarMime ?? undefined,
avatarUpdatedAt: row.avatarUpdatedAt ?? undefined,
status: row.status ?? 'offline', status: row.status ?? 'offline',
role: row.role ?? 'member', role: row.role ?? 'member',
joinedAt: row.joinedAt ?? 0, joinedAt: row.joinedAt ?? 0,

View File

@@ -67,6 +67,9 @@ export interface RoomMemberRecord {
username: string; username: string;
displayName: string; displayName: string;
avatarUrl?: string; avatarUrl?: string;
avatarHash?: string;
avatarMime?: string;
avatarUpdatedAt?: number;
role: RoomMemberRole; role: RoomMemberRole;
roleIds?: string[]; roleIds?: string[];
joinedAt: number; joinedAt: number;
@@ -336,6 +339,9 @@ function normalizeRoomMember(rawMember: Record<string, unknown>, now: number): R
const username = trimmedString(rawMember, 'username'); const username = trimmedString(rawMember, 'username');
const displayName = trimmedString(rawMember, 'displayName'); const displayName = trimmedString(rawMember, 'displayName');
const avatarUrl = trimmedString(rawMember, 'avatarUrl'); const avatarUrl = trimmedString(rawMember, 'avatarUrl');
const avatarHash = trimmedString(rawMember, 'avatarHash');
const avatarMime = trimmedString(rawMember, 'avatarMime');
const avatarUpdatedAt = isFiniteNumber(rawMember['avatarUpdatedAt']) ? rawMember['avatarUpdatedAt'] : undefined;
return { return {
id: normalizedId || normalizedKey, id: normalizedId || normalizedKey,
@@ -343,6 +349,9 @@ function normalizeRoomMember(rawMember: Record<string, unknown>, now: number): R
username: username || fallbackUsername({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, displayName }), username: username || fallbackUsername({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, displayName }),
displayName: displayName || fallbackDisplayName({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, username }), displayName: displayName || fallbackDisplayName({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, username }),
avatarUrl: avatarUrl || undefined, avatarUrl: avatarUrl || undefined,
avatarHash: avatarHash || undefined,
avatarMime: avatarMime || undefined,
avatarUpdatedAt,
role: normalizeRoomMemberRole(rawMember['role']), role: normalizeRoomMemberRole(rawMember['role']),
roleIds: uniqueStrings(Array.isArray(rawMember['roleIds']) ? rawMember['roleIds'] as string[] : undefined), roleIds: uniqueStrings(Array.isArray(rawMember['roleIds']) ? rawMember['roleIds'] as string[] : undefined),
joinedAt, joinedAt,
@@ -356,6 +365,11 @@ function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incoming
} }
const preferIncoming = incomingMember.lastSeenAt >= existingMember.lastSeenAt; const preferIncoming = incomingMember.lastSeenAt >= existingMember.lastSeenAt;
const existingAvatarUpdatedAt = existingMember.avatarUpdatedAt ?? 0;
const incomingAvatarUpdatedAt = incomingMember.avatarUpdatedAt ?? 0;
const preferIncomingAvatar = incomingAvatarUpdatedAt === existingAvatarUpdatedAt
? preferIncoming
: incomingAvatarUpdatedAt > existingAvatarUpdatedAt;
return { return {
id: existingMember.id || incomingMember.id, id: existingMember.id || incomingMember.id,
@@ -366,9 +380,16 @@ function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incoming
displayName: preferIncoming displayName: preferIncoming
? (incomingMember.displayName || existingMember.displayName) ? (incomingMember.displayName || existingMember.displayName)
: (existingMember.displayName || incomingMember.displayName), : (existingMember.displayName || incomingMember.displayName),
avatarUrl: preferIncoming avatarUrl: preferIncomingAvatar
? (incomingMember.avatarUrl || existingMember.avatarUrl) ? (incomingMember.avatarUrl || existingMember.avatarUrl)
: (existingMember.avatarUrl || incomingMember.avatarUrl), : (existingMember.avatarUrl || incomingMember.avatarUrl),
avatarHash: preferIncomingAvatar
? (incomingMember.avatarHash || existingMember.avatarHash)
: (existingMember.avatarHash || incomingMember.avatarHash),
avatarMime: preferIncomingAvatar
? (incomingMember.avatarMime || existingMember.avatarMime)
: (existingMember.avatarMime || incomingMember.avatarMime),
avatarUpdatedAt: Math.max(existingAvatarUpdatedAt, incomingAvatarUpdatedAt) || undefined,
role: mergeRoomMemberRole(existingMember.role, incomingMember.role, preferIncoming), role: mergeRoomMemberRole(existingMember.role, incomingMember.role, preferIncoming),
roleIds: preferIncoming roleIds: preferIncoming
? (incomingMember.roleIds || existingMember.roleIds) ? (incomingMember.roleIds || existingMember.roleIds)
@@ -760,6 +781,9 @@ export async function replaceRoomRelations(
username: member.username, username: member.username,
displayName: member.displayName, displayName: member.displayName,
avatarUrl: member.avatarUrl ?? null, avatarUrl: member.avatarUrl ?? null,
avatarHash: member.avatarHash ?? null,
avatarMime: member.avatarMime ?? null,
avatarUpdatedAt: member.avatarUpdatedAt ?? null,
role: member.role, role: member.role,
joinedAt: member.joinedAt, joinedAt: member.joinedAt,
lastSeenAt: member.lastSeenAt lastSeenAt: member.lastSeenAt
@@ -907,6 +931,9 @@ export async function loadRoomRelationsMap(
username: row.username, username: row.username,
displayName: row.displayName, displayName: row.displayName,
avatarUrl: row.avatarUrl ?? undefined, avatarUrl: row.avatarUrl ?? undefined,
avatarHash: row.avatarHash ?? undefined,
avatarMime: row.avatarMime ?? undefined,
avatarUpdatedAt: row.avatarUpdatedAt ?? undefined,
role: row.role, role: row.role,
joinedAt: row.joinedAt, joinedAt: row.joinedAt,
lastSeenAt: row.lastSeenAt lastSeenAt: row.lastSeenAt

View File

@@ -106,6 +106,9 @@ export interface UserPayload {
username?: string; username?: string;
displayName?: string; displayName?: string;
avatarUrl?: string; avatarUrl?: string;
avatarHash?: string;
avatarMime?: string;
avatarUpdatedAt?: number;
status?: string; status?: string;
role?: string; role?: string;
joinedAt?: number; joinedAt?: number;

View File

@@ -27,6 +27,15 @@ export class RoomMemberEntity {
@Column('text', { nullable: true }) @Column('text', { nullable: true })
avatarUrl!: string | null; avatarUrl!: string | null;
@Column('text', { nullable: true })
avatarHash!: string | null;
@Column('text', { nullable: true })
avatarMime!: string | null;
@Column('integer', { nullable: true })
avatarUpdatedAt!: number | null;
@Column('text') @Column('text')
role!: 'host' | 'admin' | 'moderator' | 'member'; role!: 'host' | 'admin' | 'moderator' | 'member';

View File

@@ -21,6 +21,15 @@ export class UserEntity {
@Column('text', { nullable: true }) @Column('text', { nullable: true })
avatarUrl!: string | null; avatarUrl!: string | null;
@Column('text', { nullable: true })
avatarHash!: string | null;
@Column('text', { nullable: true })
avatarMime!: string | null;
@Column('integer', { nullable: true })
avatarUpdatedAt!: number | null;
@Column('text', { nullable: true }) @Column('text', { nullable: true })
status!: string | null; status!: string | null;

View File

@@ -0,0 +1,18 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddProfileAvatarMetadata1000000000006 implements MigrationInterface {
name = 'AddProfileAvatarMetadata1000000000006';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "avatarHash" TEXT`);
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "avatarMime" TEXT`);
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "avatarUpdatedAt" INTEGER`);
await queryRunner.query(`ALTER TABLE "room_members" ADD COLUMN "avatarHash" TEXT`);
await queryRunner.query(`ALTER TABLE "room_members" ADD COLUMN "avatarMime" TEXT`);
await queryRunner.query(`ALTER TABLE "room_members" ADD COLUMN "avatarUpdatedAt" INTEGER`);
}
public async down(): Promise<void> {
// SQLite column removal requires table rebuilds. Keep rollback no-op.
}
}

View File

@@ -97,7 +97,7 @@
{ {
"type": "initial", "type": "initial",
"maximumWarning": "2.2MB", "maximumWarning": "2.2MB",
"maximumError": "2.3MB" "maximumError": "2.32MB"
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",

View File

@@ -16,6 +16,7 @@ import { roomsReducer } from './store/rooms/rooms.reducer';
import { NotificationsEffects } from './domains/notifications'; import { NotificationsEffects } from './domains/notifications';
import { MessagesEffects } from './store/messages/messages.effects'; import { MessagesEffects } from './store/messages/messages.effects';
import { MessagesSyncEffects } from './store/messages/messages-sync.effects'; import { MessagesSyncEffects } from './store/messages/messages-sync.effects';
import { UserAvatarEffects } from './store/users/user-avatar.effects';
import { UsersEffects } from './store/users/users.effects'; import { UsersEffects } from './store/users/users.effects';
import { RoomsEffects } from './store/rooms/rooms.effects'; import { RoomsEffects } from './store/rooms/rooms.effects';
import { RoomMembersSyncEffects } from './store/rooms/room-members-sync.effects'; import { RoomMembersSyncEffects } from './store/rooms/room-members-sync.effects';
@@ -38,6 +39,7 @@ export const appConfig: ApplicationConfig = {
NotificationsEffects, NotificationsEffects,
MessagesEffects, MessagesEffects,
MessagesSyncEffects, MessagesSyncEffects,
UserAvatarEffects,
UsersEffects, UsersEffects,
RoomsEffects, RoomsEffects,
RoomMembersSyncEffects, RoomMembersSyncEffects,

View File

@@ -13,6 +13,7 @@ infrastructure adapters and UI.
| **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` | | **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` |
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` | | **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` | | **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
| **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` |
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` | | **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` | | **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
| **theme** | JSON-driven theming, element registry, layout syncing, picker tooling, and Electron saved-theme library management | `ThemeService` | | **theme** | JSON-driven theming, element registry, layout syncing, picker tooling, and Electron saved-theme library management | `ThemeService` |
@@ -28,6 +29,7 @@ The larger domains also keep longer design notes in their own folders:
- [authentication/README.md](authentication/README.md) - [authentication/README.md](authentication/README.md)
- [chat/README.md](chat/README.md) - [chat/README.md](chat/README.md)
- [notifications/README.md](notifications/README.md) - [notifications/README.md](notifications/README.md)
- [profile-avatar/README.md](profile-avatar/README.md)
- [screen-share/README.md](screen-share/README.md) - [screen-share/README.md](screen-share/README.md)
- [server-directory/README.md](server-directory/README.md) - [server-directory/README.md](server-directory/README.md)
- [voice-connection/README.md](voice-connection/README.md) - [voice-connection/README.md](voice-connection/README.md)

View File

@@ -3,6 +3,11 @@ import { RealtimeSessionFacade } from '../../../../core/realtime';
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service'; import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
import { FILE_CHUNK_SIZE_BYTES } from '../../domain/constants/attachment-transfer.constants'; import { FILE_CHUNK_SIZE_BYTES } from '../../domain/constants/attachment-transfer.constants';
import { FileChunkEvent } from '../../domain/models/attachment-transfer.model'; import { FileChunkEvent } from '../../domain/models/attachment-transfer.model';
import {
arrayBufferToBase64,
decodeBase64,
iterateBlobChunks
} from '../../../../shared-kernel';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AttachmentTransferTransportService { export class AttachmentTransferTransportService {
@@ -10,14 +15,7 @@ export class AttachmentTransferTransportService {
private readonly attachmentStorage = inject(AttachmentStorageService); private readonly attachmentStorage = inject(AttachmentStorageService);
decodeBase64(base64: string): Uint8Array { decodeBase64(base64: string): Uint8Array {
const binary = atob(base64); return decodeBase64(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
} }
async streamFileToPeer( async streamFileToPeer(
@@ -27,31 +25,20 @@ export class AttachmentTransferTransportService {
file: File, file: File,
isCancelled: () => boolean isCancelled: () => boolean
): Promise<void> { ): Promise<void> {
const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE_BYTES); for await (const chunk of iterateBlobChunks(file, FILE_CHUNK_SIZE_BYTES)) {
let offset = 0;
let chunkIndex = 0;
while (offset < file.size) {
if (isCancelled()) if (isCancelled())
break; break;
const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES);
const arrayBuffer = await slice.arrayBuffer();
const base64 = this.arrayBufferToBase64(arrayBuffer);
const fileChunkEvent: FileChunkEvent = { const fileChunkEvent: FileChunkEvent = {
type: 'file-chunk', type: 'file-chunk',
messageId, messageId,
fileId, fileId,
index: chunkIndex, index: chunk.index,
total: totalChunks, total: chunk.total,
data: base64 data: chunk.base64
}; };
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent); await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
offset += FILE_CHUNK_SIZE_BYTES;
chunkIndex++;
} }
} }
@@ -67,7 +54,7 @@ export class AttachmentTransferTransportService {
if (!base64Full) if (!base64Full)
return; return;
const fileBytes = this.decodeBase64(base64Full); const fileBytes = decodeBase64(base64Full);
const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES); const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES);
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
@@ -81,7 +68,7 @@ export class AttachmentTransferTransportService {
slice.byteOffset, slice.byteOffset,
slice.byteOffset + slice.byteLength slice.byteOffset + slice.byteLength
); );
const base64Chunk = this.arrayBufferToBase64(sliceBuffer); const base64Chunk = arrayBufferToBase64(sliceBuffer);
const fileChunkEvent: FileChunkEvent = { const fileChunkEvent: FileChunkEvent = {
type: 'file-chunk', type: 'file-chunk',
messageId, messageId,
@@ -94,16 +81,4 @@ export class AttachmentTransferTransportService {
this.webrtc.sendToPeer(targetPeerId, fileChunkEvent); this.webrtc.sendToPeer(targetPeerId, fileChunkEvent);
} }
} }
private arrayBufferToBase64(buffer: ArrayBuffer): string {
let binary = '';
const bytes = new Uint8Array(buffer);
for (let index = 0; index < bytes.byteLength; index++) {
binary += String.fromCharCode(bytes[index]);
}
return btoa(binary);
}
} }

View File

@@ -1,5 +1,4 @@
/** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */ export { P2P_BASE64_CHUNK_SIZE_BYTES as FILE_CHUNK_SIZE_BYTES } from '../../../../shared-kernel/p2p-transfer.constants';
export const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB
/** /**
* EWMA smoothing weight for the previous speed estimate. * EWMA smoothing weight for the previous speed estimate.

View File

@@ -4,14 +4,16 @@
<button <button
#avatarBtn #avatarBtn
type="button" type="button"
class="relative flex items-center justify-center w-8 h-8 rounded-full bg-secondary text-foreground text-sm font-medium hover:bg-secondary/80 transition-colors" class="rounded-full transition-opacity hover:opacity-90"
(click)="toggleProfileCard(avatarBtn)" (click)="toggleProfileCard(avatarBtn)"
> >
{{ user()!.displayName.charAt(0).toUpperCase() || '?' }} <app-user-avatar
<span [name]="user()!.displayName"
class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-card" [avatarUrl]="user()!.avatarUrl"
[class]="currentStatusColor()" size="sm"
></span> [status]="user()!.status"
[showStatusBadge]="true"
/>
</button> </button>
</div> </div>
} @else { } @else {

View File

@@ -6,11 +6,12 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideLogIn, lucideUserPlus } from '@ng-icons/lucide'; import { lucideLogIn, lucideUserPlus } from '@ng-icons/lucide';
import { selectCurrentUser } from '../../../../store/users/users.selectors'; import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { ProfileCardService } from '../../../../shared/components/profile-card/profile-card.service'; import { ProfileCardService } from '../../../../shared/components/profile-card/profile-card.service';
import { UserAvatarComponent } from '../../../../shared';
@Component({ @Component({
selector: 'app-user-bar', selector: 'app-user-bar',
standalone: true, standalone: true,
imports: [CommonModule, NgIcon], imports: [CommonModule, NgIcon, UserAvatarComponent],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideLogIn, lucideLogIn,
@@ -28,19 +29,6 @@ export class UserBarComponent {
private router = inject(Router); private router = inject(Router);
private profileCard = inject(ProfileCardService); private profileCard = inject(ProfileCardService);
currentStatusColor(): string {
const status = this.user()?.status;
switch (status) {
case 'online': return 'bg-green-500';
case 'away': return 'bg-yellow-500';
case 'busy': return 'bg-red-500';
case 'offline': return 'bg-gray-500';
case 'disconnected': return 'bg-gray-500';
default: return 'bg-green-500';
}
}
toggleProfileCard(origin: HTMLElement): void { toggleProfileCard(origin: HTMLElement): void {
const user = this.user(); const user = this.user();

View File

@@ -11,7 +11,8 @@
(click)="openSenderProfileCard($event); $event.stopPropagation()" (click)="openSenderProfileCard($event); $event.stopPropagation()"
> >
<app-user-avatar <app-user-avatar
[name]="msg.senderName" [name]="senderUser().displayName || msg.senderName"
[avatarUrl]="senderUser().avatarUrl"
size="md" size="md"
/> />
</div> </div>

View File

@@ -146,17 +146,11 @@ export class ChatMessageItemComponent {
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT; readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
readonly isEditing = signal(false); readonly isEditing = signal(false);
readonly showEmojiPicker = signal(false); readonly showEmojiPicker = signal(false);
readonly senderUser = computed<User>(() => {
editContent = '';
openSenderProfileCard(event: MouseEvent): void {
event.stopPropagation();
const el = event.currentTarget as HTMLElement;
const msg = this.message(); const msg = this.message();
// Look up full user from store const found = this.allUsers().find((userEntry) => userEntry.id === msg.senderId || userEntry.oderId === msg.senderId);
const users = this.allUsers();
const found = users.find((userEntry) => userEntry.id === msg.senderId || userEntry.oderId === msg.senderId); return found ?? {
const user: User = found ?? {
id: msg.senderId, id: msg.senderId,
oderId: msg.senderId, oderId: msg.senderId,
username: msg.senderName, username: msg.senderName,
@@ -165,6 +159,14 @@ export class ChatMessageItemComponent {
role: 'member', role: 'member',
joinedAt: 0 joinedAt: 0
}; };
});
editContent = '';
openSenderProfileCard(event: MouseEvent): void {
event.stopPropagation();
const el = event.currentTarget as HTMLElement;
const user = this.senderUser();
const editable = user.id === this.currentUserId(); const editable = user.id === this.currentUserId();
this.profileCard.open(el, user, { editable }); this.profileCard.open(el, user, { editable });

View File

@@ -0,0 +1,44 @@
# 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.
## Responsibilities
- Accept `.webp`, `.gif`, `.jpg`, `.jpeg` profile image sources.
- Let user drag and zoom source inside fixed preview frame before saving.
- 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.
- Expose helpers used by store effects to keep avatar metadata (`avatarHash`, `avatarMime`, `avatarUpdatedAt`) consistent.
## Module map
```mermaid
graph TD
PC[ProfileCardComponent] --> PAE[ProfileAvatarEditorComponent]
PAE --> PAF[ProfileAvatarFacade]
PAF --> PAI[ProfileAvatarImageService]
PAF --> PAS[ProfileAvatarStorageService]
PAF --> Store[UsersActions.updateCurrentUserAvatar]
Store --> UAV[UserAvatarEffects]
UAV --> RTC[WebRTC data channel]
UAV --> DB[DatabaseService]
click PAE "feature/profile-avatar-editor/" "Crop and zoom editor UI" _blank
click PAF "application/services/profile-avatar.facade.ts" "Facade used by UI and effects" _blank
click PAI "infrastructure/services/profile-avatar-image.service.ts" "Canvas render and compression" _blank
click PAS "infrastructure/services/profile-avatar-storage.service.ts" "Electron file persistence" _blank
```
## 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.
## 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`.

View File

@@ -0,0 +1,69 @@
import { Injectable, inject } from '@angular/core';
import { User } from '../../../../shared-kernel';
import {
EditableProfileAvatarSource,
ProcessedProfileAvatar,
ProfileAvatarTransform,
ProfileAvatarUpdates
} from '../../domain/profile-avatar.models';
import { ProfileAvatarImageService } from '../../infrastructure/services/profile-avatar-image.service';
import { ProfileAvatarStorageService } from '../../infrastructure/services/profile-avatar-storage.service';
@Injectable({ providedIn: 'root' })
export class ProfileAvatarFacade {
private readonly image = inject(ProfileAvatarImageService);
private readonly storage = inject(ProfileAvatarStorageService);
validateFile(file: File): string | null {
return this.image.validateFile(file);
}
prepareEditableSource(file: File): Promise<EditableProfileAvatarSource> {
return this.image.prepareEditableSource(file);
}
releaseEditableSource(source: EditableProfileAvatarSource | null | undefined): void {
this.image.releaseEditableSource(source);
}
processEditableSource(
source: EditableProfileAvatarSource,
transform: ProfileAvatarTransform
): Promise<ProcessedProfileAvatar> {
return this.image.processEditableSource(source, transform);
}
persistProcessedAvatar(
user: Pick<User, 'id' | 'username' | 'displayName'>,
avatar: ProcessedProfileAvatar
): Promise<void> {
return this.storage.persistProcessedAvatar(user, avatar);
}
persistAvatarDataUrl(
user: Pick<User, 'id' | 'username' | 'displayName'>,
avatarUrl: string | null | undefined
): Promise<void> {
const mimeMatch = avatarUrl?.match(/^data:([^;]+);base64,/i);
const base64 = avatarUrl?.split(',', 2)[1] ?? '';
const avatarMime = mimeMatch?.[1]?.toLowerCase() ?? 'image/webp';
if (!base64) {
return Promise.resolve();
}
return this.storage.persistProcessedAvatar(user, {
base64,
avatarMime
});
}
buildAvatarUpdates(avatar: ProcessedProfileAvatar): ProfileAvatarUpdates {
return {
avatarUrl: avatar.avatarUrl,
avatarHash: avatar.avatarHash,
avatarMime: avatar.avatarMime,
avatarUpdatedAt: avatar.avatarUpdatedAt
};
}
}

View File

@@ -0,0 +1,38 @@
import {
PROFILE_AVATAR_MAX_ZOOM,
PROFILE_AVATAR_MIN_ZOOM,
clampProfileAvatarTransform,
clampProfileAvatarZoom,
resolveProfileAvatarStorageFileName,
resolveProfileAvatarBaseScale
} from './profile-avatar.models';
describe('profile-avatar models', () => {
it('clamps zoom inside allowed range', () => {
expect(clampProfileAvatarZoom(0.1)).toBe(PROFILE_AVATAR_MIN_ZOOM);
expect(clampProfileAvatarZoom(9)).toBe(PROFILE_AVATAR_MAX_ZOOM);
expect(clampProfileAvatarZoom(2.5)).toBe(2.5);
});
it('resolves cover scale for portrait images', () => {
expect(resolveProfileAvatarBaseScale({ width: 200, height: 400 }, 224)).toBeCloseTo(1.12);
});
it('clamps transform offsets so image still covers crop frame', () => {
const transform = clampProfileAvatarTransform(
{ width: 320, height: 240 },
{ zoom: 1, offsetX: 500, offsetY: -500 },
224
);
expect(transform.offsetX).toBeCloseTo(37.333333, 4);
expect(transform.offsetY).toBe(0);
});
it('maps avatar mime types to storage file names', () => {
expect(resolveProfileAvatarStorageFileName('image/gif')).toBe('profile.gif');
expect(resolveProfileAvatarStorageFileName('image/jpeg')).toBe('profile.jpg');
expect(resolveProfileAvatarStorageFileName('image/webp')).toBe('profile.webp');
expect(resolveProfileAvatarStorageFileName(undefined)).toBe('profile.webp');
});
});

View File

@@ -0,0 +1,99 @@
export const PROFILE_AVATAR_ALLOWED_MIME_TYPES = [
'image/webp',
'image/gif',
'image/jpeg'
] as const;
export const PROFILE_AVATAR_ACCEPT_ATTRIBUTE = '.webp,.gif,.jpg,.jpeg,image/webp,image/gif,image/jpeg';
export const PROFILE_AVATAR_OUTPUT_SIZE = 256;
export const PROFILE_AVATAR_EDITOR_FRAME_SIZE = 224;
export const PROFILE_AVATAR_MIN_ZOOM = 1;
export const PROFILE_AVATAR_MAX_ZOOM = 4;
export interface ProfileAvatarDimensions {
width: number;
height: number;
}
export interface EditableProfileAvatarSource extends ProfileAvatarDimensions {
file: File;
objectUrl: string;
mime: string;
name: string;
preservesAnimation: boolean;
}
export interface ProfileAvatarTransform {
zoom: number;
offsetX: number;
offsetY: number;
}
export interface ProfileAvatarUpdates {
avatarUrl: string;
avatarHash: string;
avatarMime: string;
avatarUpdatedAt: number;
}
export interface ProcessedProfileAvatar extends ProfileAvatarUpdates, ProfileAvatarDimensions {
base64: string;
blob: Blob;
}
export function resolveProfileAvatarStorageFileName(mime: string | null | undefined): string {
switch (mime?.toLowerCase()) {
case 'image/gif':
return 'profile.gif';
case 'image/jpeg':
case 'image/jpg':
return 'profile.jpg';
default:
return 'profile.webp';
}
}
export function clampProfileAvatarZoom(zoom: number): number {
if (!Number.isFinite(zoom)) {
return PROFILE_AVATAR_MIN_ZOOM;
}
return Math.min(Math.max(zoom, PROFILE_AVATAR_MIN_ZOOM), PROFILE_AVATAR_MAX_ZOOM);
}
export function resolveProfileAvatarBaseScale(
source: ProfileAvatarDimensions,
frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE
): number {
return Math.max(frameSize / source.width, frameSize / source.height);
}
export function clampProfileAvatarTransform(
source: ProfileAvatarDimensions,
transform: ProfileAvatarTransform,
frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE
): ProfileAvatarTransform {
const zoom = clampProfileAvatarZoom(transform.zoom);
const renderedWidth = source.width * resolveProfileAvatarBaseScale(source, frameSize) * zoom;
const renderedHeight = source.height * resolveProfileAvatarBaseScale(source, frameSize) * zoom;
const maxOffsetX = Math.max(0, (renderedWidth - frameSize) / 2);
const maxOffsetY = Math.max(0, (renderedHeight - frameSize) / 2);
return {
zoom,
offsetX: clampOffset(transform.offsetX, maxOffsetX),
offsetY: clampOffset(transform.offsetY, maxOffsetY)
};
}
function clampOffset(value: number, maxMagnitude: number): number {
if (!Number.isFinite(value)) {
return 0;
}
const nextValue = Math.min(Math.max(value, -maxMagnitude), maxMagnitude);
return Object.is(nextValue, -0) ? 0 : nextValue;
}

View File

@@ -0,0 +1,153 @@
<div
class="fixed inset-0 z-[112] bg-black/70 backdrop-blur-sm"
(click)="cancelled.emit()"
(keydown.enter)="cancelled.emit()"
(keydown.space)="cancelled.emit()"
role="button"
tabindex="0"
aria-label="Close profile image editor"
></div>
<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"
>
<div class="border-b border-border p-5">
<h3 class="text-lg font-semibold text-foreground">Adjust profile picture</h3>
<p class="mt-1 text-sm text-muted-foreground">
@if (preservesAnimation()) {
Animated GIF and WebP avatars keep their original animation and framing.
} @else {
Drag image to frame subject. Zoom until preview looks right. Final image saves as 256x256 WebP.
}
</p>
</div>
<div class="grid gap-6 overflow-y-auto p-5 sm:grid-cols-[minmax(0,1fr)_280px]">
<div class="flex flex-col items-center gap-4">
<div
class="relative overflow-hidden rounded-[32px] border border-border bg-secondary/40 shadow-inner touch-none"
[style.width.px]="frameSize"
[style.height.px]="frameSize"
(pointerdown)="onPointerDown($event)"
(pointermove)="onPointerMove($event)"
(pointerup)="onPointerUp($event)"
(pointercancel)="onPointerUp($event)"
(wheel)="onWheel($event)"
>
<img
[src]="source().objectUrl"
[alt]="source().name"
class="pointer-events-none absolute left-1/2 top-1/2 max-w-none select-none"
[style.transform]="imageTransform()"
draggable="false"
/>
<div class="pointer-events-none absolute inset-0 rounded-[32px] ring-1 ring-white/10"></div>
<div class="pointer-events-none absolute inset-4 rounded-full border border-white/45 shadow-[0_0_0_999px_rgba(4,8,15,0.58)]"></div>
</div>
<p class="text-xs text-muted-foreground">
@if (preservesAnimation()) {
Animation and original framing are preserved.
} @else {
Preview matches saved crop.
}
</p>
</div>
<div class="space-y-5">
<div class="rounded-xl border border-border bg-secondary/20 p-4">
<p class="text-xs uppercase tracking-[0.18em] text-muted-foreground">Source</p>
<p class="mt-2 truncate text-sm font-medium text-foreground">{{ source().name }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ source().width }} x {{ source().height }}</p>
</div>
<div
class="rounded-xl border border-border bg-secondary/20 p-4"
[class.opacity-60]="preservesAnimation()"
>
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-foreground">Zoom</p>
<p class="text-xs text-muted-foreground">
@if (preservesAnimation()) {
Animated avatars keep the original frame sequence.
} @else {
Use wheel or slider.
}
</p>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-lg border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-secondary"
(click)="zoomBy(-0.12)"
[disabled]="preservesAnimation()"
>
-
</button>
<button
type="button"
class="rounded-lg border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-secondary"
(click)="zoomBy(0.12)"
[disabled]="preservesAnimation()"
>
+
</button>
</div>
</div>
<input
type="range"
min="1"
max="4"
step="0.01"
class="mt-4 w-full accent-primary"
[value]="clampedTransform().zoom"
(input)="onZoomInput($event)"
[disabled]="preservesAnimation()"
/>
<p class="mt-2 text-xs text-muted-foreground">
@if (preservesAnimation()) {
Animated upload detected.
} @else {
{{ (clampedTransform().zoom * 100).toFixed(0) }}% zoom
}
</p>
</div>
@if (errorMessage()) {
<div class="rounded-xl border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-200">
{{ errorMessage() }}
</div>
}
</div>
</div>
<div class="flex items-center justify-end gap-2 border-t border-border p-4">
<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()"
[disabled]="processing()"
>
Cancel
</button>
<button
type="button"
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
(click)="confirm()"
[disabled]="processing()"
>
{{ processing() ? 'Saving...' : 'Apply picture' }}
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,157 @@
import {
Component,
HostListener,
computed,
inject,
input,
output,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ProfileAvatarFacade } from '../../application/services/profile-avatar.facade';
import {
EditableProfileAvatarSource,
ProcessedProfileAvatar,
ProfileAvatarTransform,
PROFILE_AVATAR_EDITOR_FRAME_SIZE,
clampProfileAvatarTransform,
resolveProfileAvatarBaseScale
} from '../../domain/profile-avatar.models';
@Component({
selector: 'app-profile-avatar-editor',
standalone: true,
imports: [CommonModule],
templateUrl: './profile-avatar-editor.component.html'
})
export class ProfileAvatarEditorComponent {
readonly source = input.required<EditableProfileAvatarSource>();
readonly cancelled = output<void>();
readonly confirmed = output<ProcessedProfileAvatar>();
readonly frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE;
readonly processing = signal(false);
readonly errorMessage = signal<string | null>(null);
readonly preservesAnimation = computed(() => this.source().preservesAnimation);
readonly transform = signal<ProfileAvatarTransform>({ zoom: 1,
offsetX: 0,
offsetY: 0 });
readonly clampedTransform = computed(() => clampProfileAvatarTransform(this.source(), this.transform()));
readonly imageTransform = computed(() => {
const source = this.source();
const transform = this.clampedTransform();
const scale = resolveProfileAvatarBaseScale(source, this.frameSize) * transform.zoom;
return `translate(-50%, -50%) translate(${transform.offsetX}px, ${transform.offsetY}px) scale(${scale})`;
});
private readonly avatar = inject(ProfileAvatarFacade);
private dragPointerId: number | null = null;
private dragOrigin: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
@HostListener('document:keydown.escape')
onEscape(): void {
if (!this.processing()) {
this.cancelled.emit();
}
}
onZoomChange(value: string): void {
if (this.preservesAnimation()) {
return;
}
const zoom = Number(value);
this.transform.update((current) => ({
...current,
zoom
}));
}
onZoomInput(event: Event): void {
this.onZoomChange((event.target as HTMLInputElement).value);
}
zoomBy(delta: number): void {
if (this.preservesAnimation()) {
return;
}
this.transform.update((current) => ({
...current,
zoom: current.zoom + delta
}));
}
onWheel(event: WheelEvent): void {
if (this.preservesAnimation()) {
return;
}
event.preventDefault();
this.zoomBy(event.deltaY < 0 ? 0.08 : -0.08);
}
onPointerDown(event: PointerEvent): void {
if (this.processing() || this.preservesAnimation()) {
return;
}
const currentTarget = event.currentTarget as HTMLElement | null;
const currentTransform = this.clampedTransform();
currentTarget?.setPointerCapture(event.pointerId);
this.dragPointerId = event.pointerId;
this.dragOrigin = {
x: event.clientX,
y: event.clientY,
offsetX: currentTransform.offsetX,
offsetY: currentTransform.offsetY
};
}
onPointerMove(event: PointerEvent): void {
if (this.dragPointerId !== event.pointerId || !this.dragOrigin || this.processing() || this.preservesAnimation()) {
return;
}
this.transform.set(clampProfileAvatarTransform(this.source(), {
zoom: this.clampedTransform().zoom,
offsetX: this.dragOrigin.offsetX + (event.clientX - this.dragOrigin.x),
offsetY: this.dragOrigin.offsetY + (event.clientY - this.dragOrigin.y)
}));
}
onPointerUp(event: PointerEvent): void {
if (this.dragPointerId !== event.pointerId) {
return;
}
const currentTarget = event.currentTarget as HTMLElement | null;
currentTarget?.releasePointerCapture(event.pointerId);
this.dragPointerId = null;
this.dragOrigin = null;
}
async confirm(): Promise<void> {
if (this.processing()) {
return;
}
this.processing.set(true);
this.errorMessage.set(null);
try {
const avatar = await this.avatar.processEditableSource(this.source(), this.clampedTransform());
this.confirmed.emit(avatar);
} catch {
this.errorMessage.set('Failed to process profile image.');
} finally {
this.processing.set(false);
}
}
}

View File

@@ -0,0 +1,90 @@
import { Injectable, inject } from '@angular/core';
import {
Overlay,
OverlayRef
} from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
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';
@Injectable({ providedIn: 'root' })
export class ProfileAvatarEditorService {
private readonly overlay = inject(Overlay);
private overlayRef: OverlayRef | null = null;
open(source: EditableProfileAvatarSource): Promise<ProcessedProfileAvatar | null> {
this.close();
this.syncThemeVars();
const overlayRef = this.overlay.create({
disposeOnNavigation: true,
panelClass: PROFILE_AVATAR_EDITOR_OVERLAY_CLASS,
positionStrategy: this.overlay.position().global().centerHorizontally().centerVertically(),
scrollStrategy: this.overlay.scrollStrategies.block()
});
this.overlayRef = overlayRef;
const componentRef = overlayRef.attach(new ComponentPortal(ProfileAvatarEditorComponent));
componentRef.setInput('source', source);
return new Promise((resolve) => {
let settled = false;
const finish = (result: ProcessedProfileAvatar | null): void => {
if (settled) {
return;
}
settled = true;
cancelSub.unsubscribe();
confirmSub.unsubscribe();
detachSub.unsubscribe();
if (this.overlayRef === overlayRef) {
this.overlayRef = null;
}
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));
});
}
close(): void {
if (!this.overlayRef) {
return;
}
const overlayRef = this.overlayRef;
this.overlayRef = null;
overlayRef.dispose();
}
private syncThemeVars(): void {
const appRoot = document.querySelector<HTMLElement>('[data-theme-key="appRoot"]');
const container = document.querySelector<HTMLElement>('.cdk-overlay-container');
if (!appRoot || !container) {
return;
}
for (const prop of Array.from(appRoot.style)) {
if (prop.startsWith('--')) {
container.style.setProperty(prop, appRoot.style.getPropertyValue(prop));
}
}
}
}

View File

@@ -0,0 +1,7 @@
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
} from './feature/profile-avatar-editor/profile-avatar-editor.service';

View File

@@ -0,0 +1,51 @@
import {
isAnimatedGif,
isAnimatedWebp
} from './profile-avatar-image.service';
describe('profile-avatar image animation detection', () => {
it('detects animated gifs with multiple frames', () => {
const animatedGif = new Uint8Array([
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00,
0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00,
0x3B
]).buffer;
expect(isAnimatedGif(animatedGif)).toBe(true);
});
it('does not mark single-frame gifs as animated', () => {
const staticGif = new Uint8Array([
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00,
0x3B
]).buffer;
expect(isAnimatedGif(staticGif)).toBe(false);
});
it('detects animated webp files from the VP8X animation flag', () => {
const animatedWebp = new Uint8Array([
0x52, 0x49, 0x46, 0x46, 0x16, 0x00, 0x00, 0x00,
0x57, 0x45, 0x42, 0x50,
0x56, 0x50, 0x38, 0x58, 0x0A, 0x00, 0x00, 0x00,
0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
]).buffer;
expect(isAnimatedWebp(animatedWebp)).toBe(true);
});
it('does not mark static webp files as animated', () => {
const staticWebp = new Uint8Array([
0x52, 0x49, 0x46, 0x46, 0x16, 0x00, 0x00, 0x00,
0x57, 0x45, 0x42, 0x50,
0x56, 0x50, 0x38, 0x58, 0x0A, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
]).buffer;
expect(isAnimatedWebp(staticWebp)).toBe(false);
});
});

View File

@@ -0,0 +1,335 @@
import { Injectable } from '@angular/core';
import {
PROFILE_AVATAR_ALLOWED_MIME_TYPES,
PROFILE_AVATAR_OUTPUT_SIZE,
ProfileAvatarTransform,
EditableProfileAvatarSource,
ProcessedProfileAvatar,
clampProfileAvatarTransform,
PROFILE_AVATAR_EDITOR_FRAME_SIZE,
resolveProfileAvatarBaseScale
} from '../../domain/profile-avatar.models';
const PROFILE_AVATAR_OUTPUT_MIME = 'image/webp';
const PROFILE_AVATAR_OUTPUT_QUALITY = 0.92;
export function isAnimatedGif(buffer: ArrayBuffer): boolean {
const bytes = new Uint8Array(buffer);
if (bytes.length < 13 || readAscii(bytes, 0, 6) !== 'GIF87a' && readAscii(bytes, 0, 6) !== 'GIF89a') {
return false;
}
let offset = 13;
if ((bytes[10] & 0x80) !== 0) {
offset += 3 * (2 ** ((bytes[10] & 0x07) + 1));
}
let frameCount = 0;
while (offset < bytes.length) {
const blockType = bytes[offset];
if (blockType === 0x3B) {
return false;
}
if (blockType === 0x21) {
offset += 2;
while (offset < bytes.length) {
const blockSize = bytes[offset++];
if (blockSize === 0) {
break;
}
offset += blockSize;
}
continue;
}
if (blockType !== 0x2C || offset + 10 > bytes.length) {
return false;
}
frameCount++;
if (frameCount > 1) {
return true;
}
const packedFields = bytes[offset + 9];
offset += 10;
if ((packedFields & 0x80) !== 0) {
offset += 3 * (2 ** ((packedFields & 0x07) + 1));
}
offset += 1;
while (offset < bytes.length) {
const blockSize = bytes[offset++];
if (blockSize === 0) {
break;
}
offset += blockSize;
}
}
return false;
}
export function isAnimatedWebp(buffer: ArrayBuffer): boolean {
const bytes = new Uint8Array(buffer);
if (bytes.length < 16 || readAscii(bytes, 0, 4) !== 'RIFF' || readAscii(bytes, 8, 4) !== 'WEBP') {
return false;
}
let offset = 12;
while (offset + 8 <= bytes.length) {
const chunkType = readAscii(bytes, offset, 4);
const chunkSize = readUint32LittleEndian(bytes, offset + 4);
if (chunkType === 'ANIM' || chunkType === 'ANMF') {
return true;
}
if (chunkType === 'VP8X' && offset + 9 <= bytes.length) {
const featureFlags = bytes[offset + 8];
if ((featureFlags & 0x02) !== 0) {
return true;
}
}
offset += 8 + chunkSize + (chunkSize % 2);
}
return false;
}
@Injectable({ providedIn: 'root' })
export class ProfileAvatarImageService {
validateFile(file: File): string | null {
const mimeType = file.type.toLowerCase();
const normalizedName = file.name.toLowerCase();
const isAllowedMime = PROFILE_AVATAR_ALLOWED_MIME_TYPES.includes(mimeType as typeof PROFILE_AVATAR_ALLOWED_MIME_TYPES[number]);
const isAllowedExtension = normalizedName.endsWith('.webp')
|| normalizedName.endsWith('.gif')
|| normalizedName.endsWith('.jpg')
|| normalizedName.endsWith('.jpeg');
if (!isAllowedExtension || (mimeType && !isAllowedMime)) {
return 'Invalid file type. Use WebP, GIF, JPG, or JPEG.';
}
return null;
}
async prepareEditableSource(file: File): Promise<EditableProfileAvatarSource> {
const objectUrl = URL.createObjectURL(file);
const mime = this.resolveSourceMime(file);
try {
const [image, preservesAnimation] = await Promise.all([this.loadImage(objectUrl), this.detectAnimatedSource(file, mime)]);
return {
file,
objectUrl,
mime,
name: file.name,
width: image.naturalWidth,
height: image.naturalHeight,
preservesAnimation
};
} catch (error) {
URL.revokeObjectURL(objectUrl);
throw error;
}
}
releaseEditableSource(source: EditableProfileAvatarSource | null | undefined): void {
if (!source?.objectUrl) {
return;
}
URL.revokeObjectURL(source.objectUrl);
}
async processEditableSource(
source: EditableProfileAvatarSource,
transform: ProfileAvatarTransform
): Promise<ProcessedProfileAvatar> {
if (source.preservesAnimation) {
return this.processAnimatedSource(source);
}
const image = await this.loadImage(source.objectUrl);
const canvas = document.createElement('canvas');
canvas.width = PROFILE_AVATAR_OUTPUT_SIZE;
canvas.height = PROFILE_AVATAR_OUTPUT_SIZE;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Canvas not supported');
}
const clampedTransform = clampProfileAvatarTransform(source, transform);
const previewScale = resolveProfileAvatarBaseScale(source, PROFILE_AVATAR_EDITOR_FRAME_SIZE) * clampedTransform.zoom;
const renderRatio = PROFILE_AVATAR_OUTPUT_SIZE / PROFILE_AVATAR_EDITOR_FRAME_SIZE;
const drawWidth = image.naturalWidth * previewScale * renderRatio;
const drawHeight = image.naturalHeight * previewScale * renderRatio;
const drawX = (PROFILE_AVATAR_OUTPUT_SIZE - drawWidth) / 2 + clampedTransform.offsetX * renderRatio;
const drawY = (PROFILE_AVATAR_OUTPUT_SIZE - drawHeight) / 2 + clampedTransform.offsetY * renderRatio;
context.clearRect(0, 0, canvas.width, canvas.height);
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.drawImage(image, drawX, drawY, drawWidth, drawHeight);
const renderedBlob = await this.canvasToBlob(canvas, PROFILE_AVATAR_OUTPUT_MIME, PROFILE_AVATAR_OUTPUT_QUALITY);
const compressedBlob = renderedBlob;
const updatedAt = Date.now();
const dataUrl = await this.readBlobAsDataUrl(compressedBlob);
const hash = await this.computeHash(compressedBlob);
return {
blob: compressedBlob,
base64: dataUrl.split(',', 2)[1] ?? '',
avatarUrl: dataUrl,
avatarHash: hash,
avatarMime: compressedBlob.type || PROFILE_AVATAR_OUTPUT_MIME,
avatarUpdatedAt: updatedAt,
width: PROFILE_AVATAR_OUTPUT_SIZE,
height: PROFILE_AVATAR_OUTPUT_SIZE
};
}
private async processAnimatedSource(source: EditableProfileAvatarSource): Promise<ProcessedProfileAvatar> {
const updatedAt = Date.now();
const dataUrl = await this.readBlobAsDataUrl(source.file);
const hash = await this.computeHash(source.file);
return {
blob: source.file,
base64: dataUrl.split(',', 2)[1] ?? '',
avatarUrl: dataUrl,
avatarHash: hash,
avatarMime: source.mime || source.file.type || PROFILE_AVATAR_OUTPUT_MIME,
avatarUpdatedAt: updatedAt,
width: source.width,
height: source.height
};
}
private async detectAnimatedSource(file: File, mime: string): Promise<boolean> {
if (mime !== 'image/gif' && mime !== 'image/webp') {
return false;
}
const buffer = await file.arrayBuffer();
return mime === 'image/gif'
? isAnimatedGif(buffer)
: isAnimatedWebp(buffer);
}
private resolveSourceMime(file: File): string {
const mimeType = file.type.toLowerCase();
if (mimeType === 'image/jpg') {
return 'image/jpeg';
}
if (mimeType) {
return mimeType;
}
const normalizedName = file.name.toLowerCase();
if (normalizedName.endsWith('.gif')) {
return 'image/gif';
}
if (normalizedName.endsWith('.jpg') || normalizedName.endsWith('.jpeg')) {
return 'image/jpeg';
}
if (normalizedName.endsWith('.webp')) {
return 'image/webp';
}
return PROFILE_AVATAR_OUTPUT_MIME;
}
private async computeHash(blob: Blob): Promise<string> {
const buffer = await blob.arrayBuffer();
const digest = await crypto.subtle.digest('SHA-256', buffer);
return Array.from(new Uint8Array(digest))
.map((value) => value.toString(16).padStart(2, '0'))
.join('');
}
private canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
return;
}
reject(new Error('Failed to render avatar image'));
}, type, quality);
});
}
private readBlobAsDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
return;
}
reject(new Error('Failed to encode avatar image'));
};
reader.onerror = () => reject(reader.error ?? new Error('Failed to read avatar image'));
reader.readAsDataURL(blob);
});
}
private loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('Failed to load avatar image'));
image.src = url;
});
}
}
function readAscii(bytes: Uint8Array, offset: number, length: number): string {
return String.fromCharCode(...bytes.slice(offset, offset + length));
}
function readUint32LittleEndian(bytes: Uint8Array, offset: number): number {
return bytes[offset]
| (bytes[offset + 1] << 8)
| (bytes[offset + 2] << 16)
| (bytes[offset + 3] << 24);
}

View File

@@ -0,0 +1,58 @@
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';
const LEGACY_PROFILE_FILE_NAMES = [
'profile.webp',
'profile.gif',
'profile.jpg',
'profile.jpeg',
'profile.png'
];
@Injectable({ providedIn: 'root' })
export class ProfileAvatarStorageService {
private readonly electronBridge = inject(ElectronBridgeService);
async persistProcessedAvatar(
user: Pick<User, 'id' | 'username' | 'displayName'>,
avatar: Pick<ProcessedProfileAvatar, 'base64' | 'avatarMime'>
): Promise<void> {
const electronApi = this.electronBridge.getApi();
if (!electronApi) {
return;
}
const appDataPath = await electronApi.getAppDataPath();
const usernameSegment = this.sanitizePathSegment(user.username || user.displayName || user.id || 'user');
const directoryPath = `${appDataPath}/user/${usernameSegment}/profile`;
const targetFileName = resolveProfileAvatarStorageFileName(avatar.avatarMime);
await electronApi.ensureDir(directoryPath);
for (const fileName of LEGACY_PROFILE_FILE_NAMES) {
const filePath = `${directoryPath}/${fileName}`;
if (fileName !== targetFileName && await electronApi.fileExists(filePath)) {
await electronApi.deleteFile(filePath);
}
}
await electronApi.writeFile(`${directoryPath}/${targetFileName}`, avatar.base64);
}
private sanitizePathSegment(value: string): string {
const normalized = value
.trim()
.replace(/[^a-zA-Z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
return normalized || 'user';
}
}

View File

@@ -23,6 +23,7 @@
> >
<app-user-avatar <app-user-avatar
[name]="currentUser()?.displayName || '?'" [name]="currentUser()?.displayName || '?'"
[avatarUrl]="currentUser()?.avatarUrl"
size="sm" size="sm"
[status]="currentUser()?.status" [status]="currentUser()?.status"
[showStatusBadge]="true" [showStatusBadge]="true"

View File

@@ -228,10 +228,12 @@ When a peer connection enters `disconnected`, a 10-second grace period starts. I
## Data channel ## Data channel
A single ordered data channel carries all peer-to-peer messages: chat events, voice/screen state broadcasts, voice-channel move control events, state requests, pings, and screen share control. A single ordered data channel carries all peer-to-peer messages: chat events, attachment chunks, profile-avatar summary/request/full/chunk events, voice/screen state broadcasts, voice-channel move control events, state requests, pings, and screen share control.
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. 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.
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. 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.
## Media pipeline ## Media pipeline

View File

@@ -41,6 +41,7 @@ export interface ChatEventBase {
lastUpdated?: number; lastUpdated?: number;
file?: ChatAttachmentAnnouncement; file?: ChatAttachmentAnnouncement;
fileId?: string; fileId?: string;
username?: string;
hostId?: string; hostId?: string;
hostOderId?: string; hostOderId?: string;
previousHostId?: string; previousHostId?: string;
@@ -62,6 +63,10 @@ export interface ChatEventBase {
isCameraEnabled?: boolean; isCameraEnabled?: boolean;
icon?: string; icon?: string;
iconUpdatedAt?: number; iconUpdatedAt?: number;
avatarUrl?: string;
avatarHash?: string;
avatarMime?: string;
avatarUpdatedAt?: number;
role?: UserRole; role?: UserRole;
room?: Partial<Room>; room?: Partial<Room>;
channels?: Channel[]; channels?: Channel[];
@@ -263,6 +268,43 @@ export interface ServerIconUpdateEvent extends ChatEventBase {
iconUpdatedAt: number; iconUpdatedAt: number;
} }
export interface UserAvatarSummaryEvent extends ChatEventBase {
type: 'user-avatar-summary';
oderId: string;
username?: string;
displayName?: string;
avatarHash?: string;
avatarMime?: string;
avatarUpdatedAt: number;
}
export interface UserAvatarRequestEvent extends ChatEventBase {
type: 'user-avatar-request';
oderId: string;
}
export interface UserAvatarFullEvent extends ChatEventBase {
type: 'user-avatar-full';
oderId: string;
username?: string;
displayName?: string;
avatarHash?: string;
avatarMime: string;
avatarUpdatedAt: number;
total: number;
}
export interface UserAvatarChunkEvent extends ChatEventBase {
type: 'user-avatar-chunk';
oderId: string;
avatarHash?: string;
avatarMime?: string;
avatarUpdatedAt?: number;
index: number;
total: number;
data: string;
}
export interface ServerStateRequestEvent extends ChatEventBase { export interface ServerStateRequestEvent extends ChatEventBase {
type: 'server-state-request'; type: 'server-state-request';
roomId: string; roomId: string;
@@ -343,6 +385,10 @@ export type ChatEvent =
| StateRequestEvent | StateRequestEvent
| ScreenShareRequestEvent | ScreenShareRequestEvent
| ScreenShareStopEvent | ScreenShareStopEvent
| UserAvatarSummaryEvent
| UserAvatarRequestEvent
| UserAvatarFullEvent
| UserAvatarChunkEvent
| ServerIconSummaryEvent | ServerIconSummaryEvent
| ServerIconRequestEvent | ServerIconRequestEvent
| ServerIconFullEvent | ServerIconFullEvent

View File

@@ -8,3 +8,5 @@ export * from './chat-events';
export * from './media-preferences'; export * from './media-preferences';
export * from './signaling-contracts'; export * from './signaling-contracts';
export * from './attachment-contracts'; export * from './attachment-contracts';
export * from './p2p-transfer.constants';
export * from './p2p-transfer.utils';

View File

@@ -0,0 +1,2 @@
/** Shared binary chunk size for payloads streamed over RTCDataChannel. */
export const P2P_BASE64_CHUNK_SIZE_BYTES = 64 * 1024;

View File

@@ -0,0 +1,48 @@
import { P2P_BASE64_CHUNK_SIZE_BYTES } from './p2p-transfer.constants';
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
let binary = '';
const bytes = new Uint8Array(buffer);
for (let index = 0; index < bytes.byteLength; index++) {
binary += String.fromCharCode(bytes[index]);
}
return btoa(binary);
}
export function decodeBase64(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
export async function* iterateBlobChunks(
blob: Blob,
chunkSize = P2P_BASE64_CHUNK_SIZE_BYTES
): AsyncGenerator<{ base64: string; index: number; total: number }, void, undefined> {
const totalChunks = Math.ceil(blob.size / chunkSize);
let offset = 0;
let chunkIndex = 0;
while (offset < blob.size) {
const slice = blob.slice(offset, offset + chunkSize);
const arrayBuffer = await slice.arrayBuffer();
yield {
base64: arrayBufferToBase64(arrayBuffer),
index: chunkIndex,
total: totalChunks
};
offset += chunkSize;
chunkIndex++;
}
}

View File

@@ -14,6 +14,9 @@ export interface User {
username: string; username: string;
displayName: string; displayName: string;
avatarUrl?: string; avatarUrl?: string;
avatarHash?: string;
avatarMime?: string;
avatarUpdatedAt?: number;
status: UserStatus; status: UserStatus;
role: UserRole; role: UserRole;
joinedAt: number; joinedAt: number;
@@ -33,6 +36,9 @@ export interface RoomMember {
username: string; username: string;
displayName: string; displayName: string;
avatarUrl?: string; avatarUrl?: string;
avatarHash?: string;
avatarMime?: string;
avatarUpdatedAt?: number;
role: UserRole; role: UserRole;
roleIds?: string[]; roleIds?: string[];
joinedAt: number; joinedAt: number;

View File

@@ -6,6 +6,13 @@
<div class="relative px-4"> <div class="relative px-4">
<div class="-mt-9"> <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 <app-user-avatar
[name]="user().displayName" [name]="user().displayName"
[avatarUrl]="user().avatarUrl" [avatarUrl]="user().avatarUrl"
@@ -14,6 +21,25 @@
[showStatusBadge]="true" [showStatusBadge]="true"
ringClass="ring-4 ring-card" 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 {
<app-user-avatar
[name]="user().displayName"
[avatarUrl]="user().avatarUrl"
size="xl"
[status]="user().status"
[showStatusBadge]="true"
ringClass="ring-4 ring-card"
/>
}
</div> </div>
</div> </div>
@@ -21,6 +47,16 @@
<p class="truncate text-base font-semibold text-foreground">{{ user().displayName }}</p> <p class="truncate text-base font-semibold text-foreground">{{ user().displayName }}</p>
<p class="truncate text-sm text-muted-foreground">{{ user().username }}</p> <p class="truncate text-sm text-muted-foreground">{{ user().username }}</p>
@if (editable()) {
<p class="mt-2 text-xs text-muted-foreground">Click avatar to upload and crop a profile picture.</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">
{{ avatarError() }}
</div>
}
@if (editable()) { @if (editable()) {
<div class="relative mt-3"> <div class="relative mt-3">
<button <button

View File

@@ -4,11 +4,20 @@ import {
signal signal
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideChevronDown } from '@ng-icons/lucide'; import { lucideChevronDown } from '@ng-icons/lucide';
import { UserAvatarComponent } from '../user-avatar/user-avatar.component'; import { UserAvatarComponent } from '../user-avatar/user-avatar.component';
import { UserStatusService } from '../../../core/services/user-status.service'; import { UserStatusService } from '../../../core/services/user-status.service';
import { User, UserStatus } from '../../../shared-kernel'; import { User, UserStatus } from '../../../shared-kernel';
import {
EditableProfileAvatarSource,
ProfileAvatarFacade,
ProfileAvatarEditorService,
PROFILE_AVATAR_ACCEPT_ATTRIBUTE,
ProcessedProfileAvatar
} from '../../../domains/profile-avatar';
import { UsersActions } from '../../../store/users/users.actions';
@Component({ @Component({
selector: 'app-profile-card', selector: 'app-profile-card',
@@ -25,6 +34,9 @@ export class ProfileCardComponent {
readonly user = signal<User>({ id: '', oderId: '', username: '', displayName: '', status: 'offline', role: 'member', joinedAt: 0 }); readonly user = signal<User>({ id: '', oderId: '', username: '', displayName: '', status: 'offline', role: 'member', joinedAt: 0 });
readonly editable = signal(false); readonly editable = signal(false);
readonly showStatusMenu = signal(false); readonly showStatusMenu = signal(false);
readonly avatarAccept = PROFILE_AVATAR_ACCEPT_ATTRIBUTE;
readonly avatarError = signal<string | null>(null);
readonly avatarSaving = signal(false);
readonly statusOptions: { value: UserStatus | null; label: string; color: string }[] = [ readonly statusOptions: { value: UserStatus | null; label: string; color: string }[] = [
{ value: null, label: 'Online', color: 'bg-green-500' }, { value: null, label: 'Online', color: 'bg-green-500' },
@@ -34,6 +46,9 @@ export class ProfileCardComponent {
]; ];
private readonly userStatus = inject(UserStatusService); private readonly userStatus = inject(UserStatusService);
private readonly store = inject(Store);
private readonly profileAvatar = inject(ProfileAvatarFacade);
private readonly profileAvatarEditor = inject(ProfileAvatarEditorService);
currentStatusColor(): string { currentStatusColor(): string {
switch (this.user().status) { switch (this.user().status) {
@@ -65,4 +80,71 @@ export class ProfileCardComponent {
this.userStatus.setManualStatus(status); this.userStatus.setManualStatus(status);
this.showStatusMenu.set(false); this.showStatusMenu.set(false);
} }
pickAvatar(fileInput: HTMLInputElement): void {
if (!this.editable() || this.avatarSaving()) {
return;
}
this.avatarError.set(null);
fileInput.click();
}
async onAvatarSelected(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
let source: EditableProfileAvatarSource | null = null;
input.value = '';
if (!file) {
return;
}
const validationError = this.profileAvatar.validateFile(file);
if (validationError) {
this.avatarError.set(validationError);
return;
}
try {
source = await this.profileAvatar.prepareEditableSource(file);
const avatar = await this.profileAvatarEditor.open(source);
if (!avatar) {
return;
}
await this.applyAvatar(avatar);
} catch {
this.avatarError.set('Failed to open selected image.');
} finally {
this.profileAvatar.releaseEditableSource(source);
}
}
async applyAvatar(avatar: ProcessedProfileAvatar): Promise<void> {
const currentUser = this.user();
this.avatarSaving.set(true);
this.avatarError.set(null);
try {
await this.profileAvatar.persistProcessedAvatar(currentUser, avatar);
const updates = this.profileAvatar.buildAvatarUpdates(avatar);
this.store.dispatch(UsersActions.updateCurrentUserAvatar({ avatar: updates }));
this.user.update((user) => ({
...user,
...updates
}));
} catch {
this.avatarError.set('Failed to save profile image.');
} finally {
this.avatarSaving.set(false);
}
}
} }

View File

@@ -15,6 +15,7 @@ import {
fromEvent fromEvent
} from 'rxjs'; } from 'rxjs';
import { ProfileCardComponent } from './profile-card.component'; import { ProfileCardComponent } from './profile-card.component';
import { PROFILE_AVATAR_EDITOR_OVERLAY_CLASS } from '../../../domains/profile-avatar';
import { User } from '../../../shared-kernel'; import { User } from '../../../shared-kernel';
export type ProfileCardPlacement = 'above' | 'left' | 'auto'; export type ProfileCardPlacement = 'above' | 'left' | 'auto';
@@ -102,6 +103,7 @@ export class ProfileCardService {
.pipe( .pipe(
filter((event) => { filter((event) => {
const target = event.target as Node; const target = event.target as Node;
const targetElement = target instanceof Element ? target : null;
if (this.overlayRef?.overlayElement.contains(target)) if (this.overlayRef?.overlayElement.contains(target))
return false; return false;
@@ -109,6 +111,9 @@ export class ProfileCardService {
if (this.currentOrigin?.contains(target)) if (this.currentOrigin?.contains(target))
return false; return false;
if (targetElement?.closest(`.${PROFILE_AVATAR_EDITOR_OVERLAY_CLASS}`))
return false;
return true; return true;
}) })
) )
@@ -135,6 +140,11 @@ export class ProfileCardService {
if (this.overlayRef?.overlayElement.contains(event.target as Node)) if (this.overlayRef?.overlayElement.contains(event.target as Node))
return; return;
const targetElement = event.target instanceof Element ? event.target : null;
if (targetElement?.closest(`.${PROFILE_AVATAR_EDITOR_OVERLAY_CLASS}`))
return;
event.preventDefault(); event.preventDefault();
}; };
const opts: AddEventListenerOptions = { passive: false, capture: true }; const opts: AddEventListenerOptions = { passive: false, capture: true };

View File

@@ -1,10 +1,12 @@
<div class="relative inline-block"> <div class="relative inline-block">
@if (avatarUrl()) { @if (avatarUrl()) {
<img <img
[ngSrc]="avatarUrl()!" [src]="avatarUrl()!"
[width]="sizePx()" [width]="sizePx()"
[height]="sizePx()" [height]="sizePx()"
alt="" alt=""
decoding="async"
loading="lazy"
class="rounded-full object-cover" class="rounded-full object-cover"
[class]="sizeClasses() + ' ' + ringClass()" [class]="sizeClasses() + ' ' + ringClass()"
/> />

View File

@@ -1,4 +1,3 @@
import { NgOptimizedImage } from '@angular/common';
import { import {
Component, Component,
computed, computed,
@@ -9,7 +8,6 @@ import { UserStatus } from '../../../shared-kernel';
@Component({ @Component({
selector: 'app-user-avatar', selector: 'app-user-avatar',
standalone: true, standalone: true,
imports: [NgOptimizedImage],
templateUrl: './user-avatar.component.html', templateUrl: './user-avatar.component.html',
host: { host: {
style: 'display: contents;' style: 'display: contents;'

View File

@@ -72,6 +72,7 @@ export {
// Re-export effects // Re-export effects
export { MessagesEffects } from './messages/messages.effects'; export { MessagesEffects } from './messages/messages.effects';
export { MessagesSyncEffects } from './messages/messages-sync.effects'; export { MessagesSyncEffects } from './messages/messages-sync.effects';
export { UserAvatarEffects } from './users/user-avatar.effects';
export { UsersEffects } from './users/users.effects'; export { UsersEffects } from './users/users.effects';
export { RoomsEffects } from './rooms/rooms.effects'; export { RoomsEffects } from './rooms/rooms.effects';

View File

@@ -30,6 +30,37 @@ function normalizeRoleIds(roleIds: readonly string[] | undefined): string[] | un
return normalized.length > 0 ? normalized : undefined; return normalized.length > 0 ? normalized : undefined;
} }
function normalizeAvatarUpdatedAt(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) && value > 0
? value
: undefined;
}
function mergeAvatarFields(
existingMember: Pick<RoomMember, 'avatarUrl' | 'avatarHash' | 'avatarMime' | 'avatarUpdatedAt'>,
incomingMember: Pick<RoomMember, 'avatarUrl' | 'avatarHash' | 'avatarMime' | 'avatarUpdatedAt'>,
preferIncomingFallback: boolean
): Pick<RoomMember, 'avatarUrl' | 'avatarHash' | 'avatarMime' | 'avatarUpdatedAt'> {
const existingUpdatedAt = existingMember.avatarUpdatedAt ?? 0;
const incomingUpdatedAt = incomingMember.avatarUpdatedAt ?? 0;
const preferIncoming = incomingUpdatedAt === existingUpdatedAt
? preferIncomingFallback
: incomingUpdatedAt > existingUpdatedAt;
return {
avatarUrl: preferIncoming
? (incomingMember.avatarUrl || existingMember.avatarUrl)
: (existingMember.avatarUrl || incomingMember.avatarUrl),
avatarHash: preferIncoming
? (incomingMember.avatarHash || existingMember.avatarHash)
: (existingMember.avatarHash || incomingMember.avatarHash),
avatarMime: preferIncoming
? (incomingMember.avatarMime || existingMember.avatarMime)
: (existingMember.avatarMime || incomingMember.avatarMime),
avatarUpdatedAt: Math.max(existingUpdatedAt, incomingUpdatedAt) || undefined
};
}
function normalizeMember(member: RoomMember, now = Date.now()): RoomMember { function normalizeMember(member: RoomMember, now = Date.now()): RoomMember {
const key = getRoomMemberKey(member); const key = getRoomMemberKey(member);
const lastSeenAt = const lastSeenAt =
@@ -49,6 +80,9 @@ function normalizeMember(member: RoomMember, now = Date.now()): RoomMember {
username: member.username || fallbackUsername(member), username: member.username || fallbackUsername(member),
displayName: fallbackDisplayName(member), displayName: fallbackDisplayName(member),
avatarUrl: member.avatarUrl || undefined, avatarUrl: member.avatarUrl || undefined,
avatarHash: member.avatarHash || undefined,
avatarMime: member.avatarMime || undefined,
avatarUpdatedAt: normalizeAvatarUpdatedAt(member.avatarUpdatedAt),
role: member.role || 'member', role: member.role || 'member',
roleIds: normalizeRoleIds(member.roleIds), roleIds: normalizeRoleIds(member.roleIds),
joinedAt, joinedAt,
@@ -94,6 +128,7 @@ function mergeMembers(
const normalizedExisting = normalizeMember(existingMember, now); const normalizedExisting = normalizeMember(existingMember, now);
const preferIncoming = normalizedIncoming.lastSeenAt >= normalizedExisting.lastSeenAt; const preferIncoming = normalizedIncoming.lastSeenAt >= normalizedExisting.lastSeenAt;
const avatarFields = mergeAvatarFields(normalizedExisting, normalizedIncoming, preferIncoming);
return { return {
id: normalizedExisting.id || normalizedIncoming.id, id: normalizedExisting.id || normalizedIncoming.id,
@@ -104,9 +139,7 @@ function mergeMembers(
displayName: preferIncoming displayName: preferIncoming
? (normalizedIncoming.displayName || normalizedExisting.displayName) ? (normalizedIncoming.displayName || normalizedExisting.displayName)
: (normalizedExisting.displayName || normalizedIncoming.displayName), : (normalizedExisting.displayName || normalizedIncoming.displayName),
avatarUrl: preferIncoming ...avatarFields,
? (normalizedIncoming.avatarUrl || normalizedExisting.avatarUrl)
: (normalizedExisting.avatarUrl || normalizedIncoming.avatarUrl),
role: mergeRole(normalizedExisting.role, normalizedIncoming.role, preferIncoming), role: mergeRole(normalizedExisting.role, normalizedIncoming.role, preferIncoming),
roleIds: preferIncoming roleIds: preferIncoming
? (normalizedIncoming.roleIds || normalizedExisting.roleIds) ? (normalizedIncoming.roleIds || normalizedExisting.roleIds)
@@ -145,6 +178,9 @@ export function roomMemberFromUser(
username: user.username || '', username: user.username || '',
displayName: user.displayName || user.username || 'User', displayName: user.displayName || user.username || 'User',
avatarUrl: user.avatarUrl, avatarUrl: user.avatarUrl,
avatarHash: user.avatarHash,
avatarMime: user.avatarMime,
avatarUpdatedAt: user.avatarUpdatedAt,
role: roleOverride || user.role || 'member', role: roleOverride || user.role || 'member',
joinedAt: user.joinedAt || seenAt, joinedAt: user.joinedAt || seenAt,
lastSeenAt: seenAt lastSeenAt: seenAt
@@ -290,6 +326,9 @@ export function transferRoomOwnership(
username: existingNextOwner?.username || nextOwner.username || '', username: existingNextOwner?.username || nextOwner.username || '',
displayName: existingNextOwner?.displayName || nextOwner.displayName || 'User', displayName: existingNextOwner?.displayName || nextOwner.displayName || 'User',
avatarUrl: existingNextOwner?.avatarUrl || nextOwner.avatarUrl || undefined, avatarUrl: existingNextOwner?.avatarUrl || nextOwner.avatarUrl || undefined,
avatarHash: existingNextOwner?.avatarHash || nextOwner.avatarHash || undefined,
avatarMime: existingNextOwner?.avatarMime || nextOwner.avatarMime || undefined,
avatarUpdatedAt: existingNextOwner?.avatarUpdatedAt || nextOwner.avatarUpdatedAt || undefined,
role: 'host', role: 'host',
joinedAt: existingNextOwner?.joinedAt || nextOwner.joinedAt || now, joinedAt: existingNextOwner?.joinedAt || nextOwner.joinedAt || now,
lastSeenAt: existingNextOwner?.lastSeenAt || nextOwner.lastSeenAt || now lastSeenAt: existingNextOwner?.lastSeenAt || nextOwner.lastSeenAt || now

View File

@@ -49,6 +49,9 @@ export function buildKnownUserExtras(room: Room | null, identifier: string): Rec
return { return {
username: knownMember.username, username: knownMember.username,
avatarUrl: knownMember.avatarUrl, avatarUrl: knownMember.avatarUrl,
avatarHash: knownMember.avatarHash,
avatarMime: knownMember.avatarMime,
avatarUpdatedAt: knownMember.avatarUpdatedAt,
role: knownMember.role, role: knownMember.role,
joinedAt: knownMember.joinedAt joinedAt: knownMember.joinedAt
}; };

View File

@@ -0,0 +1,85 @@
import { User } from '../../shared-kernel';
import {
shouldApplyAvatarTransfer,
shouldRequestAvatarData
} from './user-avatar.effects';
function createUser(overrides: Partial<User> = {}): User {
return {
id: 'user-1',
oderId: 'oder-1',
username: 'alice',
displayName: 'Alice',
status: 'online',
role: 'member',
joinedAt: Date.now(),
...overrides
};
}
describe('user avatar sync helpers', () => {
it('requests avatar data when the remote version is newer', () => {
const existingUser = createUser({
avatarUrl: 'data:image/webp;base64,older',
avatarHash: 'hash-1',
avatarUpdatedAt: 100
});
expect(shouldRequestAvatarData(existingUser, {
avatarHash: 'hash-2',
avatarUpdatedAt: 200
})).toBe(true);
});
it('requests avatar data when metadata matches but the local payload is missing', () => {
const existingUser = createUser({
avatarHash: 'hash-1',
avatarMime: 'image/gif',
avatarUpdatedAt: 200
});
expect(shouldRequestAvatarData(existingUser, {
avatarHash: 'hash-1',
avatarUpdatedAt: 200
})).toBe(true);
});
it('does not request avatar data when the same payload is already present', () => {
const existingUser = createUser({
avatarUrl: 'data:image/gif;base64,current',
avatarHash: 'hash-1',
avatarMime: 'image/gif',
avatarUpdatedAt: 200
});
expect(shouldRequestAvatarData(existingUser, {
avatarHash: 'hash-1',
avatarUpdatedAt: 200
})).toBe(false);
});
it('applies equal-version transfers when the local payload is missing', () => {
const existingUser = createUser({
avatarHash: 'hash-1',
avatarUpdatedAt: 200
});
expect(shouldApplyAvatarTransfer(existingUser, {
hash: 'hash-1',
updatedAt: 200
})).toBe(true);
});
it('rejects older avatar transfers', () => {
const existingUser = createUser({
avatarUrl: 'data:image/gif;base64,current',
avatarHash: 'hash-2',
avatarUpdatedAt: 200
});
expect(shouldApplyAvatarTransfer(existingUser, {
hash: 'hash-1',
updatedAt: 100
})).toBe(false);
});
});

View File

@@ -0,0 +1,438 @@
import { Injectable, inject } from '@angular/core';
import {
Actions,
createEffect,
ofType
} from '@ngrx/effects';
import { Action, Store } from '@ngrx/store';
import {
EMPTY,
from,
of
} from 'rxjs';
import {
mergeMap,
tap,
withLatestFrom
} from 'rxjs/operators';
import { ProfileAvatarFacade } from '../../domains/profile-avatar';
import {
ChatEvent,
P2P_BASE64_CHUNK_SIZE_BYTES,
User,
decodeBase64,
iterateBlobChunks
} 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 { 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;
oderId: string;
total: number;
updatedAt: number;
username: string;
chunks: (string | undefined)[];
hash?: string;
}
type AvatarVersionState = Pick<User, 'avatarUrl' | 'avatarHash' | 'avatarUpdatedAt'> | undefined;
function shouldAcceptAvatarPayload(
existingUser: AvatarVersionState,
incomingUpdatedAt: number,
incomingHash?: string
): boolean {
const localUpdatedAt = existingUser?.avatarUpdatedAt ?? 0;
if (incomingUpdatedAt > localUpdatedAt) {
return true;
}
if (incomingUpdatedAt < localUpdatedAt || incomingUpdatedAt === 0) {
return false;
}
if (!existingUser?.avatarUrl) {
return true;
}
return !!incomingHash && incomingHash !== existingUser.avatarHash;
}
export function shouldRequestAvatarData(
existingUser: AvatarVersionState,
incomingAvatar: Pick<ChatEvent, 'avatarHash' | 'avatarUpdatedAt'>
): boolean {
return shouldAcceptAvatarPayload(existingUser, incomingAvatar.avatarUpdatedAt ?? 0, incomingAvatar.avatarHash);
}
export function shouldApplyAvatarTransfer(
existingUser: AvatarVersionState,
transfer: Pick<PendingAvatarTransfer, 'hash' | 'updatedAt'>
): boolean {
return shouldAcceptAvatarPayload(existingUser, transfer.updatedAt, transfer.hash);
}
@Injectable()
export class UserAvatarEffects {
private readonly actions$ = inject(Actions);
private readonly store = inject(Store);
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly db = inject(DatabaseService);
private readonly avatars = inject(ProfileAvatarFacade);
private readonly pendingTransfers = new Map<string, PendingAvatarTransfer>();
persistCurrentAvatar$ = createEffect(
() =>
this.actions$.pipe(
ofType(UsersActions.updateCurrentUserAvatar),
withLatestFrom(this.store.select(selectCurrentUser)),
tap(([, currentUser]) => {
if (currentUser) {
this.db.saveUser(currentUser);
}
})
),
{ dispatch: false }
);
persistRemoteAvatar$ = createEffect(
() =>
this.actions$.pipe(
ofType(UsersActions.upsertRemoteUserAvatar),
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;
if (!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);
})
),
{ dispatch: false }
);
syncRoomMemberAvatars$ = createEffect(() =>
this.actions$.pipe(
ofType(UsersActions.updateCurrentUserAvatar, UsersActions.upsertRemoteUserAvatar),
withLatestFrom(
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom),
this.store.select(selectSavedRooms)
),
mergeMap(([
action,
currentUser,
currentRoom,
savedRooms
]) => {
const avatarOwner = action.type === UsersActions.updateCurrentUserAvatar.type
? currentUser
: ('user' in action ? action.user : null);
if (!avatarOwner) {
return EMPTY;
}
const actions = this.buildRoomAvatarActions(avatarOwner, currentRoom, savedRooms);
return actions.length > 0 ? actions : EMPTY;
})
)
);
broadcastCurrentAvatarSummary$ = createEffect(
() =>
this.actions$.pipe(
ofType(UsersActions.updateCurrentUserAvatar),
withLatestFrom(this.store.select(selectCurrentUser)),
tap(([, currentUser]) => {
if (!currentUser?.avatarUpdatedAt) {
return;
}
this.webrtc.broadcastMessage(this.buildAvatarSummary(currentUser));
})
),
{ dispatch: false }
);
peerConnectedAvatarSummary$ = createEffect(
() =>
this.webrtc.onPeerConnected.pipe(
withLatestFrom(this.store.select(selectCurrentUser)),
tap(([peerId, currentUser]) => {
if (!currentUser?.avatarUpdatedAt) {
return;
}
this.webrtc.sendToPeer(peerId, this.buildAvatarSummary(currentUser));
})
),
{ dispatch: false }
);
incomingAvatarEvents$ = createEffect(() =>
this.webrtc.onMessageReceived.pipe(
withLatestFrom(this.store.select(selectAllUsers), this.store.select(selectCurrentUser)),
mergeMap(([
event,
allUsers,
currentUser
]) => {
switch (event.type) {
case 'user-avatar-summary':
return this.handleAvatarSummary(event, allUsers);
case 'user-avatar-request':
return this.handleAvatarRequest(event, currentUser ?? null);
case 'user-avatar-full':
return this.handleAvatarFull(event);
case 'user-avatar-chunk':
return this.handleAvatarChunk(event, allUsers);
default:
return EMPTY;
}
})
)
);
private buildAvatarSummary(user: Pick<User, 'oderId' | 'id' | 'username' | 'displayName' | 'avatarHash' | 'avatarMime' | '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
};
}
private handleAvatarSummary(event: ChatEvent, allUsers: User[]) {
if (!event.fromPeerId || !event.oderId || !event.avatarUpdatedAt) {
return EMPTY;
}
const existingUser = allUsers.find((user) => user.id === event.oderId || user.oderId === event.oderId);
if (!shouldRequestAvatarData(existingUser, event)) {
return EMPTY;
}
this.webrtc.sendToPeer(event.fromPeerId, {
type: 'user-avatar-request',
oderId: event.oderId
});
return EMPTY;
}
private handleAvatarRequest(event: ChatEvent, currentUser: User | null) {
const currentUserKey = currentUser?.oderId || currentUser?.id;
if (!event.fromPeerId || !currentUser || !currentUserKey || event.oderId !== currentUserKey || !currentUser.avatarUrl) {
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) {
return EMPTY;
}
this.pendingTransfers.set(event.oderId, {
chunks: new Array<string | undefined>(event.total),
displayName: event.displayName || 'User',
mime: event.avatarMime,
oderId: event.oderId,
total: event.total,
updatedAt: event.avatarUpdatedAt || Date.now(),
username: event.username || (event.displayName || 'User').toLowerCase().replace(/\s+/g, '_'),
hash: event.avatarHash
});
return EMPTY;
}
private handleAvatarChunk(event: ChatEvent, allUsers: User[]) {
if (!event.oderId || typeof event.index !== 'number' || typeof event.total !== 'number' || typeof event.data !== 'string') {
return EMPTY;
}
const transfer = this.pendingTransfers.get(event.oderId);
if (!transfer || transfer.total !== event.total || event.index < 0 || event.index >= transfer.total) {
return EMPTY;
}
transfer.chunks[event.index] = event.data;
if (transfer.chunks.some((chunk) => !chunk)) {
return EMPTY;
}
this.pendingTransfers.delete(event.oderId);
return from(this.buildRemoteAvatarAction(transfer, allUsers)).pipe(
mergeMap((action) => action ? of(action) : EMPTY)
);
}
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);
return UsersActions.upsertRemoteUserAvatar({
user: {
id: existingUser?.id || transfer.oderId,
oderId: existingUser?.oderId || transfer.oderId,
username: existingUser?.username || transfer.username,
displayName: existingUser?.displayName || transfer.displayName,
avatarUrl: dataUrl,
avatarHash: transfer.hash,
avatarMime: transfer.mime,
avatarUpdatedAt: transfer.updatedAt
}
});
}
private buildRoomAvatarActions(
avatarOwner: Pick<User, 'id' | 'oderId' | 'avatarUrl' | 'avatarHash' | 'avatarMime' | 'avatarUpdatedAt'>,
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 roomActions: Action[] = [];
const avatarOwnerId = avatarOwner.oderId || avatarOwner.id;
for (const room of rooms) {
const member = findRoomMember(room.members ?? [], avatarOwnerId);
if (!member) {
continue;
}
const nextMembers = (room.members ?? []).map((roomMember) => {
if (roomMember.id !== member.id && roomMember.oderId !== member.oderId) {
return roomMember;
}
return {
...roomMember,
avatarUrl: avatarOwner.avatarUrl,
avatarHash: avatarOwner.avatarHash,
avatarMime: avatarOwner.avatarMime,
avatarUpdatedAt: avatarOwner.avatarUpdatedAt
};
});
roomActions.push(RoomsActions.updateRoom({
roomId: room.id,
changes: { members: nextMembers }
}));
}
return roomActions;
}
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;
this.webrtc.sendToPeer(targetPeerId, {
type: 'user-avatar-full',
oderId: userKey,
username: user.username,
displayName: user.displayName,
avatarHash: user.avatarHash,
avatarMime: user.avatarMime || blob.type || 'image/webp',
avatarUpdatedAt: user.avatarUpdatedAt || Date.now(),
total
});
for await (const chunk of iterateBlobChunks(blob, P2P_BASE64_CHUNK_SIZE_BYTES)) {
await this.webrtc.sendToPeerBuffered(targetPeerId, {
type: 'user-avatar-chunk',
oderId: userKey,
avatarHash: user.avatarHash,
avatarMime: user.avatarMime || blob.type || 'image/webp',
avatarUpdatedAt: user.avatarUpdatedAt || Date.now(),
index: chunk.index,
total: chunk.total,
data: chunk.base64
});
}
}
private dataUrlToBlob(dataUrl: string, mimeType: string): Promise<Blob> {
const base64 = dataUrl.split(',', 2)[1] ?? '';
return Promise.resolve(new Blob([this.decodeBase64ToArrayBuffer(base64)], { type: mimeType }));
}
private decodeBase64ToArrayBuffer(base64: string): ArrayBuffer {
const decodedBytes = decodeBase64(base64);
return decodedBytes.buffer.slice(
decodedBytes.byteOffset,
decodedBytes.byteOffset + decodedBytes.byteLength
) as ArrayBuffer;
}
private readBlobAsDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
return;
}
reject(new Error('Failed to encode avatar image'));
};
reader.onerror = () => reject(reader.error ?? new Error('Failed to read avatar image'));
reader.readAsDataURL(blob);
});
}
}

View File

@@ -87,6 +87,61 @@ describe('users reducer - status', () => {
}); });
}); });
describe('avatar updates', () => {
it('updates current user avatar metadata', () => {
const state = usersReducer(baseState, UsersActions.updateCurrentUserAvatar({
avatar: {
avatarUrl: 'data:image/webp;base64,abc123',
avatarHash: 'hash-1',
avatarMime: 'image/webp',
avatarUpdatedAt: 1234
}
}));
expect(state.entities['user-1']?.avatarUrl).toBe('data:image/webp;base64,abc123');
expect(state.entities['user-1']?.avatarHash).toBe('hash-1');
expect(state.entities['user-1']?.avatarMime).toBe('image/webp');
expect(state.entities['user-1']?.avatarUpdatedAt).toBe(1234);
});
it('keeps newer remote avatar when stale update arrives later', () => {
const withRemote = usersReducer(
baseState,
UsersActions.upsertRemoteUserAvatar({
user: {
id: 'remote-1',
oderId: 'oder-remote-1',
username: 'remote',
displayName: 'Remote',
avatarUrl: 'data:image/webp;base64,newer',
avatarHash: 'hash-newer',
avatarMime: 'image/webp',
avatarUpdatedAt: 200
}
})
);
const state = usersReducer(
withRemote,
UsersActions.upsertRemoteUserAvatar({
user: {
id: 'remote-1',
oderId: 'oder-remote-1',
username: 'remote',
displayName: 'Remote',
avatarUrl: 'data:image/webp;base64,older',
avatarHash: 'hash-older',
avatarMime: 'image/webp',
avatarUpdatedAt: 100
}
})
);
expect(state.entities['remote-1']?.avatarUrl).toBe('data:image/webp;base64,newer');
expect(state.entities['remote-1']?.avatarHash).toBe('hash-newer');
expect(state.entities['remote-1']?.avatarUpdatedAt).toBe(200);
});
});
describe('presence-aware user with status', () => { describe('presence-aware user with status', () => {
it('preserves incoming status on user join', () => { it('preserves incoming status on user join', () => {
const user = createUser({ id: 'away-user', oderId: 'oder-away', status: 'away', presenceServerIds: ['server-1'] }); const user = createUser({ id: 'away-user', oderId: 'oder-away', status: 'away', presenceServerIds: ['server-1'] });

View File

@@ -59,6 +59,9 @@ export const UsersActions = createActionGroup({
'Update Camera State': props<{ userId: string; cameraState: Partial<CameraState> }>(), 'Update Camera State': props<{ userId: string; cameraState: Partial<CameraState> }>(),
'Set Manual Status': props<{ status: UserStatus | null }>(), 'Set Manual Status': props<{ status: UserStatus | null }>(),
'Update Remote User Status': props<{ userId: string; status: UserStatus }>() '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 } }>()
} }
}); });

View File

@@ -30,6 +30,39 @@ function mergePresenceServerIds(
return normalizePresenceServerIds([...(existingServerIds ?? []), ...(incomingServerIds ?? [])]); return normalizePresenceServerIds([...(existingServerIds ?? []), ...(incomingServerIds ?? [])]);
} }
interface AvatarFields {
avatarUrl?: string;
avatarHash?: string;
avatarMime?: string;
avatarUpdatedAt?: number;
}
function mergeAvatarFields(
existingValue: AvatarFields | undefined,
incomingValue: AvatarFields,
preferIncomingFallback = true
): AvatarFields {
const existingUpdatedAt = existingValue?.avatarUpdatedAt ?? 0;
const incomingUpdatedAt = incomingValue.avatarUpdatedAt ?? 0;
const preferIncoming = incomingUpdatedAt === existingUpdatedAt
? preferIncomingFallback
: incomingUpdatedAt > existingUpdatedAt;
return {
avatarUrl: preferIncoming
? (incomingValue.avatarUrl || existingValue?.avatarUrl)
: (existingValue?.avatarUrl || incomingValue.avatarUrl),
avatarHash: preferIncoming
? (incomingValue.avatarHash || existingValue?.avatarHash)
: (existingValue?.avatarHash || incomingValue.avatarHash),
avatarMime: preferIncoming
? (incomingValue.avatarMime || existingValue?.avatarMime)
: (existingValue?.avatarMime || incomingValue.avatarMime),
avatarUpdatedAt: Math.max(existingUpdatedAt, incomingUpdatedAt) || undefined
};
}
function buildDisconnectedVoiceState(user: User): User['voiceState'] { function buildDisconnectedVoiceState(user: User): User['voiceState'] {
if (!user.voiceState) { if (!user.voiceState) {
return undefined; return undefined;
@@ -83,12 +116,36 @@ function buildPresenceAwareUser(existingUser: User | undefined, incomingUser: Us
return { return {
...existingUser, ...existingUser,
...incomingUser, ...incomingUser,
...mergeAvatarFields(existingUser, incomingUser, true),
presenceServerIds, presenceServerIds,
isOnline, isOnline,
status status
}; };
} }
function buildAvatarUser(existingUser: User | undefined, incomingUser: {
id: string;
oderId: string;
username: string;
displayName: string;
avatarUrl: string;
avatarHash?: string;
avatarMime?: string;
avatarUpdatedAt?: number;
}): User {
return {
...existingUser,
id: incomingUser.id,
oderId: incomingUser.oderId,
username: incomingUser.username || existingUser?.username || 'user',
displayName: incomingUser.displayName || existingUser?.displayName || 'User',
status: existingUser?.status ?? 'offline',
role: existingUser?.role ?? 'member',
joinedAt: existingUser?.joinedAt ?? Date.now(),
...mergeAvatarFields(existingUser, incomingUser, true)
};
}
function buildPresenceRemovalChanges( function buildPresenceRemovalChanges(
user: User, user: User,
update: { serverId?: string; serverIds?: readonly string[] } update: { serverId?: string; serverIds?: readonly string[] }
@@ -173,6 +230,18 @@ export const usersReducer = createReducer(
state state
); );
}), }),
on(UsersActions.updateCurrentUserAvatar, (state, { avatar }) => {
if (!state.currentUserId)
return state;
return usersAdapter.updateOne(
{
id: state.currentUserId,
changes: mergeAvatarFields(state.entities[state.currentUserId], avatar, true)
},
state
);
}),
on(UsersActions.loadRoomUsers, (state) => ({ on(UsersActions.loadRoomUsers, (state) => ({
...state, ...state,
loading: true, loading: true,
@@ -256,6 +325,9 @@ export const usersReducer = createReducer(
state state
) )
), ),
on(UsersActions.upsertRemoteUserAvatar, (state, { user }) =>
usersAdapter.upsertOne(buildAvatarUser(state.entities[user.id], user), state)
),
on(UsersActions.updateUserRole, (state, { userId, role }) => on(UsersActions.updateUserRole, (state, { userId, role }) =>
usersAdapter.updateOne( usersAdapter.updateOne(
{ {