Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd21568726 | |||
| 3ba8a2c9eb |
@@ -6,7 +6,7 @@ export default defineConfig({
|
|||||||
expect: { timeout: 10_000 },
|
expect: { timeout: 10_000 },
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
reporter: [['html', { outputFolder: '../test-results/html-report' }], ['list']],
|
reporter: [['html', { outputFolder: '../test-results/html-report', open: 'never' }], ['list']],
|
||||||
outputDir: '../test-results/artifacts',
|
outputDir: '../test-results/artifacts',
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://localhost:4200',
|
baseURL: 'http://localhost:4200',
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
mkdtemp,
|
|
||||||
rm
|
|
||||||
} from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import {
|
import {
|
||||||
@@ -9,11 +6,9 @@ import {
|
|||||||
type BrowserContext,
|
type BrowserContext,
|
||||||
type Page
|
type Page
|
||||||
} from '@playwright/test';
|
} from '@playwright/test';
|
||||||
import {
|
import { test, expect } from '../../fixtures/multi-client';
|
||||||
test,
|
|
||||||
expect
|
|
||||||
} from '../../fixtures/multi-client';
|
|
||||||
import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint';
|
import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint';
|
||||||
|
import { installWebRTCTracking } from '../../helpers/webrtc-helpers';
|
||||||
import { LoginPage } from '../../pages/login.page';
|
import { LoginPage } from '../../pages/login.page';
|
||||||
import { RegisterPage } from '../../pages/register.page';
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
@@ -40,17 +35,39 @@ interface PersistentClient {
|
|||||||
userDataDir: string;
|
userDataDir: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProfileMetadata {
|
||||||
|
description?: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
const STATIC_GIF_BASE64 = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
|
const STATIC_GIF_BASE64 = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
|
||||||
const GIF_FRAME_MARKER = Buffer.from([0x21, 0xF9, 0x04]);
|
const GIF_FRAME_MARKER = Buffer.from([
|
||||||
const NETSCAPE_LOOP_EXTENSION = Buffer.from([
|
0x21,
|
||||||
0x21, 0xFF, 0x0B,
|
0xF9,
|
||||||
0x4E, 0x45, 0x54, 0x53, 0x43, 0x41, 0x50, 0x45, 0x32, 0x2E, 0x30,
|
0x04
|
||||||
0x03, 0x01, 0x00, 0x00, 0x00
|
|
||||||
]);
|
]);
|
||||||
const CLIENT_LAUNCH_ARGS = [
|
const NETSCAPE_LOOP_EXTENSION = Buffer.from([
|
||||||
'--use-fake-device-for-media-stream',
|
0x21,
|
||||||
'--use-fake-ui-for-media-stream'
|
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';
|
const VOICE_CHANNEL = 'General';
|
||||||
|
|
||||||
test.describe('Profile avatar sync', () => {
|
test.describe('Profile avatar sync', () => {
|
||||||
@@ -100,6 +117,8 @@ test.describe('Profile avatar sync', () => {
|
|||||||
await joinServerFromSearch(bob.page, serverName);
|
await joinServerFromSearch(bob.page, serverName);
|
||||||
await waitForRoomReady(alice.page);
|
await waitForRoomReady(alice.page);
|
||||||
await waitForRoomReady(bob.page);
|
await waitForRoomReady(bob.page);
|
||||||
|
await waitForConnectedPeerCount(alice.page, 1);
|
||||||
|
await waitForConnectedPeerCount(bob.page, 1);
|
||||||
await expectUserRowVisible(bob.page, aliceUser.displayName);
|
await expectUserRowVisible(bob.page, aliceUser.displayName);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,6 +145,8 @@ test.describe('Profile avatar sync', () => {
|
|||||||
await registerUser(carol);
|
await registerUser(carol);
|
||||||
await joinServerFromSearch(carol.page, serverName);
|
await joinServerFromSearch(carol.page, serverName);
|
||||||
await waitForRoomReady(carol.page);
|
await waitForRoomReady(carol.page);
|
||||||
|
await waitForConnectedPeerCount(alice.page, 2);
|
||||||
|
await waitForConnectedPeerCount(carol.page, 1);
|
||||||
|
|
||||||
await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarA.dataUrl);
|
await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarA.dataUrl);
|
||||||
});
|
});
|
||||||
@@ -177,6 +198,134 @@ test.describe('Profile avatar sync', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('Profile metadata sync', () => {
|
||||||
|
test.describe.configure({ timeout: 240_000 });
|
||||||
|
|
||||||
|
test('syncs display name and description changes for online and late-joining users and persists after restart', async ({ testServer }) => {
|
||||||
|
const suffix = uniqueName('profile');
|
||||||
|
const serverName = `Profile Sync Server ${suffix}`;
|
||||||
|
const messageText = `Profile sync message ${suffix}`;
|
||||||
|
const firstProfile: ProfileMetadata = {
|
||||||
|
displayName: `Alice One ${suffix}`,
|
||||||
|
description: `First synced profile description ${suffix}`
|
||||||
|
};
|
||||||
|
const secondProfile: ProfileMetadata = {
|
||||||
|
displayName: `Alice Two ${suffix}`,
|
||||||
|
description: `Second synced profile description ${suffix}`
|
||||||
|
};
|
||||||
|
const aliceUser: TestUser = {
|
||||||
|
username: `alice_${suffix}`,
|
||||||
|
displayName: 'Alice',
|
||||||
|
password: 'TestPass123!'
|
||||||
|
};
|
||||||
|
const bobUser: TestUser = {
|
||||||
|
username: `bob_${suffix}`,
|
||||||
|
displayName: 'Bob',
|
||||||
|
password: 'TestPass123!'
|
||||||
|
};
|
||||||
|
const carolUser: TestUser = {
|
||||||
|
username: `carol_${suffix}`,
|
||||||
|
displayName: 'Carol',
|
||||||
|
password: 'TestPass123!'
|
||||||
|
};
|
||||||
|
const clients: PersistentClient[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const alice = await createPersistentClient(aliceUser, testServer.port);
|
||||||
|
const bob = await createPersistentClient(bobUser, testServer.port);
|
||||||
|
|
||||||
|
clients.push(alice, bob);
|
||||||
|
|
||||||
|
await test.step('Alice and Bob register, create a server, and join the same room', async () => {
|
||||||
|
await registerUser(alice);
|
||||||
|
await registerUser(bob);
|
||||||
|
|
||||||
|
const aliceSearchPage = new ServerSearchPage(alice.page);
|
||||||
|
|
||||||
|
await aliceSearchPage.createServer(serverName, {
|
||||||
|
description: 'Profile synchronization E2E coverage'
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
|
||||||
|
await joinServerFromSearch(bob.page, serverName);
|
||||||
|
await waitForRoomReady(alice.page);
|
||||||
|
await waitForRoomReady(bob.page);
|
||||||
|
await waitForConnectedPeerCount(alice.page, 1);
|
||||||
|
await waitForConnectedPeerCount(bob.page, 1);
|
||||||
|
await expectUserRowVisible(bob.page, aliceUser.displayName);
|
||||||
|
});
|
||||||
|
|
||||||
|
const roomUrl = alice.page.url();
|
||||||
|
|
||||||
|
await test.step('Alice updates her profile while Bob is online and Bob sees it live', async () => {
|
||||||
|
await updateProfileFromRoomSidebar(alice.page, {
|
||||||
|
displayName: aliceUser.displayName
|
||||||
|
}, firstProfile);
|
||||||
|
|
||||||
|
await expectUserRowVisible(alice.page, firstProfile.displayName);
|
||||||
|
await expectUserRowVisible(bob.page, firstProfile.displayName);
|
||||||
|
await expectProfileCardDetails(bob.page, firstProfile);
|
||||||
|
});
|
||||||
|
|
||||||
|
const carol = await createPersistentClient(carolUser, testServer.port);
|
||||||
|
|
||||||
|
clients.push(carol);
|
||||||
|
|
||||||
|
await test.step('Carol joins after the first change and sees the updated profile', async () => {
|
||||||
|
await registerUser(carol);
|
||||||
|
await joinServerFromSearch(carol.page, serverName);
|
||||||
|
await waitForRoomReady(carol.page);
|
||||||
|
await waitForConnectedPeerCount(alice.page, 2);
|
||||||
|
await waitForConnectedPeerCount(carol.page, 1);
|
||||||
|
|
||||||
|
await expectUserRowVisible(carol.page, firstProfile.displayName);
|
||||||
|
await expectProfileCardDetails(carol.page, firstProfile);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Alice changes her profile again and new chat messages use the latest display name', async () => {
|
||||||
|
await updateProfileFromRoomSidebar(alice.page, firstProfile, secondProfile);
|
||||||
|
|
||||||
|
await expectUserRowVisible(alice.page, secondProfile.displayName);
|
||||||
|
await expectUserRowVisible(bob.page, secondProfile.displayName);
|
||||||
|
await expectUserRowVisible(carol.page, secondProfile.displayName);
|
||||||
|
await expectProfileCardDetails(bob.page, secondProfile);
|
||||||
|
await expectProfileCardDetails(carol.page, secondProfile);
|
||||||
|
|
||||||
|
const aliceMessagesPage = new ChatMessagesPage(alice.page);
|
||||||
|
|
||||||
|
await aliceMessagesPage.sendMessage(messageText);
|
||||||
|
|
||||||
|
await expectChatMessageSenderName(alice.page, messageText, secondProfile.displayName);
|
||||||
|
await expectChatMessageSenderName(bob.page, messageText, secondProfile.displayName);
|
||||||
|
await expectChatMessageSenderName(carol.page, messageText, secondProfile.displayName);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Bob, Carol, and Alice keep the latest profile after a full app restart', async () => {
|
||||||
|
await restartPersistentClient(bob, testServer.port);
|
||||||
|
await openRoomAfterRestart(bob, roomUrl);
|
||||||
|
await expectUserRowVisible(bob.page, secondProfile.displayName);
|
||||||
|
await expectProfileCardDetails(bob.page, secondProfile);
|
||||||
|
|
||||||
|
await restartPersistentClient(carol, testServer.port);
|
||||||
|
await openRoomAfterRestart(carol, roomUrl);
|
||||||
|
await expectUserRowVisible(carol.page, secondProfile.displayName);
|
||||||
|
await expectProfileCardDetails(carol.page, secondProfile);
|
||||||
|
|
||||||
|
await restartPersistentClient(alice, testServer.port);
|
||||||
|
await openRoomAfterRestart(alice, roomUrl);
|
||||||
|
await expectUserRowVisible(alice.page, secondProfile.displayName);
|
||||||
|
await expectProfileCardDetails(alice.page, secondProfile);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await Promise.all(clients.map(async (client) => {
|
||||||
|
await closePersistentClient(client);
|
||||||
|
await rm(client.userDataDir, { recursive: true, force: true });
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
async function createPersistentClient(user: TestUser, testServerPort: number): Promise<PersistentClient> {
|
async function createPersistentClient(user: TestUser, testServerPort: number): Promise<PersistentClient> {
|
||||||
const userDataDir = await mkdtemp(join(tmpdir(), 'metoyou-avatar-e2e-'));
|
const userDataDir = await mkdtemp(join(tmpdir(), 'metoyou-avatar-e2e-'));
|
||||||
const session = await launchPersistentSession(userDataDir, testServerPort);
|
const session = await launchPersistentSession(userDataDir, testServerPort);
|
||||||
@@ -220,6 +369,8 @@ async function launchPersistentSession(
|
|||||||
|
|
||||||
const page = context.pages()[0] ?? await context.newPage();
|
const page = context.pages()[0] ?? await context.newPage();
|
||||||
|
|
||||||
|
await installWebRTCTracking(page);
|
||||||
|
|
||||||
return { context, page };
|
return { context, page };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,6 +439,43 @@ async function uploadAvatarFromRoomSidebar(
|
|||||||
await expect(applyButton).not.toBeVisible({ timeout: 10_000 });
|
await expect(applyButton).not.toBeVisible({ timeout: 10_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateProfileFromRoomSidebar(
|
||||||
|
page: Page,
|
||||||
|
currentProfile: ProfileMetadata,
|
||||||
|
nextProfile: ProfileMetadata
|
||||||
|
): Promise<void> {
|
||||||
|
const profileCard = await openProfileCardFromUserRow(page, currentProfile.displayName);
|
||||||
|
const displayNameButton = profileCard.getByRole('button', { name: currentProfile.displayName, exact: true });
|
||||||
|
|
||||||
|
await expect(displayNameButton).toBeVisible({ timeout: 10_000 });
|
||||||
|
await displayNameButton.click();
|
||||||
|
|
||||||
|
const displayNameInput = profileCard.locator('input[type="text"]').first();
|
||||||
|
|
||||||
|
await expect(displayNameInput).toBeVisible({ timeout: 10_000 });
|
||||||
|
await displayNameInput.fill(nextProfile.displayName);
|
||||||
|
await displayNameInput.blur();
|
||||||
|
|
||||||
|
await expect(profileCard.locator('input[type="text"]')).toHaveCount(0, { timeout: 10_000 });
|
||||||
|
|
||||||
|
const currentDescriptionText = currentProfile.description || 'Add a description';
|
||||||
|
|
||||||
|
await profileCard.getByText(currentDescriptionText, { exact: true }).click();
|
||||||
|
|
||||||
|
const descriptionInput = profileCard.locator('textarea').first();
|
||||||
|
|
||||||
|
await expect(descriptionInput).toBeVisible({ timeout: 10_000 });
|
||||||
|
await descriptionInput.fill(nextProfile.description || '');
|
||||||
|
await descriptionInput.blur();
|
||||||
|
|
||||||
|
await expect(profileCard.locator('textarea')).toHaveCount(0, { timeout: 10_000 });
|
||||||
|
await expect(profileCard.getByText(nextProfile.displayName, { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
if (nextProfile.description) {
|
||||||
|
await expect(profileCard.getByText(nextProfile.description, { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function openRoomAfterRestart(client: PersistentClient, roomUrl: string): Promise<void> {
|
async function openRoomAfterRestart(client: PersistentClient, roomUrl: string): Promise<void> {
|
||||||
await retryTransientNavigation(() => client.page.goto(roomUrl, { waitUntil: 'domcontentloaded' }));
|
await retryTransientNavigation(() => client.page.goto(roomUrl, { waitUntil: 'domcontentloaded' }));
|
||||||
|
|
||||||
@@ -332,18 +520,73 @@ async function waitForRoomReady(page: Page): Promise<void> {
|
|||||||
await expect(page.locator('app-rooms-side-panel').last()).toBeVisible({ timeout: 15_000 });
|
await expect(page.locator('app-rooms-side-panel').last()).toBeVisible({ timeout: 15_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForConnectedPeerCount(page: Page, count: number, timeout = 30_000): Promise<void> {
|
||||||
|
await page.waitForFunction((expectedCount) => {
|
||||||
|
const connections = (window as {
|
||||||
|
__rtcConnections?: RTCPeerConnection[];
|
||||||
|
}).__rtcConnections ?? [];
|
||||||
|
|
||||||
|
return connections.filter((connection) => connection.connectionState === 'connected').length >= expectedCount;
|
||||||
|
}, count, { timeout });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openProfileCardFromUserRow(page: Page, displayName: string) {
|
||||||
|
await closeProfileCard(page);
|
||||||
|
|
||||||
|
const row = getUserRow(page, displayName);
|
||||||
|
|
||||||
|
await expect(row).toBeVisible({ timeout: 20_000 });
|
||||||
|
await row.click();
|
||||||
|
|
||||||
|
const profileCard = page.locator('app-profile-card');
|
||||||
|
|
||||||
|
await expect(profileCard).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
return profileCard;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeProfileCard(page: Page): Promise<void> {
|
||||||
|
const profileCard = page.locator('app-profile-card');
|
||||||
|
|
||||||
|
if (await profileCard.count() === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(profileCard).toBeVisible({ timeout: 1_000 });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.mouse.click(8, 8);
|
||||||
|
await expect(profileCard).toHaveCount(0, { timeout: 10_000 });
|
||||||
|
}
|
||||||
|
|
||||||
function getUserRow(page: Page, displayName: string) {
|
function getUserRow(page: Page, displayName: string) {
|
||||||
const usersSidePanel = page.locator('app-rooms-side-panel').last();
|
const usersSidePanel = page.locator('app-rooms-side-panel').last();
|
||||||
|
|
||||||
return usersSidePanel.locator('[role="button"]').filter({
|
return usersSidePanel.locator('[role="button"]').filter({
|
||||||
has: page.getByText(displayName, { exact: true })
|
has: page.getByText(displayName, { exact: true })
|
||||||
}).first();
|
})
|
||||||
|
.first();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectUserRowVisible(page: Page, displayName: string): Promise<void> {
|
async function expectUserRowVisible(page: Page, displayName: string): Promise<void> {
|
||||||
await expect(getUserRow(page, displayName)).toBeVisible({ timeout: 20_000 });
|
await expect(getUserRow(page, displayName)).toBeVisible({ timeout: 20_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expectProfileCardDetails(page: Page, profile: ProfileMetadata): Promise<void> {
|
||||||
|
const profileCard = await openProfileCardFromUserRow(page, profile.displayName);
|
||||||
|
|
||||||
|
await expect(profileCard.getByText(profile.displayName, { exact: true })).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
if (profile.description) {
|
||||||
|
await expect(profileCard.getByText(profile.description, { exact: true })).toBeVisible({ timeout: 20_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await closeProfileCard(page);
|
||||||
|
}
|
||||||
|
|
||||||
async function expectSidebarAvatar(page: Page, displayName: string, expectedDataUrl: string): Promise<void> {
|
async function expectSidebarAvatar(page: Page, displayName: string, expectedDataUrl: string): Promise<void> {
|
||||||
const row = getUserRow(page, displayName);
|
const row = getUserRow(page, displayName);
|
||||||
|
|
||||||
@@ -400,6 +643,14 @@ async function expectChatMessageAvatar(page: Page, messageText: string, expected
|
|||||||
}).toBe(expectedDataUrl);
|
}).toBe(expectedDataUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expectChatMessageSenderName(page: Page, messageText: string, expectedDisplayName: string): Promise<void> {
|
||||||
|
const messagesPage = new ChatMessagesPage(page);
|
||||||
|
const messageItem = messagesPage.getMessageItemByText(messageText);
|
||||||
|
|
||||||
|
await expect(messageItem).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect(messageItem.getByText(expectedDisplayName, { exact: true })).toBeVisible({ timeout: 20_000 });
|
||||||
|
}
|
||||||
|
|
||||||
async function expectVoiceControlsAvatar(page: Page, expectedDataUrl: string): Promise<void> {
|
async function expectVoiceControlsAvatar(page: Page, expectedDataUrl: string): Promise<void> {
|
||||||
const voiceControls = page.locator('app-voice-controls');
|
const voiceControls = page.locator('app-voice-controls');
|
||||||
|
|
||||||
@@ -431,7 +682,11 @@ function buildAnimatedGifUpload(label: string): AvatarUploadPayload {
|
|||||||
const frame = baseGif.subarray(frameStart, baseGif.length - 1);
|
const frame = baseGif.subarray(frameStart, baseGif.length - 1);
|
||||||
const commentData = Buffer.from(label, 'ascii');
|
const commentData = Buffer.from(label, 'ascii');
|
||||||
const commentExtension = Buffer.concat([
|
const commentExtension = Buffer.concat([
|
||||||
Buffer.from([0x21, 0xFE, commentData.length]),
|
Buffer.from([
|
||||||
|
0x21,
|
||||||
|
0xFE,
|
||||||
|
commentData.length
|
||||||
|
]),
|
||||||
commentData,
|
commentData,
|
||||||
Buffer.from([0x00])
|
Buffer.from([0x00])
|
||||||
]);
|
]);
|
||||||
@@ -454,5 +709,6 @@ function buildAnimatedGifUpload(label: string): AvatarUploadPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function uniqueName(prefix: string): string {
|
function uniqueName(prefix: string): string {
|
||||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
||||||
|
.slice(2, 8)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export async function handleSaveUser(command: SaveUserCommand, dataSource: DataS
|
|||||||
oderId: user.oderId ?? null,
|
oderId: user.oderId ?? null,
|
||||||
username: user.username ?? null,
|
username: user.username ?? null,
|
||||||
displayName: user.displayName ?? null,
|
displayName: user.displayName ?? null,
|
||||||
|
description: user.description ?? null,
|
||||||
|
profileUpdatedAt: user.profileUpdatedAt ?? null,
|
||||||
avatarUrl: user.avatarUrl ?? null,
|
avatarUrl: user.avatarUrl ?? null,
|
||||||
avatarHash: user.avatarHash ?? null,
|
avatarHash: user.avatarHash ?? null,
|
||||||
avatarMime: user.avatarMime ?? null,
|
avatarMime: user.avatarMime ?? null,
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export function rowToUser(row: UserEntity) {
|
|||||||
oderId: row.oderId ?? '',
|
oderId: row.oderId ?? '',
|
||||||
username: row.username ?? '',
|
username: row.username ?? '',
|
||||||
displayName: row.displayName ?? '',
|
displayName: row.displayName ?? '',
|
||||||
|
description: row.description ?? undefined,
|
||||||
|
profileUpdatedAt: row.profileUpdatedAt ?? undefined,
|
||||||
avatarUrl: row.avatarUrl ?? undefined,
|
avatarUrl: row.avatarUrl ?? undefined,
|
||||||
avatarHash: row.avatarHash ?? undefined,
|
avatarHash: row.avatarHash ?? undefined,
|
||||||
avatarMime: row.avatarMime ?? undefined,
|
avatarMime: row.avatarMime ?? undefined,
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export interface RoomMemberRecord {
|
|||||||
oderId?: string;
|
oderId?: string;
|
||||||
username: string;
|
username: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
description?: string;
|
||||||
|
profileUpdatedAt?: number;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
avatarHash?: string;
|
avatarHash?: string;
|
||||||
avatarMime?: string;
|
avatarMime?: string;
|
||||||
@@ -338,16 +340,18 @@ function normalizeRoomMember(rawMember: Record<string, unknown>, now: number): R
|
|||||||
const { joinedAt, lastSeenAt } = resolveRoomMemberTimes(rawMember, now);
|
const { joinedAt, lastSeenAt } = resolveRoomMemberTimes(rawMember, now);
|
||||||
const username = trimmedString(rawMember, 'username');
|
const username = trimmedString(rawMember, 'username');
|
||||||
const displayName = trimmedString(rawMember, 'displayName');
|
const displayName = trimmedString(rawMember, 'displayName');
|
||||||
|
const description = trimmedString(rawMember, 'description');
|
||||||
|
const profileUpdatedAt = isFiniteNumber(rawMember['profileUpdatedAt']) ? rawMember['profileUpdatedAt'] : undefined;
|
||||||
const avatarUrl = trimmedString(rawMember, 'avatarUrl');
|
const avatarUrl = trimmedString(rawMember, 'avatarUrl');
|
||||||
const avatarHash = trimmedString(rawMember, 'avatarHash');
|
const avatarHash = trimmedString(rawMember, 'avatarHash');
|
||||||
const avatarMime = trimmedString(rawMember, 'avatarMime');
|
const avatarMime = trimmedString(rawMember, 'avatarMime');
|
||||||
const avatarUpdatedAt = isFiniteNumber(rawMember['avatarUpdatedAt']) ? rawMember['avatarUpdatedAt'] : undefined;
|
const avatarUpdatedAt = isFiniteNumber(rawMember['avatarUpdatedAt']) ? rawMember['avatarUpdatedAt'] : undefined;
|
||||||
|
const member: RoomMemberRecord = {
|
||||||
return {
|
|
||||||
id: normalizedId || normalizedKey,
|
id: normalizedId || normalizedKey,
|
||||||
oderId: normalizedOderId || undefined,
|
oderId: normalizedOderId || undefined,
|
||||||
username: username || fallbackUsername({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, displayName }),
|
username: username || fallbackUsername({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, displayName }),
|
||||||
displayName: displayName || fallbackDisplayName({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, username }),
|
displayName: displayName || fallbackDisplayName({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, username }),
|
||||||
|
profileUpdatedAt,
|
||||||
avatarUrl: avatarUrl || undefined,
|
avatarUrl: avatarUrl || undefined,
|
||||||
avatarHash: avatarHash || undefined,
|
avatarHash: avatarHash || undefined,
|
||||||
avatarMime: avatarMime || undefined,
|
avatarMime: avatarMime || undefined,
|
||||||
@@ -357,6 +361,12 @@ function normalizeRoomMember(rawMember: Record<string, unknown>, now: number): R
|
|||||||
joinedAt,
|
joinedAt,
|
||||||
lastSeenAt
|
lastSeenAt
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(rawMember, 'description')) {
|
||||||
|
member.description = description || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return member;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incomingMember: RoomMemberRecord): RoomMemberRecord {
|
function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incomingMember: RoomMemberRecord): RoomMemberRecord {
|
||||||
@@ -365,6 +375,11 @@ function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incoming
|
|||||||
}
|
}
|
||||||
|
|
||||||
const preferIncoming = incomingMember.lastSeenAt >= existingMember.lastSeenAt;
|
const preferIncoming = incomingMember.lastSeenAt >= existingMember.lastSeenAt;
|
||||||
|
const existingProfileUpdatedAt = existingMember.profileUpdatedAt ?? 0;
|
||||||
|
const incomingProfileUpdatedAt = incomingMember.profileUpdatedAt ?? 0;
|
||||||
|
const preferIncomingProfile = incomingProfileUpdatedAt === existingProfileUpdatedAt
|
||||||
|
? preferIncoming
|
||||||
|
: incomingProfileUpdatedAt > existingProfileUpdatedAt;
|
||||||
const existingAvatarUpdatedAt = existingMember.avatarUpdatedAt ?? 0;
|
const existingAvatarUpdatedAt = existingMember.avatarUpdatedAt ?? 0;
|
||||||
const incomingAvatarUpdatedAt = incomingMember.avatarUpdatedAt ?? 0;
|
const incomingAvatarUpdatedAt = incomingMember.avatarUpdatedAt ?? 0;
|
||||||
const preferIncomingAvatar = incomingAvatarUpdatedAt === existingAvatarUpdatedAt
|
const preferIncomingAvatar = incomingAvatarUpdatedAt === existingAvatarUpdatedAt
|
||||||
@@ -377,9 +392,13 @@ function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incoming
|
|||||||
username: preferIncoming
|
username: preferIncoming
|
||||||
? (incomingMember.username || existingMember.username)
|
? (incomingMember.username || existingMember.username)
|
||||||
: (existingMember.username || incomingMember.username),
|
: (existingMember.username || incomingMember.username),
|
||||||
displayName: preferIncoming
|
displayName: preferIncomingProfile
|
||||||
? (incomingMember.displayName || existingMember.displayName)
|
? (incomingMember.displayName || existingMember.displayName)
|
||||||
: (existingMember.displayName || incomingMember.displayName),
|
: (existingMember.displayName || incomingMember.displayName),
|
||||||
|
description: preferIncomingProfile
|
||||||
|
? (Object.prototype.hasOwnProperty.call(incomingMember, 'description') ? incomingMember.description : existingMember.description)
|
||||||
|
: existingMember.description,
|
||||||
|
profileUpdatedAt: Math.max(existingProfileUpdatedAt, incomingProfileUpdatedAt) || undefined,
|
||||||
avatarUrl: preferIncomingAvatar
|
avatarUrl: preferIncomingAvatar
|
||||||
? (incomingMember.avatarUrl || existingMember.avatarUrl)
|
? (incomingMember.avatarUrl || existingMember.avatarUrl)
|
||||||
: (existingMember.avatarUrl || incomingMember.avatarUrl),
|
: (existingMember.avatarUrl || incomingMember.avatarUrl),
|
||||||
@@ -780,6 +799,8 @@ export async function replaceRoomRelations(
|
|||||||
oderId: member.oderId ?? null,
|
oderId: member.oderId ?? null,
|
||||||
username: member.username,
|
username: member.username,
|
||||||
displayName: member.displayName,
|
displayName: member.displayName,
|
||||||
|
description: member.description ?? null,
|
||||||
|
profileUpdatedAt: member.profileUpdatedAt ?? null,
|
||||||
avatarUrl: member.avatarUrl ?? null,
|
avatarUrl: member.avatarUrl ?? null,
|
||||||
avatarHash: member.avatarHash ?? null,
|
avatarHash: member.avatarHash ?? null,
|
||||||
avatarMime: member.avatarMime ?? null,
|
avatarMime: member.avatarMime ?? null,
|
||||||
@@ -930,6 +951,8 @@ export async function loadRoomRelationsMap(
|
|||||||
oderId: row.oderId ?? undefined,
|
oderId: row.oderId ?? undefined,
|
||||||
username: row.username,
|
username: row.username,
|
||||||
displayName: row.displayName,
|
displayName: row.displayName,
|
||||||
|
description: row.description ?? undefined,
|
||||||
|
profileUpdatedAt: row.profileUpdatedAt ?? undefined,
|
||||||
avatarUrl: row.avatarUrl ?? undefined,
|
avatarUrl: row.avatarUrl ?? undefined,
|
||||||
avatarHash: row.avatarHash ?? undefined,
|
avatarHash: row.avatarHash ?? undefined,
|
||||||
avatarMime: row.avatarMime ?? undefined,
|
avatarMime: row.avatarMime ?? undefined,
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ export interface UserPayload {
|
|||||||
oderId?: string;
|
oderId?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
description?: string;
|
||||||
|
profileUpdatedAt?: number;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
avatarHash?: string;
|
avatarHash?: string;
|
||||||
avatarMime?: string;
|
avatarMime?: string;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomBytes } from 'crypto';
|
||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as fsp from 'fs/promises';
|
import * as fsp from 'fs/promises';
|
||||||
@@ -20,23 +21,93 @@ import {
|
|||||||
import { settings } from '../settings';
|
import { settings } from '../settings';
|
||||||
|
|
||||||
let applicationDataSource: DataSource | undefined;
|
let applicationDataSource: DataSource | undefined;
|
||||||
|
let dbFilePath = '';
|
||||||
|
let dbBackupPath = '';
|
||||||
|
|
||||||
|
// SQLite files start with this 16-byte header string.
|
||||||
|
const SQLITE_MAGIC = 'SQLite format 3\0';
|
||||||
|
|
||||||
export function getDataSource(): DataSource | undefined {
|
export function getDataSource(): DataSource | undefined {
|
||||||
return applicationDataSource;
|
return applicationDataSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when `data` looks like a valid SQLite file
|
||||||
|
* (correct header magic and at least one complete page).
|
||||||
|
*/
|
||||||
|
function isValidSqlite(data: Uint8Array): boolean {
|
||||||
|
if (data.length < 100)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const header = Buffer.from(data.buffer, data.byteOffset, 16).toString('ascii');
|
||||||
|
|
||||||
|
return header === SQLITE_MAGIC;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Back up the current DB file so there is always a recovery point.
|
||||||
|
* If the main file is corrupted/empty but a valid backup exists,
|
||||||
|
* restore the backup before the app loads the database.
|
||||||
|
*/
|
||||||
|
function safeguardDbFile(): Uint8Array | undefined {
|
||||||
|
if (!fs.existsSync(dbFilePath))
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
const data = new Uint8Array(fs.readFileSync(dbFilePath));
|
||||||
|
|
||||||
|
if (isValidSqlite(data)) {
|
||||||
|
fs.copyFileSync(dbFilePath, dbBackupPath);
|
||||||
|
console.log('[DB] Backed up database to', dbBackupPath);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`[DB] ${dbFilePath} appears corrupt (${data.length} bytes) - checking backup`);
|
||||||
|
|
||||||
|
if (fs.existsSync(dbBackupPath)) {
|
||||||
|
const backup = new Uint8Array(fs.readFileSync(dbBackupPath));
|
||||||
|
|
||||||
|
if (isValidSqlite(backup)) {
|
||||||
|
fs.copyFileSync(dbBackupPath, dbFilePath);
|
||||||
|
console.warn('[DB] Restored database from backup', dbBackupPath);
|
||||||
|
|
||||||
|
return backup;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('[DB] Backup is also invalid - starting with a fresh database');
|
||||||
|
} else {
|
||||||
|
console.error('[DB] No backup available - starting with a fresh database');
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the database to disk atomically: write a temp file first,
|
||||||
|
* then rename it over the real file. rename() is atomic on the same
|
||||||
|
* filesystem, so a crash mid-write can never leave a half-written DB.
|
||||||
|
*/
|
||||||
|
async function atomicSave(data: Uint8Array): Promise<void> {
|
||||||
|
const tmpPath = dbFilePath + '.tmp-' + randomBytes(6).toString('hex');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fsp.writeFile(tmpPath, Buffer.from(data));
|
||||||
|
await fsp.rename(tmpPath, dbFilePath);
|
||||||
|
} catch (err) {
|
||||||
|
await fsp.unlink(tmpPath).catch(() => {});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function initializeDatabase(): Promise<void> {
|
export async function initializeDatabase(): Promise<void> {
|
||||||
const userDataPath = app.getPath('userData');
|
const userDataPath = app.getPath('userData');
|
||||||
const dbDir = path.join(userDataPath, 'metoyou');
|
const dbDir = path.join(userDataPath, 'metoyou');
|
||||||
|
|
||||||
await fsp.mkdir(dbDir, { recursive: true });
|
await fsp.mkdir(dbDir, { recursive: true });
|
||||||
const databaseFilePath = path.join(dbDir, settings.databaseName);
|
dbFilePath = path.join(dbDir, settings.databaseName);
|
||||||
|
dbBackupPath = dbFilePath + '.bak';
|
||||||
|
|
||||||
let database: Uint8Array | undefined;
|
const database = safeguardDbFile();
|
||||||
|
|
||||||
if (fs.existsSync(databaseFilePath)) {
|
|
||||||
database = fs.readFileSync(databaseFilePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
applicationDataSource = new DataSource({
|
applicationDataSource = new DataSource({
|
||||||
type: 'sqljs',
|
type: 'sqljs',
|
||||||
@@ -59,12 +130,12 @@ export async function initializeDatabase(): Promise<void> {
|
|||||||
synchronize: false,
|
synchronize: false,
|
||||||
logging: false,
|
logging: false,
|
||||||
autoSave: true,
|
autoSave: true,
|
||||||
location: databaseFilePath
|
autoSaveCallback: atomicSave
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await applicationDataSource.initialize();
|
await applicationDataSource.initialize();
|
||||||
console.log('[DB] Connection initialised at:', databaseFilePath);
|
console.log('[DB] Connection initialised at:', dbFilePath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await applicationDataSource.runMigrations();
|
await applicationDataSource.runMigrations();
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ export class RoomMemberEntity {
|
|||||||
@Column('text')
|
@Column('text')
|
||||||
displayName!: string;
|
displayName!: string;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
description!: string | null;
|
||||||
|
|
||||||
|
@Column('integer', { nullable: true })
|
||||||
|
profileUpdatedAt!: number | null;
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
avatarUrl!: string | null;
|
avatarUrl!: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ export class UserEntity {
|
|||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
displayName!: string | null;
|
displayName!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
description!: string | null;
|
||||||
|
|
||||||
|
@Column('integer', { nullable: true })
|
||||||
|
profileUpdatedAt!: number | null;
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
avatarUrl!: string | null;
|
avatarUrl!: string | null;
|
||||||
|
|
||||||
|
|||||||
16
electron/migrations/1000000000007-AddUserProfileMetadata.ts
Normal file
16
electron/migrations/1000000000007-AddUserProfileMetadata.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddUserProfileMetadata1000000000007 implements MigrationInterface {
|
||||||
|
name = 'AddUserProfileMetadata1000000000007';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "description" TEXT`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "profileUpdatedAt" INTEGER`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "room_members" ADD COLUMN "description" TEXT`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "room_members" ADD COLUMN "profileUpdatedAt" INTEGER`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// SQLite column removal requires table rebuilds. Keep rollback no-op.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,8 +15,37 @@ let mainWindow: BrowserWindow | null = null;
|
|||||||
let tray: Tray | null = null;
|
let tray: Tray | null = null;
|
||||||
let closeToTrayEnabled = true;
|
let closeToTrayEnabled = true;
|
||||||
let appQuitting = false;
|
let appQuitting = false;
|
||||||
|
let youtubeRequestHeadersConfigured = false;
|
||||||
|
|
||||||
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
|
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
|
||||||
|
const YOUTUBE_EMBED_REFERRER = 'https://toju.app/';
|
||||||
|
|
||||||
|
function ensureYoutubeEmbedRequestHeaders(): void {
|
||||||
|
if (youtubeRequestHeadersConfigured || !app.isPackaged) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
youtubeRequestHeadersConfigured = true;
|
||||||
|
|
||||||
|
session.defaultSession.webRequest.onBeforeSendHeaders(
|
||||||
|
{
|
||||||
|
urls: [
|
||||||
|
'https://www.youtube-nocookie.com/*',
|
||||||
|
'https://www.youtube.com/*',
|
||||||
|
'https://*.youtube.com/*',
|
||||||
|
'https://*.googlevideo.com/*',
|
||||||
|
'https://*.ytimg.com/*'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
(details, callback) => {
|
||||||
|
const requestHeaders = { ...details.requestHeaders };
|
||||||
|
|
||||||
|
requestHeaders['Referer'] ??= YOUTUBE_EMBED_REFERRER;
|
||||||
|
|
||||||
|
callback({ requestHeaders });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getAssetPath(...segments: string[]): string {
|
function getAssetPath(...segments: string[]): string {
|
||||||
const basePath = app.isPackaged
|
const basePath = app.isPackaged
|
||||||
@@ -163,6 +192,7 @@ export async function createWindow(): Promise<void> {
|
|||||||
|
|
||||||
closeToTrayEnabled = readDesktopSettings().closeToTray;
|
closeToTrayEnabled = readDesktopSettings().closeToTray;
|
||||||
ensureTray();
|
ensureTray();
|
||||||
|
ensureYoutubeEmbedRequestHeaders();
|
||||||
|
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
width: 1400,
|
width: 1400,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { randomBytes } from 'crypto';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import fsp from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import {
|
import {
|
||||||
@@ -101,6 +103,23 @@ function resolveSqlJsConfig(): { locateFile: (file: string) => string } {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the database to disk atomically: write a temp file first,
|
||||||
|
* then rename it over the real file. rename() is atomic on the same
|
||||||
|
* filesystem, so a crash mid-write can never leave a half-written DB.
|
||||||
|
*/
|
||||||
|
async function atomicSave(data: Uint8Array): Promise<void> {
|
||||||
|
const tmpPath = DB_FILE + '.tmp-' + randomBytes(6).toString('hex');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fsp.writeFile(tmpPath, Buffer.from(data));
|
||||||
|
await fsp.rename(tmpPath, DB_FILE);
|
||||||
|
} catch (err) {
|
||||||
|
await fsp.unlink(tmpPath).catch(() => {});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getDataSource(): DataSource {
|
export function getDataSource(): DataSource {
|
||||||
if (!applicationDataSource?.isInitialized) {
|
if (!applicationDataSource?.isInitialized) {
|
||||||
throw new Error('DataSource not initialised');
|
throw new Error('DataSource not initialised');
|
||||||
@@ -136,7 +155,7 @@ export async function initDatabase(): Promise<void> {
|
|||||||
synchronize: process.env.DB_SYNCHRONIZE === 'true',
|
synchronize: process.env.DB_SYNCHRONIZE === 'true',
|
||||||
logging: false,
|
logging: false,
|
||||||
autoSave: true,
|
autoSave: true,
|
||||||
location: DB_FILE,
|
autoSaveCallback: atomicSave,
|
||||||
sqlJsConfig: resolveSqlJsConfig()
|
sqlJsConfig: resolveSqlJsConfig()
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2,13 +2,18 @@ import {
|
|||||||
describe,
|
describe,
|
||||||
it,
|
it,
|
||||||
expect,
|
expect,
|
||||||
beforeEach
|
beforeEach,
|
||||||
|
vi
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import { connectedUsers } from './state';
|
import { connectedUsers } from './state';
|
||||||
import { handleWebSocketMessage } from './handler';
|
import { handleWebSocketMessage } from './handler';
|
||||||
import { ConnectedUser } from './types';
|
import { ConnectedUser } from './types';
|
||||||
import { WebSocket } from 'ws';
|
import { WebSocket } from 'ws';
|
||||||
|
|
||||||
|
vi.mock('../services/server-access.service', () => ({
|
||||||
|
authorizeWebSocketJoin: vi.fn(async () => ({ allowed: true as const }))
|
||||||
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal mock WebSocket that records sent messages.
|
* Minimal mock WebSocket that records sent messages.
|
||||||
*/
|
*/
|
||||||
@@ -197,3 +202,94 @@ describe('server websocket handler - user_joined includes status', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('server websocket handler - profile metadata in presence messages', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
connectedUsers.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('broadcasts updated profile metadata when an identified user changes it', async () => {
|
||||||
|
const alice = createConnectedUser('conn-1', 'user-1', {
|
||||||
|
displayName: 'Alice',
|
||||||
|
viewedServerId: 'server-1'
|
||||||
|
});
|
||||||
|
const bob = createConnectedUser('conn-2', 'user-2', {
|
||||||
|
viewedServerId: 'server-1'
|
||||||
|
});
|
||||||
|
|
||||||
|
alice.serverIds.add('server-1');
|
||||||
|
bob.serverIds.add('server-1');
|
||||||
|
getSentMessagesStore(bob).sentMessages.length = 0;
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-1', {
|
||||||
|
type: 'identify',
|
||||||
|
oderId: 'user-1',
|
||||||
|
displayName: 'Alice Updated',
|
||||||
|
description: 'Updated bio',
|
||||||
|
profileUpdatedAt: 789
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages = getSentMessagesStore(bob).sentMessages.map((messageText: string) => JSON.parse(messageText));
|
||||||
|
const joinMsg = messages.find((message: { type: string }) => message.type === 'user_joined');
|
||||||
|
|
||||||
|
expect(joinMsg?.displayName).toBe('Alice Updated');
|
||||||
|
expect(joinMsg?.description).toBe('Updated bio');
|
||||||
|
expect(joinMsg?.profileUpdatedAt).toBe(789);
|
||||||
|
expect(joinMsg?.serverId).toBe('server-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes description and profileUpdatedAt in server_users responses', async () => {
|
||||||
|
const alice = createConnectedUser('conn-1', 'user-1');
|
||||||
|
const bob = createConnectedUser('conn-2', 'user-2');
|
||||||
|
|
||||||
|
alice.serverIds.add('server-1');
|
||||||
|
bob.serverIds.add('server-1');
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-1', {
|
||||||
|
type: 'identify',
|
||||||
|
oderId: 'user-1',
|
||||||
|
displayName: 'Alice',
|
||||||
|
description: 'Alice bio',
|
||||||
|
profileUpdatedAt: 123
|
||||||
|
});
|
||||||
|
|
||||||
|
getSentMessagesStore(bob).sentMessages.length = 0;
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-2', {
|
||||||
|
type: 'view_server',
|
||||||
|
serverId: 'server-1'
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages = getSentMessagesStore(bob).sentMessages.map((messageText: string) => JSON.parse(messageText));
|
||||||
|
const serverUsersMsg = messages.find((message: { type: string }) => message.type === 'server_users');
|
||||||
|
const aliceInList = serverUsersMsg?.users?.find((userEntry: { oderId: string }) => userEntry.oderId === 'user-1');
|
||||||
|
|
||||||
|
expect(aliceInList?.description).toBe('Alice bio');
|
||||||
|
expect(aliceInList?.profileUpdatedAt).toBe(123);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes description and profileUpdatedAt in user_joined broadcasts', async () => {
|
||||||
|
const bob = createConnectedUser('conn-2', 'user-2');
|
||||||
|
|
||||||
|
bob.serverIds.add('server-1');
|
||||||
|
bob.viewedServerId = 'server-1';
|
||||||
|
|
||||||
|
createConnectedUser('conn-1', 'user-1', {
|
||||||
|
displayName: 'Alice',
|
||||||
|
description: 'Alice bio',
|
||||||
|
profileUpdatedAt: 456,
|
||||||
|
viewedServerId: 'server-1'
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-1', {
|
||||||
|
type: 'join_server',
|
||||||
|
serverId: 'server-1'
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages = getSentMessagesStore(bob).sentMessages.map((messageText: string) => JSON.parse(messageText));
|
||||||
|
const joinMsg = messages.find((message: { type: string }) => message.type === 'user_joined');
|
||||||
|
|
||||||
|
expect(joinMsg?.description).toBe('Alice bio');
|
||||||
|
expect(joinMsg?.profileUpdatedAt).toBe(456);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,6 +20,22 @@ function normalizeDisplayName(value: unknown, fallback = 'User'): string {
|
|||||||
return normalized || fallback;
|
return normalized || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDescription(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim();
|
||||||
|
|
||||||
|
return normalized || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProfileUpdatedAt(value: unknown): number | undefined {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||||
|
? value
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function readMessageId(value: unknown): string | undefined {
|
function readMessageId(value: unknown): string | undefined {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -37,7 +53,13 @@ function readMessageId(value: unknown): string | undefined {
|
|||||||
/** Sends the current user list for a given server to a single connected user. */
|
/** Sends the current user list for a given server to a single connected user. */
|
||||||
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
||||||
const users = getUniqueUsersInServer(serverId, user.oderId)
|
const users = getUniqueUsersInServer(serverId, user.oderId)
|
||||||
.map(cu => ({ oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName), status: cu.status ?? 'online' }));
|
.map(cu => ({
|
||||||
|
oderId: cu.oderId,
|
||||||
|
displayName: normalizeDisplayName(cu.displayName),
|
||||||
|
description: cu.description,
|
||||||
|
profileUpdatedAt: cu.profileUpdatedAt,
|
||||||
|
status: cu.status ?? 'online'
|
||||||
|
}));
|
||||||
|
|
||||||
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
||||||
}
|
}
|
||||||
@@ -45,6 +67,9 @@ function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
|||||||
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
const newOderId = readMessageId(message['oderId']) ?? connectionId;
|
const newOderId = readMessageId(message['oderId']) ?? connectionId;
|
||||||
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
|
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
|
||||||
|
const previousDisplayName = normalizeDisplayName(user.displayName);
|
||||||
|
const previousDescription = user.description;
|
||||||
|
const previousProfileUpdatedAt = user.profileUpdatedAt;
|
||||||
|
|
||||||
// Close stale connections from the same identity AND the same connection
|
// Close stale connections from the same identity AND the same connection
|
||||||
// scope so offer routing always targets the freshest socket (e.g. after
|
// scope so offer routing always targets the freshest socket (e.g. after
|
||||||
@@ -67,9 +92,38 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
|
|||||||
|
|
||||||
user.oderId = newOderId;
|
user.oderId = newOderId;
|
||||||
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(message, 'description')) {
|
||||||
|
user.description = normalizeDescription(message['description']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(message, 'profileUpdatedAt')) {
|
||||||
|
user.profileUpdatedAt = normalizeProfileUpdatedAt(message['profileUpdatedAt']);
|
||||||
|
}
|
||||||
|
|
||||||
user.connectionScope = newScope;
|
user.connectionScope = newScope;
|
||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
||||||
|
|
||||||
|
if (
|
||||||
|
user.displayName === previousDisplayName
|
||||||
|
&& user.description === previousDescription
|
||||||
|
&& user.profileUpdatedAt === previousProfileUpdatedAt
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const serverId of user.serverIds) {
|
||||||
|
broadcastToServer(serverId, {
|
||||||
|
type: 'user_joined',
|
||||||
|
oderId: user.oderId,
|
||||||
|
displayName: normalizeDisplayName(user.displayName),
|
||||||
|
description: user.description,
|
||||||
|
profileUpdatedAt: user.profileUpdatedAt,
|
||||||
|
status: user.status ?? 'online',
|
||||||
|
serverId
|
||||||
|
}, user.oderId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||||
@@ -108,6 +162,8 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
|||||||
type: 'user_joined',
|
type: 'user_joined',
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
displayName: normalizeDisplayName(user.displayName),
|
displayName: normalizeDisplayName(user.displayName),
|
||||||
|
description: user.description,
|
||||||
|
profileUpdatedAt: user.profileUpdatedAt,
|
||||||
status: user.status ?? 'online',
|
status: user.status ?? 'online',
|
||||||
serverId: sid
|
serverId: sid
|
||||||
}, user.oderId);
|
}, user.oderId);
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export interface ConnectedUser {
|
|||||||
serverIds: Set<string>;
|
serverIds: Set<string>;
|
||||||
viewedServerId?: string;
|
viewedServerId?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
description?: string;
|
||||||
|
profileUpdatedAt?: number;
|
||||||
/**
|
/**
|
||||||
* Opaque scope string sent by the client (typically the signal URL it
|
* Opaque scope string sent by the client (typically the signal URL it
|
||||||
* connected through). Stale-connection eviction only targets connections
|
* connected through). Stale-connection eviction only targets connections
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import { UserAvatarComponent } from '../../../../shared';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-user-bar',
|
selector: 'app-user-bar',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, NgIcon, UserAvatarComponent],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
NgIcon,
|
||||||
|
UserAvatarComponent
|
||||||
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
lucideLogIn,
|
lucideLogIn,
|
||||||
|
|||||||
149
toju-app/src/app/domains/chat/domain/rules/link-embed.rules.ts
Normal file
149
toju-app/src/app/domains/chat/domain/rules/link-embed.rules.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
export type SpotifyResourceType = 'album' | 'artist' | 'episode' | 'playlist' | 'show' | 'track';
|
||||||
|
export type SoundcloudResourceType = 'playlist' | 'track';
|
||||||
|
|
||||||
|
export interface SpotifyResource {
|
||||||
|
type: SpotifyResourceType;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SoundcloudResource {
|
||||||
|
canonicalUrl: string;
|
||||||
|
type: SoundcloudResourceType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPOTIFY_RESOURCE_TYPES = new Set<SpotifyResourceType>([
|
||||||
|
'album',
|
||||||
|
'artist',
|
||||||
|
'episode',
|
||||||
|
'playlist',
|
||||||
|
'show',
|
||||||
|
'track'
|
||||||
|
]);
|
||||||
|
const SPOTIFY_URI_PATTERN = /^spotify:(album|artist|episode|playlist|show|track):([a-zA-Z0-9]+)$/i;
|
||||||
|
const SOUNDCLOUD_HOST_PATTERN = /^(?:www\.|m\.)?soundcloud\.com$/i;
|
||||||
|
const YOUTUBE_HOST_PATTERN = /^(?:www\.|m\.|music\.)?youtube\.com$/i;
|
||||||
|
const YOUTU_BE_HOST_PATTERN = /^(?:www\.)?youtu\.be$/i;
|
||||||
|
const YOUTUBE_VIDEO_ID_PATTERN = /^[\w-]{11}$/;
|
||||||
|
|
||||||
|
function parseUrl(url: string): URL | null {
|
||||||
|
try {
|
||||||
|
return new URL(url);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractYoutubeVideoId(url: string): string | null {
|
||||||
|
const parsedUrl = parseUrl(url);
|
||||||
|
|
||||||
|
if (!parsedUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (YOUTU_BE_HOST_PATTERN.test(parsedUrl.hostname)) {
|
||||||
|
const shortId = parsedUrl.pathname.split('/').filter(Boolean)[0] ?? '';
|
||||||
|
|
||||||
|
return YOUTUBE_VIDEO_ID_PATTERN.test(shortId) ? shortId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!YOUTUBE_HOST_PATTERN.test(parsedUrl.hostname)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
if (parsedUrl.pathname === '/watch') {
|
||||||
|
const queryId = parsedUrl.searchParams.get('v') ?? '';
|
||||||
|
|
||||||
|
return YOUTUBE_VIDEO_ID_PATTERN.test(queryId) ? queryId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathSegments.length >= 2 && (pathSegments[0] === 'embed' || pathSegments[0] === 'shorts')) {
|
||||||
|
const pathId = pathSegments[1];
|
||||||
|
|
||||||
|
return YOUTUBE_VIDEO_ID_PATTERN.test(pathId) ? pathId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isYoutubeUrl(url?: string): boolean {
|
||||||
|
return !!url && extractYoutubeVideoId(url) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractSpotifyResource(url: string): SpotifyResource | null {
|
||||||
|
const spotifyUriMatch = url.match(SPOTIFY_URI_PATTERN);
|
||||||
|
|
||||||
|
if (spotifyUriMatch?.[1] && spotifyUriMatch[2]) {
|
||||||
|
return {
|
||||||
|
type: spotifyUriMatch[1].toLowerCase() as SpotifyResourceType,
|
||||||
|
id: spotifyUriMatch[2]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedUrl = parseUrl(url);
|
||||||
|
|
||||||
|
if (!parsedUrl || !/^(?:open|play)\.spotify\.com$/i.test(parsedUrl.hostname)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = parsedUrl.pathname.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
if (segments.length >= 2 && SPOTIFY_RESOURCE_TYPES.has(segments[0] as SpotifyResourceType)) {
|
||||||
|
return {
|
||||||
|
type: segments[0] as SpotifyResourceType,
|
||||||
|
id: segments[1]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments.length >= 4 && segments[0] === 'user' && segments[2] === 'playlist') {
|
||||||
|
return {
|
||||||
|
type: 'playlist',
|
||||||
|
id: segments[3]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSpotifyUrl(url?: string): boolean {
|
||||||
|
return !!url && extractSpotifyResource(url) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractSoundcloudResource(url: string): SoundcloudResource | null {
|
||||||
|
const parsedUrl = parseUrl(url);
|
||||||
|
|
||||||
|
if (!parsedUrl || !SOUNDCLOUD_HOST_PATTERN.test(parsedUrl.hostname)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = parsedUrl.pathname.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
if (segments.length === 2) {
|
||||||
|
const canonicalUrl = new URL(`https://soundcloud.com/${segments[0]}/${segments[1]}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
canonicalUrl: canonicalUrl.toString(),
|
||||||
|
type: 'track'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments.length === 3 && segments[1] === 'sets') {
|
||||||
|
const canonicalUrl = new URL(`https://soundcloud.com/${segments[0]}/sets/${segments[2]}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
canonicalUrl: canonicalUrl.toString(),
|
||||||
|
type: 'playlist'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSoundcloudUrl(url?: string): boolean {
|
||||||
|
return !!url && extractSoundcloudResource(url) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasDedicatedChatEmbed(url?: string): boolean {
|
||||||
|
return isYoutubeUrl(url) || isSpotifyUrl(url) || isSoundcloudUrl(url);
|
||||||
|
}
|
||||||
@@ -100,6 +100,7 @@
|
|||||||
|
|
||||||
@if (msg.linkMetadata?.length) {
|
@if (msg.linkMetadata?.length) {
|
||||||
@for (meta of msg.linkMetadata; track meta.url) {
|
@for (meta of msg.linkMetadata; track meta.url) {
|
||||||
|
@if (shouldShowLinkEmbed(meta.url)) {
|
||||||
<app-chat-link-embed
|
<app-chat-link-embed
|
||||||
[metadata]="meta"
|
[metadata]="meta"
|
||||||
[canRemove]="isOwnMessage() || isAdmin()"
|
[canRemove]="isOwnMessage() || isAdmin()"
|
||||||
@@ -107,6 +108,7 @@
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@if (attachmentsList.length > 0) {
|
@if (attachmentsList.length > 0) {
|
||||||
<div class="mt-2 space-y-2">
|
<div class="mt-2 space-y-2">
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
MAX_AUTO_SAVE_SIZE_BYTES
|
MAX_AUTO_SAVE_SIZE_BYTES
|
||||||
} from '../../../../../attachment';
|
} from '../../../../../attachment';
|
||||||
import { KlipyService } from '../../../../application/services/klipy.service';
|
import { KlipyService } from '../../../../application/services/klipy.service';
|
||||||
|
import { hasDedicatedChatEmbed } from '../../../../domain/rules/link-embed.rules';
|
||||||
import {
|
import {
|
||||||
DELETED_MESSAGE_CONTENT,
|
DELETED_MESSAGE_CONTENT,
|
||||||
Message,
|
Message,
|
||||||
@@ -278,6 +279,10 @@ export class ChatMessageItemComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
shouldShowLinkEmbed(url?: string): boolean {
|
||||||
|
return !hasDedicatedChatEmbed(url);
|
||||||
|
}
|
||||||
|
|
||||||
requestReferenceScroll(messageId: string): void {
|
requestReferenceScroll(messageId: string): void {
|
||||||
this.referenceRequested.emit(messageId);
|
this.referenceRequested.emit(messageId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,14 @@
|
|||||||
<div class="block">
|
<div class="block">
|
||||||
<app-chat-youtube-embed [url]="node.url" />
|
<app-chat-youtube-embed [url]="node.url" />
|
||||||
</div>
|
</div>
|
||||||
|
} @else if (isSpotifyUrl(node.url)) {
|
||||||
|
<div class="block">
|
||||||
|
<app-chat-spotify-embed [url]="node.url" />
|
||||||
|
</div>
|
||||||
|
} @else if (isSoundcloudUrl(node.url)) {
|
||||||
|
<div class="block">
|
||||||
|
<app-chat-soundcloud-embed [url]="node.url" />
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</remark>
|
</remark>
|
||||||
|
|||||||
@@ -5,8 +5,15 @@ import remarkBreaks from 'remark-breaks';
|
|||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import remarkParse from 'remark-parse';
|
import remarkParse from 'remark-parse';
|
||||||
import { unified } from 'unified';
|
import { unified } from 'unified';
|
||||||
|
import {
|
||||||
|
isSoundcloudUrl,
|
||||||
|
isSpotifyUrl,
|
||||||
|
isYoutubeUrl
|
||||||
|
} from '../../../../../domain/rules/link-embed.rules';
|
||||||
import { ChatImageProxyFallbackDirective } from '../../../../chat-image-proxy-fallback.directive';
|
import { ChatImageProxyFallbackDirective } from '../../../../chat-image-proxy-fallback.directive';
|
||||||
import { ChatYoutubeEmbedComponent, isYoutubeUrl } from '../chat-youtube-embed/chat-youtube-embed.component';
|
import { ChatSoundcloudEmbedComponent } from '../chat-soundcloud-embed/chat-soundcloud-embed.component';
|
||||||
|
import { ChatSpotifyEmbedComponent } from '../chat-spotify-embed/chat-spotify-embed.component';
|
||||||
|
import { ChatYoutubeEmbedComponent } from '../chat-youtube-embed/chat-youtube-embed.component';
|
||||||
|
|
||||||
const PRISM_LANGUAGE_ALIASES: Record<string, string> = {
|
const PRISM_LANGUAGE_ALIASES: Record<string, string> = {
|
||||||
cs: 'csharp',
|
cs: 'csharp',
|
||||||
@@ -40,6 +47,8 @@ const REMARK_PROCESSOR = unified()
|
|||||||
RemarkModule,
|
RemarkModule,
|
||||||
MermaidComponent,
|
MermaidComponent,
|
||||||
ChatImageProxyFallbackDirective,
|
ChatImageProxyFallbackDirective,
|
||||||
|
ChatSpotifyEmbedComponent,
|
||||||
|
ChatSoundcloudEmbedComponent,
|
||||||
ChatYoutubeEmbedComponent
|
ChatYoutubeEmbedComponent
|
||||||
],
|
],
|
||||||
templateUrl: './chat-message-markdown.component.html'
|
templateUrl: './chat-message-markdown.component.html'
|
||||||
@@ -63,6 +72,14 @@ export class ChatMessageMarkdownComponent {
|
|||||||
return isYoutubeUrl(url);
|
return isYoutubeUrl(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isSpotifyUrl(url?: string): boolean {
|
||||||
|
return isSpotifyUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
isSoundcloudUrl(url?: string): boolean {
|
||||||
|
return isSoundcloudUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
isMermaidCodeBlock(lang?: string): boolean {
|
isMermaidCodeBlock(lang?: string): boolean {
|
||||||
return this.normalizeCodeLanguage(lang) === 'mermaid';
|
return this.normalizeCodeLanguage(lang) === 'mermaid';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
@if (embedUrl(); as soundcloudEmbedUrl) {
|
||||||
|
<div class="mt-2 w-[480px] max-w-full overflow-hidden rounded-md border border-border/60 bg-secondary/20">
|
||||||
|
<iframe
|
||||||
|
[src]="soundcloudEmbedUrl"
|
||||||
|
[style.height.px]="embedHeight()"
|
||||||
|
class="w-full border-0"
|
||||||
|
loading="lazy"
|
||||||
|
title="SoundCloud player"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
input
|
||||||
|
} from '@angular/core';
|
||||||
|
import { DomSanitizer } from '@angular/platform-browser';
|
||||||
|
import { extractSoundcloudResource } from '../../../../../domain/rules/link-embed.rules';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-chat-soundcloud-embed',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './chat-soundcloud-embed.component.html'
|
||||||
|
})
|
||||||
|
export class ChatSoundcloudEmbedComponent {
|
||||||
|
readonly url = input.required<string>();
|
||||||
|
|
||||||
|
readonly resource = computed(() => extractSoundcloudResource(this.url()));
|
||||||
|
|
||||||
|
readonly embedHeight = computed(() => this.resource()?.type === 'playlist' ? 352 : 166);
|
||||||
|
|
||||||
|
readonly embedUrl = computed(() => {
|
||||||
|
const resource = this.resource();
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const embedUrl = new URL('https://w.soundcloud.com/player/');
|
||||||
|
|
||||||
|
embedUrl.searchParams.set('url', resource.canonicalUrl);
|
||||||
|
embedUrl.searchParams.set('auto_play', 'false');
|
||||||
|
embedUrl.searchParams.set('hide_related', 'false');
|
||||||
|
embedUrl.searchParams.set('show_comments', 'false');
|
||||||
|
embedUrl.searchParams.set('show_user', 'true');
|
||||||
|
embedUrl.searchParams.set('show_reposts', 'false');
|
||||||
|
embedUrl.searchParams.set('show_teaser', 'true');
|
||||||
|
embedUrl.searchParams.set('visual', resource.type === 'playlist' ? 'true' : 'false');
|
||||||
|
|
||||||
|
return this.sanitizer.bypassSecurityTrustResourceUrl(embedUrl.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly sanitizer = inject(DomSanitizer);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
@if (embedUrl(); as spotifyEmbedUrl) {
|
||||||
|
<div class="mt-2 w-[480px] max-w-full overflow-hidden rounded-md border border-border/60 bg-secondary/20">
|
||||||
|
<iframe
|
||||||
|
[src]="spotifyEmbedUrl"
|
||||||
|
[style.height.px]="embedHeight()"
|
||||||
|
class="w-full border-0"
|
||||||
|
loading="lazy"
|
||||||
|
title="Spotify player"
|
||||||
|
allowfullscreen
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
input
|
||||||
|
} from '@angular/core';
|
||||||
|
import { DomSanitizer } from '@angular/platform-browser';
|
||||||
|
import { extractSpotifyResource } from '../../../../../domain/rules/link-embed.rules';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-chat-spotify-embed',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './chat-spotify-embed.component.html'
|
||||||
|
})
|
||||||
|
export class ChatSpotifyEmbedComponent {
|
||||||
|
readonly url = input.required<string>();
|
||||||
|
|
||||||
|
readonly resource = computed(() => extractSpotifyResource(this.url()));
|
||||||
|
|
||||||
|
readonly embedHeight = computed(() => {
|
||||||
|
const resource = this.resource();
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (resource.type) {
|
||||||
|
case 'track':
|
||||||
|
case 'episode':
|
||||||
|
return 152;
|
||||||
|
default:
|
||||||
|
return 352;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly embedUrl = computed(() => {
|
||||||
|
const resource = this.resource();
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const embedUrl = new URL(`https://open.spotify.com/embed/${resource.type}/${encodeURIComponent(resource.id)}`);
|
||||||
|
|
||||||
|
embedUrl.searchParams.set('utm_source', 'generator');
|
||||||
|
|
||||||
|
return this.sanitizer.bypassSecurityTrustResourceUrl(embedUrl.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly sanitizer = inject(DomSanitizer);
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
<iframe
|
<iframe
|
||||||
[src]="embedUrl()"
|
[src]="embedUrl()"
|
||||||
class="aspect-video w-full"
|
class="aspect-video w-full"
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
||||||
allowfullscreen
|
allowfullscreen
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
></iframe>
|
></iframe>
|
||||||
|
|||||||
@@ -5,8 +5,21 @@ import {
|
|||||||
input
|
input
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { DomSanitizer } from '@angular/platform-browser';
|
import { DomSanitizer } from '@angular/platform-browser';
|
||||||
|
import { extractYoutubeVideoId } from '../../../../../domain/rules/link-embed.rules';
|
||||||
|
|
||||||
const YOUTUBE_URL_PATTERN = /(?:youtube\.com\/(?:watch\?.*v=|embed\/|shorts\/)|youtu\.be\/)([\w-]{11})/;
|
const YOUTUBE_EMBED_FALLBACK_ORIGIN = 'https://toju.app';
|
||||||
|
|
||||||
|
function resolveYoutubeClientOrigin(): string {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return YOUTUBE_EMBED_FALLBACK_ORIGIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = window.location.origin;
|
||||||
|
|
||||||
|
return /^https?:\/\//.test(origin)
|
||||||
|
? origin
|
||||||
|
: YOUTUBE_EMBED_FALLBACK_ORIGIN;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-chat-youtube-embed',
|
selector: 'app-chat-youtube-embed',
|
||||||
@@ -16,11 +29,7 @@ const YOUTUBE_URL_PATTERN = /(?:youtube\.com\/(?:watch\?.*v=|embed\/|shorts\/)|y
|
|||||||
export class ChatYoutubeEmbedComponent {
|
export class ChatYoutubeEmbedComponent {
|
||||||
readonly url = input.required<string>();
|
readonly url = input.required<string>();
|
||||||
|
|
||||||
readonly videoId = computed(() => {
|
readonly videoId = computed(() => extractYoutubeVideoId(this.url()));
|
||||||
const match = this.url().match(YOUTUBE_URL_PATTERN);
|
|
||||||
|
|
||||||
return match?.[1] ?? null;
|
|
||||||
});
|
|
||||||
|
|
||||||
readonly embedUrl = computed(() => {
|
readonly embedUrl = computed(() => {
|
||||||
const id = this.videoId();
|
const id = this.videoId();
|
||||||
@@ -28,14 +37,16 @@ export class ChatYoutubeEmbedComponent {
|
|||||||
if (!id)
|
if (!id)
|
||||||
return '';
|
return '';
|
||||||
|
|
||||||
|
const clientOrigin = resolveYoutubeClientOrigin();
|
||||||
|
const embedUrl = new URL(`https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}`);
|
||||||
|
|
||||||
|
embedUrl.searchParams.set('origin', clientOrigin);
|
||||||
|
embedUrl.searchParams.set('widget_referrer', clientOrigin);
|
||||||
|
|
||||||
return this.sanitizer.bypassSecurityTrustResourceUrl(
|
return this.sanitizer.bypassSecurityTrustResourceUrl(
|
||||||
`https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}`
|
embedUrl.toString()
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
private readonly sanitizer = inject(DomSanitizer);
|
private readonly sanitizer = inject(DomSanitizer);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isYoutubeUrl(url?: string): boolean {
|
|
||||||
return !!url && YOUTUBE_URL_PATTERN.test(url);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Profile Avatar Domain
|
# Profile Avatar Domain
|
||||||
|
|
||||||
Owns local profile picture workflow: source validation, crop/zoom editor state, static 256x256 WebP rendering, animated avatar preservation, desktop file persistence, and P2P avatar sync metadata.
|
Owns local profile picture workflow plus peer-synced profile-card metadata: source validation, crop/zoom editor state, static 256x256 WebP rendering, animated avatar preservation, desktop file persistence, and P2P avatar/profile sync metadata.
|
||||||
|
|
||||||
## Responsibilities
|
## Responsibilities
|
||||||
|
|
||||||
@@ -9,7 +9,9 @@ Owns local profile picture workflow: source validation, crop/zoom editor state,
|
|||||||
- Render static avatars to `256x256` WebP with client-side compression.
|
- Render static avatars to `256x256` WebP with client-side compression.
|
||||||
- Preserve animated `.gif` and animated `.webp` uploads without flattening frames.
|
- Preserve animated `.gif` and animated `.webp` uploads without flattening frames.
|
||||||
- Persist desktop copy at `user/<username>/profile/profile.<ext>` under app data.
|
- Persist desktop copy at `user/<username>/profile/profile.<ext>` under app data.
|
||||||
|
- Let the local user edit their profile-card display name and description.
|
||||||
- Expose helpers used by store effects to keep avatar metadata (`avatarHash`, `avatarMime`, `avatarUpdatedAt`) consistent.
|
- Expose helpers used by store effects to keep avatar metadata (`avatarHash`, `avatarMime`, `avatarUpdatedAt`) consistent.
|
||||||
|
- Reuse the avatar summary/request/full handshake to sync profile text (`displayName`, `description`, `profileUpdatedAt`) alongside avatar state.
|
||||||
|
|
||||||
## Module map
|
## Module map
|
||||||
|
|
||||||
@@ -33,12 +35,14 @@ graph TD
|
|||||||
## Flow
|
## Flow
|
||||||
|
|
||||||
1. `ProfileCardComponent` opens file picker from editable avatar button.
|
1. `ProfileCardComponent` opens file picker from editable avatar button.
|
||||||
2. `ProfileAvatarEditorComponent` previews exact crop using drag + zoom.
|
2. `ProfileCardComponent` saves display-name and description edits through the users store.
|
||||||
3. `ProfileAvatarImageService` renders static uploads to `256x256` WebP, but keeps animated GIF and WebP sources intact.
|
3. `ProfileAvatarEditorComponent` previews exact crop using drag + zoom.
|
||||||
4. `ProfileAvatarStorageService` writes desktop copy when Electron is available.
|
4. `ProfileAvatarImageService` renders static uploads to `256x256` WebP, but keeps animated GIF and WebP sources intact.
|
||||||
5. `UserAvatarEffects` broadcasts avatar summary, answers requests, streams chunks, and persists received avatars locally.
|
5. `ProfileAvatarStorageService` writes desktop copy when Electron is available.
|
||||||
|
6. `UserAvatarEffects` broadcasts avatar/profile summaries, answers requests, streams chunks when needed, and persists received profile state locally.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Static uploads are normalized to WebP. Animated GIF and animated WebP uploads keep their original animation, mime type, and full-frame presentation.
|
- 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`.
|
- `avatarUrl` stays local display data. Version conflict resolution uses `avatarUpdatedAt` and `avatarHash`.
|
||||||
|
- Profile text uses its own `profileUpdatedAt` version so display-name and description changes can sync without replacing a newer avatar.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-[112] bg-black/70 backdrop-blur-sm"
|
class="fixed inset-0 z-[112] bg-black/70 backdrop-blur-sm"
|
||||||
(click)="cancelled.emit()"
|
(click)="cancelled.emit(undefined)"
|
||||||
(keydown.enter)="cancelled.emit()"
|
(keydown.enter)="cancelled.emit(undefined)"
|
||||||
(keydown.space)="cancelled.emit()"
|
(keydown.space)="cancelled.emit(undefined)"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-label="Close profile image editor"
|
aria-label="Close profile image editor"
|
||||||
@@ -11,7 +11,6 @@
|
|||||||
<div class="fixed inset-0 z-[113] flex items-center justify-center p-4 pointer-events-none">
|
<div class="fixed inset-0 z-[113] flex items-center justify-center p-4 pointer-events-none">
|
||||||
<div
|
<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"
|
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"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
@@ -135,7 +134,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg bg-secondary px-4 py-2 text-sm text-foreground transition-colors hover:bg-secondary/80"
|
class="rounded-lg bg-secondary px-4 py-2 text-sm text-foreground transition-colors hover:bg-secondary/80"
|
||||||
(click)="cancelled.emit()"
|
(click)="cancelled.emit(undefined)"
|
||||||
[disabled]="processing()"
|
[disabled]="processing()"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
export class ProfileAvatarEditorComponent {
|
export class ProfileAvatarEditorComponent {
|
||||||
readonly source = input.required<EditableProfileAvatarSource>();
|
readonly source = input.required<EditableProfileAvatarSource>();
|
||||||
|
|
||||||
readonly cancelled = output<void>();
|
readonly cancelled = output<undefined>();
|
||||||
readonly confirmed = output<ProcessedProfileAvatar>();
|
readonly confirmed = output<ProcessedProfileAvatar>();
|
||||||
|
|
||||||
readonly frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE;
|
readonly frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE;
|
||||||
@@ -53,7 +53,7 @@ export class ProfileAvatarEditorComponent {
|
|||||||
@HostListener('document:keydown.escape')
|
@HostListener('document:keydown.escape')
|
||||||
onEscape(): void {
|
onEscape(): void {
|
||||||
if (!this.processing()) {
|
if (!this.processing()) {
|
||||||
this.cancelled.emit();
|
this.cancelled.emit(undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import {
|
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
|
||||||
Overlay,
|
|
||||||
OverlayRef
|
|
||||||
} from '@angular/cdk/overlay';
|
|
||||||
import { ComponentPortal } from '@angular/cdk/portal';
|
import { ComponentPortal } from '@angular/cdk/portal';
|
||||||
import {
|
import { EditableProfileAvatarSource, ProcessedProfileAvatar } from '../../domain/profile-avatar.models';
|
||||||
EditableProfileAvatarSource,
|
|
||||||
ProcessedProfileAvatar
|
|
||||||
} from '../../domain/profile-avatar.models';
|
|
||||||
import { ProfileAvatarEditorComponent } from './profile-avatar-editor.component';
|
import { ProfileAvatarEditorComponent } from './profile-avatar-editor.component';
|
||||||
|
|
||||||
export const PROFILE_AVATAR_EDITOR_OVERLAY_CLASS = 'profile-avatar-editor-overlay-pane';
|
export const PROFILE_AVATAR_EDITOR_OVERLAY_CLASS = 'profile-avatar-editor-overlay-pane';
|
||||||
@@ -25,7 +19,9 @@ export class ProfileAvatarEditorService {
|
|||||||
const overlayRef = this.overlay.create({
|
const overlayRef = this.overlay.create({
|
||||||
disposeOnNavigation: true,
|
disposeOnNavigation: true,
|
||||||
panelClass: PROFILE_AVATAR_EDITOR_OVERLAY_CLASS,
|
panelClass: PROFILE_AVATAR_EDITOR_OVERLAY_CLASS,
|
||||||
positionStrategy: this.overlay.position().global().centerHorizontally().centerVertically(),
|
positionStrategy: this.overlay.position().global()
|
||||||
|
.centerHorizontally()
|
||||||
|
.centerVertically(),
|
||||||
scrollStrategy: this.overlay.scrollStrategies.block()
|
scrollStrategy: this.overlay.scrollStrategies.block()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,7 +51,6 @@ export class ProfileAvatarEditorService {
|
|||||||
overlayRef.dispose();
|
overlayRef.dispose();
|
||||||
resolve(result);
|
resolve(result);
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelSub = componentRef.instance.cancelled.subscribe(() => finish(null));
|
const cancelSub = componentRef.instance.cancelled.subscribe(() => finish(null));
|
||||||
const confirmSub = componentRef.instance.confirmed.subscribe((avatar) => finish(avatar));
|
const confirmSub = componentRef.instance.confirmed.subscribe((avatar) => finish(avatar));
|
||||||
const detachSub = overlayRef.detachments().subscribe(() => finish(null));
|
const detachSub = overlayRef.detachments().subscribe(() => finish(null));
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import {
|
/* eslint-disable @stylistic/js/array-element-newline */
|
||||||
isAnimatedGif,
|
import { isAnimatedGif, isAnimatedWebp } from './profile-avatar-image.service';
|
||||||
isAnimatedWebp
|
|
||||||
} from './profile-avatar-image.service';
|
|
||||||
|
|
||||||
describe('profile-avatar image animation detection', () => {
|
describe('profile-avatar image animation detection', () => {
|
||||||
it('detects animated gifs with multiple frames', () => {
|
it('detects animated gifs with multiple frames', () => {
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||||
import { User } from '../../../../shared-kernel';
|
import type { User } from '../../../../shared-kernel';
|
||||||
import {
|
import { resolveProfileAvatarStorageFileName, type ProcessedProfileAvatar } from '../../domain/profile-avatar.models';
|
||||||
ProcessedProfileAvatar,
|
|
||||||
resolveProfileAvatarStorageFileName
|
|
||||||
} from '../../domain/profile-avatar.models';
|
|
||||||
|
|
||||||
const LEGACY_PROFILE_FILE_NAMES = [
|
const LEGACY_PROFILE_FILE_NAMES = [
|
||||||
'profile.webp',
|
'profile.webp',
|
||||||
|
|||||||
@@ -199,6 +199,8 @@ export class RoomsSidePanelComponent {
|
|||||||
oderId: member.oderId || member.id,
|
oderId: member.oderId || member.id,
|
||||||
username: member.username,
|
username: member.username,
|
||||||
displayName: member.displayName,
|
displayName: member.displayName,
|
||||||
|
description: member.description,
|
||||||
|
profileUpdatedAt: member.profileUpdatedAt,
|
||||||
avatarUrl: member.avatarUrl,
|
avatarUrl: member.avatarUrl,
|
||||||
status: 'disconnected',
|
status: 'disconnected',
|
||||||
role: member.role,
|
role: member.role,
|
||||||
|
|||||||
@@ -489,16 +489,9 @@ export class ServersRailComponent {
|
|||||||
ensureEndpoint: !!resolvedRoom.sourceUrl
|
ensureEndpoint: !!resolvedRoom.sourceUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
const authoritativeServer = (
|
const authoritativeServer = selector
|
||||||
selector
|
|
||||||
? await firstValueFrom(this.serverDirectory.getServer(room.id, selector))
|
? await firstValueFrom(this.serverDirectory.getServer(room.id, selector))
|
||||||
: null
|
: null;
|
||||||
) ?? await firstValueFrom(this.serverDirectory.findServerAcrossActiveEndpoints(room.id, {
|
|
||||||
sourceId: resolvedRoom.sourceId,
|
|
||||||
sourceName: resolvedRoom.sourceName,
|
|
||||||
sourceUrl: resolvedRoom.sourceUrl,
|
|
||||||
fallbackName: resolvedRoom.sourceName ?? resolvedRoom.name
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (!authoritativeServer) {
|
if (!authoritativeServer) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ A single ordered data channel carries all peer-to-peer messages: chat events, at
|
|||||||
|
|
||||||
Back-pressure is handled with a high-water mark (4 MB) and low-water mark (1 MB). `sendToPeerBuffered()` waits for the buffer to drain before sending, which matters during file transfers.
|
Back-pressure is handled with a high-water mark (4 MB) and low-water mark (1 MB). `sendToPeerBuffered()` waits for the buffer to drain before sending, which matters during file transfers.
|
||||||
|
|
||||||
Profile avatar sync follows attachment-style chunk transport plus server-icon-style version handshakes: sender announces `avatarUpdatedAt`, receiver requests only when remote version is newer, then sender streams ordered base64 chunks over buffered sends.
|
Profile avatar sync follows attachment-style chunk transport plus server-icon-style version handshakes: sender announces avatar/profile versions, receiver requests only when either remote version is newer, then sender streams ordered base64 chunks when avatar bytes are needed and still uses the same full-event path for profile-only updates.
|
||||||
|
|
||||||
Every 5 seconds a PING message is sent to each peer. The peer responds with PONG carrying the original timestamp, and the round-trip latency is stored in a signal.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -328,8 +328,13 @@ export class WebRTCService implements OnDestroy {
|
|||||||
* @param oderId - The user's unique order/peer ID.
|
* @param oderId - The user's unique order/peer ID.
|
||||||
* @param displayName - The user's display name.
|
* @param displayName - The user's display name.
|
||||||
*/
|
*/
|
||||||
identify(oderId: string, displayName: string, signalUrl?: string): void {
|
identify(
|
||||||
this.signalingTransportHandler.identify(oderId, displayName, signalUrl);
|
oderId: string,
|
||||||
|
displayName: string,
|
||||||
|
signalUrl?: string,
|
||||||
|
profile?: { description?: string; profileUpdatedAt?: number }
|
||||||
|
): void {
|
||||||
|
this.signalingTransportHandler.identify(oderId, displayName, signalUrl, profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export interface IdentifyCredentials {
|
|||||||
oderId: string;
|
oderId: string;
|
||||||
/** The user's display name shown to other peers. */
|
/** The user's display name shown to other peers. */
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
/** Optional profile description advertised via signaling identity. */
|
||||||
|
description?: string;
|
||||||
|
/** Monotonic profile version for late-join reconciliation. */
|
||||||
|
profileUpdatedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Last-joined server info, used for reconnection. */
|
/** Last-joined server info, used for reconnection. */
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export class SignalingTransportHandler<TMessage> {
|
|||||||
return this.lastIdentifyCredentials?.displayName || DEFAULT_DISPLAY_NAME;
|
return this.lastIdentifyCredentials?.displayName || DEFAULT_DISPLAY_NAME;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getIdentifyDescription(): string | undefined {
|
||||||
|
return this.lastIdentifyCredentials?.description;
|
||||||
|
}
|
||||||
|
|
||||||
getConnectedSignalingManagers(): ConnectedSignalingManager[] {
|
getConnectedSignalingManagers(): ConnectedSignalingManager[] {
|
||||||
return this.dependencies.signalingCoordinator.getConnectedSignalingManagers();
|
return this.dependencies.signalingCoordinator.getConnectedSignalingManagers();
|
||||||
}
|
}
|
||||||
@@ -160,12 +164,27 @@ export class SignalingTransportHandler<TMessage> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
identify(oderId: string, displayName: string, signalUrl?: string): void {
|
identify(
|
||||||
|
oderId: string,
|
||||||
|
displayName: string,
|
||||||
|
signalUrl?: string,
|
||||||
|
profile?: Pick<IdentifyCredentials, 'description' | 'profileUpdatedAt'>
|
||||||
|
): void {
|
||||||
const normalizedDisplayName = displayName.trim() || DEFAULT_DISPLAY_NAME;
|
const normalizedDisplayName = displayName.trim() || DEFAULT_DISPLAY_NAME;
|
||||||
|
const normalizedDescription = typeof profile?.description === 'string'
|
||||||
|
? (profile.description.trim() || undefined)
|
||||||
|
: undefined;
|
||||||
|
const normalizedProfileUpdatedAt = typeof profile?.profileUpdatedAt === 'number'
|
||||||
|
&& Number.isFinite(profile.profileUpdatedAt)
|
||||||
|
&& profile.profileUpdatedAt > 0
|
||||||
|
? profile.profileUpdatedAt
|
||||||
|
: undefined;
|
||||||
|
|
||||||
this.lastIdentifyCredentials = {
|
this.lastIdentifyCredentials = {
|
||||||
oderId,
|
oderId,
|
||||||
displayName: normalizedDisplayName
|
displayName: normalizedDisplayName,
|
||||||
|
description: normalizedDescription,
|
||||||
|
profileUpdatedAt: normalizedProfileUpdatedAt
|
||||||
};
|
};
|
||||||
|
|
||||||
if (signalUrl) {
|
if (signalUrl) {
|
||||||
@@ -173,6 +192,8 @@ export class SignalingTransportHandler<TMessage> {
|
|||||||
type: SIGNALING_TYPE_IDENTIFY,
|
type: SIGNALING_TYPE_IDENTIFY,
|
||||||
oderId,
|
oderId,
|
||||||
displayName: normalizedDisplayName,
|
displayName: normalizedDisplayName,
|
||||||
|
description: normalizedDescription,
|
||||||
|
profileUpdatedAt: normalizedProfileUpdatedAt,
|
||||||
connectionScope: signalUrl
|
connectionScope: signalUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -190,6 +211,8 @@ export class SignalingTransportHandler<TMessage> {
|
|||||||
type: SIGNALING_TYPE_IDENTIFY,
|
type: SIGNALING_TYPE_IDENTIFY,
|
||||||
oderId,
|
oderId,
|
||||||
displayName: normalizedDisplayName,
|
displayName: normalizedDisplayName,
|
||||||
|
description: normalizedDescription,
|
||||||
|
profileUpdatedAt: normalizedProfileUpdatedAt,
|
||||||
connectionScope: managerSignalUrl
|
connectionScope: managerSignalUrl
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export interface ChatEventBase {
|
|||||||
deletedBy?: string;
|
deletedBy?: string;
|
||||||
oderId?: string;
|
oderId?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
description?: string;
|
||||||
|
profileUpdatedAt?: number;
|
||||||
emoji?: string;
|
emoji?: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
settings?: Partial<RoomSettings>;
|
settings?: Partial<RoomSettings>;
|
||||||
@@ -273,6 +275,8 @@ export interface UserAvatarSummaryEvent extends ChatEventBase {
|
|||||||
oderId: string;
|
oderId: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
description?: string;
|
||||||
|
profileUpdatedAt?: number;
|
||||||
avatarHash?: string;
|
avatarHash?: string;
|
||||||
avatarMime?: string;
|
avatarMime?: string;
|
||||||
avatarUpdatedAt: number;
|
avatarUpdatedAt: number;
|
||||||
@@ -288,8 +292,10 @@ export interface UserAvatarFullEvent extends ChatEventBase {
|
|||||||
oderId: string;
|
oderId: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
description?: string;
|
||||||
|
profileUpdatedAt?: number;
|
||||||
avatarHash?: string;
|
avatarHash?: string;
|
||||||
avatarMime: string;
|
avatarMime?: string;
|
||||||
avatarUpdatedAt: number;
|
avatarUpdatedAt: number;
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export interface User {
|
|||||||
oderId: string;
|
oderId: string;
|
||||||
username: string;
|
username: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
description?: string;
|
||||||
|
profileUpdatedAt?: number;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
avatarHash?: string;
|
avatarHash?: string;
|
||||||
avatarMime?: string;
|
avatarMime?: string;
|
||||||
@@ -35,6 +37,8 @@ export interface RoomMember {
|
|||||||
oderId?: string;
|
oderId?: string;
|
||||||
username: string;
|
username: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
description?: string;
|
||||||
|
profileUpdatedAt?: number;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
avatarHash?: string;
|
avatarHash?: string;
|
||||||
avatarMime?: string;
|
avatarMime?: string;
|
||||||
|
|||||||
@@ -2,26 +2,29 @@
|
|||||||
class="w-72 rounded-lg border border-border bg-card shadow-xl"
|
class="w-72 rounded-lg border border-border bg-card shadow-xl"
|
||||||
style="animation: profile-card-in 120ms cubic-bezier(0.2, 0, 0, 1) both"
|
style="animation: profile-card-in 120ms cubic-bezier(0.2, 0, 0, 1) both"
|
||||||
>
|
>
|
||||||
<div class="h-24 rounded-t-lg bg-gradient-to-r from-primary/30 to-primary/10"></div>
|
@let profileUser = user();
|
||||||
|
@let isEditable = editable();
|
||||||
|
@let activeField = editingField();
|
||||||
|
@let statusColor = currentStatusColor();
|
||||||
|
@let statusLabel = currentStatusLabel();
|
||||||
|
|
||||||
|
<div class="h-20 rounded-t-lg bg-gradient-to-r from-primary/30 to-primary/10"></div>
|
||||||
|
|
||||||
<div class="relative px-4">
|
<div class="relative px-4">
|
||||||
<div class="-mt-9">
|
<div class="-mt-8">
|
||||||
@if (editable()) {
|
|
||||||
<button
|
<button
|
||||||
#avatarInputButton
|
|
||||||
type="button"
|
type="button"
|
||||||
class="group relative rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
class="rounded-full"
|
||||||
(click)="pickAvatar(avatarInput)"
|
(click)="pickAvatar(avatarInput)"
|
||||||
>
|
>
|
||||||
<app-user-avatar
|
<app-user-avatar
|
||||||
[name]="user().displayName"
|
[name]="profileUser.displayName"
|
||||||
[avatarUrl]="user().avatarUrl"
|
[avatarUrl]="profileUser.avatarUrl"
|
||||||
size="xl"
|
size="xl"
|
||||||
[status]="user().status"
|
[status]="profileUser.status"
|
||||||
[showStatusBadge]="true"
|
[showStatusBadge]="true"
|
||||||
ringClass="ring-4 ring-card"
|
ringClass="ring-4 ring-card"
|
||||||
/>
|
/>
|
||||||
<span class="pointer-events-none absolute inset-0 rounded-full bg-black/0 transition-colors group-hover:bg-black/15"></span>
|
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
#avatarInput
|
#avatarInput
|
||||||
@@ -30,35 +33,78 @@
|
|||||||
[accept]="avatarAccept"
|
[accept]="avatarAccept"
|
||||||
(change)="onAvatarSelected($event)"
|
(change)="onAvatarSelected($event)"
|
||||||
/>
|
/>
|
||||||
} @else {
|
</div>
|
||||||
<app-user-avatar
|
</div>
|
||||||
[name]="user().displayName"
|
|
||||||
[avatarUrl]="user().avatarUrl"
|
<div class="px-4 pb-3 pt-2.5">
|
||||||
size="xl"
|
@if (isEditable) {
|
||||||
[status]="user().status"
|
<div class="space-y-2">
|
||||||
[showStatusBadge]="true"
|
<div>
|
||||||
ringClass="ring-4 ring-card"
|
@if (activeField === 'displayName') {
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="w-full rounded-md border border-border bg-background/70 px-2 py-1.5 text-base font-semibold text-foreground outline-none focus:border-primary/70"
|
||||||
|
|
||||||
|
[value]="displayNameDraft()"
|
||||||
|
(input)="onDisplayNameInput($event)"
|
||||||
|
(blur)="finishEdit('displayName')"
|
||||||
/>
|
/>
|
||||||
|
} @else {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="block w-full py-0.5 text-left text-base font-semibold text-foreground"
|
||||||
|
(click)="startEdit('displayName')"
|
||||||
|
>
|
||||||
|
{{ profileUser.displayName }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<p class="truncate text-sm text-muted-foreground">{{ profileUser.username }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
@if (activeField === 'description') {
|
||||||
|
<textarea
|
||||||
|
rows="3"
|
||||||
|
class="w-full resize-none rounded-md border border-border bg-background/70 px-2 py-2 text-sm leading-5 text-foreground outline-none focus:border-primary/70"
|
||||||
|
|
||||||
|
[value]="descriptionDraft()"
|
||||||
|
placeholder="Add a description"
|
||||||
|
(input)="onDescriptionInput($event)"
|
||||||
|
(blur)="finishEdit('description')"
|
||||||
|
></textarea>
|
||||||
|
} @else {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="block w-full py-1 text-left text-sm leading-5"
|
||||||
|
(click)="startEdit('description')"
|
||||||
|
>
|
||||||
|
@if (profileUser.description) {
|
||||||
|
<span class="whitespace-pre-line text-muted-foreground">{{ profileUser.description }}</span>
|
||||||
|
} @else {
|
||||||
|
<span class="text-muted-foreground/70">Add a description</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
} @else {
|
||||||
|
<p class="truncate text-base font-semibold text-foreground">{{ profileUser.displayName }}</p>
|
||||||
|
<p class="truncate text-sm text-muted-foreground">{{ profileUser.username }}</p>
|
||||||
|
|
||||||
<div class="px-5 pb-4 pt-3">
|
@if (profileUser.description) {
|
||||||
<p class="truncate text-base font-semibold text-foreground">{{ user().displayName }}</p>
|
<p class="mt-2 whitespace-pre-line text-sm leading-5 text-muted-foreground">{{ profileUser.description }}</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()) {
|
@if (avatarError()) {
|
||||||
<div class="mt-3 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-red-200">
|
<div class="mt-2.5 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-red-200">
|
||||||
{{ avatarError() }}
|
{{ avatarError() }}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (editable()) {
|
@if (isEditable) {
|
||||||
<div class="relative mt-3">
|
<div class="relative mt-2.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full items-center gap-2 rounded-md border border-border px-2.5 py-1.5 text-xs transition-colors hover:bg-secondary/60"
|
class="flex w-full items-center gap-2 rounded-md border border-border px-2.5 py-1.5 text-xs transition-colors hover:bg-secondary/60"
|
||||||
@@ -66,9 +112,9 @@
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="h-2 w-2 rounded-full"
|
class="h-2 w-2 rounded-full"
|
||||||
[class]="currentStatusColor()"
|
[class]="statusColor"
|
||||||
></span>
|
></span>
|
||||||
<span class="flex-1 text-left text-foreground">{{ currentStatusLabel() }}</span>
|
<span class="flex-1 text-left text-foreground">{{ statusLabel }}</span>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideChevronDown"
|
name="lucideChevronDown"
|
||||||
class="h-3 w-3 text-muted-foreground"
|
class="h-3 w-3 text-muted-foreground"
|
||||||
@@ -97,9 +143,9 @@
|
|||||||
<div class="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
|
<div class="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<span
|
<span
|
||||||
class="h-2 w-2 rounded-full"
|
class="h-2 w-2 rounded-full"
|
||||||
[class]="currentStatusColor()"
|
[class]="statusColor"
|
||||||
></span>
|
></span>
|
||||||
<span>{{ currentStatusLabel() }}</span>
|
<span>{{ statusLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
|
effect,
|
||||||
inject,
|
inject,
|
||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
@@ -37,6 +38,9 @@ export class ProfileCardComponent {
|
|||||||
readonly avatarAccept = PROFILE_AVATAR_ACCEPT_ATTRIBUTE;
|
readonly avatarAccept = PROFILE_AVATAR_ACCEPT_ATTRIBUTE;
|
||||||
readonly avatarError = signal<string | null>(null);
|
readonly avatarError = signal<string | null>(null);
|
||||||
readonly avatarSaving = signal(false);
|
readonly avatarSaving = signal(false);
|
||||||
|
readonly editingField = signal<'displayName' | 'description' | null>(null);
|
||||||
|
readonly displayNameDraft = signal('');
|
||||||
|
readonly descriptionDraft = signal('');
|
||||||
|
|
||||||
readonly statusOptions: { value: UserStatus | null; label: string; color: string }[] = [
|
readonly statusOptions: { value: UserStatus | null; label: string; color: string }[] = [
|
||||||
{ value: null, label: 'Online', color: 'bg-green-500' },
|
{ value: null, label: 'Online', color: 'bg-green-500' },
|
||||||
@@ -49,6 +53,19 @@ export class ProfileCardComponent {
|
|||||||
private readonly store = inject(Store);
|
private readonly store = inject(Store);
|
||||||
private readonly profileAvatar = inject(ProfileAvatarFacade);
|
private readonly profileAvatar = inject(ProfileAvatarFacade);
|
||||||
private readonly profileAvatarEditor = inject(ProfileAvatarEditorService);
|
private readonly profileAvatarEditor = inject(ProfileAvatarEditorService);
|
||||||
|
private readonly syncProfileDrafts = effect(() => {
|
||||||
|
const user = this.user();
|
||||||
|
const editingField = this.editingField();
|
||||||
|
|
||||||
|
if (editingField !== 'displayName') {
|
||||||
|
this.displayNameDraft.set(user.displayName || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingField !== 'description') {
|
||||||
|
this.descriptionDraft.set(user.description || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
}, { allowSignalWrites: true });
|
||||||
|
|
||||||
currentStatusColor(): string {
|
currentStatusColor(): string {
|
||||||
switch (this.user().status) {
|
switch (this.user().status) {
|
||||||
@@ -81,6 +98,31 @@ export class ProfileCardComponent {
|
|||||||
this.showStatusMenu.set(false);
|
this.showStatusMenu.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onDisplayNameInput(event: Event): void {
|
||||||
|
this.displayNameDraft.set((event.target as HTMLInputElement).value);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDescriptionInput(event: Event): void {
|
||||||
|
this.descriptionDraft.set((event.target as HTMLTextAreaElement).value);
|
||||||
|
}
|
||||||
|
|
||||||
|
startEdit(field: 'displayName' | 'description'): void {
|
||||||
|
if (!this.editable() || this.editingField() === field) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.editingField.set(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
finishEdit(field: 'displayName' | 'description'): void {
|
||||||
|
if (this.editingField() !== field) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.commitProfileDrafts();
|
||||||
|
this.editingField.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
pickAvatar(fileInput: HTMLInputElement): void {
|
pickAvatar(fileInput: HTMLInputElement): void {
|
||||||
if (!this.editable() || this.avatarSaving()) {
|
if (!this.editable() || this.avatarSaving()) {
|
||||||
return;
|
return;
|
||||||
@@ -147,4 +189,49 @@ export class ProfileCardComponent {
|
|||||||
this.avatarSaving.set(false);
|
this.avatarSaving.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private commitProfileDrafts(): void {
|
||||||
|
if (!this.editable()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = this.normalizeDisplayName(this.displayNameDraft());
|
||||||
|
|
||||||
|
if (!displayName) {
|
||||||
|
this.displayNameDraft.set(this.user().displayName || '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = this.user();
|
||||||
|
const description = this.normalizeDescription(this.descriptionDraft());
|
||||||
|
|
||||||
|
if (
|
||||||
|
displayName === this.normalizeDisplayName(user.displayName)
|
||||||
|
&& description === this.normalizeDescription(user.description)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = {
|
||||||
|
displayName,
|
||||||
|
description,
|
||||||
|
profileUpdatedAt: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
this.store.dispatch(UsersActions.updateCurrentUserProfile({ profile }));
|
||||||
|
this.user.update((user) => ({
|
||||||
|
...user,
|
||||||
|
...profile
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeDisplayName(value: string | undefined): string {
|
||||||
|
return value?.trim().replace(/\s+/g, ' ') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeDescription(value: string | undefined): string | undefined {
|
||||||
|
const normalized = value?.trim();
|
||||||
|
|
||||||
|
return normalized || undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { DatabaseService } from '../../infrastructure/persistence';
|
|||||||
import { reportDebuggingError, trackDebuggingTaskFailure } from '../../core/helpers/debugging-helpers';
|
import { reportDebuggingError, trackDebuggingTaskFailure } from '../../core/helpers/debugging-helpers';
|
||||||
import { DebuggingService } from '../../core/services';
|
import { DebuggingService } from '../../core/services';
|
||||||
import { AttachmentFacade } from '../../domains/attachment';
|
import { AttachmentFacade } from '../../domains/attachment';
|
||||||
|
import { hasDedicatedChatEmbed } from '../../domains/chat/domain/rules/link-embed.rules';
|
||||||
import { LinkMetadataService } from '../../domains/chat/application/services/link-metadata.service';
|
import { LinkMetadataService } from '../../domains/chat/application/services/link-metadata.service';
|
||||||
import { TimeSyncService } from '../../core/services/time-sync.service';
|
import { TimeSyncService } from '../../core/services/time-sync.service';
|
||||||
import {
|
import {
|
||||||
@@ -388,7 +389,8 @@ export class MessagesEffects {
|
|||||||
if (message.isDeleted || message.linkMetadata?.length)
|
if (message.isDeleted || message.linkMetadata?.length)
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
||||||
const urls = this.linkMetadata.extractUrls(message.content);
|
const urls = this.linkMetadata.extractUrls(message.content)
|
||||||
|
.filter((url) => !hasDedicatedChatEmbed(url));
|
||||||
|
|
||||||
if (urls.length === 0)
|
if (urls.length === 0)
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|||||||
@@ -5,15 +5,14 @@ import {
|
|||||||
createEffect,
|
createEffect,
|
||||||
ofType
|
ofType
|
||||||
} from '@ngrx/effects';
|
} from '@ngrx/effects';
|
||||||
import { Action } from '@ngrx/store';
|
import { Store, type Action } from '@ngrx/store';
|
||||||
import { Store } from '@ngrx/store';
|
|
||||||
import { EMPTY } from 'rxjs';
|
import { EMPTY } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
mergeMap,
|
mergeMap,
|
||||||
tap,
|
tap,
|
||||||
withLatestFrom
|
withLatestFrom
|
||||||
} from 'rxjs/operators';
|
} from 'rxjs/operators';
|
||||||
import {
|
import type {
|
||||||
ChatEvent,
|
ChatEvent,
|
||||||
Room,
|
Room,
|
||||||
RoomMember,
|
RoomMember,
|
||||||
@@ -394,7 +393,28 @@ export class RoomMembersSyncEffects {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.createRoomMemberUpdateActions(room, members);
|
const actions = this.createRoomMemberUpdateActions(room, members);
|
||||||
|
const currentUserId = currentUser?.oderId || currentUser?.id;
|
||||||
|
|
||||||
|
for (const member of members) {
|
||||||
|
const memberId = member.oderId || member.id;
|
||||||
|
|
||||||
|
if (!member.avatarUrl || !memberId || memberId === currentUserId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.push(UsersActions.upsertRemoteUserAvatar({
|
||||||
|
user: {
|
||||||
|
id: member.id,
|
||||||
|
oderId: memberId,
|
||||||
|
username: member.username,
|
||||||
|
displayName: member.displayName,
|
||||||
|
avatarUrl: member.avatarUrl
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleMemberLeave(
|
private handleMemberLeave(
|
||||||
|
|||||||
@@ -36,6 +36,51 @@ function normalizeAvatarUpdatedAt(value: unknown): number | undefined {
|
|||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeProfileUpdatedAt(value: unknown): number | undefined {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||||
|
? value
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDescription(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim();
|
||||||
|
|
||||||
|
return normalized || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOwnProperty(object: object, key: string): boolean {
|
||||||
|
return Object.prototype.hasOwnProperty.call(object, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeProfileFields(
|
||||||
|
existingMember: Pick<RoomMember, 'displayName' | 'description' | 'profileUpdatedAt'>,
|
||||||
|
incomingMember: Pick<RoomMember, 'displayName' | 'description' | 'profileUpdatedAt'>,
|
||||||
|
preferIncomingFallback: boolean
|
||||||
|
): Pick<RoomMember, 'displayName' | 'description' | 'profileUpdatedAt'> {
|
||||||
|
const existingUpdatedAt = existingMember.profileUpdatedAt ?? 0;
|
||||||
|
const incomingUpdatedAt = incomingMember.profileUpdatedAt ?? 0;
|
||||||
|
const preferIncoming = incomingUpdatedAt === existingUpdatedAt
|
||||||
|
? preferIncomingFallback
|
||||||
|
: incomingUpdatedAt > existingUpdatedAt;
|
||||||
|
const incomingHasDescription = hasOwnProperty(incomingMember, 'description');
|
||||||
|
const incomingDescription = normalizeDescription(incomingMember.description);
|
||||||
|
const existingDescription = normalizeDescription(existingMember.description);
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayName: preferIncoming
|
||||||
|
? (incomingMember.displayName || existingMember.displayName)
|
||||||
|
: (existingMember.displayName || incomingMember.displayName),
|
||||||
|
description: preferIncoming
|
||||||
|
? (incomingHasDescription ? incomingDescription : existingDescription)
|
||||||
|
: existingDescription,
|
||||||
|
profileUpdatedAt: Math.max(existingUpdatedAt, incomingUpdatedAt) || undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function mergeAvatarFields(
|
function mergeAvatarFields(
|
||||||
existingMember: Pick<RoomMember, 'avatarUrl' | 'avatarHash' | 'avatarMime' | 'avatarUpdatedAt'>,
|
existingMember: Pick<RoomMember, 'avatarUrl' | 'avatarHash' | 'avatarMime' | 'avatarUpdatedAt'>,
|
||||||
incomingMember: Pick<RoomMember, 'avatarUrl' | 'avatarHash' | 'avatarMime' | 'avatarUpdatedAt'>,
|
incomingMember: Pick<RoomMember, 'avatarUrl' | 'avatarHash' | 'avatarMime' | 'avatarUpdatedAt'>,
|
||||||
@@ -73,12 +118,12 @@ function normalizeMember(member: RoomMember, now = Date.now()): RoomMember {
|
|||||||
typeof member.joinedAt === 'number' && Number.isFinite(member.joinedAt)
|
typeof member.joinedAt === 'number' && Number.isFinite(member.joinedAt)
|
||||||
? member.joinedAt
|
? member.joinedAt
|
||||||
: lastSeenAt;
|
: lastSeenAt;
|
||||||
|
const nextMember: RoomMember = {
|
||||||
return {
|
|
||||||
id: member.id || key,
|
id: member.id || key,
|
||||||
oderId: member.oderId || undefined,
|
oderId: member.oderId || undefined,
|
||||||
username: member.username || fallbackUsername(member),
|
username: member.username || fallbackUsername(member),
|
||||||
displayName: fallbackDisplayName(member),
|
displayName: fallbackDisplayName(member),
|
||||||
|
profileUpdatedAt: normalizeProfileUpdatedAt(member.profileUpdatedAt),
|
||||||
avatarUrl: member.avatarUrl || undefined,
|
avatarUrl: member.avatarUrl || undefined,
|
||||||
avatarHash: member.avatarHash || undefined,
|
avatarHash: member.avatarHash || undefined,
|
||||||
avatarMime: member.avatarMime || undefined,
|
avatarMime: member.avatarMime || undefined,
|
||||||
@@ -88,6 +133,12 @@ function normalizeMember(member: RoomMember, now = Date.now()): RoomMember {
|
|||||||
joinedAt,
|
joinedAt,
|
||||||
lastSeenAt
|
lastSeenAt
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (hasOwnProperty(member, 'description')) {
|
||||||
|
nextMember.description = normalizeDescription(member.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextMember;
|
||||||
}
|
}
|
||||||
|
|
||||||
function compareMembers(firstMember: RoomMember, secondMember: RoomMember): number {
|
function compareMembers(firstMember: RoomMember, secondMember: RoomMember): number {
|
||||||
@@ -128,6 +179,7 @@ function mergeMembers(
|
|||||||
|
|
||||||
const normalizedExisting = normalizeMember(existingMember, now);
|
const normalizedExisting = normalizeMember(existingMember, now);
|
||||||
const preferIncoming = normalizedIncoming.lastSeenAt >= normalizedExisting.lastSeenAt;
|
const preferIncoming = normalizedIncoming.lastSeenAt >= normalizedExisting.lastSeenAt;
|
||||||
|
const profileFields = mergeProfileFields(normalizedExisting, normalizedIncoming, preferIncoming);
|
||||||
const avatarFields = mergeAvatarFields(normalizedExisting, normalizedIncoming, preferIncoming);
|
const avatarFields = mergeAvatarFields(normalizedExisting, normalizedIncoming, preferIncoming);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -136,9 +188,7 @@ function mergeMembers(
|
|||||||
username: preferIncoming
|
username: preferIncoming
|
||||||
? (normalizedIncoming.username || normalizedExisting.username)
|
? (normalizedIncoming.username || normalizedExisting.username)
|
||||||
: (normalizedExisting.username || normalizedIncoming.username),
|
: (normalizedExisting.username || normalizedIncoming.username),
|
||||||
displayName: preferIncoming
|
...profileFields,
|
||||||
? (normalizedIncoming.displayName || normalizedExisting.displayName)
|
|
||||||
: (normalizedExisting.displayName || normalizedIncoming.displayName),
|
|
||||||
...avatarFields,
|
...avatarFields,
|
||||||
role: mergeRole(normalizedExisting.role, normalizedIncoming.role, preferIncoming),
|
role: mergeRole(normalizedExisting.role, normalizedIncoming.role, preferIncoming),
|
||||||
roleIds: preferIncoming
|
roleIds: preferIncoming
|
||||||
@@ -177,6 +227,8 @@ export function roomMemberFromUser(
|
|||||||
oderId: user.oderId || undefined,
|
oderId: user.oderId || undefined,
|
||||||
username: user.username || '',
|
username: user.username || '',
|
||||||
displayName: user.displayName || user.username || 'User',
|
displayName: user.displayName || user.username || 'User',
|
||||||
|
description: user.description,
|
||||||
|
profileUpdatedAt: user.profileUpdatedAt,
|
||||||
avatarUrl: user.avatarUrl,
|
avatarUrl: user.avatarUrl,
|
||||||
avatarHash: user.avatarHash,
|
avatarHash: user.avatarHash,
|
||||||
avatarMime: user.avatarMime,
|
avatarMime: user.avatarMime,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { Room, User } from '../../shared-kernel';
|
import type { Room, User } from '../../shared-kernel';
|
||||||
import {
|
import {
|
||||||
type RoomSignalSource,
|
type RoomSignalSource,
|
||||||
type ServerSourceSelector,
|
type ServerSourceSelector,
|
||||||
@@ -353,6 +353,8 @@ export class RoomSignalingConnection {
|
|||||||
const wsUrl = this.serverDirectory.getWebSocketUrl(selector);
|
const wsUrl = this.serverDirectory.getWebSocketUrl(selector);
|
||||||
const oderId = resolvedOderId || user?.oderId || this.webrtc.peerId();
|
const oderId = resolvedOderId || user?.oderId || this.webrtc.peerId();
|
||||||
const displayName = resolveUserDisplayName(user);
|
const displayName = resolveUserDisplayName(user);
|
||||||
|
const description = user?.description;
|
||||||
|
const profileUpdatedAt = user?.profileUpdatedAt;
|
||||||
const sameSignalRooms = this.getRoomsForSignalingUrl(this.includeRoom(savedRooms, room), wsUrl);
|
const sameSignalRooms = this.getRoomsForSignalingUrl(this.includeRoom(savedRooms, room), wsUrl);
|
||||||
const backgroundRooms = sameSignalRooms.filter((candidate) => candidate.id !== room.id);
|
const backgroundRooms = sameSignalRooms.filter((candidate) => candidate.id !== room.id);
|
||||||
const joinCurrentEndpointRooms = () => {
|
const joinCurrentEndpointRooms = () => {
|
||||||
@@ -361,7 +363,10 @@ export class RoomSignalingConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.webrtc.setCurrentServer(room.id);
|
this.webrtc.setCurrentServer(room.id);
|
||||||
this.webrtc.identify(oderId, displayName, wsUrl);
|
this.webrtc.identify(oderId, displayName, wsUrl, {
|
||||||
|
description,
|
||||||
|
profileUpdatedAt
|
||||||
|
});
|
||||||
|
|
||||||
for (const backgroundRoom of backgroundRooms) {
|
for (const backgroundRoom of backgroundRooms) {
|
||||||
this.webrtc.joinRoom(backgroundRoom.id, oderId, wsUrl);
|
this.webrtc.joinRoom(backgroundRoom.id, oderId, wsUrl);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
createEffect,
|
createEffect,
|
||||||
ofType
|
ofType
|
||||||
} from '@ngrx/effects';
|
} from '@ngrx/effects';
|
||||||
import { Action, Store } from '@ngrx/store';
|
import { Store, type Action } from '@ngrx/store';
|
||||||
import {
|
import {
|
||||||
of,
|
of,
|
||||||
from,
|
from,
|
||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
import { RealtimeSessionFacade } from '../../core/realtime';
|
import { RealtimeSessionFacade } from '../../core/realtime';
|
||||||
import { DatabaseService } from '../../infrastructure/persistence';
|
import { DatabaseService } from '../../infrastructure/persistence';
|
||||||
import { resolveRoomPermission } from '../../domains/access-control';
|
import { resolveRoomPermission } from '../../domains/access-control';
|
||||||
import {
|
import type {
|
||||||
ChatEvent,
|
ChatEvent,
|
||||||
Room,
|
Room,
|
||||||
RoomSettings,
|
RoomSettings,
|
||||||
@@ -50,9 +50,9 @@ import {
|
|||||||
resolveRoom,
|
resolveRoom,
|
||||||
sanitizeRoomSnapshot,
|
sanitizeRoomSnapshot,
|
||||||
normalizeIncomingBans,
|
normalizeIncomingBans,
|
||||||
getPersistedCurrentUserId,
|
getPersistedCurrentUserId
|
||||||
RoomPresenceSignalingMessage
|
|
||||||
} from './rooms.helpers';
|
} from './rooms.helpers';
|
||||||
|
import type { RoomPresenceSignalingMessage } from './rooms.helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NgRx effects for real-time state synchronisation: signaling presence
|
* NgRx effects for real-time state synchronisation: signaling presence
|
||||||
@@ -113,6 +113,8 @@ export class RoomStateSyncEffects {
|
|||||||
.map((user) =>
|
.map((user) =>
|
||||||
buildSignalingUser(user, {
|
buildSignalingUser(user, {
|
||||||
...buildKnownUserExtras(room, user.oderId),
|
...buildKnownUserExtras(room, user.oderId),
|
||||||
|
description: user.description,
|
||||||
|
profileUpdatedAt: user.profileUpdatedAt,
|
||||||
presenceServerIds: [signalingMessage.serverId],
|
presenceServerIds: [signalingMessage.serverId],
|
||||||
...(user.status ? { status: user.status } : {})
|
...(user.status ? { status: user.status } : {})
|
||||||
})
|
})
|
||||||
@@ -141,12 +143,16 @@ export class RoomStateSyncEffects {
|
|||||||
const joinedUser = {
|
const joinedUser = {
|
||||||
oderId: signalingMessage.oderId,
|
oderId: signalingMessage.oderId,
|
||||||
displayName: signalingMessage.displayName,
|
displayName: signalingMessage.displayName,
|
||||||
|
description: signalingMessage.description,
|
||||||
|
profileUpdatedAt: signalingMessage.profileUpdatedAt,
|
||||||
status: signalingMessage.status
|
status: signalingMessage.status
|
||||||
};
|
};
|
||||||
const actions: Action[] = [
|
const actions: Action[] = [
|
||||||
UsersActions.userJoined({
|
UsersActions.userJoined({
|
||||||
user: buildSignalingUser(joinedUser, {
|
user: buildSignalingUser(joinedUser, {
|
||||||
...buildKnownUserExtras(room, joinedUser.oderId),
|
...buildKnownUserExtras(room, joinedUser.oderId),
|
||||||
|
description: joinedUser.description,
|
||||||
|
profileUpdatedAt: joinedUser.profileUpdatedAt,
|
||||||
presenceServerIds: [signalingMessage.serverId]
|
presenceServerIds: [signalingMessage.serverId]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export function buildKnownUserExtras(room: Room | null, identifier: string): Rec
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
username: knownMember.username,
|
username: knownMember.username,
|
||||||
|
description: knownMember.description,
|
||||||
|
profileUpdatedAt: knownMember.profileUpdatedAt,
|
||||||
avatarUrl: knownMember.avatarUrl,
|
avatarUrl: knownMember.avatarUrl,
|
||||||
avatarHash: knownMember.avatarHash,
|
avatarHash: knownMember.avatarHash,
|
||||||
avatarMime: knownMember.avatarMime,
|
avatarMime: knownMember.avatarMime,
|
||||||
@@ -194,8 +196,10 @@ export interface RoomPresenceSignalingMessage {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
serverIds?: string[];
|
serverIds?: string[];
|
||||||
users?: { oderId: string; displayName: string; status?: string }[];
|
users?: { oderId: string; displayName: string; description?: string; profileUpdatedAt?: number; status?: string }[];
|
||||||
oderId?: string;
|
oderId?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
description?: string;
|
||||||
|
profileUpdatedAt?: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { User } from '../../shared-kernel';
|
import { User } from '../../shared-kernel';
|
||||||
import {
|
import { shouldApplyAvatarTransfer, shouldRequestAvatarData } from './user-avatar.effects';
|
||||||
shouldApplyAvatarTransfer,
|
|
||||||
shouldRequestAvatarData
|
|
||||||
} from './user-avatar.effects';
|
|
||||||
|
|
||||||
function createUser(overrides: Partial<User> = {}): User {
|
function createUser(overrides: Partial<User> = {}): User {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import {
|
import {
|
||||||
Actions,
|
Actions,
|
||||||
createEffect,
|
createEffect,
|
||||||
ofType
|
ofType
|
||||||
} from '@ngrx/effects';
|
} from '@ngrx/effects';
|
||||||
import { Action, Store } from '@ngrx/store';
|
import { Store, type Action } from '@ngrx/store';
|
||||||
import {
|
import {
|
||||||
EMPTY,
|
EMPTY,
|
||||||
from,
|
from,
|
||||||
@@ -17,26 +18,22 @@ import {
|
|||||||
} from 'rxjs/operators';
|
} from 'rxjs/operators';
|
||||||
import { ProfileAvatarFacade } from '../../domains/profile-avatar';
|
import { ProfileAvatarFacade } from '../../domains/profile-avatar';
|
||||||
import {
|
import {
|
||||||
ChatEvent,
|
|
||||||
P2P_BASE64_CHUNK_SIZE_BYTES,
|
P2P_BASE64_CHUNK_SIZE_BYTES,
|
||||||
User,
|
|
||||||
decodeBase64,
|
decodeBase64,
|
||||||
iterateBlobChunks
|
iterateBlobChunks
|
||||||
} from '../../shared-kernel';
|
} from '../../shared-kernel';
|
||||||
|
import type { ChatEvent, User } from '../../shared-kernel';
|
||||||
import { RealtimeSessionFacade } from '../../core/realtime';
|
import { RealtimeSessionFacade } from '../../core/realtime';
|
||||||
import { DatabaseService } from '../../infrastructure/persistence';
|
import { DatabaseService } from '../../infrastructure/persistence';
|
||||||
import { UsersActions } from './users.actions';
|
import { UsersActions } from './users.actions';
|
||||||
import {
|
import { selectAllUsers, selectCurrentUser } from './users.selectors';
|
||||||
selectAllUsers,
|
|
||||||
selectCurrentUser
|
|
||||||
} from './users.selectors';
|
|
||||||
import { selectCurrentRoom, selectSavedRooms } from '../rooms/rooms.selectors';
|
import { selectCurrentRoom, selectSavedRooms } from '../rooms/rooms.selectors';
|
||||||
import { RoomsActions } from '../rooms/rooms.actions';
|
import { RoomsActions } from '../rooms/rooms.actions';
|
||||||
import { findRoomMember } from '../rooms/room-members.helpers';
|
import { findRoomMember } from '../rooms/room-members.helpers';
|
||||||
|
|
||||||
interface PendingAvatarTransfer {
|
interface PendingAvatarTransfer {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
mime: string;
|
mime?: string;
|
||||||
oderId: string;
|
oderId: string;
|
||||||
total: number;
|
total: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
@@ -46,6 +43,17 @@ interface PendingAvatarTransfer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AvatarVersionState = Pick<User, 'avatarUrl' | 'avatarHash' | 'avatarUpdatedAt'> | undefined;
|
type AvatarVersionState = Pick<User, 'avatarUrl' | 'avatarHash' | 'avatarUpdatedAt'> | undefined;
|
||||||
|
type RoomProfileState = Pick<User,
|
||||||
|
| 'id'
|
||||||
|
| 'oderId'
|
||||||
|
| 'displayName'
|
||||||
|
| 'description'
|
||||||
|
| 'profileUpdatedAt'
|
||||||
|
| 'avatarUrl'
|
||||||
|
| 'avatarHash'
|
||||||
|
| 'avatarMime'
|
||||||
|
| 'avatarUpdatedAt'
|
||||||
|
>;
|
||||||
|
|
||||||
function shouldAcceptAvatarPayload(
|
function shouldAcceptAvatarPayload(
|
||||||
existingUser: AvatarVersionState,
|
existingUser: AvatarVersionState,
|
||||||
@@ -69,9 +77,13 @@ function shouldAcceptAvatarPayload(
|
|||||||
return !!incomingHash && incomingHash !== existingUser.avatarHash;
|
return !!incomingHash && incomingHash !== existingUser.avatarHash;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasSyncableUserData(user: Pick<User, 'avatarUpdatedAt' | 'profileUpdatedAt'> | null | undefined): boolean {
|
||||||
|
return (user?.avatarUpdatedAt ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function shouldRequestAvatarData(
|
export function shouldRequestAvatarData(
|
||||||
existingUser: AvatarVersionState,
|
existingUser: AvatarVersionState,
|
||||||
incomingAvatar: Pick<ChatEvent, 'avatarHash' | 'avatarUpdatedAt'>
|
incomingAvatar: Pick<ChatEvent, 'avatarHash' | 'avatarUpdatedAt' | 'profileUpdatedAt'>
|
||||||
): boolean {
|
): boolean {
|
||||||
return shouldAcceptAvatarPayload(existingUser, incomingAvatar.avatarUpdatedAt ?? 0, incomingAvatar.avatarHash);
|
return shouldAcceptAvatarPayload(existingUser, incomingAvatar.avatarUpdatedAt ?? 0, incomingAvatar.avatarHash);
|
||||||
}
|
}
|
||||||
@@ -114,29 +126,41 @@ export class UserAvatarEffects {
|
|||||||
withLatestFrom(this.store.select(selectAllUsers)),
|
withLatestFrom(this.store.select(selectAllUsers)),
|
||||||
tap(([{ user }, allUsers]) => {
|
tap(([{ user }, allUsers]) => {
|
||||||
const mergedUser = allUsers.find((entry) => entry.id === user.id || entry.oderId === user.oderId);
|
const mergedUser = allUsers.find((entry) => entry.id === user.id || entry.oderId === user.oderId);
|
||||||
const avatarUrl = mergedUser?.avatarUrl ?? user.avatarUrl;
|
const userToPersist = mergedUser ?? {
|
||||||
|
id: user.id,
|
||||||
|
oderId: user.oderId,
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.displayName,
|
||||||
|
description: user.description,
|
||||||
|
profileUpdatedAt: user.profileUpdatedAt,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
avatarHash: user.avatarHash,
|
||||||
|
avatarMime: user.avatarMime,
|
||||||
|
avatarUpdatedAt: user.avatarUpdatedAt,
|
||||||
|
status: 'offline' as const,
|
||||||
|
role: 'member' as const,
|
||||||
|
joinedAt: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
if (!avatarUrl) {
|
this.db.saveUser(userToPersist);
|
||||||
|
|
||||||
|
if (!user.avatarUrl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mergedUser) {
|
|
||||||
this.db.saveUser(mergedUser);
|
|
||||||
}
|
|
||||||
|
|
||||||
void this.avatars.persistAvatarDataUrl({
|
void this.avatars.persistAvatarDataUrl({
|
||||||
id: mergedUser?.id || user.id,
|
id: userToPersist.id,
|
||||||
username: mergedUser?.username || user.username,
|
username: userToPersist.username,
|
||||||
displayName: mergedUser?.displayName || user.displayName
|
displayName: userToPersist.displayName
|
||||||
}, avatarUrl);
|
}, user.avatarUrl);
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
{ dispatch: false }
|
{ dispatch: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
syncRoomMemberAvatars$ = createEffect(() =>
|
syncRoomMemberProfiles$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(UsersActions.updateCurrentUserAvatar, UsersActions.upsertRemoteUserAvatar),
|
ofType(UsersActions.updateCurrentUserAvatar, UsersActions.updateCurrentUserProfile, UsersActions.upsertRemoteUserAvatar),
|
||||||
withLatestFrom(
|
withLatestFrom(
|
||||||
this.store.select(selectCurrentUser),
|
this.store.select(selectCurrentUser),
|
||||||
this.store.select(selectCurrentRoom),
|
this.store.select(selectCurrentRoom),
|
||||||
@@ -148,28 +172,36 @@ export class UserAvatarEffects {
|
|||||||
currentRoom,
|
currentRoom,
|
||||||
savedRooms
|
savedRooms
|
||||||
]) => {
|
]) => {
|
||||||
const avatarOwner = action.type === UsersActions.updateCurrentUserAvatar.type
|
const avatarOwner = action.type === UsersActions.upsertRemoteUserAvatar.type
|
||||||
? currentUser
|
? action.user
|
||||||
: ('user' in action ? action.user : null);
|
: action.type === UsersActions.updateCurrentUserProfile.type
|
||||||
|
? (currentUser ? {
|
||||||
|
...currentUser,
|
||||||
|
...action.profile
|
||||||
|
} : null)
|
||||||
|
: (currentUser ? {
|
||||||
|
...currentUser,
|
||||||
|
...action.avatar
|
||||||
|
} : null);
|
||||||
|
|
||||||
if (!avatarOwner) {
|
if (!avatarOwner) {
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
}
|
}
|
||||||
|
|
||||||
const actions = this.buildRoomAvatarActions(avatarOwner, currentRoom, savedRooms);
|
const actions = this.buildRoomProfileActions(avatarOwner, currentRoom, savedRooms);
|
||||||
|
|
||||||
return actions.length > 0 ? actions : EMPTY;
|
return actions.length > 0 ? actions : EMPTY;
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
broadcastCurrentAvatarSummary$ = createEffect(
|
broadcastCurrentProfileSummary$ = createEffect(
|
||||||
() =>
|
() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(UsersActions.updateCurrentUserAvatar),
|
ofType(UsersActions.updateCurrentUserAvatar, UsersActions.updateCurrentUserProfile),
|
||||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||||
tap(([, currentUser]) => {
|
tap(([, currentUser]) => {
|
||||||
if (!currentUser?.avatarUpdatedAt) {
|
if (!currentUser || !hasSyncableUserData(currentUser)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +216,7 @@ export class UserAvatarEffects {
|
|||||||
this.webrtc.onPeerConnected.pipe(
|
this.webrtc.onPeerConnected.pipe(
|
||||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||||
tap(([peerId, currentUser]) => {
|
tap(([peerId, currentUser]) => {
|
||||||
if (!currentUser?.avatarUpdatedAt) {
|
if (!currentUser || !hasSyncableUserData(currentUser)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,7 +242,7 @@ export class UserAvatarEffects {
|
|||||||
return this.handleAvatarRequest(event, currentUser ?? null);
|
return this.handleAvatarRequest(event, currentUser ?? null);
|
||||||
|
|
||||||
case 'user-avatar-full':
|
case 'user-avatar-full':
|
||||||
return this.handleAvatarFull(event);
|
return this.handleAvatarFull(event, allUsers);
|
||||||
|
|
||||||
case 'user-avatar-chunk':
|
case 'user-avatar-chunk':
|
||||||
return this.handleAvatarChunk(event, allUsers);
|
return this.handleAvatarChunk(event, allUsers);
|
||||||
@@ -222,14 +254,11 @@ export class UserAvatarEffects {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
private buildAvatarSummary(user: Pick<User, 'oderId' | 'id' | 'username' | 'displayName' | 'avatarHash' | 'avatarMime' | 'avatarUpdatedAt'>): ChatEvent {
|
private buildAvatarSummary(user: Pick<User, 'oderId' | 'id' | 'avatarHash' | 'avatarUpdatedAt'>): ChatEvent {
|
||||||
return {
|
return {
|
||||||
type: 'user-avatar-summary',
|
type: 'user-avatar-summary',
|
||||||
oderId: user.oderId || user.id,
|
oderId: user.oderId || user.id,
|
||||||
username: user.username,
|
|
||||||
displayName: user.displayName,
|
|
||||||
avatarHash: user.avatarHash,
|
avatarHash: user.avatarHash,
|
||||||
avatarMime: user.avatarMime,
|
|
||||||
avatarUpdatedAt: user.avatarUpdatedAt || 0
|
avatarUpdatedAt: user.avatarUpdatedAt || 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -256,15 +285,34 @@ export class UserAvatarEffects {
|
|||||||
private handleAvatarRequest(event: ChatEvent, currentUser: User | null) {
|
private handleAvatarRequest(event: ChatEvent, currentUser: User | null) {
|
||||||
const currentUserKey = currentUser?.oderId || currentUser?.id;
|
const currentUserKey = currentUser?.oderId || currentUser?.id;
|
||||||
|
|
||||||
if (!event.fromPeerId || !currentUser || !currentUserKey || event.oderId !== currentUserKey || !currentUser.avatarUrl) {
|
if (!event.fromPeerId || !currentUser || !currentUserKey || event.oderId !== currentUserKey || !hasSyncableUserData(currentUser)) {
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
}
|
}
|
||||||
|
|
||||||
return from(this.sendAvatarToPeer(event.fromPeerId, currentUser)).pipe(mergeMap(() => EMPTY));
|
return from(this.sendAvatarToPeer(event.fromPeerId, currentUser)).pipe(mergeMap(() => EMPTY));
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleAvatarFull(event: ChatEvent) {
|
private handleAvatarFull(event: ChatEvent, allUsers: User[]) {
|
||||||
if (!event.oderId || !event.avatarMime || typeof event.total !== 'number' || event.total < 1) {
|
if (!event.oderId || typeof event.total !== 'number' || event.total < 0) {
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.total === 0) {
|
||||||
|
return from(this.buildRemoteAvatarAction({
|
||||||
|
chunks: [],
|
||||||
|
displayName: event.displayName || 'User',
|
||||||
|
mime: event.avatarMime,
|
||||||
|
oderId: event.oderId,
|
||||||
|
total: 0,
|
||||||
|
updatedAt: event.avatarUpdatedAt || 0,
|
||||||
|
username: event.username || (event.displayName || 'User').toLowerCase().replace(/\s+/g, '_'),
|
||||||
|
hash: event.avatarHash
|
||||||
|
}, allUsers)).pipe(
|
||||||
|
mergeMap((action) => action ? of(action) : EMPTY)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.avatarMime) {
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,36 +354,55 @@ export class UserAvatarEffects {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildRemoteAvatarAction(transfer: PendingAvatarTransfer, allUsers: User[]): Promise<Action | null> {
|
private async buildRemoteAvatarAction(
|
||||||
const existingUser = allUsers.find((user) => user.id === transfer.oderId || user.oderId === transfer.oderId);
|
transfer: PendingAvatarTransfer,
|
||||||
|
allUsers: User[]
|
||||||
|
): Promise<Action | null> {
|
||||||
|
const existingUser = allUsers.find(
|
||||||
|
(user) => user.id === transfer.oderId || user.oderId === transfer.oderId
|
||||||
|
);
|
||||||
|
|
||||||
if (!shouldApplyAvatarTransfer(existingUser, transfer)) {
|
if (!shouldApplyAvatarTransfer(existingUser, transfer)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = new Blob(transfer.chunks.map((chunk) => this.decodeBase64ToArrayBuffer(chunk!)), { type: transfer.mime });
|
const base64Chunks = transfer.chunks.filter(
|
||||||
const dataUrl = await this.readBlobAsDataUrl(blob);
|
(chunk): chunk is string => typeof chunk === 'string'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (transfer.total > 0 && base64Chunks.length !== transfer.total) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataUrl = transfer.total > 0
|
||||||
|
? await this.readBlobAsDataUrl(new Blob(
|
||||||
|
base64Chunks.map((chunk) => this.decodeBase64ToArrayBuffer(chunk)),
|
||||||
|
{ type: transfer.mime || 'image/webp' }
|
||||||
|
))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return UsersActions.upsertRemoteUserAvatar({
|
return UsersActions.upsertRemoteUserAvatar({
|
||||||
user: {
|
user: {
|
||||||
id: existingUser?.id || transfer.oderId,
|
id: existingUser?.id || transfer.oderId,
|
||||||
oderId: existingUser?.oderId || transfer.oderId,
|
oderId: existingUser?.oderId || transfer.oderId,
|
||||||
username: existingUser?.username || transfer.username,
|
username: existingUser?.username || transfer.username,
|
||||||
displayName: existingUser?.displayName || transfer.displayName,
|
displayName: transfer.displayName || existingUser?.displayName || 'User',
|
||||||
avatarUrl: dataUrl,
|
avatarUrl: dataUrl,
|
||||||
avatarHash: transfer.hash,
|
avatarHash: transfer.hash,
|
||||||
avatarMime: transfer.mime,
|
avatarMime: transfer.mime,
|
||||||
avatarUpdatedAt: transfer.updatedAt
|
avatarUpdatedAt: transfer.updatedAt || undefined
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildRoomAvatarActions(
|
private buildRoomProfileActions(
|
||||||
avatarOwner: Pick<User, 'id' | 'oderId' | 'avatarUrl' | 'avatarHash' | 'avatarMime' | 'avatarUpdatedAt'>,
|
avatarOwner: RoomProfileState,
|
||||||
currentRoom: ReturnType<typeof selectCurrentRoom['projector']> | null,
|
currentRoom: ReturnType<typeof selectCurrentRoom['projector']> | null,
|
||||||
savedRooms: ReturnType<typeof selectSavedRooms['projector']>
|
savedRooms: ReturnType<typeof selectSavedRooms['projector']>
|
||||||
): Action[] {
|
): Action[] {
|
||||||
const rooms = [currentRoom, ...savedRooms.filter((room) => room.id !== currentRoom?.id)].filter((room): room is NonNullable<typeof currentRoom> => !!room);
|
const rooms = [currentRoom, ...savedRooms.filter((room) => room.id !== currentRoom?.id)].filter(
|
||||||
|
(room): room is NonNullable<typeof currentRoom> => !!room
|
||||||
|
);
|
||||||
const roomActions: Action[] = [];
|
const roomActions: Action[] = [];
|
||||||
const avatarOwnerId = avatarOwner.oderId || avatarOwner.id;
|
const avatarOwnerId = avatarOwner.oderId || avatarOwner.id;
|
||||||
|
|
||||||
@@ -353,6 +420,9 @@ export class UserAvatarEffects {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...roomMember,
|
...roomMember,
|
||||||
|
displayName: avatarOwner.displayName,
|
||||||
|
description: avatarOwner.description,
|
||||||
|
profileUpdatedAt: avatarOwner.profileUpdatedAt,
|
||||||
avatarUrl: avatarOwner.avatarUrl,
|
avatarUrl: avatarOwner.avatarUrl,
|
||||||
avatarHash: avatarOwner.avatarHash,
|
avatarHash: avatarOwner.avatarHash,
|
||||||
avatarMime: avatarOwner.avatarMime,
|
avatarMime: avatarOwner.avatarMime,
|
||||||
@@ -370,13 +440,11 @@ export class UserAvatarEffects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async sendAvatarToPeer(targetPeerId: string, user: User): Promise<void> {
|
private async sendAvatarToPeer(targetPeerId: string, user: User): Promise<void> {
|
||||||
if (!user.avatarUrl) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await this.dataUrlToBlob(user.avatarUrl, user.avatarMime || 'image/webp');
|
|
||||||
const total = Math.ceil(blob.size / P2P_BASE64_CHUNK_SIZE_BYTES);
|
|
||||||
const userKey = user.oderId || user.id;
|
const userKey = user.oderId || user.id;
|
||||||
|
const blob = user.avatarUrl
|
||||||
|
? await this.dataUrlToBlob(user.avatarUrl, user.avatarMime || 'image/webp')
|
||||||
|
: null;
|
||||||
|
const total = blob ? Math.ceil(blob.size / P2P_BASE64_CHUNK_SIZE_BYTES) : 0;
|
||||||
|
|
||||||
this.webrtc.sendToPeer(targetPeerId, {
|
this.webrtc.sendToPeer(targetPeerId, {
|
||||||
type: 'user-avatar-full',
|
type: 'user-avatar-full',
|
||||||
@@ -384,11 +452,15 @@ export class UserAvatarEffects {
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
avatarHash: user.avatarHash,
|
avatarHash: user.avatarHash,
|
||||||
avatarMime: user.avatarMime || blob.type || 'image/webp',
|
avatarMime: blob ? (user.avatarMime || blob.type || 'image/webp') : undefined,
|
||||||
avatarUpdatedAt: user.avatarUpdatedAt || Date.now(),
|
avatarUpdatedAt: user.avatarUpdatedAt || 0,
|
||||||
total
|
total
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!blob) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for await (const chunk of iterateBlobChunks(blob, P2P_BASE64_CHUNK_SIZE_BYTES)) {
|
for await (const chunk of iterateBlobChunks(blob, P2P_BASE64_CHUNK_SIZE_BYTES)) {
|
||||||
await this.webrtc.sendToPeerBuffered(targetPeerId, {
|
await this.webrtc.sendToPeerBuffered(targetPeerId, {
|
||||||
type: 'user-avatar-chunk',
|
type: 'user-avatar-chunk',
|
||||||
|
|||||||
@@ -140,6 +140,74 @@ describe('users reducer - status', () => {
|
|||||||
expect(state.entities['remote-1']?.avatarHash).toBe('hash-newer');
|
expect(state.entities['remote-1']?.avatarHash).toBe('hash-newer');
|
||||||
expect(state.entities['remote-1']?.avatarUpdatedAt).toBe(200);
|
expect(state.entities['remote-1']?.avatarUpdatedAt).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('updates the current user profile metadata', () => {
|
||||||
|
const state = usersReducer(baseState, UsersActions.updateCurrentUserProfile({
|
||||||
|
profile: {
|
||||||
|
displayName: 'Updated User',
|
||||||
|
description: 'New description',
|
||||||
|
profileUpdatedAt: 4567
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(state.entities['user-1']?.displayName).toBe('Updated User');
|
||||||
|
expect(state.entities['user-1']?.description).toBe('New description');
|
||||||
|
expect(state.entities['user-1']?.profileUpdatedAt).toBe(4567);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps newer remote profile text when stale profile data arrives later', () => {
|
||||||
|
const withRemote = usersReducer(
|
||||||
|
baseState,
|
||||||
|
UsersActions.upsertRemoteUserAvatar({
|
||||||
|
user: {
|
||||||
|
id: 'remote-1',
|
||||||
|
oderId: 'oder-remote-1',
|
||||||
|
username: 'remote',
|
||||||
|
displayName: 'Remote Newer',
|
||||||
|
description: 'Newest bio',
|
||||||
|
profileUpdatedAt: 300
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const state = usersReducer(
|
||||||
|
withRemote,
|
||||||
|
UsersActions.upsertRemoteUserAvatar({
|
||||||
|
user: {
|
||||||
|
id: 'remote-1',
|
||||||
|
oderId: 'oder-remote-1',
|
||||||
|
username: 'remote',
|
||||||
|
displayName: 'Remote Older',
|
||||||
|
description: 'Old bio',
|
||||||
|
profileUpdatedAt: 100
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.entities['remote-1']?.displayName).toBe('Remote Newer');
|
||||||
|
expect(state.entities['remote-1']?.description).toBe('Newest bio');
|
||||||
|
expect(state.entities['remote-1']?.profileUpdatedAt).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows remote profile-only sync updates without avatar bytes', () => {
|
||||||
|
const state = usersReducer(
|
||||||
|
baseState,
|
||||||
|
UsersActions.upsertRemoteUserAvatar({
|
||||||
|
user: {
|
||||||
|
id: 'remote-2',
|
||||||
|
oderId: 'oder-remote-2',
|
||||||
|
username: 'remote2',
|
||||||
|
displayName: 'Remote Profile',
|
||||||
|
description: 'Profile only sync',
|
||||||
|
profileUpdatedAt: 700
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.entities['remote-2']?.displayName).toBe('Remote Profile');
|
||||||
|
expect(state.entities['remote-2']?.description).toBe('Profile only sync');
|
||||||
|
expect(state.entities['remote-2']?.profileUpdatedAt).toBe(700);
|
||||||
|
expect(state.entities['remote-2']?.avatarUrl).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('presence-aware user with status', () => {
|
describe('presence-aware user with status', () => {
|
||||||
|
|||||||
@@ -39,7 +39,13 @@ export const UsersActions = createActionGroup({
|
|||||||
'Kick User': props<{ userId: string; roomId?: string }>(),
|
'Kick User': props<{ userId: string; roomId?: string }>(),
|
||||||
'Kick User Success': props<{ userId: string; roomId: string }>(),
|
'Kick User Success': props<{ userId: string; roomId: string }>(),
|
||||||
|
|
||||||
'Ban User': props<{ userId: string; roomId?: string; displayName?: string; reason?: string; expiresAt?: number }>(),
|
'Ban User': props<{
|
||||||
|
userId: string;
|
||||||
|
roomId?: string;
|
||||||
|
displayName?: string;
|
||||||
|
reason?: string;
|
||||||
|
expiresAt?: number;
|
||||||
|
}>(),
|
||||||
'Ban User Success': props<{ userId: string; roomId: string; ban: BanEntry }>(),
|
'Ban User Success': props<{ userId: string; roomId: string; ban: BanEntry }>(),
|
||||||
'Unban User': props<{ roomId: string; oderId: string }>(),
|
'Unban User': props<{ roomId: string; oderId: string }>(),
|
||||||
'Unban User Success': props<{ oderId: string }>(),
|
'Unban User Success': props<{ oderId: string }>(),
|
||||||
@@ -61,7 +67,34 @@ export const UsersActions = createActionGroup({
|
|||||||
'Set Manual Status': props<{ status: UserStatus | null }>(),
|
'Set Manual Status': props<{ status: UserStatus | null }>(),
|
||||||
'Update Remote User Status': props<{ userId: string; status: UserStatus }>(),
|
'Update Remote User Status': props<{ userId: string; status: UserStatus }>(),
|
||||||
|
|
||||||
'Update Current User Avatar': props<{ avatar: { avatarUrl: string; avatarHash: string; avatarMime: string; avatarUpdatedAt: number } }>(),
|
'Update Current User Profile': props<{
|
||||||
'Upsert Remote User Avatar': props<{ user: { id: string; oderId: string; username: string; displayName: string; avatarUrl: string; avatarHash?: string; avatarMime?: string; avatarUpdatedAt?: number } }>()
|
profile: {
|
||||||
|
displayName: string;
|
||||||
|
description?: string;
|
||||||
|
profileUpdatedAt: number;
|
||||||
|
};
|
||||||
|
}>(),
|
||||||
|
'Update Current User Avatar': props<{
|
||||||
|
avatar: {
|
||||||
|
avatarUrl: string;
|
||||||
|
avatarHash: string;
|
||||||
|
avatarMime: string;
|
||||||
|
avatarUpdatedAt: number;
|
||||||
|
};
|
||||||
|
}>(),
|
||||||
|
'Upsert Remote User Avatar': props<{
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
oderId: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
description?: string;
|
||||||
|
profileUpdatedAt?: number;
|
||||||
|
avatarUrl?: string;
|
||||||
|
avatarHash?: string;
|
||||||
|
avatarMime?: string;
|
||||||
|
avatarUpdatedAt?: number;
|
||||||
|
};
|
||||||
|
}>()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -429,7 +429,8 @@ export class UsersEffects {
|
|||||||
ofType(
|
ofType(
|
||||||
UsersActions.setCurrentUser,
|
UsersActions.setCurrentUser,
|
||||||
UsersActions.loadCurrentUserSuccess,
|
UsersActions.loadCurrentUserSuccess,
|
||||||
UsersActions.updateCurrentUser
|
UsersActions.updateCurrentUser,
|
||||||
|
UsersActions.updateCurrentUserProfile
|
||||||
),
|
),
|
||||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||||
tap(([, user]) => {
|
tap(([, user]) => {
|
||||||
@@ -449,14 +450,18 @@ export class UsersEffects {
|
|||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(
|
ofType(
|
||||||
UsersActions.setCurrentUser,
|
UsersActions.setCurrentUser,
|
||||||
UsersActions.loadCurrentUserSuccess
|
UsersActions.loadCurrentUserSuccess,
|
||||||
|
UsersActions.updateCurrentUserProfile
|
||||||
),
|
),
|
||||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||||
tap(([, user]) => {
|
tap(([, user]) => {
|
||||||
if (!user)
|
if (!user)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
this.webrtc.identify(user.oderId || user.id, this.resolveDisplayName(user));
|
this.webrtc.identify(user.oderId || user.id, this.resolveDisplayName(user), undefined, {
|
||||||
|
description: user.description,
|
||||||
|
profileUpdatedAt: user.profileUpdatedAt
|
||||||
|
});
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
{ dispatch: false }
|
{ dispatch: false }
|
||||||
|
|||||||
@@ -37,6 +37,69 @@ interface AvatarFields {
|
|||||||
avatarUpdatedAt?: number;
|
avatarUpdatedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProfileFields {
|
||||||
|
displayName: string;
|
||||||
|
description?: string;
|
||||||
|
profileUpdatedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOwnProperty(object: object, key: string): boolean {
|
||||||
|
return Object.prototype.hasOwnProperty.call(object, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProfileUpdatedAt(value: unknown): number | undefined {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||||
|
? value
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDisplayName(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim().replace(/\s+/g, ' ');
|
||||||
|
|
||||||
|
return normalized || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDescription(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim();
|
||||||
|
|
||||||
|
return normalized || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeProfileFields(
|
||||||
|
existingValue: Partial<ProfileFields> | undefined,
|
||||||
|
incomingValue: Partial<ProfileFields>,
|
||||||
|
preferIncomingFallback = true
|
||||||
|
): ProfileFields {
|
||||||
|
const existingUpdatedAt = normalizeProfileUpdatedAt(existingValue?.profileUpdatedAt) ?? 0;
|
||||||
|
const incomingUpdatedAt = normalizeProfileUpdatedAt(incomingValue.profileUpdatedAt) ?? 0;
|
||||||
|
const preferIncoming = incomingUpdatedAt === existingUpdatedAt
|
||||||
|
? preferIncomingFallback
|
||||||
|
: incomingUpdatedAt > existingUpdatedAt;
|
||||||
|
const existingDisplayName = normalizeDisplayName(existingValue?.displayName);
|
||||||
|
const incomingDisplayName = normalizeDisplayName(incomingValue.displayName);
|
||||||
|
const existingDescription = normalizeDescription(existingValue?.description);
|
||||||
|
const incomingHasDescription = hasOwnProperty(incomingValue, 'description');
|
||||||
|
const incomingDescription = normalizeDescription(incomingValue.description);
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayName: preferIncoming
|
||||||
|
? (incomingDisplayName || existingDisplayName || 'User')
|
||||||
|
: (existingDisplayName || incomingDisplayName || 'User'),
|
||||||
|
description: preferIncoming
|
||||||
|
? (incomingHasDescription ? incomingDescription : existingDescription)
|
||||||
|
: existingDescription,
|
||||||
|
profileUpdatedAt: Math.max(existingUpdatedAt, incomingUpdatedAt) || undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function mergeAvatarFields(
|
function mergeAvatarFields(
|
||||||
existingValue: AvatarFields | undefined,
|
existingValue: AvatarFields | undefined,
|
||||||
incomingValue: AvatarFields,
|
incomingValue: AvatarFields,
|
||||||
@@ -112,10 +175,12 @@ function buildPresenceAwareUser(existingUser: User | undefined, incomingUser: Us
|
|||||||
? incomingUser.status
|
? incomingUser.status
|
||||||
: (existingUser?.status && existingUser.status !== 'offline' ? existingUser.status : 'online'))
|
: (existingUser?.status && existingUser.status !== 'offline' ? existingUser.status : 'online'))
|
||||||
: 'offline';
|
: 'offline';
|
||||||
|
const profileFields = mergeProfileFields(existingUser, incomingUser, true);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...existingUser,
|
...existingUser,
|
||||||
...incomingUser,
|
...incomingUser,
|
||||||
|
...profileFields,
|
||||||
...mergeAvatarFields(existingUser, incomingUser, true),
|
...mergeAvatarFields(existingUser, incomingUser, true),
|
||||||
presenceServerIds,
|
presenceServerIds,
|
||||||
isOnline,
|
isOnline,
|
||||||
@@ -128,17 +193,21 @@ function buildAvatarUser(existingUser: User | undefined, incomingUser: {
|
|||||||
oderId: string;
|
oderId: string;
|
||||||
username: string;
|
username: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
avatarUrl: string;
|
description?: string;
|
||||||
|
profileUpdatedAt?: number;
|
||||||
|
avatarUrl?: string;
|
||||||
avatarHash?: string;
|
avatarHash?: string;
|
||||||
avatarMime?: string;
|
avatarMime?: string;
|
||||||
avatarUpdatedAt?: number;
|
avatarUpdatedAt?: number;
|
||||||
}): User {
|
}): User {
|
||||||
|
const profileFields = mergeProfileFields(existingUser, incomingUser, true);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...existingUser,
|
...existingUser,
|
||||||
id: incomingUser.id,
|
id: incomingUser.id,
|
||||||
oderId: incomingUser.oderId,
|
oderId: incomingUser.oderId,
|
||||||
username: incomingUser.username || existingUser?.username || 'user',
|
username: incomingUser.username || existingUser?.username || 'user',
|
||||||
displayName: incomingUser.displayName || existingUser?.displayName || 'User',
|
...profileFields,
|
||||||
status: existingUser?.status ?? 'offline',
|
status: existingUser?.status ?? 'offline',
|
||||||
role: existingUser?.role ?? 'member',
|
role: existingUser?.role ?? 'member',
|
||||||
joinedAt: existingUser?.joinedAt ?? Date.now(),
|
joinedAt: existingUser?.joinedAt ?? Date.now(),
|
||||||
@@ -230,6 +299,18 @@ export const usersReducer = createReducer(
|
|||||||
state
|
state
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
on(UsersActions.updateCurrentUserProfile, (state, { profile }) => {
|
||||||
|
if (!state.currentUserId)
|
||||||
|
return state;
|
||||||
|
|
||||||
|
return usersAdapter.updateOne(
|
||||||
|
{
|
||||||
|
id: state.currentUserId,
|
||||||
|
changes: mergeProfileFields(state.entities[state.currentUserId], profile, true)
|
||||||
|
},
|
||||||
|
state
|
||||||
|
);
|
||||||
|
}),
|
||||||
on(UsersActions.updateCurrentUserAvatar, (state, { avatar }) => {
|
on(UsersActions.updateCurrentUserAvatar, (state, { avatar }) => {
|
||||||
if (!state.currentUserId)
|
if (!state.currentUserId)
|
||||||
return state;
|
return state;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
http-equiv="Content-Security-Policy"
|
http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; connect-src 'self' blob: ws: wss: http: https:; media-src 'self' blob:; img-src 'self' data: blob: http: https:; frame-src https://www.youtube-nocookie.com;"
|
content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; connect-src 'self' blob: ws: wss: http: https:; media-src 'self' blob:; img-src 'self' data: blob: http: https:; frame-src https://www.youtube-nocookie.com https://open.spotify.com https://w.soundcloud.com;"
|
||||||
/>
|
/>
|
||||||
<link
|
<link
|
||||||
rel="icon"
|
rel="icon"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
"outDir": "./out-tsc/spec",
|
"outDir": "./out-tsc/spec",
|
||||||
"types": [
|
"types": [
|
||||||
"vitest/globals"
|
"vitest/globals"
|
||||||
|
|||||||
Reference in New Issue
Block a user