feat: Add profile images
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
458
e2e/tests/chat/profile-avatar-sync.spec.ts
Normal file
458
e2e/tests/chat/profile-avatar-sync.spec.ts
Normal 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)}`;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown>, 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<string, unknown>, 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "2.2MB",
|
||||
"maximumError": "2.3MB"
|
||||
"maximumError": "2.32MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -4,14 +4,16 @@
|
||||
<button
|
||||
#avatarBtn
|
||||
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)"
|
||||
>
|
||||
{{ user()!.displayName.charAt(0).toUpperCase() || '?' }}
|
||||
<span
|
||||
class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-card"
|
||||
[class]="currentStatusColor()"
|
||||
></span>
|
||||
<app-user-avatar
|
||||
[name]="user()!.displayName"
|
||||
[avatarUrl]="user()!.avatarUrl"
|
||||
size="sm"
|
||||
[status]="user()!.status"
|
||||
[showStatusBadge]="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
(click)="openSenderProfileCard($event); $event.stopPropagation()"
|
||||
>
|
||||
<app-user-avatar
|
||||
[name]="msg.senderName"
|
||||
[name]="senderUser().displayName || msg.senderName"
|
||||
[avatarUrl]="senderUser().avatarUrl"
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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<User>(() => {
|
||||
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 });
|
||||
|
||||
44
toju-app/src/app/domains/profile-avatar/README.md
Normal file
44
toju-app/src/app/domains/profile-avatar/README.md
Normal 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`.
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
7
toju-app/src/app/domains/profile-avatar/index.ts
Normal file
7
toju-app/src/app/domains/profile-avatar/index.ts
Normal 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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
>
|
||||
<app-user-avatar
|
||||
[name]="currentUser()?.displayName || '?'"
|
||||
[avatarUrl]="currentUser()?.avatarUrl"
|
||||
size="sm"
|
||||
[status]="currentUser()?.status"
|
||||
[showStatusBadge]="true"
|
||||
|
||||
@@ -228,10 +228,12 @@ When a peer connection enters `disconnected`, a 10-second grace period starts. I
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
## Media pipeline
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface ChatEventBase {
|
||||
lastUpdated?: number;
|
||||
file?: ChatAttachmentAnnouncement;
|
||||
fileId?: string;
|
||||
username?: string;
|
||||
hostId?: string;
|
||||
hostOderId?: string;
|
||||
previousHostId?: string;
|
||||
@@ -62,6 +63,10 @@ export interface ChatEventBase {
|
||||
isCameraEnabled?: boolean;
|
||||
icon?: string;
|
||||
iconUpdatedAt?: number;
|
||||
avatarUrl?: string;
|
||||
avatarHash?: string;
|
||||
avatarMime?: string;
|
||||
avatarUpdatedAt?: number;
|
||||
role?: UserRole;
|
||||
room?: Partial<Room>;
|
||||
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
|
||||
|
||||
@@ -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';
|
||||
|
||||
2
toju-app/src/app/shared-kernel/p2p-transfer.constants.ts
Normal file
2
toju-app/src/app/shared-kernel/p2p-transfer.constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** Shared binary chunk size for payloads streamed over RTCDataChannel. */
|
||||
export const P2P_BASE64_CHUNK_SIZE_BYTES = 64 * 1024;
|
||||
48
toju-app/src/app/shared-kernel/p2p-transfer.utils.ts
Normal file
48
toju-app/src/app/shared-kernel/p2p-transfer.utils.ts
Normal 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++;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -6,14 +6,40 @@
|
||||
|
||||
<div class="relative px-4">
|
||||
<div class="-mt-9">
|
||||
<app-user-avatar
|
||||
[name]="user().displayName"
|
||||
[avatarUrl]="user().avatarUrl"
|
||||
size="xl"
|
||||
[status]="user().status"
|
||||
[showStatusBadge]="true"
|
||||
ringClass="ring-4 ring-card"
|
||||
/>
|
||||
@if (editable()) {
|
||||
<button
|
||||
#avatarInputButton
|
||||
type="button"
|
||||
class="group relative rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
(click)="pickAvatar(avatarInput)"
|
||||
>
|
||||
<app-user-avatar
|
||||
[name]="user().displayName"
|
||||
[avatarUrl]="user().avatarUrl"
|
||||
size="xl"
|
||||
[status]="user().status"
|
||||
[showStatusBadge]="true"
|
||||
ringClass="ring-4 ring-card"
|
||||
/>
|
||||
<span class="pointer-events-none absolute inset-0 rounded-full bg-black/0 transition-colors group-hover:bg-black/15"></span>
|
||||
</button>
|
||||
<input
|
||||
#avatarInput
|
||||
type="file"
|
||||
class="hidden"
|
||||
[accept]="avatarAccept"
|
||||
(change)="onAvatarSelected($event)"
|
||||
/>
|
||||
} @else {
|
||||
<app-user-avatar
|
||||
[name]="user().displayName"
|
||||
[avatarUrl]="user().avatarUrl"
|
||||
size="xl"
|
||||
[status]="user().status"
|
||||
[showStatusBadge]="true"
|
||||
ringClass="ring-4 ring-card"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +47,16 @@
|
||||
<p class="truncate text-base font-semibold text-foreground">{{ user().displayName }}</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()) {
|
||||
<div class="relative mt-3">
|
||||
<button
|
||||
|
||||
@@ -4,11 +4,20 @@ import {
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideChevronDown } from '@ng-icons/lucide';
|
||||
import { UserAvatarComponent } from '../user-avatar/user-avatar.component';
|
||||
import { UserStatusService } from '../../../core/services/user-status.service';
|
||||
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({
|
||||
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 editable = 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 }[] = [
|
||||
{ value: null, label: 'Online', color: 'bg-green-500' },
|
||||
@@ -34,6 +46,9 @@ export class ProfileCardComponent {
|
||||
];
|
||||
|
||||
private readonly userStatus = inject(UserStatusService);
|
||||
private readonly store = inject(Store);
|
||||
private readonly profileAvatar = inject(ProfileAvatarFacade);
|
||||
private readonly profileAvatarEditor = inject(ProfileAvatarEditorService);
|
||||
|
||||
currentStatusColor(): string {
|
||||
switch (this.user().status) {
|
||||
@@ -65,4 +80,71 @@ export class ProfileCardComponent {
|
||||
this.userStatus.setManualStatus(status);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
fromEvent
|
||||
} from 'rxjs';
|
||||
import { ProfileCardComponent } from './profile-card.component';
|
||||
import { PROFILE_AVATAR_EDITOR_OVERLAY_CLASS } from '../../../domains/profile-avatar';
|
||||
import { User } from '../../../shared-kernel';
|
||||
|
||||
export type ProfileCardPlacement = 'above' | 'left' | 'auto';
|
||||
@@ -102,6 +103,7 @@ export class ProfileCardService {
|
||||
.pipe(
|
||||
filter((event) => {
|
||||
const target = event.target as Node;
|
||||
const targetElement = target instanceof Element ? target : null;
|
||||
|
||||
if (this.overlayRef?.overlayElement.contains(target))
|
||||
return false;
|
||||
@@ -109,6 +111,9 @@ export class ProfileCardService {
|
||||
if (this.currentOrigin?.contains(target))
|
||||
return false;
|
||||
|
||||
if (targetElement?.closest(`.${PROFILE_AVATAR_EDITOR_OVERLAY_CLASS}`))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
})
|
||||
)
|
||||
@@ -135,6 +140,11 @@ export class ProfileCardService {
|
||||
if (this.overlayRef?.overlayElement.contains(event.target as Node))
|
||||
return;
|
||||
|
||||
const targetElement = event.target instanceof Element ? event.target : null;
|
||||
|
||||
if (targetElement?.closest(`.${PROFILE_AVATAR_EDITOR_OVERLAY_CLASS}`))
|
||||
return;
|
||||
|
||||
event.preventDefault();
|
||||
};
|
||||
const opts: AddEventListenerOptions = { passive: false, capture: true };
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<div class="relative inline-block">
|
||||
@if (avatarUrl()) {
|
||||
<img
|
||||
[ngSrc]="avatarUrl()!"
|
||||
[src]="avatarUrl()!"
|
||||
[width]="sizePx()"
|
||||
[height]="sizePx()"
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
class="rounded-full object-cover"
|
||||
[class]="sizeClasses() + ' ' + ringClass()"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { NgOptimizedImage } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
@@ -9,7 +8,6 @@ import { UserStatus } from '../../../shared-kernel';
|
||||
@Component({
|
||||
selector: 'app-user-avatar',
|
||||
standalone: true,
|
||||
imports: [NgOptimizedImage],
|
||||
templateUrl: './user-avatar.component.html',
|
||||
host: {
|
||||
style: 'display: contents;'
|
||||
|
||||
@@ -72,6 +72,7 @@ export {
|
||||
// Re-export effects
|
||||
export { MessagesEffects } from './messages/messages.effects';
|
||||
export { MessagesSyncEffects } from './messages/messages-sync.effects';
|
||||
export { UserAvatarEffects } from './users/user-avatar.effects';
|
||||
export { UsersEffects } from './users/users.effects';
|
||||
export { RoomsEffects } from './rooms/rooms.effects';
|
||||
|
||||
|
||||
@@ -30,6 +30,37 @@ function normalizeRoleIds(roleIds: readonly string[] | undefined): string[] | un
|
||||
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 {
|
||||
const key = getRoomMemberKey(member);
|
||||
const lastSeenAt =
|
||||
@@ -49,6 +80,9 @@ function normalizeMember(member: RoomMember, now = Date.now()): RoomMember {
|
||||
username: member.username || fallbackUsername(member),
|
||||
displayName: fallbackDisplayName(member),
|
||||
avatarUrl: member.avatarUrl || undefined,
|
||||
avatarHash: member.avatarHash || undefined,
|
||||
avatarMime: member.avatarMime || undefined,
|
||||
avatarUpdatedAt: normalizeAvatarUpdatedAt(member.avatarUpdatedAt),
|
||||
role: member.role || 'member',
|
||||
roleIds: normalizeRoleIds(member.roleIds),
|
||||
joinedAt,
|
||||
@@ -94,6 +128,7 @@ function mergeMembers(
|
||||
|
||||
const normalizedExisting = normalizeMember(existingMember, now);
|
||||
const preferIncoming = normalizedIncoming.lastSeenAt >= normalizedExisting.lastSeenAt;
|
||||
const avatarFields = mergeAvatarFields(normalizedExisting, normalizedIncoming, preferIncoming);
|
||||
|
||||
return {
|
||||
id: normalizedExisting.id || normalizedIncoming.id,
|
||||
@@ -104,9 +139,7 @@ function mergeMembers(
|
||||
displayName: preferIncoming
|
||||
? (normalizedIncoming.displayName || normalizedExisting.displayName)
|
||||
: (normalizedExisting.displayName || normalizedIncoming.displayName),
|
||||
avatarUrl: preferIncoming
|
||||
? (normalizedIncoming.avatarUrl || normalizedExisting.avatarUrl)
|
||||
: (normalizedExisting.avatarUrl || normalizedIncoming.avatarUrl),
|
||||
...avatarFields,
|
||||
role: mergeRole(normalizedExisting.role, normalizedIncoming.role, preferIncoming),
|
||||
roleIds: preferIncoming
|
||||
? (normalizedIncoming.roleIds || normalizedExisting.roleIds)
|
||||
@@ -145,6 +178,9 @@ export function roomMemberFromUser(
|
||||
username: user.username || '',
|
||||
displayName: user.displayName || user.username || 'User',
|
||||
avatarUrl: user.avatarUrl,
|
||||
avatarHash: user.avatarHash,
|
||||
avatarMime: user.avatarMime,
|
||||
avatarUpdatedAt: user.avatarUpdatedAt,
|
||||
role: roleOverride || user.role || 'member',
|
||||
joinedAt: user.joinedAt || seenAt,
|
||||
lastSeenAt: seenAt
|
||||
@@ -290,6 +326,9 @@ export function transferRoomOwnership(
|
||||
username: existingNextOwner?.username || nextOwner.username || '',
|
||||
displayName: existingNextOwner?.displayName || nextOwner.displayName || 'User',
|
||||
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',
|
||||
joinedAt: existingNextOwner?.joinedAt || nextOwner.joinedAt || now,
|
||||
lastSeenAt: existingNextOwner?.lastSeenAt || nextOwner.lastSeenAt || now
|
||||
|
||||
@@ -49,6 +49,9 @@ export function buildKnownUserExtras(room: Room | null, identifier: string): Rec
|
||||
return {
|
||||
username: knownMember.username,
|
||||
avatarUrl: knownMember.avatarUrl,
|
||||
avatarHash: knownMember.avatarHash,
|
||||
avatarMime: knownMember.avatarMime,
|
||||
avatarUpdatedAt: knownMember.avatarUpdatedAt,
|
||||
role: knownMember.role,
|
||||
joinedAt: knownMember.joinedAt
|
||||
};
|
||||
|
||||
85
toju-app/src/app/store/users/user-avatar.effects.spec.ts
Normal file
85
toju-app/src/app/store/users/user-avatar.effects.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
438
toju-app/src/app/store/users/user-avatar.effects.ts
Normal file
438
toju-app/src/app/store/users/user-avatar.effects.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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', () => {
|
||||
it('preserves incoming status on user join', () => {
|
||||
const user = createUser({ id: 'away-user', oderId: 'oder-away', status: 'away', presenceServerIds: ['server-1'] });
|
||||
|
||||
@@ -59,6 +59,9 @@ export const UsersActions = createActionGroup({
|
||||
'Update Camera State': props<{ userId: string; cameraState: Partial<CameraState> }>(),
|
||||
|
||||
'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 } }>()
|
||||
}
|
||||
});
|
||||
|
||||
@@ -30,6 +30,39 @@ function mergePresenceServerIds(
|
||||
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'] {
|
||||
if (!user.voiceState) {
|
||||
return undefined;
|
||||
@@ -83,12 +116,36 @@ function buildPresenceAwareUser(existingUser: User | undefined, incomingUser: Us
|
||||
return {
|
||||
...existingUser,
|
||||
...incomingUser,
|
||||
...mergeAvatarFields(existingUser, incomingUser, true),
|
||||
presenceServerIds,
|
||||
isOnline,
|
||||
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(
|
||||
user: User,
|
||||
update: { serverId?: string; serverIds?: readonly string[] }
|
||||
@@ -173,6 +230,18 @@ export const usersReducer = createReducer(
|
||||
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) => ({
|
||||
...state,
|
||||
loading: true,
|
||||
@@ -256,6 +325,9 @@ export const usersReducer = createReducer(
|
||||
state
|
||||
)
|
||||
),
|
||||
on(UsersActions.upsertRemoteUserAvatar, (state, { user }) =>
|
||||
usersAdapter.upsertOne(buildAvatarUser(state.entities[user.id], user), state)
|
||||
),
|
||||
on(UsersActions.updateUserRole, (state, { userId, role }) =>
|
||||
usersAdapter.updateOne(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user