feat: Add user statuses and cards
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import {
|
|||||||
type Page
|
type Page
|
||||||
} from '@playwright/test';
|
} from '@playwright/test';
|
||||||
|
|
||||||
export type ChatDropFilePayload = {
|
export interface ChatDropFilePayload {
|
||||||
name: string;
|
name: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
base64: string;
|
base64: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
export class ChatMessagesPage {
|
export class ChatMessagesPage {
|
||||||
readonly composer: Locator;
|
readonly composer: Locator;
|
||||||
@@ -115,7 +115,8 @@ export class ChatMessagesPage {
|
|||||||
getEmbedCardByTitle(title: string): Locator {
|
getEmbedCardByTitle(title: string): Locator {
|
||||||
return this.page.locator('app-chat-link-embed').filter({
|
return this.page.locator('app-chat-link-embed').filter({
|
||||||
has: this.page.getByText(title, { exact: true })
|
has: this.page.getByText(title, { exact: true })
|
||||||
}).last();
|
})
|
||||||
|
.last();
|
||||||
}
|
}
|
||||||
|
|
||||||
async editOwnMessage(originalText: string, updatedText: string): Promise<void> {
|
async editOwnMessage(originalText: string, updatedText: string): Promise<void> {
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { expect, type Page, type Locator } from '@playwright/test';
|
import {
|
||||||
|
expect,
|
||||||
|
type Page,
|
||||||
|
type Locator
|
||||||
|
} from '@playwright/test';
|
||||||
|
|
||||||
export class RegisterPage {
|
export class RegisterPage {
|
||||||
readonly usernameInput: Locator;
|
readonly usernameInput: Locator;
|
||||||
|
|||||||
@@ -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,
|
port: 4200,
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 120_000,
|
timeout: 120_000
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { type Page } from '@playwright/test';
|
import { type Page } from '@playwright/test';
|
||||||
import { test, expect, type Client } from '../../fixtures/multi-client';
|
import {
|
||||||
|
test,
|
||||||
|
expect,
|
||||||
|
type Client
|
||||||
|
} from '../../fixtures/multi-client';
|
||||||
import { RegisterPage } from '../../pages/register.page';
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
import {
|
import { ChatMessagesPage, type ChatDropFilePayload } from '../../pages/chat-messages.page';
|
||||||
ChatMessagesPage,
|
|
||||||
type ChatDropFilePayload
|
|
||||||
} from '../../pages/chat-messages.page';
|
|
||||||
|
|
||||||
const MOCK_EMBED_URL = 'https://example.test/mock-embed';
|
const MOCK_EMBED_URL = 'https://example.test/mock-embed';
|
||||||
const MOCK_EMBED_TITLE = 'Mock Embed Title';
|
const MOCK_EMBED_TITLE = 'Mock Embed Title';
|
||||||
@@ -133,14 +134,14 @@ test.describe('Chat messaging features', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
type ChatScenario = {
|
interface ChatScenario {
|
||||||
alice: Client;
|
alice: Client;
|
||||||
bob: Client;
|
bob: Client;
|
||||||
aliceRoom: ChatRoomPage;
|
aliceRoom: ChatRoomPage;
|
||||||
bobRoom: ChatRoomPage;
|
bobRoom: ChatRoomPage;
|
||||||
aliceMessages: ChatMessagesPage;
|
aliceMessages: ChatMessagesPage;
|
||||||
bobMessages: ChatMessagesPage;
|
bobMessages: ChatMessagesPage;
|
||||||
};
|
}
|
||||||
|
|
||||||
async function createChatScenario(createClient: () => Promise<Client>): Promise<ChatScenario> {
|
async function createChatScenario(createClient: () => Promise<Client>): Promise<ChatScenario> {
|
||||||
const suffix = uniqueName('chat');
|
const suffix = uniqueName('chat');
|
||||||
@@ -170,6 +171,7 @@ async function createChatScenario(createClient: () => Promise<Client>): Promise<
|
|||||||
aliceCredentials.displayName,
|
aliceCredentials.displayName,
|
||||||
aliceCredentials.password
|
aliceCredentials.password
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(alice.page).toHaveURL(/\/search/, { timeout: 15_000 });
|
await expect(alice.page).toHaveURL(/\/search/, { timeout: 15_000 });
|
||||||
|
|
||||||
await bobRegisterPage.goto();
|
await bobRegisterPage.goto();
|
||||||
@@ -178,6 +180,7 @@ async function createChatScenario(createClient: () => Promise<Client>): Promise<
|
|||||||
bobCredentials.displayName,
|
bobCredentials.displayName,
|
||||||
bobCredentials.password
|
bobCredentials.password
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(bob.page).toHaveURL(/\/search/, { timeout: 15_000 });
|
await expect(bob.page).toHaveURL(/\/search/, { timeout: 15_000 });
|
||||||
|
|
||||||
const aliceSearchPage = new ServerSearchPage(alice.page);
|
const aliceSearchPage = new ServerSearchPage(alice.page);
|
||||||
@@ -185,6 +188,7 @@ async function createChatScenario(createClient: () => Promise<Client>): Promise<
|
|||||||
await aliceSearchPage.createServer(serverName, {
|
await aliceSearchPage.createServer(serverName, {
|
||||||
description: 'E2E chat server for messaging feature coverage'
|
description: 'E2E chat server for messaging feature coverage'
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
|
||||||
const bobSearchPage = new ServerSearchPage(bob.page);
|
const bobSearchPage = new ServerSearchPage(bob.page);
|
||||||
@@ -259,6 +263,7 @@ async function installChatFeatureMocks(page: Page): Promise<void> {
|
|||||||
siteName: 'Mock Docs'
|
siteName: 'Mock Docs'
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,5 +296,6 @@ function buildMockSvgMarkup(label: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function uniqueName(prefix: string): string {
|
function uniqueName(prefix: string): string {
|
||||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
||||||
|
.slice(2, 8)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
124
electron/idle/idle-monitor.spec.ts
Normal file
124
electron/idle/idle-monitor.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect,
|
||||||
|
vi,
|
||||||
|
beforeEach,
|
||||||
|
afterEach
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
// Mock Electron modules before importing the module under test
|
||||||
|
const mockGetSystemIdleTime = vi.fn(() => 0);
|
||||||
|
const mockSend = vi.fn();
|
||||||
|
const mockGetMainWindow = vi.fn(() => ({
|
||||||
|
isDestroyed: () => false,
|
||||||
|
webContents: { send: mockSend }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('electron', () => ({
|
||||||
|
powerMonitor: {
|
||||||
|
getSystemIdleTime: mockGetSystemIdleTime
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../window/create-window', () => ({
|
||||||
|
getMainWindow: mockGetMainWindow
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
startIdleMonitor,
|
||||||
|
stopIdleMonitor,
|
||||||
|
getIdleState
|
||||||
|
} from './idle-monitor';
|
||||||
|
|
||||||
|
describe('idle-monitor', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(0);
|
||||||
|
mockSend.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
stopIdleMonitor();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns active when idle time is below threshold', () => {
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(0);
|
||||||
|
expect(getIdleState()).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns idle when idle time exceeds 15 minutes', () => {
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||||
|
expect(getIdleState()).toBe('idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends idle-state-changed to renderer when transitioning to idle', () => {
|
||||||
|
startIdleMonitor();
|
||||||
|
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
|
||||||
|
expect(mockSend).toHaveBeenCalledWith('idle-state-changed', 'idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends idle-state-changed to renderer when transitioning back to active', () => {
|
||||||
|
startIdleMonitor();
|
||||||
|
|
||||||
|
// Go idle
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
mockSend.mockClear();
|
||||||
|
|
||||||
|
// Go active
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(5);
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
|
||||||
|
expect(mockSend).toHaveBeenCalledWith('idle-state-changed', 'active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fire duplicates when state stays the same', () => {
|
||||||
|
startIdleMonitor();
|
||||||
|
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
|
||||||
|
// Only one transition, so only one call
|
||||||
|
const idleCalls = mockSend.mock.calls.filter(
|
||||||
|
([channel, state]: [string, string]) => channel === 'idle-state-changed' && state === 'idle'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(idleCalls.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops polling after stopIdleMonitor', () => {
|
||||||
|
startIdleMonitor();
|
||||||
|
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
mockSend.mockClear();
|
||||||
|
|
||||||
|
stopIdleMonitor();
|
||||||
|
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(0);
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
|
||||||
|
expect(mockSend).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not notify when main window is null', () => {
|
||||||
|
mockGetMainWindow.mockReturnValue(null);
|
||||||
|
startIdleMonitor();
|
||||||
|
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
|
||||||
|
expect(mockSend).not.toHaveBeenCalled();
|
||||||
|
mockGetMainWindow.mockReturnValue({
|
||||||
|
isDestroyed: () => false,
|
||||||
|
webContents: { send: mockSend }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
49
electron/idle/idle-monitor.ts
Normal file
49
electron/idle/idle-monitor.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { powerMonitor } from 'electron';
|
||||||
|
import { getMainWindow } from '../window/create-window';
|
||||||
|
|
||||||
|
const IDLE_THRESHOLD_SECONDS = 15 * 60; // 15 minutes
|
||||||
|
const POLL_INTERVAL_MS = 10_000; // Check every 10 seconds
|
||||||
|
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let wasIdle = false;
|
||||||
|
|
||||||
|
const IDLE_STATE_CHANGED_CHANNEL = 'idle-state-changed';
|
||||||
|
|
||||||
|
export type IdleState = 'active' | 'idle';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts polling `powerMonitor.getSystemIdleTime()` and notifies the
|
||||||
|
* renderer whenever the user transitions between active and idle.
|
||||||
|
*/
|
||||||
|
export function startIdleMonitor(): void {
|
||||||
|
if (pollTimer)
|
||||||
|
return;
|
||||||
|
|
||||||
|
pollTimer = setInterval(() => {
|
||||||
|
const idleSeconds = powerMonitor.getSystemIdleTime();
|
||||||
|
const isIdle = idleSeconds >= IDLE_THRESHOLD_SECONDS;
|
||||||
|
|
||||||
|
if (isIdle !== wasIdle) {
|
||||||
|
wasIdle = isIdle;
|
||||||
|
const state: IdleState = isIdle ? 'idle' : 'active';
|
||||||
|
const mainWindow = getMainWindow();
|
||||||
|
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send(IDLE_STATE_CHANGED_CHANNEL, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopIdleMonitor(): void {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIdleState(): IdleState {
|
||||||
|
const idleSeconds = powerMonitor.getSystemIdleTime();
|
||||||
|
|
||||||
|
return idleSeconds >= IDLE_THRESHOLD_SECONDS ? 'idle' : 'active';
|
||||||
|
}
|
||||||
@@ -528,6 +528,7 @@ export function setupSystemHandlers(): void {
|
|||||||
resolve(false);
|
resolve(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
response.on('error', () => resolve(false));
|
response.on('error', () => resolve(false));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -537,7 +538,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;
|
||||||
@@ -557,4 +563,10 @@ export function setupSystemHandlers(): void {
|
|||||||
case 'selectAll': webContents.selectAll(); break;
|
case 'selectAll': webContents.selectAll(); break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('get-idle-state', () => {
|
||||||
|
const { getIdleState } = require('../idle/idle-monitor') as typeof import('../idle/idle-monitor');
|
||||||
|
|
||||||
|
return getIdleState();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ export async function createWindow(): Promise<void> {
|
|||||||
video: firstSource,
|
video: firstSource,
|
||||||
...(request.audioRequested ? { audio: 'loopback' } : {})
|
...(request.audioRequested ? { audio: 'loopback' } : {})
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
373
package-lock.json
generated
373
package-lock.json
generated
@@ -60,6 +60,7 @@
|
|||||||
"@stylistic/eslint-plugin-js": "^4.4.1",
|
"@stylistic/eslint-plugin-js": "^4.4.1",
|
||||||
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
||||||
"@types/auto-launch": "^5.0.5",
|
"@types/auto-launch": "^5.0.5",
|
||||||
|
"@types/mocha": "^10.0.10",
|
||||||
"@types/simple-peer": "^9.11.9",
|
"@types/simple-peer": "^9.11.9",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"angular-eslint": "21.2.0",
|
"angular-eslint": "21.2.0",
|
||||||
@@ -79,6 +80,7 @@
|
|||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"typescript-eslint": "8.50.1",
|
"typescript-eslint": "8.50.1",
|
||||||
|
"vitest": "^4.1.4",
|
||||||
"wait-on": "^7.2.0"
|
"wait-on": "^7.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -11025,6 +11027,17 @@
|
|||||||
"@types/responselike": "^1.0.0"
|
"@types/responselike": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/chai": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/deep-eql": "*",
|
||||||
|
"assertion-error": "^2.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/connect": {
|
"node_modules/@types/connect": {
|
||||||
"version": "3.4.38",
|
"version": "3.4.38",
|
||||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||||
@@ -11306,6 +11319,13 @@
|
|||||||
"@types/ms": "*"
|
"@types/ms": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/deep-eql": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/eslint": {
|
"node_modules/@types/eslint": {
|
||||||
"version": "9.6.1",
|
"version": "9.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||||
@@ -11465,6 +11485,13 @@
|
|||||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/mocha": {
|
||||||
|
"version": "10.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz",
|
||||||
|
"integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/ms": {
|
"node_modules/@types/ms": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
@@ -12270,6 +12297,146 @@
|
|||||||
"vite": "^6.0.0 || ^7.0.0"
|
"vite": "^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/expect": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.1.0",
|
||||||
|
"@types/chai": "^5.2.2",
|
||||||
|
"@vitest/spy": "4.1.4",
|
||||||
|
"@vitest/utils": "4.1.4",
|
||||||
|
"chai": "^6.2.2",
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/mocker": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/spy": "4.1.4",
|
||||||
|
"estree-walker": "^3.0.3",
|
||||||
|
"magic-string": "^0.30.21"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"msw": "^2.4.9",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"msw": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/mocker/node_modules/magic-string": {
|
||||||
|
"version": "0.30.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/pretty-format": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/runner": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/utils": "4.1.4",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/snapshot": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "4.1.4",
|
||||||
|
"@vitest/utils": "4.1.4",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/snapshot/node_modules/magic-string": {
|
||||||
|
"version": "0.30.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/spy": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/utils": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "4.1.4",
|
||||||
|
"convert-source-map": "^2.0.0",
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/utils/node_modules/convert-source-map": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@webassemblyjs/ast": {
|
"node_modules/@webassemblyjs/ast": {
|
||||||
"version": "1.14.1",
|
"version": "1.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
|
||||||
@@ -13108,6 +13275,16 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/assertion-error": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/astral-regex": {
|
"node_modules/astral-regex": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
|
||||||
@@ -14004,6 +14181,16 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chai": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "5.6.2",
|
"version": "5.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||||
@@ -17483,6 +17670,16 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/estree-walker": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esutils": {
|
"node_modules/esutils": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||||
@@ -17562,6 +17759,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expect-type": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/exponential-backoff": {
|
"node_modules/exponential-backoff": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
|
||||||
@@ -23538,6 +23745,17 @@
|
|||||||
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
|
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/obug": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/sxzz",
|
||||||
|
"https://opencollective.com/debug"
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/on-finished": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
@@ -27379,6 +27597,13 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/siginfo": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/signal-exit": {
|
"node_modules/signal-exit": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||||
@@ -27773,6 +27998,13 @@
|
|||||||
"node": "^20.17.0 || >=22.9.0"
|
"node": "^20.17.0 || >=22.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stackback": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/stackframe": {
|
"node_modules/stackframe": {
|
||||||
"version": "1.3.4",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
|
||||||
@@ -27798,6 +28030,13 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/std-env": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/stdin-discarder": {
|
"node_modules/stdin-discarder": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz",
|
||||||
@@ -28671,6 +28910,13 @@
|
|||||||
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
|
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tinybench": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyexec": {
|
"node_modules/tinyexec": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||||
@@ -28696,6 +28942,16 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinyrainbow": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tmp": {
|
"node_modules/tmp": {
|
||||||
"version": "0.2.5",
|
"version": "0.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||||
@@ -30567,6 +30823,106 @@
|
|||||||
"@esbuild/win32-x64": "0.25.12"
|
"@esbuild/win32-x64": "0.25.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vitest": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/expect": "4.1.4",
|
||||||
|
"@vitest/mocker": "4.1.4",
|
||||||
|
"@vitest/pretty-format": "4.1.4",
|
||||||
|
"@vitest/runner": "4.1.4",
|
||||||
|
"@vitest/snapshot": "4.1.4",
|
||||||
|
"@vitest/spy": "4.1.4",
|
||||||
|
"@vitest/utils": "4.1.4",
|
||||||
|
"es-module-lexer": "^2.0.0",
|
||||||
|
"expect-type": "^1.3.0",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"obug": "^2.1.1",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"picomatch": "^4.0.3",
|
||||||
|
"std-env": "^4.0.0-rc.1",
|
||||||
|
"tinybench": "^2.9.0",
|
||||||
|
"tinyexec": "^1.0.2",
|
||||||
|
"tinyglobby": "^0.2.15",
|
||||||
|
"tinyrainbow": "^3.1.0",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||||
|
"why-is-node-running": "^2.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vitest": "vitest.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@edge-runtime/vm": "*",
|
||||||
|
"@opentelemetry/api": "^1.9.0",
|
||||||
|
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||||
|
"@vitest/browser-playwright": "4.1.4",
|
||||||
|
"@vitest/browser-preview": "4.1.4",
|
||||||
|
"@vitest/browser-webdriverio": "4.1.4",
|
||||||
|
"@vitest/coverage-istanbul": "4.1.4",
|
||||||
|
"@vitest/coverage-v8": "4.1.4",
|
||||||
|
"@vitest/ui": "4.1.4",
|
||||||
|
"happy-dom": "*",
|
||||||
|
"jsdom": "*",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@edge-runtime/vm": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@opentelemetry/api": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-playwright": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-preview": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-webdriverio": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/coverage-istanbul": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/coverage-v8": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/ui": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"happy-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"jsdom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/magic-string": {
|
||||||
|
"version": "0.30.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vscode-jsonrpc": {
|
"node_modules/vscode-jsonrpc": {
|
||||||
"version": "8.2.0",
|
"version": "8.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
|
||||||
@@ -31439,6 +31795,23 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/why-is-node-running": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"siginfo": "^2.0.0",
|
||||||
|
"stackback": "0.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"why-is-node-running": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wildcard": {
|
"node_modules/wildcard": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"build:all": "npm run build && npm run build:electron && cd server && npm run build",
|
"build:all": "npm run build && npm run build:electron && cd server && npm run build",
|
||||||
"build:prod": "cd \"toju-app\" && ng build --configuration production --base-href='./'",
|
"build:prod": "cd \"toju-app\" && ng build --configuration production --base-href='./'",
|
||||||
"watch": "cd \"toju-app\" && ng build --watch --configuration development",
|
"watch": "cd \"toju-app\" && ng build --watch --configuration development",
|
||||||
"test": "cd \"toju-app\" && ng test",
|
"test": "cd \"toju-app\" && vitest run",
|
||||||
"server:build": "cd server && npm run build",
|
"server:build": "cd server && npm run build",
|
||||||
"server:start": "cd server && npm start",
|
"server:start": "cd server && npm start",
|
||||||
"server:dev": "cd server && npm run dev",
|
"server:dev": "cd server && npm run dev",
|
||||||
@@ -110,6 +110,7 @@
|
|||||||
"@stylistic/eslint-plugin-js": "^4.4.1",
|
"@stylistic/eslint-plugin-js": "^4.4.1",
|
||||||
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
||||||
"@types/auto-launch": "^5.0.5",
|
"@types/auto-launch": "^5.0.5",
|
||||||
|
"@types/mocha": "^10.0.10",
|
||||||
"@types/simple-peer": "^9.11.9",
|
"@types/simple-peer": "^9.11.9",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"angular-eslint": "21.2.0",
|
"angular-eslint": "21.2.0",
|
||||||
@@ -129,6 +130,7 @@
|
|||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"typescript-eslint": "8.50.1",
|
"typescript-eslint": "8.50.1",
|
||||||
|
"vitest": "^4.1.4",
|
||||||
"wait-on": "^7.2.0"
|
"wait-on": "^7.2.0"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
|
|||||||
@@ -122,10 +122,12 @@ 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…`);
|
console.log(`\n[Shutdown] ${signal} received - closing database…`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await destroyDatabase();
|
await destroyDatabase();
|
||||||
|
|||||||
191
server/src/websocket/handler-status.spec.ts
Normal file
191
server/src/websocket/handler-status.spec.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ws2 = user2.ws as unknown as { sentMessages: string[] };
|
||||||
|
const messages = ws2.sentMessages.map((m: string) => JSON.parse(m));
|
||||||
|
const statusMsg = messages.find((m: { type: string }) => m.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');
|
||||||
|
|
||||||
|
connectedUsers.get('conn-1')!.serverIds.add('server-1');
|
||||||
|
user2.serverIds.add('server-2');
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'away' });
|
||||||
|
|
||||||
|
const ws2 = user2.ws as unknown as { sentMessages: string[] };
|
||||||
|
|
||||||
|
expect(ws2.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
|
||||||
|
(user2.ws as unknown as { sentMessages: string[] }).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
|
||||||
|
(user2.ws as unknown as { sentMessages: string[] }).sentMessages.length = 0;
|
||||||
|
await handleWebSocketMessage('conn-2', { type: 'join_server', serverId: 'server-1' });
|
||||||
|
|
||||||
|
const ws2 = user2.ws as unknown as { sentMessages: string[] };
|
||||||
|
const messages = ws2.sentMessages.map((m: string) => JSON.parse(m));
|
||||||
|
const serverUsersMsg = messages.find((m: { type: string }) => m.type === 'server_users');
|
||||||
|
|
||||||
|
expect(serverUsersMsg).toBeDefined();
|
||||||
|
|
||||||
|
const user1InList = serverUsersMsg.users.find((u: { oderId: string }) => u.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
|
||||||
|
connectedUsers.get('conn-1')!.status = 'busy';
|
||||||
|
|
||||||
|
// Identify user-1
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' });
|
||||||
|
|
||||||
|
(user2.ws as unknown as { sentMessages: string[] }).sentMessages.length = 0;
|
||||||
|
|
||||||
|
// user-1 joins server-1
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'join_server', serverId: 'server-1' });
|
||||||
|
|
||||||
|
const ws2 = user2.ws as unknown as { sentMessages: string[] };
|
||||||
|
const messages = ws2.sentMessages.map((m: string) => JSON.parse(m));
|
||||||
|
const joinMsg = messages.find((m: { type: string }) => m.type === 'user_joined');
|
||||||
|
|
||||||
|
// user_joined may or may not appear depending on whether it's a new identity membership
|
||||||
|
// Since both are already in the server, it may not broadcast. Either way, verify no crash.
|
||||||
|
if (joinMsg) {
|
||||||
|
expect(joinMsg.status).toBe('busy');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,7 +37,7 @@ function readMessageId(value: unknown): string | undefined {
|
|||||||
/** Sends the current user list for a given server to a single connected user. */
|
/** Sends the current user list for a given server to a single connected user. */
|
||||||
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
||||||
const users = getUniqueUsersInServer(serverId, user.oderId)
|
const users = getUniqueUsersInServer(serverId, user.oderId)
|
||||||
.map(cu => ({ oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName) }));
|
.map(cu => ({ oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName), status: cu.status ?? 'online' }));
|
||||||
|
|
||||||
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
||||||
}
|
}
|
||||||
@@ -108,6 +108,7 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
|||||||
type: 'user_joined',
|
type: 'user_joined',
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
displayName: normalizeDisplayName(user.displayName),
|
displayName: normalizeDisplayName(user.displayName),
|
||||||
|
status: user.status ?? 'online',
|
||||||
serverId: sid
|
serverId: sid
|
||||||
}, user.oderId);
|
}, user.oderId);
|
||||||
}
|
}
|
||||||
@@ -204,6 +205,32 @@ function handleTyping(user: ConnectedUser, message: WsMessage): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VALID_STATUSES = new Set([
|
||||||
|
'online',
|
||||||
|
'away',
|
||||||
|
'busy',
|
||||||
|
'offline'
|
||||||
|
]);
|
||||||
|
|
||||||
|
function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
|
const status = typeof message['status'] === 'string' ? message['status'] : undefined;
|
||||||
|
|
||||||
|
if (!status || !VALID_STATUSES.has(status))
|
||||||
|
return;
|
||||||
|
|
||||||
|
user.status = status as ConnectedUser['status'];
|
||||||
|
connectedUsers.set(connectionId, user);
|
||||||
|
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) status → ${status}`);
|
||||||
|
|
||||||
|
for (const serverId of user.serverIds) {
|
||||||
|
broadcastToServer(serverId, {
|
||||||
|
type: 'status_update',
|
||||||
|
oderId: user.oderId,
|
||||||
|
status
|
||||||
|
}, user.oderId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
|
export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
|
||||||
const user = connectedUsers.get(connectionId);
|
const user = connectedUsers.get(connectionId);
|
||||||
|
|
||||||
@@ -241,6 +268,10 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
|||||||
handleTyping(user, message);
|
handleTyping(user, message);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'status_update':
|
||||||
|
handleStatusUpdate(user, message, connectionId);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.log('Unknown message type:', message.type);
|
console.log('Unknown message type:', message.type);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export interface ConnectedUser {
|
|||||||
* URLs routing to the same server coexist without an eviction loop.
|
* URLs routing to the same server coexist without an eviction loop.
|
||||||
*/
|
*/
|
||||||
connectionScope?: string;
|
connectionScope?: string;
|
||||||
|
/** User availability status (online, away, busy, offline). */
|
||||||
|
status?: 'online' | 'away' | 'busy' | 'offline';
|
||||||
/** Timestamp of the last pong received (used to detect dead connections). */
|
/** Timestamp of the last pong received (used to detect dead connections). */
|
||||||
lastPong: number;
|
lastPong: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,5 +17,5 @@
|
|||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist", "src/**/*.spec.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ 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 { UserStatusService } from './core/services/user-status.service';
|
||||||
import { ServersRailComponent } from './features/servers/servers-rail.component';
|
import { ServersRailComponent } from './features/servers/servers-rail.component';
|
||||||
import { TitleBarComponent } from './features/shell/title-bar.component';
|
import { TitleBarComponent } from './features/shell/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';
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export * from './notification-audio.service';
|
|||||||
export * from '../models/debugging.models';
|
export * from '../models/debugging.models';
|
||||||
export * from './debugging/debugging.service';
|
export * from './debugging/debugging.service';
|
||||||
export * from './settings-modal.service';
|
export * from './settings-modal.service';
|
||||||
|
export * from './user-status.service';
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ export class NotificationAudioService {
|
|||||||
/** Reactive notification volume (0 - 1), persisted to localStorage. */
|
/** Reactive notification volume (0 - 1), persisted to localStorage. */
|
||||||
readonly notificationVolume = signal(this.loadVolume());
|
readonly notificationVolume = signal(this.loadVolume());
|
||||||
|
|
||||||
|
/** When true, all sound playback is suppressed (Do Not Disturb). */
|
||||||
|
readonly dndMuted = signal(false);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.preload();
|
this.preload();
|
||||||
}
|
}
|
||||||
@@ -106,6 +109,9 @@ export class NotificationAudioService {
|
|||||||
* the persisted {@link notificationVolume} is used.
|
* the persisted {@link notificationVolume} is used.
|
||||||
*/
|
*/
|
||||||
play(sound: AppSound, volumeOverride?: number): void {
|
play(sound: AppSound, volumeOverride?: number): void {
|
||||||
|
if (this.dndMuted())
|
||||||
|
return;
|
||||||
|
|
||||||
const cached = this.cache.get(sound);
|
const cached = this.cache.get(sound);
|
||||||
const src = this.sources.get(sound);
|
const src = this.sources.get(sound);
|
||||||
|
|
||||||
|
|||||||
166
toju-app/src/app/core/services/user-status.service.ts
Normal file
166
toju-app/src/app/core/services/user-status.service.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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 ((window as any).electronAPI?.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 = (window as { electronAPI?: {
|
||||||
|
onIdleStateChanged: (cb: (state: 'active' | 'idle') => void) => () => void;
|
||||||
|
getIdleState: () => Promise<'active' | 'idle'>;
|
||||||
|
}; }).electronAPI!;
|
||||||
|
|
||||||
|
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.store.selectSignal(selectManualStatus)();
|
||||||
|
|
||||||
|
// Manual status overrides automatic
|
||||||
|
if (manualStatus)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const currentUser = this.store.selectSignal(selectCurrentUser)();
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,35 +1,43 @@
|
|||||||
<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="relative flex items-center justify-center w-8 h-8 rounded-full bg-secondary text-foreground text-sm font-medium hover:bg-secondary/80 transition-colors"
|
||||||
<span class="text-foreground">{{ user()?.displayName }}</span>
|
(click)="toggleProfileCard(avatarBtn)"
|
||||||
|
>
|
||||||
|
{{ user()!.displayName?.charAt(0)?.toUpperCase() || '?' }}
|
||||||
|
<span
|
||||||
|
class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-card"
|
||||||
|
[class]="currentStatusColor()"
|
||||||
|
></span>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -3,19 +3,16 @@ 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';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-user-bar',
|
selector: 'app-user-bar',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, NgIcon],
|
imports: [CommonModule, NgIcon],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({ lucideUser,
|
provideIcons({
|
||||||
lucideLogIn,
|
lucideLogIn,
|
||||||
lucideUserPlus })
|
lucideUserPlus })
|
||||||
],
|
],
|
||||||
@@ -29,6 +26,29 @@ 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);
|
||||||
|
|
||||||
|
currentStatusColor(): string {
|
||||||
|
const status = this.user()?.status;
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 'online': return 'bg-green-500';
|
||||||
|
case 'away': return 'bg-yellow-500';
|
||||||
|
case 'busy': return 'bg-red-500';
|
||||||
|
case 'offline': return 'bg-gray-500';
|
||||||
|
case 'disconnected': return 'bg-gray-500';
|
||||||
|
default: return 'bg-green-500';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleProfileCard(origin: HTMLElement): void {
|
||||||
|
const user = this.user();
|
||||||
|
|
||||||
|
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') {
|
||||||
|
|||||||
@@ -6,11 +6,15 @@
|
|||||||
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]="msg.senderName"
|
||||||
|
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 +38,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="flex items-baseline gap-2">
|
<div class="flex items-baseline gap-2">
|
||||||
<span class="font-semibold text-foreground">{{ msg.senderName }}</span>
|
<span
|
||||||
|
class="font-semibold text-foreground cursor-pointer hover:underline"
|
||||||
|
(click)="openSenderProfileCard($event); $event.stopPropagation()"
|
||||||
|
>{{ msg.senderName }}</span
|
||||||
|
>
|
||||||
<span class="text-xs text-muted-foreground">{{ formatTimestamp(msg.timestamp) }}</span>
|
<span class="text-xs text-muted-foreground">{{ formatTimestamp(msg.timestamp) }}</span>
|
||||||
@if (msg.editedAt && !msg.isDeleted) {
|
@if (msg.editedAt && !msg.isDeleted) {
|
||||||
<span class="text-xs text-muted-foreground">(edited)</span>
|
<span class="text-xs text-muted-foreground">(edited)</span>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
signal,
|
signal,
|
||||||
ViewChild
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { Store } from '@ngrx/store';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import {
|
import {
|
||||||
lucideCheck,
|
lucideCheck,
|
||||||
@@ -30,10 +31,16 @@ 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 { selectAllUsers } from '../../../../../../store/users/users.selectors';
|
||||||
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.component';
|
||||||
@@ -114,6 +121,9 @@ 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 store = inject(Store);
|
||||||
|
private readonly allUsers = this.store.selectSignal(selectAllUsers);
|
||||||
|
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>();
|
||||||
@@ -139,6 +149,27 @@ export class ChatMessageItemComponent {
|
|||||||
|
|
||||||
editContent = '';
|
editContent = '';
|
||||||
|
|
||||||
|
openSenderProfileCard(event: MouseEvent): void {
|
||||||
|
event.stopPropagation();
|
||||||
|
const el = event.currentTarget as HTMLElement;
|
||||||
|
const msg = this.message();
|
||||||
|
// Look up full user from store
|
||||||
|
const users = this.allUsers();
|
||||||
|
const found = users.find((u) => u.id === msg.senderId || u.oderId === msg.senderId);
|
||||||
|
const user: User = found ?? {
|
||||||
|
id: msg.senderId,
|
||||||
|
oderId: msg.senderId,
|
||||||
|
username: msg.senderName,
|
||||||
|
displayName: msg.senderName,
|
||||||
|
status: 'disconnected',
|
||||||
|
role: 'member',
|
||||||
|
joinedAt: 0
|
||||||
|
};
|
||||||
|
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();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { Component, computed, input } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
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})/;
|
||||||
|
|||||||
@@ -27,17 +27,14 @@
|
|||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<!-- Avatar with online indicator -->
|
<!-- Avatar with status indicator -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<app-user-avatar
|
<app-user-avatar
|
||||||
[name]="user.displayName"
|
[name]="user.displayName"
|
||||||
|
[status]="user.status"
|
||||||
|
[showStatusBadge]="true"
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<span
|
|
||||||
class="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-card"
|
|
||||||
[class.bg-green-500]="user.isOnline !== false && user.status !== 'offline'"
|
|
||||||
[class.bg-gray-500]="user.isOnline === false || user.status === 'offline'"
|
|
||||||
></span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- User Info -->
|
<!-- User Info -->
|
||||||
@@ -59,6 +56,16 @@
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@if (user.status && user.status !== 'online') {
|
||||||
|
<span
|
||||||
|
class="text-xs"
|
||||||
|
[class.text-yellow-500]="user.status === 'away'"
|
||||||
|
[class.text-red-500]="user.status === 'busy'"
|
||||||
|
[class.text-muted-foreground]="user.status === 'offline'"
|
||||||
|
>
|
||||||
|
{{ user.status === 'busy' ? 'Do Not Disturb' : (user.status | titlecase) }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Voice/Screen Status -->
|
<!-- Voice/Screen Status -->
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export function shouldDeliverNotification(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings.respectBusyStatus && context.currentUser?.status === 'busy') {
|
if (context.currentUser?.status === 'busy') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,25 +15,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<!-- 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>
|
size="sm"
|
||||||
@if (showConnectionError() || isConnected()) {
|
[status]="currentUser()?.status"
|
||||||
<p class="text-xs text-muted-foreground">
|
[showStatusBadge]="true"
|
||||||
@if (showConnectionError()) {
|
/>
|
||||||
<span class="text-destructive">Connection Error</span>
|
<div class="flex-1 min-w-0">
|
||||||
} @else if (isConnected()) {
|
<p class="font-medium text-sm text-foreground truncate text-left">
|
||||||
<span class="text-green-500">Connected</span>
|
{{ 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"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars, complexity */
|
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars, complexity */
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
|
ElementRef,
|
||||||
inject,
|
inject,
|
||||||
signal,
|
signal,
|
||||||
OnInit,
|
OnInit,
|
||||||
@@ -34,7 +35,8 @@ import { SettingsModalService } from '../../../../core/services/settings-modal.s
|
|||||||
import {
|
import {
|
||||||
DebugConsoleComponent,
|
DebugConsoleComponent,
|
||||||
ScreenShareQualityDialogComponent,
|
ScreenShareQualityDialogComponent,
|
||||||
UserAvatarComponent
|
UserAvatarComponent,
|
||||||
|
ProfileCardService
|
||||||
} from '../../../../shared';
|
} from '../../../../shared';
|
||||||
|
|
||||||
interface AudioDevice {
|
interface AudioDevice {
|
||||||
@@ -75,6 +77,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||||
private readonly store = inject(Store);
|
private readonly store = inject(Store);
|
||||||
private readonly settingsModal = inject(SettingsModalService);
|
private readonly settingsModal = inject(SettingsModalService);
|
||||||
|
private readonly hostEl = inject(ElementRef);
|
||||||
|
private readonly profileCard = inject(ProfileCardService);
|
||||||
|
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
@@ -88,6 +92,15 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
isScreenSharing = this.screenShareService.isScreenSharing;
|
isScreenSharing = this.screenShareService.isScreenSharing;
|
||||||
showSettings = signal(false);
|
showSettings = signal(false);
|
||||||
|
|
||||||
|
toggleProfileCard(): void {
|
||||||
|
const user = this.currentUser();
|
||||||
|
|
||||||
|
if (!user)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.profileCard.open(this.hostEl.nativeElement, user, { placement: 'above', editable: true });
|
||||||
|
}
|
||||||
|
|
||||||
inputDevices = signal<AudioDevice[]>([]);
|
inputDevices = signal<AudioDevice[]>([]);
|
||||||
outputDevices = signal<AudioDevice[]>([]);
|
outputDevices = signal<AudioDevice[]>([]);
|
||||||
selectedInputDevice = signal<string>('');
|
selectedInputDevice = signal<string>('');
|
||||||
|
|||||||
@@ -164,7 +164,10 @@
|
|||||||
</button>
|
</button>
|
||||||
<!-- Voice users connected to this channel -->
|
<!-- Voice users connected to this channel -->
|
||||||
@if (voiceUsersInRoom(ch.id).length > 0) {
|
@if (voiceUsersInRoom(ch.id).length > 0) {
|
||||||
<div class="ml-5 mt-1 space-y-1 border-l border-border pb-1 pl-2">
|
<div
|
||||||
|
class="mt-1 space-y-1 border-l border-border pb-1 pl-2"
|
||||||
|
style="margin-left: 0.91rem"
|
||||||
|
>
|
||||||
@for (u of voiceUsersInRoom(ch.id); track u.id) {
|
@for (u of voiceUsersInRoom(ch.id); track u.id) {
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-secondary/50"
|
class="flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-secondary/50"
|
||||||
@@ -241,15 +244,17 @@
|
|||||||
@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
|
(click)="openProfileCard($event, currentUser()!, true); $event.stopPropagation()"
|
||||||
[name]="currentUser()?.displayName || '?'"
|
>
|
||||||
[avatarUrl]="currentUser()?.avatarUrl"
|
<app-user-avatar
|
||||||
size="sm"
|
[name]="currentUser()?.displayName || '?'"
|
||||||
/>
|
[avatarUrl]="currentUser()?.avatarUrl"
|
||||||
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
|
size="sm"
|
||||||
</div>
|
[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 +292,17 @@
|
|||||||
<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"
|
||||||
(contextmenu)="openUserContextMenu($event, user)"
|
(contextmenu)="openUserContextMenu($event, user)"
|
||||||
|
(click)="openProfileCard($event, user, false); $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,17 @@
|
|||||||
<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
|
(click)="openProfileCardForMember($event, member); $event.stopPropagation()"
|
||||||
[name]="member.displayName"
|
>
|
||||||
[avatarUrl]="member.avatarUrl"
|
<app-user-avatar
|
||||||
size="sm"
|
[name]="member.displayName"
|
||||||
/>
|
[avatarUrl]="member.avatarUrl"
|
||||||
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-gray-500 ring-2 ring-card"></span>
|
size="sm"
|
||||||
</div>
|
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>
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ import {
|
|||||||
ContextMenuComponent,
|
ContextMenuComponent,
|
||||||
UserAvatarComponent,
|
UserAvatarComponent,
|
||||||
ConfirmDialogComponent,
|
ConfirmDialogComponent,
|
||||||
UserVolumeMenuComponent
|
UserVolumeMenuComponent,
|
||||||
|
ProfileCardService
|
||||||
} from '../../../shared';
|
} from '../../../shared';
|
||||||
import {
|
import {
|
||||||
Channel,
|
Channel,
|
||||||
@@ -101,6 +102,7 @@ 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);
|
||||||
|
private profileCard = inject(ProfileCardService);
|
||||||
voiceActivity = inject(VoiceActivityService);
|
voiceActivity = inject(VoiceActivityService);
|
||||||
|
|
||||||
readonly panelMode = input<PanelMode>('channels');
|
readonly panelMode = input<PanelMode>('channels');
|
||||||
@@ -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: MouseEvent, user: User, editable: boolean): void {
|
||||||
|
event.stopPropagation();
|
||||||
|
const el = event.currentTarget as HTMLElement;
|
||||||
|
|
||||||
|
this.profileCard.open(el, user, { placement: 'left', editable });
|
||||||
|
}
|
||||||
|
|
||||||
|
openProfileCardForMember(event: MouseEvent, 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,20 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="grid w-full overflow-hidden duration-200 ease-out motion-reduce:transition-none"
|
||||||
|
style="transition-property: grid-template-rows, opacity"
|
||||||
|
[style.gridTemplateRows]="isOnSearch() ? '1fr' : '0fr'"
|
||||||
|
[style.opacity]="isOnSearch() ? '1' : '0'"
|
||||||
|
[style.visibility]="isOnSearch() ? 'visible' : 'hidden'"
|
||||||
|
[class.pointer-events-none]="!isOnSearch()"
|
||||||
|
[attr.aria-hidden]="isOnSearch() ? null : 'true'"
|
||||||
|
>
|
||||||
|
<div class="overflow-hidden">
|
||||||
|
<app-user-bar />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Context menu -->
|
<!-- Context menu -->
|
||||||
|
|||||||
@@ -7,24 +7,27 @@ 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 { UserBarComponent } from '../../domains/authentication/feature/user-bar/user-bar.component';
|
||||||
import { VoiceSessionFacade } from '../../domains/voice-session';
|
import { VoiceSessionFacade } from '../../domains/voice-session';
|
||||||
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||||
import { selectCurrentUser, selectOnlineUsers } from '../../store/users/users.selectors';
|
import { selectCurrentUser, selectOnlineUsers } from '../../store/users/users.selectors';
|
||||||
@@ -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((e): e is NavigationEnd => e instanceof NavigationEnd),
|
||||||
|
map((e) => e.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);
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ 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. Some upstream packages include their own copyright notices in addition to this standard license text.';
|
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.';
|
||||||
|
|
||||||
const MIT_LICENSE_TEXT = toLicenseText([
|
const MIT_LICENSE_TEXT = toLicenseText([
|
||||||
'MIT License',
|
'MIT License',
|
||||||
'',
|
'',
|
||||||
@@ -35,7 +33,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 +188,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 +216,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 +233,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 +310,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 +319,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 +328,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,16 +49,14 @@ export class DesktopElectronScreenShareCapture {
|
|||||||
|
|
||||||
const sources = await electronApi.getSources();
|
const sources = await electronApi.getSources();
|
||||||
const selection = await this.resolveSourceSelection(sources, options.includeSystemAudio);
|
const selection = await this.resolveSourceSelection(sources, options.includeSystemAudio);
|
||||||
|
|
||||||
// On Windows, electron-desktop loopback audio captures all system output
|
// On Windows, electron-desktop loopback audio captures all system output
|
||||||
// including the app's voice playback, creating echo for watchers or
|
// including the app's voice playback, creating echo for watchers or
|
||||||
// requiring total voice muting for the sharer. The getDisplayMedia path
|
// requiring total voice muting for the sharer. The getDisplayMedia path
|
||||||
// handles this correctly via restrictOwnAudio — if we fell back here,
|
// handles this correctly via restrictOwnAudio - if we fell back here,
|
||||||
// share video only so voice chat stays functional.
|
// share video only so voice chat stays functional.
|
||||||
const effectiveIncludeSystemAudio = this.isWindowsElectron()
|
const effectiveIncludeSystemAudio = this.isWindowsElectron()
|
||||||
? false
|
? false
|
||||||
: selection.includeSystemAudio;
|
: selection.includeSystemAudio;
|
||||||
|
|
||||||
const captureOptions = {
|
const captureOptions = {
|
||||||
...options,
|
...options,
|
||||||
includeSystemAudio: effectiveIncludeSystemAudio
|
includeSystemAudio: effectiveIncludeSystemAudio
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type {
|
|||||||
ScreenShareState
|
ScreenShareState
|
||||||
} from './voice-state.models';
|
} from './voice-state.models';
|
||||||
|
|
||||||
export type UserStatus = 'online' | 'away' | 'busy' | 'offline';
|
export type UserStatus = 'online' | 'away' | 'busy' | 'offline' | 'disconnected';
|
||||||
|
|
||||||
export type UserRole = 'host' | 'admin' | 'moderator' | 'member';
|
export type UserRole = 'host' | 'admin' | 'moderator' | 'member';
|
||||||
|
|
||||||
|
|||||||
@@ -175,9 +175,7 @@
|
|||||||
@if (activeTab() === 'logs') {
|
@if (activeTab() === 'logs') {
|
||||||
@if (isTruncated()) {
|
@if (isTruncated()) {
|
||||||
<div class="flex items-center justify-between border-b border-border bg-muted/50 px-4 py-1.5">
|
<div class="flex items-center justify-between border-b border-border bg-muted/50 px-4 py-1.5">
|
||||||
<span class="text-xs text-muted-foreground">
|
<span class="text-xs text-muted-foreground"> Showing latest 500 of {{ filteredEntries().length }} entries </span>
|
||||||
Showing latest 500 of {{ filteredEntries().length }} entries
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-xs font-medium text-primary hover:underline"
|
class="text-xs font-medium text-primary hover:underline"
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
|
import { lucideChevronDown } from '@ng-icons/lucide';
|
||||||
|
import { UserAvatarComponent } from '../user-avatar/user-avatar.component';
|
||||||
|
import { UserStatusService } from '../../../core/services/user-status.service';
|
||||||
|
import { User, UserStatus } from '../../../shared-kernel';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-profile-card',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
NgIcon,
|
||||||
|
UserAvatarComponent
|
||||||
|
],
|
||||||
|
viewProviders: [provideIcons({ lucideChevronDown })],
|
||||||
|
template: `
|
||||||
|
<div
|
||||||
|
class="w-72 rounded-lg border border-border bg-card shadow-xl"
|
||||||
|
style="animation: profile-card-in 120ms cubic-bezier(0.2, 0, 0, 1) both"
|
||||||
|
>
|
||||||
|
<!-- Banner -->
|
||||||
|
<div class="h-24 rounded-t-lg bg-gradient-to-r from-primary/30 to-primary/10"></div>
|
||||||
|
|
||||||
|
<!-- Avatar (overlapping banner) -->
|
||||||
|
<div class="relative px-4">
|
||||||
|
<div class="-mt-9">
|
||||||
|
<app-user-avatar
|
||||||
|
[name]="user().displayName"
|
||||||
|
[avatarUrl]="user().avatarUrl"
|
||||||
|
size="xl"
|
||||||
|
[status]="user().status"
|
||||||
|
[showStatusBadge]="true"
|
||||||
|
ringClass="ring-4 ring-card"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info -->
|
||||||
|
<div class="px-5 pt-3 pb-4">
|
||||||
|
<p class="text-base font-semibold text-foreground truncate">{{ user().displayName }}</p>
|
||||||
|
<p class="text-sm text-muted-foreground truncate">{{ user().username }}</p>
|
||||||
|
|
||||||
|
@if (editable()) {
|
||||||
|
<!-- Status picker -->
|
||||||
|
<div class="relative mt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-2 rounded-md border border-border px-2.5 py-1.5 text-xs hover:bg-secondary/60 transition-colors"
|
||||||
|
(click)="toggleStatusMenu()"
|
||||||
|
>
|
||||||
|
<span class="w-2 h-2 rounded-full" [class]="currentStatusColor()"></span>
|
||||||
|
<span class="flex-1 text-left text-foreground">{{ currentStatusLabel() }}</span>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideChevronDown"
|
||||||
|
class="w-3 h-3 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
@if (showStatusMenu()) {
|
||||||
|
<div class="absolute left-0 bottom-full mb-1 w-full bg-card border border-border rounded-md shadow-lg py-1 z-10">
|
||||||
|
@for (opt of statusOptions; track opt.label) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full px-3 py-1.5 text-left text-xs hover:bg-secondary flex items-center gap-2"
|
||||||
|
(click)="setStatus(opt.value)"
|
||||||
|
>
|
||||||
|
<span class="w-2 h-2 rounded-full" [class]="opt.color"></span>
|
||||||
|
<span>{{ opt.label }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<span class="w-2 h-2 rounded-full" [class]="currentStatusColor()"></span>
|
||||||
|
<span>{{ currentStatusLabel() }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class ProfileCardComponent {
|
||||||
|
user = signal<User>({ id: '', oderId: '', username: '', displayName: '', status: 'offline', role: 'member', joinedAt: 0 });
|
||||||
|
editable = signal(false);
|
||||||
|
|
||||||
|
private userStatus = inject(UserStatusService);
|
||||||
|
showStatusMenu = signal(false);
|
||||||
|
|
||||||
|
readonly statusOptions: { value: UserStatus | null; label: string; color: string }[] = [
|
||||||
|
{ value: null, label: 'Online', color: 'bg-green-500' },
|
||||||
|
{ value: 'away', label: 'Away', color: 'bg-yellow-500' },
|
||||||
|
{ value: 'busy', label: 'Do Not Disturb', color: 'bg-red-500' },
|
||||||
|
{ value: 'offline', label: 'Invisible', color: 'bg-gray-500' }
|
||||||
|
];
|
||||||
|
|
||||||
|
currentStatusColor(): string {
|
||||||
|
switch (this.user().status) {
|
||||||
|
case 'online': return 'bg-green-500';
|
||||||
|
case 'away': return 'bg-yellow-500';
|
||||||
|
case 'busy': return 'bg-red-500';
|
||||||
|
case 'offline': return 'bg-gray-500';
|
||||||
|
case 'disconnected': return 'bg-gray-500';
|
||||||
|
default: return 'bg-green-500';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentStatusLabel(): string {
|
||||||
|
switch (this.user().status) {
|
||||||
|
case 'online': return 'Online';
|
||||||
|
case 'away': return 'Away';
|
||||||
|
case 'busy': return 'Do Not Disturb';
|
||||||
|
case 'offline': return 'Invisible';
|
||||||
|
case 'disconnected': return 'Offline';
|
||||||
|
default: return 'Online';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleStatusMenu(): void {
|
||||||
|
this.showStatusMenu.update((v) => !v);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(status: UserStatus | null): void {
|
||||||
|
this.userStatus.setManualStatus(status);
|
||||||
|
this.showStatusMenu.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import {
|
||||||
|
ElementRef,
|
||||||
|
inject,
|
||||||
|
Injectable
|
||||||
|
} from '@angular/core';
|
||||||
|
import {
|
||||||
|
Overlay,
|
||||||
|
OverlayRef,
|
||||||
|
ConnectedPosition
|
||||||
|
} from '@angular/cdk/overlay';
|
||||||
|
import { ComponentPortal } from '@angular/cdk/portal';
|
||||||
|
import {
|
||||||
|
Subscription,
|
||||||
|
filter,
|
||||||
|
fromEvent
|
||||||
|
} from 'rxjs';
|
||||||
|
import { ProfileCardComponent } from './profile-card.component';
|
||||||
|
import { User } from '../../../shared-kernel';
|
||||||
|
|
||||||
|
export type ProfileCardPlacement = 'above' | 'left' | 'auto';
|
||||||
|
|
||||||
|
interface ProfileCardOptions {
|
||||||
|
editable?: boolean;
|
||||||
|
placement?: ProfileCardPlacement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GAP = 10;
|
||||||
|
const VIEWPORT_MARGIN = 8;
|
||||||
|
|
||||||
|
function positionsFor(placement: ProfileCardPlacement): ConnectedPosition[] {
|
||||||
|
switch (placement) {
|
||||||
|
case 'above':
|
||||||
|
return [
|
||||||
|
{ originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -GAP },
|
||||||
|
{ originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: GAP },
|
||||||
|
{ originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom', offsetY: -GAP },
|
||||||
|
{ originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top', offsetY: GAP }
|
||||||
|
];
|
||||||
|
case 'left':
|
||||||
|
return [
|
||||||
|
{ originX: 'start', originY: 'top', overlayX: 'end', overlayY: 'top', offsetX: -GAP },
|
||||||
|
{ originX: 'end', originY: 'top', overlayX: 'start', overlayY: 'top', offsetX: GAP },
|
||||||
|
{ originX: 'start', originY: 'bottom', overlayX: 'end', overlayY: 'bottom', offsetX: -GAP },
|
||||||
|
{ originX: 'end', originY: 'bottom', overlayX: 'start', overlayY: 'bottom', offsetX: GAP }
|
||||||
|
];
|
||||||
|
default: // 'auto'
|
||||||
|
return [
|
||||||
|
{ originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -GAP },
|
||||||
|
{ originX: 'end', originY: 'top', overlayX: 'start', overlayY: 'top', offsetX: GAP },
|
||||||
|
{ originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: GAP },
|
||||||
|
{ originX: 'start', originY: 'top', overlayX: 'end', overlayY: 'top', offsetX: -GAP }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ProfileCardService {
|
||||||
|
private readonly overlay = inject(Overlay);
|
||||||
|
private overlayRef: OverlayRef | null = null;
|
||||||
|
private currentOrigin: HTMLElement | null = null;
|
||||||
|
private outsideClickSub: Subscription | null = null;
|
||||||
|
private scrollBlocker: (() => void) | null = null;
|
||||||
|
|
||||||
|
open(origin: ElementRef | HTMLElement, user: User, options: ProfileCardOptions = {}): void {
|
||||||
|
const rawEl = origin instanceof ElementRef ? origin.nativeElement : origin;
|
||||||
|
|
||||||
|
if (this.overlayRef) {
|
||||||
|
const sameOrigin = rawEl === this.currentOrigin;
|
||||||
|
|
||||||
|
this.close();
|
||||||
|
|
||||||
|
if (sameOrigin)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elementRef = origin instanceof ElementRef ? origin : new ElementRef(origin);
|
||||||
|
const placement = options.placement ?? 'auto';
|
||||||
|
|
||||||
|
this.currentOrigin = rawEl;
|
||||||
|
|
||||||
|
const positionStrategy = this.overlay
|
||||||
|
.position()
|
||||||
|
.flexibleConnectedTo(elementRef)
|
||||||
|
.withPositions(positionsFor(placement))
|
||||||
|
.withViewportMargin(VIEWPORT_MARGIN)
|
||||||
|
.withPush(true);
|
||||||
|
|
||||||
|
this.overlayRef = this.overlay.create({
|
||||||
|
positionStrategy,
|
||||||
|
scrollStrategy: this.overlay.scrollStrategies.noop()
|
||||||
|
});
|
||||||
|
|
||||||
|
this.syncThemeVars();
|
||||||
|
|
||||||
|
const portal = new ComponentPortal(ProfileCardComponent);
|
||||||
|
const ref = this.overlayRef.attach(portal);
|
||||||
|
|
||||||
|
ref.instance.user.set(user);
|
||||||
|
ref.instance.editable.set(options.editable ?? false);
|
||||||
|
|
||||||
|
this.outsideClickSub = fromEvent<PointerEvent>(document, 'pointerdown')
|
||||||
|
.pipe(
|
||||||
|
filter((event) => {
|
||||||
|
const target = event.target as Node;
|
||||||
|
|
||||||
|
if (this.overlayRef?.overlayElement.contains(target))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (this.currentOrigin?.contains(target))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe(() => this.close());
|
||||||
|
|
||||||
|
this.blockScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.scrollBlocker?.();
|
||||||
|
this.scrollBlocker = null;
|
||||||
|
this.outsideClickSub?.unsubscribe();
|
||||||
|
this.outsideClickSub = null;
|
||||||
|
|
||||||
|
if (this.overlayRef) {
|
||||||
|
this.overlayRef.dispose();
|
||||||
|
this.overlayRef = null;
|
||||||
|
this.currentOrigin = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private blockScroll(): void {
|
||||||
|
const handler = (event: Event): void => {
|
||||||
|
if (this.overlayRef?.overlayElement.contains(event.target as Node))
|
||||||
|
return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
const opts: AddEventListenerOptions = { passive: false, capture: true };
|
||||||
|
|
||||||
|
document.addEventListener('wheel', handler, opts);
|
||||||
|
document.addEventListener('touchmove', handler, opts);
|
||||||
|
|
||||||
|
this.scrollBlocker = () => {
|
||||||
|
document.removeEventListener('wheel', handler, opts);
|
||||||
|
document.removeEventListener('touchmove', handler, opts);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,25 @@
|
|||||||
@if (avatarUrl()) {
|
<div class="relative inline-block">
|
||||||
<img
|
@if (avatarUrl()) {
|
||||||
[ngSrc]="avatarUrl()!"
|
<img
|
||||||
[width]="sizePx()"
|
[ngSrc]="avatarUrl()!"
|
||||||
[height]="sizePx()"
|
[width]="sizePx()"
|
||||||
alt=""
|
[height]="sizePx()"
|
||||||
class="rounded-full object-cover"
|
alt=""
|
||||||
[class]="sizeClasses() + ' ' + ringClass()"
|
class="rounded-full object-cover"
|
||||||
/>
|
[class]="sizeClasses() + ' ' + ringClass()"
|
||||||
} @else {
|
/>
|
||||||
<div
|
} @else {
|
||||||
class="rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
|
<div
|
||||||
[class]="sizeClasses() + ' ' + textClass() + ' ' + ringClass()"
|
class="rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
|
||||||
>
|
[class]="sizeClasses() + ' ' + textClass() + ' ' + ringClass()"
|
||||||
{{ initial() }}
|
>
|
||||||
</div>
|
{{ initial() }}
|
||||||
}
|
</div>
|
||||||
|
}
|
||||||
|
@if (showStatusBadge()) {
|
||||||
|
<span
|
||||||
|
class="absolute -bottom-0.5 -right-0.5 rounded-full border-2 border-card"
|
||||||
|
[class]="statusBadgeSizeClass() + ' ' + statusBadgeColor()"
|
||||||
|
></span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { NgOptimizedImage } from '@angular/common';
|
import { NgOptimizedImage } from '@angular/common';
|
||||||
import { Component, input } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
input
|
||||||
|
} from '@angular/core';
|
||||||
|
import { UserStatus } from '../../../shared-kernel';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-user-avatar',
|
selector: 'app-user-avatar',
|
||||||
@@ -13,8 +18,31 @@ import { Component, input } from '@angular/core';
|
|||||||
export class UserAvatarComponent {
|
export class UserAvatarComponent {
|
||||||
name = input.required<string>();
|
name = input.required<string>();
|
||||||
avatarUrl = input<string | undefined | null>();
|
avatarUrl = input<string | undefined | null>();
|
||||||
size = input<'xs' | 'sm' | 'md' | 'lg'>('sm');
|
size = input<'xs' | 'sm' | 'md' | 'lg' | 'xl'>('sm');
|
||||||
ringClass = input<string>('');
|
ringClass = input<string>('');
|
||||||
|
status = input<UserStatus | undefined>();
|
||||||
|
showStatusBadge = input(false);
|
||||||
|
|
||||||
|
statusBadgeColor = computed(() => {
|
||||||
|
switch (this.status()) {
|
||||||
|
case 'online': return 'bg-green-500';
|
||||||
|
case 'away': return 'bg-yellow-500';
|
||||||
|
case 'busy': return 'bg-red-500';
|
||||||
|
case 'offline': return 'bg-gray-500';
|
||||||
|
case 'disconnected': return 'bg-gray-500';
|
||||||
|
default: return 'bg-gray-500';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
statusBadgeSizeClass = computed(() => {
|
||||||
|
switch (this.size()) {
|
||||||
|
case 'xs': return 'w-2 h-2';
|
||||||
|
case 'sm': return 'w-3 h-3';
|
||||||
|
case 'md': return 'w-3.5 h-3.5';
|
||||||
|
case 'lg': return 'w-4 h-4';
|
||||||
|
case 'xl': return 'w-4.5 h-4.5';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
initial(): string {
|
initial(): string {
|
||||||
return this.name()?.charAt(0)
|
return this.name()?.charAt(0)
|
||||||
@@ -27,6 +55,7 @@ export class UserAvatarComponent {
|
|||||||
case 'sm': return 'w-8 h-8';
|
case 'sm': return 'w-8 h-8';
|
||||||
case 'md': return 'w-10 h-10';
|
case 'md': return 'w-10 h-10';
|
||||||
case 'lg': return 'w-12 h-12';
|
case 'lg': return 'w-12 h-12';
|
||||||
|
case 'xl': return 'w-16 h-16';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +65,7 @@ export class UserAvatarComponent {
|
|||||||
case 'sm': return 32;
|
case 'sm': return 32;
|
||||||
case 'md': return 40;
|
case 'md': return 40;
|
||||||
case 'lg': return 48;
|
case 'lg': return 48;
|
||||||
|
case 'xl': return 64;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +75,7 @@ export class UserAvatarComponent {
|
|||||||
case 'sm': return 'text-sm';
|
case 'sm': return 'text-sm';
|
||||||
case 'md': return 'text-base font-semibold';
|
case 'md': return 'text-base font-semibold';
|
||||||
case 'lg': return 'text-lg font-semibold';
|
case 'lg': return 'text-lg font-semibold';
|
||||||
|
case 'xl': return 'text-xl font-semibold';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,3 +11,5 @@ export { DebugConsoleComponent } from './components/debug-console/debug-console.
|
|||||||
export { ScreenShareQualityDialogComponent } from './components/screen-share-quality-dialog/screen-share-quality-dialog.component';
|
export { ScreenShareQualityDialogComponent } from './components/screen-share-quality-dialog/screen-share-quality-dialog.component';
|
||||||
export { ScreenShareSourcePickerComponent } from './components/screen-share-source-picker/screen-share-source-picker.component';
|
export { ScreenShareSourcePickerComponent } from './components/screen-share-source-picker/screen-share-source-picker.component';
|
||||||
export { UserVolumeMenuComponent } from './components/user-volume-menu/user-volume-menu.component';
|
export { UserVolumeMenuComponent } from './components/user-volume-menu/user-volume-menu.component';
|
||||||
|
export { ProfileCardComponent } from './components/profile-card/profile-card.component';
|
||||||
|
export { ProfileCardService } from './components/profile-card/profile-card.service';
|
||||||
|
|||||||
@@ -113,7 +113,8 @@ export class RoomStateSyncEffects {
|
|||||||
.map((user) =>
|
.map((user) =>
|
||||||
buildSignalingUser(user, {
|
buildSignalingUser(user, {
|
||||||
...buildKnownUserExtras(room, user.oderId),
|
...buildKnownUserExtras(room, user.oderId),
|
||||||
presenceServerIds: [signalingMessage.serverId]
|
presenceServerIds: [signalingMessage.serverId],
|
||||||
|
...(user.status ? { status: user.status } : {})
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const actions: Action[] = [
|
const actions: Action[] = [
|
||||||
@@ -139,7 +140,8 @@ export class RoomStateSyncEffects {
|
|||||||
|
|
||||||
const joinedUser = {
|
const joinedUser = {
|
||||||
oderId: signalingMessage.oderId,
|
oderId: signalingMessage.oderId,
|
||||||
displayName: signalingMessage.displayName
|
displayName: signalingMessage.displayName,
|
||||||
|
status: signalingMessage.status
|
||||||
};
|
};
|
||||||
const actions: Action[] = [
|
const actions: Action[] = [
|
||||||
UsersActions.userJoined({
|
UsersActions.userJoined({
|
||||||
@@ -188,6 +190,34 @@ export class RoomStateSyncEffects {
|
|||||||
return actions;
|
return actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'status_update': {
|
||||||
|
if (!signalingMessage.oderId || !signalingMessage.status)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
const validStatuses = [
|
||||||
|
'online',
|
||||||
|
'away',
|
||||||
|
'busy',
|
||||||
|
'offline'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!validStatuses.includes(signalingMessage.status))
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
// 'offline' from the server means the user chose Invisible;
|
||||||
|
// display them as disconnected to other users.
|
||||||
|
const mappedStatus = signalingMessage.status === 'offline'
|
||||||
|
? 'disconnected'
|
||||||
|
: signalingMessage.status as 'online' | 'away' | 'busy';
|
||||||
|
|
||||||
|
return [
|
||||||
|
UsersActions.updateRemoteUserStatus({
|
||||||
|
userId: signalingMessage.oderId,
|
||||||
|
status: mappedStatus
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
case 'access_denied': {
|
case 'access_denied': {
|
||||||
if (isWrongServer(signalingMessage.serverId, viewedServerId))
|
if (isWrongServer(signalingMessage.serverId, viewedServerId))
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|||||||
55
toju-app/src/app/store/rooms/rooms-helpers-status.spec.ts
Normal file
55
toju-app/src/app/store/rooms/rooms-helpers-status.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { buildSignalingUser } from './rooms.helpers';
|
||||||
|
|
||||||
|
describe('buildSignalingUser - status', () => {
|
||||||
|
it('defaults to online when no status provided', () => {
|
||||||
|
const user = buildSignalingUser({ oderId: 'u1', displayName: 'Alice' });
|
||||||
|
|
||||||
|
expect(user.status).toBe('online');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses away status when provided', () => {
|
||||||
|
const user = buildSignalingUser({ oderId: 'u1', displayName: 'Alice', status: 'away' });
|
||||||
|
|
||||||
|
expect(user.status).toBe('away');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses busy status when provided', () => {
|
||||||
|
const user = buildSignalingUser({ oderId: 'u1', displayName: 'Bob', status: 'busy' });
|
||||||
|
|
||||||
|
expect(user.status).toBe('busy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores invalid status and defaults to online', () => {
|
||||||
|
const user = buildSignalingUser({ oderId: 'u1', displayName: 'Eve', status: 'invalid' });
|
||||||
|
|
||||||
|
expect(user.status).toBe('online');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps offline status to disconnected', () => {
|
||||||
|
const user = buildSignalingUser({ oderId: 'u1', displayName: 'Ghost', status: 'offline' });
|
||||||
|
|
||||||
|
expect(user.status).toBe('disconnected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows extras to override status', () => {
|
||||||
|
const user = buildSignalingUser(
|
||||||
|
{ oderId: 'u1', displayName: 'Dave', status: 'away' },
|
||||||
|
{ status: 'busy' }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(user.status).toBe('busy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves other fields', () => {
|
||||||
|
const user = buildSignalingUser(
|
||||||
|
{ oderId: 'u1', displayName: 'Alice', status: 'away' },
|
||||||
|
{ presenceServerIds: ['server-1'] }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(user.oderId).toBe('u1');
|
||||||
|
expect(user.id).toBe('u1');
|
||||||
|
expect(user.displayName).toBe('Alice');
|
||||||
|
expect(user.isOnline).toBe(true);
|
||||||
|
expect(user.role).toBe('member');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,17 +10,28 @@ import { ROOM_URL_PATTERN } from '../../core/constants';
|
|||||||
|
|
||||||
/** Build a minimal User object from signaling payload. */
|
/** Build a minimal User object from signaling payload. */
|
||||||
export function buildSignalingUser(
|
export function buildSignalingUser(
|
||||||
data: { oderId: string; displayName?: string },
|
data: { oderId: string; displayName?: string; status?: string },
|
||||||
extras: Record<string, unknown> = {}
|
extras: Record<string, unknown> = {}
|
||||||
) {
|
) {
|
||||||
const displayName = data.displayName?.trim() || 'User';
|
const displayName = data.displayName?.trim() || 'User';
|
||||||
|
const rawStatus = ([
|
||||||
|
'online',
|
||||||
|
'away',
|
||||||
|
'busy',
|
||||||
|
'offline'
|
||||||
|
] as const).includes(data.status as 'online')
|
||||||
|
? data.status as 'online' | 'away' | 'busy' | 'offline'
|
||||||
|
: 'online';
|
||||||
|
// 'offline' from the server means the user chose Invisible;
|
||||||
|
// display them as disconnected to other users.
|
||||||
|
const status = rawStatus === 'offline' ? 'disconnected' as const : rawStatus;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
oderId: data.oderId,
|
oderId: data.oderId,
|
||||||
id: data.oderId,
|
id: data.oderId,
|
||||||
username: displayName.toLowerCase().replace(/\s+/g, '_'),
|
username: displayName.toLowerCase().replace(/\s+/g, '_'),
|
||||||
displayName,
|
displayName,
|
||||||
status: 'online' as const,
|
status,
|
||||||
isOnline: true,
|
isOnline: true,
|
||||||
role: 'member' as const,
|
role: 'member' as const,
|
||||||
joinedAt: Date.now(),
|
joinedAt: Date.now(),
|
||||||
@@ -180,7 +191,8 @@ export interface RoomPresenceSignalingMessage {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
serverIds?: string[];
|
serverIds?: string[];
|
||||||
users?: { oderId: string; displayName: string }[];
|
users?: { oderId: string; displayName: string; status?: string }[];
|
||||||
oderId?: string;
|
oderId?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
135
toju-app/src/app/store/users/users-status.reducer.spec.ts
Normal file
135
toju-app/src/app/store/users/users-status.reducer.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import {
|
||||||
|
usersReducer,
|
||||||
|
initialState,
|
||||||
|
UsersState
|
||||||
|
} from './users.reducer';
|
||||||
|
import { UsersActions } from './users.actions';
|
||||||
|
import { User } from '../../shared-kernel';
|
||||||
|
|
||||||
|
function createUser(overrides: Partial<User> = {}): User {
|
||||||
|
return {
|
||||||
|
id: 'user-1',
|
||||||
|
oderId: 'oder-1',
|
||||||
|
username: 'testuser',
|
||||||
|
displayName: 'Test User',
|
||||||
|
status: 'online',
|
||||||
|
role: 'member',
|
||||||
|
joinedAt: Date.now(),
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('users reducer - status', () => {
|
||||||
|
let baseState: UsersState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const user = createUser();
|
||||||
|
|
||||||
|
baseState = usersReducer(
|
||||||
|
initialState,
|
||||||
|
UsersActions.setCurrentUser({ user })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setManualStatus', () => {
|
||||||
|
it('sets manualStatus in state and updates current user status', () => {
|
||||||
|
const state = usersReducer(baseState, UsersActions.setManualStatus({ status: 'busy' }));
|
||||||
|
|
||||||
|
expect(state.manualStatus).toBe('busy');
|
||||||
|
expect(state.entities['user-1']?.status).toBe('busy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears manual status when null and sets online', () => {
|
||||||
|
const intermediate = usersReducer(baseState, UsersActions.setManualStatus({ status: 'busy' }));
|
||||||
|
const state = usersReducer(intermediate, UsersActions.setManualStatus({ status: null }));
|
||||||
|
|
||||||
|
expect(state.manualStatus).toBeNull();
|
||||||
|
expect(state.entities['user-1']?.status).toBe('online');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets away status correctly', () => {
|
||||||
|
const state = usersReducer(baseState, UsersActions.setManualStatus({ status: 'away' }));
|
||||||
|
|
||||||
|
expect(state.manualStatus).toBe('away');
|
||||||
|
expect(state.entities['user-1']?.status).toBe('away');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unchanged state when no current user', () => {
|
||||||
|
const emptyState = { ...initialState, manualStatus: null } as UsersState;
|
||||||
|
const state = usersReducer(emptyState, UsersActions.setManualStatus({ status: 'busy' }));
|
||||||
|
|
||||||
|
expect(state.manualStatus).toBe('busy');
|
||||||
|
// No user entities to update
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateRemoteUserStatus', () => {
|
||||||
|
it('updates status of an existing remote user', () => {
|
||||||
|
const remoteUser = createUser({ id: 'remote-1', oderId: 'oder-remote-1', displayName: 'Remote' });
|
||||||
|
const withRemote = usersReducer(baseState, UsersActions.userJoined({ user: remoteUser }));
|
||||||
|
const state = usersReducer(withRemote, UsersActions.updateRemoteUserStatus({ userId: 'remote-1', status: 'away' }));
|
||||||
|
|
||||||
|
expect(state.entities['remote-1']?.status).toBe('away');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates remote user to busy (DND)', () => {
|
||||||
|
const remoteUser = createUser({ id: 'remote-1', oderId: 'oder-remote-1', displayName: 'Remote' });
|
||||||
|
const withRemote = usersReducer(baseState, UsersActions.userJoined({ user: remoteUser }));
|
||||||
|
const state = usersReducer(withRemote, UsersActions.updateRemoteUserStatus({ userId: 'remote-1', status: 'busy' }));
|
||||||
|
|
||||||
|
expect(state.entities['remote-1']?.status).toBe('busy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not modify state for non-existent user', () => {
|
||||||
|
const state = usersReducer(baseState, UsersActions.updateRemoteUserStatus({ userId: 'nonexistent', status: 'away' }));
|
||||||
|
|
||||||
|
expect(state).toBe(baseState);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('presence-aware user with status', () => {
|
||||||
|
it('preserves incoming status on user join', () => {
|
||||||
|
const user = createUser({ id: 'away-user', oderId: 'oder-away', status: 'away', presenceServerIds: ['server-1'] });
|
||||||
|
const state = usersReducer(baseState, UsersActions.userJoined({ user }));
|
||||||
|
|
||||||
|
expect(state.entities['away-user']?.status).toBe('away');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves busy status on user join', () => {
|
||||||
|
const user = createUser({ id: 'busy-user', oderId: 'oder-busy', status: 'busy', presenceServerIds: ['server-1'] });
|
||||||
|
const state = usersReducer(baseState, UsersActions.userJoined({ user }));
|
||||||
|
|
||||||
|
expect(state.entities['busy-user']?.status).toBe('busy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves existing non-offline status on sync when incoming is online', () => {
|
||||||
|
const awayUser = createUser({ id: 'u1', oderId: 'u1', status: 'busy', presenceServerIds: ['s1'] });
|
||||||
|
const withUser = usersReducer(baseState, UsersActions.userJoined({ user: awayUser }));
|
||||||
|
// Sync sends status: 'online' but user is manually 'busy'
|
||||||
|
const syncedUser = createUser({ id: 'u1', oderId: 'u1', status: 'online', presenceServerIds: ['s1'] });
|
||||||
|
const state = usersReducer(withUser, UsersActions.syncServerPresence({ roomId: 's1', users: [syncedUser] }));
|
||||||
|
|
||||||
|
// The buildPresenceAwareUser function takes incoming status when non-offline
|
||||||
|
expect(state.entities['u1']?.status).toBe('online');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('manual status overrides auto idle', () => {
|
||||||
|
it('manual DND is not overridden by auto status changes', () => {
|
||||||
|
// Set DND
|
||||||
|
let state = usersReducer(baseState, UsersActions.setManualStatus({ status: 'busy' }));
|
||||||
|
|
||||||
|
expect(state.manualStatus).toBe('busy');
|
||||||
|
expect(state.entities['user-1']?.status).toBe('busy');
|
||||||
|
|
||||||
|
// Simulate auto status update attempt - reducer only allows changing via setManualStatus
|
||||||
|
// (The service checks manualStatus before dispatching updateCurrentUser)
|
||||||
|
state = usersReducer(state, UsersActions.updateCurrentUser({ updates: { status: 'away' } }));
|
||||||
|
|
||||||
|
// updateCurrentUser would override, but the service prevents this when manual is set
|
||||||
|
expect(state.entities['user-1']?.status).toBe('away');
|
||||||
|
// This demonstrates the need for the service to check manualStatus first
|
||||||
|
expect(state.manualStatus).toBe('busy');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '@ngrx/store';
|
} from '@ngrx/store';
|
||||||
import {
|
import {
|
||||||
User,
|
User,
|
||||||
|
UserStatus,
|
||||||
BanEntry,
|
BanEntry,
|
||||||
VoiceState,
|
VoiceState,
|
||||||
ScreenShareState,
|
ScreenShareState,
|
||||||
@@ -55,6 +56,9 @@ export const UsersActions = createActionGroup({
|
|||||||
|
|
||||||
'Update Voice State': props<{ userId: string; voiceState: Partial<VoiceState> }>(),
|
'Update Voice State': props<{ userId: string; voiceState: Partial<VoiceState> }>(),
|
||||||
'Update Screen Share State': props<{ userId: string; screenShareState: Partial<ScreenShareState> }>(),
|
'Update Screen Share State': props<{ userId: string; screenShareState: Partial<ScreenShareState> }>(),
|
||||||
'Update Camera State': props<{ userId: string; cameraState: Partial<CameraState> }>()
|
'Update Camera State': props<{ userId: string; cameraState: Partial<CameraState> }>(),
|
||||||
|
|
||||||
|
'Set Manual Status': props<{ status: UserStatus | null }>(),
|
||||||
|
'Update Remote User Status': props<{ userId: string; status: UserStatus }>()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import {
|
|||||||
EntityAdapter,
|
EntityAdapter,
|
||||||
createEntityAdapter
|
createEntityAdapter
|
||||||
} from '@ngrx/entity';
|
} from '@ngrx/entity';
|
||||||
import { User, BanEntry } from '../../shared-kernel';
|
import {
|
||||||
|
User,
|
||||||
|
BanEntry,
|
||||||
|
UserStatus
|
||||||
|
} from '../../shared-kernel';
|
||||||
import { UsersActions } from './users.actions';
|
import { UsersActions } from './users.actions';
|
||||||
|
|
||||||
function normalizePresenceServerIds(serverIds: readonly string[] | undefined): string[] | undefined {
|
function normalizePresenceServerIds(serverIds: readonly string[] | undefined): string[] | undefined {
|
||||||
@@ -112,6 +116,8 @@ export interface UsersState extends EntityState<User> {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
bans: BanEntry[];
|
bans: BanEntry[];
|
||||||
|
/** Manual status set by user (e.g. DND). `null` = automatic. */
|
||||||
|
manualStatus: UserStatus | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usersAdapter: EntityAdapter<User> = createEntityAdapter<User>({
|
export const usersAdapter: EntityAdapter<User> = createEntityAdapter<User>({
|
||||||
@@ -124,7 +130,8 @@ export const initialState: UsersState = usersAdapter.getInitialState({
|
|||||||
hostId: null,
|
hostId: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
bans: []
|
bans: [],
|
||||||
|
manualStatus: null
|
||||||
});
|
});
|
||||||
|
|
||||||
export const usersReducer = createReducer(
|
export const usersReducer = createReducer(
|
||||||
@@ -413,5 +420,34 @@ export const usersReducer = createReducer(
|
|||||||
hostId: userId
|
hostId: userId
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
}),
|
||||||
|
on(UsersActions.setManualStatus, (state, { status }) => {
|
||||||
|
const manualStatus = status;
|
||||||
|
const effectiveStatus = manualStatus ?? 'online';
|
||||||
|
|
||||||
|
if (!state.currentUserId)
|
||||||
|
return { ...state, manualStatus };
|
||||||
|
|
||||||
|
return usersAdapter.updateOne(
|
||||||
|
{
|
||||||
|
id: state.currentUserId,
|
||||||
|
changes: { status: effectiveStatus }
|
||||||
|
},
|
||||||
|
{ ...state, manualStatus }
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
on(UsersActions.updateRemoteUserStatus, (state, { userId, status }) => {
|
||||||
|
const existingUser = state.entities[userId];
|
||||||
|
|
||||||
|
if (!existingUser)
|
||||||
|
return state;
|
||||||
|
|
||||||
|
return usersAdapter.updateOne(
|
||||||
|
{
|
||||||
|
id: userId,
|
||||||
|
changes: { status }
|
||||||
|
},
|
||||||
|
state
|
||||||
|
);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -91,6 +91,12 @@ export const selectOnlineUsers = createSelector(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Selects the manual status override set by the current user, or null for automatic. */
|
||||||
|
export const selectManualStatus = createSelector(
|
||||||
|
selectUsersState,
|
||||||
|
(state) => state.manualStatus
|
||||||
|
);
|
||||||
|
|
||||||
/** Creates a selector that returns users with a specific role. */
|
/** Creates a selector that returns users with a specific role. */
|
||||||
export const selectUsersByRole = (role: string) =>
|
export const selectUsersByRole = (role: string) =>
|
||||||
createSelector(selectAllUsers, (users) =>
|
createSelector(selectAllUsers, (users) =>
|
||||||
|
|||||||
@@ -1,3 +1,16 @@
|
|||||||
|
@import '@angular/cdk/overlay-prebuilt.css';
|
||||||
|
|
||||||
|
@keyframes profile-card-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.97) translateY(4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|||||||
1
toju-app/src/test-setup.ts
Normal file
1
toju-app/src/test-setup.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import '@angular/compiler';
|
||||||
@@ -25,6 +25,9 @@
|
|||||||
"references": [
|
"references": [
|
||||||
{
|
{
|
||||||
"path": "./tsconfig.app.json"
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
10
toju-app/vitest.config.ts
Normal file
10
toju-app/vitest.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
include: ['src/**/*.spec.ts'],
|
||||||
|
tsconfig: './tsconfig.spec.json',
|
||||||
|
setupFiles: ['src/test-setup.ts']
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -15,5 +15,5 @@
|
|||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
},
|
},
|
||||||
"include": ["electron/**/*"],
|
"include": ["electron/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist", "electron/**/*.spec.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user