From 17738ec48476127337db6a08b751f5184759a58f Mon Sep 17 00:00:00 2001 From: Myx Date: Fri, 17 Apr 2026 03:05:47 +0200 Subject: [PATCH] feat: Add profile images --- e2e/playwright.config.ts | 2 +- e2e/tests/chat/profile-avatar-sync.spec.ts | 458 ++++++++++++++++++ electron/cqrs/commands/handlers/saveUser.ts | 3 + electron/cqrs/mappers.ts | 3 + electron/cqrs/relations.ts | 29 +- electron/cqrs/types.ts | 3 + electron/entities/RoomMemberEntity.ts | 9 + electron/entities/UserEntity.ts | 9 + .../1000000000006-AddProfileAvatarMetadata.ts | 18 + toju-app/angular.json | 2 +- toju-app/src/app/app.config.ts | 2 + toju-app/src/app/domains/README.md | 2 + .../attachment-transfer-transport.service.ts | 49 +- .../attachment-transfer.constants.ts | 3 +- .../feature/user-bar/user-bar.component.html | 14 +- .../feature/user-bar/user-bar.component.ts | 16 +- .../chat-message-item.component.html | 3 +- .../chat-message-item.component.ts | 22 +- .../src/app/domains/profile-avatar/README.md | 44 ++ .../services/profile-avatar.facade.ts | 69 +++ .../domain/profile-avatar.models.spec.ts | 38 ++ .../domain/profile-avatar.models.ts | 99 ++++ .../profile-avatar-editor.component.html | 153 ++++++ .../profile-avatar-editor.component.ts | 157 ++++++ .../profile-avatar-editor.service.ts | 90 ++++ .../src/app/domains/profile-avatar/index.ts | 7 + .../profile-avatar-image.service.spec.ts | 51 ++ .../services/profile-avatar-image.service.ts | 335 +++++++++++++ .../profile-avatar-storage.service.ts | 58 +++ .../voice-controls.component.html | 1 + .../src/app/infrastructure/realtime/README.md | 4 +- toju-app/src/app/shared-kernel/chat-events.ts | 46 ++ toju-app/src/app/shared-kernel/index.ts | 2 + .../shared-kernel/p2p-transfer.constants.ts | 2 + .../app/shared-kernel/p2p-transfer.utils.ts | 48 ++ toju-app/src/app/shared-kernel/user.models.ts | 6 + .../profile-card/profile-card.component.html | 52 +- .../profile-card/profile-card.component.ts | 82 ++++ .../profile-card/profile-card.service.ts | 10 + .../user-avatar/user-avatar.component.html | 4 +- .../user-avatar/user-avatar.component.ts | 2 - toju-app/src/app/store/index.ts | 1 + .../app/store/rooms/room-members.helpers.ts | 45 +- toju-app/src/app/store/rooms/rooms.helpers.ts | 3 + .../store/users/user-avatar.effects.spec.ts | 85 ++++ .../app/store/users/user-avatar.effects.ts | 438 +++++++++++++++++ .../store/users/users-status.reducer.spec.ts | 55 +++ toju-app/src/app/store/users/users.actions.ts | 5 +- toju-app/src/app/store/users/users.reducer.ts | 72 +++ 49 files changed, 2622 insertions(+), 89 deletions(-) create mode 100644 e2e/tests/chat/profile-avatar-sync.spec.ts create mode 100644 electron/migrations/1000000000006-AddProfileAvatarMetadata.ts create mode 100644 toju-app/src/app/domains/profile-avatar/README.md create mode 100644 toju-app/src/app/domains/profile-avatar/application/services/profile-avatar.facade.ts create mode 100644 toju-app/src/app/domains/profile-avatar/domain/profile-avatar.models.spec.ts create mode 100644 toju-app/src/app/domains/profile-avatar/domain/profile-avatar.models.ts create mode 100644 toju-app/src/app/domains/profile-avatar/feature/profile-avatar-editor/profile-avatar-editor.component.html create mode 100644 toju-app/src/app/domains/profile-avatar/feature/profile-avatar-editor/profile-avatar-editor.component.ts create mode 100644 toju-app/src/app/domains/profile-avatar/feature/profile-avatar-editor/profile-avatar-editor.service.ts create mode 100644 toju-app/src/app/domains/profile-avatar/index.ts create mode 100644 toju-app/src/app/domains/profile-avatar/infrastructure/services/profile-avatar-image.service.spec.ts create mode 100644 toju-app/src/app/domains/profile-avatar/infrastructure/services/profile-avatar-image.service.ts create mode 100644 toju-app/src/app/domains/profile-avatar/infrastructure/services/profile-avatar-storage.service.ts create mode 100644 toju-app/src/app/shared-kernel/p2p-transfer.constants.ts create mode 100644 toju-app/src/app/shared-kernel/p2p-transfer.utils.ts create mode 100644 toju-app/src/app/store/users/user-avatar.effects.spec.ts create mode 100644 toju-app/src/app/store/users/user-avatar.effects.ts diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 9f48303..0ae4a2c 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -29,7 +29,7 @@ export default defineConfig({ ], webServer: { command: 'cd ../toju-app && npx ng serve', - port: 4200, + url: 'http://localhost:4200', reuseExistingServer: !process.env.CI, timeout: 120_000 } diff --git a/e2e/tests/chat/profile-avatar-sync.spec.ts b/e2e/tests/chat/profile-avatar-sync.spec.ts new file mode 100644 index 0000000..37e6c92 --- /dev/null +++ b/e2e/tests/chat/profile-avatar-sync.spec.ts @@ -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 { + 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 { + await closePersistentClient(client); + + const session = await launchPersistentSession(client.userDataDir, testServerPort); + + client.context = session.context; + client.page = session.page; +} + +async function closePersistentClient(client: PersistentClient): Promise { + try { + await client.context.close(); + } catch { + // Ignore repeated cleanup attempts during finally. + } +} + +async function launchPersistentSession( + userDataDir: string, + testServerPort: number +): Promise<{ context: BrowserContext; page: Page }> { + const context = await chromium.launchPersistentContext(userDataDir, { + args: CLIENT_LAUNCH_ARGS, + baseURL: 'http://localhost:4200', + permissions: ['microphone', 'camera'] + }); + + await installTestServerEndpoint(context, testServerPort); + + const page = context.pages()[0] ?? await context.newPage(); + + return { context, page }; +} + +async function registerUser(client: PersistentClient): Promise { + const registerPage = new RegisterPage(client.page); + + await retryTransientNavigation(() => registerPage.goto()); + await registerPage.register(client.user.username, client.user.displayName, client.user.password); + await expect(client.page).toHaveURL(/\/search/, { timeout: 15_000 }); +} + +async function joinServerFromSearch(page: Page, serverName: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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(navigate: () => Promise, attempts = 4): Promise { + let lastError: unknown; + + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + return await navigate(); + } catch (error) { + lastError = error; + + const message = error instanceof Error ? error.message : String(error); + const isTransientNavigationError = message.includes('ERR_EMPTY_RESPONSE') || message.includes('ERR_CONNECTION_RESET'); + + if (!isTransientNavigationError || attempt === attempts) { + throw error; + } + } + } + + throw lastError instanceof Error + ? lastError + : new Error(`Navigation failed after ${attempts} attempts`); +} + +async function waitForRoomReady(page: Page): Promise { + const messagesPage = new ChatMessagesPage(page); + + await messagesPage.waitForReady(); + await expect(page.locator('app-rooms-side-panel').last()).toBeVisible({ timeout: 15_000 }); +} + +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 { + await expect(getUserRow(page, displayName)).toBeVisible({ timeout: 20_000 }); +} + +async function expectSidebarAvatar(page: Page, displayName: string, expectedDataUrl: string): Promise { + 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 { + 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 { + 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)}`; +} diff --git a/electron/cqrs/commands/handlers/saveUser.ts b/electron/cqrs/commands/handlers/saveUser.ts index b9ececc..b39853e 100644 --- a/electron/cqrs/commands/handlers/saveUser.ts +++ b/electron/cqrs/commands/handlers/saveUser.ts @@ -11,6 +11,9 @@ export async function handleSaveUser(command: SaveUserCommand, dataSource: DataS username: user.username ?? null, displayName: user.displayName ?? null, avatarUrl: user.avatarUrl ?? null, + avatarHash: user.avatarHash ?? null, + avatarMime: user.avatarMime ?? null, + avatarUpdatedAt: user.avatarUpdatedAt ?? null, status: user.status ?? null, role: user.role ?? null, joinedAt: user.joinedAt ?? null, diff --git a/electron/cqrs/mappers.ts b/electron/cqrs/mappers.ts index b33cdd6..57644b3 100644 --- a/electron/cqrs/mappers.ts +++ b/electron/cqrs/mappers.ts @@ -47,6 +47,9 @@ export function rowToUser(row: UserEntity) { username: row.username ?? '', displayName: row.displayName ?? '', avatarUrl: row.avatarUrl ?? undefined, + avatarHash: row.avatarHash ?? undefined, + avatarMime: row.avatarMime ?? undefined, + avatarUpdatedAt: row.avatarUpdatedAt ?? undefined, status: row.status ?? 'offline', role: row.role ?? 'member', joinedAt: row.joinedAt ?? 0, diff --git a/electron/cqrs/relations.ts b/electron/cqrs/relations.ts index 9cfb76d..0139461 100644 --- a/electron/cqrs/relations.ts +++ b/electron/cqrs/relations.ts @@ -67,6 +67,9 @@ export interface RoomMemberRecord { username: string; displayName: string; avatarUrl?: string; + avatarHash?: string; + avatarMime?: string; + avatarUpdatedAt?: number; role: RoomMemberRole; roleIds?: string[]; joinedAt: number; @@ -336,6 +339,9 @@ function normalizeRoomMember(rawMember: Record, now: number): R const username = trimmedString(rawMember, 'username'); const displayName = trimmedString(rawMember, 'displayName'); const avatarUrl = trimmedString(rawMember, 'avatarUrl'); + const avatarHash = trimmedString(rawMember, 'avatarHash'); + const avatarMime = trimmedString(rawMember, 'avatarMime'); + const avatarUpdatedAt = isFiniteNumber(rawMember['avatarUpdatedAt']) ? rawMember['avatarUpdatedAt'] : undefined; return { id: normalizedId || normalizedKey, @@ -343,6 +349,9 @@ function normalizeRoomMember(rawMember: Record, now: number): R username: username || fallbackUsername({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, displayName }), displayName: displayName || fallbackDisplayName({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, username }), avatarUrl: avatarUrl || undefined, + avatarHash: avatarHash || undefined, + avatarMime: avatarMime || undefined, + avatarUpdatedAt, role: normalizeRoomMemberRole(rawMember['role']), roleIds: uniqueStrings(Array.isArray(rawMember['roleIds']) ? rawMember['roleIds'] as string[] : undefined), joinedAt, @@ -356,6 +365,11 @@ function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incoming } const preferIncoming = incomingMember.lastSeenAt >= existingMember.lastSeenAt; + const existingAvatarUpdatedAt = existingMember.avatarUpdatedAt ?? 0; + const incomingAvatarUpdatedAt = incomingMember.avatarUpdatedAt ?? 0; + const preferIncomingAvatar = incomingAvatarUpdatedAt === existingAvatarUpdatedAt + ? preferIncoming + : incomingAvatarUpdatedAt > existingAvatarUpdatedAt; return { id: existingMember.id || incomingMember.id, @@ -366,9 +380,16 @@ function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incoming displayName: preferIncoming ? (incomingMember.displayName || existingMember.displayName) : (existingMember.displayName || incomingMember.displayName), - avatarUrl: preferIncoming + avatarUrl: preferIncomingAvatar ? (incomingMember.avatarUrl || existingMember.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), roleIds: preferIncoming ? (incomingMember.roleIds || existingMember.roleIds) @@ -760,6 +781,9 @@ export async function replaceRoomRelations( username: member.username, displayName: member.displayName, avatarUrl: member.avatarUrl ?? null, + avatarHash: member.avatarHash ?? null, + avatarMime: member.avatarMime ?? null, + avatarUpdatedAt: member.avatarUpdatedAt ?? null, role: member.role, joinedAt: member.joinedAt, lastSeenAt: member.lastSeenAt @@ -907,6 +931,9 @@ export async function loadRoomRelationsMap( username: row.username, displayName: row.displayName, avatarUrl: row.avatarUrl ?? undefined, + avatarHash: row.avatarHash ?? undefined, + avatarMime: row.avatarMime ?? undefined, + avatarUpdatedAt: row.avatarUpdatedAt ?? undefined, role: row.role, joinedAt: row.joinedAt, lastSeenAt: row.lastSeenAt diff --git a/electron/cqrs/types.ts b/electron/cqrs/types.ts index d8f14d1..83cf792 100644 --- a/electron/cqrs/types.ts +++ b/electron/cqrs/types.ts @@ -106,6 +106,9 @@ export interface UserPayload { username?: string; displayName?: string; avatarUrl?: string; + avatarHash?: string; + avatarMime?: string; + avatarUpdatedAt?: number; status?: string; role?: string; joinedAt?: number; diff --git a/electron/entities/RoomMemberEntity.ts b/electron/entities/RoomMemberEntity.ts index 73f78eb..bb56784 100644 --- a/electron/entities/RoomMemberEntity.ts +++ b/electron/entities/RoomMemberEntity.ts @@ -27,6 +27,15 @@ export class RoomMemberEntity { @Column('text', { nullable: true }) 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') role!: 'host' | 'admin' | 'moderator' | 'member'; diff --git a/electron/entities/UserEntity.ts b/electron/entities/UserEntity.ts index 291d15d..172d5e2 100644 --- a/electron/entities/UserEntity.ts +++ b/electron/entities/UserEntity.ts @@ -21,6 +21,15 @@ export class UserEntity { @Column('text', { nullable: true }) 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 }) status!: string | null; diff --git a/electron/migrations/1000000000006-AddProfileAvatarMetadata.ts b/electron/migrations/1000000000006-AddProfileAvatarMetadata.ts new file mode 100644 index 0000000..945d1cd --- /dev/null +++ b/electron/migrations/1000000000006-AddProfileAvatarMetadata.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddProfileAvatarMetadata1000000000006 implements MigrationInterface { + name = 'AddProfileAvatarMetadata1000000000006'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + // SQLite column removal requires table rebuilds. Keep rollback no-op. + } +} diff --git a/toju-app/angular.json b/toju-app/angular.json index 087a4e4..5fbe615 100644 --- a/toju-app/angular.json +++ b/toju-app/angular.json @@ -97,7 +97,7 @@ { "type": "initial", "maximumWarning": "2.2MB", - "maximumError": "2.3MB" + "maximumError": "2.32MB" }, { "type": "anyComponentStyle", diff --git a/toju-app/src/app/app.config.ts b/toju-app/src/app/app.config.ts index 9613fcd..ab7abdd 100644 --- a/toju-app/src/app/app.config.ts +++ b/toju-app/src/app/app.config.ts @@ -16,6 +16,7 @@ import { roomsReducer } from './store/rooms/rooms.reducer'; import { NotificationsEffects } from './domains/notifications'; import { MessagesEffects } from './store/messages/messages.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 { RoomsEffects } from './store/rooms/rooms.effects'; import { RoomMembersSyncEffects } from './store/rooms/room-members-sync.effects'; @@ -38,6 +39,7 @@ export const appConfig: ApplicationConfig = { NotificationsEffects, MessagesEffects, MessagesSyncEffects, + UserAvatarEffects, UsersEffects, RoomsEffects, RoomMembersSyncEffects, diff --git a/toju-app/src/app/domains/README.md b/toju-app/src/app/domains/README.md index f07dfa5..061576b 100644 --- a/toju-app/src/app/domains/README.md +++ b/toju-app/src/app/domains/README.md @@ -13,6 +13,7 @@ infrastructure adapters and UI. | **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` | | **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` | | **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` | | **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` | @@ -28,6 +29,7 @@ The larger domains also keep longer design notes in their own folders: - [authentication/README.md](authentication/README.md) - [chat/README.md](chat/README.md) - [notifications/README.md](notifications/README.md) +- [profile-avatar/README.md](profile-avatar/README.md) - [screen-share/README.md](screen-share/README.md) - [server-directory/README.md](server-directory/README.md) - [voice-connection/README.md](voice-connection/README.md) diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-transfer-transport.service.ts b/toju-app/src/app/domains/attachment/application/services/attachment-transfer-transport.service.ts index dc5174f..bfcc33b 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-transfer-transport.service.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-transfer-transport.service.ts @@ -3,6 +3,11 @@ import { RealtimeSessionFacade } from '../../../../core/realtime'; import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service'; import { FILE_CHUNK_SIZE_BYTES } from '../../domain/constants/attachment-transfer.constants'; import { FileChunkEvent } from '../../domain/models/attachment-transfer.model'; +import { + arrayBufferToBase64, + decodeBase64, + iterateBlobChunks +} from '../../../../shared-kernel'; @Injectable({ providedIn: 'root' }) export class AttachmentTransferTransportService { @@ -10,14 +15,7 @@ export class AttachmentTransferTransportService { private readonly attachmentStorage = inject(AttachmentStorageService); 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; + return decodeBase64(base64); } async streamFileToPeer( @@ -27,31 +25,20 @@ export class AttachmentTransferTransportService { file: File, isCancelled: () => boolean ): Promise { - const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE_BYTES); - - let offset = 0; - let chunkIndex = 0; - - while (offset < file.size) { + for await (const chunk of iterateBlobChunks(file, FILE_CHUNK_SIZE_BYTES)) { if (isCancelled()) break; - const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES); - const arrayBuffer = await slice.arrayBuffer(); - const base64 = this.arrayBufferToBase64(arrayBuffer); const fileChunkEvent: FileChunkEvent = { type: 'file-chunk', messageId, fileId, - index: chunkIndex, - total: totalChunks, - data: base64 + index: chunk.index, + total: chunk.total, + data: chunk.base64 }; await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent); - - offset += FILE_CHUNK_SIZE_BYTES; - chunkIndex++; } } @@ -67,7 +54,7 @@ export class AttachmentTransferTransportService { if (!base64Full) return; - const fileBytes = this.decodeBase64(base64Full); + const fileBytes = decodeBase64(base64Full); const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES); for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { @@ -81,7 +68,7 @@ export class AttachmentTransferTransportService { slice.byteOffset, slice.byteOffset + slice.byteLength ); - const base64Chunk = this.arrayBufferToBase64(sliceBuffer); + const base64Chunk = arrayBufferToBase64(sliceBuffer); const fileChunkEvent: FileChunkEvent = { type: 'file-chunk', messageId, @@ -94,16 +81,4 @@ export class AttachmentTransferTransportService { 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); - } } diff --git a/toju-app/src/app/domains/attachment/domain/constants/attachment-transfer.constants.ts b/toju-app/src/app/domains/attachment/domain/constants/attachment-transfer.constants.ts index fe8d0b9..a15861d 100644 --- a/toju-app/src/app/domains/attachment/domain/constants/attachment-transfer.constants.ts +++ b/toju-app/src/app/domains/attachment/domain/constants/attachment-transfer.constants.ts @@ -1,5 +1,4 @@ -/** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */ -export const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB +export { P2P_BASE64_CHUNK_SIZE_BYTES as FILE_CHUNK_SIZE_BYTES } from '../../../../shared-kernel/p2p-transfer.constants'; /** * EWMA smoothing weight for the previous speed estimate. diff --git a/toju-app/src/app/domains/authentication/feature/user-bar/user-bar.component.html b/toju-app/src/app/domains/authentication/feature/user-bar/user-bar.component.html index eaa9abf..49dccc8 100644 --- a/toju-app/src/app/domains/authentication/feature/user-bar/user-bar.component.html +++ b/toju-app/src/app/domains/authentication/feature/user-bar/user-bar.component.html @@ -4,14 +4,16 @@ } @else { diff --git a/toju-app/src/app/domains/authentication/feature/user-bar/user-bar.component.ts b/toju-app/src/app/domains/authentication/feature/user-bar/user-bar.component.ts index 50281ab..eff6e16 100644 --- a/toju-app/src/app/domains/authentication/feature/user-bar/user-bar.component.ts +++ b/toju-app/src/app/domains/authentication/feature/user-bar/user-bar.component.ts @@ -6,11 +6,12 @@ import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucideLogIn, lucideUserPlus } from '@ng-icons/lucide'; import { selectCurrentUser } from '../../../../store/users/users.selectors'; import { ProfileCardService } from '../../../../shared/components/profile-card/profile-card.service'; +import { UserAvatarComponent } from '../../../../shared'; @Component({ selector: 'app-user-bar', standalone: true, - imports: [CommonModule, NgIcon], + imports: [CommonModule, NgIcon, UserAvatarComponent], viewProviders: [ provideIcons({ lucideLogIn, @@ -28,19 +29,6 @@ export class UserBarComponent { private router = inject(Router); 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 { const user = this.user(); diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html index 20f05ff..736359a 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html @@ -11,7 +11,8 @@ (click)="openSenderProfileCard($event); $event.stopPropagation()" > diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts index f978701..06a3ba4 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts @@ -146,17 +146,11 @@ export class ChatMessageItemComponent { readonly deletedMessageContent = DELETED_MESSAGE_CONTENT; readonly isEditing = signal(false); readonly showEmojiPicker = signal(false); - - editContent = ''; - - openSenderProfileCard(event: MouseEvent): void { - event.stopPropagation(); - const el = event.currentTarget as HTMLElement; + readonly senderUser = computed(() => { const msg = this.message(); - // Look up full user from store - const users = this.allUsers(); - const found = users.find((userEntry) => userEntry.id === msg.senderId || userEntry.oderId === msg.senderId); - const user: User = found ?? { + const found = this.allUsers().find((userEntry) => userEntry.id === msg.senderId || userEntry.oderId === msg.senderId); + + return found ?? { id: msg.senderId, oderId: msg.senderId, username: msg.senderName, @@ -165,6 +159,14 @@ export class ChatMessageItemComponent { role: 'member', 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(); this.profileCard.open(el, user, { editable }); diff --git a/toju-app/src/app/domains/profile-avatar/README.md b/toju-app/src/app/domains/profile-avatar/README.md new file mode 100644 index 0000000..b4ded61 --- /dev/null +++ b/toju-app/src/app/domains/profile-avatar/README.md @@ -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//profile/profile.` 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`. diff --git a/toju-app/src/app/domains/profile-avatar/application/services/profile-avatar.facade.ts b/toju-app/src/app/domains/profile-avatar/application/services/profile-avatar.facade.ts new file mode 100644 index 0000000..4b97185 --- /dev/null +++ b/toju-app/src/app/domains/profile-avatar/application/services/profile-avatar.facade.ts @@ -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 { + return this.image.prepareEditableSource(file); + } + + releaseEditableSource(source: EditableProfileAvatarSource | null | undefined): void { + this.image.releaseEditableSource(source); + } + + processEditableSource( + source: EditableProfileAvatarSource, + transform: ProfileAvatarTransform + ): Promise { + return this.image.processEditableSource(source, transform); + } + + persistProcessedAvatar( + user: Pick, + avatar: ProcessedProfileAvatar + ): Promise { + return this.storage.persistProcessedAvatar(user, avatar); + } + + persistAvatarDataUrl( + user: Pick, + avatarUrl: string | null | undefined + ): Promise { + 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 + }; + } +} diff --git a/toju-app/src/app/domains/profile-avatar/domain/profile-avatar.models.spec.ts b/toju-app/src/app/domains/profile-avatar/domain/profile-avatar.models.spec.ts new file mode 100644 index 0000000..e3e890a --- /dev/null +++ b/toju-app/src/app/domains/profile-avatar/domain/profile-avatar.models.spec.ts @@ -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'); + }); +}); diff --git a/toju-app/src/app/domains/profile-avatar/domain/profile-avatar.models.ts b/toju-app/src/app/domains/profile-avatar/domain/profile-avatar.models.ts new file mode 100644 index 0000000..0e3849a --- /dev/null +++ b/toju-app/src/app/domains/profile-avatar/domain/profile-avatar.models.ts @@ -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; +} diff --git a/toju-app/src/app/domains/profile-avatar/feature/profile-avatar-editor/profile-avatar-editor.component.html b/toju-app/src/app/domains/profile-avatar/feature/profile-avatar-editor/profile-avatar-editor.component.html new file mode 100644 index 0000000..1f1cb97 --- /dev/null +++ b/toju-app/src/app/domains/profile-avatar/feature/profile-avatar-editor/profile-avatar-editor.component.html @@ -0,0 +1,153 @@ +
+ +
+ +
diff --git a/toju-app/src/app/domains/profile-avatar/feature/profile-avatar-editor/profile-avatar-editor.component.ts b/toju-app/src/app/domains/profile-avatar/feature/profile-avatar-editor/profile-avatar-editor.component.ts new file mode 100644 index 0000000..33678b7 --- /dev/null +++ b/toju-app/src/app/domains/profile-avatar/feature/profile-avatar-editor/profile-avatar-editor.component.ts @@ -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(); + + readonly cancelled = output(); + readonly confirmed = output(); + + readonly frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE; + readonly processing = signal(false); + readonly errorMessage = signal(null); + readonly preservesAnimation = computed(() => this.source().preservesAnimation); + readonly transform = signal({ 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 { + 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); + } + } +} diff --git a/toju-app/src/app/domains/profile-avatar/feature/profile-avatar-editor/profile-avatar-editor.service.ts b/toju-app/src/app/domains/profile-avatar/feature/profile-avatar-editor/profile-avatar-editor.service.ts new file mode 100644 index 0000000..0a250b6 --- /dev/null +++ b/toju-app/src/app/domains/profile-avatar/feature/profile-avatar-editor/profile-avatar-editor.service.ts @@ -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 { + 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('[data-theme-key="appRoot"]'); + const container = document.querySelector('.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)); + } + } + } +} diff --git a/toju-app/src/app/domains/profile-avatar/index.ts b/toju-app/src/app/domains/profile-avatar/index.ts new file mode 100644 index 0000000..e0eef13 --- /dev/null +++ b/toju-app/src/app/domains/profile-avatar/index.ts @@ -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'; diff --git a/toju-app/src/app/domains/profile-avatar/infrastructure/services/profile-avatar-image.service.spec.ts b/toju-app/src/app/domains/profile-avatar/infrastructure/services/profile-avatar-image.service.spec.ts new file mode 100644 index 0000000..867964b --- /dev/null +++ b/toju-app/src/app/domains/profile-avatar/infrastructure/services/profile-avatar-image.service.spec.ts @@ -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); + }); +}); diff --git a/toju-app/src/app/domains/profile-avatar/infrastructure/services/profile-avatar-image.service.ts b/toju-app/src/app/domains/profile-avatar/infrastructure/services/profile-avatar-image.service.ts new file mode 100644 index 0000000..bccc36e --- /dev/null +++ b/toju-app/src/app/domains/profile-avatar/infrastructure/services/profile-avatar-image.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/toju-app/src/app/domains/profile-avatar/infrastructure/services/profile-avatar-storage.service.ts b/toju-app/src/app/domains/profile-avatar/infrastructure/services/profile-avatar-storage.service.ts new file mode 100644 index 0000000..03a547d --- /dev/null +++ b/toju-app/src/app/domains/profile-avatar/infrastructure/services/profile-avatar-storage.service.ts @@ -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, + avatar: Pick + ): Promise { + 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'; + } +} diff --git a/toju-app/src/app/domains/voice-session/feature/voice-controls/voice-controls.component.html b/toju-app/src/app/domains/voice-session/feature/voice-controls/voice-controls.component.html index 56672b9..7347b2b 100644 --- a/toju-app/src/app/domains/voice-session/feature/voice-controls/voice-controls.component.html +++ b/toju-app/src/app/domains/voice-session/feature/voice-controls/voice-controls.component.html @@ -23,6 +23,7 @@ > ; channels?: Channel[]; @@ -263,6 +268,43 @@ export interface ServerIconUpdateEvent extends ChatEventBase { 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 { type: 'server-state-request'; roomId: string; @@ -343,6 +385,10 @@ export type ChatEvent = | StateRequestEvent | ScreenShareRequestEvent | ScreenShareStopEvent + | UserAvatarSummaryEvent + | UserAvatarRequestEvent + | UserAvatarFullEvent + | UserAvatarChunkEvent | ServerIconSummaryEvent | ServerIconRequestEvent | ServerIconFullEvent diff --git a/toju-app/src/app/shared-kernel/index.ts b/toju-app/src/app/shared-kernel/index.ts index 2311b39..7dd1fbe 100644 --- a/toju-app/src/app/shared-kernel/index.ts +++ b/toju-app/src/app/shared-kernel/index.ts @@ -8,3 +8,5 @@ export * from './chat-events'; export * from './media-preferences'; export * from './signaling-contracts'; export * from './attachment-contracts'; +export * from './p2p-transfer.constants'; +export * from './p2p-transfer.utils'; diff --git a/toju-app/src/app/shared-kernel/p2p-transfer.constants.ts b/toju-app/src/app/shared-kernel/p2p-transfer.constants.ts new file mode 100644 index 0000000..0583cc5 --- /dev/null +++ b/toju-app/src/app/shared-kernel/p2p-transfer.constants.ts @@ -0,0 +1,2 @@ +/** Shared binary chunk size for payloads streamed over RTCDataChannel. */ +export const P2P_BASE64_CHUNK_SIZE_BYTES = 64 * 1024; diff --git a/toju-app/src/app/shared-kernel/p2p-transfer.utils.ts b/toju-app/src/app/shared-kernel/p2p-transfer.utils.ts new file mode 100644 index 0000000..30600b9 --- /dev/null +++ b/toju-app/src/app/shared-kernel/p2p-transfer.utils.ts @@ -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++; + } +} diff --git a/toju-app/src/app/shared-kernel/user.models.ts b/toju-app/src/app/shared-kernel/user.models.ts index 724d06e..a0911b0 100644 --- a/toju-app/src/app/shared-kernel/user.models.ts +++ b/toju-app/src/app/shared-kernel/user.models.ts @@ -14,6 +14,9 @@ export interface User { username: string; displayName: string; avatarUrl?: string; + avatarHash?: string; + avatarMime?: string; + avatarUpdatedAt?: number; status: UserStatus; role: UserRole; joinedAt: number; @@ -33,6 +36,9 @@ export interface RoomMember { username: string; displayName: string; avatarUrl?: string; + avatarHash?: string; + avatarMime?: string; + avatarUpdatedAt?: number; role: UserRole; roleIds?: string[]; joinedAt: number; diff --git a/toju-app/src/app/shared/components/profile-card/profile-card.component.html b/toju-app/src/app/shared/components/profile-card/profile-card.component.html index a6a78fc..e616f0e 100644 --- a/toju-app/src/app/shared/components/profile-card/profile-card.component.html +++ b/toju-app/src/app/shared/components/profile-card/profile-card.component.html @@ -6,14 +6,40 @@
- + @if (editable()) { + + + } @else { + + }
@@ -21,6 +47,16 @@

{{ user().displayName }}

{{ user().username }}

+ @if (editable()) { +

Click avatar to upload and crop a profile picture.

+ } + + @if (avatarError()) { +
+ {{ avatarError() }} +
+ } + @if (editable()) {