5 Commits

Author SHA1 Message Date
Myx
28797a0141 perf: use lookup for chats
Some checks failed
Queue Release Build / prepare (push) Successful in 16s
Deploy Web Apps / deploy (push) Successful in 15m8s
Queue Release Build / build-linux (push) Successful in 26m49s
Queue Release Build / build-windows (push) Failing after 12m6s
Queue Release Build / finalize (push) Has been skipped
2026-04-17 03:53:53 +02:00
Myx
17738ec484 feat: Add profile images 2026-04-17 03:06:44 +02:00
Myx
35b616fb77 refactor: Clean lint errors and organise files 2026-04-17 01:06:01 +02:00
Myx
2927a86fbb feat: Add user statuses and cards 2026-04-16 22:52:45 +02:00
Myx
b4ac0cdc92 fix: Windows audio mute fix 2026-04-16 19:07:44 +02:00
137 changed files with 5661 additions and 854 deletions

View File

@@ -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:

View File

@@ -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,
} }
); );

View File

@@ -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;

View File

@@ -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> {

View File

@@ -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 });
} }

View File

@@ -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
}, }
}); });

View File

@@ -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)}`;
} }

View File

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

View File

@@ -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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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 }
});
});
});

View 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';
}

View File

@@ -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();
});
} }

View File

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

View File

@@ -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)
}; };

View File

@@ -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'

View File

@@ -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
View File

@@ -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",

View File

@@ -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": {

View File

@@ -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();

View File

@@ -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
}; };
} }

View 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');
}
});
});

View File

@@ -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);
} }

View File

@@ -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;
} }

View File

@@ -17,5 +17,5 @@
"sourceMap": true "sourceMap": true
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist", "src/**/*.spec.ts"]
} }

View File

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

View File

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

View File

@@ -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);

View File

@@ -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';

View File

@@ -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);

View 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;
}
}

View File

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

View File

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

View File

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

View File

@@ -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"
<span class="text-foreground">{{ user()?.displayName }}</span> (click)="toggleProfileCard(avatarBtn)"
>
<app-user-avatar
[name]="user()!.displayName"
[avatarUrl]="user()!.avatarUrl"
size="sm"
[status]="user()!.status"
[showStatusBadge]="true"
/>
</button>
</div> </div>
} @else { } @else {
<button <div class="flex flex-col items-center gap-1">
type="button" <button
(click)="goto('login')" type="button"
class="px-2 py-1 text-sm rounded bg-secondary hover:bg-secondary/80 flex items-center gap-1" (click)="goto('login')"
> 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 >
name="lucideLogIn" <ng-icon
class="w-4 h-4" name="lucideLogIn"
/> class="w-3 h-3"
Login />
</button> Login
<button </button>
type="button" <button
(click)="goto('register')" type="button"
class="px-2 py-1 text-sm rounded bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-1" (click)="goto('register')"
> 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 >
name="lucideUserPlus" <ng-icon
class="w-4 h-4" name="lucideUserPlus"
/> class="w-3 h-3"
Register />
</button> Register
</button>
</div>
} }
</div> </div>

View File

@@ -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') {

View File

@@ -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> {

View File

@@ -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',

View File

@@ -6,11 +6,16 @@
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"
> >
<app-user-avatar <div
[name]="msg.senderName" class="flex-shrink-0 cursor-pointer"
size="md" (click)="openSenderProfileCard($event); $event.stopPropagation()"
class="flex-shrink-0" >
/> <app-user-avatar
[name]="senderUser().displayName || msg.senderName"
[avatarUrl]="senderUser().avatarUrl"
size="md"
/>
</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>

View File

@@ -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();

View File

@@ -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',

View File

@@ -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>
}

View File

@@ -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 {

View File

@@ -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)"

View File

@@ -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;

View File

@@ -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 -->

View File

@@ -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;
} }

View File

@@ -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',

View File

@@ -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';

View File

@@ -0,0 +1,44 @@
# Profile Avatar Domain
Owns local profile picture workflow: source validation, crop/zoom editor state, static 256x256 WebP rendering, animated avatar preservation, desktop file persistence, and P2P avatar sync metadata.
## Responsibilities
- Accept `.webp`, `.gif`, `.jpg`, `.jpeg` profile image sources.
- Let user drag and zoom source inside fixed preview frame before saving.
- Render static avatars to `256x256` WebP with client-side compression.
- Preserve animated `.gif` and animated `.webp` uploads without flattening frames.
- Persist desktop copy at `user/<username>/profile/profile.<ext>` under app data.
- Expose helpers used by store effects to keep avatar metadata (`avatarHash`, `avatarMime`, `avatarUpdatedAt`) consistent.
## Module map
```mermaid
graph TD
PC[ProfileCardComponent] --> PAE[ProfileAvatarEditorComponent]
PAE --> PAF[ProfileAvatarFacade]
PAF --> PAI[ProfileAvatarImageService]
PAF --> PAS[ProfileAvatarStorageService]
PAF --> Store[UsersActions.updateCurrentUserAvatar]
Store --> UAV[UserAvatarEffects]
UAV --> RTC[WebRTC data channel]
UAV --> DB[DatabaseService]
click PAE "feature/profile-avatar-editor/" "Crop and zoom editor UI" _blank
click PAF "application/services/profile-avatar.facade.ts" "Facade used by UI and effects" _blank
click PAI "infrastructure/services/profile-avatar-image.service.ts" "Canvas render and compression" _blank
click PAS "infrastructure/services/profile-avatar-storage.service.ts" "Electron file persistence" _blank
```
## Flow
1. `ProfileCardComponent` opens file picker from editable avatar button.
2. `ProfileAvatarEditorComponent` previews exact crop using drag + zoom.
3. `ProfileAvatarImageService` renders static uploads to `256x256` WebP, but keeps animated GIF and WebP sources intact.
4. `ProfileAvatarStorageService` writes desktop copy when Electron is available.
5. `UserAvatarEffects` broadcasts avatar summary, answers requests, streams chunks, and persists received avatars locally.
## Notes
- Static uploads are normalized to WebP. Animated GIF and animated WebP uploads keep their original animation, mime type, and full-frame presentation.
- `avatarUrl` stays local display data. Version conflict resolution uses `avatarUpdatedAt` and `avatarHash`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
export * from './domain/profile-avatar.models';
export { ProfileAvatarFacade } from './application/services/profile-avatar.facade';
export { ProfileAvatarEditorComponent } from './feature/profile-avatar-editor/profile-avatar-editor.component';
export {
PROFILE_AVATAR_EDITOR_OVERLAY_CLASS,
ProfileAvatarEditorService
} from './feature/profile-avatar-editor/profile-avatar-editor.service';

View File

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

View File

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

View File

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

View File

@@ -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);
}); });
}); });

View File

@@ -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';

View File

@@ -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';

View File

@@ -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',

View File

@@ -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';

View File

@@ -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(

View File

@@ -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">

View File

@@ -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,

View File

@@ -15,25 +15,34 @@
} }
<!-- User Info --> <!-- User Info -->
<div class="flex items-center gap-3"> <div class="relative flex items-center gap-3">
<app-user-avatar <button
[name]="currentUser()?.displayName || '?'" type="button"
size="sm" 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()"
<div class="flex-1 min-w-0"> >
<p class="font-medium text-sm text-foreground truncate"> <app-user-avatar
{{ currentUser()?.displayName || 'Unknown' }} [name]="currentUser()?.displayName || '?'"
</p> [avatarUrl]="currentUser()?.avatarUrl"
@if (showConnectionError() || isConnected()) { size="sm"
<p class="text-xs text-muted-foreground"> [status]="currentUser()?.status"
@if (showConnectionError()) { [showStatusBadge]="true"
<span class="text-destructive">Connection Error</span> />
} @else if (isConnected()) { <div class="flex-1 min-w-0">
<span class="text-green-500">Connected</span> <p class="font-medium text-sm text-foreground truncate text-left">
} {{ currentUser()?.displayName || 'Unknown' }}
</p> </p>
} @if (showConnectionError() || isConnected()) {
</div> <p class="text-xs text-muted-foreground text-left">
@if (showConnectionError()) {
<span class="text-destructive">Connection Error</span>
} @else if (isConnected()) {
<span class="text-green-500">Connected</span>
}
</p>
}
</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"

View File

@@ -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>('');

View File

@@ -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"
<app-user-avatar role="button"
[name]="currentUser()?.displayName || '?'" tabindex="0"
[avatarUrl]="currentUser()?.avatarUrl" (click)="openProfileCard($event, currentUser()!, true); $event.stopPropagation()"
size="sm" (keydown.enter)="openProfileCard($event, currentUser()!, true); $event.stopPropagation()"
/> (keydown.space)="openProfileCard($event, currentUser()!, true); $event.preventDefault(); $event.stopPropagation()"
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span> >
</div> <app-user-avatar
[name]="currentUser()?.displayName || '?'"
[avatarUrl]="currentUser()?.avatarUrl"
size="sm"
[status]="currentUser()?.status"
[showStatusBadge]="true"
/>
<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"
<app-user-avatar role="button"
[name]="member.displayName" tabindex="0"
[avatarUrl]="member.avatarUrl" (click)="openProfileCardForMember($event, member); $event.stopPropagation()"
size="sm" (keydown.enter)="openProfileCardForMember($event, member); $event.stopPropagation()"
/> (keydown.space)="openProfileCardForMember($event, member); $event.preventDefault(); $event.stopPropagation()"
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-gray-500 ring-2 ring-card"></span> >
</div> <app-user-avatar
[name]="member.displayName"
[avatarUrl]="member.avatarUrl"
size="sm"
status="disconnected"
[showStatusBadge]="true"
/>
<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>

View File

@@ -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();

View File

@@ -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,23 +86,20 @@ 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();
if (item.isLocal || !item.hasAudio) { if (item.isLocal || !item.hasAudio) {
this.volume.set(0); this.volume.set(0);
this.muted.set(false); this.muted.set(false);
return; return;
} }
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();

View File

@@ -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,38 +456,35 @@ 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();
if (!isExpanded) { if (!isExpanded) {
this.clearHeaderHideTimeout(); this.clearHeaderHideTimeout();
this.showWorkspaceHeader.set(true); this.showWorkspaceHeader.set(true);
this.wasExpanded = false; this.wasExpanded = false;
this.wasAutoHideChrome = false; this.wasAutoHideChrome = false;
return; return;
} }
if (!shouldAutoHideChrome) {
this.clearHeaderHideTimeout();
this.showWorkspaceHeader.set(true);
this.wasExpanded = true;
this.wasAutoHideChrome = false;
return;
}
const shouldRevealChrome = !this.wasExpanded || !this.wasAutoHideChrome;
if (!shouldAutoHideChrome) {
this.clearHeaderHideTimeout();
this.showWorkspaceHeader.set(true);
this.wasExpanded = true; this.wasExpanded = true;
this.wasAutoHideChrome = true; this.wasAutoHideChrome = false;
return;
}
if (shouldRevealChrome) { const shouldRevealChrome = !this.wasExpanded || !this.wasAutoHideChrome;
this.revealWorkspaceChrome();
} this.wasExpanded = true;
}, this.wasAutoHideChrome = true;
{ allowSignalWrites: true }
); if (shouldRevealChrome) {
this.revealWorkspaceChrome();
}
});
} }
onWorkspacePointerMove(): void { onWorkspacePointerMove(): void {

View File

@@ -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 -->

View File

@@ -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);

View File

@@ -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"

View File

@@ -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';

View File

@@ -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
} }

View File

@@ -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