refactor: Clean lint errors and organise files
This commit is contained in:
@@ -16,6 +16,7 @@ const TEST_PORT = process.env.TEST_SERVER_PORT || '3099';
|
|||||||
const SERVER_DIR = join(__dirname, '..', '..', 'server');
|
const SERVER_DIR = join(__dirname, '..', '..', 'server');
|
||||||
const SERVER_ENTRY = join(SERVER_DIR, 'src', 'index.ts');
|
const SERVER_ENTRY = join(SERVER_DIR, 'src', 'index.ts');
|
||||||
const SERVER_TSCONFIG = join(SERVER_DIR, 'tsconfig.json');
|
const SERVER_TSCONFIG = join(SERVER_DIR, 'tsconfig.json');
|
||||||
|
const TS_NODE_BIN = join(SERVER_DIR, 'node_modules', 'ts-node', 'dist', 'bin.js');
|
||||||
|
|
||||||
// ── Create isolated temp data directory ──────────────────────────────
|
// ── Create isolated temp data directory ──────────────────────────────
|
||||||
const tmpDir = mkdtempSync(join(tmpdir(), 'metoyou-e2e-'));
|
const tmpDir = mkdtempSync(join(tmpdir(), 'metoyou-e2e-'));
|
||||||
@@ -43,8 +44,8 @@ console.log(`[E2E Server] Starting on port ${TEST_PORT}...`);
|
|||||||
// Module resolution (require/import) uses __dirname, so server source
|
// Module resolution (require/import) uses __dirname, so server source
|
||||||
// and node_modules are found from the real server/ directory.
|
// and node_modules are found from the real server/ directory.
|
||||||
const child = spawn(
|
const child = spawn(
|
||||||
'npx',
|
process.execPath,
|
||||||
['ts-node', '--project', SERVER_TSCONFIG, SERVER_ENTRY],
|
[TS_NODE_BIN, '--project', SERVER_TSCONFIG, SERVER_ENTRY],
|
||||||
{
|
{
|
||||||
cwd: tmpDir,
|
cwd: tmpDir,
|
||||||
env: {
|
env: {
|
||||||
@@ -55,7 +56,6 @@ const child = spawn(
|
|||||||
DB_SYNCHRONIZE: 'true',
|
DB_SYNCHRONIZE: 'true',
|
||||||
},
|
},
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
shell: true,
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,15 @@ import { type Page } from '@playwright/test';
|
|||||||
export async function installWebRTCTracking(page: Page): Promise<void> {
|
export async function installWebRTCTracking(page: Page): Promise<void> {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
const connections: RTCPeerConnection[] = [];
|
const connections: RTCPeerConnection[] = [];
|
||||||
|
const syntheticMediaResources: {
|
||||||
|
audioCtx: AudioContext;
|
||||||
|
source?: AudioScheduledSourceNode;
|
||||||
|
drawIntervalId?: number;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
(window as any).__rtcConnections = connections;
|
(window as any).__rtcConnections = connections;
|
||||||
(window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[];
|
(window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[];
|
||||||
|
(window as any).__rtcSyntheticMediaResources = syntheticMediaResources;
|
||||||
|
|
||||||
const OriginalRTCPeerConnection = window.RTCPeerConnection;
|
const OriginalRTCPeerConnection = window.RTCPeerConnection;
|
||||||
|
|
||||||
@@ -55,18 +61,37 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
|
|||||||
// Get the original stream (may include video)
|
// Get the original stream (may include video)
|
||||||
const originalStream = await origGetUserMedia(constraints);
|
const originalStream = await origGetUserMedia(constraints);
|
||||||
const audioCtx = new AudioContext();
|
const audioCtx = new AudioContext();
|
||||||
const oscillator = audioCtx.createOscillator();
|
const noiseBuffer = audioCtx.createBuffer(1, audioCtx.sampleRate * 2, audioCtx.sampleRate);
|
||||||
|
const noiseData = noiseBuffer.getChannelData(0);
|
||||||
|
|
||||||
oscillator.frequency.value = 440;
|
for (let sampleIndex = 0; sampleIndex < noiseData.length; sampleIndex++) {
|
||||||
|
noiseData[sampleIndex] = (Math.random() * 2 - 1) * 0.18;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = audioCtx.createBufferSource();
|
||||||
|
const gain = audioCtx.createGain();
|
||||||
|
|
||||||
|
source.buffer = noiseBuffer;
|
||||||
|
source.loop = true;
|
||||||
|
gain.gain.value = 0.12;
|
||||||
|
|
||||||
const dest = audioCtx.createMediaStreamDestination();
|
const dest = audioCtx.createMediaStreamDestination();
|
||||||
|
|
||||||
oscillator.connect(dest);
|
source.connect(gain);
|
||||||
oscillator.start();
|
gain.connect(dest);
|
||||||
|
source.start();
|
||||||
|
|
||||||
|
if (audioCtx.state === 'suspended') {
|
||||||
|
try {
|
||||||
|
await audioCtx.resume();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
const synthAudioTrack = dest.stream.getAudioTracks()[0];
|
const synthAudioTrack = dest.stream.getAudioTracks()[0];
|
||||||
const resultStream = new MediaStream();
|
const resultStream = new MediaStream();
|
||||||
|
|
||||||
|
syntheticMediaResources.push({ audioCtx, source });
|
||||||
|
|
||||||
resultStream.addTrack(synthAudioTrack);
|
resultStream.addTrack(synthAudioTrack);
|
||||||
|
|
||||||
// Keep any video tracks from the original stream
|
// Keep any video tracks from the original stream
|
||||||
@@ -79,6 +104,14 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
|
|||||||
track.stop();
|
track.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
synthAudioTrack.addEventListener('ended', () => {
|
||||||
|
try {
|
||||||
|
source.stop();
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
void audioCtx.close().catch(() => {});
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
return resultStream;
|
return resultStream;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -128,10 +161,32 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
|
|||||||
osc.connect(dest);
|
osc.connect(dest);
|
||||||
osc.start();
|
osc.start();
|
||||||
|
|
||||||
|
if (audioCtx.state === 'suspended') {
|
||||||
|
try {
|
||||||
|
await audioCtx.resume();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
const audioTrack = dest.stream.getAudioTracks()[0];
|
const audioTrack = dest.stream.getAudioTracks()[0];
|
||||||
// Combine video + audio into one stream
|
// Combine video + audio into one stream
|
||||||
const resultStream = new MediaStream([videoTrack, audioTrack]);
|
const resultStream = new MediaStream([videoTrack, audioTrack]);
|
||||||
|
|
||||||
|
syntheticMediaResources.push({
|
||||||
|
audioCtx,
|
||||||
|
source: osc,
|
||||||
|
drawIntervalId: drawInterval as unknown as number
|
||||||
|
});
|
||||||
|
|
||||||
|
audioTrack.addEventListener('ended', () => {
|
||||||
|
clearInterval(drawInterval);
|
||||||
|
|
||||||
|
try {
|
||||||
|
osc.stop();
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
void audioCtx.close().catch(() => {});
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
// Tag the stream so tests can identify it
|
// Tag the stream so tests can identify it
|
||||||
(resultStream as any).__isScreenShare = true;
|
(resultStream as any).__isScreenShare = true;
|
||||||
|
|
||||||
|
|||||||
@@ -29,11 +29,12 @@ export class RegisterPage {
|
|||||||
try {
|
try {
|
||||||
await expect(this.usernameInput).toBeVisible({ timeout: 10_000 });
|
await expect(this.usernameInput).toBeVisible({ timeout: 10_000 });
|
||||||
} catch {
|
} catch {
|
||||||
// Angular router may redirect to /login on first load; click through.
|
// Angular router may redirect to /login on first load; use the
|
||||||
const registerLink = this.page.getByRole('link', { name: 'Register' })
|
// visible login-form action instead of broad text matching.
|
||||||
.or(this.page.getByText('Register'));
|
const registerButton = this.page.getByRole('button', { name: 'Register', exact: true }).last();
|
||||||
|
|
||||||
await registerLink.first().click();
|
await expect(registerButton).toBeVisible({ timeout: 10_000 });
|
||||||
|
await registerButton.click();
|
||||||
await expect(this.usernameInput).toBeVisible({ timeout: 30_000 });
|
await expect(this.usernameInput).toBeVisible({ timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
} from '../update/desktop-updater';
|
} from '../update/desktop-updater';
|
||||||
import { consumePendingDeepLink } from '../app/deep-links';
|
import { consumePendingDeepLink } from '../app/deep-links';
|
||||||
import { synchronizeAutoStartSetting } from '../app/auto-start';
|
import { synchronizeAutoStartSetting } from '../app/auto-start';
|
||||||
|
import { getIdleState } from '../idle/idle-monitor';
|
||||||
import {
|
import {
|
||||||
getMainWindow,
|
getMainWindow,
|
||||||
getWindowIconPath,
|
getWindowIconPath,
|
||||||
@@ -557,16 +558,22 @@ export function setupSystemHandlers(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case 'cut': webContents.cut(); break;
|
case 'cut':
|
||||||
case 'copy': webContents.copy(); break;
|
webContents.cut();
|
||||||
case 'paste': webContents.paste(); break;
|
break;
|
||||||
case 'selectAll': webContents.selectAll(); break;
|
case 'copy':
|
||||||
|
webContents.copy();
|
||||||
|
break;
|
||||||
|
case 'paste':
|
||||||
|
webContents.paste();
|
||||||
|
break;
|
||||||
|
case 'selectAll':
|
||||||
|
webContents.selectAll();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-idle-state', () => {
|
ipcMain.handle('get-idle-state', () => {
|
||||||
const { getIdleState } = require('../idle/idle-monitor') as typeof import('../idle/idle-monitor');
|
|
||||||
|
|
||||||
return getIdleState();
|
return getIdleState();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ module.exports = tseslint.config(
|
|||||||
'complexity': ['warn',{ max:20 }],
|
'complexity': ['warn',{ max:20 }],
|
||||||
'curly': 'off',
|
'curly': 'off',
|
||||||
'eol-last': 'error',
|
'eol-last': 'error',
|
||||||
'id-denylist': ['warn','e','cb','i','x','c','y','any','string','String','Undefined','undefined','callback'],
|
'id-denylist': ['warn','e','cb','i','c','any','string','String','Undefined','undefined','callback'],
|
||||||
'max-len': ['error',{ code:150, ignoreComments:true }],
|
'max-len': ['error',{ code:150, ignoreComments:true }],
|
||||||
'new-parens': 'error',
|
'new-parens': 'error',
|
||||||
'newline-per-chained-call': 'error',
|
'newline-per-chained-call': 'error',
|
||||||
@@ -172,7 +172,7 @@ module.exports = tseslint.config(
|
|||||||
// Ensure only one statement per line to prevent patterns like: if (cond) { doThing(); }
|
// Ensure only one statement per line to prevent patterns like: if (cond) { doThing(); }
|
||||||
'max-statements-per-line': ['error', { max: 1 }],
|
'max-statements-per-line': ['error', { max: 1 }],
|
||||||
// Prevent single-character identifiers for variables/params; do not check object property names
|
// Prevent single-character identifiers for variables/params; do not check object property names
|
||||||
'id-length': ['error', { min: 2, properties: 'never', exceptions: ['_'] }],
|
'id-length': ['error', { min: 2, properties: 'never', exceptions: ['_', 'x', 'y'] }],
|
||||||
// Require blank lines around block-like statements (if, function, class, switch, try, etc.)
|
// Require blank lines around block-like statements (if, function, class, switch, try, etc.)
|
||||||
'padding-line-between-statements': [
|
'padding-line-between-statements': [
|
||||||
'error',
|
'error',
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ function buildServer(app: ReturnType<typeof createApp>, serverProtocol: ServerHt
|
|||||||
return createHttpServer(app);
|
return createHttpServer(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let listeningServer: ReturnType<typeof buildServer> | null = null;
|
||||||
|
let staleJoinRequestInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
const variablesConfig = ensureVariablesConfig();
|
const variablesConfig = ensureVariablesConfig();
|
||||||
const serverProtocol = getServerProtocol();
|
const serverProtocol = getServerProtocol();
|
||||||
@@ -86,10 +89,12 @@ async function bootstrap(): Promise<void> {
|
|||||||
const app = createApp();
|
const app = createApp();
|
||||||
const server = buildServer(app, serverProtocol);
|
const server = buildServer(app, serverProtocol);
|
||||||
|
|
||||||
|
listeningServer = server;
|
||||||
|
|
||||||
setupWebSocket(server);
|
setupWebSocket(server);
|
||||||
|
|
||||||
// Periodically clean up stale join requests (older than 24 h)
|
// Periodically clean up stale join requests (older than 24 h)
|
||||||
setInterval(() => {
|
staleJoinRequestInterval = setInterval(() => {
|
||||||
deleteStaleJoinRequests(24 * 60 * 60 * 1000)
|
deleteStaleJoinRequests(24 * 60 * 60 * 1000)
|
||||||
.catch(err => console.error('Failed to clean up stale join requests:', err));
|
.catch(err => console.error('Failed to clean up stale join requests:', err));
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
@@ -127,8 +132,25 @@ async function gracefulShutdown(signal: string): Promise<void> {
|
|||||||
|
|
||||||
shuttingDown = true;
|
shuttingDown = true;
|
||||||
|
|
||||||
|
if (staleJoinRequestInterval) {
|
||||||
|
clearInterval(staleJoinRequestInterval);
|
||||||
|
staleJoinRequestInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`\n[Shutdown] ${signal} received - closing database…`);
|
console.log(`\n[Shutdown] ${signal} received - closing database…`);
|
||||||
|
|
||||||
|
if (listeningServer?.listening) {
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
listeningServer?.close(() => resolve());
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Shutdown] Error closing server:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listeningServer = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await destroyDatabase();
|
await destroyDatabase();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable complexity */
|
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { getKlipyApiKey, hasKlipyApiKey } from '../config/variables';
|
import { getKlipyApiKey, hasKlipyApiKey } from '../config/variables';
|
||||||
|
|
||||||
@@ -47,6 +46,11 @@ interface KlipyApiResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ResolvedGifMedia {
|
||||||
|
previewMeta: NormalizedMediaMeta | null;
|
||||||
|
sourceMeta: NormalizedMediaMeta;
|
||||||
|
}
|
||||||
|
|
||||||
function pickFirst<T>(...values: (T | null | undefined)[]): T | undefined {
|
function pickFirst<T>(...values: (T | null | undefined)[]): T | undefined {
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
if (value != null)
|
if (value != null)
|
||||||
@@ -130,33 +134,49 @@ function extractKlipyResponseData(payload: unknown): { items: unknown[]; hasNext
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveGifMedia(file?: KlipyGifVariants): ResolvedGifMedia | null {
|
||||||
|
const previewVariant = pickFirst(file?.md, file?.sm, file?.xs, file?.hd);
|
||||||
|
const sourceVariant = pickFirst(file?.hd, file?.md, file?.sm, file?.xs);
|
||||||
|
const previewMeta = pickGifMeta(previewVariant);
|
||||||
|
const sourceMeta = pickGifMeta(sourceVariant) ?? previewMeta;
|
||||||
|
|
||||||
|
if (!sourceMeta?.url)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
previewMeta,
|
||||||
|
sourceMeta
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGifSlug(gifItem: KlipyGifItem): string | undefined {
|
||||||
|
return sanitizeString(gifItem.slug) ?? sanitizeString(gifItem.id);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeGifItem(item: unknown): NormalizedKlipyGif | null {
|
function normalizeGifItem(item: unknown): NormalizedKlipyGif | null {
|
||||||
if (!item || typeof item !== 'object')
|
if (!item || typeof item !== 'object')
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
const gifItem = item as KlipyGifItem;
|
const gifItem = item as KlipyGifItem;
|
||||||
|
const resolvedMedia = resolveGifMedia(gifItem.file);
|
||||||
|
const slug = resolveGifSlug(gifItem);
|
||||||
|
|
||||||
if (gifItem.type === 'ad')
|
if (gifItem.type === 'ad')
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
const lowVariant = pickFirst(gifItem.file?.md, gifItem.file?.sm, gifItem.file?.xs, gifItem.file?.hd);
|
if (!slug || !resolvedMedia)
|
||||||
const highVariant = pickFirst(gifItem.file?.hd, gifItem.file?.md, gifItem.file?.sm, gifItem.file?.xs);
|
|
||||||
const lowMeta = pickGifMeta(lowVariant);
|
|
||||||
const highMeta = pickGifMeta(highVariant);
|
|
||||||
const selectedMeta = highMeta ?? lowMeta;
|
|
||||||
const slug = sanitizeString(gifItem.slug) ?? sanitizeString(gifItem.id);
|
|
||||||
|
|
||||||
if (!slug || !selectedMeta?.url)
|
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
const { previewMeta, sourceMeta } = resolvedMedia;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: slug,
|
id: slug,
|
||||||
slug,
|
slug,
|
||||||
title: sanitizeString(gifItem.title),
|
title: sanitizeString(gifItem.title),
|
||||||
url: selectedMeta.url,
|
url: sourceMeta.url,
|
||||||
previewUrl: lowMeta?.url ?? selectedMeta.url,
|
previewUrl: previewMeta?.url ?? sourceMeta.url,
|
||||||
width: selectedMeta.width ?? lowMeta?.width ?? 0,
|
width: sourceMeta.width ?? previewMeta?.width ?? 0,
|
||||||
height: selectedMeta.height ?? lowMeta?.height ?? 0
|
height: sourceMeta.height ?? previewMeta?.height ?? 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,19 @@ function createConnectedUser(
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRequiredConnectedUser(connectionId: string): ConnectedUser {
|
||||||
|
const connectedUser = connectedUsers.get(connectionId);
|
||||||
|
|
||||||
|
if (!connectedUser)
|
||||||
|
throw new Error(`Expected connected user for ${connectionId}`);
|
||||||
|
|
||||||
|
return connectedUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSentMessagesStore(user: ConnectedUser): { sentMessages: string[] } {
|
||||||
|
return user.ws as unknown as { sentMessages: string[] };
|
||||||
|
}
|
||||||
|
|
||||||
describe('server websocket handler - status_update', () => {
|
describe('server websocket handler - status_update', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
connectedUsers.clear();
|
connectedUsers.clear();
|
||||||
@@ -68,27 +81,24 @@ describe('server websocket handler - status_update', () => {
|
|||||||
|
|
||||||
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'busy' });
|
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'busy' });
|
||||||
|
|
||||||
const ws2 = user2.ws as unknown as { sentMessages: string[] };
|
const messages = getSentMessagesStore(user2).sentMessages.map((messageText: string) => JSON.parse(messageText));
|
||||||
const messages = ws2.sentMessages.map((m: string) => JSON.parse(m));
|
const statusMsg = messages.find((message: { type: string }) => message.type === 'status_update');
|
||||||
const statusMsg = messages.find((m: { type: string }) => m.type === 'status_update');
|
|
||||||
|
|
||||||
expect(statusMsg).toBeDefined();
|
expect(statusMsg).toBeDefined();
|
||||||
expect(statusMsg.oderId).toBe('user-1');
|
expect(statusMsg?.oderId).toBe('user-1');
|
||||||
expect(statusMsg.status).toBe('busy');
|
expect(statusMsg?.status).toBe('busy');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not broadcast to users in different servers', async () => {
|
it('does not broadcast to users in different servers', async () => {
|
||||||
createConnectedUser('conn-1', 'user-1');
|
createConnectedUser('conn-1', 'user-1');
|
||||||
const user2 = createConnectedUser('conn-2', 'user-2');
|
const user2 = createConnectedUser('conn-2', 'user-2');
|
||||||
|
|
||||||
connectedUsers.get('conn-1')!.serverIds.add('server-1');
|
getRequiredConnectedUser('conn-1').serverIds.add('server-1');
|
||||||
user2.serverIds.add('server-2');
|
user2.serverIds.add('server-2');
|
||||||
|
|
||||||
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'away' });
|
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'away' });
|
||||||
|
|
||||||
const ws2 = user2.ws as unknown as { sentMessages: string[] };
|
expect(getSentMessagesStore(user2).sentMessages.length).toBe(0);
|
||||||
|
|
||||||
expect(ws2.sentMessages.length).toBe(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores invalid status values', async () => {
|
it('ignores invalid status values', async () => {
|
||||||
@@ -134,22 +144,21 @@ describe('server websocket handler - status_update', () => {
|
|||||||
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'away' });
|
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'away' });
|
||||||
|
|
||||||
// Clear sent messages
|
// Clear sent messages
|
||||||
(user2.ws as unknown as { sentMessages: string[] }).sentMessages.length = 0;
|
getSentMessagesStore(user2).sentMessages.length = 0;
|
||||||
|
|
||||||
// Identify first (required for handler)
|
// Identify first (required for handler)
|
||||||
await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' });
|
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
|
// user-2 joins server → should receive server_users with user-1's status
|
||||||
(user2.ws as unknown as { sentMessages: string[] }).sentMessages.length = 0;
|
getSentMessagesStore(user2).sentMessages.length = 0;
|
||||||
await handleWebSocketMessage('conn-2', { type: 'join_server', serverId: 'server-1' });
|
await handleWebSocketMessage('conn-2', { type: 'join_server', serverId: 'server-1' });
|
||||||
|
|
||||||
const ws2 = user2.ws as unknown as { sentMessages: string[] };
|
const messages = getSentMessagesStore(user2).sentMessages.map((messageText: string) => JSON.parse(messageText));
|
||||||
const messages = ws2.sentMessages.map((m: string) => JSON.parse(m));
|
const serverUsersMsg = messages.find((message: { type: string }) => message.type === 'server_users');
|
||||||
const serverUsersMsg = messages.find((m: { type: string }) => m.type === 'server_users');
|
|
||||||
|
|
||||||
expect(serverUsersMsg).toBeDefined();
|
expect(serverUsersMsg).toBeDefined();
|
||||||
|
|
||||||
const user1InList = serverUsersMsg.users.find((u: { oderId: string }) => u.oderId === 'user-1');
|
const user1InList = serverUsersMsg?.users?.find((userEntry: { oderId: string }) => userEntry.oderId === 'user-1');
|
||||||
|
|
||||||
expect(user1InList?.status).toBe('away');
|
expect(user1InList?.status).toBe('away');
|
||||||
});
|
});
|
||||||
@@ -168,19 +177,18 @@ describe('server websocket handler - user_joined includes status', () => {
|
|||||||
user2.serverIds.add('server-1');
|
user2.serverIds.add('server-1');
|
||||||
|
|
||||||
// Set user-1's status to busy before joining
|
// Set user-1's status to busy before joining
|
||||||
connectedUsers.get('conn-1')!.status = 'busy';
|
getRequiredConnectedUser('conn-1').status = 'busy';
|
||||||
|
|
||||||
// Identify user-1
|
// Identify user-1
|
||||||
await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' });
|
await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' });
|
||||||
|
|
||||||
(user2.ws as unknown as { sentMessages: string[] }).sentMessages.length = 0;
|
getSentMessagesStore(user2).sentMessages.length = 0;
|
||||||
|
|
||||||
// user-1 joins server-1
|
// user-1 joins server-1
|
||||||
await handleWebSocketMessage('conn-1', { type: 'join_server', serverId: 'server-1' });
|
await handleWebSocketMessage('conn-1', { type: 'join_server', serverId: 'server-1' });
|
||||||
|
|
||||||
const ws2 = user2.ws as unknown as { sentMessages: string[] };
|
const messages = getSentMessagesStore(user2).sentMessages.map((messageText: string) => JSON.parse(messageText));
|
||||||
const messages = ws2.sentMessages.map((m: string) => JSON.parse(m));
|
const joinMsg = messages.find((message: { type: string }) => message.type === 'user_joined');
|
||||||
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
|
// 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.
|
// Since both are already in the server, it may not broadcast. Either way, verify no crash.
|
||||||
|
|||||||
@@ -34,13 +34,13 @@ 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 { UserStatusService } from './core/services/user-status.service';
|
||||||
import { ServersRailComponent } from './features/servers/servers-rail.component';
|
import { ServersRailComponent } from './features/servers/servers-rail/servers-rail.component';
|
||||||
import { TitleBarComponent } from './features/shell/title-bar.component';
|
import { TitleBarComponent } from './features/shell/title-bar/title-bar.component';
|
||||||
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
|
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
|
||||||
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
|
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
|
||||||
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
|
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
|
||||||
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
|
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
|
||||||
import { NativeContextMenuComponent } from './features/shell/native-context-menu.component';
|
import { NativeContextMenuComponent } from './features/shell/native-context-menu/native-context-menu.component';
|
||||||
import { UsersActions } from './store/users/users.actions';
|
import { UsersActions } from './store/users/users.actions';
|
||||||
import { RoomsActions } from './store/rooms/rooms.actions';
|
import { RoomsActions } from './store/rooms/rooms.actions';
|
||||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||||
@@ -161,7 +161,7 @@ export class App implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void import('./domains/theme/feature/settings/theme-settings.component')
|
void import('./domains/theme/feature/settings/theme-settings/theme-settings.component')
|
||||||
.then((module) => {
|
.then((module) => {
|
||||||
this.themeStudioFullscreenComponent.set(module.ThemeSettingsComponent);
|
this.themeStudioFullscreenComponent.set(module.ThemeSettingsComponent);
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,15 @@ import { UserStatus } from '../../shared-kernel';
|
|||||||
const BROWSER_IDLE_POLL_MS = 10_000;
|
const BROWSER_IDLE_POLL_MS = 10_000;
|
||||||
const BROWSER_IDLE_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes
|
const BROWSER_IDLE_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes
|
||||||
|
|
||||||
|
interface ElectronIdleApi {
|
||||||
|
onIdleStateChanged: (listener: (state: 'active' | 'idle') => void) => () => void;
|
||||||
|
getIdleState: () => Promise<'active' | 'idle'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type IdleAwareWindow = Window & {
|
||||||
|
electronAPI?: ElectronIdleApi;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Orchestrates user status based on idle detection (Electron powerMonitor
|
* Orchestrates user status based on idle detection (Electron powerMonitor
|
||||||
* or browser-fallback) and manual overrides (e.g. Do Not Disturb).
|
* or browser-fallback) and manual overrides (e.g. Do Not Disturb).
|
||||||
@@ -27,6 +36,8 @@ export class UserStatusService implements OnDestroy {
|
|||||||
private zone = inject(NgZone);
|
private zone = inject(NgZone);
|
||||||
private webrtc = inject(RealtimeSessionFacade);
|
private webrtc = inject(RealtimeSessionFacade);
|
||||||
private audio = inject(NotificationAudioService);
|
private audio = inject(NotificationAudioService);
|
||||||
|
private readonly manualStatus = this.store.selectSignal(selectManualStatus);
|
||||||
|
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
private electronCleanup: (() => void) | null = null;
|
private electronCleanup: (() => void) | null = null;
|
||||||
private browserPollTimer: ReturnType<typeof setInterval> | null = null;
|
private browserPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
@@ -41,7 +52,7 @@ export class UserStatusService implements OnDestroy {
|
|||||||
|
|
||||||
this.started = true;
|
this.started = true;
|
||||||
|
|
||||||
if ((window as any).electronAPI?.onIdleStateChanged) {
|
if (this.getElectronIdleApi()?.onIdleStateChanged) {
|
||||||
this.startElectronIdleDetection();
|
this.startElectronIdleDetection();
|
||||||
} else {
|
} else {
|
||||||
this.startBrowserIdleDetection();
|
this.startBrowserIdleDetection();
|
||||||
@@ -77,10 +88,10 @@ export class UserStatusService implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private startElectronIdleDetection(): void {
|
private startElectronIdleDetection(): void {
|
||||||
const api = (window as { electronAPI?: {
|
const api = this.getElectronIdleApi();
|
||||||
onIdleStateChanged: (cb: (state: 'active' | 'idle') => void) => () => void;
|
|
||||||
getIdleState: () => Promise<'active' | 'idle'>;
|
if (!api)
|
||||||
}; }).electronAPI!;
|
return;
|
||||||
|
|
||||||
this.electronCleanup = api.onIdleStateChanged((idleState: 'active' | 'idle') => {
|
this.electronCleanup = api.onIdleStateChanged((idleState: 'active' | 'idle') => {
|
||||||
this.zone.run(() => {
|
this.zone.run(() => {
|
||||||
@@ -138,13 +149,13 @@ export class UserStatusService implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applyAutoStatusIfAllowed(): void {
|
private applyAutoStatusIfAllowed(): void {
|
||||||
const manualStatus = this.store.selectSignal(selectManualStatus)();
|
const manualStatus = this.manualStatus();
|
||||||
|
|
||||||
// Manual status overrides automatic
|
// Manual status overrides automatic
|
||||||
if (manualStatus)
|
if (manualStatus)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const currentUser = this.store.selectSignal(selectCurrentUser)();
|
const currentUser = this.currentUser();
|
||||||
|
|
||||||
if (currentUser?.status !== this.currentAutoStatus) {
|
if (currentUser?.status !== this.currentAutoStatus) {
|
||||||
this.store.dispatch(UsersActions.setManualStatus({ status: null }));
|
this.store.dispatch(UsersActions.setManualStatus({ status: null }));
|
||||||
@@ -163,4 +174,8 @@ export class UserStatusService implements OnDestroy {
|
|||||||
status
|
status
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getElectronIdleApi(): ElectronIdleApi | undefined {
|
||||||
|
return (window as IdleAwareWindow).electronAPI;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
class="relative flex items-center justify-center w-8 h-8 rounded-full bg-secondary text-foreground text-sm font-medium hover:bg-secondary/80 transition-colors"
|
class="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"
|
||||||
(click)="toggleProfileCard(avatarBtn)"
|
(click)="toggleProfileCard(avatarBtn)"
|
||||||
>
|
>
|
||||||
{{ user()!.displayName?.charAt(0)?.toUpperCase() || '?' }}
|
{{ user()!.displayName.charAt(0).toUpperCase() || '?' }}
|
||||||
<span
|
<span
|
||||||
class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-card"
|
class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-card"
|
||||||
[class]="currentStatusColor()"
|
[class]="currentStatusColor()"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export class LinkMetadataService {
|
|||||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||||
|
|
||||||
extractUrls(content: string): string[] {
|
extractUrls(content: string): string[] {
|
||||||
return [...content.matchAll(URL_PATTERN)].map((m) => m[0]);
|
return [...content.matchAll(URL_PATTERN)].map((match) => match[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchMetadata(url: string): Promise<LinkMetadata> {
|
async fetchMetadata(url: string): Promise<LinkMetadata> {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { lucideX } from '@ng-icons/lucide';
|
import { lucideX } from '@ng-icons/lucide';
|
||||||
import { LinkMetadata } from '../../../../../../shared-kernel';
|
import { LinkMetadata } from '../../../../../../../shared-kernel';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-chat-link-embed',
|
selector: 'app-chat-link-embed',
|
||||||
@@ -43,8 +43,8 @@ import {
|
|||||||
ProfileCardService,
|
ProfileCardService,
|
||||||
UserAvatarComponent
|
UserAvatarComponent
|
||||||
} from '../../../../../../shared';
|
} from '../../../../../../shared';
|
||||||
import { ChatMessageMarkdownComponent } from './chat-message-markdown.component';
|
import { ChatMessageMarkdownComponent } from './chat-message-markdown/chat-message-markdown.component';
|
||||||
import { ChatLinkEmbedComponent } from './chat-link-embed.component';
|
import { ChatLinkEmbedComponent } from './chat-link-embed/chat-link-embed.component';
|
||||||
import {
|
import {
|
||||||
ChatMessageDeleteEvent,
|
ChatMessageDeleteEvent,
|
||||||
ChatMessageEditEvent,
|
ChatMessageEditEvent,
|
||||||
@@ -155,7 +155,7 @@ export class ChatMessageItemComponent {
|
|||||||
const msg = this.message();
|
const msg = this.message();
|
||||||
// Look up full user from store
|
// Look up full user from store
|
||||||
const users = this.allUsers();
|
const users = this.allUsers();
|
||||||
const found = users.find((u) => u.id === msg.senderId || u.oderId === msg.senderId);
|
const found = users.find((userEntry) => userEntry.id === msg.senderId || userEntry.oderId === msg.senderId);
|
||||||
const user: User = found ?? {
|
const user: User = found ?? {
|
||||||
id: msg.senderId,
|
id: msg.senderId,
|
||||||
oderId: msg.senderId,
|
oderId: msg.senderId,
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import remarkBreaks from 'remark-breaks';
|
|||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import remarkParse from 'remark-parse';
|
import remarkParse from 'remark-parse';
|
||||||
import { unified } from 'unified';
|
import { unified } from 'unified';
|
||||||
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
|
import { ChatImageProxyFallbackDirective } from '../../../../chat-image-proxy-fallback.directive';
|
||||||
import { ChatYoutubeEmbedComponent, isYoutubeUrl } from './chat-youtube-embed.component';
|
import { ChatYoutubeEmbedComponent, isYoutubeUrl } from '../chat-youtube-embed/chat-youtube-embed.component';
|
||||||
|
|
||||||
const PRISM_LANGUAGE_ALIASES: Record<string, string> = {
|
const PRISM_LANGUAGE_ALIASES: Record<string, string> = {
|
||||||
cs: 'csharp',
|
cs: 'csharp',
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
@if (videoId()) {
|
||||||
|
<div class="mt-2 w-[480px] max-w-full overflow-hidden rounded-md border border-border/60">
|
||||||
|
<iframe
|
||||||
|
[src]="embedUrl()"
|
||||||
|
class="aspect-video w-full"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowfullscreen
|
||||||
|
loading="lazy"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
|
inject,
|
||||||
input
|
input
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { DomSanitizer } from '@angular/platform-browser';
|
import { DomSanitizer } from '@angular/platform-browser';
|
||||||
@@ -10,19 +11,7 @@ const YOUTUBE_URL_PATTERN = /(?:youtube\.com\/(?:watch\?.*v=|embed\/|shorts\/)|y
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-chat-youtube-embed',
|
selector: 'app-chat-youtube-embed',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
template: `
|
templateUrl: './chat-youtube-embed.component.html'
|
||||||
@if (videoId()) {
|
|
||||||
<div class="mt-2 w-[480px] max-w-full overflow-hidden rounded-md border border-border/60">
|
|
||||||
<iframe
|
|
||||||
[src]="embedUrl()"
|
|
||||||
class="aspect-video w-full"
|
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
||||||
allowfullscreen
|
|
||||||
loading="lazy"
|
|
||||||
></iframe>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
`
|
|
||||||
})
|
})
|
||||||
export class ChatYoutubeEmbedComponent {
|
export class ChatYoutubeEmbedComponent {
|
||||||
readonly url = input.required<string>();
|
readonly url = input.required<string>();
|
||||||
@@ -44,7 +33,7 @@ export class ChatYoutubeEmbedComponent {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
constructor(private readonly sanitizer: DomSanitizer) {}
|
private readonly sanitizer = inject(DomSanitizer);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isYoutubeUrl(url?: string): boolean {
|
export function isYoutubeUrl(url?: string): boolean {
|
||||||
@@ -13,9 +13,9 @@ import {
|
|||||||
lucideMessageSquareText,
|
lucideMessageSquareText,
|
||||||
lucideMoonStar
|
lucideMoonStar
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
import { selectSavedRooms } from '../../../../../store/rooms/rooms.selectors';
|
||||||
import type { Room } from '../../../../shared-kernel';
|
import type { Room } from '../../../../../shared-kernel';
|
||||||
import { NotificationsFacade } from '../../application/facades/notifications.facade';
|
import { NotificationsFacade } from '../../../application/facades/notifications.facade';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-notifications-settings',
|
selector: 'app-notifications-settings',
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
export * from './application/facades/notifications.facade';
|
export * from './application/facades/notifications.facade';
|
||||||
export * from './application/effects/notifications.effects';
|
export * from './application/effects/notifications.effects';
|
||||||
export { NotificationsSettingsComponent } from './feature/settings/notifications-settings.component';
|
export { NotificationsSettingsComponent } from './feature/settings/notifications-settings/notifications-settings.component';
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
ThemeGridEditorItem,
|
ThemeGridEditorItem,
|
||||||
ThemeGridRect,
|
ThemeGridRect,
|
||||||
ThemeLayoutContainerDefinition
|
ThemeLayoutContainerDefinition
|
||||||
} from '../../domain/models/theme.model';
|
} from '../../../domain/models/theme.model';
|
||||||
|
|
||||||
type DragMode = 'move' | 'resize';
|
type DragMode = 'move' | 'resize';
|
||||||
|
|
||||||
@@ -8,26 +8,26 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
import { SettingsModalService } from '../../../../../core/services/settings-modal.service';
|
||||||
import {
|
import {
|
||||||
ThemeContainerKey,
|
ThemeContainerKey,
|
||||||
ThemeElementStyleProperty,
|
ThemeElementStyleProperty,
|
||||||
ThemeRegistryEntry
|
ThemeRegistryEntry
|
||||||
} from '../../domain/models/theme.model';
|
} from '../../../domain/models/theme.model';
|
||||||
import {
|
import {
|
||||||
THEME_ANIMATION_FIELDS as THEME_ANIMATION_FIELD_HINTS,
|
THEME_ANIMATION_FIELDS as THEME_ANIMATION_FIELD_HINTS,
|
||||||
THEME_ELEMENT_STYLE_FIELDS,
|
THEME_ELEMENT_STYLE_FIELDS,
|
||||||
createAnimationStarterDefinition,
|
createAnimationStarterDefinition,
|
||||||
getSuggestedFieldDefault
|
getSuggestedFieldDefault
|
||||||
} from '../../domain/logic/theme-schema.logic';
|
} from '../../../domain/logic/theme-schema.logic';
|
||||||
import { ElementPickerService } from '../../application/services/element-picker.service';
|
import { ElementPickerService } from '../../../application/services/element-picker.service';
|
||||||
import { LayoutSyncService } from '../../application/services/layout-sync.service';
|
import { LayoutSyncService } from '../../../application/services/layout-sync.service';
|
||||||
import { ThemeLibraryService } from '../../application/services/theme-library.service';
|
import { ThemeLibraryService } from '../../../application/services/theme-library.service';
|
||||||
import { ThemeRegistryService } from '../../application/services/theme-registry.service';
|
import { ThemeRegistryService } from '../../../application/services/theme-registry.service';
|
||||||
import { ThemeService } from '../../application/services/theme.service';
|
import { ThemeService } from '../../../application/services/theme.service';
|
||||||
import { THEME_LLM_GUIDE } from '../../domain/constants/theme-llm-guide.constants';
|
import { THEME_LLM_GUIDE } from '../../../domain/constants/theme-llm-guide.constants';
|
||||||
import { ThemeGridEditorComponent } from './theme-grid-editor.component';
|
import { ThemeGridEditorComponent } from '../theme-grid-editor/theme-grid-editor.component';
|
||||||
import { ThemeJsonCodeEditorComponent } from './theme-json-code-editor.component';
|
import { ThemeJsonCodeEditorComponent } from '../theme-json-code-editor/theme-json-code-editor.component';
|
||||||
|
|
||||||
type JumpSection = 'elements' | 'layout' | 'animations';
|
type JumpSection = 'elements' | 'layout' | 'animations';
|
||||||
type ThemeStudioWorkspace = 'editor' | 'inspector' | 'layout';
|
type ThemeStudioWorkspace = 'editor' | 'inspector' | 'layout';
|
||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
import { ElementPickerService } from '../application/services/element-picker.service';
|
import { ElementPickerService } from '../../application/services/element-picker.service';
|
||||||
import { ThemeRegistryService } from '../application/services/theme-registry.service';
|
import { ThemeRegistryService } from '../../application/services/theme-registry.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-theme-picker-overlay',
|
selector: 'app-theme-picker-overlay',
|
||||||
@@ -10,4 +10,4 @@ export * from './domain/logic/theme-schema.logic';
|
|||||||
export * from './domain/logic/theme-validation.logic';
|
export * from './domain/logic/theme-validation.logic';
|
||||||
|
|
||||||
export { ThemeNodeDirective } from './feature/theme-node.directive';
|
export { ThemeNodeDirective } from './feature/theme-node.directive';
|
||||||
export { ThemePickerOverlayComponent } from './feature/theme-picker-overlay.component';
|
export { ThemePickerOverlayComponent } from './feature/theme-picker-overlay/theme-picker-overlay.component';
|
||||||
|
|||||||
@@ -52,16 +52,13 @@ export class VoiceWorkspaceService {
|
|||||||
readonly hasCustomMiniWindowPosition = computed(() => this._hasCustomMiniWindowPosition());
|
readonly hasCustomMiniWindowPosition = computed(() => this._hasCustomMiniWindowPosition());
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(
|
effect(() => {
|
||||||
() => {
|
if (this.voiceSession.voiceSession()) {
|
||||||
if (this.voiceSession.voiceSession()) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
this.reset();
|
this.reset();
|
||||||
},
|
});
|
||||||
{ allowSignalWrites: true }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
open(
|
open(
|
||||||
|
|||||||
@@ -17,9 +17,11 @@
|
|||||||
/>
|
/>
|
||||||
@if (voiceSession()?.serverIcon) {
|
@if (voiceSession()?.serverIcon) {
|
||||||
<img
|
<img
|
||||||
[src]="voiceSession()?.serverIcon"
|
[ngSrc]="voiceSession()?.serverIcon || ''"
|
||||||
class="w-5 h-5 rounded object-cover"
|
class="w-5 h-5 rounded object-cover"
|
||||||
alt=""
|
alt=""
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
/>
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="flex h-5 w-5 items-center justify-center rounded-sm bg-muted text-[10px] font-semibold">
|
<div class="flex h-5 w-5 items-center justify-center rounded-sm bg-muted text-[10px] font-semibold">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
computed,
|
computed,
|
||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule, NgOptimizedImage } from '@angular/common';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +34,7 @@ import { ThemeNodeDirective } from '../../../../domains/theme';
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
NgOptimizedImage,
|
||||||
NgIcon,
|
NgIcon,
|
||||||
DebugConsoleComponent,
|
DebugConsoleComponent,
|
||||||
ScreenShareQualityDialogComponent,
|
ScreenShareQualityDialogComponent,
|
||||||
|
|||||||
@@ -182,15 +182,7 @@
|
|||||||
[name]="u.displayName"
|
[name]="u.displayName"
|
||||||
[avatarUrl]="u.avatarUrl"
|
[avatarUrl]="u.avatarUrl"
|
||||||
size="xs"
|
size="xs"
|
||||||
[ringClass]="
|
[ringClass]="getVoiceUserRingClass(u)"
|
||||||
u.voiceState?.isDeafened
|
|
||||||
? 'ring-2 ring-red-500'
|
|
||||||
: u.voiceState?.isMuted
|
|
||||||
? 'ring-2 ring-yellow-500'
|
|
||||||
: voiceActivity.isSpeaking(u.oderId || u.id)()
|
|
||||||
? 'ring-2 ring-green-400 shadow-[0_0_8px_2px_rgba(74,222,128,0.6)]'
|
|
||||||
: 'ring-2 ring-green-500/40'
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
|
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
|
||||||
<!-- Ping latency indicator -->
|
<!-- Ping latency indicator -->
|
||||||
@@ -246,7 +238,11 @@
|
|||||||
<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
|
<div
|
||||||
class="flex items-center gap-2 rounded-md bg-secondary/60 px-3 py-2 hover:bg-secondary/80 transition-colors cursor-pointer"
|
class="flex items-center gap-2 rounded-md bg-secondary/60 px-3 py-2 hover:bg-secondary/80 transition-colors cursor-pointer"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
(click)="openProfileCard($event, currentUser()!, true); $event.stopPropagation()"
|
(click)="openProfileCard($event, currentUser()!, true); $event.stopPropagation()"
|
||||||
|
(keydown.enter)="openProfileCard($event, currentUser()!, true); $event.stopPropagation()"
|
||||||
|
(keydown.space)="openProfileCard($event, currentUser()!, true); $event.preventDefault(); $event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<app-user-avatar
|
<app-user-avatar
|
||||||
[name]="currentUser()?.displayName || '?'"
|
[name]="currentUser()?.displayName || '?'"
|
||||||
@@ -293,8 +289,12 @@
|
|||||||
@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 cursor-pointer"
|
class="group/user flex items-center gap-2 rounded-md px-3 py-2 transition-colors hover:bg-secondary/50 cursor-pointer"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
(contextmenu)="openUserContextMenu($event, user)"
|
(contextmenu)="openUserContextMenu($event, user)"
|
||||||
(click)="openProfileCard($event, user, false); $event.stopPropagation()"
|
(click)="openProfileCard($event, user, false); $event.stopPropagation()"
|
||||||
|
(keydown.enter)="openProfileCard($event, user, false); $event.stopPropagation()"
|
||||||
|
(keydown.space)="openProfileCard($event, user, false); $event.preventDefault(); $event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<app-user-avatar
|
<app-user-avatar
|
||||||
[name]="user.displayName"
|
[name]="user.displayName"
|
||||||
@@ -352,7 +352,11 @@
|
|||||||
@for (member of offlineRoomMembers(); track member.oderId || member.id) {
|
@for (member of offlineRoomMembers(); track member.oderId || member.id) {
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-2 rounded-md px-3 py-2 opacity-80 hover:bg-secondary/30 transition-colors cursor-pointer"
|
class="flex items-center gap-2 rounded-md px-3 py-2 opacity-80 hover:bg-secondary/30 transition-colors cursor-pointer"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
(click)="openProfileCardForMember($event, member); $event.stopPropagation()"
|
(click)="openProfileCardForMember($event, member); $event.stopPropagation()"
|
||||||
|
(keydown.enter)="openProfileCardForMember($event, member); $event.stopPropagation()"
|
||||||
|
(keydown.space)="openProfileCardForMember($event, member); $event.preventDefault(); $event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<app-user-avatar
|
<app-user-avatar
|
||||||
[name]="member.displayName"
|
[name]="member.displayName"
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export class RoomsSidePanelComponent {
|
|||||||
private voiceWorkspace = inject(VoiceWorkspaceService);
|
private voiceWorkspace = inject(VoiceWorkspaceService);
|
||||||
private voicePlayback = inject(VoicePlaybackService);
|
private voicePlayback = inject(VoicePlaybackService);
|
||||||
private profileCard = inject(ProfileCardService);
|
private profileCard = inject(ProfileCardService);
|
||||||
voiceActivity = inject(VoiceActivityService);
|
private readonly voiceActivity = inject(VoiceActivityService);
|
||||||
|
|
||||||
readonly panelMode = input<PanelMode>('channels');
|
readonly panelMode = input<PanelMode>('channels');
|
||||||
readonly showVoiceControls = input(true);
|
readonly showVoiceControls = input(true);
|
||||||
@@ -186,14 +186,14 @@ 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 {
|
openProfileCard(event: Event, user: User, editable: boolean): void {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const el = event.currentTarget as HTMLElement;
|
const el = event.currentTarget as HTMLElement;
|
||||||
|
|
||||||
this.profileCard.open(el, user, { placement: 'left', editable });
|
this.profileCard.open(el, user, { placement: 'left', editable });
|
||||||
}
|
}
|
||||||
|
|
||||||
openProfileCardForMember(event: MouseEvent, member: RoomMember): void {
|
openProfileCardForMember(event: Event, member: RoomMember): void {
|
||||||
const user: User = {
|
const user: User = {
|
||||||
id: member.id,
|
id: member.id,
|
||||||
oderId: member.oderId || member.id,
|
oderId: member.oderId || member.id,
|
||||||
@@ -886,6 +886,22 @@ export class RoomsSidePanelComponent {
|
|||||||
return this.isUserSharing(userId) || this.isUserOnCamera(userId);
|
return this.isUserSharing(userId) || this.isUserOnCamera(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getVoiceUserRingClass(user: User): string {
|
||||||
|
if (user.voiceState?.isDeafened) {
|
||||||
|
return 'ring-2 ring-red-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.voiceState?.isMuted) {
|
||||||
|
return 'ring-2 ring-yellow-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isVoiceUserSpeaking(user)) {
|
||||||
|
return 'ring-2 ring-green-400 shadow-[0_0_8px_2px_rgba(74,222,128,0.6)]';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'ring-2 ring-green-500/40';
|
||||||
|
}
|
||||||
|
|
||||||
getUserLiveIconName(userId: string): string {
|
getUserLiveIconName(userId: string): string {
|
||||||
return this.isUserSharing(userId) ? 'lucideMonitor' : 'lucideVideo';
|
return this.isUserSharing(userId) ? 'lucideMonitor' : 'lucideVideo';
|
||||||
}
|
}
|
||||||
@@ -981,6 +997,12 @@ export class RoomsSidePanelComponent {
|
|||||||
return 'bg-red-500';
|
return 'bg-red-500';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isVoiceUserSpeaking(user: User): boolean {
|
||||||
|
const userKey = user.oderId || user.id;
|
||||||
|
|
||||||
|
return !!userKey && this.voiceActivity.speakingMap().get(userKey) === true;
|
||||||
|
}
|
||||||
|
|
||||||
private findKnownUser(userId: string): User | null {
|
private findKnownUser(userId: string): User | null {
|
||||||
const current = this.currentUser();
|
const current = this.currentUser();
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ import {
|
|||||||
lucideVolumeX
|
lucideVolumeX
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { UserAvatarComponent } from '../../../shared';
|
import { UserAvatarComponent } from '../../../../shared';
|
||||||
import { VoiceWorkspacePlaybackService } from './voice-workspace-playback.service';
|
import { VoiceWorkspacePlaybackService } from '../voice-workspace-playback.service';
|
||||||
import { VoiceWorkspaceStreamItem } from './voice-workspace.models';
|
import { VoiceWorkspaceStreamItem } from '../voice-workspace.models';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-voice-workspace-stream-tile',
|
selector: 'app-voice-workspace-stream-tile',
|
||||||
@@ -86,23 +86,20 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
|||||||
void video.play().catch(() => {});
|
void video.play().catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
effect(
|
effect(() => {
|
||||||
() => {
|
this.workspacePlayback.settings();
|
||||||
this.workspacePlayback.settings();
|
|
||||||
|
|
||||||
const item = this.item();
|
const item = this.item();
|
||||||
|
|
||||||
if (item.isLocal || !item.hasAudio) {
|
if (item.isLocal || !item.hasAudio) {
|
||||||
this.volume.set(0);
|
this.volume.set(0);
|
||||||
this.muted.set(false);
|
this.muted.set(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.volume.set(this.workspacePlayback.getUserVolume(item.peerKey));
|
this.volume.set(this.workspacePlayback.getUserVolume(item.peerKey));
|
||||||
this.muted.set(this.workspacePlayback.isUserMuted(item.peerKey));
|
this.muted.set(this.workspacePlayback.isUserMuted(item.peerKey));
|
||||||
},
|
});
|
||||||
{ allowSignalWrites: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const ref = this.videoRef();
|
const ref = this.videoRef();
|
||||||
@@ -48,7 +48,7 @@ import { UsersActions } from '../../../store/users/users.actions';
|
|||||||
import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors';
|
import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors';
|
||||||
import { ScreenShareQualityDialogComponent, UserAvatarComponent } from '../../../shared';
|
import { ScreenShareQualityDialogComponent, UserAvatarComponent } from '../../../shared';
|
||||||
import { VoiceWorkspacePlaybackService } from './voice-workspace-playback.service';
|
import { VoiceWorkspacePlaybackService } from './voice-workspace-playback.service';
|
||||||
import { VoiceWorkspaceStreamTileComponent } from './voice-workspace-stream-tile.component';
|
import { VoiceWorkspaceStreamTileComponent } from './voice-workspace-stream-tile/voice-workspace-stream-tile.component';
|
||||||
import { VoiceWorkspaceStreamItem } from './voice-workspace.models';
|
import { VoiceWorkspaceStreamItem } from './voice-workspace.models';
|
||||||
import { ThemeNodeDirective } from '../../../domains/theme';
|
import { ThemeNodeDirective } from '../../../domains/theme';
|
||||||
|
|
||||||
@@ -456,38 +456,35 @@ export class VoiceWorkspaceComponent {
|
|||||||
this.pruneObservedRemoteStreams(peerKeys);
|
this.pruneObservedRemoteStreams(peerKeys);
|
||||||
});
|
});
|
||||||
|
|
||||||
effect(
|
effect(() => {
|
||||||
() => {
|
const isExpanded = this.showExpanded();
|
||||||
const isExpanded = this.showExpanded();
|
const shouldAutoHideChrome = this.shouldAutoHideChrome();
|
||||||
const shouldAutoHideChrome = this.shouldAutoHideChrome();
|
|
||||||
|
|
||||||
if (!isExpanded) {
|
if (!isExpanded) {
|
||||||
this.clearHeaderHideTimeout();
|
this.clearHeaderHideTimeout();
|
||||||
this.showWorkspaceHeader.set(true);
|
this.showWorkspaceHeader.set(true);
|
||||||
this.wasExpanded = false;
|
this.wasExpanded = false;
|
||||||
this.wasAutoHideChrome = false;
|
this.wasAutoHideChrome = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!shouldAutoHideChrome) {
|
|
||||||
this.clearHeaderHideTimeout();
|
|
||||||
this.showWorkspaceHeader.set(true);
|
|
||||||
this.wasExpanded = true;
|
|
||||||
this.wasAutoHideChrome = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldRevealChrome = !this.wasExpanded || !this.wasAutoHideChrome;
|
|
||||||
|
|
||||||
|
if (!shouldAutoHideChrome) {
|
||||||
|
this.clearHeaderHideTimeout();
|
||||||
|
this.showWorkspaceHeader.set(true);
|
||||||
this.wasExpanded = true;
|
this.wasExpanded = true;
|
||||||
this.wasAutoHideChrome = true;
|
this.wasAutoHideChrome = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldRevealChrome) {
|
const shouldRevealChrome = !this.wasExpanded || !this.wasAutoHideChrome;
|
||||||
this.revealWorkspaceChrome();
|
|
||||||
}
|
this.wasExpanded = true;
|
||||||
},
|
this.wasAutoHideChrome = true;
|
||||||
{ allowSignalWrites: true }
|
|
||||||
);
|
if (shouldRevealChrome) {
|
||||||
|
this.revealWorkspaceChrome();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onWorkspacePointerMove(): void {
|
onWorkspacePointerMove(): void {
|
||||||
|
|||||||
@@ -26,21 +26,21 @@ import {
|
|||||||
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 { 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';
|
||||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
||||||
import { DatabaseService } from '../../infrastructure/persistence';
|
import { DatabaseService } from '../../../infrastructure/persistence';
|
||||||
import { NotificationsFacade } from '../../domains/notifications';
|
import { NotificationsFacade } from '../../../domains/notifications';
|
||||||
import { type ServerInfo, ServerDirectoryFacade } from '../../domains/server-directory';
|
import { type ServerInfo, ServerDirectoryFacade } from '../../../domains/server-directory';
|
||||||
import { hasRoomBanForUser } from '../../domains/access-control';
|
import { hasRoomBanForUser } from '../../../domains/access-control';
|
||||||
import {
|
import {
|
||||||
ConfirmDialogComponent,
|
ConfirmDialogComponent,
|
||||||
ContextMenuComponent,
|
ContextMenuComponent,
|
||||||
LeaveServerDialogComponent
|
LeaveServerDialogComponent
|
||||||
} from '../../shared';
|
} from '../../../shared';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-servers-rail',
|
selector: 'app-servers-rail',
|
||||||
@@ -81,8 +81,8 @@ export class ServersRailComponent {
|
|||||||
bannedRoomLookup = signal<Record<string, boolean>>({});
|
bannedRoomLookup = signal<Record<string, boolean>>({});
|
||||||
isOnSearch = toSignal(
|
isOnSearch = toSignal(
|
||||||
this.router.events.pipe(
|
this.router.events.pipe(
|
||||||
filter((e): e is NavigationEnd => e instanceof NavigationEnd),
|
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||||
map((e) => e.urlAfterRedirects.startsWith('/search'))
|
map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/search'))
|
||||||
),
|
),
|
||||||
{ initialValue: this.router.url.startsWith('/search') }
|
{ initialValue: this.router.url.startsWith('/search') }
|
||||||
);
|
);
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
@if (canKickMembers(member)) {
|
@if (canKickMembers(member)) {
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
(click)="kickMember(member)"
|
(click)="kickMember(member)"
|
||||||
class="grid h-8 w-8 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
|
class="grid h-8 w-8 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
|
||||||
title="Kick"
|
title="Kick"
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
}
|
}
|
||||||
@if (canBanMembers(member)) {
|
@if (canBanMembers(member)) {
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
(click)="banMember(member)"
|
(click)="banMember(member)"
|
||||||
class="grid h-8 w-8 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
|
class="grid h-8 w-8 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
|
||||||
title="Ban"
|
title="Ban"
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import { RealtimeSessionFacade } from '../../../core/realtime';
|
|||||||
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||||
import { Room, UserRole } from '../../../shared-kernel';
|
import { Room, UserRole } from '../../../shared-kernel';
|
||||||
import { NotificationsSettingsComponent } from '../../../domains/notifications/feature/settings/notifications-settings.component';
|
import { NotificationsSettingsComponent } from '../../../domains/notifications';
|
||||||
import { resolveLegacyRole, resolveRoomPermission } from '../../../domains/access-control';
|
import { resolveLegacyRole, resolveRoomPermission } from '../../../domains/access-control';
|
||||||
|
|
||||||
import { GeneralSettingsComponent } from './general-settings/general-settings.component';
|
import { GeneralSettingsComponent } from './general-settings/general-settings.component';
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ export interface ThirdPartyLicense {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toLicenseText = (lines: readonly string[]): string => lines.join('\n');
|
const toLicenseText = (lines: readonly string[]): string => lines.join('\n');
|
||||||
const GROUPED_LICENSE_NOTE = 'Grouped by the license declared in the installed package metadata for the packages below. 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',
|
||||||
'',
|
'',
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import {
|
|||||||
inject,
|
inject,
|
||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { ElectronBridgeService } from '../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
||||||
import { ContextMenuComponent } from '../../shared';
|
import { ContextMenuComponent } from '../../../shared';
|
||||||
import type { ContextMenuParams } from '../../core/platform/electron/electron-api.models';
|
import type { ContextMenuParams } from '../../../core/platform/electron/electron-api.models';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-native-context-menu',
|
selector: 'app-native-context-menu',
|
||||||
@@ -26,18 +26,18 @@ import {
|
|||||||
selectVoiceChannels,
|
selectVoiceChannels,
|
||||||
selectIsSignalServerReconnecting,
|
selectIsSignalServerReconnecting,
|
||||||
selectSignalServerCompatibilityError
|
selectSignalServerCompatibilityError
|
||||||
} from '../../store/rooms/rooms.selectors';
|
} from '../../../store/rooms/rooms.selectors';
|
||||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
||||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||||
import { ElectronBridgeService } from '../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
||||||
import { RealtimeSessionFacade } from '../../core/realtime';
|
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||||
import { ServerDirectoryFacade } from '../../domains/server-directory';
|
import { ServerDirectoryFacade } from '../../../domains/server-directory';
|
||||||
import { PlatformService } from '../../core/platform';
|
import { PlatformService } from '../../../core/platform';
|
||||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
|
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
||||||
import { LeaveServerDialogComponent } from '../../shared';
|
import { LeaveServerDialogComponent } from '../../../shared';
|
||||||
import { Room } from '../../shared-kernel';
|
import { Room } from '../../../shared-kernel';
|
||||||
import { VoiceWorkspaceService } from '../../domains/voice-session';
|
import { VoiceWorkspaceService } from '../../../domains/voice-session';
|
||||||
import { ThemeNodeDirective } from '../../domains/theme';
|
import { ThemeNodeDirective } from '../../../domains/theme';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-title-bar',
|
selector: 'app-title-bar',
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable complexity */
|
|
||||||
import {
|
import {
|
||||||
SIGNALING_TYPE_ANSWER,
|
SIGNALING_TYPE_ANSWER,
|
||||||
SIGNALING_TYPE_OFFER,
|
SIGNALING_TYPE_OFFER,
|
||||||
@@ -11,6 +10,7 @@ import {
|
|||||||
PeerConnectionManagerContext,
|
PeerConnectionManagerContext,
|
||||||
PeerConnectionManagerState
|
PeerConnectionManagerState
|
||||||
} from '../shared';
|
} from '../shared';
|
||||||
|
import type { PeerData } from '../../realtime.types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue a negotiation task so SDP operations for a single peer never overlap.
|
* Queue a negotiation task so SDP operations for a single peer never overlap.
|
||||||
@@ -95,6 +95,97 @@ function replaceUnusablePeer(
|
|||||||
state.peerNegotiationQueue.delete(peerId);
|
state.peerNegotiationQueue.delete(peerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getOrCreatePeerForOffer(
|
||||||
|
state: PeerConnectionManagerState,
|
||||||
|
fromUserId: string,
|
||||||
|
handlers: NegotiationHandlers
|
||||||
|
): PeerData {
|
||||||
|
return state.activePeerConnections.get(fromUserId) ?? handlers.createPeerConnection(fromUserId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveOfferCollision(
|
||||||
|
peerData: PeerData,
|
||||||
|
callbacks: PeerConnectionManagerContext['callbacks'],
|
||||||
|
logger: PeerConnectionManagerContext['logger'],
|
||||||
|
fromUserId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const signalingState = peerData.connection.signalingState;
|
||||||
|
const hasCollision = signalingState === 'have-local-offer' || signalingState === 'have-local-pranswer';
|
||||||
|
|
||||||
|
if (!hasCollision)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
const localOderId = callbacks.getIdentifyCredentials()?.oderId ?? null;
|
||||||
|
const isPolite = !localOderId || localOderId > fromUserId;
|
||||||
|
|
||||||
|
if (!isPolite) {
|
||||||
|
logger.info('Ignoring colliding offer (impolite side)', { fromUserId, localOderId });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Rolling back local offer (polite side)', { fromUserId, localOderId });
|
||||||
|
|
||||||
|
await peerData.connection.setLocalDescription({
|
||||||
|
type: 'rollback'
|
||||||
|
} as RTCSessionDescriptionInit);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPeerSendersFromTransceivers(peerData: PeerData): void {
|
||||||
|
const transceivers = peerData.connection.getTransceivers();
|
||||||
|
|
||||||
|
for (const transceiver of transceivers) {
|
||||||
|
const receiverKind = transceiver.receiver.track?.kind;
|
||||||
|
|
||||||
|
if (receiverKind === TRACK_KIND_AUDIO) {
|
||||||
|
if (!peerData.audioSender) {
|
||||||
|
peerData.audioSender = transceiver.sender;
|
||||||
|
}
|
||||||
|
|
||||||
|
transceiver.direction = TRANSCEIVER_SEND_RECV;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receiverKind === TRACK_KIND_VIDEO && !peerData.videoSender) {
|
||||||
|
peerData.videoSender = transceiver.sender;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachAnswererLocalTracks(
|
||||||
|
peerData: PeerData,
|
||||||
|
localStream: MediaStream | null,
|
||||||
|
logger: PeerConnectionManagerContext['logger'],
|
||||||
|
fromUserId: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!localStream)
|
||||||
|
return;
|
||||||
|
|
||||||
|
logger.logStream(`localStream->${fromUserId} (answerer)`, localStream);
|
||||||
|
|
||||||
|
for (const track of localStream.getTracks()) {
|
||||||
|
if (track.kind === TRACK_KIND_AUDIO && peerData.audioSender) {
|
||||||
|
await peerData.audioSender.replaceTrack(track);
|
||||||
|
logger.info('audio replaceTrack (answerer) ok', { fromUserId });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.kind === TRACK_KIND_VIDEO && peerData.videoSender) {
|
||||||
|
await peerData.videoSender.replaceTrack(track);
|
||||||
|
logger.info('video replaceTrack (answerer) ok', { fromUserId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyPendingIceCandidates(peerData: PeerData): Promise<void> {
|
||||||
|
for (const candidate of peerData.pendingIceCandidates) {
|
||||||
|
await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate));
|
||||||
|
}
|
||||||
|
|
||||||
|
peerData.pendingIceCandidates = [];
|
||||||
|
}
|
||||||
|
|
||||||
export async function doHandleOffer(
|
export async function doHandleOffer(
|
||||||
context: PeerConnectionManagerContext,
|
context: PeerConnectionManagerContext,
|
||||||
fromUserId: string,
|
fromUserId: string,
|
||||||
@@ -107,72 +198,18 @@ export async function doHandleOffer(
|
|||||||
|
|
||||||
replaceUnusablePeer(context, fromUserId, 'incoming offer');
|
replaceUnusablePeer(context, fromUserId, 'incoming offer');
|
||||||
|
|
||||||
let peerData = state.activePeerConnections.get(fromUserId);
|
const peerData = getOrCreatePeerForOffer(state, fromUserId, handlers);
|
||||||
|
|
||||||
if (!peerData) {
|
|
||||||
peerData = handlers.createPeerConnection(fromUserId, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const signalingState = peerData.connection.signalingState;
|
const shouldApplyOffer = await resolveOfferCollision(peerData, callbacks, logger, fromUserId);
|
||||||
const hasCollision =
|
|
||||||
signalingState === 'have-local-offer' || signalingState === 'have-local-pranswer';
|
|
||||||
|
|
||||||
if (hasCollision) {
|
if (!shouldApplyOffer)
|
||||||
const localOderId = callbacks.getIdentifyCredentials()?.oderId ?? null;
|
return;
|
||||||
const isPolite = !localOderId || localOderId > fromUserId;
|
|
||||||
|
|
||||||
if (!isPolite) {
|
|
||||||
logger.info('Ignoring colliding offer (impolite side)', { fromUserId, localOderId });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Rolling back local offer (polite side)', { fromUserId, localOderId });
|
|
||||||
|
|
||||||
await peerData.connection.setLocalDescription({
|
|
||||||
type: 'rollback'
|
|
||||||
} as RTCSessionDescriptionInit);
|
|
||||||
}
|
|
||||||
|
|
||||||
await peerData.connection.setRemoteDescription(new RTCSessionDescription(sdp));
|
await peerData.connection.setRemoteDescription(new RTCSessionDescription(sdp));
|
||||||
|
syncPeerSendersFromTransceivers(peerData);
|
||||||
const transceivers = peerData.connection.getTransceivers();
|
await attachAnswererLocalTracks(peerData, callbacks.getLocalMediaStream(), logger, fromUserId);
|
||||||
|
await applyPendingIceCandidates(peerData);
|
||||||
for (const transceiver of transceivers) {
|
|
||||||
const receiverKind = transceiver.receiver.track?.kind;
|
|
||||||
|
|
||||||
if (receiverKind === TRACK_KIND_AUDIO) {
|
|
||||||
if (!peerData.audioSender) {
|
|
||||||
peerData.audioSender = transceiver.sender;
|
|
||||||
}
|
|
||||||
|
|
||||||
transceiver.direction = TRANSCEIVER_SEND_RECV;
|
|
||||||
} else if (receiverKind === TRACK_KIND_VIDEO && !peerData.videoSender) {
|
|
||||||
peerData.videoSender = transceiver.sender;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const localStream = callbacks.getLocalMediaStream();
|
|
||||||
|
|
||||||
if (localStream) {
|
|
||||||
logger.logStream(`localStream->${fromUserId} (answerer)`, localStream);
|
|
||||||
|
|
||||||
for (const track of localStream.getTracks()) {
|
|
||||||
if (track.kind === TRACK_KIND_AUDIO && peerData.audioSender) {
|
|
||||||
await peerData.audioSender.replaceTrack(track);
|
|
||||||
logger.info('audio replaceTrack (answerer) ok', { fromUserId });
|
|
||||||
} else if (track.kind === TRACK_KIND_VIDEO && peerData.videoSender) {
|
|
||||||
await peerData.videoSender.replaceTrack(track);
|
|
||||||
logger.info('video replaceTrack (answerer) ok', { fromUserId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const candidate of peerData.pendingIceCandidates) {
|
|
||||||
await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate));
|
|
||||||
}
|
|
||||||
|
|
||||||
peerData.pendingIceCandidates = [];
|
|
||||||
|
|
||||||
const answer = await peerData.connection.createAnswer();
|
const answer = await peerData.connection.createAnswer();
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,7 @@ import {
|
|||||||
})
|
})
|
||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
export class ContextMenuComponent implements OnInit, AfterViewInit {
|
export class ContextMenuComponent implements OnInit, AfterViewInit {
|
||||||
// eslint-disable-next-line id-length, id-denylist
|
|
||||||
x = input.required<number>();
|
x = input.required<number>();
|
||||||
// eslint-disable-next-line id-length, id-denylist
|
|
||||||
y = input.required<number>();
|
y = input.required<number>();
|
||||||
width = input<string>('w-48');
|
width = input<string>('w-48');
|
||||||
widthPx = input<number | null>(null);
|
widthPx = input<number | null>(null);
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import { type DebugLogEntry, type DebugLogLevel } from '../../../../core/service
|
|||||||
export class DebugConsoleEntryListComponent {
|
export class DebugConsoleEntryListComponent {
|
||||||
readonly entries = input.required<DebugLogEntry[]>();
|
readonly entries = input.required<DebugLogEntry[]>();
|
||||||
readonly autoScroll = input.required<boolean>();
|
readonly autoScroll = input.required<boolean>();
|
||||||
readonly entryExpanded = output<void>();
|
readonly entryExpanded = output();
|
||||||
readonly expandedEntryIds = signal<number[]>([]);
|
readonly expandedEntryIds = signal<number[]>([]);
|
||||||
|
|
||||||
private readonly viewportRef = viewChild<ElementRef<HTMLDivElement>>('viewport');
|
private readonly viewportRef = viewChild<ElementRef<HTMLDivElement>>('viewport');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* eslint-disable id-denylist, id-length, padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ type DebugConsoleLauncherVariant = 'floating' | 'inline' | 'compact';
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
export class DebugConsoleComponent {
|
export class DebugConsoleComponent {
|
||||||
|
private static readonly VISIBLE_ENTRY_LIMIT = 500;
|
||||||
|
|
||||||
readonly debugging = inject(DebuggingService);
|
readonly debugging = inject(DebuggingService);
|
||||||
readonly resizeService = inject(DebugConsoleResizeService);
|
readonly resizeService = inject(DebugConsoleResizeService);
|
||||||
readonly exportService = inject(DebugConsoleExportService);
|
readonly exportService = inject(DebugConsoleExportService);
|
||||||
@@ -78,7 +80,6 @@ export class DebugConsoleComponent {
|
|||||||
readonly sourceOptions = computed(() => {
|
readonly sourceOptions = computed(() => {
|
||||||
return Array.from(new Set(this.entries().map((entry) => entry.source))).sort();
|
return Array.from(new Set(this.entries().map((entry) => entry.source))).sort();
|
||||||
});
|
});
|
||||||
private static readonly VISIBLE_ENTRY_LIMIT = 500;
|
|
||||||
|
|
||||||
readonly filteredEntries = computed(() => {
|
readonly filteredEntries = computed(() => {
|
||||||
const searchTerm = this.searchTerm().trim()
|
const searchTerm = this.searchTerm().trim()
|
||||||
@@ -245,7 +246,7 @@ export class DebugConsoleComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggleShowAllEntries(): void {
|
toggleShowAllEntries(): void {
|
||||||
this.showAllEntries.update((v) => !v);
|
this.showAllEntries.update((isVisible) => !isVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
startTopResize(event: MouseEvent): void {
|
startTopResize(event: MouseEvent): void {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<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"
|
||||||
|
>
|
||||||
|
<div class="h-24 rounded-t-lg bg-gradient-to-r from-primary/30 to-primary/10"></div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="px-5 pb-4 pt-3">
|
||||||
|
<p class="truncate text-base font-semibold text-foreground">{{ user().displayName }}</p>
|
||||||
|
<p class="truncate text-sm text-muted-foreground">{{ user().username }}</p>
|
||||||
|
|
||||||
|
@if (editable()) {
|
||||||
|
<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 transition-colors hover:bg-secondary/60"
|
||||||
|
(click)="toggleStatusMenu()"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-2 w-2 rounded-full"
|
||||||
|
[class]="currentStatusColor()"
|
||||||
|
></span>
|
||||||
|
<span class="flex-1 text-left text-foreground">{{ currentStatusLabel() }}</span>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideChevronDown"
|
||||||
|
class="h-3 w-3 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (showStatusMenu()) {
|
||||||
|
<div class="absolute bottom-full left-0 z-10 mb-1 w-full rounded-md border border-border bg-card py-1 shadow-lg">
|
||||||
|
@for (opt of statusOptions; track opt.label) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-secondary"
|
||||||
|
(click)="setStatus(opt.value)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-2 w-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="h-2 w-2 rounded-full"
|
||||||
|
[class]="currentStatusColor()"
|
||||||
|
></span>
|
||||||
|
<span>{{ currentStatusLabel() }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -19,80 +19,12 @@ import { User, UserStatus } from '../../../shared-kernel';
|
|||||||
UserAvatarComponent
|
UserAvatarComponent
|
||||||
],
|
],
|
||||||
viewProviders: [provideIcons({ lucideChevronDown })],
|
viewProviders: [provideIcons({ lucideChevronDown })],
|
||||||
template: `
|
templateUrl: './profile-card.component.html'
|
||||||
<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 {
|
export class ProfileCardComponent {
|
||||||
user = signal<User>({ id: '', oderId: '', username: '', displayName: '', status: 'offline', role: 'member', joinedAt: 0 });
|
readonly user = signal<User>({ id: '', oderId: '', username: '', displayName: '', status: 'offline', role: 'member', joinedAt: 0 });
|
||||||
editable = signal(false);
|
readonly editable = signal(false);
|
||||||
|
readonly showStatusMenu = signal(false);
|
||||||
private userStatus = inject(UserStatusService);
|
|
||||||
showStatusMenu = signal(false);
|
|
||||||
|
|
||||||
readonly statusOptions: { value: UserStatus | null; label: string; color: string }[] = [
|
readonly statusOptions: { value: UserStatus | null; label: string; color: string }[] = [
|
||||||
{ value: null, label: 'Online', color: 'bg-green-500' },
|
{ value: null, label: 'Online', color: 'bg-green-500' },
|
||||||
@@ -101,6 +33,8 @@ export class ProfileCardComponent {
|
|||||||
{ value: 'offline', label: 'Invisible', color: 'bg-gray-500' }
|
{ value: 'offline', label: 'Invisible', color: 'bg-gray-500' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private readonly userStatus = inject(UserStatusService);
|
||||||
|
|
||||||
currentStatusColor(): string {
|
currentStatusColor(): string {
|
||||||
switch (this.user().status) {
|
switch (this.user().status) {
|
||||||
case 'online': return 'bg-green-500';
|
case 'online': return 'bg-green-500';
|
||||||
@@ -124,7 +58,7 @@ export class ProfileCardComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggleStatusMenu(): void {
|
toggleStatusMenu(): void {
|
||||||
this.showStatusMenu.update((v) => !v);
|
this.showStatusMenu.update((isOpen) => !isOpen);
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus(status: UserStatus | null): void {
|
setStatus(status: UserStatus | null): void {
|
||||||
|
|||||||
@@ -21,9 +21,7 @@ import { ContextMenuComponent } from '../context-menu/context-menu.component';
|
|||||||
})
|
})
|
||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
export class UserVolumeMenuComponent implements OnInit {
|
export class UserVolumeMenuComponent implements OnInit {
|
||||||
// eslint-disable-next-line id-length, id-denylist
|
|
||||||
x = input.required<number>();
|
x = input.required<number>();
|
||||||
// eslint-disable-next-line id-length, id-denylist
|
|
||||||
y = input.required<number>();
|
y = input.required<number>();
|
||||||
peerId = input.required<string>();
|
peerId = input.required<string>();
|
||||||
displayName = input.required<string>();
|
displayName = input.required<string>();
|
||||||
|
|||||||
Reference in New Issue
Block a user