Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28797a0141 | |||
| 17738ec484 | |||
| 35b616fb77 | |||
| 2927a86fbb | |||
| b4ac0cdc92 |
@@ -3,7 +3,7 @@ import { type BrowserContext, type Page } from '@playwright/test';
|
|||||||
const SERVER_ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints';
|
const SERVER_ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints';
|
||||||
const REMOVED_DEFAULT_KEYS_STORAGE_KEY = 'metoyou_removed_default_server_keys';
|
const REMOVED_DEFAULT_KEYS_STORAGE_KEY = 'metoyou_removed_default_server_keys';
|
||||||
|
|
||||||
type SeededEndpointStorageState = {
|
interface SeededEndpointStorageState {
|
||||||
key: string;
|
key: string;
|
||||||
removedKey: string;
|
removedKey: string;
|
||||||
endpoints: {
|
endpoints: {
|
||||||
@@ -14,7 +14,7 @@ type SeededEndpointStorageState = {
|
|||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
status: string;
|
status: string;
|
||||||
}[];
|
}[];
|
||||||
};
|
}
|
||||||
|
|
||||||
function buildSeededEndpointStorageState(
|
function buildSeededEndpointStorageState(
|
||||||
port: number = Number(process.env.TEST_SERVER_PORT) || 3099
|
port: number = Number(process.env.TEST_SERVER_PORT) || 3099
|
||||||
@@ -40,7 +40,11 @@ function applySeededEndpointStorageState(storageState: SeededEndpointStorageStat
|
|||||||
const storage = window.localStorage;
|
const storage = window.localStorage;
|
||||||
|
|
||||||
storage.setItem(storageState.key, JSON.stringify(storageState.endpoints));
|
storage.setItem(storageState.key, JSON.stringify(storageState.endpoints));
|
||||||
storage.setItem(storageState.removedKey, JSON.stringify(['default', 'toju-primary', 'toju-sweden']));
|
storage.setItem(storageState.removedKey, JSON.stringify([
|
||||||
|
'default',
|
||||||
|
'toju-primary',
|
||||||
|
'toju-sweden'
|
||||||
|
]));
|
||||||
} catch {
|
} catch {
|
||||||
// about:blank and some Playwright UI pages deny localStorage access.
|
// about:blank and some Playwright UI pages deny localStorage access.
|
||||||
}
|
}
|
||||||
@@ -59,7 +63,7 @@ export async function installTestServerEndpoint(
|
|||||||
* Seed localStorage with a single signal endpoint pointing at the test server.
|
* Seed localStorage with a single signal endpoint pointing at the test server.
|
||||||
* Must be called AFTER navigating to the app origin (localStorage is per-origin)
|
* Must be called AFTER navigating to the app origin (localStorage is per-origin)
|
||||||
* but BEFORE the app reads from storage (i.e. before the Angular bootstrap is
|
* but BEFORE the app reads from storage (i.e. before the Angular bootstrap is
|
||||||
* relied upon — calling it in the first goto() landing page is fine since the
|
* relied upon - calling it in the first goto() landing page is fine since the
|
||||||
* page will re-read on next navigation/reload).
|
* page will re-read on next navigation/reload).
|
||||||
*
|
*
|
||||||
* Typical usage:
|
* Typical usage:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const TEST_PORT = process.env.TEST_SERVER_PORT || '3099';
|
|||||||
const SERVER_DIR = join(__dirname, '..', '..', 'server');
|
const SERVER_DIR = join(__dirname, '..', '..', 'server');
|
||||||
const SERVER_ENTRY = join(SERVER_DIR, 'src', 'index.ts');
|
const SERVER_ENTRY = join(SERVER_DIR, 'src', 'index.ts');
|
||||||
const SERVER_TSCONFIG = join(SERVER_DIR, 'tsconfig.json');
|
const SERVER_TSCONFIG = join(SERVER_DIR, 'tsconfig.json');
|
||||||
|
const TS_NODE_BIN = join(SERVER_DIR, 'node_modules', 'ts-node', 'dist', 'bin.js');
|
||||||
|
|
||||||
// ── Create isolated temp data directory ──────────────────────────────
|
// ── Create isolated temp data directory ──────────────────────────────
|
||||||
const tmpDir = mkdtempSync(join(tmpdir(), 'metoyou-e2e-'));
|
const tmpDir = mkdtempSync(join(tmpdir(), 'metoyou-e2e-'));
|
||||||
@@ -43,8 +44,8 @@ console.log(`[E2E Server] Starting on port ${TEST_PORT}...`);
|
|||||||
// Module resolution (require/import) uses __dirname, so server source
|
// Module resolution (require/import) uses __dirname, so server source
|
||||||
// and node_modules are found from the real server/ directory.
|
// and node_modules are found from the real server/ directory.
|
||||||
const child = spawn(
|
const child = spawn(
|
||||||
'npx',
|
process.execPath,
|
||||||
['ts-node', '--project', SERVER_TSCONFIG, SERVER_ENTRY],
|
[TS_NODE_BIN, '--project', SERVER_TSCONFIG, SERVER_ENTRY],
|
||||||
{
|
{
|
||||||
cwd: tmpDir,
|
cwd: tmpDir,
|
||||||
env: {
|
env: {
|
||||||
@@ -55,7 +56,6 @@ const child = spawn(
|
|||||||
DB_SYNCHRONIZE: 'true',
|
DB_SYNCHRONIZE: 'true',
|
||||||
},
|
},
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
shell: true,
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,15 @@ import { type Page } from '@playwright/test';
|
|||||||
export async function installWebRTCTracking(page: Page): Promise<void> {
|
export async function installWebRTCTracking(page: Page): Promise<void> {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
const connections: RTCPeerConnection[] = [];
|
const connections: RTCPeerConnection[] = [];
|
||||||
|
const syntheticMediaResources: {
|
||||||
|
audioCtx: AudioContext;
|
||||||
|
source?: AudioScheduledSourceNode;
|
||||||
|
drawIntervalId?: number;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
(window as any).__rtcConnections = connections;
|
(window as any).__rtcConnections = connections;
|
||||||
(window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[];
|
(window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[];
|
||||||
|
(window as any).__rtcSyntheticMediaResources = syntheticMediaResources;
|
||||||
|
|
||||||
const OriginalRTCPeerConnection = window.RTCPeerConnection;
|
const OriginalRTCPeerConnection = window.RTCPeerConnection;
|
||||||
|
|
||||||
@@ -55,18 +61,37 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
|
|||||||
// Get the original stream (may include video)
|
// Get the original stream (may include video)
|
||||||
const originalStream = await origGetUserMedia(constraints);
|
const originalStream = await origGetUserMedia(constraints);
|
||||||
const audioCtx = new AudioContext();
|
const audioCtx = new AudioContext();
|
||||||
const oscillator = audioCtx.createOscillator();
|
const noiseBuffer = audioCtx.createBuffer(1, audioCtx.sampleRate * 2, audioCtx.sampleRate);
|
||||||
|
const noiseData = noiseBuffer.getChannelData(0);
|
||||||
|
|
||||||
oscillator.frequency.value = 440;
|
for (let sampleIndex = 0; sampleIndex < noiseData.length; sampleIndex++) {
|
||||||
|
noiseData[sampleIndex] = (Math.random() * 2 - 1) * 0.18;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = audioCtx.createBufferSource();
|
||||||
|
const gain = audioCtx.createGain();
|
||||||
|
|
||||||
|
source.buffer = noiseBuffer;
|
||||||
|
source.loop = true;
|
||||||
|
gain.gain.value = 0.12;
|
||||||
|
|
||||||
const dest = audioCtx.createMediaStreamDestination();
|
const dest = audioCtx.createMediaStreamDestination();
|
||||||
|
|
||||||
oscillator.connect(dest);
|
source.connect(gain);
|
||||||
oscillator.start();
|
gain.connect(dest);
|
||||||
|
source.start();
|
||||||
|
|
||||||
|
if (audioCtx.state === 'suspended') {
|
||||||
|
try {
|
||||||
|
await audioCtx.resume();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
const synthAudioTrack = dest.stream.getAudioTracks()[0];
|
const synthAudioTrack = dest.stream.getAudioTracks()[0];
|
||||||
const resultStream = new MediaStream();
|
const resultStream = new MediaStream();
|
||||||
|
|
||||||
|
syntheticMediaResources.push({ audioCtx, source });
|
||||||
|
|
||||||
resultStream.addTrack(synthAudioTrack);
|
resultStream.addTrack(synthAudioTrack);
|
||||||
|
|
||||||
// Keep any video tracks from the original stream
|
// Keep any video tracks from the original stream
|
||||||
@@ -79,6 +104,14 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
|
|||||||
track.stop();
|
track.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
synthAudioTrack.addEventListener('ended', () => {
|
||||||
|
try {
|
||||||
|
source.stop();
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
void audioCtx.close().catch(() => {});
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
return resultStream;
|
return resultStream;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -128,10 +161,32 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
|
|||||||
osc.connect(dest);
|
osc.connect(dest);
|
||||||
osc.start();
|
osc.start();
|
||||||
|
|
||||||
|
if (audioCtx.state === 'suspended') {
|
||||||
|
try {
|
||||||
|
await audioCtx.resume();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
const audioTrack = dest.stream.getAudioTracks()[0];
|
const audioTrack = dest.stream.getAudioTracks()[0];
|
||||||
// Combine video + audio into one stream
|
// Combine video + audio into one stream
|
||||||
const resultStream = new MediaStream([videoTrack, audioTrack]);
|
const resultStream = new MediaStream([videoTrack, audioTrack]);
|
||||||
|
|
||||||
|
syntheticMediaResources.push({
|
||||||
|
audioCtx,
|
||||||
|
source: osc,
|
||||||
|
drawIntervalId: drawInterval as unknown as number
|
||||||
|
});
|
||||||
|
|
||||||
|
audioTrack.addEventListener('ended', () => {
|
||||||
|
clearInterval(drawInterval);
|
||||||
|
|
||||||
|
try {
|
||||||
|
osc.stop();
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
void audioCtx.close().catch(() => {});
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
// Tag the stream so tests can identify it
|
// Tag the stream so tests can identify it
|
||||||
(resultStream as any).__isScreenShare = true;
|
(resultStream as any).__isScreenShare = true;
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import {
|
|||||||
type Page
|
type Page
|
||||||
} from '@playwright/test';
|
} from '@playwright/test';
|
||||||
|
|
||||||
export type ChatDropFilePayload = {
|
export interface ChatDropFilePayload {
|
||||||
name: string;
|
name: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
base64: string;
|
base64: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
export class ChatMessagesPage {
|
export class ChatMessagesPage {
|
||||||
readonly composer: Locator;
|
readonly composer: Locator;
|
||||||
@@ -115,7 +115,8 @@ export class ChatMessagesPage {
|
|||||||
getEmbedCardByTitle(title: string): Locator {
|
getEmbedCardByTitle(title: string): Locator {
|
||||||
return this.page.locator('app-chat-link-embed').filter({
|
return this.page.locator('app-chat-link-embed').filter({
|
||||||
has: this.page.getByText(title, { exact: true })
|
has: this.page.getByText(title, { exact: true })
|
||||||
}).last();
|
})
|
||||||
|
.last();
|
||||||
}
|
}
|
||||||
|
|
||||||
async editOwnMessage(originalText: string, updatedText: string): Promise<void> {
|
async editOwnMessage(originalText: string, updatedText: string): Promise<void> {
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { expect, type Page, type Locator } from '@playwright/test';
|
import {
|
||||||
|
expect,
|
||||||
|
type Page,
|
||||||
|
type Locator
|
||||||
|
} from '@playwright/test';
|
||||||
|
|
||||||
export class RegisterPage {
|
export class RegisterPage {
|
||||||
readonly usernameInput: Locator;
|
readonly usernameInput: Locator;
|
||||||
@@ -25,11 +29,12 @@ export class RegisterPage {
|
|||||||
try {
|
try {
|
||||||
await expect(this.usernameInput).toBeVisible({ timeout: 10_000 });
|
await expect(this.usernameInput).toBeVisible({ timeout: 10_000 });
|
||||||
} catch {
|
} catch {
|
||||||
// Angular router may redirect to /login on first load; click through.
|
// Angular router may redirect to /login on first load; use the
|
||||||
const registerLink = this.page.getByRole('link', { name: 'Register' })
|
// visible login-form action instead of broad text matching.
|
||||||
.or(this.page.getByText('Register'));
|
const registerButton = this.page.getByRole('button', { name: 'Register', exact: true }).last();
|
||||||
|
|
||||||
await registerLink.first().click();
|
await expect(registerButton).toBeVisible({ timeout: 10_000 });
|
||||||
|
await registerButton.click();
|
||||||
await expect(this.usernameInput).toBeVisible({ timeout: 30_000 });
|
await expect(this.usernameInput).toBeVisible({ timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default defineConfig({
|
|||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
video: 'on-first-retry',
|
video: 'on-first-retry',
|
||||||
actionTimeout: 15_000,
|
actionTimeout: 15_000
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
@@ -22,18 +22,15 @@ export default defineConfig({
|
|||||||
...devices['Desktop Chrome'],
|
...devices['Desktop Chrome'],
|
||||||
permissions: ['microphone', 'camera'],
|
permissions: ['microphone', 'camera'],
|
||||||
launchOptions: {
|
launchOptions: {
|
||||||
args: [
|
args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream']
|
||||||
'--use-fake-device-for-media-stream',
|
}
|
||||||
'--use-fake-ui-for-media-stream',
|
}
|
||||||
],
|
}
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'cd ../toju-app && npx ng serve',
|
command: 'cd ../toju-app && npx ng serve',
|
||||||
port: 4200,
|
url: 'http://localhost:4200',
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 120_000,
|
timeout: 120_000
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { type Page } from '@playwright/test';
|
import { type Page } from '@playwright/test';
|
||||||
import { test, expect, type Client } from '../../fixtures/multi-client';
|
import {
|
||||||
|
test,
|
||||||
|
expect,
|
||||||
|
type Client
|
||||||
|
} from '../../fixtures/multi-client';
|
||||||
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';
|
||||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
import {
|
import { ChatMessagesPage, type ChatDropFilePayload } from '../../pages/chat-messages.page';
|
||||||
ChatMessagesPage,
|
|
||||||
type ChatDropFilePayload
|
|
||||||
} from '../../pages/chat-messages.page';
|
|
||||||
|
|
||||||
const MOCK_EMBED_URL = 'https://example.test/mock-embed';
|
const MOCK_EMBED_URL = 'https://example.test/mock-embed';
|
||||||
const MOCK_EMBED_TITLE = 'Mock Embed Title';
|
const MOCK_EMBED_TITLE = 'Mock Embed Title';
|
||||||
@@ -133,14 +134,14 @@ test.describe('Chat messaging features', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
type ChatScenario = {
|
interface ChatScenario {
|
||||||
alice: Client;
|
alice: Client;
|
||||||
bob: Client;
|
bob: Client;
|
||||||
aliceRoom: ChatRoomPage;
|
aliceRoom: ChatRoomPage;
|
||||||
bobRoom: ChatRoomPage;
|
bobRoom: ChatRoomPage;
|
||||||
aliceMessages: ChatMessagesPage;
|
aliceMessages: ChatMessagesPage;
|
||||||
bobMessages: ChatMessagesPage;
|
bobMessages: ChatMessagesPage;
|
||||||
};
|
}
|
||||||
|
|
||||||
async function createChatScenario(createClient: () => Promise<Client>): Promise<ChatScenario> {
|
async function createChatScenario(createClient: () => Promise<Client>): Promise<ChatScenario> {
|
||||||
const suffix = uniqueName('chat');
|
const suffix = uniqueName('chat');
|
||||||
@@ -170,6 +171,7 @@ async function createChatScenario(createClient: () => Promise<Client>): Promise<
|
|||||||
aliceCredentials.displayName,
|
aliceCredentials.displayName,
|
||||||
aliceCredentials.password
|
aliceCredentials.password
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(alice.page).toHaveURL(/\/search/, { timeout: 15_000 });
|
await expect(alice.page).toHaveURL(/\/search/, { timeout: 15_000 });
|
||||||
|
|
||||||
await bobRegisterPage.goto();
|
await bobRegisterPage.goto();
|
||||||
@@ -178,6 +180,7 @@ async function createChatScenario(createClient: () => Promise<Client>): Promise<
|
|||||||
bobCredentials.displayName,
|
bobCredentials.displayName,
|
||||||
bobCredentials.password
|
bobCredentials.password
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(bob.page).toHaveURL(/\/search/, { timeout: 15_000 });
|
await expect(bob.page).toHaveURL(/\/search/, { timeout: 15_000 });
|
||||||
|
|
||||||
const aliceSearchPage = new ServerSearchPage(alice.page);
|
const aliceSearchPage = new ServerSearchPage(alice.page);
|
||||||
@@ -185,6 +188,7 @@ async function createChatScenario(createClient: () => Promise<Client>): Promise<
|
|||||||
await aliceSearchPage.createServer(serverName, {
|
await aliceSearchPage.createServer(serverName, {
|
||||||
description: 'E2E chat server for messaging feature coverage'
|
description: 'E2E chat server for messaging feature coverage'
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
|
||||||
const bobSearchPage = new ServerSearchPage(bob.page);
|
const bobSearchPage = new ServerSearchPage(bob.page);
|
||||||
@@ -259,6 +263,7 @@ async function installChatFeatureMocks(page: Page): Promise<void> {
|
|||||||
siteName: 'Mock Docs'
|
siteName: 'Mock Docs'
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,5 +296,6 @@ function buildMockSvgMarkup(label: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
458
e2e/tests/chat/profile-avatar-sync.spec.ts
Normal file
458
e2e/tests/chat/profile-avatar-sync.spec.ts
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
import {
|
||||||
|
mkdtemp,
|
||||||
|
rm
|
||||||
|
} from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import {
|
||||||
|
chromium,
|
||||||
|
type BrowserContext,
|
||||||
|
type Page
|
||||||
|
} from '@playwright/test';
|
||||||
|
import {
|
||||||
|
test,
|
||||||
|
expect
|
||||||
|
} from '../../fixtures/multi-client';
|
||||||
|
import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint';
|
||||||
|
import { LoginPage } from '../../pages/login.page';
|
||||||
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
|
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
||||||
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
|
|
||||||
|
interface TestUser {
|
||||||
|
displayName: string;
|
||||||
|
password: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AvatarUploadPayload {
|
||||||
|
buffer: Buffer;
|
||||||
|
dataUrl: string;
|
||||||
|
mimeType: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersistentClient {
|
||||||
|
context: BrowserContext;
|
||||||
|
page: Page;
|
||||||
|
user: TestUser;
|
||||||
|
userDataDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATIC_GIF_BASE64 = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
|
||||||
|
const GIF_FRAME_MARKER = Buffer.from([0x21, 0xF9, 0x04]);
|
||||||
|
const NETSCAPE_LOOP_EXTENSION = Buffer.from([
|
||||||
|
0x21, 0xFF, 0x0B,
|
||||||
|
0x4E, 0x45, 0x54, 0x53, 0x43, 0x41, 0x50, 0x45, 0x32, 0x2E, 0x30,
|
||||||
|
0x03, 0x01, 0x00, 0x00, 0x00
|
||||||
|
]);
|
||||||
|
const CLIENT_LAUNCH_ARGS = [
|
||||||
|
'--use-fake-device-for-media-stream',
|
||||||
|
'--use-fake-ui-for-media-stream'
|
||||||
|
];
|
||||||
|
const VOICE_CHANNEL = 'General';
|
||||||
|
|
||||||
|
test.describe('Profile avatar sync', () => {
|
||||||
|
test.describe.configure({ timeout: 240_000 });
|
||||||
|
|
||||||
|
test('syncs avatar changes for online and late-joining users and persists after restart', async ({ testServer }) => {
|
||||||
|
const suffix = uniqueName('avatar');
|
||||||
|
const serverName = `Avatar Sync Server ${suffix}`;
|
||||||
|
const messageText = `Avatar sync message ${suffix}`;
|
||||||
|
const avatarA = buildAnimatedGifUpload('alpha');
|
||||||
|
const avatarB = buildAnimatedGifUpload('beta');
|
||||||
|
const aliceUser: TestUser = {
|
||||||
|
username: `alice_${suffix}`,
|
||||||
|
displayName: 'Alice',
|
||||||
|
password: 'TestPass123!'
|
||||||
|
};
|
||||||
|
const bobUser: TestUser = {
|
||||||
|
username: `bob_${suffix}`,
|
||||||
|
displayName: 'Bob',
|
||||||
|
password: 'TestPass123!'
|
||||||
|
};
|
||||||
|
const carolUser: TestUser = {
|
||||||
|
username: `carol_${suffix}`,
|
||||||
|
displayName: 'Carol',
|
||||||
|
password: 'TestPass123!'
|
||||||
|
};
|
||||||
|
const clients: PersistentClient[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const alice = await createPersistentClient(aliceUser, testServer.port);
|
||||||
|
const bob = await createPersistentClient(bobUser, testServer.port);
|
||||||
|
|
||||||
|
clients.push(alice, bob);
|
||||||
|
|
||||||
|
await test.step('Alice and Bob register, create a server, and join the same room', async () => {
|
||||||
|
await registerUser(alice);
|
||||||
|
await registerUser(bob);
|
||||||
|
|
||||||
|
const aliceSearchPage = new ServerSearchPage(alice.page);
|
||||||
|
|
||||||
|
await aliceSearchPage.createServer(serverName, {
|
||||||
|
description: 'Avatar synchronization E2E coverage'
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
|
||||||
|
await joinServerFromSearch(bob.page, serverName);
|
||||||
|
await waitForRoomReady(alice.page);
|
||||||
|
await waitForRoomReady(bob.page);
|
||||||
|
await expectUserRowVisible(bob.page, aliceUser.displayName);
|
||||||
|
});
|
||||||
|
|
||||||
|
const roomUrl = alice.page.url();
|
||||||
|
|
||||||
|
await test.step('Alice uploads the first avatar while Bob is online and Bob sees it live', async () => {
|
||||||
|
await uploadAvatarFromRoomSidebar(alice.page, aliceUser.displayName, avatarA);
|
||||||
|
|
||||||
|
await expectSidebarAvatar(alice.page, aliceUser.displayName, avatarA.dataUrl);
|
||||||
|
await expectSidebarAvatar(bob.page, aliceUser.displayName, avatarA.dataUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Alice sees the updated avatar in voice controls', async () => {
|
||||||
|
await ensureVoiceChannelExists(alice.page, VOICE_CHANNEL);
|
||||||
|
await joinVoiceChannel(alice.page, VOICE_CHANNEL);
|
||||||
|
await expectVoiceControlsAvatar(alice.page, avatarA.dataUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
const carol = await createPersistentClient(carolUser, testServer.port);
|
||||||
|
|
||||||
|
clients.push(carol);
|
||||||
|
|
||||||
|
await test.step('Carol joins after the first change and sees the updated avatar', async () => {
|
||||||
|
await registerUser(carol);
|
||||||
|
await joinServerFromSearch(carol.page, serverName);
|
||||||
|
await waitForRoomReady(carol.page);
|
||||||
|
|
||||||
|
await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarA.dataUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Alice avatar is used in chat messages for everyone in the room', async () => {
|
||||||
|
const aliceMessagesPage = new ChatMessagesPage(alice.page);
|
||||||
|
|
||||||
|
await aliceMessagesPage.sendMessage(messageText);
|
||||||
|
|
||||||
|
await expectChatMessageAvatar(alice.page, messageText, avatarA.dataUrl);
|
||||||
|
await expectChatMessageAvatar(bob.page, messageText, avatarA.dataUrl);
|
||||||
|
await expectChatMessageAvatar(carol.page, messageText, avatarA.dataUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Alice changes the avatar again and all three users see the update in real time', async () => {
|
||||||
|
await uploadAvatarFromRoomSidebar(alice.page, aliceUser.displayName, avatarB);
|
||||||
|
|
||||||
|
await expectSidebarAvatar(alice.page, aliceUser.displayName, avatarB.dataUrl);
|
||||||
|
await expectSidebarAvatar(bob.page, aliceUser.displayName, avatarB.dataUrl);
|
||||||
|
await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarB.dataUrl);
|
||||||
|
await expectChatMessageAvatar(alice.page, messageText, avatarB.dataUrl);
|
||||||
|
await expectChatMessageAvatar(bob.page, messageText, avatarB.dataUrl);
|
||||||
|
await expectChatMessageAvatar(carol.page, messageText, avatarB.dataUrl);
|
||||||
|
await expectVoiceControlsAvatar(alice.page, avatarB.dataUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Bob, Carol, and Alice each keep the updated avatar after a full app restart', async () => {
|
||||||
|
await restartPersistentClient(bob, testServer.port);
|
||||||
|
await openRoomAfterRestart(bob, roomUrl);
|
||||||
|
await expectSidebarAvatar(bob.page, aliceUser.displayName, avatarB.dataUrl);
|
||||||
|
await expectChatMessageAvatar(bob.page, messageText, avatarB.dataUrl);
|
||||||
|
|
||||||
|
await restartPersistentClient(carol, testServer.port);
|
||||||
|
await openRoomAfterRestart(carol, roomUrl);
|
||||||
|
await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarB.dataUrl);
|
||||||
|
await expectChatMessageAvatar(carol.page, messageText, avatarB.dataUrl);
|
||||||
|
|
||||||
|
await restartPersistentClient(alice, testServer.port);
|
||||||
|
await openRoomAfterRestart(alice, roomUrl);
|
||||||
|
await expectSidebarAvatar(alice.page, aliceUser.displayName, avatarB.dataUrl);
|
||||||
|
await expectChatMessageAvatar(alice.page, messageText, avatarB.dataUrl);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await Promise.all(clients.map(async (client) => {
|
||||||
|
await closePersistentClient(client);
|
||||||
|
await rm(client.userDataDir, { recursive: true, force: true });
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createPersistentClient(user: TestUser, testServerPort: number): Promise<PersistentClient> {
|
||||||
|
const userDataDir = await mkdtemp(join(tmpdir(), 'metoyou-avatar-e2e-'));
|
||||||
|
const session = await launchPersistentSession(userDataDir, testServerPort);
|
||||||
|
|
||||||
|
return {
|
||||||
|
context: session.context,
|
||||||
|
page: session.page,
|
||||||
|
user,
|
||||||
|
userDataDir
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restartPersistentClient(client: PersistentClient, testServerPort: number): Promise<void> {
|
||||||
|
await closePersistentClient(client);
|
||||||
|
|
||||||
|
const session = await launchPersistentSession(client.userDataDir, testServerPort);
|
||||||
|
|
||||||
|
client.context = session.context;
|
||||||
|
client.page = session.page;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closePersistentClient(client: PersistentClient): Promise<void> {
|
||||||
|
try {
|
||||||
|
await client.context.close();
|
||||||
|
} catch {
|
||||||
|
// Ignore repeated cleanup attempts during finally.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function launchPersistentSession(
|
||||||
|
userDataDir: string,
|
||||||
|
testServerPort: number
|
||||||
|
): Promise<{ context: BrowserContext; page: Page }> {
|
||||||
|
const context = await chromium.launchPersistentContext(userDataDir, {
|
||||||
|
args: CLIENT_LAUNCH_ARGS,
|
||||||
|
baseURL: 'http://localhost:4200',
|
||||||
|
permissions: ['microphone', 'camera']
|
||||||
|
});
|
||||||
|
|
||||||
|
await installTestServerEndpoint(context, testServerPort);
|
||||||
|
|
||||||
|
const page = context.pages()[0] ?? await context.newPage();
|
||||||
|
|
||||||
|
return { context, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerUser(client: PersistentClient): Promise<void> {
|
||||||
|
const registerPage = new RegisterPage(client.page);
|
||||||
|
|
||||||
|
await retryTransientNavigation(() => registerPage.goto());
|
||||||
|
await registerPage.register(client.user.username, client.user.displayName, client.user.password);
|
||||||
|
await expect(client.page).toHaveURL(/\/search/, { timeout: 15_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function joinServerFromSearch(page: Page, serverName: string): Promise<void> {
|
||||||
|
const searchPage = new ServerSearchPage(page);
|
||||||
|
const serverCard = page.locator('button', { hasText: serverName }).first();
|
||||||
|
|
||||||
|
await searchPage.searchInput.fill(serverName);
|
||||||
|
await expect(serverCard).toBeVisible({ timeout: 15_000 });
|
||||||
|
await serverCard.click();
|
||||||
|
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureVoiceChannelExists(page: Page, channelName: string): Promise<void> {
|
||||||
|
const chatRoom = new ChatRoomPage(page);
|
||||||
|
const existingVoiceChannel = page.locator('app-rooms-side-panel').getByRole('button', { name: channelName, exact: true });
|
||||||
|
|
||||||
|
if (await existingVoiceChannel.count() > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await chatRoom.openCreateVoiceChannelDialog();
|
||||||
|
await chatRoom.createChannel(channelName);
|
||||||
|
await expect(existingVoiceChannel).toBeVisible({ timeout: 10_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function joinVoiceChannel(page: Page, channelName: string): Promise<void> {
|
||||||
|
const chatRoom = new ChatRoomPage(page);
|
||||||
|
|
||||||
|
await chatRoom.joinVoiceChannel(channelName);
|
||||||
|
await expect(page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAvatarFromRoomSidebar(
|
||||||
|
page: Page,
|
||||||
|
displayName: string,
|
||||||
|
avatar: AvatarUploadPayload
|
||||||
|
): Promise<void> {
|
||||||
|
const currentUserRow = getUserRow(page, displayName);
|
||||||
|
const profileFileInput = page.locator('app-profile-card input[type="file"]');
|
||||||
|
const applyButton = page.getByRole('button', { name: 'Apply picture' });
|
||||||
|
|
||||||
|
await expect(currentUserRow).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
if (await profileFileInput.count() === 0) {
|
||||||
|
await currentUserRow.click();
|
||||||
|
await expect(profileFileInput).toBeAttached({ timeout: 10_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await profileFileInput.setInputFiles({
|
||||||
|
name: avatar.name,
|
||||||
|
mimeType: avatar.mimeType,
|
||||||
|
buffer: avatar.buffer
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(applyButton).toBeVisible({ timeout: 10_000 });
|
||||||
|
await applyButton.click();
|
||||||
|
await expect(applyButton).not.toBeVisible({ timeout: 10_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openRoomAfterRestart(client: PersistentClient, roomUrl: string): Promise<void> {
|
||||||
|
await retryTransientNavigation(() => client.page.goto(roomUrl, { waitUntil: 'domcontentloaded' }));
|
||||||
|
|
||||||
|
if (client.page.url().includes('/login')) {
|
||||||
|
const loginPage = new LoginPage(client.page);
|
||||||
|
|
||||||
|
await loginPage.login(client.user.username, client.user.password);
|
||||||
|
await expect(client.page).toHaveURL(/\/(search|room)\//, { timeout: 15_000 });
|
||||||
|
await client.page.goto(roomUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitForRoomReady(client.page);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> {
|
||||||
|
let lastError: unknown;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||||
|
try {
|
||||||
|
return await navigate();
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
const isTransientNavigationError = message.includes('ERR_EMPTY_RESPONSE') || message.includes('ERR_CONNECTION_RESET');
|
||||||
|
|
||||||
|
if (!isTransientNavigationError || attempt === attempts) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError instanceof Error
|
||||||
|
? lastError
|
||||||
|
: new Error(`Navigation failed after ${attempts} attempts`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForRoomReady(page: Page): Promise<void> {
|
||||||
|
const messagesPage = new ChatMessagesPage(page);
|
||||||
|
|
||||||
|
await messagesPage.waitForReady();
|
||||||
|
await expect(page.locator('app-rooms-side-panel').last()).toBeVisible({ timeout: 15_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserRow(page: Page, displayName: string) {
|
||||||
|
const usersSidePanel = page.locator('app-rooms-side-panel').last();
|
||||||
|
|
||||||
|
return usersSidePanel.locator('[role="button"]').filter({
|
||||||
|
has: page.getByText(displayName, { exact: true })
|
||||||
|
}).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectUserRowVisible(page: Page, displayName: string): Promise<void> {
|
||||||
|
await expect(getUserRow(page, displayName)).toBeVisible({ timeout: 20_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectSidebarAvatar(page: Page, displayName: string, expectedDataUrl: string): Promise<void> {
|
||||||
|
const row = getUserRow(page, displayName);
|
||||||
|
|
||||||
|
await expect(row).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const image = row.locator('img').first();
|
||||||
|
|
||||||
|
if (await image.count() === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return image.getAttribute('src');
|
||||||
|
}, {
|
||||||
|
timeout: 20_000,
|
||||||
|
message: `${displayName} avatar src should update`
|
||||||
|
}).toBe(expectedDataUrl);
|
||||||
|
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const image = row.locator('img').first();
|
||||||
|
|
||||||
|
if (await image.count() === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return image.evaluate((element) => {
|
||||||
|
const img = element as HTMLImageElement;
|
||||||
|
|
||||||
|
return img.complete && img.naturalWidth > 0 && img.naturalHeight > 0;
|
||||||
|
});
|
||||||
|
}, {
|
||||||
|
timeout: 20_000,
|
||||||
|
message: `${displayName} avatar image should load`
|
||||||
|
}).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectChatMessageAvatar(page: Page, messageText: string, expectedDataUrl: string): Promise<void> {
|
||||||
|
const messagesPage = new ChatMessagesPage(page);
|
||||||
|
const messageItem = messagesPage.getMessageItemByText(messageText);
|
||||||
|
|
||||||
|
await expect(messageItem).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const image = messageItem.locator('app-user-avatar img').first();
|
||||||
|
|
||||||
|
if (await image.count() === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return image.getAttribute('src');
|
||||||
|
}, {
|
||||||
|
timeout: 20_000,
|
||||||
|
message: `Chat message avatar for "${messageText}" should update`
|
||||||
|
}).toBe(expectedDataUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectVoiceControlsAvatar(page: Page, expectedDataUrl: string): Promise<void> {
|
||||||
|
const voiceControls = page.locator('app-voice-controls');
|
||||||
|
|
||||||
|
await expect(voiceControls).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const image = voiceControls.locator('app-user-avatar img').first();
|
||||||
|
|
||||||
|
if (await image.count() === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return image.getAttribute('src');
|
||||||
|
}, {
|
||||||
|
timeout: 20_000,
|
||||||
|
message: 'Voice controls avatar should update'
|
||||||
|
}).toBe(expectedDataUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAnimatedGifUpload(label: string): AvatarUploadPayload {
|
||||||
|
const baseGif = Buffer.from(STATIC_GIF_BASE64, 'base64');
|
||||||
|
const frameStart = baseGif.indexOf(GIF_FRAME_MARKER);
|
||||||
|
|
||||||
|
if (frameStart < 0) {
|
||||||
|
throw new Error('Failed to locate GIF frame marker for animated avatar payload');
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = baseGif.subarray(0, frameStart);
|
||||||
|
const frame = baseGif.subarray(frameStart, baseGif.length - 1);
|
||||||
|
const commentData = Buffer.from(label, 'ascii');
|
||||||
|
const commentExtension = Buffer.concat([
|
||||||
|
Buffer.from([0x21, 0xFE, commentData.length]),
|
||||||
|
commentData,
|
||||||
|
Buffer.from([0x00])
|
||||||
|
]);
|
||||||
|
const buffer = Buffer.concat([
|
||||||
|
header,
|
||||||
|
NETSCAPE_LOOP_EXTENSION,
|
||||||
|
commentExtension,
|
||||||
|
frame,
|
||||||
|
frame,
|
||||||
|
Buffer.from([0x3B])
|
||||||
|
]);
|
||||||
|
const base64 = buffer.toString('base64');
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer,
|
||||||
|
dataUrl: `data:image/gif;base64,${base64}`,
|
||||||
|
mimeType: 'image/gif',
|
||||||
|
name: `animated-avatar-${label}.gif`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueName(prefix: string): string {
|
||||||
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
setupSystemHandlers,
|
setupSystemHandlers,
|
||||||
setupWindowControlHandlers
|
setupWindowControlHandlers
|
||||||
} from '../ipc';
|
} from '../ipc';
|
||||||
|
import { startIdleMonitor, stopIdleMonitor } from '../idle/idle-monitor';
|
||||||
|
|
||||||
export function registerAppLifecycle(): void {
|
export function registerAppLifecycle(): void {
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
@@ -34,6 +35,7 @@ export function registerAppLifecycle(): void {
|
|||||||
await synchronizeAutoStartSetting();
|
await synchronizeAutoStartSetting();
|
||||||
initializeDesktopUpdater();
|
initializeDesktopUpdater();
|
||||||
await createWindow();
|
await createWindow();
|
||||||
|
startIdleMonitor();
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (getMainWindow()) {
|
if (getMainWindow()) {
|
||||||
@@ -57,6 +59,7 @@ export function registerAppLifecycle(): void {
|
|||||||
if (getDataSource()?.isInitialized) {
|
if (getDataSource()?.isInitialized) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
shutdownDesktopUpdater();
|
shutdownDesktopUpdater();
|
||||||
|
stopIdleMonitor();
|
||||||
await cleanupLinuxScreenShareAudioRouting();
|
await cleanupLinuxScreenShareAudioRouting();
|
||||||
await destroyDatabase();
|
await destroyDatabase();
|
||||||
app.quit();
|
app.quit();
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export async function handleSaveUser(command: SaveUserCommand, dataSource: DataS
|
|||||||
username: user.username ?? null,
|
username: user.username ?? null,
|
||||||
displayName: user.displayName ?? null,
|
displayName: user.displayName ?? null,
|
||||||
avatarUrl: user.avatarUrl ?? null,
|
avatarUrl: user.avatarUrl ?? null,
|
||||||
|
avatarHash: user.avatarHash ?? null,
|
||||||
|
avatarMime: user.avatarMime ?? null,
|
||||||
|
avatarUpdatedAt: user.avatarUpdatedAt ?? null,
|
||||||
status: user.status ?? null,
|
status: user.status ?? null,
|
||||||
role: user.role ?? null,
|
role: user.role ?? null,
|
||||||
joinedAt: user.joinedAt ?? null,
|
joinedAt: user.joinedAt ?? null,
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ export function rowToUser(row: UserEntity) {
|
|||||||
username: row.username ?? '',
|
username: row.username ?? '',
|
||||||
displayName: row.displayName ?? '',
|
displayName: row.displayName ?? '',
|
||||||
avatarUrl: row.avatarUrl ?? undefined,
|
avatarUrl: row.avatarUrl ?? undefined,
|
||||||
|
avatarHash: row.avatarHash ?? undefined,
|
||||||
|
avatarMime: row.avatarMime ?? undefined,
|
||||||
|
avatarUpdatedAt: row.avatarUpdatedAt ?? undefined,
|
||||||
status: row.status ?? 'offline',
|
status: row.status ?? 'offline',
|
||||||
role: row.role ?? 'member',
|
role: row.role ?? 'member',
|
||||||
joinedAt: row.joinedAt ?? 0,
|
joinedAt: row.joinedAt ?? 0,
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ export interface RoomMemberRecord {
|
|||||||
username: string;
|
username: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
avatarHash?: string;
|
||||||
|
avatarMime?: string;
|
||||||
|
avatarUpdatedAt?: number;
|
||||||
role: RoomMemberRole;
|
role: RoomMemberRole;
|
||||||
roleIds?: string[];
|
roleIds?: string[];
|
||||||
joinedAt: number;
|
joinedAt: number;
|
||||||
@@ -336,6 +339,9 @@ function normalizeRoomMember(rawMember: Record<string, unknown>, now: number): R
|
|||||||
const username = trimmedString(rawMember, 'username');
|
const username = trimmedString(rawMember, 'username');
|
||||||
const displayName = trimmedString(rawMember, 'displayName');
|
const displayName = trimmedString(rawMember, 'displayName');
|
||||||
const avatarUrl = trimmedString(rawMember, 'avatarUrl');
|
const avatarUrl = trimmedString(rawMember, 'avatarUrl');
|
||||||
|
const avatarHash = trimmedString(rawMember, 'avatarHash');
|
||||||
|
const avatarMime = trimmedString(rawMember, 'avatarMime');
|
||||||
|
const avatarUpdatedAt = isFiniteNumber(rawMember['avatarUpdatedAt']) ? rawMember['avatarUpdatedAt'] : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: normalizedId || normalizedKey,
|
id: normalizedId || normalizedKey,
|
||||||
@@ -343,6 +349,9 @@ function normalizeRoomMember(rawMember: Record<string, unknown>, now: number): R
|
|||||||
username: username || fallbackUsername({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, displayName }),
|
username: username || fallbackUsername({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, displayName }),
|
||||||
displayName: displayName || fallbackDisplayName({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, username }),
|
displayName: displayName || fallbackDisplayName({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, username }),
|
||||||
avatarUrl: avatarUrl || undefined,
|
avatarUrl: avatarUrl || undefined,
|
||||||
|
avatarHash: avatarHash || undefined,
|
||||||
|
avatarMime: avatarMime || undefined,
|
||||||
|
avatarUpdatedAt,
|
||||||
role: normalizeRoomMemberRole(rawMember['role']),
|
role: normalizeRoomMemberRole(rawMember['role']),
|
||||||
roleIds: uniqueStrings(Array.isArray(rawMember['roleIds']) ? rawMember['roleIds'] as string[] : undefined),
|
roleIds: uniqueStrings(Array.isArray(rawMember['roleIds']) ? rawMember['roleIds'] as string[] : undefined),
|
||||||
joinedAt,
|
joinedAt,
|
||||||
@@ -356,6 +365,11 @@ function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incoming
|
|||||||
}
|
}
|
||||||
|
|
||||||
const preferIncoming = incomingMember.lastSeenAt >= existingMember.lastSeenAt;
|
const preferIncoming = incomingMember.lastSeenAt >= existingMember.lastSeenAt;
|
||||||
|
const existingAvatarUpdatedAt = existingMember.avatarUpdatedAt ?? 0;
|
||||||
|
const incomingAvatarUpdatedAt = incomingMember.avatarUpdatedAt ?? 0;
|
||||||
|
const preferIncomingAvatar = incomingAvatarUpdatedAt === existingAvatarUpdatedAt
|
||||||
|
? preferIncoming
|
||||||
|
: incomingAvatarUpdatedAt > existingAvatarUpdatedAt;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: existingMember.id || incomingMember.id,
|
id: existingMember.id || incomingMember.id,
|
||||||
@@ -366,9 +380,16 @@ function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incoming
|
|||||||
displayName: preferIncoming
|
displayName: preferIncoming
|
||||||
? (incomingMember.displayName || existingMember.displayName)
|
? (incomingMember.displayName || existingMember.displayName)
|
||||||
: (existingMember.displayName || incomingMember.displayName),
|
: (existingMember.displayName || incomingMember.displayName),
|
||||||
avatarUrl: preferIncoming
|
avatarUrl: preferIncomingAvatar
|
||||||
? (incomingMember.avatarUrl || existingMember.avatarUrl)
|
? (incomingMember.avatarUrl || existingMember.avatarUrl)
|
||||||
: (existingMember.avatarUrl || incomingMember.avatarUrl),
|
: (existingMember.avatarUrl || incomingMember.avatarUrl),
|
||||||
|
avatarHash: preferIncomingAvatar
|
||||||
|
? (incomingMember.avatarHash || existingMember.avatarHash)
|
||||||
|
: (existingMember.avatarHash || incomingMember.avatarHash),
|
||||||
|
avatarMime: preferIncomingAvatar
|
||||||
|
? (incomingMember.avatarMime || existingMember.avatarMime)
|
||||||
|
: (existingMember.avatarMime || incomingMember.avatarMime),
|
||||||
|
avatarUpdatedAt: Math.max(existingAvatarUpdatedAt, incomingAvatarUpdatedAt) || undefined,
|
||||||
role: mergeRoomMemberRole(existingMember.role, incomingMember.role, preferIncoming),
|
role: mergeRoomMemberRole(existingMember.role, incomingMember.role, preferIncoming),
|
||||||
roleIds: preferIncoming
|
roleIds: preferIncoming
|
||||||
? (incomingMember.roleIds || existingMember.roleIds)
|
? (incomingMember.roleIds || existingMember.roleIds)
|
||||||
@@ -760,6 +781,9 @@ export async function replaceRoomRelations(
|
|||||||
username: member.username,
|
username: member.username,
|
||||||
displayName: member.displayName,
|
displayName: member.displayName,
|
||||||
avatarUrl: member.avatarUrl ?? null,
|
avatarUrl: member.avatarUrl ?? null,
|
||||||
|
avatarHash: member.avatarHash ?? null,
|
||||||
|
avatarMime: member.avatarMime ?? null,
|
||||||
|
avatarUpdatedAt: member.avatarUpdatedAt ?? null,
|
||||||
role: member.role,
|
role: member.role,
|
||||||
joinedAt: member.joinedAt,
|
joinedAt: member.joinedAt,
|
||||||
lastSeenAt: member.lastSeenAt
|
lastSeenAt: member.lastSeenAt
|
||||||
@@ -907,6 +931,9 @@ export async function loadRoomRelationsMap(
|
|||||||
username: row.username,
|
username: row.username,
|
||||||
displayName: row.displayName,
|
displayName: row.displayName,
|
||||||
avatarUrl: row.avatarUrl ?? undefined,
|
avatarUrl: row.avatarUrl ?? undefined,
|
||||||
|
avatarHash: row.avatarHash ?? undefined,
|
||||||
|
avatarMime: row.avatarMime ?? undefined,
|
||||||
|
avatarUpdatedAt: row.avatarUpdatedAt ?? undefined,
|
||||||
role: row.role,
|
role: row.role,
|
||||||
joinedAt: row.joinedAt,
|
joinedAt: row.joinedAt,
|
||||||
lastSeenAt: row.lastSeenAt
|
lastSeenAt: row.lastSeenAt
|
||||||
|
|||||||
@@ -106,6 +106,9 @@ export interface UserPayload {
|
|||||||
username?: string;
|
username?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
avatarHash?: string;
|
||||||
|
avatarMime?: string;
|
||||||
|
avatarUpdatedAt?: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
joinedAt?: number;
|
joinedAt?: number;
|
||||||
|
|||||||
@@ -27,6 +27,15 @@ export class RoomMemberEntity {
|
|||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
avatarUrl!: string | null;
|
avatarUrl!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
avatarHash!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
avatarMime!: string | null;
|
||||||
|
|
||||||
|
@Column('integer', { nullable: true })
|
||||||
|
avatarUpdatedAt!: number | null;
|
||||||
|
|
||||||
@Column('text')
|
@Column('text')
|
||||||
role!: 'host' | 'admin' | 'moderator' | 'member';
|
role!: 'host' | 'admin' | 'moderator' | 'member';
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ export class UserEntity {
|
|||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
avatarUrl!: string | null;
|
avatarUrl!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
avatarHash!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
avatarMime!: string | null;
|
||||||
|
|
||||||
|
@Column('integer', { nullable: true })
|
||||||
|
avatarUpdatedAt!: number | null;
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
status!: string | null;
|
status!: string | null;
|
||||||
|
|
||||||
|
|||||||
124
electron/idle/idle-monitor.spec.ts
Normal file
124
electron/idle/idle-monitor.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect,
|
||||||
|
vi,
|
||||||
|
beforeEach,
|
||||||
|
afterEach
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
// Mock Electron modules before importing the module under test
|
||||||
|
const mockGetSystemIdleTime = vi.fn(() => 0);
|
||||||
|
const mockSend = vi.fn();
|
||||||
|
const mockGetMainWindow = vi.fn(() => ({
|
||||||
|
isDestroyed: () => false,
|
||||||
|
webContents: { send: mockSend }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('electron', () => ({
|
||||||
|
powerMonitor: {
|
||||||
|
getSystemIdleTime: mockGetSystemIdleTime
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../window/create-window', () => ({
|
||||||
|
getMainWindow: mockGetMainWindow
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
startIdleMonitor,
|
||||||
|
stopIdleMonitor,
|
||||||
|
getIdleState
|
||||||
|
} from './idle-monitor';
|
||||||
|
|
||||||
|
describe('idle-monitor', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(0);
|
||||||
|
mockSend.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
stopIdleMonitor();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns active when idle time is below threshold', () => {
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(0);
|
||||||
|
expect(getIdleState()).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns idle when idle time exceeds 15 minutes', () => {
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||||
|
expect(getIdleState()).toBe('idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends idle-state-changed to renderer when transitioning to idle', () => {
|
||||||
|
startIdleMonitor();
|
||||||
|
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
|
||||||
|
expect(mockSend).toHaveBeenCalledWith('idle-state-changed', 'idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends idle-state-changed to renderer when transitioning back to active', () => {
|
||||||
|
startIdleMonitor();
|
||||||
|
|
||||||
|
// Go idle
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
mockSend.mockClear();
|
||||||
|
|
||||||
|
// Go active
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(5);
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
|
||||||
|
expect(mockSend).toHaveBeenCalledWith('idle-state-changed', 'active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fire duplicates when state stays the same', () => {
|
||||||
|
startIdleMonitor();
|
||||||
|
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
|
||||||
|
// Only one transition, so only one call
|
||||||
|
const idleCalls = mockSend.mock.calls.filter(
|
||||||
|
([channel, state]: [string, string]) => channel === 'idle-state-changed' && state === 'idle'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(idleCalls.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops polling after stopIdleMonitor', () => {
|
||||||
|
startIdleMonitor();
|
||||||
|
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
mockSend.mockClear();
|
||||||
|
|
||||||
|
stopIdleMonitor();
|
||||||
|
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(0);
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
|
||||||
|
expect(mockSend).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not notify when main window is null', () => {
|
||||||
|
mockGetMainWindow.mockReturnValue(null);
|
||||||
|
startIdleMonitor();
|
||||||
|
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
|
||||||
|
expect(mockSend).not.toHaveBeenCalled();
|
||||||
|
mockGetMainWindow.mockReturnValue({
|
||||||
|
isDestroyed: () => false,
|
||||||
|
webContents: { send: mockSend }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
49
electron/idle/idle-monitor.ts
Normal file
49
electron/idle/idle-monitor.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { powerMonitor } from 'electron';
|
||||||
|
import { getMainWindow } from '../window/create-window';
|
||||||
|
|
||||||
|
const IDLE_THRESHOLD_SECONDS = 15 * 60; // 15 minutes
|
||||||
|
const POLL_INTERVAL_MS = 10_000; // Check every 10 seconds
|
||||||
|
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let wasIdle = false;
|
||||||
|
|
||||||
|
const IDLE_STATE_CHANGED_CHANNEL = 'idle-state-changed';
|
||||||
|
|
||||||
|
export type IdleState = 'active' | 'idle';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts polling `powerMonitor.getSystemIdleTime()` and notifies the
|
||||||
|
* renderer whenever the user transitions between active and idle.
|
||||||
|
*/
|
||||||
|
export function startIdleMonitor(): void {
|
||||||
|
if (pollTimer)
|
||||||
|
return;
|
||||||
|
|
||||||
|
pollTimer = setInterval(() => {
|
||||||
|
const idleSeconds = powerMonitor.getSystemIdleTime();
|
||||||
|
const isIdle = idleSeconds >= IDLE_THRESHOLD_SECONDS;
|
||||||
|
|
||||||
|
if (isIdle !== wasIdle) {
|
||||||
|
wasIdle = isIdle;
|
||||||
|
const state: IdleState = isIdle ? 'idle' : 'active';
|
||||||
|
const mainWindow = getMainWindow();
|
||||||
|
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send(IDLE_STATE_CHANGED_CHANNEL, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopIdleMonitor(): void {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIdleState(): IdleState {
|
||||||
|
const idleSeconds = powerMonitor.getSystemIdleTime();
|
||||||
|
|
||||||
|
return idleSeconds >= IDLE_THRESHOLD_SECONDS ? 'idle' : 'active';
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
} from '../update/desktop-updater';
|
} from '../update/desktop-updater';
|
||||||
import { consumePendingDeepLink } from '../app/deep-links';
|
import { consumePendingDeepLink } from '../app/deep-links';
|
||||||
import { synchronizeAutoStartSetting } from '../app/auto-start';
|
import { synchronizeAutoStartSetting } from '../app/auto-start';
|
||||||
|
import { getIdleState } from '../idle/idle-monitor';
|
||||||
import {
|
import {
|
||||||
getMainWindow,
|
getMainWindow,
|
||||||
getWindowIconPath,
|
getWindowIconPath,
|
||||||
@@ -528,6 +529,7 @@ export function setupSystemHandlers(): void {
|
|||||||
resolve(false);
|
resolve(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
response.on('error', () => resolve(false));
|
response.on('error', () => resolve(false));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -537,7 +539,12 @@ export function setupSystemHandlers(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('context-menu-command', (_event, command: string) => {
|
ipcMain.handle('context-menu-command', (_event, command: string) => {
|
||||||
const allowedCommands = ['cut', 'copy', 'paste', 'selectAll'] as const;
|
const allowedCommands = [
|
||||||
|
'cut',
|
||||||
|
'copy',
|
||||||
|
'paste',
|
||||||
|
'selectAll'
|
||||||
|
] as const;
|
||||||
|
|
||||||
if (!allowedCommands.includes(command as typeof allowedCommands[number])) {
|
if (!allowedCommands.includes(command as typeof allowedCommands[number])) {
|
||||||
return;
|
return;
|
||||||
@@ -551,10 +558,22 @@ export function setupSystemHandlers(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case 'cut': webContents.cut(); break;
|
case 'cut':
|
||||||
case 'copy': webContents.copy(); break;
|
webContents.cut();
|
||||||
case 'paste': webContents.paste(); break;
|
break;
|
||||||
case 'selectAll': webContents.selectAll(); break;
|
case 'copy':
|
||||||
|
webContents.copy();
|
||||||
|
break;
|
||||||
|
case 'paste':
|
||||||
|
webContents.paste();
|
||||||
|
break;
|
||||||
|
case 'selectAll':
|
||||||
|
webContents.selectAll();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('get-idle-state', () => {
|
||||||
|
return getIdleState();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddProfileAvatarMetadata1000000000006 implements MigrationInterface {
|
||||||
|
name = 'AddProfileAvatarMetadata1000000000006';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "avatarHash" TEXT`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "avatarMime" TEXT`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "avatarUpdatedAt" INTEGER`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "room_members" ADD COLUMN "avatarHash" TEXT`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "room_members" ADD COLUMN "avatarMime" TEXT`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "room_members" ADD COLUMN "avatarUpdatedAt" INTEGER`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// SQLite column removal requires table rebuilds. Keep rollback no-op.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monit
|
|||||||
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
||||||
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
|
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
|
||||||
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
|
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
|
||||||
|
const IDLE_STATE_CHANGED_CHANNEL = 'idle-state-changed';
|
||||||
|
|
||||||
export interface LinuxScreenShareAudioRoutingInfo {
|
export interface LinuxScreenShareAudioRoutingInfo {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
@@ -214,6 +215,9 @@ export interface ElectronAPI {
|
|||||||
contextMenuCommand: (command: string) => Promise<void>;
|
contextMenuCommand: (command: string) => Promise<void>;
|
||||||
copyImageToClipboard: (srcURL: string) => Promise<boolean>;
|
copyImageToClipboard: (srcURL: string) => Promise<boolean>;
|
||||||
|
|
||||||
|
getIdleState: () => Promise<'active' | 'idle'>;
|
||||||
|
onIdleStateChanged: (listener: (state: 'active' | 'idle') => void) => () => void;
|
||||||
|
|
||||||
command: <T = unknown>(command: Command) => Promise<T>;
|
command: <T = unknown>(command: Command) => Promise<T>;
|
||||||
query: <T = unknown>(query: Query) => Promise<T>;
|
query: <T = unknown>(query: Query) => Promise<T>;
|
||||||
}
|
}
|
||||||
@@ -333,6 +337,19 @@ const electronAPI: ElectronAPI = {
|
|||||||
contextMenuCommand: (command) => ipcRenderer.invoke('context-menu-command', command),
|
contextMenuCommand: (command) => ipcRenderer.invoke('context-menu-command', command),
|
||||||
copyImageToClipboard: (srcURL) => ipcRenderer.invoke('copy-image-to-clipboard', srcURL),
|
copyImageToClipboard: (srcURL) => ipcRenderer.invoke('copy-image-to-clipboard', srcURL),
|
||||||
|
|
||||||
|
getIdleState: () => ipcRenderer.invoke('get-idle-state'),
|
||||||
|
onIdleStateChanged: (listener) => {
|
||||||
|
const wrappedListener = (_event: Electron.IpcRendererEvent, state: 'active' | 'idle') => {
|
||||||
|
listener(state);
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcRenderer.on(IDLE_STATE_CHANGED_CHANNEL, wrappedListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ipcRenderer.removeListener(IDLE_STATE_CHANGED_CHANNEL, wrappedListener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
command: (command) => ipcRenderer.invoke('cqrs:command', command),
|
command: (command) => ipcRenderer.invoke('cqrs:command', command),
|
||||||
query: (query) => ipcRenderer.invoke('cqrs:query', query)
|
query: (query) => ipcRenderer.invoke('cqrs:query', query)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -210,6 +210,40 @@ export async function createWindow(): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
session.defaultSession.setDisplayMediaRequestHandler(
|
||||||
|
async (request, respond) => {
|
||||||
|
// On Windows the system picker (useSystemPicker: true) is preferred.
|
||||||
|
// This handler is only reached when the system picker is unavailable.
|
||||||
|
// Include loopback audio when the renderer requested it so that
|
||||||
|
// getDisplayMedia receives an audio track and the renderer-side
|
||||||
|
// restrictOwnAudio constraint can keep the app's own voice playback
|
||||||
|
// out of the captured stream.
|
||||||
|
try {
|
||||||
|
const sources = await desktopCapturer.getSources({
|
||||||
|
types: ['window', 'screen'],
|
||||||
|
thumbnailSize: { width: 150, height: 150 }
|
||||||
|
});
|
||||||
|
const firstSource = sources[0];
|
||||||
|
|
||||||
|
if (firstSource) {
|
||||||
|
respond({
|
||||||
|
video: firstSource,
|
||||||
|
...(request.audioRequested ? { audio: 'loopback' } : {})
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// desktopCapturer also unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
respond({});
|
||||||
|
},
|
||||||
|
{ useSystemPicker: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (process.env['NODE_ENV'] === 'development') {
|
if (process.env['NODE_ENV'] === 'development') {
|
||||||
const devUrl = process.env['SSL'] === 'true'
|
const devUrl = process.env['SSL'] === 'true'
|
||||||
? 'https://localhost:4200'
|
? 'https://localhost:4200'
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ module.exports = tseslint.config(
|
|||||||
'complexity': ['warn',{ max:20 }],
|
'complexity': ['warn',{ max:20 }],
|
||||||
'curly': 'off',
|
'curly': 'off',
|
||||||
'eol-last': 'error',
|
'eol-last': 'error',
|
||||||
'id-denylist': ['warn','e','cb','i','x','c','y','any','string','String','Undefined','undefined','callback'],
|
'id-denylist': ['warn','e','cb','i','c','any','string','String','Undefined','undefined','callback'],
|
||||||
'max-len': ['error',{ code:150, ignoreComments:true }],
|
'max-len': ['error',{ code:150, ignoreComments:true }],
|
||||||
'new-parens': 'error',
|
'new-parens': 'error',
|
||||||
'newline-per-chained-call': 'error',
|
'newline-per-chained-call': 'error',
|
||||||
@@ -172,7 +172,7 @@ module.exports = tseslint.config(
|
|||||||
// Ensure only one statement per line to prevent patterns like: if (cond) { doThing(); }
|
// Ensure only one statement per line to prevent patterns like: if (cond) { doThing(); }
|
||||||
'max-statements-per-line': ['error', { max: 1 }],
|
'max-statements-per-line': ['error', { max: 1 }],
|
||||||
// Prevent single-character identifiers for variables/params; do not check object property names
|
// Prevent single-character identifiers for variables/params; do not check object property names
|
||||||
'id-length': ['error', { min: 2, properties: 'never', exceptions: ['_'] }],
|
'id-length': ['error', { min: 2, properties: 'never', exceptions: ['_', 'x', 'y'] }],
|
||||||
// Require blank lines around block-like statements (if, function, class, switch, try, etc.)
|
// Require blank lines around block-like statements (if, function, class, switch, try, etc.)
|
||||||
'padding-line-between-statements': [
|
'padding-line-between-statements': [
|
||||||
'error',
|
'error',
|
||||||
|
|||||||
373
package-lock.json
generated
373
package-lock.json
generated
@@ -60,6 +60,7 @@
|
|||||||
"@stylistic/eslint-plugin-js": "^4.4.1",
|
"@stylistic/eslint-plugin-js": "^4.4.1",
|
||||||
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
||||||
"@types/auto-launch": "^5.0.5",
|
"@types/auto-launch": "^5.0.5",
|
||||||
|
"@types/mocha": "^10.0.10",
|
||||||
"@types/simple-peer": "^9.11.9",
|
"@types/simple-peer": "^9.11.9",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"angular-eslint": "21.2.0",
|
"angular-eslint": "21.2.0",
|
||||||
@@ -79,6 +80,7 @@
|
|||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"typescript-eslint": "8.50.1",
|
"typescript-eslint": "8.50.1",
|
||||||
|
"vitest": "^4.1.4",
|
||||||
"wait-on": "^7.2.0"
|
"wait-on": "^7.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -11025,6 +11027,17 @@
|
|||||||
"@types/responselike": "^1.0.0"
|
"@types/responselike": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/chai": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/deep-eql": "*",
|
||||||
|
"assertion-error": "^2.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/connect": {
|
"node_modules/@types/connect": {
|
||||||
"version": "3.4.38",
|
"version": "3.4.38",
|
||||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||||
@@ -11306,6 +11319,13 @@
|
|||||||
"@types/ms": "*"
|
"@types/ms": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/deep-eql": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/eslint": {
|
"node_modules/@types/eslint": {
|
||||||
"version": "9.6.1",
|
"version": "9.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||||
@@ -11465,6 +11485,13 @@
|
|||||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/mocha": {
|
||||||
|
"version": "10.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz",
|
||||||
|
"integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/ms": {
|
"node_modules/@types/ms": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
@@ -12270,6 +12297,146 @@
|
|||||||
"vite": "^6.0.0 || ^7.0.0"
|
"vite": "^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/expect": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.1.0",
|
||||||
|
"@types/chai": "^5.2.2",
|
||||||
|
"@vitest/spy": "4.1.4",
|
||||||
|
"@vitest/utils": "4.1.4",
|
||||||
|
"chai": "^6.2.2",
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/mocker": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/spy": "4.1.4",
|
||||||
|
"estree-walker": "^3.0.3",
|
||||||
|
"magic-string": "^0.30.21"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"msw": "^2.4.9",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"msw": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/mocker/node_modules/magic-string": {
|
||||||
|
"version": "0.30.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/pretty-format": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/runner": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/utils": "4.1.4",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/snapshot": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "4.1.4",
|
||||||
|
"@vitest/utils": "4.1.4",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/snapshot/node_modules/magic-string": {
|
||||||
|
"version": "0.30.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/spy": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/utils": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "4.1.4",
|
||||||
|
"convert-source-map": "^2.0.0",
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/utils/node_modules/convert-source-map": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@webassemblyjs/ast": {
|
"node_modules/@webassemblyjs/ast": {
|
||||||
"version": "1.14.1",
|
"version": "1.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
|
||||||
@@ -13108,6 +13275,16 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/assertion-error": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/astral-regex": {
|
"node_modules/astral-regex": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
|
||||||
@@ -14004,6 +14181,16 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chai": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "5.6.2",
|
"version": "5.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||||
@@ -17483,6 +17670,16 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/estree-walker": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esutils": {
|
"node_modules/esutils": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||||
@@ -17562,6 +17759,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expect-type": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/exponential-backoff": {
|
"node_modules/exponential-backoff": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
|
||||||
@@ -23538,6 +23745,17 @@
|
|||||||
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
|
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/obug": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/sxzz",
|
||||||
|
"https://opencollective.com/debug"
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/on-finished": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
@@ -27379,6 +27597,13 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/siginfo": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/signal-exit": {
|
"node_modules/signal-exit": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||||
@@ -27773,6 +27998,13 @@
|
|||||||
"node": "^20.17.0 || >=22.9.0"
|
"node": "^20.17.0 || >=22.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stackback": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/stackframe": {
|
"node_modules/stackframe": {
|
||||||
"version": "1.3.4",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
|
||||||
@@ -27798,6 +28030,13 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/std-env": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/stdin-discarder": {
|
"node_modules/stdin-discarder": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz",
|
||||||
@@ -28671,6 +28910,13 @@
|
|||||||
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
|
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tinybench": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyexec": {
|
"node_modules/tinyexec": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||||
@@ -28696,6 +28942,16 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinyrainbow": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tmp": {
|
"node_modules/tmp": {
|
||||||
"version": "0.2.5",
|
"version": "0.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||||
@@ -30567,6 +30823,106 @@
|
|||||||
"@esbuild/win32-x64": "0.25.12"
|
"@esbuild/win32-x64": "0.25.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vitest": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/expect": "4.1.4",
|
||||||
|
"@vitest/mocker": "4.1.4",
|
||||||
|
"@vitest/pretty-format": "4.1.4",
|
||||||
|
"@vitest/runner": "4.1.4",
|
||||||
|
"@vitest/snapshot": "4.1.4",
|
||||||
|
"@vitest/spy": "4.1.4",
|
||||||
|
"@vitest/utils": "4.1.4",
|
||||||
|
"es-module-lexer": "^2.0.0",
|
||||||
|
"expect-type": "^1.3.0",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"obug": "^2.1.1",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"picomatch": "^4.0.3",
|
||||||
|
"std-env": "^4.0.0-rc.1",
|
||||||
|
"tinybench": "^2.9.0",
|
||||||
|
"tinyexec": "^1.0.2",
|
||||||
|
"tinyglobby": "^0.2.15",
|
||||||
|
"tinyrainbow": "^3.1.0",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||||
|
"why-is-node-running": "^2.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vitest": "vitest.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@edge-runtime/vm": "*",
|
||||||
|
"@opentelemetry/api": "^1.9.0",
|
||||||
|
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||||
|
"@vitest/browser-playwright": "4.1.4",
|
||||||
|
"@vitest/browser-preview": "4.1.4",
|
||||||
|
"@vitest/browser-webdriverio": "4.1.4",
|
||||||
|
"@vitest/coverage-istanbul": "4.1.4",
|
||||||
|
"@vitest/coverage-v8": "4.1.4",
|
||||||
|
"@vitest/ui": "4.1.4",
|
||||||
|
"happy-dom": "*",
|
||||||
|
"jsdom": "*",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@edge-runtime/vm": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@opentelemetry/api": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-playwright": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-preview": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-webdriverio": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/coverage-istanbul": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/coverage-v8": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/ui": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"happy-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"jsdom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/magic-string": {
|
||||||
|
"version": "0.30.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vscode-jsonrpc": {
|
"node_modules/vscode-jsonrpc": {
|
||||||
"version": "8.2.0",
|
"version": "8.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
|
||||||
@@ -31439,6 +31795,23 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/why-is-node-running": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"siginfo": "^2.0.0",
|
||||||
|
"stackback": "0.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"why-is-node-running": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wildcard": {
|
"node_modules/wildcard": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"build:all": "npm run build && npm run build:electron && cd server && npm run build",
|
"build:all": "npm run build && npm run build:electron && cd server && npm run build",
|
||||||
"build:prod": "cd \"toju-app\" && ng build --configuration production --base-href='./'",
|
"build:prod": "cd \"toju-app\" && ng build --configuration production --base-href='./'",
|
||||||
"watch": "cd \"toju-app\" && ng build --watch --configuration development",
|
"watch": "cd \"toju-app\" && ng build --watch --configuration development",
|
||||||
"test": "cd \"toju-app\" && ng test",
|
"test": "cd \"toju-app\" && vitest run",
|
||||||
"server:build": "cd server && npm run build",
|
"server:build": "cd server && npm run build",
|
||||||
"server:start": "cd server && npm start",
|
"server:start": "cd server && npm start",
|
||||||
"server:dev": "cd server && npm run dev",
|
"server:dev": "cd server && npm run dev",
|
||||||
@@ -110,6 +110,7 @@
|
|||||||
"@stylistic/eslint-plugin-js": "^4.4.1",
|
"@stylistic/eslint-plugin-js": "^4.4.1",
|
||||||
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
||||||
"@types/auto-launch": "^5.0.5",
|
"@types/auto-launch": "^5.0.5",
|
||||||
|
"@types/mocha": "^10.0.10",
|
||||||
"@types/simple-peer": "^9.11.9",
|
"@types/simple-peer": "^9.11.9",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"angular-eslint": "21.2.0",
|
"angular-eslint": "21.2.0",
|
||||||
@@ -129,6 +130,7 @@
|
|||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"typescript-eslint": "8.50.1",
|
"typescript-eslint": "8.50.1",
|
||||||
|
"vitest": "^4.1.4",
|
||||||
"wait-on": "^7.2.0"
|
"wait-on": "^7.2.0"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ function buildServer(app: ReturnType<typeof createApp>, serverProtocol: ServerHt
|
|||||||
return createHttpServer(app);
|
return createHttpServer(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let listeningServer: ReturnType<typeof buildServer> | null = null;
|
||||||
|
let staleJoinRequestInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
const variablesConfig = ensureVariablesConfig();
|
const variablesConfig = ensureVariablesConfig();
|
||||||
const serverProtocol = getServerProtocol();
|
const serverProtocol = getServerProtocol();
|
||||||
@@ -86,10 +89,12 @@ async function bootstrap(): Promise<void> {
|
|||||||
const app = createApp();
|
const app = createApp();
|
||||||
const server = buildServer(app, serverProtocol);
|
const server = buildServer(app, serverProtocol);
|
||||||
|
|
||||||
|
listeningServer = server;
|
||||||
|
|
||||||
setupWebSocket(server);
|
setupWebSocket(server);
|
||||||
|
|
||||||
// Periodically clean up stale join requests (older than 24 h)
|
// Periodically clean up stale join requests (older than 24 h)
|
||||||
setInterval(() => {
|
staleJoinRequestInterval = setInterval(() => {
|
||||||
deleteStaleJoinRequests(24 * 60 * 60 * 1000)
|
deleteStaleJoinRequests(24 * 60 * 60 * 1000)
|
||||||
.catch(err => console.error('Failed to clean up stale join requests:', err));
|
.catch(err => console.error('Failed to clean up stale join requests:', err));
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
@@ -122,10 +127,29 @@ async function bootstrap(): Promise<void> {
|
|||||||
let shuttingDown = false;
|
let shuttingDown = false;
|
||||||
|
|
||||||
async function gracefulShutdown(signal: string): Promise<void> {
|
async function gracefulShutdown(signal: string): Promise<void> {
|
||||||
if (shuttingDown) return;
|
if (shuttingDown)
|
||||||
|
return;
|
||||||
|
|
||||||
shuttingDown = true;
|
shuttingDown = true;
|
||||||
|
|
||||||
console.log(`\n[Shutdown] ${signal} received — closing database…`);
|
if (staleJoinRequestInterval) {
|
||||||
|
clearInterval(staleJoinRequestInterval);
|
||||||
|
staleJoinRequestInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n[Shutdown] ${signal} received - closing database…`);
|
||||||
|
|
||||||
|
if (listeningServer?.listening) {
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
listeningServer?.close(() => resolve());
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Shutdown] Error closing server:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listeningServer = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await destroyDatabase();
|
await destroyDatabase();
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable complexity */
|
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { getKlipyApiKey, hasKlipyApiKey } from '../config/variables';
|
import { getKlipyApiKey, hasKlipyApiKey } from '../config/variables';
|
||||||
|
|
||||||
@@ -47,6 +46,11 @@ interface KlipyApiResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ResolvedGifMedia {
|
||||||
|
previewMeta: NormalizedMediaMeta | null;
|
||||||
|
sourceMeta: NormalizedMediaMeta;
|
||||||
|
}
|
||||||
|
|
||||||
function pickFirst<T>(...values: (T | null | undefined)[]): T | undefined {
|
function pickFirst<T>(...values: (T | null | undefined)[]): T | undefined {
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
if (value != null)
|
if (value != null)
|
||||||
@@ -130,33 +134,49 @@ function extractKlipyResponseData(payload: unknown): { items: unknown[]; hasNext
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveGifMedia(file?: KlipyGifVariants): ResolvedGifMedia | null {
|
||||||
|
const previewVariant = pickFirst(file?.md, file?.sm, file?.xs, file?.hd);
|
||||||
|
const sourceVariant = pickFirst(file?.hd, file?.md, file?.sm, file?.xs);
|
||||||
|
const previewMeta = pickGifMeta(previewVariant);
|
||||||
|
const sourceMeta = pickGifMeta(sourceVariant) ?? previewMeta;
|
||||||
|
|
||||||
|
if (!sourceMeta?.url)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
previewMeta,
|
||||||
|
sourceMeta
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGifSlug(gifItem: KlipyGifItem): string | undefined {
|
||||||
|
return sanitizeString(gifItem.slug) ?? sanitizeString(gifItem.id);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeGifItem(item: unknown): NormalizedKlipyGif | null {
|
function normalizeGifItem(item: unknown): NormalizedKlipyGif | null {
|
||||||
if (!item || typeof item !== 'object')
|
if (!item || typeof item !== 'object')
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
const gifItem = item as KlipyGifItem;
|
const gifItem = item as KlipyGifItem;
|
||||||
|
const resolvedMedia = resolveGifMedia(gifItem.file);
|
||||||
|
const slug = resolveGifSlug(gifItem);
|
||||||
|
|
||||||
if (gifItem.type === 'ad')
|
if (gifItem.type === 'ad')
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
const lowVariant = pickFirst(gifItem.file?.md, gifItem.file?.sm, gifItem.file?.xs, gifItem.file?.hd);
|
if (!slug || !resolvedMedia)
|
||||||
const highVariant = pickFirst(gifItem.file?.hd, gifItem.file?.md, gifItem.file?.sm, gifItem.file?.xs);
|
|
||||||
const lowMeta = pickGifMeta(lowVariant);
|
|
||||||
const highMeta = pickGifMeta(highVariant);
|
|
||||||
const selectedMeta = highMeta ?? lowMeta;
|
|
||||||
const slug = sanitizeString(gifItem.slug) ?? sanitizeString(gifItem.id);
|
|
||||||
|
|
||||||
if (!slug || !selectedMeta?.url)
|
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
const { previewMeta, sourceMeta } = resolvedMedia;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: slug,
|
id: slug,
|
||||||
slug,
|
slug,
|
||||||
title: sanitizeString(gifItem.title),
|
title: sanitizeString(gifItem.title),
|
||||||
url: selectedMeta.url,
|
url: sourceMeta.url,
|
||||||
previewUrl: lowMeta?.url ?? selectedMeta.url,
|
previewUrl: previewMeta?.url ?? sourceMeta.url,
|
||||||
width: selectedMeta.width ?? lowMeta?.width ?? 0,
|
width: sourceMeta.width ?? previewMeta?.width ?? 0,
|
||||||
height: selectedMeta.height ?? lowMeta?.height ?? 0
|
height: sourceMeta.height ?? previewMeta?.height ?? 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
199
server/src/websocket/handler-status.spec.ts
Normal file
199
server/src/websocket/handler-status.spec.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect,
|
||||||
|
beforeEach
|
||||||
|
} from 'vitest';
|
||||||
|
import { connectedUsers } from './state';
|
||||||
|
import { handleWebSocketMessage } from './handler';
|
||||||
|
import { ConnectedUser } from './types';
|
||||||
|
import { WebSocket } from 'ws';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal mock WebSocket that records sent messages.
|
||||||
|
*/
|
||||||
|
function createMockWs(): WebSocket & { sentMessages: string[] } {
|
||||||
|
const sent: string[] = [];
|
||||||
|
const ws = {
|
||||||
|
readyState: WebSocket.OPEN,
|
||||||
|
send: (data: string) => { sent.push(data); },
|
||||||
|
close: () => {},
|
||||||
|
sentMessages: sent
|
||||||
|
} as unknown as WebSocket & { sentMessages: string[] };
|
||||||
|
|
||||||
|
return ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConnectedUser(
|
||||||
|
connectionId: string,
|
||||||
|
oderId: string,
|
||||||
|
overrides: Partial<ConnectedUser> = {}
|
||||||
|
): ConnectedUser {
|
||||||
|
const ws = createMockWs();
|
||||||
|
const user: ConnectedUser = {
|
||||||
|
oderId,
|
||||||
|
ws,
|
||||||
|
serverIds: new Set(),
|
||||||
|
displayName: 'Test User',
|
||||||
|
lastPong: Date.now(),
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
|
||||||
|
connectedUsers.set(connectionId, user);
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequiredConnectedUser(connectionId: string): ConnectedUser {
|
||||||
|
const connectedUser = connectedUsers.get(connectionId);
|
||||||
|
|
||||||
|
if (!connectedUser)
|
||||||
|
throw new Error(`Expected connected user for ${connectionId}`);
|
||||||
|
|
||||||
|
return connectedUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSentMessagesStore(user: ConnectedUser): { sentMessages: string[] } {
|
||||||
|
return user.ws as unknown as { sentMessages: string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('server websocket handler - status_update', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
connectedUsers.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates user status on valid status_update message', async () => {
|
||||||
|
const user = createConnectedUser('conn-1', 'user-1');
|
||||||
|
|
||||||
|
user.serverIds.add('server-1');
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'away' });
|
||||||
|
|
||||||
|
expect(connectedUsers.get('conn-1')?.status).toBe('away');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('broadcasts status_update to other users in the same server', async () => {
|
||||||
|
const user1 = createConnectedUser('conn-1', 'user-1');
|
||||||
|
const user2 = createConnectedUser('conn-2', 'user-2');
|
||||||
|
|
||||||
|
user1.serverIds.add('server-1');
|
||||||
|
user2.serverIds.add('server-1');
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'busy' });
|
||||||
|
|
||||||
|
const messages = getSentMessagesStore(user2).sentMessages.map((messageText: string) => JSON.parse(messageText));
|
||||||
|
const statusMsg = messages.find((message: { type: string }) => message.type === 'status_update');
|
||||||
|
|
||||||
|
expect(statusMsg).toBeDefined();
|
||||||
|
expect(statusMsg?.oderId).toBe('user-1');
|
||||||
|
expect(statusMsg?.status).toBe('busy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not broadcast to users in different servers', async () => {
|
||||||
|
createConnectedUser('conn-1', 'user-1');
|
||||||
|
const user2 = createConnectedUser('conn-2', 'user-2');
|
||||||
|
|
||||||
|
getRequiredConnectedUser('conn-1').serverIds.add('server-1');
|
||||||
|
user2.serverIds.add('server-2');
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'away' });
|
||||||
|
|
||||||
|
expect(getSentMessagesStore(user2).sentMessages.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores invalid status values', async () => {
|
||||||
|
createConnectedUser('conn-1', 'user-1');
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'invalid_status' });
|
||||||
|
|
||||||
|
expect(connectedUsers.get('conn-1')?.status).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores missing status field', async () => {
|
||||||
|
createConnectedUser('conn-1', 'user-1');
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'status_update' });
|
||||||
|
|
||||||
|
expect(connectedUsers.get('conn-1')?.status).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts all valid status values', async () => {
|
||||||
|
for (const status of [
|
||||||
|
'online',
|
||||||
|
'away',
|
||||||
|
'busy',
|
||||||
|
'offline'
|
||||||
|
]) {
|
||||||
|
connectedUsers.clear();
|
||||||
|
createConnectedUser('conn-1', 'user-1');
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'status_update', status });
|
||||||
|
|
||||||
|
expect(connectedUsers.get('conn-1')?.status).toBe(status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes status in server_users response after status change', async () => {
|
||||||
|
const user1 = createConnectedUser('conn-1', 'user-1');
|
||||||
|
const user2 = createConnectedUser('conn-2', 'user-2');
|
||||||
|
|
||||||
|
user1.serverIds.add('server-1');
|
||||||
|
user2.serverIds.add('server-1');
|
||||||
|
|
||||||
|
// Set user-1 to away
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'away' });
|
||||||
|
|
||||||
|
// Clear sent messages
|
||||||
|
getSentMessagesStore(user2).sentMessages.length = 0;
|
||||||
|
|
||||||
|
// Identify first (required for handler)
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' });
|
||||||
|
|
||||||
|
// user-2 joins server → should receive server_users with user-1's status
|
||||||
|
getSentMessagesStore(user2).sentMessages.length = 0;
|
||||||
|
await handleWebSocketMessage('conn-2', { type: 'join_server', serverId: 'server-1' });
|
||||||
|
|
||||||
|
const messages = getSentMessagesStore(user2).sentMessages.map((messageText: string) => JSON.parse(messageText));
|
||||||
|
const serverUsersMsg = messages.find((message: { type: string }) => message.type === 'server_users');
|
||||||
|
|
||||||
|
expect(serverUsersMsg).toBeDefined();
|
||||||
|
|
||||||
|
const user1InList = serverUsersMsg?.users?.find((userEntry: { oderId: string }) => userEntry.oderId === 'user-1');
|
||||||
|
|
||||||
|
expect(user1InList?.status).toBe('away');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('server websocket handler - user_joined includes status', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
connectedUsers.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes status in user_joined broadcast', async () => {
|
||||||
|
const user1 = createConnectedUser('conn-1', 'user-1');
|
||||||
|
const user2 = createConnectedUser('conn-2', 'user-2');
|
||||||
|
|
||||||
|
user1.serverIds.add('server-1');
|
||||||
|
user2.serverIds.add('server-1');
|
||||||
|
|
||||||
|
// Set user-1's status to busy before joining
|
||||||
|
getRequiredConnectedUser('conn-1').status = 'busy';
|
||||||
|
|
||||||
|
// Identify user-1
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' });
|
||||||
|
|
||||||
|
getSentMessagesStore(user2).sentMessages.length = 0;
|
||||||
|
|
||||||
|
// user-1 joins server-1
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'join_server', serverId: 'server-1' });
|
||||||
|
|
||||||
|
const messages = getSentMessagesStore(user2).sentMessages.map((messageText: string) => JSON.parse(messageText));
|
||||||
|
const joinMsg = messages.find((message: { type: string }) => message.type === 'user_joined');
|
||||||
|
|
||||||
|
// user_joined may or may not appear depending on whether it's a new identity membership
|
||||||
|
// Since both are already in the server, it may not broadcast. Either way, verify no crash.
|
||||||
|
if (joinMsg) {
|
||||||
|
expect(joinMsg.status).toBe('busy');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,7 +37,7 @@ 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) }));
|
.map(cu => ({ oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName), status: cu.status ?? 'online' }));
|
||||||
|
|
||||||
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
||||||
}
|
}
|
||||||
@@ -108,6 +108,7 @@ 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),
|
||||||
|
status: user.status ?? 'online',
|
||||||
serverId: sid
|
serverId: sid
|
||||||
}, user.oderId);
|
}, user.oderId);
|
||||||
}
|
}
|
||||||
@@ -204,6 +205,32 @@ function handleTyping(user: ConnectedUser, message: WsMessage): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VALID_STATUSES = new Set([
|
||||||
|
'online',
|
||||||
|
'away',
|
||||||
|
'busy',
|
||||||
|
'offline'
|
||||||
|
]);
|
||||||
|
|
||||||
|
function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
|
const status = typeof message['status'] === 'string' ? message['status'] : undefined;
|
||||||
|
|
||||||
|
if (!status || !VALID_STATUSES.has(status))
|
||||||
|
return;
|
||||||
|
|
||||||
|
user.status = status as ConnectedUser['status'];
|
||||||
|
connectedUsers.set(connectionId, user);
|
||||||
|
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) status → ${status}`);
|
||||||
|
|
||||||
|
for (const serverId of user.serverIds) {
|
||||||
|
broadcastToServer(serverId, {
|
||||||
|
type: 'status_update',
|
||||||
|
oderId: user.oderId,
|
||||||
|
status
|
||||||
|
}, user.oderId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
|
export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
|
||||||
const user = connectedUsers.get(connectionId);
|
const user = connectedUsers.get(connectionId);
|
||||||
|
|
||||||
@@ -241,6 +268,10 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
|||||||
handleTyping(user, message);
|
handleTyping(user, message);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'status_update':
|
||||||
|
handleStatusUpdate(user, message, connectionId);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.log('Unknown message type:', message.type);
|
console.log('Unknown message type:', message.type);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export interface ConnectedUser {
|
|||||||
* URLs routing to the same server coexist without an eviction loop.
|
* URLs routing to the same server coexist without an eviction loop.
|
||||||
*/
|
*/
|
||||||
connectionScope?: string;
|
connectionScope?: string;
|
||||||
|
/** User availability status (online, away, busy, offline). */
|
||||||
|
status?: 'online' | 'away' | 'busy' | 'offline';
|
||||||
/** Timestamp of the last pong received (used to detect dead connections). */
|
/** Timestamp of the last pong received (used to detect dead connections). */
|
||||||
lastPong: number;
|
lastPong: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,5 +17,5 @@
|
|||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist", "src/**/*.spec.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@
|
|||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "2.2MB",
|
"maximumWarning": "2.2MB",
|
||||||
"maximumError": "2.3MB"
|
"maximumError": "2.32MB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { roomsReducer } from './store/rooms/rooms.reducer';
|
|||||||
import { NotificationsEffects } from './domains/notifications';
|
import { NotificationsEffects } from './domains/notifications';
|
||||||
import { MessagesEffects } from './store/messages/messages.effects';
|
import { MessagesEffects } from './store/messages/messages.effects';
|
||||||
import { MessagesSyncEffects } from './store/messages/messages-sync.effects';
|
import { MessagesSyncEffects } from './store/messages/messages-sync.effects';
|
||||||
|
import { UserAvatarEffects } from './store/users/user-avatar.effects';
|
||||||
import { UsersEffects } from './store/users/users.effects';
|
import { UsersEffects } from './store/users/users.effects';
|
||||||
import { RoomsEffects } from './store/rooms/rooms.effects';
|
import { RoomsEffects } from './store/rooms/rooms.effects';
|
||||||
import { RoomMembersSyncEffects } from './store/rooms/room-members-sync.effects';
|
import { RoomMembersSyncEffects } from './store/rooms/room-members-sync.effects';
|
||||||
@@ -38,6 +39,7 @@ export const appConfig: ApplicationConfig = {
|
|||||||
NotificationsEffects,
|
NotificationsEffects,
|
||||||
MessagesEffects,
|
MessagesEffects,
|
||||||
MessagesSyncEffects,
|
MessagesSyncEffects,
|
||||||
|
UserAvatarEffects,
|
||||||
UsersEffects,
|
UsersEffects,
|
||||||
RoomsEffects,
|
RoomsEffects,
|
||||||
RoomMembersSyncEffects,
|
RoomMembersSyncEffects,
|
||||||
|
|||||||
@@ -33,13 +33,14 @@ import { VoiceSessionFacade } from './domains/voice-session';
|
|||||||
import { ExternalLinkService } from './core/platform';
|
import { ExternalLinkService } from './core/platform';
|
||||||
import { SettingsModalService } from './core/services/settings-modal.service';
|
import { SettingsModalService } from './core/services/settings-modal.service';
|
||||||
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
|
||||||
import { ServersRailComponent } from './features/servers/servers-rail.component';
|
import { UserStatusService } from './core/services/user-status.service';
|
||||||
import { TitleBarComponent } from './features/shell/title-bar.component';
|
import { ServersRailComponent } from './features/servers/servers-rail/servers-rail.component';
|
||||||
|
import { TitleBarComponent } from './features/shell/title-bar/title-bar.component';
|
||||||
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
|
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
|
||||||
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
|
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
|
||||||
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
|
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
|
||||||
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
|
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
|
||||||
import { NativeContextMenuComponent } from './features/shell/native-context-menu.component';
|
import { NativeContextMenuComponent } from './features/shell/native-context-menu/native-context-menu.component';
|
||||||
import { UsersActions } from './store/users/users.actions';
|
import { UsersActions } from './store/users/users.actions';
|
||||||
import { RoomsActions } from './store/rooms/rooms.actions';
|
import { RoomsActions } from './store/rooms/rooms.actions';
|
||||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||||
@@ -92,6 +93,7 @@ export class App implements OnInit, OnDestroy {
|
|||||||
readonly voiceSession = inject(VoiceSessionFacade);
|
readonly voiceSession = inject(VoiceSessionFacade);
|
||||||
readonly externalLinks = inject(ExternalLinkService);
|
readonly externalLinks = inject(ExternalLinkService);
|
||||||
readonly electronBridge = inject(ElectronBridgeService);
|
readonly electronBridge = inject(ElectronBridgeService);
|
||||||
|
readonly userStatus = inject(UserStatusService);
|
||||||
readonly dismissedDesktopUpdateNoticeKey = signal<string | null>(null);
|
readonly dismissedDesktopUpdateNoticeKey = signal<string | null>(null);
|
||||||
readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null);
|
readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null);
|
||||||
readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null);
|
readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null);
|
||||||
@@ -159,7 +161,7 @@ export class App implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void import('./domains/theme/feature/settings/theme-settings.component')
|
void import('./domains/theme/feature/settings/theme-settings/theme-settings.component')
|
||||||
.then((module) => {
|
.then((module) => {
|
||||||
this.themeStudioFullscreenComponent.set(module.ThemeSettingsComponent);
|
this.themeStudioFullscreenComponent.set(module.ThemeSettingsComponent);
|
||||||
});
|
});
|
||||||
@@ -231,6 +233,8 @@ export class App implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.store.dispatch(UsersActions.loadCurrentUser());
|
this.store.dispatch(UsersActions.loadCurrentUser());
|
||||||
|
|
||||||
|
this.userStatus.start();
|
||||||
|
|
||||||
this.store.dispatch(RoomsActions.loadRooms());
|
this.store.dispatch(RoomsActions.loadRooms());
|
||||||
|
|
||||||
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
|
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable complexity, padding-line-between-statements */
|
|
||||||
import { getDebugNetworkMetricSnapshot } from '../../../infrastructure/realtime/logging/debug-network-metrics';
|
import { getDebugNetworkMetricSnapshot } from '../../../infrastructure/realtime/logging/debug-network-metrics';
|
||||||
import type { Room, User } from '../../models/index';
|
import type { Room, User } from '../../models/index';
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +29,14 @@ import type {
|
|||||||
MutableDebugNetworkNode
|
MutableDebugNetworkNode
|
||||||
} from '../../models/debugging.models';
|
} from '../../models/debugging.models';
|
||||||
|
|
||||||
|
interface FinalizedNodePresentation {
|
||||||
|
userId: string | null;
|
||||||
|
label: string;
|
||||||
|
secondaryLabel: string;
|
||||||
|
title: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildDebugNetworkSnapshot(
|
export function buildDebugNetworkSnapshot(
|
||||||
entries: readonly DebugLogEntry[],
|
entries: readonly DebugLogEntry[],
|
||||||
currentUser: User | null,
|
currentUser: User | null,
|
||||||
@@ -158,36 +165,92 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
const payload = this.getEntryPayloadRecord(entry.payload);
|
const payload = this.getEntryPayloadRecord(entry.payload);
|
||||||
const timestamp = entry.timestamp;
|
const timestamp = entry.timestamp;
|
||||||
|
|
||||||
if (
|
if (this.applySignalingServerStateChange(state, entry.message, payload, timestamp))
|
||||||
entry.message === 'Connecting to signaling server'
|
|
||||||
|| entry.message === 'Connected to signaling server'
|
|
||||||
|| entry.message === 'Attempting reconnect'
|
|
||||||
|| entry.message === 'Disconnected from signaling server'
|
|
||||||
|| entry.message === 'Signaling socket error'
|
|
||||||
|| entry.message === 'Failed to initialize signaling socket'
|
|
||||||
) {
|
|
||||||
const url = this.getPayloadString(payload, 'serverUrl') ?? this.getPayloadString(payload, 'url');
|
|
||||||
|
|
||||||
if (!url)
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const serverNode = this.ensureSignalingServerNode(state, url, timestamp);
|
|
||||||
const edge = this.ensureNetworkEdge(state, 'signaling', LOCAL_NETWORK_NODE_ID, serverNode.id, timestamp);
|
|
||||||
|
|
||||||
edge.stateLabel = this.getSignalingEdgeStateLabel(entry.message);
|
|
||||||
edge.isActive = edge.stateLabel === 'connected' || edge.stateLabel === 'connecting' || edge.stateLabel === 'active';
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.message !== 'inbound' && entry.message !== 'outbound')
|
if (entry.message !== 'inbound' && entry.message !== 'outbound')
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const direction = entry.message as DebugNetworkMessageDirection;
|
const direction = entry.message as DebugNetworkMessageDirection;
|
||||||
const type = this.getPayloadString(payload, 'type') ?? 'unknown';
|
const type = this.getPayloadString(payload, 'type') ?? 'unknown';
|
||||||
|
|
||||||
|
this.recordSignalingTransportMessage(state, payload, type, direction, timestamp, entry.count);
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'identify':
|
||||||
|
this.applySignalingIdentifyMessage(state, direction, payload, timestamp);
|
||||||
|
return;
|
||||||
|
case 'connected':
|
||||||
|
this.applySignalingConnectedMessage(state, payload, timestamp);
|
||||||
|
return;
|
||||||
|
case 'join_server':
|
||||||
|
case 'view_server':
|
||||||
|
case 'leave_server':
|
||||||
|
this.applySignalingMembershipMessage(state, type, payload, timestamp);
|
||||||
|
return;
|
||||||
|
case 'server_users':
|
||||||
|
this.applySignalingServerUsersMessage(state, payload, timestamp);
|
||||||
|
return;
|
||||||
|
case 'user_joined':
|
||||||
|
case 'user_left':
|
||||||
|
case 'user_typing':
|
||||||
|
this.applySignalingUserActivityMessage(state, type, payload, timestamp);
|
||||||
|
return;
|
||||||
|
case 'typing':
|
||||||
|
this.ensureLocalNetworkNode(state, timestamp).typingExpiresAt = timestamp + NETWORK_TYPING_TTL_MS;
|
||||||
|
return;
|
||||||
|
case 'offer':
|
||||||
|
case 'answer':
|
||||||
|
case 'ice_candidate':
|
||||||
|
this.applySignalingPeerHandshakeMessage(state, type, direction, payload, timestamp, entry.count);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applySignalingServerStateChange(
|
||||||
|
state: DebugNetworkBuildState,
|
||||||
|
message: string,
|
||||||
|
payload: Record<string, unknown> | null,
|
||||||
|
timestamp: number
|
||||||
|
): boolean {
|
||||||
|
if (
|
||||||
|
message !== 'Connecting to signaling server'
|
||||||
|
&& message !== 'Connected to signaling server'
|
||||||
|
&& message !== 'Attempting reconnect'
|
||||||
|
&& message !== 'Disconnected from signaling server'
|
||||||
|
&& message !== 'Signaling socket error'
|
||||||
|
&& message !== 'Failed to initialize signaling socket'
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = this.getPayloadString(payload, 'serverUrl') ?? this.getPayloadString(payload, 'url');
|
||||||
|
|
||||||
|
if (!url)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
const serverNode = this.ensureSignalingServerNode(state, url, timestamp);
|
||||||
|
const edge = this.ensureNetworkEdge(state, 'signaling', LOCAL_NETWORK_NODE_ID, serverNode.id, timestamp);
|
||||||
|
|
||||||
|
edge.stateLabel = this.getSignalingEdgeStateLabel(message);
|
||||||
|
edge.isActive = edge.stateLabel === 'connected' || edge.stateLabel === 'connecting' || edge.stateLabel === 'active';
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordSignalingTransportMessage(
|
||||||
|
state: DebugNetworkBuildState,
|
||||||
|
payload: Record<string, unknown> | null,
|
||||||
|
type: string,
|
||||||
|
direction: DebugNetworkMessageDirection,
|
||||||
|
timestamp: number,
|
||||||
|
count: number
|
||||||
|
): void {
|
||||||
const url = this.getPayloadString(payload, 'url');
|
const url = this.getPayloadString(payload, 'url');
|
||||||
|
|
||||||
if (url) {
|
if (!url)
|
||||||
|
return;
|
||||||
|
|
||||||
const signalingNode = this.ensureSignalingServerNode(state, url, timestamp);
|
const signalingNode = this.ensureSignalingServerNode(state, url, timestamp);
|
||||||
const signalingEdge = this.ensureNetworkEdge(state, 'signaling', LOCAL_NETWORK_NODE_ID, signalingNode.id, timestamp);
|
const signalingEdge = this.ensureNetworkEdge(state, 'signaling', LOCAL_NETWORK_NODE_ID, signalingNode.id, timestamp);
|
||||||
|
|
||||||
@@ -196,32 +259,41 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
if (!signalingEdge.stateLabel || signalingEdge.stateLabel === 'disconnected')
|
if (!signalingEdge.stateLabel || signalingEdge.stateLabel === 'disconnected')
|
||||||
signalingEdge.stateLabel = 'active';
|
signalingEdge.stateLabel = 'active';
|
||||||
|
|
||||||
this.recordNetworkMessage(signalingEdge, type, direction, 'signaling', timestamp, entry.count);
|
this.recordNetworkMessage(signalingEdge, type, direction, 'signaling', timestamp, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (type) {
|
private applySignalingIdentifyMessage(
|
||||||
case 'identify': {
|
state: DebugNetworkBuildState,
|
||||||
|
direction: DebugNetworkMessageDirection,
|
||||||
|
payload: Record<string, unknown> | null,
|
||||||
|
timestamp: number
|
||||||
|
): void {
|
||||||
|
if (direction !== 'outbound')
|
||||||
|
return;
|
||||||
|
|
||||||
const oderId = this.getPayloadString(payload, 'oderId');
|
const oderId = this.getPayloadString(payload, 'oderId');
|
||||||
const displayName = this.getPayloadString(payload, 'displayName');
|
const displayName = this.getPayloadString(payload, 'displayName');
|
||||||
|
|
||||||
if (direction === 'outbound')
|
|
||||||
this.ensureLocalNetworkNode(state, timestamp, oderId, displayName);
|
this.ensureLocalNetworkNode(state, timestamp, oderId, displayName);
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'connected': {
|
private applySignalingConnectedMessage(
|
||||||
|
state: DebugNetworkBuildState,
|
||||||
|
payload: Record<string, unknown> | null,
|
||||||
|
timestamp: number
|
||||||
|
): void {
|
||||||
const oderId = this.getPayloadString(payload, 'oderId');
|
const oderId = this.getPayloadString(payload, 'oderId');
|
||||||
|
|
||||||
if (oderId)
|
if (oderId)
|
||||||
this.ensureLocalNetworkNode(state, timestamp, oderId);
|
this.ensureLocalNetworkNode(state, timestamp, oderId);
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'join_server':
|
private applySignalingMembershipMessage(
|
||||||
case 'view_server':
|
state: DebugNetworkBuildState,
|
||||||
case 'leave_server': {
|
type: 'join_server' | 'view_server' | 'leave_server',
|
||||||
|
payload: Record<string, unknown> | null,
|
||||||
|
timestamp: number
|
||||||
|
): void {
|
||||||
const serverId = this.getPayloadString(payload, 'serverId');
|
const serverId = this.getPayloadString(payload, 'serverId');
|
||||||
|
|
||||||
if (!serverId)
|
if (!serverId)
|
||||||
@@ -234,11 +306,13 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
membershipEdge.stateLabel = type === 'view_server'
|
membershipEdge.stateLabel = type === 'view_server'
|
||||||
? 'viewing'
|
? 'viewing'
|
||||||
: (type === 'join_server' ? 'joined' : 'left');
|
: (type === 'join_server' ? 'joined' : 'left');
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'server_users': {
|
private applySignalingServerUsersMessage(
|
||||||
|
state: DebugNetworkBuildState,
|
||||||
|
payload: Record<string, unknown> | null,
|
||||||
|
timestamp: number
|
||||||
|
): void {
|
||||||
const serverId = this.getPayloadString(payload, 'serverId');
|
const serverId = this.getPayloadString(payload, 'serverId');
|
||||||
const users = this.getPayloadArray(payload, 'users');
|
const users = this.getPayloadArray(payload, 'users');
|
||||||
|
|
||||||
@@ -265,13 +339,14 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
membershipEdge.isActive = true;
|
membershipEdge.isActive = true;
|
||||||
membershipEdge.stateLabel = 'joined';
|
membershipEdge.stateLabel = 'joined';
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'user_joined':
|
private applySignalingUserActivityMessage(
|
||||||
case 'user_left':
|
state: DebugNetworkBuildState,
|
||||||
case 'user_typing': {
|
type: 'user_joined' | 'user_left' | 'user_typing',
|
||||||
|
payload: Record<string, unknown> | null,
|
||||||
|
timestamp: number
|
||||||
|
): void {
|
||||||
const oderId = this.getPayloadString(payload, 'oderId');
|
const oderId = this.getPayloadString(payload, 'oderId');
|
||||||
const displayName = this.getPayloadString(payload, 'displayName');
|
const displayName = this.getPayloadString(payload, 'displayName');
|
||||||
const serverId = this.getPayloadString(payload, 'serverId');
|
const serverId = this.getPayloadString(payload, 'serverId');
|
||||||
@@ -284,7 +359,9 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
if (type === 'user_typing')
|
if (type === 'user_typing')
|
||||||
clientNode.typingExpiresAt = timestamp + NETWORK_TYPING_TTL_MS;
|
clientNode.typingExpiresAt = timestamp + NETWORK_TYPING_TTL_MS;
|
||||||
|
|
||||||
if (serverId) {
|
if (!serverId)
|
||||||
|
return;
|
||||||
|
|
||||||
const serverNode = this.ensureAppServerNode(state, serverId, timestamp);
|
const serverNode = this.ensureAppServerNode(state, serverId, timestamp);
|
||||||
const membershipEdge = this.ensureNetworkEdge(state, 'membership', clientNode.id, serverNode.id, timestamp);
|
const membershipEdge = this.ensureNetworkEdge(state, 'membership', clientNode.id, serverNode.id, timestamp);
|
||||||
|
|
||||||
@@ -292,16 +369,14 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
membershipEdge.stateLabel = type === 'user_joined' ? 'joined' : (type === 'user_left' ? 'left' : 'active');
|
membershipEdge.stateLabel = type === 'user_joined' ? 'joined' : (type === 'user_left' ? 'left' : 'active');
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
private applySignalingPeerHandshakeMessage(
|
||||||
}
|
state: DebugNetworkBuildState,
|
||||||
|
type: 'offer' | 'answer' | 'ice_candidate',
|
||||||
case 'typing':
|
direction: DebugNetworkMessageDirection,
|
||||||
this.ensureLocalNetworkNode(state, timestamp).typingExpiresAt = timestamp + NETWORK_TYPING_TTL_MS;
|
payload: Record<string, unknown> | null,
|
||||||
break;
|
timestamp: number,
|
||||||
|
count: number
|
||||||
case 'offer':
|
): void {
|
||||||
case 'answer':
|
|
||||||
case 'ice_candidate': {
|
|
||||||
const peerId = direction === 'outbound'
|
const peerId = direction === 'outbound'
|
||||||
? (this.getPayloadString(payload, 'targetPeerId') ?? this.getPayloadString(payload, 'fromUserId'))
|
? (this.getPayloadString(payload, 'targetPeerId') ?? this.getPayloadString(payload, 'fromUserId'))
|
||||||
: (this.getPayloadString(payload, 'fromUserId') ?? this.getPayloadString(payload, 'targetPeerId'));
|
: (this.getPayloadString(payload, 'fromUserId') ?? this.getPayloadString(payload, 'targetPeerId'));
|
||||||
@@ -313,28 +388,50 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
const peerNode = this.ensureClientNetworkNode(state, peerId, timestamp, displayName);
|
const peerNode = this.ensureClientNetworkNode(state, peerId, timestamp, displayName);
|
||||||
const peerEdge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, peerNode.id, timestamp);
|
const peerEdge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, peerNode.id, timestamp);
|
||||||
|
|
||||||
this.incrementHandshakeStats(peerNode, type, direction, entry.count);
|
this.incrementHandshakeStats(peerNode, type, direction, count);
|
||||||
this.recordNetworkMessage(peerEdge, type, direction, 'signaling', timestamp, entry.count);
|
this.recordNetworkMessage(peerEdge, type, direction, 'signaling', timestamp, count);
|
||||||
peerEdge.isActive = true;
|
peerEdge.isActive = true;
|
||||||
|
|
||||||
if (peerEdge.stateLabel !== 'connected')
|
if (peerEdge.stateLabel !== 'connected')
|
||||||
peerEdge.stateLabel = 'negotiating';
|
peerEdge.stateLabel = 'negotiating';
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyDataChannelNetworkEntry(state: DebugNetworkBuildState, entry: DebugLogEntry): void {
|
private applyDataChannelNetworkEntry(state: DebugNetworkBuildState, entry: DebugLogEntry): void {
|
||||||
const payload = this.getEntryPayloadRecord(entry.payload);
|
const payload = this.getEntryPayloadRecord(entry.payload);
|
||||||
const timestamp = entry.timestamp;
|
const timestamp = entry.timestamp;
|
||||||
|
|
||||||
if (entry.message === 'Peer latency updated') {
|
if (this.applyDataChannelLatencyMessage(state, entry.message, payload, timestamp))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (this.applyDataChannelStateMessage(state, entry.message, payload, timestamp))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (entry.message !== 'inbound' && entry.message !== 'outbound')
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.applyDataChannelPayloadMessage(
|
||||||
|
state,
|
||||||
|
payload,
|
||||||
|
entry.message as DebugNetworkMessageDirection,
|
||||||
|
timestamp,
|
||||||
|
entry.count
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyDataChannelLatencyMessage(
|
||||||
|
state: DebugNetworkBuildState,
|
||||||
|
message: string,
|
||||||
|
payload: Record<string, unknown> | null,
|
||||||
|
timestamp: number
|
||||||
|
): boolean {
|
||||||
|
if (message !== 'Peer latency updated')
|
||||||
|
return false;
|
||||||
|
|
||||||
const peerId = this.getPayloadString(payload, 'peerId');
|
const peerId = this.getPayloadString(payload, 'peerId');
|
||||||
const latencyMs = this.getPayloadNumber(payload, 'latencyMs');
|
const latencyMs = this.getPayloadNumber(payload, 'latencyMs');
|
||||||
|
|
||||||
if (!peerId || latencyMs === null)
|
if (!peerId || latencyMs === null)
|
||||||
return;
|
return true;
|
||||||
|
|
||||||
const peerNode = this.ensureClientNetworkNode(state, peerId, timestamp);
|
const peerNode = this.ensureClientNetworkNode(state, peerId, timestamp);
|
||||||
const edge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, peerNode.id, timestamp);
|
const edge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, peerNode.id, timestamp);
|
||||||
@@ -344,37 +441,48 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
edge.stateLabel = 'connected';
|
edge.stateLabel = 'connected';
|
||||||
peerNode.pingMs = latencyMs;
|
peerNode.pingMs = latencyMs;
|
||||||
|
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.message === 'Data channel open' || entry.message === 'Data channel closed' || entry.message === 'Data channel error') {
|
private applyDataChannelStateMessage(
|
||||||
|
state: DebugNetworkBuildState,
|
||||||
|
message: string,
|
||||||
|
payload: Record<string, unknown> | null,
|
||||||
|
timestamp: number
|
||||||
|
): boolean {
|
||||||
|
if (message !== 'Data channel open' && message !== 'Data channel closed' && message !== 'Data channel error')
|
||||||
|
return false;
|
||||||
|
|
||||||
const peerId = this.getPayloadString(payload, 'peerId');
|
const peerId = this.getPayloadString(payload, 'peerId');
|
||||||
|
|
||||||
if (!peerId)
|
if (!peerId)
|
||||||
return;
|
return true;
|
||||||
|
|
||||||
const peerNode = this.ensureClientNetworkNode(state, peerId, timestamp);
|
const peerNode = this.ensureClientNetworkNode(state, peerId, timestamp);
|
||||||
const edge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, peerNode.id, timestamp);
|
const edge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, peerNode.id, timestamp);
|
||||||
|
|
||||||
if (entry.message === 'Data channel open') {
|
if (message === 'Data channel open') {
|
||||||
edge.isActive = true;
|
edge.isActive = true;
|
||||||
edge.stateLabel = 'connected';
|
edge.stateLabel = 'connected';
|
||||||
} else if (entry.message === 'Data channel closed') {
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
edge.isActive = false;
|
edge.isActive = false;
|
||||||
edge.stateLabel = 'closed';
|
edge.stateLabel = message === 'Data channel closed' ? 'closed' : 'error';
|
||||||
|
|
||||||
|
if (message === 'Data channel closed')
|
||||||
peerNode.isSpeaking = false;
|
peerNode.isSpeaking = false;
|
||||||
} else {
|
|
||||||
edge.isActive = false;
|
return true;
|
||||||
edge.stateLabel = 'error';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
private applyDataChannelPayloadMessage(
|
||||||
}
|
state: DebugNetworkBuildState,
|
||||||
|
payload: Record<string, unknown> | null,
|
||||||
if (entry.message !== 'inbound' && entry.message !== 'outbound')
|
direction: DebugNetworkMessageDirection,
|
||||||
return;
|
timestamp: number,
|
||||||
|
count: number
|
||||||
const direction = entry.message as DebugNetworkMessageDirection;
|
): void {
|
||||||
const peerId = this.getPayloadString(payload, 'peerId');
|
const peerId = this.getPayloadString(payload, 'peerId');
|
||||||
|
|
||||||
if (!peerId)
|
if (!peerId)
|
||||||
@@ -386,16 +494,16 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
|
|
||||||
edge.isActive = true;
|
edge.isActive = true;
|
||||||
edge.stateLabel = 'connected';
|
edge.stateLabel = 'connected';
|
||||||
this.recordNetworkMessage(edge, type, direction, 'data-channel', timestamp, entry.count);
|
this.recordNetworkMessage(edge, type, direction, 'data-channel', timestamp, count);
|
||||||
|
|
||||||
if (type === 'chat-message' || type === 'message')
|
if (type === 'chat-message' || type === 'message')
|
||||||
this.incrementTextMessageCount(peerNode, direction, entry.count);
|
this.incrementTextMessageCount(peerNode, direction, count);
|
||||||
|
|
||||||
if (type === 'file-chunk' && direction === 'inbound') {
|
if (type === 'file-chunk' && direction === 'inbound') {
|
||||||
const bytes = this.getPayloadNumber(payload, 'bytes');
|
const bytes = this.getPayloadNumber(payload, 'bytes');
|
||||||
|
|
||||||
if (bytes !== null)
|
if (bytes !== null)
|
||||||
this.recordFileTransferSample(peerNode, timestamp, bytes, entry.count);
|
this.recordFileTransferSample(peerNode, timestamp, bytes, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (direction === 'outbound') {
|
if (direction === 'outbound') {
|
||||||
@@ -412,6 +520,21 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'voice-state') {
|
if (type === 'voice-state') {
|
||||||
|
this.applyVoiceStateDataChannelMessage(state, payload, direction, peerNode, timestamp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'screen-state' || type === 'camera-state')
|
||||||
|
this.applyStreamingStateDataChannelMessage(state, type, payload, direction, peerNode, timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyVoiceStateDataChannelMessage(
|
||||||
|
state: DebugNetworkBuildState,
|
||||||
|
payload: Record<string, unknown> | null,
|
||||||
|
direction: DebugNetworkMessageDirection,
|
||||||
|
peerNode: MutableDebugNetworkNode,
|
||||||
|
timestamp: number
|
||||||
|
): void {
|
||||||
const voiceState = this.getPayloadRecord(payload, 'voiceState');
|
const voiceState = this.getPayloadRecord(payload, 'voiceState');
|
||||||
const subjectNode = direction === 'outbound'
|
const subjectNode = direction === 'outbound'
|
||||||
? this.ensureLocalNetworkNode(
|
? this.ensureLocalNetworkNode(
|
||||||
@@ -426,16 +549,24 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
|
|
||||||
const serverId = this.getStringProperty(voiceState, 'serverId') ?? this.getStringProperty(voiceState, 'roomId');
|
const serverId = this.getStringProperty(voiceState, 'serverId') ?? this.getStringProperty(voiceState, 'roomId');
|
||||||
|
|
||||||
if (serverId) {
|
if (!serverId)
|
||||||
|
return;
|
||||||
|
|
||||||
const serverNode = this.ensureAppServerNode(state, serverId, timestamp);
|
const serverNode = this.ensureAppServerNode(state, serverId, timestamp);
|
||||||
const membershipEdge = this.ensureNetworkEdge(state, 'membership', subjectNode.id, serverNode.id, timestamp);
|
const membershipEdge = this.ensureNetworkEdge(state, 'membership', subjectNode.id, serverNode.id, timestamp);
|
||||||
|
|
||||||
membershipEdge.isActive = true;
|
membershipEdge.isActive = true;
|
||||||
membershipEdge.stateLabel = 'joined';
|
membershipEdge.stateLabel = 'joined';
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'screen-state' || type === 'camera-state') {
|
private applyStreamingStateDataChannelMessage(
|
||||||
|
state: DebugNetworkBuildState,
|
||||||
|
type: 'screen-state' | 'camera-state',
|
||||||
|
payload: Record<string, unknown> | null,
|
||||||
|
direction: DebugNetworkMessageDirection,
|
||||||
|
peerNode: MutableDebugNetworkNode,
|
||||||
|
timestamp: number
|
||||||
|
): void {
|
||||||
const subjectNode = direction === 'outbound'
|
const subjectNode = direction === 'outbound'
|
||||||
? this.ensureLocalNetworkNode(
|
? this.ensureLocalNetworkNode(
|
||||||
state,
|
state,
|
||||||
@@ -448,14 +579,14 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
? this.getPayloadBoolean(payload, 'isScreenSharing')
|
? this.getPayloadBoolean(payload, 'isScreenSharing')
|
||||||
: this.getPayloadBoolean(payload, 'isCameraEnabled');
|
: this.getPayloadBoolean(payload, 'isCameraEnabled');
|
||||||
|
|
||||||
if (isStreaming !== null) {
|
if (isStreaming === null)
|
||||||
|
return;
|
||||||
|
|
||||||
subjectNode.isStreaming = isStreaming;
|
subjectNode.isStreaming = isStreaming;
|
||||||
|
|
||||||
if (!isStreaming)
|
if (!isStreaming)
|
||||||
subjectNode.streams.video = 0;
|
subjectNode.streams.video = 0;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyGenericWebRTCNetworkEntry(state: DebugNetworkBuildState, entry: DebugLogEntry): void {
|
private applyGenericWebRTCNetworkEntry(state: DebugNetworkBuildState, entry: DebugLogEntry): void {
|
||||||
const payload = this.getEntryPayloadRecord(entry.payload);
|
const payload = this.getEntryPayloadRecord(entry.payload);
|
||||||
@@ -471,14 +602,35 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
switch (entry.message) {
|
switch (entry.message) {
|
||||||
case 'Creating peer connection':
|
case 'Creating peer connection':
|
||||||
case 'Received data channel':
|
case 'Received data channel':
|
||||||
|
this.markPeerEdgeConnecting(edge);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'connectionstatechange':
|
||||||
|
this.applyGenericConnectionStateMessage(peerNode, edge, payload);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'Remote stream updated':
|
||||||
|
this.applyGenericRemoteStreamMessage(peerNode, edge, payload);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'Peer transport stats':
|
||||||
|
this.applyGenericTransportStatsMessage(peerNode, edge, payload, timestamp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private markPeerEdgeConnecting(edge: MutableDebugNetworkEdge): void {
|
||||||
edge.isActive = true;
|
edge.isActive = true;
|
||||||
|
|
||||||
if (edge.stateLabel !== 'connected')
|
if (edge.stateLabel !== 'connected')
|
||||||
edge.stateLabel = 'connecting';
|
edge.stateLabel = 'connecting';
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
private applyGenericConnectionStateMessage(
|
||||||
|
peerNode: MutableDebugNetworkNode,
|
||||||
case 'connectionstatechange': {
|
edge: MutableDebugNetworkEdge,
|
||||||
|
payload: Record<string, unknown> | null
|
||||||
|
): void {
|
||||||
const connectionState = this.getStringProperty(payload, 'state');
|
const connectionState = this.getStringProperty(payload, 'state');
|
||||||
|
|
||||||
if (!connectionState)
|
if (!connectionState)
|
||||||
@@ -489,22 +641,26 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
edge.stateLabel = connectionState;
|
edge.stateLabel = connectionState;
|
||||||
edge.isActive = connectionState === 'connected' || connectionState === 'connecting';
|
edge.isActive = connectionState === 'connected' || connectionState === 'connecting';
|
||||||
|
|
||||||
if (!edge.isActive) {
|
if (edge.isActive)
|
||||||
|
return;
|
||||||
|
|
||||||
peerNode.isSpeaking = false;
|
peerNode.isSpeaking = false;
|
||||||
|
|
||||||
if (connectionState === 'disconnected' || connectionState === 'failed') {
|
if (connectionState !== 'disconnected' && connectionState !== 'failed')
|
||||||
|
return;
|
||||||
|
|
||||||
if (previousState !== connectionState)
|
if (previousState !== connectionState)
|
||||||
peerNode.connectionDrops += 1;
|
peerNode.connectionDrops += 1;
|
||||||
|
|
||||||
peerNode.streams.audio = 0;
|
peerNode.streams.audio = 0;
|
||||||
peerNode.streams.video = 0;
|
peerNode.streams.video = 0;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
private applyGenericRemoteStreamMessage(
|
||||||
}
|
peerNode: MutableDebugNetworkNode,
|
||||||
|
edge: MutableDebugNetworkEdge,
|
||||||
case 'Remote stream updated': {
|
payload: Record<string, unknown> | null
|
||||||
|
): void {
|
||||||
const audioTrackCount = this.getNumberProperty(payload, 'audioTrackCount');
|
const audioTrackCount = this.getNumberProperty(payload, 'audioTrackCount');
|
||||||
const videoTrackCount = this.getNumberProperty(payload, 'videoTrackCount');
|
const videoTrackCount = this.getNumberProperty(payload, 'videoTrackCount');
|
||||||
|
|
||||||
@@ -518,11 +674,14 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
|
|
||||||
if (videoTrackCount !== null)
|
if (videoTrackCount !== null)
|
||||||
peerNode.streams.video = Math.max(0, Math.round(videoTrackCount));
|
peerNode.streams.video = Math.max(0, Math.round(videoTrackCount));
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'Peer transport stats': {
|
private applyGenericTransportStatsMessage(
|
||||||
|
peerNode: MutableDebugNetworkNode,
|
||||||
|
edge: MutableDebugNetworkEdge,
|
||||||
|
payload: Record<string, unknown> | null,
|
||||||
|
timestamp: number
|
||||||
|
): void {
|
||||||
edge.isActive = true;
|
edge.isActive = true;
|
||||||
|
|
||||||
if (!edge.stateLabel || edge.stateLabel === 'negotiating')
|
if (!edge.stateLabel || edge.stateLabel === 'negotiating')
|
||||||
@@ -532,10 +691,6 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
audioMbps: this.getNumberProperty(payload, 'audioDownloadMbps'),
|
audioMbps: this.getNumberProperty(payload, 'audioDownloadMbps'),
|
||||||
videoMbps: this.getNumberProperty(payload, 'videoDownloadMbps')
|
videoMbps: this.getNumberProperty(payload, 'videoDownloadMbps')
|
||||||
}, timestamp);
|
}, timestamp);
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyVoiceActivityNetworkEntry(state: DebugNetworkBuildState, entry: DebugLogEntry): void {
|
private applyVoiceActivityNetworkEntry(state: DebugNetworkBuildState, entry: DebugLogEntry): void {
|
||||||
@@ -558,12 +713,31 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
const localVoiceServerId = localUser ? this.getUserVoiceServerId(localUser) : null;
|
const localVoiceServerId = localUser ? this.getUserVoiceServerId(localUser) : null;
|
||||||
const localVoiceConnected = localUser?.voiceState?.isConnected === true;
|
const localVoiceConnected = localUser?.voiceState?.isConnected === true;
|
||||||
|
|
||||||
if (localUser && localIdentity) {
|
this.applyLiveLocalUserState(state, localUser, localIdentity, localVoiceServerId, localVoiceConnected, now);
|
||||||
|
|
||||||
|
for (const user of state.users) {
|
||||||
|
this.applyLiveRemoteUserState(state, user, localVoiceConnected, localVoiceServerId, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyLiveLocalUserState(
|
||||||
|
state: DebugNetworkBuildState,
|
||||||
|
localUser: User | null,
|
||||||
|
localIdentity: string | null,
|
||||||
|
localVoiceServerId: string | null,
|
||||||
|
localVoiceConnected: boolean,
|
||||||
|
now: number
|
||||||
|
): void {
|
||||||
|
if (!localUser || !localIdentity)
|
||||||
|
return;
|
||||||
|
|
||||||
const localNode = this.ensureLocalNetworkNode(state, now, localIdentity, localUser.displayName);
|
const localNode = this.ensureLocalNetworkNode(state, now, localIdentity, localUser.displayName);
|
||||||
|
|
||||||
this.applyUserStateToNetworkNode(localNode, localUser, now);
|
this.applyUserStateToNetworkNode(localNode, localUser, now);
|
||||||
|
|
||||||
if (localVoiceServerId) {
|
if (!localVoiceServerId)
|
||||||
|
return;
|
||||||
|
|
||||||
const serverNode = this.ensureAppServerNode(state, localVoiceServerId, now);
|
const serverNode = this.ensureAppServerNode(state, localVoiceServerId, now);
|
||||||
const membershipEdge = this.ensureNetworkEdge(state, 'membership', localNode.id, serverNode.id, now);
|
const membershipEdge = this.ensureNetworkEdge(state, 'membership', localNode.id, serverNode.id, now);
|
||||||
|
|
||||||
@@ -571,13 +745,18 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
membershipEdge.stateLabel = localVoiceConnected ? 'joined' : 'viewing';
|
membershipEdge.stateLabel = localVoiceConnected ? 'joined' : 'viewing';
|
||||||
membershipEdge.lastSeen = now;
|
membershipEdge.lastSeen = now;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for (const user of state.users) {
|
private applyLiveRemoteUserState(
|
||||||
|
state: DebugNetworkBuildState,
|
||||||
|
user: User,
|
||||||
|
localVoiceConnected: boolean,
|
||||||
|
localVoiceServerId: string | null,
|
||||||
|
now: number
|
||||||
|
): void {
|
||||||
const identity = this.getUserNetworkIdentity(user);
|
const identity = this.getUserNetworkIdentity(user);
|
||||||
|
|
||||||
if (!identity)
|
if (!identity)
|
||||||
continue;
|
return;
|
||||||
|
|
||||||
const node = this.isLocalIdentity(state, identity)
|
const node = this.isLocalIdentity(state, identity)
|
||||||
? this.ensureLocalNetworkNode(state, now, identity, user.displayName)
|
? this.ensureLocalNetworkNode(state, now, identity, user.displayName)
|
||||||
@@ -587,25 +766,19 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
|
|
||||||
const voiceServerId = this.getUserVoiceServerId(user);
|
const voiceServerId = this.getUserVoiceServerId(user);
|
||||||
|
|
||||||
if (voiceServerId) {
|
this.syncLiveUserMembershipEdge(state, node, voiceServerId, user.voiceState?.isConnected === true, now);
|
||||||
const serverNode = this.ensureAppServerNode(state, voiceServerId, now);
|
|
||||||
const membershipEdge = this.ensureNetworkEdge(state, 'membership', node.id, serverNode.id, now);
|
|
||||||
|
|
||||||
membershipEdge.isActive = user.voiceState?.isConnected === true;
|
|
||||||
membershipEdge.stateLabel = membershipEdge.isActive ? 'joined' : 'inactive';
|
|
||||||
|
|
||||||
if (membershipEdge.isActive)
|
|
||||||
membershipEdge.lastSeen = now;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!this.isLocalIdentity(state, identity)
|
this.isLocalIdentity(state, identity)
|
||||||
&& localVoiceConnected
|
|| !localVoiceConnected
|
||||||
&& user.voiceState?.isConnected === true
|
|| user.voiceState?.isConnected !== true
|
||||||
&& voiceServerId
|
|| !voiceServerId
|
||||||
&& localVoiceServerId
|
|| !localVoiceServerId
|
||||||
&& voiceServerId === localVoiceServerId
|
|| voiceServerId !== localVoiceServerId
|
||||||
) {
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const peerEdge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, node.id, now);
|
const peerEdge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, node.id, now);
|
||||||
|
|
||||||
peerEdge.isActive = true;
|
peerEdge.isActive = true;
|
||||||
@@ -615,7 +788,25 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
if (node.pingMs !== null)
|
if (node.pingMs !== null)
|
||||||
peerEdge.pingMs = node.pingMs;
|
peerEdge.pingMs = node.pingMs;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private syncLiveUserMembershipEdge(
|
||||||
|
state: DebugNetworkBuildState,
|
||||||
|
node: MutableDebugNetworkNode,
|
||||||
|
voiceServerId: string | null,
|
||||||
|
isVoiceConnected: boolean,
|
||||||
|
now: number
|
||||||
|
): void {
|
||||||
|
if (!voiceServerId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const serverNode = this.ensureAppServerNode(state, voiceServerId, now);
|
||||||
|
const membershipEdge = this.ensureNetworkEdge(state, 'membership', node.id, serverNode.id, now);
|
||||||
|
|
||||||
|
membershipEdge.isActive = isVoiceConnected;
|
||||||
|
membershipEdge.stateLabel = isVoiceConnected ? 'joined' : 'inactive';
|
||||||
|
|
||||||
|
if (isVoiceConnected)
|
||||||
|
membershipEdge.lastSeen = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyUserStateToNetworkNode(node: MutableDebugNetworkNode, user: User, now: number): void {
|
private applyUserStateToNetworkNode(node: MutableDebugNetworkNode, user: User, now: number): void {
|
||||||
@@ -700,6 +891,7 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
node.label = currentRoom?.id === serverId
|
node.label = currentRoom?.id === serverId
|
||||||
? currentRoom.name
|
? currentRoom.name
|
||||||
: (node.label || `Server ${this.shortenIdentifier(serverId)}`);
|
: (node.label || `Server ${this.shortenIdentifier(serverId)}`);
|
||||||
|
|
||||||
node.secondaryLabel = serverId;
|
node.secondaryLabel = serverId;
|
||||||
node.title = node.label;
|
node.title = node.label;
|
||||||
|
|
||||||
@@ -898,6 +1090,7 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
bytes: bytes * count,
|
bytes: bytes * count,
|
||||||
timestamp
|
timestamp
|
||||||
});
|
});
|
||||||
|
|
||||||
node.fileTransferSamples = node.fileTransferSamples.filter(
|
node.fileTransferSamples = node.fileTransferSamples.filter(
|
||||||
(sample) => timestamp - sample.timestamp <= NETWORK_FILE_TRANSFER_RATE_WINDOW_MS
|
(sample) => timestamp - sample.timestamp <= NETWORK_FILE_TRANSFER_RATE_WINDOW_MS
|
||||||
);
|
);
|
||||||
@@ -973,22 +1166,90 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
this.applyLivePeerMetricsToNetworkNode(node, node.identity, now);
|
this.applyLivePeerMetricsToNetworkNode(node, node.identity, now);
|
||||||
|
|
||||||
const isTyping = node.typingExpiresAt !== null && node.typingExpiresAt > now;
|
const isTyping = node.typingExpiresAt !== null && node.typingExpiresAt > now;
|
||||||
const user = node.identity ? state.userLookup.get(node.identity) : null;
|
const user = node.identity ? state.userLookup.get(node.identity) : undefined;
|
||||||
const userId = node.userId || user?.id || null;
|
const presentation = this.buildFinalizedNodePresentation(node, state, user, hasActiveEdge);
|
||||||
const label = node.kind === 'local-client'
|
const streams = this.buildFinalizedNodeStreams(node);
|
||||||
? (state.currentUser?.displayName || state.currentUser?.username || node.label || 'You')
|
const downloads = this.getFreshDownloadStats(node, now);
|
||||||
: (user?.displayName || user?.username || node.label);
|
const statuses = this.buildFinalizedNodeStatuses(node, streams, isTyping, hasActiveEdge);
|
||||||
const secondaryLabel = node.kind === 'local-client'
|
|
||||||
? (state.currentUser?.username ? `@${state.currentUser.username}` : 'You')
|
return {
|
||||||
: (user?.username ? `@${user.username}` : node.secondaryLabel);
|
id: node.id,
|
||||||
const title = node.kind === 'local-client'
|
identity: node.identity,
|
||||||
? 'Current client'
|
userId: presentation.userId,
|
||||||
: node.title;
|
kind: node.kind,
|
||||||
const streams = {
|
label: presentation.label,
|
||||||
|
secondaryLabel: presentation.secondaryLabel,
|
||||||
|
title: presentation.title,
|
||||||
|
statuses,
|
||||||
|
isActive: presentation.isActive,
|
||||||
|
isVoiceConnected: node.isVoiceConnected,
|
||||||
|
isTyping,
|
||||||
|
isSpeaking: node.isSpeaking,
|
||||||
|
isMuted: node.isMuted,
|
||||||
|
isDeafened: node.isDeafened,
|
||||||
|
isStreaming: node.isStreaming,
|
||||||
|
connectionDrops: node.connectionDrops,
|
||||||
|
downloads,
|
||||||
|
handshake: { ...node.handshake },
|
||||||
|
pingMs: node.pingMs,
|
||||||
|
streams,
|
||||||
|
textMessages: { ...node.textMessages },
|
||||||
|
lastSeen: node.lastSeen
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildFinalizedNodePresentation(
|
||||||
|
node: MutableDebugNetworkNode,
|
||||||
|
state: DebugNetworkBuildState,
|
||||||
|
user: User | undefined,
|
||||||
|
hasActiveEdge: boolean
|
||||||
|
): FinalizedNodePresentation {
|
||||||
|
if (node.kind === 'local-client')
|
||||||
|
return this.buildLocalNodePresentation(node, state);
|
||||||
|
|
||||||
|
return this.buildRemoteNodePresentation(node, user, hasActiveEdge);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildLocalNodePresentation(
|
||||||
|
node: MutableDebugNetworkNode,
|
||||||
|
state: DebugNetworkBuildState
|
||||||
|
): FinalizedNodePresentation {
|
||||||
|
return {
|
||||||
|
userId: node.userId || state.currentUser?.id || null,
|
||||||
|
label: state.currentUser?.displayName || state.currentUser?.username || node.label || 'You',
|
||||||
|
secondaryLabel: state.currentUser?.username ? `@${state.currentUser.username}` : 'You',
|
||||||
|
title: 'Current client',
|
||||||
|
isActive: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRemoteNodePresentation(
|
||||||
|
node: MutableDebugNetworkNode,
|
||||||
|
user: User | undefined,
|
||||||
|
hasActiveEdge: boolean
|
||||||
|
): FinalizedNodePresentation {
|
||||||
|
return {
|
||||||
|
userId: node.userId || user?.id || null,
|
||||||
|
label: user?.displayName || user?.username || node.label,
|
||||||
|
secondaryLabel: user?.username ? `@${user.username}` : node.secondaryLabel,
|
||||||
|
title: node.title,
|
||||||
|
isActive: hasActiveEdge || node.isActive
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildFinalizedNodeStreams(node: MutableDebugNetworkNode): { audio: number; video: number } {
|
||||||
|
return {
|
||||||
audio: node.streams.audio > 0 ? node.streams.audio : (node.isVoiceConnected ? 1 : 0),
|
audio: node.streams.audio > 0 ? node.streams.audio : (node.isVoiceConnected ? 1 : 0),
|
||||||
video: node.streams.video > 0 ? node.streams.video : (node.isStreaming ? 1 : 0)
|
video: node.streams.video > 0 ? node.streams.video : (node.isStreaming ? 1 : 0)
|
||||||
};
|
};
|
||||||
const downloads = this.getFreshDownloadStats(node, now);
|
}
|
||||||
|
|
||||||
|
private buildFinalizedNodeStatuses(
|
||||||
|
node: MutableDebugNetworkNode,
|
||||||
|
streams: { audio: number; video: number },
|
||||||
|
isTyping: boolean,
|
||||||
|
hasActiveEdge: boolean
|
||||||
|
): string[] {
|
||||||
const statuses: string[] = [];
|
const statuses: string[] = [];
|
||||||
|
|
||||||
if (node.kind === 'local-client' || node.kind === 'remote-client') {
|
if (node.kind === 'local-client' || node.kind === 'remote-client') {
|
||||||
@@ -1018,36 +1279,19 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
|
|
||||||
if (node.connectionDrops > 0)
|
if (node.connectionDrops > 0)
|
||||||
statuses.push(this.buildStreamStatus(node.connectionDrops, 'drop'));
|
statuses.push(this.buildStreamStatus(node.connectionDrops, 'drop'));
|
||||||
} else if (node.kind === 'signaling-server') {
|
|
||||||
statuses.push(hasActiveEdge ? 'connected' : 'idle');
|
return statuses;
|
||||||
} else if (hasActiveEdge) {
|
|
||||||
statuses.push('active');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
if (node.kind === 'signaling-server') {
|
||||||
id: node.id,
|
statuses.push(hasActiveEdge ? 'connected' : 'idle');
|
||||||
identity: node.identity,
|
return statuses;
|
||||||
userId,
|
}
|
||||||
kind: node.kind,
|
|
||||||
label,
|
if (hasActiveEdge)
|
||||||
secondaryLabel,
|
statuses.push('active');
|
||||||
title,
|
|
||||||
statuses,
|
return statuses;
|
||||||
isActive: node.kind === 'local-client' || hasActiveEdge || node.isActive,
|
|
||||||
isVoiceConnected: node.isVoiceConnected,
|
|
||||||
isTyping,
|
|
||||||
isSpeaking: node.isSpeaking,
|
|
||||||
isMuted: node.isMuted,
|
|
||||||
isDeafened: node.isDeafened,
|
|
||||||
isStreaming: node.isStreaming,
|
|
||||||
connectionDrops: node.connectionDrops,
|
|
||||||
downloads,
|
|
||||||
handshake: { ...node.handshake },
|
|
||||||
pingMs: node.pingMs,
|
|
||||||
streams,
|
|
||||||
textMessages: { ...node.textMessages },
|
|
||||||
lastSeen: node.lastSeen
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyLivePeerMetricsToNetworkNode(
|
private applyLivePeerMetricsToNetworkNode(
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export * from './notification-audio.service';
|
|||||||
export * from '../models/debugging.models';
|
export * from '../models/debugging.models';
|
||||||
export * from './debugging/debugging.service';
|
export * from './debugging/debugging.service';
|
||||||
export * from './settings-modal.service';
|
export * from './settings-modal.service';
|
||||||
|
export * from './user-status.service';
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ export class NotificationAudioService {
|
|||||||
/** Reactive notification volume (0 - 1), persisted to localStorage. */
|
/** Reactive notification volume (0 - 1), persisted to localStorage. */
|
||||||
readonly notificationVolume = signal(this.loadVolume());
|
readonly notificationVolume = signal(this.loadVolume());
|
||||||
|
|
||||||
|
/** When true, all sound playback is suppressed (Do Not Disturb). */
|
||||||
|
readonly dndMuted = signal(false);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.preload();
|
this.preload();
|
||||||
}
|
}
|
||||||
@@ -106,6 +109,9 @@ export class NotificationAudioService {
|
|||||||
* the persisted {@link notificationVolume} is used.
|
* the persisted {@link notificationVolume} is used.
|
||||||
*/
|
*/
|
||||||
play(sound: AppSound, volumeOverride?: number): void {
|
play(sound: AppSound, volumeOverride?: number): void {
|
||||||
|
if (this.dndMuted())
|
||||||
|
return;
|
||||||
|
|
||||||
const cached = this.cache.get(sound);
|
const cached = this.cache.get(sound);
|
||||||
const src = this.sources.get(sound);
|
const src = this.sources.get(sound);
|
||||||
|
|
||||||
|
|||||||
181
toju-app/src/app/core/services/user-status.service.ts
Normal file
181
toju-app/src/app/core/services/user-status.service.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
OnDestroy,
|
||||||
|
NgZone,
|
||||||
|
inject
|
||||||
|
} from '@angular/core';
|
||||||
|
import { Store } from '@ngrx/store';
|
||||||
|
import { UsersActions } from '../../store/users/users.actions';
|
||||||
|
import { selectManualStatus, selectCurrentUser } from '../../store/users/users.selectors';
|
||||||
|
import { RealtimeSessionFacade } from '../realtime';
|
||||||
|
import { NotificationAudioService } from './notification-audio.service';
|
||||||
|
import { UserStatus } from '../../shared-kernel';
|
||||||
|
|
||||||
|
const BROWSER_IDLE_POLL_MS = 10_000;
|
||||||
|
const BROWSER_IDLE_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes
|
||||||
|
|
||||||
|
interface ElectronIdleApi {
|
||||||
|
onIdleStateChanged: (listener: (state: 'active' | 'idle') => void) => () => void;
|
||||||
|
getIdleState: () => Promise<'active' | 'idle'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type IdleAwareWindow = Window & {
|
||||||
|
electronAPI?: ElectronIdleApi;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestrates user status based on idle detection (Electron powerMonitor
|
||||||
|
* or browser-fallback) and manual overrides (e.g. Do Not Disturb).
|
||||||
|
*
|
||||||
|
* Manual status always takes priority over automatic idle detection.
|
||||||
|
* When manual status is cleared, the service falls back to automatic.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class UserStatusService implements OnDestroy {
|
||||||
|
private store = inject(Store);
|
||||||
|
private zone = inject(NgZone);
|
||||||
|
private webrtc = inject(RealtimeSessionFacade);
|
||||||
|
private audio = inject(NotificationAudioService);
|
||||||
|
private readonly manualStatus = this.store.selectSignal(selectManualStatus);
|
||||||
|
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
|
private electronCleanup: (() => void) | null = null;
|
||||||
|
private browserPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private lastActivityTimestamp = Date.now();
|
||||||
|
private browserActivityListeners: (() => void)[] = [];
|
||||||
|
private currentAutoStatus: UserStatus = 'online';
|
||||||
|
private started = false;
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.started)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.started = true;
|
||||||
|
|
||||||
|
if (this.getElectronIdleApi()?.onIdleStateChanged) {
|
||||||
|
this.startElectronIdleDetection();
|
||||||
|
} else {
|
||||||
|
this.startBrowserIdleDetection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set a manual status override (e.g. DND = 'busy'). Pass `null` to clear. */
|
||||||
|
setManualStatus(status: UserStatus | null): void {
|
||||||
|
this.store.dispatch(UsersActions.setManualStatus({ status }));
|
||||||
|
this.audio.dndMuted.set(status === 'busy');
|
||||||
|
this.broadcastStatus(this.resolveEffectiveStatus(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanup(): void {
|
||||||
|
this.electronCleanup?.();
|
||||||
|
this.electronCleanup = null;
|
||||||
|
|
||||||
|
if (this.browserPollTimer) {
|
||||||
|
clearInterval(this.browserPollTimer);
|
||||||
|
this.browserPollTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const remove of this.browserActivityListeners) {
|
||||||
|
remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.browserActivityListeners = [];
|
||||||
|
this.started = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private startElectronIdleDetection(): void {
|
||||||
|
const api = this.getElectronIdleApi();
|
||||||
|
|
||||||
|
if (!api)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.electronCleanup = api.onIdleStateChanged((idleState: 'active' | 'idle') => {
|
||||||
|
this.zone.run(() => {
|
||||||
|
this.currentAutoStatus = idleState === 'idle' ? 'away' : 'online';
|
||||||
|
this.applyAutoStatusIfAllowed();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check initial state
|
||||||
|
api.getIdleState().then((idleState: 'active' | 'idle') => {
|
||||||
|
this.zone.run(() => {
|
||||||
|
this.currentAutoStatus = idleState === 'idle' ? 'away' : 'online';
|
||||||
|
this.applyAutoStatusIfAllowed();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private startBrowserIdleDetection(): void {
|
||||||
|
this.lastActivityTimestamp = Date.now();
|
||||||
|
|
||||||
|
const onActivity = () => {
|
||||||
|
this.lastActivityTimestamp = Date.now();
|
||||||
|
const wasAway = this.currentAutoStatus === 'away';
|
||||||
|
|
||||||
|
if (wasAway) {
|
||||||
|
this.currentAutoStatus = 'online';
|
||||||
|
this.zone.run(() => this.applyAutoStatusIfAllowed());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const events = [
|
||||||
|
'mousemove',
|
||||||
|
'keydown',
|
||||||
|
'mousedown',
|
||||||
|
'touchstart',
|
||||||
|
'scroll'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const evt of events) {
|
||||||
|
document.addEventListener(evt, onActivity, { passive: true });
|
||||||
|
this.browserActivityListeners.push(() =>
|
||||||
|
document.removeEventListener(evt, onActivity)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.zone.runOutsideAngular(() => {
|
||||||
|
this.browserPollTimer = setInterval(() => {
|
||||||
|
const idle = Date.now() - this.lastActivityTimestamp >= BROWSER_IDLE_THRESHOLD_MS;
|
||||||
|
|
||||||
|
if (idle && this.currentAutoStatus !== 'away') {
|
||||||
|
this.currentAutoStatus = 'away';
|
||||||
|
this.zone.run(() => this.applyAutoStatusIfAllowed());
|
||||||
|
}
|
||||||
|
}, BROWSER_IDLE_POLL_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyAutoStatusIfAllowed(): void {
|
||||||
|
const manualStatus = this.manualStatus();
|
||||||
|
|
||||||
|
// Manual status overrides automatic
|
||||||
|
if (manualStatus)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const currentUser = this.currentUser();
|
||||||
|
|
||||||
|
if (currentUser?.status !== this.currentAutoStatus) {
|
||||||
|
this.store.dispatch(UsersActions.setManualStatus({ status: null }));
|
||||||
|
this.store.dispatch(UsersActions.updateCurrentUser({ updates: { status: this.currentAutoStatus } }));
|
||||||
|
this.broadcastStatus(this.currentAutoStatus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveEffectiveStatus(manualStatus: UserStatus | null): UserStatus {
|
||||||
|
return manualStatus ?? this.currentAutoStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcastStatus(status: UserStatus): void {
|
||||||
|
this.webrtc.sendRawMessage({
|
||||||
|
type: 'status_update',
|
||||||
|
status
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getElectronIdleApi(): ElectronIdleApi | undefined {
|
||||||
|
return (window as IdleAwareWindow).electronAPI;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ infrastructure adapters and UI.
|
|||||||
| **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` |
|
| **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` |
|
||||||
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
|
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
|
||||||
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
|
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
|
||||||
|
| **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` |
|
||||||
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
|
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
|
||||||
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
|
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
|
||||||
| **theme** | JSON-driven theming, element registry, layout syncing, picker tooling, and Electron saved-theme library management | `ThemeService` |
|
| **theme** | JSON-driven theming, element registry, layout syncing, picker tooling, and Electron saved-theme library management | `ThemeService` |
|
||||||
@@ -28,6 +29,7 @@ The larger domains also keep longer design notes in their own folders:
|
|||||||
- [authentication/README.md](authentication/README.md)
|
- [authentication/README.md](authentication/README.md)
|
||||||
- [chat/README.md](chat/README.md)
|
- [chat/README.md](chat/README.md)
|
||||||
- [notifications/README.md](notifications/README.md)
|
- [notifications/README.md](notifications/README.md)
|
||||||
|
- [profile-avatar/README.md](profile-avatar/README.md)
|
||||||
- [screen-share/README.md](screen-share/README.md)
|
- [screen-share/README.md](screen-share/README.md)
|
||||||
- [server-directory/README.md](server-directory/README.md)
|
- [server-directory/README.md](server-directory/README.md)
|
||||||
- [voice-connection/README.md](voice-connection/README.md)
|
- [voice-connection/README.md](voice-connection/README.md)
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { RealtimeSessionFacade } from '../../../../core/realtime';
|
|||||||
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
|
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
|
||||||
import { FILE_CHUNK_SIZE_BYTES } from '../../domain/constants/attachment-transfer.constants';
|
import { FILE_CHUNK_SIZE_BYTES } from '../../domain/constants/attachment-transfer.constants';
|
||||||
import { FileChunkEvent } from '../../domain/models/attachment-transfer.model';
|
import { FileChunkEvent } from '../../domain/models/attachment-transfer.model';
|
||||||
|
import {
|
||||||
|
arrayBufferToBase64,
|
||||||
|
decodeBase64,
|
||||||
|
iterateBlobChunks
|
||||||
|
} from '../../../../shared-kernel';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AttachmentTransferTransportService {
|
export class AttachmentTransferTransportService {
|
||||||
@@ -10,14 +15,7 @@ export class AttachmentTransferTransportService {
|
|||||||
private readonly attachmentStorage = inject(AttachmentStorageService);
|
private readonly attachmentStorage = inject(AttachmentStorageService);
|
||||||
|
|
||||||
decodeBase64(base64: string): Uint8Array {
|
decodeBase64(base64: string): Uint8Array {
|
||||||
const binary = atob(base64);
|
return decodeBase64(base64);
|
||||||
const bytes = new Uint8Array(binary.length);
|
|
||||||
|
|
||||||
for (let index = 0; index < binary.length; index++) {
|
|
||||||
bytes[index] = binary.charCodeAt(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
return bytes;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async streamFileToPeer(
|
async streamFileToPeer(
|
||||||
@@ -27,31 +25,20 @@ export class AttachmentTransferTransportService {
|
|||||||
file: File,
|
file: File,
|
||||||
isCancelled: () => boolean
|
isCancelled: () => boolean
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE_BYTES);
|
for await (const chunk of iterateBlobChunks(file, FILE_CHUNK_SIZE_BYTES)) {
|
||||||
|
|
||||||
let offset = 0;
|
|
||||||
let chunkIndex = 0;
|
|
||||||
|
|
||||||
while (offset < file.size) {
|
|
||||||
if (isCancelled())
|
if (isCancelled())
|
||||||
break;
|
break;
|
||||||
|
|
||||||
const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES);
|
|
||||||
const arrayBuffer = await slice.arrayBuffer();
|
|
||||||
const base64 = this.arrayBufferToBase64(arrayBuffer);
|
|
||||||
const fileChunkEvent: FileChunkEvent = {
|
const fileChunkEvent: FileChunkEvent = {
|
||||||
type: 'file-chunk',
|
type: 'file-chunk',
|
||||||
messageId,
|
messageId,
|
||||||
fileId,
|
fileId,
|
||||||
index: chunkIndex,
|
index: chunk.index,
|
||||||
total: totalChunks,
|
total: chunk.total,
|
||||||
data: base64
|
data: chunk.base64
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
|
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
|
||||||
|
|
||||||
offset += FILE_CHUNK_SIZE_BYTES;
|
|
||||||
chunkIndex++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +54,7 @@ export class AttachmentTransferTransportService {
|
|||||||
if (!base64Full)
|
if (!base64Full)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const fileBytes = this.decodeBase64(base64Full);
|
const fileBytes = decodeBase64(base64Full);
|
||||||
const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES);
|
const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES);
|
||||||
|
|
||||||
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
||||||
@@ -81,7 +68,7 @@ export class AttachmentTransferTransportService {
|
|||||||
slice.byteOffset,
|
slice.byteOffset,
|
||||||
slice.byteOffset + slice.byteLength
|
slice.byteOffset + slice.byteLength
|
||||||
);
|
);
|
||||||
const base64Chunk = this.arrayBufferToBase64(sliceBuffer);
|
const base64Chunk = arrayBufferToBase64(sliceBuffer);
|
||||||
const fileChunkEvent: FileChunkEvent = {
|
const fileChunkEvent: FileChunkEvent = {
|
||||||
type: 'file-chunk',
|
type: 'file-chunk',
|
||||||
messageId,
|
messageId,
|
||||||
@@ -94,16 +81,4 @@ export class AttachmentTransferTransportService {
|
|||||||
this.webrtc.sendToPeer(targetPeerId, fileChunkEvent);
|
this.webrtc.sendToPeer(targetPeerId, fileChunkEvent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private arrayBufferToBase64(buffer: ArrayBuffer): string {
|
|
||||||
let binary = '';
|
|
||||||
|
|
||||||
const bytes = new Uint8Array(buffer);
|
|
||||||
|
|
||||||
for (let index = 0; index < bytes.byteLength; index++) {
|
|
||||||
binary += String.fromCharCode(bytes[index]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return btoa(binary);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
/** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */
|
export { P2P_BASE64_CHUNK_SIZE_BYTES as FILE_CHUNK_SIZE_BYTES } from '../../../../shared-kernel/p2p-transfer.constants';
|
||||||
export const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EWMA smoothing weight for the previous speed estimate.
|
* EWMA smoothing weight for the previous speed estimate.
|
||||||
|
|||||||
@@ -1,35 +1,45 @@
|
|||||||
<div class="h-10 border-b border-border bg-card flex items-center justify-end px-3 gap-2">
|
<div class="w-full border-t border-border bg-card/50 px-1 py-2">
|
||||||
<div class="flex-1"></div>
|
|
||||||
@if (user()) {
|
@if (user()) {
|
||||||
<div class="flex items-center gap-2 text-sm">
|
<div class="flex flex-col items-center gap-1 text-xs">
|
||||||
<ng-icon
|
<button
|
||||||
name="lucideUser"
|
#avatarBtn
|
||||||
class="w-4 h-4 text-muted-foreground"
|
type="button"
|
||||||
|
class="rounded-full transition-opacity hover:opacity-90"
|
||||||
|
(click)="toggleProfileCard(avatarBtn)"
|
||||||
|
>
|
||||||
|
<app-user-avatar
|
||||||
|
[name]="user()!.displayName"
|
||||||
|
[avatarUrl]="user()!.avatarUrl"
|
||||||
|
size="sm"
|
||||||
|
[status]="user()!.status"
|
||||||
|
[showStatusBadge]="true"
|
||||||
/>
|
/>
|
||||||
<span class="text-foreground">{{ user()?.displayName }}</span>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
|
<div class="flex flex-col items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="goto('login')"
|
(click)="goto('login')"
|
||||||
class="px-2 py-1 text-sm rounded bg-secondary hover:bg-secondary/80 flex items-center gap-1"
|
class="w-full px-1 py-1 text-[10px] rounded bg-secondary hover:bg-secondary/80 flex items-center justify-center gap-1"
|
||||||
>
|
>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideLogIn"
|
name="lucideLogIn"
|
||||||
class="w-4 h-4"
|
class="w-3 h-3"
|
||||||
/>
|
/>
|
||||||
Login
|
Login
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="goto('register')"
|
(click)="goto('register')"
|
||||||
class="px-2 py-1 text-sm rounded bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-1"
|
class="w-full px-1 py-1 text-[10px] rounded bg-primary text-primary-foreground hover:bg-primary/90 flex items-center justify-center gap-1"
|
||||||
>
|
>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideUserPlus"
|
name="lucideUserPlus"
|
||||||
class="w-4 h-4"
|
class="w-3 h-3"
|
||||||
/>
|
/>
|
||||||
Register
|
Register
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,19 +3,17 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import {
|
import { lucideLogIn, lucideUserPlus } from '@ng-icons/lucide';
|
||||||
lucideUser,
|
|
||||||
lucideLogIn,
|
|
||||||
lucideUserPlus
|
|
||||||
} from '@ng-icons/lucide';
|
|
||||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||||
|
import { ProfileCardService } from '../../../../shared/components/profile-card/profile-card.service';
|
||||||
|
import { UserAvatarComponent } from '../../../../shared';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-user-bar',
|
selector: 'app-user-bar',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, NgIcon],
|
imports: [CommonModule, NgIcon, UserAvatarComponent],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({ lucideUser,
|
provideIcons({
|
||||||
lucideLogIn,
|
lucideLogIn,
|
||||||
lucideUserPlus })
|
lucideUserPlus })
|
||||||
],
|
],
|
||||||
@@ -29,6 +27,16 @@ export class UserBarComponent {
|
|||||||
user = this.store.selectSignal(selectCurrentUser);
|
user = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
private profileCard = inject(ProfileCardService);
|
||||||
|
|
||||||
|
toggleProfileCard(origin: HTMLElement): void {
|
||||||
|
const user = this.user();
|
||||||
|
|
||||||
|
if (!user)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.profileCard.open(origin, user, { placement: 'above', editable: true });
|
||||||
|
}
|
||||||
|
|
||||||
/** Navigate to the specified authentication page. */
|
/** Navigate to the specified authentication page. */
|
||||||
goto(path: 'login' | 'register') {
|
goto(path: 'login' | 'register') {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export class LinkMetadataService {
|
|||||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||||
|
|
||||||
extractUrls(content: string): string[] {
|
extractUrls(content: string): string[] {
|
||||||
return [...content.matchAll(URL_PATTERN)].map((m) => m[0]);
|
return [...content.matchAll(URL_PATTERN)].map((match) => match[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchMetadata(url: string): Promise<LinkMetadata> {
|
async fetchMetadata(url: string): Promise<LinkMetadata> {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { lucideX } from '@ng-icons/lucide';
|
import { lucideX } from '@ng-icons/lucide';
|
||||||
import { LinkMetadata } from '../../../../../../shared-kernel';
|
import { LinkMetadata } from '../../../../../../../shared-kernel';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-chat-link-embed',
|
selector: 'app-chat-link-embed',
|
||||||
@@ -5,12 +5,17 @@
|
|||||||
[attr.data-message-id]="msg.id"
|
[attr.data-message-id]="msg.id"
|
||||||
class="group relative flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30"
|
class="group relative flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30"
|
||||||
[class.opacity-50]="msg.isDeleted"
|
[class.opacity-50]="msg.isDeleted"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 cursor-pointer"
|
||||||
|
(click)="openSenderProfileCard($event); $event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<app-user-avatar
|
<app-user-avatar
|
||||||
[name]="msg.senderName"
|
[name]="senderUser().displayName || msg.senderName"
|
||||||
|
[avatarUrl]="senderUser().avatarUrl"
|
||||||
size="md"
|
size="md"
|
||||||
class="flex-shrink-0"
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
@if (msg.replyToId) {
|
@if (msg.replyToId) {
|
||||||
@@ -34,7 +39,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="flex items-baseline gap-2">
|
<div class="flex items-baseline gap-2">
|
||||||
<span class="font-semibold text-foreground">{{ msg.senderName }}</span>
|
<span
|
||||||
|
class="font-semibold text-foreground cursor-pointer hover:underline"
|
||||||
|
(click)="openSenderProfileCard($event); $event.stopPropagation()"
|
||||||
|
>{{ msg.senderName }}</span
|
||||||
|
>
|
||||||
<span class="text-xs text-muted-foreground">{{ formatTimestamp(msg.timestamp) }}</span>
|
<span class="text-xs text-muted-foreground">{{ formatTimestamp(msg.timestamp) }}</span>
|
||||||
@if (msg.editedAt && !msg.isDeleted) {
|
@if (msg.editedAt && !msg.isDeleted) {
|
||||||
<span class="text-xs text-muted-foreground">(edited)</span>
|
<span class="text-xs text-muted-foreground">(edited)</span>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
signal,
|
signal,
|
||||||
ViewChild
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import {
|
import {
|
||||||
lucideCheck,
|
lucideCheck,
|
||||||
@@ -30,14 +31,20 @@ 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 { DELETED_MESSAGE_CONTENT, Message } from '../../../../../../shared-kernel';
|
import {
|
||||||
|
DELETED_MESSAGE_CONTENT,
|
||||||
|
Message,
|
||||||
|
User
|
||||||
|
} from '../../../../../../shared-kernel';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ChatAudioPlayerComponent,
|
ChatAudioPlayerComponent,
|
||||||
ChatVideoPlayerComponent,
|
ChatVideoPlayerComponent,
|
||||||
|
ProfileCardService,
|
||||||
UserAvatarComponent
|
UserAvatarComponent
|
||||||
} from '../../../../../../shared';
|
} from '../../../../../../shared';
|
||||||
import { ChatMessageMarkdownComponent } from './chat-message-markdown.component';
|
import { ChatMessageMarkdownComponent } from './chat-message-markdown/chat-message-markdown.component';
|
||||||
import { ChatLinkEmbedComponent } from './chat-link-embed.component';
|
import { ChatLinkEmbedComponent } from './chat-link-embed/chat-link-embed.component';
|
||||||
import {
|
import {
|
||||||
ChatMessageDeleteEvent,
|
ChatMessageDeleteEvent,
|
||||||
ChatMessageEditEvent,
|
ChatMessageEditEvent,
|
||||||
@@ -114,12 +121,14 @@ export class ChatMessageItemComponent {
|
|||||||
|
|
||||||
private readonly attachmentsSvc = inject(AttachmentFacade);
|
private readonly attachmentsSvc = inject(AttachmentFacade);
|
||||||
private readonly klipy = inject(KlipyService);
|
private readonly klipy = inject(KlipyService);
|
||||||
|
private readonly profileCard = inject(ProfileCardService);
|
||||||
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
|
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
|
||||||
|
|
||||||
readonly message = input.required<Message>();
|
readonly message = input.required<Message>();
|
||||||
readonly repliedMessage = input<Message | undefined>();
|
readonly repliedMessage = input<Message | undefined>();
|
||||||
readonly currentUserId = input<string | null>(null);
|
readonly currentUserId = input<string | null>(null);
|
||||||
readonly isAdmin = input(false);
|
readonly isAdmin = input(false);
|
||||||
|
readonly userLookup = input<ReadonlyMap<string, User>>(new Map());
|
||||||
|
|
||||||
readonly replyRequested = output<ChatMessageReplyEvent>();
|
readonly replyRequested = output<ChatMessageReplyEvent>();
|
||||||
readonly deleteRequested = output<ChatMessageDeleteEvent>();
|
readonly deleteRequested = output<ChatMessageDeleteEvent>();
|
||||||
@@ -136,9 +145,32 @@ export class ChatMessageItemComponent {
|
|||||||
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
|
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
|
||||||
readonly isEditing = signal(false);
|
readonly isEditing = signal(false);
|
||||||
readonly showEmojiPicker = signal(false);
|
readonly showEmojiPicker = signal(false);
|
||||||
|
readonly senderUser = computed<User>(() => {
|
||||||
|
const msg = this.message();
|
||||||
|
const found = this.userLookup().get(msg.senderId);
|
||||||
|
|
||||||
|
return found ?? {
|
||||||
|
id: msg.senderId,
|
||||||
|
oderId: msg.senderId,
|
||||||
|
username: msg.senderName,
|
||||||
|
displayName: msg.senderName,
|
||||||
|
status: 'disconnected',
|
||||||
|
role: 'member',
|
||||||
|
joinedAt: 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
editContent = '';
|
editContent = '';
|
||||||
|
|
||||||
|
openSenderProfileCard(event: MouseEvent): void {
|
||||||
|
event.stopPropagation();
|
||||||
|
const el = event.currentTarget as HTMLElement;
|
||||||
|
const user = this.senderUser();
|
||||||
|
const editable = user.id === this.currentUserId();
|
||||||
|
|
||||||
|
this.profileCard.open(el, user, { editable });
|
||||||
|
}
|
||||||
|
|
||||||
readonly attachmentViewModels = computed<ChatMessageAttachmentViewModel[]>(() => {
|
readonly attachmentViewModels = computed<ChatMessageAttachmentViewModel[]>(() => {
|
||||||
void this.attachmentVersion();
|
void this.attachmentVersion();
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ 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 { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
|
import { ChatImageProxyFallbackDirective } from '../../../../chat-image-proxy-fallback.directive';
|
||||||
import { ChatYoutubeEmbedComponent, isYoutubeUrl } from './chat-youtube-embed.component';
|
import { ChatYoutubeEmbedComponent, isYoutubeUrl } 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',
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
@if (videoId()) {
|
||||||
|
<div class="mt-2 w-[480px] max-w-full overflow-hidden rounded-md border border-border/60">
|
||||||
|
<iframe
|
||||||
|
[src]="embedUrl()"
|
||||||
|
class="aspect-video w-full"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowfullscreen
|
||||||
|
loading="lazy"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
import { Component, computed, input } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
input
|
||||||
|
} from '@angular/core';
|
||||||
import { DomSanitizer } from '@angular/platform-browser';
|
import { DomSanitizer } from '@angular/platform-browser';
|
||||||
|
|
||||||
const YOUTUBE_URL_PATTERN = /(?:youtube\.com\/(?:watch\?.*v=|embed\/|shorts\/)|youtu\.be\/)([\w-]{11})/;
|
const YOUTUBE_URL_PATTERN = /(?:youtube\.com\/(?:watch\?.*v=|embed\/|shorts\/)|youtu\.be\/)([\w-]{11})/;
|
||||||
@@ -6,19 +11,7 @@ const YOUTUBE_URL_PATTERN = /(?:youtube\.com\/(?:watch\?.*v=|embed\/|shorts\/)|y
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-chat-youtube-embed',
|
selector: 'app-chat-youtube-embed',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
template: `
|
templateUrl: './chat-youtube-embed.component.html'
|
||||||
@if (videoId()) {
|
|
||||||
<div class="mt-2 w-[480px] max-w-full overflow-hidden rounded-md border border-border/60">
|
|
||||||
<iframe
|
|
||||||
[src]="embedUrl()"
|
|
||||||
class="aspect-video w-full"
|
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
||||||
allowfullscreen
|
|
||||||
loading="lazy"
|
|
||||||
></iframe>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
`
|
|
||||||
})
|
})
|
||||||
export class ChatYoutubeEmbedComponent {
|
export class ChatYoutubeEmbedComponent {
|
||||||
readonly url = input.required<string>();
|
readonly url = input.required<string>();
|
||||||
@@ -40,7 +33,7 @@ export class ChatYoutubeEmbedComponent {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
constructor(private readonly sanitizer: DomSanitizer) {}
|
private readonly sanitizer = inject(DomSanitizer);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isYoutubeUrl(url?: string): boolean {
|
export function isYoutubeUrl(url?: string): boolean {
|
||||||
@@ -53,6 +53,7 @@
|
|||||||
[repliedMessage]="findRepliedMessage(message.replyToId)"
|
[repliedMessage]="findRepliedMessage(message.replyToId)"
|
||||||
[currentUserId]="currentUserId()"
|
[currentUserId]="currentUserId()"
|
||||||
[isAdmin]="isAdmin()"
|
[isAdmin]="isAdmin()"
|
||||||
|
[userLookup]="userLookup()"
|
||||||
(replyRequested)="handleReplyRequested($event)"
|
(replyRequested)="handleReplyRequested($event)"
|
||||||
(deleteRequested)="handleDeleteRequested($event)"
|
(deleteRequested)="handleDeleteRequested($event)"
|
||||||
(editSaved)="handleEditSaved($event)"
|
(editSaved)="handleEditSaved($event)"
|
||||||
|
|||||||
@@ -8,13 +8,15 @@ import {
|
|||||||
ViewChild,
|
ViewChild,
|
||||||
computed,
|
computed,
|
||||||
effect,
|
effect,
|
||||||
|
inject,
|
||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { Store } from '@ngrx/store';
|
||||||
import { Attachment } from '../../../../../attachment';
|
import { Attachment } from '../../../../../attachment';
|
||||||
import { getMessageTimestamp } from '../../../../domain/rules/message.rules';
|
import { getMessageTimestamp } from '../../../../domain/rules/message.rules';
|
||||||
import { Message } from '../../../../../../shared-kernel';
|
import { Message, User } from '../../../../../../shared-kernel';
|
||||||
import {
|
import {
|
||||||
ChatMessageDeleteEvent,
|
ChatMessageDeleteEvent,
|
||||||
ChatMessageEditEvent,
|
ChatMessageEditEvent,
|
||||||
@@ -23,6 +25,7 @@ import {
|
|||||||
ChatMessageReactionEvent,
|
ChatMessageReactionEvent,
|
||||||
ChatMessageReplyEvent
|
ChatMessageReplyEvent
|
||||||
} from '../../models/chat-messages.model';
|
} from '../../models/chat-messages.model';
|
||||||
|
import { selectAllUsers } from '../../../../../../store/users/users.selectors';
|
||||||
import { ChatMessageItemComponent } from '../message-item/chat-message-item.component';
|
import { ChatMessageItemComponent } from '../message-item/chat-message-item.component';
|
||||||
|
|
||||||
interface PrismGlobal {
|
interface PrismGlobal {
|
||||||
@@ -47,6 +50,8 @@ declare global {
|
|||||||
export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||||
@ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>;
|
@ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>;
|
||||||
|
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
private readonly allUsers = this.store.selectSignal(selectAllUsers);
|
||||||
private readonly dateSeparatorFormatter = new Intl.DateTimeFormat('en-GB', {
|
private readonly dateSeparatorFormatter = new Intl.DateTimeFormat('en-GB', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
@@ -110,6 +115,20 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
return labels;
|
return labels;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
readonly userLookup = computed<ReadonlyMap<string, User>>(() => {
|
||||||
|
const lookup = new Map<string, User>();
|
||||||
|
|
||||||
|
for (const user of this.allUsers()) {
|
||||||
|
lookup.set(user.id, user);
|
||||||
|
|
||||||
|
if (user.oderId && user.oderId !== user.id) {
|
||||||
|
lookup.set(user.oderId, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lookup;
|
||||||
|
});
|
||||||
|
|
||||||
private initialScrollObserver: MutationObserver | null = null;
|
private initialScrollObserver: MutationObserver | null = null;
|
||||||
private initialScrollTimer: ReturnType<typeof setTimeout> | null = null;
|
private initialScrollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private boundOnImageLoad: (() => void) | null = null;
|
private boundOnImageLoad: (() => void) | null = null;
|
||||||
|
|||||||
@@ -27,17 +27,14 @@
|
|||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<!-- Avatar with online indicator -->
|
<!-- Avatar with status indicator -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<app-user-avatar
|
<app-user-avatar
|
||||||
[name]="user.displayName"
|
[name]="user.displayName"
|
||||||
|
[status]="user.status"
|
||||||
|
[showStatusBadge]="true"
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<span
|
|
||||||
class="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-card"
|
|
||||||
[class.bg-green-500]="user.isOnline !== false && user.status !== 'offline'"
|
|
||||||
[class.bg-gray-500]="user.isOnline === false || user.status === 'offline'"
|
|
||||||
></span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- User Info -->
|
<!-- User Info -->
|
||||||
@@ -59,6 +56,16 @@
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@if (user.status && user.status !== 'online') {
|
||||||
|
<span
|
||||||
|
class="text-xs"
|
||||||
|
[class.text-yellow-500]="user.status === 'away'"
|
||||||
|
[class.text-red-500]="user.status === 'busy'"
|
||||||
|
[class.text-muted-foreground]="user.status === 'offline'"
|
||||||
|
>
|
||||||
|
{{ user.status === 'busy' ? 'Do Not Disturb' : (user.status | titlecase) }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Voice/Screen Status -->
|
<!-- Voice/Screen Status -->
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export function shouldDeliverNotification(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings.respectBusyStatus && context.currentUser?.status === 'busy') {
|
if (context.currentUser?.status === 'busy') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import {
|
|||||||
lucideMessageSquareText,
|
lucideMessageSquareText,
|
||||||
lucideMoonStar
|
lucideMoonStar
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
import { selectSavedRooms } from '../../../../../store/rooms/rooms.selectors';
|
||||||
import type { Room } from '../../../../shared-kernel';
|
import type { Room } from '../../../../../shared-kernel';
|
||||||
import { NotificationsFacade } from '../../application/facades/notifications.facade';
|
import { NotificationsFacade } from '../../../application/facades/notifications.facade';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-notifications-settings',
|
selector: 'app-notifications-settings',
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
export * from './application/facades/notifications.facade';
|
export * from './application/facades/notifications.facade';
|
||||||
export * from './application/effects/notifications.effects';
|
export * from './application/effects/notifications.effects';
|
||||||
export { NotificationsSettingsComponent } from './feature/settings/notifications-settings.component';
|
export { NotificationsSettingsComponent } from './feature/settings/notifications-settings/notifications-settings.component';
|
||||||
|
|||||||
44
toju-app/src/app/domains/profile-avatar/README.md
Normal file
44
toju-app/src/app/domains/profile-avatar/README.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Profile Avatar Domain
|
||||||
|
|
||||||
|
Owns local profile picture workflow: source validation, crop/zoom editor state, static 256x256 WebP rendering, animated avatar preservation, desktop file persistence, and P2P avatar sync metadata.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
- Accept `.webp`, `.gif`, `.jpg`, `.jpeg` profile image sources.
|
||||||
|
- Let user drag and zoom source inside fixed preview frame before saving.
|
||||||
|
- Render static avatars to `256x256` WebP with client-side compression.
|
||||||
|
- Preserve animated `.gif` and animated `.webp` uploads without flattening frames.
|
||||||
|
- Persist desktop copy at `user/<username>/profile/profile.<ext>` under app data.
|
||||||
|
- Expose helpers used by store effects to keep avatar metadata (`avatarHash`, `avatarMime`, `avatarUpdatedAt`) consistent.
|
||||||
|
|
||||||
|
## Module map
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
PC[ProfileCardComponent] --> PAE[ProfileAvatarEditorComponent]
|
||||||
|
PAE --> PAF[ProfileAvatarFacade]
|
||||||
|
PAF --> PAI[ProfileAvatarImageService]
|
||||||
|
PAF --> PAS[ProfileAvatarStorageService]
|
||||||
|
PAF --> Store[UsersActions.updateCurrentUserAvatar]
|
||||||
|
Store --> UAV[UserAvatarEffects]
|
||||||
|
UAV --> RTC[WebRTC data channel]
|
||||||
|
UAV --> DB[DatabaseService]
|
||||||
|
|
||||||
|
click PAE "feature/profile-avatar-editor/" "Crop and zoom editor UI" _blank
|
||||||
|
click PAF "application/services/profile-avatar.facade.ts" "Facade used by UI and effects" _blank
|
||||||
|
click PAI "infrastructure/services/profile-avatar-image.service.ts" "Canvas render and compression" _blank
|
||||||
|
click PAS "infrastructure/services/profile-avatar-storage.service.ts" "Electron file persistence" _blank
|
||||||
|
```
|
||||||
|
|
||||||
|
## Flow
|
||||||
|
|
||||||
|
1. `ProfileCardComponent` opens file picker from editable avatar button.
|
||||||
|
2. `ProfileAvatarEditorComponent` previews exact crop using drag + zoom.
|
||||||
|
3. `ProfileAvatarImageService` renders static uploads to `256x256` WebP, but keeps animated GIF and WebP sources intact.
|
||||||
|
4. `ProfileAvatarStorageService` writes desktop copy when Electron is available.
|
||||||
|
5. `UserAvatarEffects` broadcasts avatar summary, answers requests, streams chunks, and persists received avatars locally.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Static uploads are normalized to WebP. Animated GIF and animated WebP uploads keep their original animation, mime type, and full-frame presentation.
|
||||||
|
- `avatarUrl` stays local display data. Version conflict resolution uses `avatarUpdatedAt` and `avatarHash`.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { User } from '../../../../shared-kernel';
|
||||||
|
import {
|
||||||
|
EditableProfileAvatarSource,
|
||||||
|
ProcessedProfileAvatar,
|
||||||
|
ProfileAvatarTransform,
|
||||||
|
ProfileAvatarUpdates
|
||||||
|
} from '../../domain/profile-avatar.models';
|
||||||
|
import { ProfileAvatarImageService } from '../../infrastructure/services/profile-avatar-image.service';
|
||||||
|
import { ProfileAvatarStorageService } from '../../infrastructure/services/profile-avatar-storage.service';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ProfileAvatarFacade {
|
||||||
|
private readonly image = inject(ProfileAvatarImageService);
|
||||||
|
private readonly storage = inject(ProfileAvatarStorageService);
|
||||||
|
|
||||||
|
validateFile(file: File): string | null {
|
||||||
|
return this.image.validateFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareEditableSource(file: File): Promise<EditableProfileAvatarSource> {
|
||||||
|
return this.image.prepareEditableSource(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseEditableSource(source: EditableProfileAvatarSource | null | undefined): void {
|
||||||
|
this.image.releaseEditableSource(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
processEditableSource(
|
||||||
|
source: EditableProfileAvatarSource,
|
||||||
|
transform: ProfileAvatarTransform
|
||||||
|
): Promise<ProcessedProfileAvatar> {
|
||||||
|
return this.image.processEditableSource(source, transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
persistProcessedAvatar(
|
||||||
|
user: Pick<User, 'id' | 'username' | 'displayName'>,
|
||||||
|
avatar: ProcessedProfileAvatar
|
||||||
|
): Promise<void> {
|
||||||
|
return this.storage.persistProcessedAvatar(user, avatar);
|
||||||
|
}
|
||||||
|
|
||||||
|
persistAvatarDataUrl(
|
||||||
|
user: Pick<User, 'id' | 'username' | 'displayName'>,
|
||||||
|
avatarUrl: string | null | undefined
|
||||||
|
): Promise<void> {
|
||||||
|
const mimeMatch = avatarUrl?.match(/^data:([^;]+);base64,/i);
|
||||||
|
const base64 = avatarUrl?.split(',', 2)[1] ?? '';
|
||||||
|
const avatarMime = mimeMatch?.[1]?.toLowerCase() ?? 'image/webp';
|
||||||
|
|
||||||
|
if (!base64) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.storage.persistProcessedAvatar(user, {
|
||||||
|
base64,
|
||||||
|
avatarMime
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
buildAvatarUpdates(avatar: ProcessedProfileAvatar): ProfileAvatarUpdates {
|
||||||
|
return {
|
||||||
|
avatarUrl: avatar.avatarUrl,
|
||||||
|
avatarHash: avatar.avatarHash,
|
||||||
|
avatarMime: avatar.avatarMime,
|
||||||
|
avatarUpdatedAt: avatar.avatarUpdatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
PROFILE_AVATAR_MAX_ZOOM,
|
||||||
|
PROFILE_AVATAR_MIN_ZOOM,
|
||||||
|
clampProfileAvatarTransform,
|
||||||
|
clampProfileAvatarZoom,
|
||||||
|
resolveProfileAvatarStorageFileName,
|
||||||
|
resolveProfileAvatarBaseScale
|
||||||
|
} from './profile-avatar.models';
|
||||||
|
|
||||||
|
describe('profile-avatar models', () => {
|
||||||
|
it('clamps zoom inside allowed range', () => {
|
||||||
|
expect(clampProfileAvatarZoom(0.1)).toBe(PROFILE_AVATAR_MIN_ZOOM);
|
||||||
|
expect(clampProfileAvatarZoom(9)).toBe(PROFILE_AVATAR_MAX_ZOOM);
|
||||||
|
expect(clampProfileAvatarZoom(2.5)).toBe(2.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves cover scale for portrait images', () => {
|
||||||
|
expect(resolveProfileAvatarBaseScale({ width: 200, height: 400 }, 224)).toBeCloseTo(1.12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps transform offsets so image still covers crop frame', () => {
|
||||||
|
const transform = clampProfileAvatarTransform(
|
||||||
|
{ width: 320, height: 240 },
|
||||||
|
{ zoom: 1, offsetX: 500, offsetY: -500 },
|
||||||
|
224
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(transform.offsetX).toBeCloseTo(37.333333, 4);
|
||||||
|
expect(transform.offsetY).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps avatar mime types to storage file names', () => {
|
||||||
|
expect(resolveProfileAvatarStorageFileName('image/gif')).toBe('profile.gif');
|
||||||
|
expect(resolveProfileAvatarStorageFileName('image/jpeg')).toBe('profile.jpg');
|
||||||
|
expect(resolveProfileAvatarStorageFileName('image/webp')).toBe('profile.webp');
|
||||||
|
expect(resolveProfileAvatarStorageFileName(undefined)).toBe('profile.webp');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
export const PROFILE_AVATAR_ALLOWED_MIME_TYPES = [
|
||||||
|
'image/webp',
|
||||||
|
'image/gif',
|
||||||
|
'image/jpeg'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const PROFILE_AVATAR_ACCEPT_ATTRIBUTE = '.webp,.gif,.jpg,.jpeg,image/webp,image/gif,image/jpeg';
|
||||||
|
export const PROFILE_AVATAR_OUTPUT_SIZE = 256;
|
||||||
|
export const PROFILE_AVATAR_EDITOR_FRAME_SIZE = 224;
|
||||||
|
export const PROFILE_AVATAR_MIN_ZOOM = 1;
|
||||||
|
export const PROFILE_AVATAR_MAX_ZOOM = 4;
|
||||||
|
|
||||||
|
export interface ProfileAvatarDimensions {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditableProfileAvatarSource extends ProfileAvatarDimensions {
|
||||||
|
file: File;
|
||||||
|
objectUrl: string;
|
||||||
|
mime: string;
|
||||||
|
name: string;
|
||||||
|
preservesAnimation: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileAvatarTransform {
|
||||||
|
zoom: number;
|
||||||
|
offsetX: number;
|
||||||
|
offsetY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileAvatarUpdates {
|
||||||
|
avatarUrl: string;
|
||||||
|
avatarHash: string;
|
||||||
|
avatarMime: string;
|
||||||
|
avatarUpdatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessedProfileAvatar extends ProfileAvatarUpdates, ProfileAvatarDimensions {
|
||||||
|
base64: string;
|
||||||
|
blob: Blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveProfileAvatarStorageFileName(mime: string | null | undefined): string {
|
||||||
|
switch (mime?.toLowerCase()) {
|
||||||
|
case 'image/gif':
|
||||||
|
return 'profile.gif';
|
||||||
|
|
||||||
|
case 'image/jpeg':
|
||||||
|
case 'image/jpg':
|
||||||
|
return 'profile.jpg';
|
||||||
|
|
||||||
|
default:
|
||||||
|
return 'profile.webp';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampProfileAvatarZoom(zoom: number): number {
|
||||||
|
if (!Number.isFinite(zoom)) {
|
||||||
|
return PROFILE_AVATAR_MIN_ZOOM;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(Math.max(zoom, PROFILE_AVATAR_MIN_ZOOM), PROFILE_AVATAR_MAX_ZOOM);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveProfileAvatarBaseScale(
|
||||||
|
source: ProfileAvatarDimensions,
|
||||||
|
frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE
|
||||||
|
): number {
|
||||||
|
return Math.max(frameSize / source.width, frameSize / source.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampProfileAvatarTransform(
|
||||||
|
source: ProfileAvatarDimensions,
|
||||||
|
transform: ProfileAvatarTransform,
|
||||||
|
frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE
|
||||||
|
): ProfileAvatarTransform {
|
||||||
|
const zoom = clampProfileAvatarZoom(transform.zoom);
|
||||||
|
const renderedWidth = source.width * resolveProfileAvatarBaseScale(source, frameSize) * zoom;
|
||||||
|
const renderedHeight = source.height * resolveProfileAvatarBaseScale(source, frameSize) * zoom;
|
||||||
|
const maxOffsetX = Math.max(0, (renderedWidth - frameSize) / 2);
|
||||||
|
const maxOffsetY = Math.max(0, (renderedHeight - frameSize) / 2);
|
||||||
|
|
||||||
|
return {
|
||||||
|
zoom,
|
||||||
|
offsetX: clampOffset(transform.offsetX, maxOffsetX),
|
||||||
|
offsetY: clampOffset(transform.offsetY, maxOffsetY)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampOffset(value: number, maxMagnitude: number): number {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextValue = Math.min(Math.max(value, -maxMagnitude), maxMagnitude);
|
||||||
|
|
||||||
|
return Object.is(nextValue, -0) ? 0 : nextValue;
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<div
|
||||||
|
class="fixed inset-0 z-[112] bg-black/70 backdrop-blur-sm"
|
||||||
|
(click)="cancelled.emit()"
|
||||||
|
(keydown.enter)="cancelled.emit()"
|
||||||
|
(keydown.space)="cancelled.emit()"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Close profile image editor"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div class="fixed inset-0 z-[113] flex items-center justify-center p-4 pointer-events-none">
|
||||||
|
<div
|
||||||
|
class="pointer-events-auto flex max-h-[calc(100vh-2rem)] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-2xl"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div class="border-b border-border p-5">
|
||||||
|
<h3 class="text-lg font-semibold text-foreground">Adjust profile picture</h3>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
@if (preservesAnimation()) {
|
||||||
|
Animated GIF and WebP avatars keep their original animation and framing.
|
||||||
|
} @else {
|
||||||
|
Drag image to frame subject. Zoom until preview looks right. Final image saves as 256x256 WebP.
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 overflow-y-auto p-5 sm:grid-cols-[minmax(0,1fr)_280px]">
|
||||||
|
<div class="flex flex-col items-center gap-4">
|
||||||
|
<div
|
||||||
|
class="relative overflow-hidden rounded-[32px] border border-border bg-secondary/40 shadow-inner touch-none"
|
||||||
|
[style.width.px]="frameSize"
|
||||||
|
[style.height.px]="frameSize"
|
||||||
|
(pointerdown)="onPointerDown($event)"
|
||||||
|
(pointermove)="onPointerMove($event)"
|
||||||
|
(pointerup)="onPointerUp($event)"
|
||||||
|
(pointercancel)="onPointerUp($event)"
|
||||||
|
(wheel)="onWheel($event)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
[src]="source().objectUrl"
|
||||||
|
[alt]="source().name"
|
||||||
|
class="pointer-events-none absolute left-1/2 top-1/2 max-w-none select-none"
|
||||||
|
[style.transform]="imageTransform()"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="pointer-events-none absolute inset-0 rounded-[32px] ring-1 ring-white/10"></div>
|
||||||
|
<div class="pointer-events-none absolute inset-4 rounded-full border border-white/45 shadow-[0_0_0_999px_rgba(4,8,15,0.58)]"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
@if (preservesAnimation()) {
|
||||||
|
Animation and original framing are preserved.
|
||||||
|
} @else {
|
||||||
|
Preview matches saved crop.
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-5">
|
||||||
|
<div class="rounded-xl border border-border bg-secondary/20 p-4">
|
||||||
|
<p class="text-xs uppercase tracking-[0.18em] text-muted-foreground">Source</p>
|
||||||
|
<p class="mt-2 truncate text-sm font-medium text-foreground">{{ source().name }}</p>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">{{ source().width }} x {{ source().height }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-border bg-secondary/20 p-4"
|
||||||
|
[class.opacity-60]="preservesAnimation()"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-foreground">Zoom</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
@if (preservesAnimation()) {
|
||||||
|
Animated avatars keep the original frame sequence.
|
||||||
|
} @else {
|
||||||
|
Use wheel or slider.
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-secondary"
|
||||||
|
(click)="zoomBy(-0.12)"
|
||||||
|
[disabled]="preservesAnimation()"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-secondary"
|
||||||
|
(click)="zoomBy(0.12)"
|
||||||
|
[disabled]="preservesAnimation()"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="4"
|
||||||
|
step="0.01"
|
||||||
|
class="mt-4 w-full accent-primary"
|
||||||
|
[value]="clampedTransform().zoom"
|
||||||
|
(input)="onZoomInput($event)"
|
||||||
|
[disabled]="preservesAnimation()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p class="mt-2 text-xs text-muted-foreground">
|
||||||
|
@if (preservesAnimation()) {
|
||||||
|
Animated upload detected.
|
||||||
|
} @else {
|
||||||
|
{{ (clampedTransform().zoom * 100).toFixed(0) }}% zoom
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (errorMessage()) {
|
||||||
|
<div class="rounded-xl border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
||||||
|
{{ errorMessage() }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-2 border-t border-border p-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg bg-secondary px-4 py-2 text-sm text-foreground transition-colors hover:bg-secondary/80"
|
||||||
|
(click)="cancelled.emit()"
|
||||||
|
[disabled]="processing()"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
(click)="confirm()"
|
||||||
|
[disabled]="processing()"
|
||||||
|
>
|
||||||
|
{{ processing() ? 'Saving...' : 'Apply picture' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
HostListener,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ProfileAvatarFacade } from '../../application/services/profile-avatar.facade';
|
||||||
|
import {
|
||||||
|
EditableProfileAvatarSource,
|
||||||
|
ProcessedProfileAvatar,
|
||||||
|
ProfileAvatarTransform,
|
||||||
|
PROFILE_AVATAR_EDITOR_FRAME_SIZE,
|
||||||
|
clampProfileAvatarTransform,
|
||||||
|
resolveProfileAvatarBaseScale
|
||||||
|
} from '../../domain/profile-avatar.models';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-profile-avatar-editor',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './profile-avatar-editor.component.html'
|
||||||
|
})
|
||||||
|
export class ProfileAvatarEditorComponent {
|
||||||
|
readonly source = input.required<EditableProfileAvatarSource>();
|
||||||
|
|
||||||
|
readonly cancelled = output<void>();
|
||||||
|
readonly confirmed = output<ProcessedProfileAvatar>();
|
||||||
|
|
||||||
|
readonly frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE;
|
||||||
|
readonly processing = signal(false);
|
||||||
|
readonly errorMessage = signal<string | null>(null);
|
||||||
|
readonly preservesAnimation = computed(() => this.source().preservesAnimation);
|
||||||
|
readonly transform = signal<ProfileAvatarTransform>({ zoom: 1,
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 0 });
|
||||||
|
readonly clampedTransform = computed(() => clampProfileAvatarTransform(this.source(), this.transform()));
|
||||||
|
readonly imageTransform = computed(() => {
|
||||||
|
const source = this.source();
|
||||||
|
const transform = this.clampedTransform();
|
||||||
|
const scale = resolveProfileAvatarBaseScale(source, this.frameSize) * transform.zoom;
|
||||||
|
|
||||||
|
return `translate(-50%, -50%) translate(${transform.offsetX}px, ${transform.offsetY}px) scale(${scale})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly avatar = inject(ProfileAvatarFacade);
|
||||||
|
private dragPointerId: number | null = null;
|
||||||
|
private dragOrigin: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
|
||||||
|
|
||||||
|
@HostListener('document:keydown.escape')
|
||||||
|
onEscape(): void {
|
||||||
|
if (!this.processing()) {
|
||||||
|
this.cancelled.emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onZoomChange(value: string): void {
|
||||||
|
if (this.preservesAnimation()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoom = Number(value);
|
||||||
|
|
||||||
|
this.transform.update((current) => ({
|
||||||
|
...current,
|
||||||
|
zoom
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
onZoomInput(event: Event): void {
|
||||||
|
this.onZoomChange((event.target as HTMLInputElement).value);
|
||||||
|
}
|
||||||
|
|
||||||
|
zoomBy(delta: number): void {
|
||||||
|
if (this.preservesAnimation()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.transform.update((current) => ({
|
||||||
|
...current,
|
||||||
|
zoom: current.zoom + delta
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
onWheel(event: WheelEvent): void {
|
||||||
|
if (this.preservesAnimation()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
this.zoomBy(event.deltaY < 0 ? 0.08 : -0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerDown(event: PointerEvent): void {
|
||||||
|
if (this.processing() || this.preservesAnimation()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTarget = event.currentTarget as HTMLElement | null;
|
||||||
|
const currentTransform = this.clampedTransform();
|
||||||
|
|
||||||
|
currentTarget?.setPointerCapture(event.pointerId);
|
||||||
|
this.dragPointerId = event.pointerId;
|
||||||
|
this.dragOrigin = {
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
offsetX: currentTransform.offsetX,
|
||||||
|
offsetY: currentTransform.offsetY
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerMove(event: PointerEvent): void {
|
||||||
|
if (this.dragPointerId !== event.pointerId || !this.dragOrigin || this.processing() || this.preservesAnimation()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.transform.set(clampProfileAvatarTransform(this.source(), {
|
||||||
|
zoom: this.clampedTransform().zoom,
|
||||||
|
offsetX: this.dragOrigin.offsetX + (event.clientX - this.dragOrigin.x),
|
||||||
|
offsetY: this.dragOrigin.offsetY + (event.clientY - this.dragOrigin.y)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerUp(event: PointerEvent): void {
|
||||||
|
if (this.dragPointerId !== event.pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTarget = event.currentTarget as HTMLElement | null;
|
||||||
|
|
||||||
|
currentTarget?.releasePointerCapture(event.pointerId);
|
||||||
|
this.dragPointerId = null;
|
||||||
|
this.dragOrigin = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirm(): Promise<void> {
|
||||||
|
if (this.processing()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processing.set(true);
|
||||||
|
this.errorMessage.set(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const avatar = await this.avatar.processEditableSource(this.source(), this.clampedTransform());
|
||||||
|
|
||||||
|
this.confirmed.emit(avatar);
|
||||||
|
} catch {
|
||||||
|
this.errorMessage.set('Failed to process profile image.');
|
||||||
|
} finally {
|
||||||
|
this.processing.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import {
|
||||||
|
Overlay,
|
||||||
|
OverlayRef
|
||||||
|
} from '@angular/cdk/overlay';
|
||||||
|
import { ComponentPortal } from '@angular/cdk/portal';
|
||||||
|
import {
|
||||||
|
EditableProfileAvatarSource,
|
||||||
|
ProcessedProfileAvatar
|
||||||
|
} from '../../domain/profile-avatar.models';
|
||||||
|
import { ProfileAvatarEditorComponent } from './profile-avatar-editor.component';
|
||||||
|
|
||||||
|
export const PROFILE_AVATAR_EDITOR_OVERLAY_CLASS = 'profile-avatar-editor-overlay-pane';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ProfileAvatarEditorService {
|
||||||
|
private readonly overlay = inject(Overlay);
|
||||||
|
private overlayRef: OverlayRef | null = null;
|
||||||
|
|
||||||
|
open(source: EditableProfileAvatarSource): Promise<ProcessedProfileAvatar | null> {
|
||||||
|
this.close();
|
||||||
|
|
||||||
|
this.syncThemeVars();
|
||||||
|
|
||||||
|
const overlayRef = this.overlay.create({
|
||||||
|
disposeOnNavigation: true,
|
||||||
|
panelClass: PROFILE_AVATAR_EDITOR_OVERLAY_CLASS,
|
||||||
|
positionStrategy: this.overlay.position().global().centerHorizontally().centerVertically(),
|
||||||
|
scrollStrategy: this.overlay.scrollStrategies.block()
|
||||||
|
});
|
||||||
|
|
||||||
|
this.overlayRef = overlayRef;
|
||||||
|
|
||||||
|
const componentRef = overlayRef.attach(new ComponentPortal(ProfileAvatarEditorComponent));
|
||||||
|
|
||||||
|
componentRef.setInput('source', source);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const finish = (result: ProcessedProfileAvatar | null): void => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
settled = true;
|
||||||
|
cancelSub.unsubscribe();
|
||||||
|
confirmSub.unsubscribe();
|
||||||
|
detachSub.unsubscribe();
|
||||||
|
|
||||||
|
if (this.overlayRef === overlayRef) {
|
||||||
|
this.overlayRef = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayRef.dispose();
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelSub = componentRef.instance.cancelled.subscribe(() => finish(null));
|
||||||
|
const confirmSub = componentRef.instance.confirmed.subscribe((avatar) => finish(avatar));
|
||||||
|
const detachSub = overlayRef.detachments().subscribe(() => finish(null));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
if (!this.overlayRef) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlayRef = this.overlayRef;
|
||||||
|
|
||||||
|
this.overlayRef = null;
|
||||||
|
overlayRef.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncThemeVars(): void {
|
||||||
|
const appRoot = document.querySelector<HTMLElement>('[data-theme-key="appRoot"]');
|
||||||
|
const container = document.querySelector<HTMLElement>('.cdk-overlay-container');
|
||||||
|
|
||||||
|
if (!appRoot || !container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const prop of Array.from(appRoot.style)) {
|
||||||
|
if (prop.startsWith('--')) {
|
||||||
|
container.style.setProperty(prop, appRoot.style.getPropertyValue(prop));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
toju-app/src/app/domains/profile-avatar/index.ts
Normal file
7
toju-app/src/app/domains/profile-avatar/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export * from './domain/profile-avatar.models';
|
||||||
|
export { ProfileAvatarFacade } from './application/services/profile-avatar.facade';
|
||||||
|
export { ProfileAvatarEditorComponent } from './feature/profile-avatar-editor/profile-avatar-editor.component';
|
||||||
|
export {
|
||||||
|
PROFILE_AVATAR_EDITOR_OVERLAY_CLASS,
|
||||||
|
ProfileAvatarEditorService
|
||||||
|
} from './feature/profile-avatar-editor/profile-avatar-editor.service';
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import {
|
||||||
|
isAnimatedGif,
|
||||||
|
isAnimatedWebp
|
||||||
|
} from './profile-avatar-image.service';
|
||||||
|
|
||||||
|
describe('profile-avatar image animation detection', () => {
|
||||||
|
it('detects animated gifs with multiple frames', () => {
|
||||||
|
const animatedGif = new Uint8Array([
|
||||||
|
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
|
||||||
|
0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00,
|
||||||
|
0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00,
|
||||||
|
0x3B
|
||||||
|
]).buffer;
|
||||||
|
|
||||||
|
expect(isAnimatedGif(animatedGif)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mark single-frame gifs as animated', () => {
|
||||||
|
const staticGif = new Uint8Array([
|
||||||
|
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
|
||||||
|
0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00,
|
||||||
|
0x3B
|
||||||
|
]).buffer;
|
||||||
|
|
||||||
|
expect(isAnimatedGif(staticGif)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects animated webp files from the VP8X animation flag', () => {
|
||||||
|
const animatedWebp = new Uint8Array([
|
||||||
|
0x52, 0x49, 0x46, 0x46, 0x16, 0x00, 0x00, 0x00,
|
||||||
|
0x57, 0x45, 0x42, 0x50,
|
||||||
|
0x56, 0x50, 0x38, 0x58, 0x0A, 0x00, 0x00, 0x00,
|
||||||
|
0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
|
||||||
|
]).buffer;
|
||||||
|
|
||||||
|
expect(isAnimatedWebp(animatedWebp)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mark static webp files as animated', () => {
|
||||||
|
const staticWebp = new Uint8Array([
|
||||||
|
0x52, 0x49, 0x46, 0x46, 0x16, 0x00, 0x00, 0x00,
|
||||||
|
0x57, 0x45, 0x42, 0x50,
|
||||||
|
0x56, 0x50, 0x38, 0x58, 0x0A, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
|
||||||
|
]).buffer;
|
||||||
|
|
||||||
|
expect(isAnimatedWebp(staticWebp)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import {
|
||||||
|
PROFILE_AVATAR_ALLOWED_MIME_TYPES,
|
||||||
|
PROFILE_AVATAR_OUTPUT_SIZE,
|
||||||
|
ProfileAvatarTransform,
|
||||||
|
EditableProfileAvatarSource,
|
||||||
|
ProcessedProfileAvatar,
|
||||||
|
clampProfileAvatarTransform,
|
||||||
|
PROFILE_AVATAR_EDITOR_FRAME_SIZE,
|
||||||
|
resolveProfileAvatarBaseScale
|
||||||
|
} from '../../domain/profile-avatar.models';
|
||||||
|
|
||||||
|
const PROFILE_AVATAR_OUTPUT_MIME = 'image/webp';
|
||||||
|
const PROFILE_AVATAR_OUTPUT_QUALITY = 0.92;
|
||||||
|
|
||||||
|
export function isAnimatedGif(buffer: ArrayBuffer): boolean {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
|
||||||
|
if (bytes.length < 13 || readAscii(bytes, 0, 6) !== 'GIF87a' && readAscii(bytes, 0, 6) !== 'GIF89a') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset = 13;
|
||||||
|
|
||||||
|
if ((bytes[10] & 0x80) !== 0) {
|
||||||
|
offset += 3 * (2 ** ((bytes[10] & 0x07) + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
let frameCount = 0;
|
||||||
|
|
||||||
|
while (offset < bytes.length) {
|
||||||
|
const blockType = bytes[offset];
|
||||||
|
|
||||||
|
if (blockType === 0x3B) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blockType === 0x21) {
|
||||||
|
offset += 2;
|
||||||
|
|
||||||
|
while (offset < bytes.length) {
|
||||||
|
const blockSize = bytes[offset++];
|
||||||
|
|
||||||
|
if (blockSize === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += blockSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blockType !== 0x2C || offset + 10 > bytes.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
frameCount++;
|
||||||
|
|
||||||
|
if (frameCount > 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const packedFields = bytes[offset + 9];
|
||||||
|
|
||||||
|
offset += 10;
|
||||||
|
|
||||||
|
if ((packedFields & 0x80) !== 0) {
|
||||||
|
offset += 3 * (2 ** ((packedFields & 0x07) + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += 1;
|
||||||
|
|
||||||
|
while (offset < bytes.length) {
|
||||||
|
const blockSize = bytes[offset++];
|
||||||
|
|
||||||
|
if (blockSize === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += blockSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAnimatedWebp(buffer: ArrayBuffer): boolean {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
|
||||||
|
if (bytes.length < 16 || readAscii(bytes, 0, 4) !== 'RIFF' || readAscii(bytes, 8, 4) !== 'WEBP') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset = 12;
|
||||||
|
|
||||||
|
while (offset + 8 <= bytes.length) {
|
||||||
|
const chunkType = readAscii(bytes, offset, 4);
|
||||||
|
const chunkSize = readUint32LittleEndian(bytes, offset + 4);
|
||||||
|
|
||||||
|
if (chunkType === 'ANIM' || chunkType === 'ANMF') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunkType === 'VP8X' && offset + 9 <= bytes.length) {
|
||||||
|
const featureFlags = bytes[offset + 8];
|
||||||
|
|
||||||
|
if ((featureFlags & 0x02) !== 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += 8 + chunkSize + (chunkSize % 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ProfileAvatarImageService {
|
||||||
|
validateFile(file: File): string | null {
|
||||||
|
const mimeType = file.type.toLowerCase();
|
||||||
|
const normalizedName = file.name.toLowerCase();
|
||||||
|
const isAllowedMime = PROFILE_AVATAR_ALLOWED_MIME_TYPES.includes(mimeType as typeof PROFILE_AVATAR_ALLOWED_MIME_TYPES[number]);
|
||||||
|
const isAllowedExtension = normalizedName.endsWith('.webp')
|
||||||
|
|| normalizedName.endsWith('.gif')
|
||||||
|
|| normalizedName.endsWith('.jpg')
|
||||||
|
|| normalizedName.endsWith('.jpeg');
|
||||||
|
|
||||||
|
if (!isAllowedExtension || (mimeType && !isAllowedMime)) {
|
||||||
|
return 'Invalid file type. Use WebP, GIF, JPG, or JPEG.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async prepareEditableSource(file: File): Promise<EditableProfileAvatarSource> {
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
const mime = this.resolveSourceMime(file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [image, preservesAnimation] = await Promise.all([this.loadImage(objectUrl), this.detectAnimatedSource(file, mime)]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
objectUrl,
|
||||||
|
mime,
|
||||||
|
name: file.name,
|
||||||
|
width: image.naturalWidth,
|
||||||
|
height: image.naturalHeight,
|
||||||
|
preservesAnimation
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseEditableSource(source: EditableProfileAvatarSource | null | undefined): void {
|
||||||
|
if (!source?.objectUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
URL.revokeObjectURL(source.objectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
async processEditableSource(
|
||||||
|
source: EditableProfileAvatarSource,
|
||||||
|
transform: ProfileAvatarTransform
|
||||||
|
): Promise<ProcessedProfileAvatar> {
|
||||||
|
if (source.preservesAnimation) {
|
||||||
|
return this.processAnimatedSource(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = await this.loadImage(source.objectUrl);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
|
||||||
|
canvas.width = PROFILE_AVATAR_OUTPUT_SIZE;
|
||||||
|
canvas.height = PROFILE_AVATAR_OUTPUT_SIZE;
|
||||||
|
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('Canvas not supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampedTransform = clampProfileAvatarTransform(source, transform);
|
||||||
|
const previewScale = resolveProfileAvatarBaseScale(source, PROFILE_AVATAR_EDITOR_FRAME_SIZE) * clampedTransform.zoom;
|
||||||
|
const renderRatio = PROFILE_AVATAR_OUTPUT_SIZE / PROFILE_AVATAR_EDITOR_FRAME_SIZE;
|
||||||
|
const drawWidth = image.naturalWidth * previewScale * renderRatio;
|
||||||
|
const drawHeight = image.naturalHeight * previewScale * renderRatio;
|
||||||
|
const drawX = (PROFILE_AVATAR_OUTPUT_SIZE - drawWidth) / 2 + clampedTransform.offsetX * renderRatio;
|
||||||
|
const drawY = (PROFILE_AVATAR_OUTPUT_SIZE - drawHeight) / 2 + clampedTransform.offsetY * renderRatio;
|
||||||
|
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
context.imageSmoothingEnabled = true;
|
||||||
|
context.imageSmoothingQuality = 'high';
|
||||||
|
context.drawImage(image, drawX, drawY, drawWidth, drawHeight);
|
||||||
|
|
||||||
|
const renderedBlob = await this.canvasToBlob(canvas, PROFILE_AVATAR_OUTPUT_MIME, PROFILE_AVATAR_OUTPUT_QUALITY);
|
||||||
|
const compressedBlob = renderedBlob;
|
||||||
|
const updatedAt = Date.now();
|
||||||
|
const dataUrl = await this.readBlobAsDataUrl(compressedBlob);
|
||||||
|
const hash = await this.computeHash(compressedBlob);
|
||||||
|
|
||||||
|
return {
|
||||||
|
blob: compressedBlob,
|
||||||
|
base64: dataUrl.split(',', 2)[1] ?? '',
|
||||||
|
avatarUrl: dataUrl,
|
||||||
|
avatarHash: hash,
|
||||||
|
avatarMime: compressedBlob.type || PROFILE_AVATAR_OUTPUT_MIME,
|
||||||
|
avatarUpdatedAt: updatedAt,
|
||||||
|
width: PROFILE_AVATAR_OUTPUT_SIZE,
|
||||||
|
height: PROFILE_AVATAR_OUTPUT_SIZE
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processAnimatedSource(source: EditableProfileAvatarSource): Promise<ProcessedProfileAvatar> {
|
||||||
|
const updatedAt = Date.now();
|
||||||
|
const dataUrl = await this.readBlobAsDataUrl(source.file);
|
||||||
|
const hash = await this.computeHash(source.file);
|
||||||
|
|
||||||
|
return {
|
||||||
|
blob: source.file,
|
||||||
|
base64: dataUrl.split(',', 2)[1] ?? '',
|
||||||
|
avatarUrl: dataUrl,
|
||||||
|
avatarHash: hash,
|
||||||
|
avatarMime: source.mime || source.file.type || PROFILE_AVATAR_OUTPUT_MIME,
|
||||||
|
avatarUpdatedAt: updatedAt,
|
||||||
|
width: source.width,
|
||||||
|
height: source.height
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async detectAnimatedSource(file: File, mime: string): Promise<boolean> {
|
||||||
|
if (mime !== 'image/gif' && mime !== 'image/webp') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
|
||||||
|
return mime === 'image/gif'
|
||||||
|
? isAnimatedGif(buffer)
|
||||||
|
: isAnimatedWebp(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveSourceMime(file: File): string {
|
||||||
|
const mimeType = file.type.toLowerCase();
|
||||||
|
|
||||||
|
if (mimeType === 'image/jpg') {
|
||||||
|
return 'image/jpeg';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mimeType) {
|
||||||
|
return mimeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedName = file.name.toLowerCase();
|
||||||
|
|
||||||
|
if (normalizedName.endsWith('.gif')) {
|
||||||
|
return 'image/gif';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedName.endsWith('.jpg') || normalizedName.endsWith('.jpeg')) {
|
||||||
|
return 'image/jpeg';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedName.endsWith('.webp')) {
|
||||||
|
return 'image/webp';
|
||||||
|
}
|
||||||
|
|
||||||
|
return PROFILE_AVATAR_OUTPUT_MIME;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async computeHash(blob: Blob): Promise<string> {
|
||||||
|
const buffer = await blob.arrayBuffer();
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', buffer);
|
||||||
|
|
||||||
|
return Array.from(new Uint8Array(digest))
|
||||||
|
.map((value) => value.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
private canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (blob) {
|
||||||
|
resolve(blob);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(new Error('Failed to render avatar image'));
|
||||||
|
}, type, quality);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private readBlobAsDataUrl(blob: Blob): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = () => {
|
||||||
|
if (typeof reader.result === 'string') {
|
||||||
|
resolve(reader.result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(new Error('Failed to encode avatar image'));
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.onerror = () => reject(reader.error ?? new Error('Failed to read avatar image'));
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadImage(url: string): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
|
||||||
|
image.onload = () => resolve(image);
|
||||||
|
image.onerror = () => reject(new Error('Failed to load avatar image'));
|
||||||
|
image.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAscii(bytes: Uint8Array, offset: number, length: number): string {
|
||||||
|
return String.fromCharCode(...bytes.slice(offset, offset + length));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUint32LittleEndian(bytes: Uint8Array, offset: number): number {
|
||||||
|
return bytes[offset]
|
||||||
|
| (bytes[offset + 1] << 8)
|
||||||
|
| (bytes[offset + 2] << 16)
|
||||||
|
| (bytes[offset + 3] << 24);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||||
|
import { User } from '../../../../shared-kernel';
|
||||||
|
import {
|
||||||
|
ProcessedProfileAvatar,
|
||||||
|
resolveProfileAvatarStorageFileName
|
||||||
|
} from '../../domain/profile-avatar.models';
|
||||||
|
|
||||||
|
const LEGACY_PROFILE_FILE_NAMES = [
|
||||||
|
'profile.webp',
|
||||||
|
'profile.gif',
|
||||||
|
'profile.jpg',
|
||||||
|
'profile.jpeg',
|
||||||
|
'profile.png'
|
||||||
|
];
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ProfileAvatarStorageService {
|
||||||
|
private readonly electronBridge = inject(ElectronBridgeService);
|
||||||
|
|
||||||
|
async persistProcessedAvatar(
|
||||||
|
user: Pick<User, 'id' | 'username' | 'displayName'>,
|
||||||
|
avatar: Pick<ProcessedProfileAvatar, 'base64' | 'avatarMime'>
|
||||||
|
): Promise<void> {
|
||||||
|
const electronApi = this.electronBridge.getApi();
|
||||||
|
|
||||||
|
if (!electronApi) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appDataPath = await electronApi.getAppDataPath();
|
||||||
|
const usernameSegment = this.sanitizePathSegment(user.username || user.displayName || user.id || 'user');
|
||||||
|
const directoryPath = `${appDataPath}/user/${usernameSegment}/profile`;
|
||||||
|
const targetFileName = resolveProfileAvatarStorageFileName(avatar.avatarMime);
|
||||||
|
|
||||||
|
await electronApi.ensureDir(directoryPath);
|
||||||
|
|
||||||
|
for (const fileName of LEGACY_PROFILE_FILE_NAMES) {
|
||||||
|
const filePath = `${directoryPath}/${fileName}`;
|
||||||
|
|
||||||
|
if (fileName !== targetFileName && await electronApi.fileExists(filePath)) {
|
||||||
|
await electronApi.deleteFile(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await electronApi.writeFile(`${directoryPath}/${targetFileName}`, avatar.base64);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizePathSegment(value: string): string {
|
||||||
|
const normalized = value
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 80);
|
||||||
|
|
||||||
|
return normalized || 'user';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,6 @@ describe('room-signal-source helpers', () => {
|
|||||||
expect(areRoomSignalSourcesEqual(
|
expect(areRoomSignalSourcesEqual(
|
||||||
{ sourceUrl: 'https://signal.toju.app/' },
|
{ sourceUrl: 'https://signal.toju.app/' },
|
||||||
{ signalingUrl: 'wss://signal.toju.app' }
|
{ signalingUrl: 'wss://signal.toju.app' }
|
||||||
)).toBeTrue();
|
)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
ThemeGridEditorItem,
|
ThemeGridEditorItem,
|
||||||
ThemeGridRect,
|
ThemeGridRect,
|
||||||
ThemeLayoutContainerDefinition
|
ThemeLayoutContainerDefinition
|
||||||
} from '../../domain/models/theme.model';
|
} from '../../../domain/models/theme.model';
|
||||||
|
|
||||||
type DragMode = 'move' | 'resize';
|
type DragMode = 'move' | 'resize';
|
||||||
|
|
||||||
@@ -8,26 +8,26 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
import { SettingsModalService } from '../../../../../core/services/settings-modal.service';
|
||||||
import {
|
import {
|
||||||
ThemeContainerKey,
|
ThemeContainerKey,
|
||||||
ThemeElementStyleProperty,
|
ThemeElementStyleProperty,
|
||||||
ThemeRegistryEntry
|
ThemeRegistryEntry
|
||||||
} from '../../domain/models/theme.model';
|
} from '../../../domain/models/theme.model';
|
||||||
import {
|
import {
|
||||||
THEME_ANIMATION_FIELDS as THEME_ANIMATION_FIELD_HINTS,
|
THEME_ANIMATION_FIELDS as THEME_ANIMATION_FIELD_HINTS,
|
||||||
THEME_ELEMENT_STYLE_FIELDS,
|
THEME_ELEMENT_STYLE_FIELDS,
|
||||||
createAnimationStarterDefinition,
|
createAnimationStarterDefinition,
|
||||||
getSuggestedFieldDefault
|
getSuggestedFieldDefault
|
||||||
} from '../../domain/logic/theme-schema.logic';
|
} from '../../../domain/logic/theme-schema.logic';
|
||||||
import { ElementPickerService } from '../../application/services/element-picker.service';
|
import { ElementPickerService } from '../../../application/services/element-picker.service';
|
||||||
import { LayoutSyncService } from '../../application/services/layout-sync.service';
|
import { LayoutSyncService } from '../../../application/services/layout-sync.service';
|
||||||
import { ThemeLibraryService } from '../../application/services/theme-library.service';
|
import { ThemeLibraryService } from '../../../application/services/theme-library.service';
|
||||||
import { ThemeRegistryService } from '../../application/services/theme-registry.service';
|
import { ThemeRegistryService } from '../../../application/services/theme-registry.service';
|
||||||
import { ThemeService } from '../../application/services/theme.service';
|
import { ThemeService } from '../../../application/services/theme.service';
|
||||||
import { THEME_LLM_GUIDE } from '../../domain/constants/theme-llm-guide.constants';
|
import { THEME_LLM_GUIDE } from '../../../domain/constants/theme-llm-guide.constants';
|
||||||
import { ThemeGridEditorComponent } from './theme-grid-editor.component';
|
import { ThemeGridEditorComponent } from '../theme-grid-editor/theme-grid-editor.component';
|
||||||
import { ThemeJsonCodeEditorComponent } from './theme-json-code-editor.component';
|
import { ThemeJsonCodeEditorComponent } from '../theme-json-code-editor/theme-json-code-editor.component';
|
||||||
|
|
||||||
type JumpSection = 'elements' | 'layout' | 'animations';
|
type JumpSection = 'elements' | 'layout' | 'animations';
|
||||||
type ThemeStudioWorkspace = 'editor' | 'inspector' | 'layout';
|
type ThemeStudioWorkspace = 'editor' | 'inspector' | 'layout';
|
||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
import { ElementPickerService } from '../application/services/element-picker.service';
|
import { ElementPickerService } from '../../application/services/element-picker.service';
|
||||||
import { ThemeRegistryService } from '../application/services/theme-registry.service';
|
import { ThemeRegistryService } from '../../application/services/theme-registry.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-theme-picker-overlay',
|
selector: 'app-theme-picker-overlay',
|
||||||
@@ -10,4 +10,4 @@ export * from './domain/logic/theme-schema.logic';
|
|||||||
export * from './domain/logic/theme-validation.logic';
|
export * from './domain/logic/theme-validation.logic';
|
||||||
|
|
||||||
export { ThemeNodeDirective } from './feature/theme-node.directive';
|
export { ThemeNodeDirective } from './feature/theme-node.directive';
|
||||||
export { ThemePickerOverlayComponent } from './feature/theme-picker-overlay.component';
|
export { ThemePickerOverlayComponent } from './feature/theme-picker-overlay/theme-picker-overlay.component';
|
||||||
|
|||||||
@@ -52,16 +52,13 @@ export class VoiceWorkspaceService {
|
|||||||
readonly hasCustomMiniWindowPosition = computed(() => this._hasCustomMiniWindowPosition());
|
readonly hasCustomMiniWindowPosition = computed(() => this._hasCustomMiniWindowPosition());
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(
|
effect(() => {
|
||||||
() => {
|
|
||||||
if (this.voiceSession.voiceSession()) {
|
if (this.voiceSession.voiceSession()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.reset();
|
this.reset();
|
||||||
},
|
});
|
||||||
{ allowSignalWrites: true }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
open(
|
open(
|
||||||
|
|||||||
@@ -17,9 +17,11 @@
|
|||||||
/>
|
/>
|
||||||
@if (voiceSession()?.serverIcon) {
|
@if (voiceSession()?.serverIcon) {
|
||||||
<img
|
<img
|
||||||
[src]="voiceSession()?.serverIcon"
|
[ngSrc]="voiceSession()?.serverIcon || ''"
|
||||||
class="w-5 h-5 rounded object-cover"
|
class="w-5 h-5 rounded object-cover"
|
||||||
alt=""
|
alt=""
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
/>
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="flex h-5 w-5 items-center justify-center rounded-sm bg-muted text-[10px] font-semibold">
|
<div class="flex h-5 w-5 items-center justify-center rounded-sm bg-muted text-[10px] font-semibold">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
computed,
|
computed,
|
||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule, NgOptimizedImage } from '@angular/common';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +34,7 @@ import { ThemeNodeDirective } from '../../../../domains/theme';
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
NgOptimizedImage,
|
||||||
NgIcon,
|
NgIcon,
|
||||||
DebugConsoleComponent,
|
DebugConsoleComponent,
|
||||||
ScreenShareQualityDialogComponent,
|
ScreenShareQualityDialogComponent,
|
||||||
|
|||||||
@@ -15,17 +15,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<!-- User Info -->
|
<!-- User Info -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="relative flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-3 flex-1 min-w-0 rounded-md px-1 py-0.5 hover:bg-secondary/60 transition-colors cursor-pointer"
|
||||||
|
(click)="toggleProfileCard(); $event.stopPropagation()"
|
||||||
|
>
|
||||||
<app-user-avatar
|
<app-user-avatar
|
||||||
[name]="currentUser()?.displayName || '?'"
|
[name]="currentUser()?.displayName || '?'"
|
||||||
|
[avatarUrl]="currentUser()?.avatarUrl"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
[status]="currentUser()?.status"
|
||||||
|
[showStatusBadge]="true"
|
||||||
/>
|
/>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="font-medium text-sm text-foreground truncate">
|
<p class="font-medium text-sm text-foreground truncate text-left">
|
||||||
{{ currentUser()?.displayName || 'Unknown' }}
|
{{ currentUser()?.displayName || 'Unknown' }}
|
||||||
</p>
|
</p>
|
||||||
@if (showConnectionError() || isConnected()) {
|
@if (showConnectionError() || isConnected()) {
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground text-left">
|
||||||
@if (showConnectionError()) {
|
@if (showConnectionError()) {
|
||||||
<span class="text-destructive">Connection Error</span>
|
<span class="text-destructive">Connection Error</span>
|
||||||
} @else if (isConnected()) {
|
} @else if (isConnected()) {
|
||||||
@@ -34,6 +42,7 @@
|
|||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<app-debug-console
|
<app-debug-console
|
||||||
launcherVariant="inline"
|
launcherVariant="inline"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars, complexity */
|
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars, complexity */
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
|
ElementRef,
|
||||||
inject,
|
inject,
|
||||||
signal,
|
signal,
|
||||||
OnInit,
|
OnInit,
|
||||||
@@ -34,7 +35,8 @@ import { SettingsModalService } from '../../../../core/services/settings-modal.s
|
|||||||
import {
|
import {
|
||||||
DebugConsoleComponent,
|
DebugConsoleComponent,
|
||||||
ScreenShareQualityDialogComponent,
|
ScreenShareQualityDialogComponent,
|
||||||
UserAvatarComponent
|
UserAvatarComponent,
|
||||||
|
ProfileCardService
|
||||||
} from '../../../../shared';
|
} from '../../../../shared';
|
||||||
|
|
||||||
interface AudioDevice {
|
interface AudioDevice {
|
||||||
@@ -75,6 +77,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||||
private readonly store = inject(Store);
|
private readonly store = inject(Store);
|
||||||
private readonly settingsModal = inject(SettingsModalService);
|
private readonly settingsModal = inject(SettingsModalService);
|
||||||
|
private readonly hostEl = inject(ElementRef);
|
||||||
|
private readonly profileCard = inject(ProfileCardService);
|
||||||
|
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
@@ -88,6 +92,15 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
isScreenSharing = this.screenShareService.isScreenSharing;
|
isScreenSharing = this.screenShareService.isScreenSharing;
|
||||||
showSettings = signal(false);
|
showSettings = signal(false);
|
||||||
|
|
||||||
|
toggleProfileCard(): void {
|
||||||
|
const user = this.currentUser();
|
||||||
|
|
||||||
|
if (!user)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.profileCard.open(this.hostEl.nativeElement, user, { placement: 'above', editable: true });
|
||||||
|
}
|
||||||
|
|
||||||
inputDevices = signal<AudioDevice[]>([]);
|
inputDevices = signal<AudioDevice[]>([]);
|
||||||
outputDevices = signal<AudioDevice[]>([]);
|
outputDevices = signal<AudioDevice[]>([]);
|
||||||
selectedInputDevice = signal<string>('');
|
selectedInputDevice = signal<string>('');
|
||||||
|
|||||||
@@ -164,7 +164,10 @@
|
|||||||
</button>
|
</button>
|
||||||
<!-- Voice users connected to this channel -->
|
<!-- Voice users connected to this channel -->
|
||||||
@if (voiceUsersInRoom(ch.id).length > 0) {
|
@if (voiceUsersInRoom(ch.id).length > 0) {
|
||||||
<div class="ml-5 mt-1 space-y-1 border-l border-border pb-1 pl-2">
|
<div
|
||||||
|
class="mt-1 space-y-1 border-l border-border pb-1 pl-2"
|
||||||
|
style="margin-left: 0.91rem"
|
||||||
|
>
|
||||||
@for (u of voiceUsersInRoom(ch.id); track u.id) {
|
@for (u of voiceUsersInRoom(ch.id); track u.id) {
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-secondary/50"
|
class="flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-secondary/50"
|
||||||
@@ -179,15 +182,7 @@
|
|||||||
[name]="u.displayName"
|
[name]="u.displayName"
|
||||||
[avatarUrl]="u.avatarUrl"
|
[avatarUrl]="u.avatarUrl"
|
||||||
size="xs"
|
size="xs"
|
||||||
[ringClass]="
|
[ringClass]="getVoiceUserRingClass(u)"
|
||||||
u.voiceState?.isDeafened
|
|
||||||
? 'ring-2 ring-red-500'
|
|
||||||
: u.voiceState?.isMuted
|
|
||||||
? 'ring-2 ring-yellow-500'
|
|
||||||
: voiceActivity.isSpeaking(u.oderId || u.id)()
|
|
||||||
? 'ring-2 ring-green-400 shadow-[0_0_8px_2px_rgba(74,222,128,0.6)]'
|
|
||||||
: 'ring-2 ring-green-500/40'
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
|
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
|
||||||
<!-- Ping latency indicator -->
|
<!-- Ping latency indicator -->
|
||||||
@@ -241,15 +236,21 @@
|
|||||||
@if (currentUser()) {
|
@if (currentUser()) {
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">You</h4>
|
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">You</h4>
|
||||||
<div class="flex items-center gap-2 rounded-md bg-secondary/60 px-3 py-2">
|
<div
|
||||||
<div class="relative">
|
class="flex items-center gap-2 rounded-md bg-secondary/60 px-3 py-2 hover:bg-secondary/80 transition-colors cursor-pointer"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
(click)="openProfileCard($event, currentUser()!, true); $event.stopPropagation()"
|
||||||
|
(keydown.enter)="openProfileCard($event, currentUser()!, true); $event.stopPropagation()"
|
||||||
|
(keydown.space)="openProfileCard($event, currentUser()!, true); $event.preventDefault(); $event.stopPropagation()"
|
||||||
|
>
|
||||||
<app-user-avatar
|
<app-user-avatar
|
||||||
[name]="currentUser()?.displayName || '?'"
|
[name]="currentUser()?.displayName || '?'"
|
||||||
[avatarUrl]="currentUser()?.avatarUrl"
|
[avatarUrl]="currentUser()?.avatarUrl"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
[status]="currentUser()?.status"
|
||||||
|
[showStatusBadge]="true"
|
||||||
/>
|
/>
|
||||||
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-sm text-foreground truncate">{{ currentUser()?.displayName }}</p>
|
<p class="text-sm text-foreground truncate">{{ currentUser()?.displayName }}</p>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -287,17 +288,21 @@
|
|||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
@for (user of onlineRoomUsers(); track user.id) {
|
@for (user of onlineRoomUsers(); track user.id) {
|
||||||
<div
|
<div
|
||||||
class="group/user flex items-center gap-2 rounded-md px-3 py-2 transition-colors hover:bg-secondary/50"
|
class="group/user flex items-center gap-2 rounded-md px-3 py-2 transition-colors hover:bg-secondary/50 cursor-pointer"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
(contextmenu)="openUserContextMenu($event, user)"
|
(contextmenu)="openUserContextMenu($event, user)"
|
||||||
|
(click)="openProfileCard($event, user, false); $event.stopPropagation()"
|
||||||
|
(keydown.enter)="openProfileCard($event, user, false); $event.stopPropagation()"
|
||||||
|
(keydown.space)="openProfileCard($event, user, false); $event.preventDefault(); $event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<div class="relative">
|
|
||||||
<app-user-avatar
|
<app-user-avatar
|
||||||
[name]="user.displayName"
|
[name]="user.displayName"
|
||||||
[avatarUrl]="user.avatarUrl"
|
[avatarUrl]="user.avatarUrl"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
[status]="user.status"
|
||||||
|
[showStatusBadge]="true"
|
||||||
/>
|
/>
|
||||||
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<p class="text-sm text-foreground truncate">{{ user.displayName }}</p>
|
<p class="text-sm text-foreground truncate">{{ user.displayName }}</p>
|
||||||
@@ -345,15 +350,21 @@
|
|||||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Offline - {{ offlineRoomMembers().length }}</h4>
|
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Offline - {{ offlineRoomMembers().length }}</h4>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
@for (member of offlineRoomMembers(); track member.oderId || member.id) {
|
@for (member of offlineRoomMembers(); track member.oderId || member.id) {
|
||||||
<div class="flex items-center gap-2 rounded-md px-3 py-2 opacity-80">
|
<div
|
||||||
<div class="relative">
|
class="flex items-center gap-2 rounded-md px-3 py-2 opacity-80 hover:bg-secondary/30 transition-colors cursor-pointer"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
(click)="openProfileCardForMember($event, member); $event.stopPropagation()"
|
||||||
|
(keydown.enter)="openProfileCardForMember($event, member); $event.stopPropagation()"
|
||||||
|
(keydown.space)="openProfileCardForMember($event, member); $event.preventDefault(); $event.stopPropagation()"
|
||||||
|
>
|
||||||
<app-user-avatar
|
<app-user-avatar
|
||||||
[name]="member.displayName"
|
[name]="member.displayName"
|
||||||
[avatarUrl]="member.avatarUrl"
|
[avatarUrl]="member.avatarUrl"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
status="disconnected"
|
||||||
|
[showStatusBadge]="true"
|
||||||
/>
|
/>
|
||||||
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-gray-500 ring-2 ring-card"></span>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<p class="text-sm text-foreground/80 truncate">{{ member.displayName }}</p>
|
<p class="text-sm text-foreground/80 truncate">{{ member.displayName }}</p>
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ import {
|
|||||||
ContextMenuComponent,
|
ContextMenuComponent,
|
||||||
UserAvatarComponent,
|
UserAvatarComponent,
|
||||||
ConfirmDialogComponent,
|
ConfirmDialogComponent,
|
||||||
UserVolumeMenuComponent
|
UserVolumeMenuComponent,
|
||||||
|
ProfileCardService
|
||||||
} from '../../../shared';
|
} from '../../../shared';
|
||||||
import {
|
import {
|
||||||
Channel,
|
Channel,
|
||||||
@@ -101,7 +102,8 @@ export class RoomsSidePanelComponent {
|
|||||||
private voiceSessionService = inject(VoiceSessionFacade);
|
private voiceSessionService = inject(VoiceSessionFacade);
|
||||||
private voiceWorkspace = inject(VoiceWorkspaceService);
|
private voiceWorkspace = inject(VoiceWorkspaceService);
|
||||||
private voicePlayback = inject(VoicePlaybackService);
|
private voicePlayback = inject(VoicePlaybackService);
|
||||||
voiceActivity = inject(VoiceActivityService);
|
private profileCard = inject(ProfileCardService);
|
||||||
|
private readonly voiceActivity = inject(VoiceActivityService);
|
||||||
|
|
||||||
readonly panelMode = input<PanelMode>('channels');
|
readonly panelMode = input<PanelMode>('channels');
|
||||||
readonly showVoiceControls = input(true);
|
readonly showVoiceControls = input(true);
|
||||||
@@ -184,6 +186,28 @@ export class RoomsSidePanelComponent {
|
|||||||
draggedVoiceUserId = signal<string | null>(null);
|
draggedVoiceUserId = signal<string | null>(null);
|
||||||
dragTargetVoiceChannelId = signal<string | null>(null);
|
dragTargetVoiceChannelId = signal<string | null>(null);
|
||||||
|
|
||||||
|
openProfileCard(event: Event, user: User, editable: boolean): void {
|
||||||
|
event.stopPropagation();
|
||||||
|
const el = event.currentTarget as HTMLElement;
|
||||||
|
|
||||||
|
this.profileCard.open(el, user, { placement: 'left', editable });
|
||||||
|
}
|
||||||
|
|
||||||
|
openProfileCardForMember(event: Event, member: RoomMember): void {
|
||||||
|
const user: User = {
|
||||||
|
id: member.id,
|
||||||
|
oderId: member.oderId || member.id,
|
||||||
|
username: member.username,
|
||||||
|
displayName: member.displayName,
|
||||||
|
avatarUrl: member.avatarUrl,
|
||||||
|
status: 'disconnected',
|
||||||
|
role: member.role,
|
||||||
|
joinedAt: member.joinedAt
|
||||||
|
};
|
||||||
|
|
||||||
|
this.openProfileCard(event, user, false);
|
||||||
|
}
|
||||||
|
|
||||||
private roomMemberKey(member: RoomMember): string {
|
private roomMemberKey(member: RoomMember): string {
|
||||||
return member.oderId || member.id;
|
return member.oderId || member.id;
|
||||||
}
|
}
|
||||||
@@ -862,6 +886,22 @@ export class RoomsSidePanelComponent {
|
|||||||
return this.isUserSharing(userId) || this.isUserOnCamera(userId);
|
return this.isUserSharing(userId) || this.isUserOnCamera(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getVoiceUserRingClass(user: User): string {
|
||||||
|
if (user.voiceState?.isDeafened) {
|
||||||
|
return 'ring-2 ring-red-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.voiceState?.isMuted) {
|
||||||
|
return 'ring-2 ring-yellow-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isVoiceUserSpeaking(user)) {
|
||||||
|
return 'ring-2 ring-green-400 shadow-[0_0_8px_2px_rgba(74,222,128,0.6)]';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'ring-2 ring-green-500/40';
|
||||||
|
}
|
||||||
|
|
||||||
getUserLiveIconName(userId: string): string {
|
getUserLiveIconName(userId: string): string {
|
||||||
return this.isUserSharing(userId) ? 'lucideMonitor' : 'lucideVideo';
|
return this.isUserSharing(userId) ? 'lucideMonitor' : 'lucideVideo';
|
||||||
}
|
}
|
||||||
@@ -957,6 +997,12 @@ export class RoomsSidePanelComponent {
|
|||||||
return 'bg-red-500';
|
return 'bg-red-500';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isVoiceUserSpeaking(user: User): boolean {
|
||||||
|
const userKey = user.oderId || user.id;
|
||||||
|
|
||||||
|
return !!userKey && this.voiceActivity.speakingMap().get(userKey) === true;
|
||||||
|
}
|
||||||
|
|
||||||
private findKnownUser(userId: string): User | null {
|
private findKnownUser(userId: string): User | null {
|
||||||
const current = this.currentUser();
|
const current = this.currentUser();
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ import {
|
|||||||
lucideVolumeX
|
lucideVolumeX
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { UserAvatarComponent } from '../../../shared';
|
import { UserAvatarComponent } from '../../../../shared';
|
||||||
import { VoiceWorkspacePlaybackService } from './voice-workspace-playback.service';
|
import { VoiceWorkspacePlaybackService } from '../voice-workspace-playback.service';
|
||||||
import { VoiceWorkspaceStreamItem } from './voice-workspace.models';
|
import { VoiceWorkspaceStreamItem } from '../voice-workspace.models';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-voice-workspace-stream-tile',
|
selector: 'app-voice-workspace-stream-tile',
|
||||||
@@ -86,8 +86,7 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
|||||||
void video.play().catch(() => {});
|
void video.play().catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
effect(
|
effect(() => {
|
||||||
() => {
|
|
||||||
this.workspacePlayback.settings();
|
this.workspacePlayback.settings();
|
||||||
|
|
||||||
const item = this.item();
|
const item = this.item();
|
||||||
@@ -100,9 +99,7 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
|||||||
|
|
||||||
this.volume.set(this.workspacePlayback.getUserVolume(item.peerKey));
|
this.volume.set(this.workspacePlayback.getUserVolume(item.peerKey));
|
||||||
this.muted.set(this.workspacePlayback.isUserMuted(item.peerKey));
|
this.muted.set(this.workspacePlayback.isUserMuted(item.peerKey));
|
||||||
},
|
});
|
||||||
{ allowSignalWrites: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const ref = this.videoRef();
|
const ref = this.videoRef();
|
||||||
@@ -48,7 +48,7 @@ import { UsersActions } from '../../../store/users/users.actions';
|
|||||||
import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors';
|
import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors';
|
||||||
import { ScreenShareQualityDialogComponent, UserAvatarComponent } from '../../../shared';
|
import { ScreenShareQualityDialogComponent, UserAvatarComponent } from '../../../shared';
|
||||||
import { VoiceWorkspacePlaybackService } from './voice-workspace-playback.service';
|
import { VoiceWorkspacePlaybackService } from './voice-workspace-playback.service';
|
||||||
import { VoiceWorkspaceStreamTileComponent } from './voice-workspace-stream-tile.component';
|
import { VoiceWorkspaceStreamTileComponent } from './voice-workspace-stream-tile/voice-workspace-stream-tile.component';
|
||||||
import { VoiceWorkspaceStreamItem } from './voice-workspace.models';
|
import { VoiceWorkspaceStreamItem } from './voice-workspace.models';
|
||||||
import { ThemeNodeDirective } from '../../../domains/theme';
|
import { ThemeNodeDirective } from '../../../domains/theme';
|
||||||
|
|
||||||
@@ -456,8 +456,7 @@ export class VoiceWorkspaceComponent {
|
|||||||
this.pruneObservedRemoteStreams(peerKeys);
|
this.pruneObservedRemoteStreams(peerKeys);
|
||||||
});
|
});
|
||||||
|
|
||||||
effect(
|
effect(() => {
|
||||||
() => {
|
|
||||||
const isExpanded = this.showExpanded();
|
const isExpanded = this.showExpanded();
|
||||||
const shouldAutoHideChrome = this.shouldAutoHideChrome();
|
const shouldAutoHideChrome = this.shouldAutoHideChrome();
|
||||||
|
|
||||||
@@ -485,9 +484,7 @@ export class VoiceWorkspaceComponent {
|
|||||||
if (shouldRevealChrome) {
|
if (shouldRevealChrome) {
|
||||||
this.revealWorkspaceChrome();
|
this.revealWorkspaceChrome();
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
{ allowSignalWrites: true }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onWorkspacePointerMove(): void {
|
onWorkspacePointerMove(): void {
|
||||||
|
|||||||
@@ -78,6 +78,20 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="grid w-full overflow-hidden duration-200 ease-out motion-reduce:transition-none"
|
||||||
|
style="transition-property: grid-template-rows, opacity"
|
||||||
|
[style.gridTemplateRows]="isOnSearch() ? '1fr' : '0fr'"
|
||||||
|
[style.opacity]="isOnSearch() ? '1' : '0'"
|
||||||
|
[style.visibility]="isOnSearch() ? 'visible' : 'hidden'"
|
||||||
|
[class.pointer-events-none]="!isOnSearch()"
|
||||||
|
[attr.aria-hidden]="isOnSearch() ? null : 'true'"
|
||||||
|
>
|
||||||
|
<div class="overflow-hidden">
|
||||||
|
<app-user-bar />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Context menu -->
|
<!-- Context menu -->
|
||||||
@@ -7,37 +7,40 @@ import {
|
|||||||
inject,
|
inject,
|
||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
||||||
import { CommonModule, NgOptimizedImage } from '@angular/common';
|
import { CommonModule, NgOptimizedImage } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { Router } from '@angular/router';
|
import { NavigationEnd, Router } from '@angular/router';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { lucidePlus } from '@ng-icons/lucide';
|
import { lucidePlus } from '@ng-icons/lucide';
|
||||||
import {
|
import {
|
||||||
EMPTY,
|
EMPTY,
|
||||||
Subject,
|
Subject,
|
||||||
catchError,
|
catchError,
|
||||||
|
filter,
|
||||||
firstValueFrom,
|
firstValueFrom,
|
||||||
from,
|
from,
|
||||||
|
map,
|
||||||
switchMap,
|
switchMap,
|
||||||
tap
|
tap
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
|
|
||||||
import { Room, User } from '../../shared-kernel';
|
import { Room, User } from '../../../shared-kernel';
|
||||||
import { VoiceSessionFacade } from '../../domains/voice-session';
|
import { UserBarComponent } from '../../../domains/authentication/feature/user-bar/user-bar.component';
|
||||||
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
import { VoiceSessionFacade } from '../../../domains/voice-session';
|
||||||
import { selectCurrentUser, selectOnlineUsers } from '../../store/users/users.selectors';
|
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors';
|
||||||
import { DatabaseService } from '../../infrastructure/persistence';
|
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
||||||
import { NotificationsFacade } from '../../domains/notifications';
|
import { DatabaseService } from '../../../infrastructure/persistence';
|
||||||
import { type ServerInfo, ServerDirectoryFacade } from '../../domains/server-directory';
|
import { NotificationsFacade } from '../../../domains/notifications';
|
||||||
import { hasRoomBanForUser } from '../../domains/access-control';
|
import { type ServerInfo, ServerDirectoryFacade } from '../../../domains/server-directory';
|
||||||
|
import { hasRoomBanForUser } from '../../../domains/access-control';
|
||||||
import {
|
import {
|
||||||
ConfirmDialogComponent,
|
ConfirmDialogComponent,
|
||||||
ContextMenuComponent,
|
ContextMenuComponent,
|
||||||
LeaveServerDialogComponent
|
LeaveServerDialogComponent
|
||||||
} from '../../shared';
|
} from '../../../shared';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-servers-rail',
|
selector: 'app-servers-rail',
|
||||||
@@ -49,7 +52,8 @@ import {
|
|||||||
ConfirmDialogComponent,
|
ConfirmDialogComponent,
|
||||||
ContextMenuComponent,
|
ContextMenuComponent,
|
||||||
LeaveServerDialogComponent,
|
LeaveServerDialogComponent,
|
||||||
NgOptimizedImage
|
NgOptimizedImage,
|
||||||
|
UserBarComponent
|
||||||
],
|
],
|
||||||
viewProviders: [provideIcons({ lucidePlus })],
|
viewProviders: [provideIcons({ lucidePlus })],
|
||||||
templateUrl: './servers-rail.component.html'
|
templateUrl: './servers-rail.component.html'
|
||||||
@@ -75,6 +79,13 @@ export class ServersRailComponent {
|
|||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||||
bannedRoomLookup = signal<Record<string, boolean>>({});
|
bannedRoomLookup = signal<Record<string, boolean>>({});
|
||||||
|
isOnSearch = toSignal(
|
||||||
|
this.router.events.pipe(
|
||||||
|
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||||
|
map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/search'))
|
||||||
|
),
|
||||||
|
{ initialValue: this.router.url.startsWith('/search') }
|
||||||
|
);
|
||||||
bannedServerName = signal('');
|
bannedServerName = signal('');
|
||||||
showBannedDialog = signal(false);
|
showBannedDialog = signal(false);
|
||||||
showPasswordDialog = signal(false);
|
showPasswordDialog = signal(false);
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
@if (canKickMembers(member)) {
|
@if (canKickMembers(member)) {
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
(click)="kickMember(member)"
|
(click)="kickMember(member)"
|
||||||
class="grid h-8 w-8 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
|
class="grid h-8 w-8 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
|
||||||
title="Kick"
|
title="Kick"
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
}
|
}
|
||||||
@if (canBanMembers(member)) {
|
@if (canBanMembers(member)) {
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
(click)="banMember(member)"
|
(click)="banMember(member)"
|
||||||
class="grid h-8 w-8 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
|
class="grid h-8 w-8 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
|
||||||
title="Ban"
|
title="Ban"
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import { RealtimeSessionFacade } from '../../../core/realtime';
|
|||||||
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||||
import { Room, UserRole } from '../../../shared-kernel';
|
import { Room, UserRole } from '../../../shared-kernel';
|
||||||
import { NotificationsSettingsComponent } from '../../../domains/notifications/feature/settings/notifications-settings.component';
|
import { NotificationsSettingsComponent } from '../../../domains/notifications';
|
||||||
import { resolveLegacyRole, resolveRoomPermission } from '../../../domains/access-control';
|
import { resolveLegacyRole, resolveRoomPermission } from '../../../domains/access-control';
|
||||||
|
|
||||||
import { GeneralSettingsComponent } from './general-settings/general-settings.component';
|
import { GeneralSettingsComponent } from './general-settings/general-settings.component';
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ export interface ThirdPartyLicense {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toLicenseText = (lines: readonly string[]): string => lines.join('\n');
|
const toLicenseText = (lines: readonly string[]): string => lines.join('\n');
|
||||||
|
const GROUPED_LICENSE_NOTE = 'Grouped by the license declared in the installed package metadata for the packages below. '
|
||||||
const GROUPED_LICENSE_NOTE = 'Grouped by the license declared in the installed package metadata for the packages below. Some upstream packages include their own copyright notices in addition to this standard license text.';
|
+ 'Some upstream packages include their own copyright notices in addition to this standard license text.';
|
||||||
|
|
||||||
const MIT_LICENSE_TEXT = toLicenseText([
|
const MIT_LICENSE_TEXT = toLicenseText([
|
||||||
'MIT License',
|
'MIT License',
|
||||||
'',
|
'',
|
||||||
@@ -35,7 +34,6 @@ const MIT_LICENSE_TEXT = toLicenseText([
|
|||||||
'OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE',
|
'OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE',
|
||||||
'SOFTWARE.'
|
'SOFTWARE.'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const APACHE_LICENSE_TEXT = toLicenseText([
|
const APACHE_LICENSE_TEXT = toLicenseText([
|
||||||
'Apache License',
|
'Apache License',
|
||||||
'Version 2.0, January 2004',
|
'Version 2.0, January 2004',
|
||||||
@@ -191,7 +189,6 @@ const APACHE_LICENSE_TEXT = toLicenseText([
|
|||||||
'',
|
'',
|
||||||
'END OF TERMS AND CONDITIONS'
|
'END OF TERMS AND CONDITIONS'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const WAVESURFER_BSD_LICENSE_TEXT = toLicenseText([
|
const WAVESURFER_BSD_LICENSE_TEXT = toLicenseText([
|
||||||
'BSD 3-Clause License',
|
'BSD 3-Clause License',
|
||||||
'',
|
'',
|
||||||
@@ -220,7 +217,6 @@ const WAVESURFER_BSD_LICENSE_TEXT = toLicenseText([
|
|||||||
'IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT',
|
'IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT',
|
||||||
'OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.'
|
'OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ISC_LICENSE_TEXT = toLicenseText([
|
const ISC_LICENSE_TEXT = toLicenseText([
|
||||||
'ISC License',
|
'ISC License',
|
||||||
'',
|
'',
|
||||||
@@ -238,7 +234,6 @@ const ISC_LICENSE_TEXT = toLicenseText([
|
|||||||
'ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS',
|
'ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS',
|
||||||
'SOFTWARE.'
|
'SOFTWARE.'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ZERO_BSD_LICENSE_TEXT = toLicenseText([
|
const ZERO_BSD_LICENSE_TEXT = toLicenseText([
|
||||||
'Zero-Clause BSD',
|
'Zero-Clause BSD',
|
||||||
'',
|
'',
|
||||||
@@ -316,9 +311,7 @@ export const THIRD_PARTY_LICENSES: ThirdPartyLicense[] = [
|
|||||||
name: 'BSD-licensed packages',
|
name: 'BSD-licensed packages',
|
||||||
licenseName: 'BSD 3-Clause License',
|
licenseName: 'BSD 3-Clause License',
|
||||||
sourceUrl: 'https://opensource.org/licenses/BSD-3-Clause',
|
sourceUrl: 'https://opensource.org/licenses/BSD-3-Clause',
|
||||||
packages: [
|
packages: ['wavesurfer.js'],
|
||||||
'wavesurfer.js'
|
|
||||||
],
|
|
||||||
text: WAVESURFER_BSD_LICENSE_TEXT,
|
text: WAVESURFER_BSD_LICENSE_TEXT,
|
||||||
note: 'License text reproduced from the bundled wavesurfer.js package license.'
|
note: 'License text reproduced from the bundled wavesurfer.js package license.'
|
||||||
},
|
},
|
||||||
@@ -327,9 +320,7 @@ export const THIRD_PARTY_LICENSES: ThirdPartyLicense[] = [
|
|||||||
name: 'ISC-licensed packages',
|
name: 'ISC-licensed packages',
|
||||||
licenseName: 'ISC License',
|
licenseName: 'ISC License',
|
||||||
sourceUrl: 'https://opensource.org/license/isc-license-txt',
|
sourceUrl: 'https://opensource.org/license/isc-license-txt',
|
||||||
packages: [
|
packages: ['@ng-icons/lucide'],
|
||||||
'@ng-icons/lucide'
|
|
||||||
],
|
|
||||||
text: ISC_LICENSE_TEXT,
|
text: ISC_LICENSE_TEXT,
|
||||||
note: GROUPED_LICENSE_NOTE
|
note: GROUPED_LICENSE_NOTE
|
||||||
},
|
},
|
||||||
@@ -338,9 +329,7 @@ export const THIRD_PARTY_LICENSES: ThirdPartyLicense[] = [
|
|||||||
name: '0BSD-licensed packages',
|
name: '0BSD-licensed packages',
|
||||||
licenseName: '0BSD License',
|
licenseName: '0BSD License',
|
||||||
sourceUrl: 'https://opensource.org/license/0bsd',
|
sourceUrl: 'https://opensource.org/license/0bsd',
|
||||||
packages: [
|
packages: ['tslib'],
|
||||||
'tslib'
|
|
||||||
],
|
|
||||||
text: ZERO_BSD_LICENSE_TEXT,
|
text: ZERO_BSD_LICENSE_TEXT,
|
||||||
note: GROUPED_LICENSE_NOTE
|
note: GROUPED_LICENSE_NOTE
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import {
|
|||||||
inject,
|
inject,
|
||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { ElectronBridgeService } from '../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
||||||
import { ContextMenuComponent } from '../../shared';
|
import { ContextMenuComponent } from '../../../shared';
|
||||||
import type { ContextMenuParams } from '../../core/platform/electron/electron-api.models';
|
import type { ContextMenuParams } from '../../../core/platform/electron/electron-api.models';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-native-context-menu',
|
selector: 'app-native-context-menu',
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user