refactor: Clean lint errors and organise files

This commit is contained in:
2026-04-17 01:06:01 +02:00
parent 2927a86fbb
commit 35b616fb77
60 changed files with 1161 additions and 728 deletions

View File

@@ -16,6 +16,7 @@ const TEST_PORT = process.env.TEST_SERVER_PORT || '3099';
const SERVER_DIR = join(__dirname, '..', '..', 'server');
const SERVER_ENTRY = join(SERVER_DIR, 'src', 'index.ts');
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 ──────────────────────────────
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
// and node_modules are found from the real server/ directory.
const child = spawn(
'npx',
['ts-node', '--project', SERVER_TSCONFIG, SERVER_ENTRY],
process.execPath,
[TS_NODE_BIN, '--project', SERVER_TSCONFIG, SERVER_ENTRY],
{
cwd: tmpDir,
env: {
@@ -55,7 +56,6 @@ const child = spawn(
DB_SYNCHRONIZE: 'true',
},
stdio: 'inherit',
shell: true,
}
);

View File

@@ -11,9 +11,15 @@ import { type Page } from '@playwright/test';
export async function installWebRTCTracking(page: Page): Promise<void> {
await page.addInitScript(() => {
const connections: RTCPeerConnection[] = [];
const syntheticMediaResources: {
audioCtx: AudioContext;
source?: AudioScheduledSourceNode;
drawIntervalId?: number;
}[] = [];
(window as any).__rtcConnections = connections;
(window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[];
(window as any).__rtcSyntheticMediaResources = syntheticMediaResources;
const OriginalRTCPeerConnection = window.RTCPeerConnection;
@@ -55,18 +61,37 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
// Get the original stream (may include video)
const originalStream = await origGetUserMedia(constraints);
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();
oscillator.connect(dest);
oscillator.start();
source.connect(gain);
gain.connect(dest);
source.start();
if (audioCtx.state === 'suspended') {
try {
await audioCtx.resume();
} catch {}
}
const synthAudioTrack = dest.stream.getAudioTracks()[0];
const resultStream = new MediaStream();
syntheticMediaResources.push({ audioCtx, source });
resultStream.addTrack(synthAudioTrack);
// Keep any video tracks from the original stream
@@ -79,6 +104,14 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
track.stop();
}
synthAudioTrack.addEventListener('ended', () => {
try {
source.stop();
} catch {}
void audioCtx.close().catch(() => {});
}, { once: true });
return resultStream;
};
@@ -128,10 +161,32 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
osc.connect(dest);
osc.start();
if (audioCtx.state === 'suspended') {
try {
await audioCtx.resume();
} catch {}
}
const audioTrack = dest.stream.getAudioTracks()[0];
// Combine video + audio into one stream
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
(resultStream as any).__isScreenShare = true;

View File

@@ -29,11 +29,12 @@ export class RegisterPage {
try {
await expect(this.usernameInput).toBeVisible({ timeout: 10_000 });
} catch {
// Angular router may redirect to /login on first load; click through.
const registerLink = this.page.getByRole('link', { name: 'Register' })
.or(this.page.getByText('Register'));
// Angular router may redirect to /login on first load; use the
// visible login-form action instead of broad text matching.
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 });
}

View File

@@ -36,6 +36,7 @@ import {
} from '../update/desktop-updater';
import { consumePendingDeepLink } from '../app/deep-links';
import { synchronizeAutoStartSetting } from '../app/auto-start';
import { getIdleState } from '../idle/idle-monitor';
import {
getMainWindow,
getWindowIconPath,
@@ -557,16 +558,22 @@ export function setupSystemHandlers(): void {
}
switch (command) {
case 'cut': webContents.cut(); break;
case 'copy': webContents.copy(); break;
case 'paste': webContents.paste(); break;
case 'selectAll': webContents.selectAll(); break;
case 'cut':
webContents.cut();
break;
case 'copy':
webContents.copy();
break;
case 'paste':
webContents.paste();
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();
});
}

View File

@@ -123,7 +123,7 @@ module.exports = tseslint.config(
'complexity': ['warn',{ max:20 }],
'curly': 'off',
'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 }],
'new-parens': '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(); }
'max-statements-per-line': ['error', { max: 1 }],
// 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.)
'padding-line-between-statements': [
'error',

View File

@@ -59,6 +59,9 @@ function buildServer(app: ReturnType<typeof createApp>, serverProtocol: ServerHt
return createHttpServer(app);
}
let listeningServer: ReturnType<typeof buildServer> | null = null;
let staleJoinRequestInterval: ReturnType<typeof setInterval> | null = null;
async function bootstrap(): Promise<void> {
const variablesConfig = ensureVariablesConfig();
const serverProtocol = getServerProtocol();
@@ -86,10 +89,12 @@ async function bootstrap(): Promise<void> {
const app = createApp();
const server = buildServer(app, serverProtocol);
listeningServer = server;
setupWebSocket(server);
// Periodically clean up stale join requests (older than 24 h)
setInterval(() => {
staleJoinRequestInterval = setInterval(() => {
deleteStaleJoinRequests(24 * 60 * 60 * 1000)
.catch(err => console.error('Failed to clean up stale join requests:', err));
}, 60 * 1000);
@@ -127,8 +132,25 @@ async function gracefulShutdown(signal: string): Promise<void> {
shuttingDown = true;
if (staleJoinRequestInterval) {
clearInterval(staleJoinRequestInterval);
staleJoinRequestInterval = null;
}
console.log(`\n[Shutdown] ${signal} received - closing database…`);
if (listeningServer?.listening) {
try {
await new Promise<void>((resolve) => {
listeningServer?.close(() => resolve());
});
} catch (err) {
console.error('[Shutdown] Error closing server:', err);
}
}
listeningServer = null;
try {
await destroyDatabase();
} catch (err) {

View File

@@ -1,4 +1,3 @@
/* eslint-disable complexity */
import { Router } from 'express';
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 {
for (const value of values) {
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 {
if (!item || typeof item !== 'object')
return null;
const gifItem = item as KlipyGifItem;
const resolvedMedia = resolveGifMedia(gifItem.file);
const slug = resolveGifSlug(gifItem);
if (gifItem.type === 'ad')
return null;
const lowVariant = pickFirst(gifItem.file?.md, gifItem.file?.sm, gifItem.file?.xs, gifItem.file?.hd);
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)
if (!slug || !resolvedMedia)
return null;
const { previewMeta, sourceMeta } = resolvedMedia;
return {
id: slug,
slug,
title: sanitizeString(gifItem.title),
url: selectedMeta.url,
previewUrl: lowMeta?.url ?? selectedMeta.url,
width: selectedMeta.width ?? lowMeta?.width ?? 0,
height: selectedMeta.height ?? lowMeta?.height ?? 0
url: sourceMeta.url,
previewUrl: previewMeta?.url ?? sourceMeta.url,
width: sourceMeta.width ?? previewMeta?.width ?? 0,
height: sourceMeta.height ?? previewMeta?.height ?? 0
};
}

View File

@@ -44,6 +44,19 @@ function createConnectedUser(
return user;
}
function getRequiredConnectedUser(connectionId: string): ConnectedUser {
const connectedUser = connectedUsers.get(connectionId);
if (!connectedUser)
throw new Error(`Expected connected user for ${connectionId}`);
return connectedUser;
}
function getSentMessagesStore(user: ConnectedUser): { sentMessages: string[] } {
return user.ws as unknown as { sentMessages: string[] };
}
describe('server websocket handler - status_update', () => {
beforeEach(() => {
connectedUsers.clear();
@@ -68,27 +81,24 @@ describe('server websocket handler - status_update', () => {
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');
const messages = getSentMessagesStore(user2).sentMessages.map((messageText: string) => JSON.parse(messageText));
const statusMsg = messages.find((message: { type: string }) => message.type === 'status_update');
expect(statusMsg).toBeDefined();
expect(statusMsg.oderId).toBe('user-1');
expect(statusMsg.status).toBe('busy');
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');
getRequiredConnectedUser('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);
expect(getSentMessagesStore(user2).sentMessages.length).toBe(0);
});
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' });
// Clear sent messages
(user2.ws as unknown as { sentMessages: string[] }).sentMessages.length = 0;
getSentMessagesStore(user2).sentMessages.length = 0;
// Identify first (required for handler)
await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' });
// user-2 joins server → should receive server_users with user-1's status
(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' });
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');
const messages = getSentMessagesStore(user2).sentMessages.map((messageText: string) => JSON.parse(messageText));
const serverUsersMsg = messages.find((message: { type: string }) => message.type === 'server_users');
expect(serverUsersMsg).toBeDefined();
const user1InList = serverUsersMsg.users.find((u: { oderId: string }) => u.oderId === 'user-1');
const user1InList = serverUsersMsg?.users?.find((userEntry: { oderId: string }) => userEntry.oderId === 'user-1');
expect(user1InList?.status).toBe('away');
});
@@ -168,19 +177,18 @@ describe('server websocket handler - user_joined includes status', () => {
user2.serverIds.add('server-1');
// Set user-1's status to busy before joining
connectedUsers.get('conn-1')!.status = 'busy';
getRequiredConnectedUser('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;
getSentMessagesStore(user2).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');
const messages = getSentMessagesStore(user2).sentMessages.map((messageText: string) => JSON.parse(messageText));
const joinMsg = messages.find((message: { type: string }) => message.type === 'user_joined');
// user_joined may or may not appear depending on whether it's a new identity membership
// Since both are already in the server, it may not broadcast. Either way, verify no crash.

View File

@@ -34,13 +34,13 @@ import { ExternalLinkService } from './core/platform';
import { SettingsModalService } from './core/services/settings-modal.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 { TitleBarComponent } from './features/shell/title-bar.component';
import { ServersRailComponent } from './features/servers/servers-rail/servers-rail.component';
import { TitleBarComponent } from './features/shell/title-bar/title-bar.component';
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.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 { 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 { RoomsActions } from './store/rooms/rooms.actions';
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
@@ -161,7 +161,7 @@ export class App implements OnInit, OnDestroy {
return;
}
void import('./domains/theme/feature/settings/theme-settings.component')
void import('./domains/theme/feature/settings/theme-settings/theme-settings.component')
.then((module) => {
this.themeStudioFullscreenComponent.set(module.ThemeSettingsComponent);
});

View File

@@ -14,6 +14,15 @@ import { UserStatus } from '../../shared-kernel';
const BROWSER_IDLE_POLL_MS = 10_000;
const BROWSER_IDLE_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes
interface ElectronIdleApi {
onIdleStateChanged: (listener: (state: 'active' | 'idle') => void) => () => void;
getIdleState: () => Promise<'active' | 'idle'>;
}
type IdleAwareWindow = Window & {
electronAPI?: ElectronIdleApi;
};
/**
* Orchestrates user status based on idle detection (Electron powerMonitor
* or browser-fallback) and manual overrides (e.g. Do Not Disturb).
@@ -27,6 +36,8 @@ export class UserStatusService implements OnDestroy {
private zone = inject(NgZone);
private webrtc = inject(RealtimeSessionFacade);
private audio = inject(NotificationAudioService);
private readonly manualStatus = this.store.selectSignal(selectManualStatus);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private electronCleanup: (() => void) | null = null;
private browserPollTimer: ReturnType<typeof setInterval> | null = null;
@@ -41,7 +52,7 @@ export class UserStatusService implements OnDestroy {
this.started = true;
if ((window as any).electronAPI?.onIdleStateChanged) {
if (this.getElectronIdleApi()?.onIdleStateChanged) {
this.startElectronIdleDetection();
} else {
this.startBrowserIdleDetection();
@@ -77,10 +88,10 @@ export class UserStatusService implements OnDestroy {
}
private startElectronIdleDetection(): void {
const api = (window as { electronAPI?: {
onIdleStateChanged: (cb: (state: 'active' | 'idle') => void) => () => void;
getIdleState: () => Promise<'active' | 'idle'>;
}; }).electronAPI!;
const api = this.getElectronIdleApi();
if (!api)
return;
this.electronCleanup = api.onIdleStateChanged((idleState: 'active' | 'idle') => {
this.zone.run(() => {
@@ -138,13 +149,13 @@ export class UserStatusService implements OnDestroy {
}
private applyAutoStatusIfAllowed(): void {
const manualStatus = this.store.selectSignal(selectManualStatus)();
const manualStatus = this.manualStatus();
// Manual status overrides automatic
if (manualStatus)
return;
const currentUser = this.store.selectSignal(selectCurrentUser)();
const currentUser = this.currentUser();
if (currentUser?.status !== this.currentAutoStatus) {
this.store.dispatch(UsersActions.setManualStatus({ status: null }));
@@ -163,4 +174,8 @@ export class UserStatusService implements OnDestroy {
status
});
}
private getElectronIdleApi(): ElectronIdleApi | undefined {
return (window as IdleAwareWindow).electronAPI;
}
}

View File

@@ -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"
(click)="toggleProfileCard(avatarBtn)"
>
{{ user()!.displayName?.charAt(0)?.toUpperCase() || '?' }}
{{ 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()"

View File

@@ -12,7 +12,7 @@ export class LinkMetadataService {
private readonly serverDirectory = inject(ServerDirectoryFacade);
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> {

View File

@@ -5,7 +5,7 @@ import {
} from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideX } from '@ng-icons/lucide';
import { LinkMetadata } from '../../../../../../shared-kernel';
import { LinkMetadata } from '../../../../../../../shared-kernel';
@Component({
selector: 'app-chat-link-embed',

View File

@@ -43,8 +43,8 @@ import {
ProfileCardService,
UserAvatarComponent
} from '../../../../../../shared';
import { ChatMessageMarkdownComponent } from './chat-message-markdown.component';
import { ChatLinkEmbedComponent } from './chat-link-embed.component';
import { ChatMessageMarkdownComponent } from './chat-message-markdown/chat-message-markdown.component';
import { ChatLinkEmbedComponent } from './chat-link-embed/chat-link-embed.component';
import {
ChatMessageDeleteEvent,
ChatMessageEditEvent,
@@ -155,7 +155,7 @@ export class ChatMessageItemComponent {
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 found = users.find((userEntry) => userEntry.id === msg.senderId || userEntry.oderId === msg.senderId);
const user: User = found ?? {
id: msg.senderId,
oderId: msg.senderId,

View File

@@ -5,8 +5,8 @@ import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse';
import { unified } from 'unified';
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
import { ChatYoutubeEmbedComponent, isYoutubeUrl } from './chat-youtube-embed.component';
import { ChatImageProxyFallbackDirective } from '../../../../chat-image-proxy-fallback.directive';
import { ChatYoutubeEmbedComponent, isYoutubeUrl } from '../chat-youtube-embed/chat-youtube-embed.component';
const PRISM_LANGUAGE_ALIASES: Record<string, string> = {
cs: 'csharp',

View File

@@ -0,0 +1,11 @@
@if (videoId()) {
<div class="mt-2 w-[480px] max-w-full overflow-hidden rounded-md border border-border/60">
<iframe
[src]="embedUrl()"
class="aspect-video w-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
loading="lazy"
></iframe>
</div>
}

View File

@@ -1,6 +1,7 @@
import {
Component,
computed,
inject,
input
} from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
@@ -10,19 +11,7 @@ const YOUTUBE_URL_PATTERN = /(?:youtube\.com\/(?:watch\?.*v=|embed\/|shorts\/)|y
@Component({
selector: 'app-chat-youtube-embed',
standalone: true,
template: `
@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>
}
`
templateUrl: './chat-youtube-embed.component.html'
})
export class ChatYoutubeEmbedComponent {
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 {

View File

@@ -13,9 +13,9 @@ import {
lucideMessageSquareText,
lucideMoonStar
} from '@ng-icons/lucide';
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
import type { Room } from '../../../../shared-kernel';
import { NotificationsFacade } from '../../application/facades/notifications.facade';
import { selectSavedRooms } from '../../../../../store/rooms/rooms.selectors';
import type { Room } from '../../../../../shared-kernel';
import { NotificationsFacade } from '../../../application/facades/notifications.facade';
@Component({
selector: 'app-notifications-settings',

View File

@@ -1,3 +1,3 @@
export * from './application/facades/notifications.facade';
export * from './application/effects/notifications.effects';
export { NotificationsSettingsComponent } from './feature/settings/notifications-settings.component';
export { NotificationsSettingsComponent } from './feature/settings/notifications-settings/notifications-settings.component';

View File

@@ -14,7 +14,7 @@ import {
ThemeGridEditorItem,
ThemeGridRect,
ThemeLayoutContainerDefinition
} from '../../domain/models/theme.model';
} from '../../../domain/models/theme.model';
type DragMode = 'move' | 'resize';

View File

@@ -8,26 +8,26 @@ import {
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
import { SettingsModalService } from '../../../../../core/services/settings-modal.service';
import {
ThemeContainerKey,
ThemeElementStyleProperty,
ThemeRegistryEntry
} from '../../domain/models/theme.model';
} from '../../../domain/models/theme.model';
import {
THEME_ANIMATION_FIELDS as THEME_ANIMATION_FIELD_HINTS,
THEME_ELEMENT_STYLE_FIELDS,
createAnimationStarterDefinition,
getSuggestedFieldDefault
} from '../../domain/logic/theme-schema.logic';
import { ElementPickerService } from '../../application/services/element-picker.service';
import { LayoutSyncService } from '../../application/services/layout-sync.service';
import { ThemeLibraryService } from '../../application/services/theme-library.service';
import { ThemeRegistryService } from '../../application/services/theme-registry.service';
import { ThemeService } from '../../application/services/theme.service';
import { THEME_LLM_GUIDE } from '../../domain/constants/theme-llm-guide.constants';
import { ThemeGridEditorComponent } from './theme-grid-editor.component';
import { ThemeJsonCodeEditorComponent } from './theme-json-code-editor.component';
} from '../../../domain/logic/theme-schema.logic';
import { ElementPickerService } from '../../../application/services/element-picker.service';
import { LayoutSyncService } from '../../../application/services/layout-sync.service';
import { ThemeLibraryService } from '../../../application/services/theme-library.service';
import { ThemeRegistryService } from '../../../application/services/theme-registry.service';
import { ThemeService } from '../../../application/services/theme.service';
import { THEME_LLM_GUIDE } from '../../../domain/constants/theme-llm-guide.constants';
import { ThemeGridEditorComponent } from '../theme-grid-editor/theme-grid-editor.component';
import { ThemeJsonCodeEditorComponent } from '../theme-json-code-editor/theme-json-code-editor.component';
type JumpSection = 'elements' | 'layout' | 'animations';
type ThemeStudioWorkspace = 'editor' | 'inspector' | 'layout';

View File

@@ -5,8 +5,8 @@ import {
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ElementPickerService } from '../application/services/element-picker.service';
import { ThemeRegistryService } from '../application/services/theme-registry.service';
import { ElementPickerService } from '../../application/services/element-picker.service';
import { ThemeRegistryService } from '../../application/services/theme-registry.service';
@Component({
selector: 'app-theme-picker-overlay',

View File

@@ -10,4 +10,4 @@ export * from './domain/logic/theme-schema.logic';
export * from './domain/logic/theme-validation.logic';
export { ThemeNodeDirective } from './feature/theme-node.directive';
export { ThemePickerOverlayComponent } from './feature/theme-picker-overlay.component';
export { ThemePickerOverlayComponent } from './feature/theme-picker-overlay/theme-picker-overlay.component';

View File

@@ -52,16 +52,13 @@ export class VoiceWorkspaceService {
readonly hasCustomMiniWindowPosition = computed(() => this._hasCustomMiniWindowPosition());
constructor() {
effect(
() => {
if (this.voiceSession.voiceSession()) {
return;
}
effect(() => {
if (this.voiceSession.voiceSession()) {
return;
}
this.reset();
},
{ allowSignalWrites: true }
);
this.reset();
});
}
open(

View File

@@ -17,9 +17,11 @@
/>
@if (voiceSession()?.serverIcon) {
<img
[src]="voiceSession()?.serverIcon"
[ngSrc]="voiceSession()?.serverIcon || ''"
class="w-5 h-5 rounded object-cover"
alt=""
width="20"
height="20"
/>
} @else {
<div class="flex h-5 w-5 items-center justify-center rounded-sm bg-muted text-[10px] font-semibold">

View File

@@ -6,7 +6,7 @@ import {
computed,
OnInit
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { CommonModule, NgOptimizedImage } from '@angular/common';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
@@ -34,6 +34,7 @@ import { ThemeNodeDirective } from '../../../../domains/theme';
standalone: true,
imports: [
CommonModule,
NgOptimizedImage,
NgIcon,
DebugConsoleComponent,
ScreenShareQualityDialogComponent,

View File

@@ -182,15 +182,7 @@
[name]="u.displayName"
[avatarUrl]="u.avatarUrl"
size="xs"
[ringClass]="
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'
"
[ringClass]="getVoiceUserRingClass(u)"
/>
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
<!-- 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>
<div
class="flex items-center gap-2 rounded-md bg-secondary/60 px-3 py-2 hover:bg-secondary/80 transition-colors cursor-pointer"
role="button"
tabindex="0"
(click)="openProfileCard($event, currentUser()!, true); $event.stopPropagation()"
(keydown.enter)="openProfileCard($event, currentUser()!, true); $event.stopPropagation()"
(keydown.space)="openProfileCard($event, currentUser()!, true); $event.preventDefault(); $event.stopPropagation()"
>
<app-user-avatar
[name]="currentUser()?.displayName || '?'"
@@ -293,8 +289,12 @@
@for (user of onlineRoomUsers(); track user.id) {
<div
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)"
(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
[name]="user.displayName"
@@ -352,7 +352,11 @@
@for (member of offlineRoomMembers(); track member.oderId || member.id) {
<div
class="flex items-center gap-2 rounded-md px-3 py-2 opacity-80 hover:bg-secondary/30 transition-colors cursor-pointer"
role="button"
tabindex="0"
(click)="openProfileCardForMember($event, member); $event.stopPropagation()"
(keydown.enter)="openProfileCardForMember($event, member); $event.stopPropagation()"
(keydown.space)="openProfileCardForMember($event, member); $event.preventDefault(); $event.stopPropagation()"
>
<app-user-avatar
[name]="member.displayName"

View File

@@ -103,7 +103,7 @@ export class RoomsSidePanelComponent {
private voiceWorkspace = inject(VoiceWorkspaceService);
private voicePlayback = inject(VoicePlaybackService);
private profileCard = inject(ProfileCardService);
voiceActivity = inject(VoiceActivityService);
private readonly voiceActivity = inject(VoiceActivityService);
readonly panelMode = input<PanelMode>('channels');
readonly showVoiceControls = input(true);
@@ -186,14 +186,14 @@ export class RoomsSidePanelComponent {
draggedVoiceUserId = 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();
const el = event.currentTarget as HTMLElement;
this.profileCard.open(el, user, { placement: 'left', editable });
}
openProfileCardForMember(event: MouseEvent, member: RoomMember): void {
openProfileCardForMember(event: Event, member: RoomMember): void {
const user: User = {
id: member.id,
oderId: member.oderId || member.id,
@@ -886,6 +886,22 @@ export class RoomsSidePanelComponent {
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 {
return this.isUserSharing(userId) ? 'lucideMonitor' : 'lucideVideo';
}
@@ -981,6 +997,12 @@ export class RoomsSidePanelComponent {
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 {
const current = this.currentUser();

View File

@@ -22,9 +22,9 @@ import {
lucideVolumeX
} from '@ng-icons/lucide';
import { UserAvatarComponent } from '../../../shared';
import { VoiceWorkspacePlaybackService } from './voice-workspace-playback.service';
import { VoiceWorkspaceStreamItem } from './voice-workspace.models';
import { UserAvatarComponent } from '../../../../shared';
import { VoiceWorkspacePlaybackService } from '../voice-workspace-playback.service';
import { VoiceWorkspaceStreamItem } from '../voice-workspace.models';
@Component({
selector: 'app-voice-workspace-stream-tile',
@@ -86,23 +86,20 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
void video.play().catch(() => {});
});
effect(
() => {
this.workspacePlayback.settings();
effect(() => {
this.workspacePlayback.settings();
const item = this.item();
const item = this.item();
if (item.isLocal || !item.hasAudio) {
this.volume.set(0);
this.muted.set(false);
return;
}
if (item.isLocal || !item.hasAudio) {
this.volume.set(0);
this.muted.set(false);
return;
}
this.volume.set(this.workspacePlayback.getUserVolume(item.peerKey));
this.muted.set(this.workspacePlayback.isUserMuted(item.peerKey));
},
{ allowSignalWrites: true }
);
this.volume.set(this.workspacePlayback.getUserVolume(item.peerKey));
this.muted.set(this.workspacePlayback.isUserMuted(item.peerKey));
});
effect(() => {
const ref = this.videoRef();

View File

@@ -48,7 +48,7 @@ import { UsersActions } from '../../../store/users/users.actions';
import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors';
import { ScreenShareQualityDialogComponent, UserAvatarComponent } from '../../../shared';
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 { ThemeNodeDirective } from '../../../domains/theme';
@@ -456,38 +456,35 @@ export class VoiceWorkspaceComponent {
this.pruneObservedRemoteStreams(peerKeys);
});
effect(
() => {
const isExpanded = this.showExpanded();
const shouldAutoHideChrome = this.shouldAutoHideChrome();
effect(() => {
const isExpanded = this.showExpanded();
const shouldAutoHideChrome = this.shouldAutoHideChrome();
if (!isExpanded) {
this.clearHeaderHideTimeout();
this.showWorkspaceHeader.set(true);
this.wasExpanded = false;
this.wasAutoHideChrome = false;
return;
}
if (!shouldAutoHideChrome) {
this.clearHeaderHideTimeout();
this.showWorkspaceHeader.set(true);
this.wasExpanded = true;
this.wasAutoHideChrome = false;
return;
}
const shouldRevealChrome = !this.wasExpanded || !this.wasAutoHideChrome;
if (!isExpanded) {
this.clearHeaderHideTimeout();
this.showWorkspaceHeader.set(true);
this.wasExpanded = false;
this.wasAutoHideChrome = false;
return;
}
if (!shouldAutoHideChrome) {
this.clearHeaderHideTimeout();
this.showWorkspaceHeader.set(true);
this.wasExpanded = true;
this.wasAutoHideChrome = true;
this.wasAutoHideChrome = false;
return;
}
if (shouldRevealChrome) {
this.revealWorkspaceChrome();
}
},
{ allowSignalWrites: true }
);
const shouldRevealChrome = !this.wasExpanded || !this.wasAutoHideChrome;
this.wasExpanded = true;
this.wasAutoHideChrome = true;
if (shouldRevealChrome) {
this.revealWorkspaceChrome();
}
});
}
onWorkspacePointerMove(): void {

View File

@@ -26,21 +26,21 @@ import {
tap
} from 'rxjs';
import { Room, User } from '../../shared-kernel';
import { UserBarComponent } from '../../domains/authentication/feature/user-bar/user-bar.component';
import { VoiceSessionFacade } from '../../domains/voice-session';
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
import { selectCurrentUser, selectOnlineUsers } from '../../store/users/users.selectors';
import { RoomsActions } from '../../store/rooms/rooms.actions';
import { DatabaseService } from '../../infrastructure/persistence';
import { NotificationsFacade } from '../../domains/notifications';
import { type ServerInfo, ServerDirectoryFacade } from '../../domains/server-directory';
import { hasRoomBanForUser } from '../../domains/access-control';
import { Room, User } from '../../../shared-kernel';
import { UserBarComponent } from '../../../domains/authentication/feature/user-bar/user-bar.component';
import { VoiceSessionFacade } from '../../../domains/voice-session';
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors';
import { RoomsActions } from '../../../store/rooms/rooms.actions';
import { DatabaseService } from '../../../infrastructure/persistence';
import { NotificationsFacade } from '../../../domains/notifications';
import { type ServerInfo, ServerDirectoryFacade } from '../../../domains/server-directory';
import { hasRoomBanForUser } from '../../../domains/access-control';
import {
ConfirmDialogComponent,
ContextMenuComponent,
LeaveServerDialogComponent
} from '../../shared';
} from '../../../shared';
@Component({
selector: 'app-servers-rail',
@@ -81,8 +81,8 @@ export class ServersRailComponent {
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'))
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/search'))
),
{ initialValue: this.router.url.startsWith('/search') }
);

View File

@@ -25,6 +25,7 @@
<div class="flex items-center gap-1">
@if (canKickMembers(member)) {
<button
type="button"
(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"
title="Kick"
@@ -37,6 +38,7 @@
}
@if (canBanMembers(member)) {
<button
type="button"
(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"
title="Ban"

View File

@@ -32,7 +32,7 @@ import { RealtimeSessionFacade } from '../../../core/realtime';
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../store/users/users.selectors';
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 { GeneralSettingsComponent } from './general-settings/general-settings.component';

View File

@@ -9,7 +9,8 @@ export interface ThirdPartyLicense {
}
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([
'MIT License',
'',

View File

@@ -5,9 +5,9 @@ import {
inject,
signal
} from '@angular/core';
import { ElectronBridgeService } from '../../core/platform/electron/electron-bridge.service';
import { ContextMenuComponent } from '../../shared';
import type { ContextMenuParams } from '../../core/platform/electron/electron-api.models';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
import { ContextMenuComponent } from '../../../shared';
import type { ContextMenuParams } from '../../../core/platform/electron/electron-api.models';
@Component({
selector: 'app-native-context-menu',

View File

@@ -26,18 +26,18 @@ import {
selectVoiceChannels,
selectIsSignalServerReconnecting,
selectSignalServerCompatibilityError
} from '../../store/rooms/rooms.selectors';
import { RoomsActions } from '../../store/rooms/rooms.actions';
import { selectCurrentUser } from '../../store/users/users.selectors';
import { ElectronBridgeService } from '../../core/platform/electron/electron-bridge.service';
import { RealtimeSessionFacade } from '../../core/realtime';
import { ServerDirectoryFacade } from '../../domains/server-directory';
import { PlatformService } from '../../core/platform';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
import { LeaveServerDialogComponent } from '../../shared';
import { Room } from '../../shared-kernel';
import { VoiceWorkspaceService } from '../../domains/voice-session';
import { ThemeNodeDirective } from '../../domains/theme';
} from '../../../store/rooms/rooms.selectors';
import { RoomsActions } from '../../../store/rooms/rooms.actions';
import { selectCurrentUser } from '../../../store/users/users.selectors';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { ServerDirectoryFacade } from '../../../domains/server-directory';
import { PlatformService } from '../../../core/platform';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
import { LeaveServerDialogComponent } from '../../../shared';
import { Room } from '../../../shared-kernel';
import { VoiceWorkspaceService } from '../../../domains/voice-session';
import { ThemeNodeDirective } from '../../../domains/theme';
@Component({
selector: 'app-title-bar',

View File

@@ -1,4 +1,3 @@
/* eslint-disable complexity */
import {
SIGNALING_TYPE_ANSWER,
SIGNALING_TYPE_OFFER,
@@ -11,6 +10,7 @@ import {
PeerConnectionManagerContext,
PeerConnectionManagerState
} from '../shared';
import type { PeerData } from '../../realtime.types';
/**
* Queue a negotiation task so SDP operations for a single peer never overlap.
@@ -95,6 +95,97 @@ function replaceUnusablePeer(
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(
context: PeerConnectionManagerContext,
fromUserId: string,
@@ -107,72 +198,18 @@ export async function doHandleOffer(
replaceUnusablePeer(context, fromUserId, 'incoming offer');
let peerData = state.activePeerConnections.get(fromUserId);
if (!peerData) {
peerData = handlers.createPeerConnection(fromUserId, false);
}
const peerData = getOrCreatePeerForOffer(state, fromUserId, handlers);
try {
const signalingState = peerData.connection.signalingState;
const hasCollision =
signalingState === 'have-local-offer' || signalingState === 'have-local-pranswer';
const shouldApplyOffer = await resolveOfferCollision(peerData, callbacks, logger, fromUserId);
if (hasCollision) {
const localOderId = callbacks.getIdentifyCredentials()?.oderId ?? null;
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);
}
if (!shouldApplyOffer)
return;
await peerData.connection.setRemoteDescription(new RTCSessionDescription(sdp));
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;
} 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 = [];
syncPeerSendersFromTransceivers(peerData);
await attachAnswererLocalTracks(peerData, callbacks.getLocalMediaStream(), logger, fromUserId);
await applyPendingIceCandidates(peerData);
const answer = await peerData.connection.createAnswer();

View File

@@ -18,9 +18,7 @@ import {
})
/* eslint-disable @typescript-eslint/member-ordering */
export class ContextMenuComponent implements OnInit, AfterViewInit {
// eslint-disable-next-line id-length, id-denylist
x = input.required<number>();
// eslint-disable-next-line id-length, id-denylist
y = input.required<number>();
width = input<string>('w-48');
widthPx = input<number | null>(null);

View File

@@ -31,7 +31,7 @@ import { type DebugLogEntry, type DebugLogLevel } from '../../../../core/service
export class DebugConsoleEntryListComponent {
readonly entries = input.required<DebugLogEntry[]>();
readonly autoScroll = input.required<boolean>();
readonly entryExpanded = output<void>();
readonly entryExpanded = output();
readonly expandedEntryIds = signal<number[]>([]);
private readonly viewportRef = viewChild<ElementRef<HTMLDivElement>>('viewport');

View File

@@ -1,4 +1,4 @@
/* eslint-disable id-denylist, id-length, padding-line-between-statements */
/* eslint-disable padding-line-between-statements */
import {
Component,
ElementRef,

View File

@@ -46,6 +46,8 @@ type DebugConsoleLauncherVariant = 'floating' | 'inline' | 'compact';
}
})
export class DebugConsoleComponent {
private static readonly VISIBLE_ENTRY_LIMIT = 500;
readonly debugging = inject(DebuggingService);
readonly resizeService = inject(DebugConsoleResizeService);
readonly exportService = inject(DebugConsoleExportService);
@@ -78,7 +80,6 @@ export class DebugConsoleComponent {
readonly sourceOptions = computed(() => {
return Array.from(new Set(this.entries().map((entry) => entry.source))).sort();
});
private static readonly VISIBLE_ENTRY_LIMIT = 500;
readonly filteredEntries = computed(() => {
const searchTerm = this.searchTerm().trim()
@@ -245,7 +246,7 @@ export class DebugConsoleComponent {
}
toggleShowAllEntries(): void {
this.showAllEntries.update((v) => !v);
this.showAllEntries.update((isVisible) => !isVisible);
}
startTopResize(event: MouseEvent): void {

View File

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

View File

@@ -19,80 +19,12 @@ import { User, UserStatus } from '../../../shared-kernel';
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>
`
templateUrl: './profile-card.component.html'
})
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 user = signal<User>({ id: '', oderId: '', username: '', displayName: '', status: 'offline', role: 'member', joinedAt: 0 });
readonly editable = signal(false);
readonly showStatusMenu = signal(false);
readonly statusOptions: { value: UserStatus | null; label: string; color: string }[] = [
{ value: null, label: 'Online', color: 'bg-green-500' },
@@ -101,6 +33,8 @@ export class ProfileCardComponent {
{ value: 'offline', label: 'Invisible', color: 'bg-gray-500' }
];
private readonly userStatus = inject(UserStatusService);
currentStatusColor(): string {
switch (this.user().status) {
case 'online': return 'bg-green-500';
@@ -124,7 +58,7 @@ export class ProfileCardComponent {
}
toggleStatusMenu(): void {
this.showStatusMenu.update((v) => !v);
this.showStatusMenu.update((isOpen) => !isOpen);
}
setStatus(status: UserStatus | null): void {

View File

@@ -21,9 +21,7 @@ import { ContextMenuComponent } from '../context-menu/context-menu.component';
})
/* eslint-disable @typescript-eslint/member-ordering */
export class UserVolumeMenuComponent implements OnInit {
// eslint-disable-next-line id-length, id-denylist
x = input.required<number>();
// eslint-disable-next-line id-length, id-denylist
y = input.required<number>();
peerId = input.required<string>();
displayName = input.required<string>();