fix: Fix multiple bugs with new authentication flow
This commit is contained in:
@@ -58,6 +58,7 @@ import { RoomsActions } from './store/rooms/rooms.actions';
|
||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||
import { ROOM_URL_PATTERN } from './core/constants';
|
||||
import { clearStoredCurrentUserId, getStoredCurrentUserId } from './core/storage/current-user-storage';
|
||||
import { buildLoginReturnQueryParams } from './domains/authentication/domain/logic/auth-navigation.rules';
|
||||
import { runWhenIdle } from './shared/rxjs';
|
||||
import {
|
||||
ThemeNodeDirective,
|
||||
@@ -319,9 +320,7 @@ export class App implements OnInit, OnDestroy {
|
||||
this.router.navigate(['/dashboard'], { replaceUrl: true }).catch(() => {});
|
||||
} else {
|
||||
this.router.navigate(['/login'], {
|
||||
queryParams: {
|
||||
returnUrl: currentUrl
|
||||
}
|
||||
queryParams: buildLoginReturnQueryParams(currentUrl)
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import { ClientInstanceService } from './client-instance.service';
|
||||
|
||||
const STORAGE_KEY = 'metoyou.clientInstanceId';
|
||||
|
||||
describe('ClientInstanceService', () => {
|
||||
const storage = new Map<string, string>();
|
||||
|
||||
beforeEach(() => {
|
||||
storage.clear();
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { storage.set(key, value); },
|
||||
removeItem: (key: string) => { storage.delete(key); }
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('creates and persists a stable client instance id', () => {
|
||||
const service = new ClientInstanceService();
|
||||
const first = service.getClientInstanceId();
|
||||
const second = new ClientInstanceService().getClientInstanceId();
|
||||
|
||||
expect(first).toMatch(/^[0-9a-f-]{36}$/i);
|
||||
expect(second).toBe(first);
|
||||
expect(storage.get(STORAGE_KEY)).toBe(first);
|
||||
});
|
||||
|
||||
it('reuses a stored client instance id', () => {
|
||||
storage.set(STORAGE_KEY, 'device-123');
|
||||
|
||||
expect(new ClientInstanceService().getClientInstanceId()).toBe('device-123');
|
||||
});
|
||||
});
|
||||
38
toju-app/src/app/core/platform/client-instance.service.ts
Normal file
38
toju-app/src/app/core/platform/client-instance.service.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
const STORAGE_KEY = 'metoyou.clientInstanceId';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ClientInstanceService {
|
||||
private cachedId: string | null = null;
|
||||
|
||||
getClientInstanceId(): string {
|
||||
if (this.cachedId) {
|
||||
return this.cachedId;
|
||||
}
|
||||
|
||||
const stored = this.readStoredId();
|
||||
|
||||
if (stored) {
|
||||
this.cachedId = stored;
|
||||
return stored;
|
||||
}
|
||||
|
||||
const created = crypto.randomUUID();
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, created);
|
||||
this.cachedId = created;
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
private readStoredId(): string | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)?.trim();
|
||||
|
||||
return raw || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,6 +244,13 @@ export interface ElectronAppMetricsSnapshot {
|
||||
processes: ElectronAppMetricsProcess[];
|
||||
}
|
||||
|
||||
export interface ElectronPerfDiagEntry {
|
||||
collectedAt: number;
|
||||
source: 'main' | 'renderer';
|
||||
type: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ElectronApi {
|
||||
linuxDisplayServer: string;
|
||||
minimizeWindow: () => void;
|
||||
@@ -263,6 +270,8 @@ export interface ElectronApi {
|
||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||
getAppMetrics: () => Promise<ElectronAppMetricsSnapshot>;
|
||||
isPerfDiagEnabled?: () => Promise<boolean>;
|
||||
reportPerfDiagSample?: (entry: ElectronPerfDiagEntry) => Promise<boolean>;
|
||||
getAppDataPath: () => Promise<string>;
|
||||
openCurrentDataFolder: () => Promise<boolean>;
|
||||
exportUserData: () => Promise<ExportUserDataResult>;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './platform.service';
|
||||
export * from './external-link.service';
|
||||
export * from './viewport.service';
|
||||
export * from './client-instance.service';
|
||||
|
||||
@@ -15,8 +15,8 @@ authentication/
|
||||
│ └── authentication.model.ts LoginResponse interface
|
||||
│
|
||||
├── feature/
|
||||
│ ├── login/ Login form component
|
||||
│ ├── register/ Registration form component
|
||||
│ ├── login/ Login form (`<form ngSubmit>`; autofocus + select-on-focus via shared directives)
|
||||
│ ├── register/ Registration form (same form-field UX as login)
|
||||
│ └── user-bar/ Displays current user or login/register links
|
||||
│
|
||||
└── index.ts Barrel exports
|
||||
|
||||
@@ -6,7 +6,54 @@ import {
|
||||
} from 'vitest';
|
||||
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { waitForAuthenticationOutcome } from './auth-navigation.rules';
|
||||
import {
|
||||
buildLoginReturnQueryParams,
|
||||
resolveSafeReturnUrl,
|
||||
waitForAuthenticationOutcome
|
||||
} from './auth-navigation.rules';
|
||||
|
||||
describe('resolveSafeReturnUrl', () => {
|
||||
it('returns the requested in-app path unchanged', () => {
|
||||
expect(resolveSafeReturnUrl('/servers')).toBe('/servers');
|
||||
expect(resolveSafeReturnUrl('/room/abc')).toBe('/room/abc');
|
||||
});
|
||||
|
||||
it('unwraps nested login returnUrl chains to the original destination', () => {
|
||||
const nested = '/login?returnUrl=%2Flogin%3FreturnUrl%3D%252Fservers';
|
||||
|
||||
expect(resolveSafeReturnUrl(nested)).toBe('/servers');
|
||||
expect(resolveSafeReturnUrl(`/login?returnUrl=${encodeURIComponent(nested)}`)).toBe('/servers');
|
||||
});
|
||||
|
||||
it('falls back to dashboard for auth-only return targets', () => {
|
||||
expect(resolveSafeReturnUrl('/login')).toBe('/dashboard');
|
||||
expect(resolveSafeReturnUrl('/register')).toBe('/dashboard');
|
||||
expect(resolveSafeReturnUrl(null)).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('rejects open redirects and protocol-relative paths', () => {
|
||||
expect(resolveSafeReturnUrl('//evil.example/phish')).toBe('/dashboard');
|
||||
expect(resolveSafeReturnUrl('https://evil.example/phish')).toBe('/dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildLoginReturnQueryParams', () => {
|
||||
it('preserves a safe destination when redirecting from protected routes', () => {
|
||||
expect(buildLoginReturnQueryParams('/servers')).toEqual({ returnUrl: '/servers' });
|
||||
});
|
||||
|
||||
it('does not nest login returnUrl values', () => {
|
||||
expect(buildLoginReturnQueryParams('/login?returnUrl=%2Fservers')).toEqual({ returnUrl: '/servers' });
|
||||
expect(buildLoginReturnQueryParams('/login?returnUrl=%2Flogin%3FreturnUrl%3D%252Fservers')).toEqual({
|
||||
returnUrl: '/servers'
|
||||
});
|
||||
});
|
||||
|
||||
it('omits returnUrl when there is no meaningful destination', () => {
|
||||
expect(buildLoginReturnQueryParams('/login')).toEqual({});
|
||||
expect(buildLoginReturnQueryParams('/register')).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitForAuthenticationOutcome', () => {
|
||||
it('resolves when authentication storage preparation succeeds', async () => {
|
||||
|
||||
@@ -8,10 +8,88 @@ import {
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import type { User } from '../../../../shared-kernel';
|
||||
|
||||
export const DEFAULT_POST_AUTH_URL = '/dashboard';
|
||||
|
||||
const AUTH_ROUTE_PATHS = new Set(['/login', '/register']);
|
||||
const MAX_RETURN_URL_DEPTH = 10;
|
||||
|
||||
export type AuthenticationOutcome =
|
||||
| { kind: 'success'; user: User }
|
||||
| { kind: 'failure'; error: string };
|
||||
|
||||
export function isAuthRoutePath(path: string): boolean {
|
||||
return AUTH_ROUTE_PATHS.has(path);
|
||||
}
|
||||
|
||||
export function getRoutePathFromUrl(url: string): string {
|
||||
if (!url) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
const [path] = url.split(/[?#]/, 1);
|
||||
|
||||
return path || '/';
|
||||
}
|
||||
|
||||
export function extractReturnUrlParam(url: string): string | null {
|
||||
const queryStart = url.indexOf('?');
|
||||
|
||||
if (queryStart === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hashStart = url.indexOf('#', queryStart + 1);
|
||||
const query = hashStart === -1
|
||||
? url.slice(queryStart + 1)
|
||||
: url.slice(queryStart + 1, hashStart);
|
||||
|
||||
return new URLSearchParams(query).get('returnUrl');
|
||||
}
|
||||
|
||||
export function resolveSafeReturnUrl(
|
||||
url: string | null | undefined,
|
||||
fallback = DEFAULT_POST_AUTH_URL
|
||||
): string {
|
||||
let candidate = url?.trim() ?? '';
|
||||
let depth = 0;
|
||||
|
||||
while (candidate && depth < MAX_RETURN_URL_DEPTH) {
|
||||
if (!candidate.startsWith('/') || candidate.startsWith('//')) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const path = getRoutePathFromUrl(candidate);
|
||||
|
||||
if (!isAuthRoutePath(path)) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
const nestedReturnUrl = extractReturnUrlParam(candidate)?.trim();
|
||||
|
||||
if (!nestedReturnUrl) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
candidate = nestedReturnUrl;
|
||||
depth += 1;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function buildLoginReturnQueryParams(
|
||||
currentUrl: string,
|
||||
fallback = DEFAULT_POST_AUTH_URL
|
||||
): { returnUrl?: string } {
|
||||
const safeReturnUrl = resolveSafeReturnUrl(currentUrl, fallback);
|
||||
|
||||
if (safeReturnUrl === fallback) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return { returnUrl: safeReturnUrl };
|
||||
}
|
||||
|
||||
export function waitForAuthenticationOutcome(
|
||||
actions$: Observable<{ type: string; user?: User; error?: string }>
|
||||
): Observable<AuthenticationOutcome> {
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
<h1 class="text-lg font-semibold text-foreground">{{ 'auth.login.title' | translate }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<form
|
||||
class="space-y-3"
|
||||
(ngSubmit)="submit()"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
for="login-username"
|
||||
@@ -19,6 +22,9 @@
|
||||
[(ngModel)]="username"
|
||||
type="text"
|
||||
id="login-username"
|
||||
name="username"
|
||||
appAutoFocus
|
||||
appSelectOnFocus
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
/>
|
||||
</div>
|
||||
@@ -32,6 +38,7 @@
|
||||
[(ngModel)]="password"
|
||||
type="password"
|
||||
id="login-password"
|
||||
name="password"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
/>
|
||||
</div>
|
||||
@@ -44,6 +51,7 @@
|
||||
<select
|
||||
[(ngModel)]="serverId"
|
||||
id="login-server"
|
||||
name="serverId"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
>
|
||||
@for (s of servers(); track s.id) {
|
||||
@@ -55,22 +63,21 @@
|
||||
<p class="text-xs text-destructive">{{ error() }}</p>
|
||||
}
|
||||
<button
|
||||
(click)="submit()"
|
||||
type="button"
|
||||
type="submit"
|
||||
class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{{ 'auth.login.submit' | translate }}
|
||||
</button>
|
||||
<div class="text-xs text-muted-foreground text-center mt-2">
|
||||
{{ 'auth.login.noAccount' | translate }}
|
||||
<button
|
||||
type="button"
|
||||
(click)="goRegister()"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{{ 'auth.login.registerLink' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="text-xs text-muted-foreground text-center mt-2">
|
||||
{{ 'auth.login.noAccount' | translate }}
|
||||
<button
|
||||
type="button"
|
||||
(click)="goRegister()"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{{ 'auth.login.registerLink' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
OnInit,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
@@ -11,14 +12,24 @@ import { Actions } from '@ngrx/effects';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideLogIn } from '@ng-icons/lucide';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import {
|
||||
filter,
|
||||
firstValueFrom,
|
||||
take
|
||||
} from 'rxjs';
|
||||
|
||||
import { AuthenticationService } from '../../application/services/authentication.service';
|
||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||
import { waitForAuthenticationOutcome } from '../../domain/logic/auth-navigation.rules';
|
||||
import {
|
||||
buildLoginReturnQueryParams,
|
||||
resolveSafeReturnUrl,
|
||||
waitForAuthenticationOutcome
|
||||
} from '../../domain/logic/auth-navigation.rules';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
@@ -27,6 +38,8 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideLogIn })],
|
||||
@@ -35,7 +48,7 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
/**
|
||||
* Login form allowing existing users to authenticate against a selected server.
|
||||
*/
|
||||
export class LoginComponent {
|
||||
export class LoginComponent implements OnInit {
|
||||
serversSvc = inject(ServerDirectoryFacade);
|
||||
|
||||
servers = this.serversSvc.servers;
|
||||
@@ -54,6 +67,18 @@ export class LoginComponent {
|
||||
/** TrackBy function for server list rendering. */
|
||||
trackById(_index: number, item: { id: string }) { return item.id; }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.store.select(selectCurrentUser).pipe(
|
||||
filter(Boolean),
|
||||
take(1)
|
||||
)
|
||||
.subscribe(() => {
|
||||
const returnUrl = resolveSafeReturnUrl(this.route.snapshot.queryParamMap.get('returnUrl'));
|
||||
|
||||
void this.router.navigateByUrl(returnUrl);
|
||||
});
|
||||
}
|
||||
|
||||
/** Validate and submit the login form, then navigate to search on success. */
|
||||
submit() {
|
||||
this.error.set(null);
|
||||
@@ -88,14 +113,9 @@ export class LoginComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||
const returnUrl = resolveSafeReturnUrl(this.route.snapshot.queryParamMap.get('returnUrl'));
|
||||
|
||||
if (returnUrl?.startsWith('/')) {
|
||||
await this.router.navigateByUrl(returnUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.router.navigate(['/dashboard']);
|
||||
await this.router.navigateByUrl(returnUrl);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err?.error?.error || this.appI18n.instant('auth.login.failed'));
|
||||
@@ -105,10 +125,8 @@ export class LoginComponent {
|
||||
|
||||
/** Navigate to the registration page. */
|
||||
goRegister() {
|
||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||
|
||||
this.router.navigate(['/register'], {
|
||||
queryParams: returnUrl ? { returnUrl } : undefined
|
||||
queryParams: buildLoginReturnQueryParams(this.router.url)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
<h1 class="text-lg font-semibold text-foreground">{{ 'auth.register.title' | translate }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<form
|
||||
class="space-y-3"
|
||||
(ngSubmit)="submit()"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
for="register-username"
|
||||
@@ -19,6 +22,9 @@
|
||||
[(ngModel)]="username"
|
||||
type="text"
|
||||
id="register-username"
|
||||
name="username"
|
||||
appAutoFocus
|
||||
appSelectOnFocus
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
/>
|
||||
</div>
|
||||
@@ -32,6 +38,8 @@
|
||||
[(ngModel)]="displayName"
|
||||
type="text"
|
||||
id="register-display-name"
|
||||
name="displayName"
|
||||
appSelectOnFocus
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
/>
|
||||
</div>
|
||||
@@ -45,6 +53,7 @@
|
||||
[(ngModel)]="password"
|
||||
type="password"
|
||||
id="register-password"
|
||||
name="password"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
/>
|
||||
</div>
|
||||
@@ -57,6 +66,7 @@
|
||||
<select
|
||||
[(ngModel)]="serverId"
|
||||
id="register-server"
|
||||
name="serverId"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
>
|
||||
@for (s of servers(); track s.id) {
|
||||
@@ -68,22 +78,21 @@
|
||||
<p class="text-xs text-destructive">{{ error() }}</p>
|
||||
}
|
||||
<button
|
||||
(click)="submit()"
|
||||
type="button"
|
||||
type="submit"
|
||||
class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{{ 'auth.register.submit' | translate }}
|
||||
</button>
|
||||
<div class="text-xs text-muted-foreground text-center mt-2">
|
||||
{{ 'auth.register.haveAccount' | translate }}
|
||||
<button
|
||||
type="button"
|
||||
(click)="goLogin()"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{{ 'auth.register.loginLink' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="text-xs text-muted-foreground text-center mt-2">
|
||||
{{ 'auth.register.haveAccount' | translate }}
|
||||
<button
|
||||
type="button"
|
||||
(click)="goLogin()"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{{ 'auth.register.loginLink' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,10 +15,15 @@ import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { AuthenticationService } from '../../application/services/authentication.service';
|
||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||
import { waitForAuthenticationOutcome } from '../../domain/logic/auth-navigation.rules';
|
||||
import {
|
||||
buildLoginReturnQueryParams,
|
||||
resolveSafeReturnUrl,
|
||||
waitForAuthenticationOutcome
|
||||
} from '../../domain/logic/auth-navigation.rules';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
|
||||
|
||||
@Component({
|
||||
selector: 'app-register',
|
||||
@@ -27,6 +32,8 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideUserPlus })],
|
||||
@@ -90,14 +97,9 @@ export class RegisterComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||
const returnUrl = resolveSafeReturnUrl(this.route.snapshot.queryParamMap.get('returnUrl'));
|
||||
|
||||
if (returnUrl?.startsWith('/')) {
|
||||
await this.router.navigateByUrl(returnUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.router.navigate(['/dashboard']);
|
||||
await this.router.navigateByUrl(returnUrl);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err?.error?.error || this.appI18n.instant('auth.register.failed'));
|
||||
@@ -107,10 +109,8 @@ export class RegisterComponent {
|
||||
|
||||
/** Navigate to the login page. */
|
||||
goLogin() {
|
||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||
|
||||
this.router.navigate(['/login'], {
|
||||
queryParams: returnUrl ? { returnUrl } : undefined
|
||||
queryParams: buildLoginReturnQueryParams(this.router.url)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import {
|
||||
merge,
|
||||
interval,
|
||||
@@ -47,6 +48,7 @@ export class TypingIndicatorComponent {
|
||||
private readonly store = inject(Store);
|
||||
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
private lastRoomId: string | null = null;
|
||||
private lastConversationKey: string | null = null;
|
||||
|
||||
@@ -145,9 +147,22 @@ export class TypingIndicatorComponent {
|
||||
private recomputeDisplay(): void {
|
||||
const now = Date.now();
|
||||
const activeChannelId = this.activeChannelId() ?? 'general';
|
||||
const names = Array.from(this.typingMap.values())
|
||||
.filter((entry) => entry.expiresAt > now && entry.channelId === activeChannelId)
|
||||
.map((e) => e.name);
|
||||
const currentUserId = this.currentUser()?.id || this.currentUser()?.oderId;
|
||||
const names = Array.from(this.typingMap.entries())
|
||||
.filter(([typingKey, entry]) => {
|
||||
if (entry.expiresAt <= now || entry.channelId !== activeChannelId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!currentUserId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [, oderId] = typingKey.split(':');
|
||||
|
||||
return oderId !== currentUserId;
|
||||
})
|
||||
.map(([, entry]) => entry.name);
|
||||
|
||||
this.typingDisplay.set(names.slice(0, MAX_SHOWN));
|
||||
this.typingOthersCount.set(Math.max(0, names.length - MAX_SHOWN));
|
||||
|
||||
@@ -72,6 +72,8 @@
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
appAutoFocus
|
||||
appSelectOnFocus
|
||||
class="w-full rounded-md border border-border bg-background py-2 pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="{{ 'emoji.picker.searchPlaceholder' | translate }}"
|
||||
aria-label="{{ 'emoji.picker.searchAria' | translate }}"
|
||||
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
import { CustomEmoji, EmojiShortcutEntry } from '../../../../shared-kernel';
|
||||
import { CustomEmojiService } from '../../application/custom-emoji.service';
|
||||
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||
import {
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective
|
||||
} from '../../../../shared/directives';
|
||||
import {
|
||||
CUSTOM_EMOJI_ACCEPT_ATTRIBUTE,
|
||||
UNICODE_EMOJI_PICKER_ENTRIES,
|
||||
@@ -34,7 +38,13 @@ import {
|
||||
@Component({
|
||||
selector: 'app-custom-emoji-picker',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS],
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [provideIcons({ lucidePlus, lucideSearch, lucideSmile, lucideUpload, lucideX })],
|
||||
templateUrl: './custom-emoji-picker.component.html'
|
||||
})
|
||||
|
||||
@@ -21,7 +21,11 @@ import {
|
||||
VoiceConnectionFacade,
|
||||
VoicePlaybackService
|
||||
} from '../../../voice-connection';
|
||||
import { VoiceSessionFacade } from '../../../voice-session';
|
||||
import {
|
||||
VoiceSessionFacade,
|
||||
isVoiceOnAnotherClient
|
||||
} from '../../../voice-session';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { DirectMessageService, PeerDeliveryService } from '../../../direct-message';
|
||||
import type { DirectMessageConversation } from '../../../direct-message';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
@@ -43,6 +47,7 @@ export class DirectCallService {
|
||||
private readonly audio = inject(NotificationAudioService);
|
||||
private readonly voice = inject(VoiceConnectionFacade);
|
||||
private readonly voiceSession = inject(VoiceSessionFacade);
|
||||
private readonly realtime = inject(RealtimeSessionFacade);
|
||||
private readonly voiceActivity = inject(VoiceActivityService);
|
||||
private readonly playback = inject(VoicePlaybackService);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
@@ -325,6 +330,11 @@ export class DirectCallService {
|
||||
this.leaveCurrentVoiceTargetForCall(callId);
|
||||
this.audio.stop(AppSound.Call);
|
||||
|
||||
if (isVoiceOnAnotherClient(me.voiceState, this.realtime.getClientInstanceId())) {
|
||||
this.realtime.requestVoiceClientTakeover();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 300));
|
||||
}
|
||||
|
||||
const ok = await this.voice.ensureSignalingConnected();
|
||||
|
||||
if (!ok || !navigator.mediaDevices?.getUserMedia) {
|
||||
@@ -941,7 +951,8 @@ export class DirectCallService {
|
||||
isMuted: connected ? this.voice.isMuted() : false,
|
||||
isDeafened: connected ? this.voice.isDeafened() : false,
|
||||
roomId: connected ? session.callId : undefined,
|
||||
serverId: connected ? session.callId : undefined
|
||||
serverId: connected ? session.callId : undefined,
|
||||
clientInstanceId: connected ? this.realtime.getClientInstanceId() : undefined
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
appAutoFocus
|
||||
appSelectOnFocus
|
||||
[attr.aria-label]="'dm.find.searchAriaLabel' | translate"
|
||||
class="h-10 w-full rounded-lg border border-border bg-secondary py-2 pl-10 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
[placeholder]="'dm.find.searchPlaceholder' | translate"
|
||||
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
import {
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective
|
||||
} from '../../../../shared/directives';
|
||||
import { UserSearchListComponent } from '../user-search-list/user-search-list.component';
|
||||
import { selectAllUsers } from '../../../../store/users/users.selectors';
|
||||
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
||||
@@ -35,6 +39,8 @@ import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
||||
RouterLink,
|
||||
NgIcon,
|
||||
UserSearchListComponent,
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideArrowLeft, lucideSearch, lucideUsers })],
|
||||
|
||||
@@ -74,8 +74,11 @@
|
||||
<label class="relative flex min-w-0 flex-1">
|
||||
<input
|
||||
type="text"
|
||||
appAutoFocus
|
||||
appSelectOnFocus
|
||||
appSubmitOnEnter
|
||||
(submitOnEnter)="addSourceUrl()"
|
||||
[(ngModel)]="newSourceUrl"
|
||||
(keyup.enter)="addSourceUrl()"
|
||||
[placeholder]="'plugins.store.sourcePlaceholder' | translate"
|
||||
[attr.aria-label]="'plugins.store.sourceAria' | translate"
|
||||
class="min-h-9 w-full rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
@@ -244,6 +247,7 @@
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
appSelectOnFocus
|
||||
[ngModel]="searchTerm()"
|
||||
(ngModelChange)="searchTerm.set($event)"
|
||||
[placeholder]="'plugins.store.searchPlaceholder' | translate"
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
lucideX
|
||||
} from '@ng-icons/lucide';
|
||||
import { ExternalLinkService } from '../../../../core/platform';
|
||||
import { resolveSafeReturnUrl, getRoutePathFromUrl } from '../../../authentication/domain/logic/auth-navigation.rules';
|
||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||
import { ChatMessageMarkdownComponent } from '../../../chat';
|
||||
import { resolveLegacyRole, resolveRoomPermission } from '../../../access-control';
|
||||
@@ -40,6 +41,11 @@ import { selectCurrentRoom, selectSavedRooms } from '../../../../store/rooms/roo
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { ModalBackdropComponent } from '../../../../shared';
|
||||
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
import {
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective
|
||||
} from '../../../../shared/directives';
|
||||
import { PluginCapabilityService } from '../../application/services/plugin-capability.service';
|
||||
import { PluginStoreService } from '../../application/services/plugin-store.service';
|
||||
import type {
|
||||
@@ -64,6 +70,9 @@ interface ServerPluginInstallDialog {
|
||||
ChatMessageMarkdownComponent,
|
||||
NgIcon,
|
||||
ModalBackdropComponent,
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
@@ -598,13 +607,14 @@ export class PluginStoreComponent implements OnInit {
|
||||
}
|
||||
|
||||
private getReturnUrl(): string {
|
||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl');
|
||||
const returnUrl = resolveSafeReturnUrl(this.route.snapshot.queryParamMap.get('returnUrl'));
|
||||
const path = getRoutePathFromUrl(returnUrl);
|
||||
|
||||
if (returnUrl?.startsWith('/') && !returnUrl.startsWith('//') && !returnUrl.startsWith('/plugin-store')) {
|
||||
return returnUrl;
|
||||
if (path.startsWith('/plugin-store')) {
|
||||
return '/dashboard';
|
||||
}
|
||||
|
||||
return '/dashboard';
|
||||
return returnUrl;
|
||||
}
|
||||
|
||||
private canManageServerPlugins(room: Room, user: User): boolean {
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
{{ 'common.actions.cancel' | translate }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
type="submit"
|
||||
form="create-server-dialog-form"
|
||||
class="min-h-11 flex-1 rounded-lg bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
[disabled]="!canCreate"
|
||||
(click)="create()"
|
||||
>
|
||||
{{ 'servers.create.submit' | translate }}
|
||||
</button>
|
||||
@@ -60,10 +60,10 @@
|
||||
</button>
|
||||
<button
|
||||
id="create-server-dialog-submit"
|
||||
type="button"
|
||||
type="submit"
|
||||
form="create-server-dialog-form"
|
||||
class="flex-1 rounded-lg bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
[disabled]="!canCreate"
|
||||
(click)="create()"
|
||||
>
|
||||
{{ 'servers.create.submit' | translate }}
|
||||
</button>
|
||||
@@ -73,7 +73,11 @@
|
||||
|
||||
<!-- Shared form body for both presentations. -->
|
||||
<ng-template #form>
|
||||
<div class="space-y-5 p-4">
|
||||
<form
|
||||
id="create-server-dialog-form"
|
||||
class="space-y-5 p-4"
|
||||
(ngSubmit)="create()"
|
||||
>
|
||||
<div>
|
||||
<span class="mb-2 block text-sm font-medium text-foreground">{{ 'servers.create.pickCategory' | translate }}</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@@ -104,6 +108,9 @@
|
||||
<input
|
||||
id="create-server-dialog-name"
|
||||
type="text"
|
||||
name="serverName"
|
||||
appAutoFocus
|
||||
appSelectOnFocus
|
||||
[ngModel]="name()"
|
||||
(ngModelChange)="name.set($event)"
|
||||
[placeholder]="'servers.create.namePlaceholder' | translate"
|
||||
@@ -119,6 +126,7 @@
|
||||
>
|
||||
<textarea
|
||||
id="create-server-dialog-description"
|
||||
name="serverDescription"
|
||||
[ngModel]="description()"
|
||||
(ngModelChange)="description.set($event)"
|
||||
[placeholder]="'servers.create.descriptionPlaceholder' | translate"
|
||||
@@ -152,6 +160,8 @@
|
||||
<input
|
||||
id="create-server-dialog-topic"
|
||||
type="text"
|
||||
name="serverTopic"
|
||||
appSelectOnFocus
|
||||
[ngModel]="topic()"
|
||||
(ngModelChange)="topic.set($event)"
|
||||
[placeholder]="'servers.create.topicPlaceholder' | translate"
|
||||
@@ -167,6 +177,7 @@
|
||||
>
|
||||
<select
|
||||
id="create-server-dialog-signal-endpoint"
|
||||
name="sourceId"
|
||||
[ngModel]="sourceId()"
|
||||
(ngModelChange)="sourceId.set($event)"
|
||||
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
@@ -182,6 +193,7 @@
|
||||
<input
|
||||
id="create-server-dialog-private"
|
||||
type="checkbox"
|
||||
name="isPrivate"
|
||||
[ngModel]="isPrivate()"
|
||||
(ngModelChange)="isPrivate.set($event)"
|
||||
class="h-4 w-4 rounded border-border bg-secondary"
|
||||
@@ -202,6 +214,7 @@
|
||||
<input
|
||||
id="create-server-dialog-password"
|
||||
type="password"
|
||||
name="serverPassword"
|
||||
[ngModel]="password()"
|
||||
(ngModelChange)="password.set($event)"
|
||||
[placeholder]="'servers.create.passwordPlaceholder' | translate"
|
||||
@@ -212,5 +225,5 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</ng-template>
|
||||
|
||||
@@ -14,11 +14,16 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideChevronDown, lucideChevronUp } from '@ng-icons/lucide';
|
||||
|
||||
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
import { buildLoginReturnQueryParams } from '../../../authentication/domain/logic/auth-navigation.rules';
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
|
||||
import { ThemeNodeDirective } from '../../../theme';
|
||||
import { ViewportService } from '../../../../core/platform';
|
||||
import { BottomSheetComponent, ModalBackdropComponent } from '../../../../shared';
|
||||
import {
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective
|
||||
} from '../../../../shared/directives';
|
||||
import { CATEGORY_PRESETS, ServerCategoryPreset } from '../create-server/create-server.component';
|
||||
|
||||
/**
|
||||
@@ -37,6 +42,8 @@ import { CATEGORY_PRESETS, ServerCategoryPreset } from '../create-server/create-
|
||||
ThemeNodeDirective,
|
||||
BottomSheetComponent,
|
||||
ModalBackdropComponent,
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideChevronDown, lucideChevronUp })],
|
||||
@@ -104,7 +111,9 @@ export class CreateServerDialogComponent {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
|
||||
if (!currentUserId) {
|
||||
this.router.navigate(['/login']);
|
||||
this.router.navigate(['/login'], {
|
||||
queryParams: buildLoginReturnQueryParams(this.router.url)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,10 @@
|
||||
</header>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<div class="mx-auto w-full max-w-lg space-y-6 p-4 sm:p-6">
|
||||
<form
|
||||
class="mx-auto w-full max-w-lg space-y-6 p-4 sm:p-6"
|
||||
(ngSubmit)="createServer()"
|
||||
>
|
||||
<div>
|
||||
<span class="mb-2 block text-sm font-medium text-foreground">{{ 'servers.create.pickCategory' | translate }}</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@@ -48,6 +51,9 @@
|
||||
<input
|
||||
id="create-server-name"
|
||||
type="text"
|
||||
name="serverName"
|
||||
appAutoFocus
|
||||
appSelectOnFocus
|
||||
[ngModel]="name()"
|
||||
(ngModelChange)="name.set($event)"
|
||||
[placeholder]="'servers.create.namePlaceholder' | translate"
|
||||
@@ -63,6 +69,7 @@
|
||||
>
|
||||
<textarea
|
||||
id="create-server-description"
|
||||
name="serverDescription"
|
||||
[ngModel]="description()"
|
||||
(ngModelChange)="description.set($event)"
|
||||
[placeholder]="'servers.create.descriptionPlaceholder' | translate"
|
||||
@@ -96,6 +103,8 @@
|
||||
<input
|
||||
id="create-server-topic"
|
||||
type="text"
|
||||
name="serverTopic"
|
||||
appSelectOnFocus
|
||||
[ngModel]="topic()"
|
||||
(ngModelChange)="topic.set($event)"
|
||||
[placeholder]="'servers.create.topicPlaceholder' | translate"
|
||||
@@ -111,6 +120,7 @@
|
||||
>
|
||||
<select
|
||||
id="create-server-signal-endpoint"
|
||||
name="sourceId"
|
||||
[(ngModel)]="sourceId"
|
||||
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
@@ -125,6 +135,7 @@
|
||||
<input
|
||||
id="create-server-private"
|
||||
type="checkbox"
|
||||
name="isPrivate"
|
||||
[ngModel]="isPrivate()"
|
||||
(ngModelChange)="isPrivate.set($event)"
|
||||
class="h-4 w-4 rounded border-border bg-secondary"
|
||||
@@ -145,6 +156,7 @@
|
||||
<input
|
||||
id="create-server-password"
|
||||
type="password"
|
||||
name="serverPassword"
|
||||
[ngModel]="password()"
|
||||
(ngModelChange)="password.set($event)"
|
||||
[placeholder]="'servers.create.passwordPlaceholder' | translate"
|
||||
@@ -167,14 +179,13 @@
|
||||
</button>
|
||||
<button
|
||||
id="create-server-submit"
|
||||
type="button"
|
||||
type="submit"
|
||||
class="flex-1 rounded-lg bg-primary px-4 py-2 font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
[disabled]="!canCreate"
|
||||
(click)="createServer()"
|
||||
>
|
||||
{{ 'servers.create.submit' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,11 @@ import {
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { setStoredCurrentUserId } from '../../../../core/storage/current-user-storage';
|
||||
import { buildLoginReturnQueryParams } from '../../../authentication/domain/logic/auth-navigation.rules';
|
||||
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
|
||||
|
||||
/** Preset categories that pre-fill the server topic to speed up creation. */
|
||||
@@ -47,6 +51,8 @@ export const CATEGORY_PRESETS: ServerCategoryPreset[] = [
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
NgIcon,
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideArrowLeft, lucideChevronDown, lucideChevronUp })],
|
||||
@@ -56,6 +62,7 @@ export class CreateServerComponent implements OnInit {
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
readonly categories = CATEGORY_PRESETS;
|
||||
activeEndpoints = this.serverDirectory.activeServers;
|
||||
@@ -102,13 +109,19 @@ export class CreateServerComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
const currentUser = this.currentUser();
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId') || currentUser?.id;
|
||||
|
||||
if (!currentUserId) {
|
||||
this.router.navigate(['/login']);
|
||||
this.router.navigate(['/login'], {
|
||||
queryParams: buildLoginReturnQueryParams(this.router.url)
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setStoredCurrentUserId(currentUserId);
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.createRoom({
|
||||
name: this.name().trim(),
|
||||
|
||||
@@ -17,6 +17,7 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants';
|
||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import { buildLoginReturnQueryParams } from '../../../authentication/domain/logic/auth-navigation.rules';
|
||||
|
||||
@Component({
|
||||
selector: 'app-invite',
|
||||
@@ -168,9 +169,7 @@ export class InviteComponent implements OnInit {
|
||||
this.message.set(this.i18n.instant('servers.invite.messages.redirectingLogin'));
|
||||
|
||||
await this.router.navigate(['/login'], {
|
||||
queryParams: {
|
||||
returnUrl: this.router.url
|
||||
}
|
||||
queryParams: buildLoginReturnQueryParams(this.router.url)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -144,6 +144,8 @@
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
appAutoFocus
|
||||
appSelectOnFocus
|
||||
[attr.aria-label]="'servers.browser.search.ariaLabel' | translate"
|
||||
class="h-10 w-full rounded-lg border border-border bg-secondary py-2 pl-10 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
[placeholder]="resolvedSearchPlaceholder"
|
||||
|
||||
@@ -30,6 +30,9 @@ import {
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
import { setStoredCurrentUserId } from '../../../../core/storage/current-user-storage';
|
||||
import { buildLoginReturnQueryParams } from '../../../authentication/domain/logic/auth-navigation.rules';
|
||||
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
import {
|
||||
selectSearchResults,
|
||||
@@ -93,6 +96,8 @@ export interface ServerDiscoverySection {
|
||||
ConfirmDialogComponent,
|
||||
LeaveServerDialogComponent,
|
||||
ModalBackdropComponent,
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
@@ -260,13 +265,19 @@ export class ServerBrowserComponent implements OnInit {
|
||||
}
|
||||
|
||||
async joinServer(server: ServerInfo): Promise<void> {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
const currentUser = this.currentUser();
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId') || currentUser?.id;
|
||||
|
||||
if (!currentUserId) {
|
||||
this.router.navigate(['/login']);
|
||||
this.router.navigate(['/login'], {
|
||||
queryParams: buildLoginReturnQueryParams(this.router.url)
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setStoredCurrentUserId(currentUserId);
|
||||
|
||||
if (await this.isServerBanned(server)) {
|
||||
this.bannedServerName.set(server.name);
|
||||
this.showBannedDialog.set(true);
|
||||
@@ -492,14 +503,19 @@ export class ServerBrowserComponent implements OnInit {
|
||||
password?: string,
|
||||
options: { acceptedRequirements?: PluginRequirementSummary[]; skipPluginConsent?: boolean } = {}
|
||||
): Promise<void> {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
const currentUser = this.currentUser();
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId') || currentUser?.id;
|
||||
|
||||
if (!currentUserId) {
|
||||
this.router.navigate(['/login']);
|
||||
this.router.navigate(['/login'], {
|
||||
queryParams: buildLoginReturnQueryParams(this.router.url)
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setStoredCurrentUserId(currentUserId);
|
||||
|
||||
this.joinErrorMessage.set(null);
|
||||
this.joinPasswordError.set(null);
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ A reactive `speakingMap` signal (a `Map<string, boolean>`) is published whenever
|
||||
|
||||
## Voice playback
|
||||
|
||||
`VoicePlaybackService` handles audio output for remote peers. Each peer gets an independent Web Audio pipeline:
|
||||
`VoicePlaybackService` handles audio output for remote peers. Each peer gets an independent Web Audio pipeline. Pipelines are rebuilt only when that peer's live voice audio track set changes — composite remote-stream notifications (camera, screen share, SDP renegotiation) reuse the existing graph so AudioContexts are not churned.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import {
|
||||
Injector,
|
||||
runInInjectionContext,
|
||||
signal,
|
||||
ɵChangeDetectionScheduler as ChangeDetectionScheduler,
|
||||
ɵEffectScheduler as EffectScheduler
|
||||
} from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Subject } from 'rxjs';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { ScreenShareFacade } from '../../../screen-share';
|
||||
import { VoiceConnectionFacade } from '../facades/voice-connection.facade';
|
||||
import { VoicePlaybackService } from './voice-playback.service';
|
||||
|
||||
let audioContextCount = 0;
|
||||
|
||||
describe('VoicePlaybackService', () => {
|
||||
beforeEach(() => {
|
||||
audioContextCount = 0;
|
||||
installAudioDomMocks();
|
||||
installLocalStorageMock();
|
||||
});
|
||||
|
||||
it('creates one audio pipeline per peer on first connect', () => {
|
||||
const context = createServiceContext({ isVoiceConnected: true });
|
||||
const stream = createMockAudioStream(['track-a']);
|
||||
|
||||
context.service.handleRemoteStream('peer-1', stream, connectedOptions());
|
||||
|
||||
expect(audioContextCount).toBe(1);
|
||||
});
|
||||
|
||||
it('reuses the existing pipeline when the same voice stream is handled again', () => {
|
||||
const context = createServiceContext({ isVoiceConnected: true });
|
||||
const stream = createMockAudioStream(['track-a']);
|
||||
|
||||
context.service.handleRemoteStream('peer-1', stream, connectedOptions());
|
||||
context.service.handleRemoteStream('peer-1', stream, connectedOptions());
|
||||
|
||||
expect(audioContextCount).toBe(1);
|
||||
});
|
||||
|
||||
it('reuses the pipeline when only the MediaStream wrapper changes but live audio tracks are unchanged', () => {
|
||||
const context = createServiceContext({ isVoiceConnected: true });
|
||||
const track = createMockAudioTrack('track-a');
|
||||
const firstStream = createMockAudioStreamFromTracks([track]);
|
||||
const secondStream = createMockAudioStreamFromTracks([track]);
|
||||
|
||||
context.service.handleRemoteStream('peer-1', firstStream, connectedOptions());
|
||||
context.service.handleRemoteStream('peer-1', secondStream, connectedOptions());
|
||||
|
||||
expect(audioContextCount).toBe(1);
|
||||
});
|
||||
|
||||
it('rebuilds the pipeline when the live audio track set changes', () => {
|
||||
const context = createServiceContext({ isVoiceConnected: true });
|
||||
const firstStream = createMockAudioStream(['track-a']);
|
||||
const secondStream = createMockAudioStream(['track-b']);
|
||||
|
||||
context.service.handleRemoteStream('peer-1', firstStream, connectedOptions());
|
||||
context.service.handleRemoteStream('peer-1', secondStream, connectedOptions());
|
||||
|
||||
expect(audioContextCount).toBe(2);
|
||||
});
|
||||
|
||||
it('does not recreate pipelines for unrelated remote stream notifications', () => {
|
||||
const context = createServiceContext({ isVoiceConnected: true });
|
||||
const voiceStream = createMockAudioStream(['track-a']);
|
||||
|
||||
context.voiceConnection.getRemoteVoiceStream.mockReturnValue(voiceStream);
|
||||
context.service.handleRemoteStream('peer-1', voiceStream, connectedOptions());
|
||||
|
||||
context.remoteStream$.next({ peerId: 'peer-1', stream: createMockAudioStream(['screen-audio']) });
|
||||
|
||||
expect(audioContextCount).toBe(1);
|
||||
});
|
||||
|
||||
it('creates pipelines for each peer when multiple friends join voice', () => {
|
||||
const context = createServiceContext({ isVoiceConnected: true });
|
||||
|
||||
context.service.handleRemoteStream('peer-1', createMockAudioStream(['track-1']), connectedOptions());
|
||||
context.service.handleRemoteStream('peer-2', createMockAudioStream(['track-2']), connectedOptions());
|
||||
|
||||
expect(audioContextCount).toBe(2);
|
||||
});
|
||||
|
||||
it('still applies updated playback options when reusing an existing pipeline', () => {
|
||||
const context = createServiceContext({ isVoiceConnected: true });
|
||||
const stream = createMockAudioStream(['track-a']);
|
||||
|
||||
context.service.handleRemoteStream('peer-1', stream, connectedOptions({ outputVolume: 1 }));
|
||||
context.service.handleRemoteStream('peer-1', stream, connectedOptions({ outputVolume: 0.5, isDeafened: true }));
|
||||
|
||||
expect(audioContextCount).toBe(1);
|
||||
expect(context.service.getUserVolume('peer-1')).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
interface ServiceContext {
|
||||
service: VoicePlaybackService;
|
||||
voiceConnection: {
|
||||
isVoiceConnected: ReturnType<typeof signal<boolean>>;
|
||||
onRemoteStream: Subject<{ peerId: string; stream: MediaStream }>;
|
||||
onVoiceConnected: Subject<void>;
|
||||
onPeerDisconnected: Subject<string>;
|
||||
getRemoteVoiceStream: ReturnType<typeof vi.fn>;
|
||||
getConnectedPeers: ReturnType<typeof vi.fn>;
|
||||
syncOutgoingVoiceRouting: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
remoteStream$: Subject<{ peerId: string; stream: MediaStream }>;
|
||||
}
|
||||
|
||||
function createServiceContext(options: { isVoiceConnected?: boolean } = {}): ServiceContext {
|
||||
const remoteStream$ = new Subject<{ peerId: string; stream: MediaStream }>();
|
||||
const voiceConnection = {
|
||||
isVoiceConnected: signal(options.isVoiceConnected ?? false),
|
||||
onRemoteStream: remoteStream$,
|
||||
onVoiceConnected: new Subject<void>(),
|
||||
onPeerDisconnected: new Subject<string>(),
|
||||
getRemoteVoiceStream: vi.fn(() => null),
|
||||
getConnectedPeers: vi.fn(() => []),
|
||||
syncOutgoingVoiceRouting: vi.fn()
|
||||
};
|
||||
const screenShare = {
|
||||
isScreenShareRemotePlaybackSuppressed: signal(false),
|
||||
forceDefaultRemotePlaybackOutput: signal(false)
|
||||
};
|
||||
const store = {
|
||||
selectSignal: vi.fn((selector: unknown) => {
|
||||
if (selector === selectCurrentUser) {
|
||||
return signal(null);
|
||||
}
|
||||
|
||||
if (selector === selectAllUsers) {
|
||||
return signal([]);
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected selector in VoicePlaybackService test: ${String(selector)}`);
|
||||
})
|
||||
};
|
||||
const scheduledEffects = new Set<{ dirty: boolean; run: () => void }>();
|
||||
const effectScheduler = {
|
||||
add: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
|
||||
scheduledEffects.add(scheduledEffect);
|
||||
}),
|
||||
flush: vi.fn(() => {
|
||||
for (const scheduledEffect of scheduledEffects) {
|
||||
if (scheduledEffect.dirty) {
|
||||
scheduledEffect.run();
|
||||
}
|
||||
}
|
||||
}),
|
||||
remove: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
|
||||
scheduledEffects.delete(scheduledEffect);
|
||||
}),
|
||||
schedule: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
|
||||
scheduledEffects.add(scheduledEffect);
|
||||
})
|
||||
};
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
VoicePlaybackService,
|
||||
{
|
||||
provide: ChangeDetectionScheduler,
|
||||
useValue: { notify: vi.fn() }
|
||||
},
|
||||
{ provide: EffectScheduler, useValue: effectScheduler },
|
||||
{ provide: VoiceConnectionFacade, useValue: voiceConnection },
|
||||
{ provide: ScreenShareFacade, useValue: screenShare },
|
||||
{ provide: Store, useValue: store }
|
||||
]
|
||||
});
|
||||
|
||||
return {
|
||||
service: runInInjectionContext(injector, () => injector.get(VoicePlaybackService)),
|
||||
voiceConnection,
|
||||
remoteStream$
|
||||
};
|
||||
}
|
||||
|
||||
function connectedOptions(overrides: Partial<{ outputVolume: number; isDeafened: boolean }> = {}) {
|
||||
return {
|
||||
isConnected: true,
|
||||
outputVolume: overrides.outputVolume ?? 1,
|
||||
isDeafened: overrides.isDeafened ?? false
|
||||
};
|
||||
}
|
||||
|
||||
function createMockAudioTrack(id: string, readyState: MediaStreamTrackState = 'live'): MediaStreamTrack {
|
||||
return {
|
||||
id,
|
||||
kind: 'audio',
|
||||
readyState,
|
||||
enabled: true
|
||||
} as MediaStreamTrack;
|
||||
}
|
||||
|
||||
function createMockAudioStream(trackIds: string[]): MediaStream {
|
||||
return createMockAudioStreamFromTracks(trackIds.map((id) => createMockAudioTrack(id)));
|
||||
}
|
||||
|
||||
function createMockAudioStreamFromTracks(tracks: MediaStreamTrack[]): MediaStream {
|
||||
return {
|
||||
getAudioTracks: () => tracks,
|
||||
getTracks: () => tracks
|
||||
} as unknown as MediaStream;
|
||||
}
|
||||
|
||||
function installAudioDomMocks(): void {
|
||||
vi.stubGlobal('MediaStream', class MediaStream {
|
||||
constructor(private readonly tracks: MediaStreamTrack[] = []) {}
|
||||
|
||||
getAudioTracks(): MediaStreamTrack[] {
|
||||
return this.tracks;
|
||||
}
|
||||
|
||||
getTracks(): MediaStreamTrack[] {
|
||||
return this.tracks;
|
||||
}
|
||||
});
|
||||
|
||||
vi.stubGlobal('Audio', class {
|
||||
muted = false;
|
||||
volume = 1;
|
||||
srcObject: MediaStream | null = null;
|
||||
|
||||
play = vi.fn().mockResolvedValue(undefined);
|
||||
remove = vi.fn();
|
||||
});
|
||||
|
||||
vi.stubGlobal('AudioContext', class {
|
||||
state = 'running';
|
||||
close = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
constructor() {
|
||||
audioContextCount += 1;
|
||||
}
|
||||
|
||||
createGain() {
|
||||
return {
|
||||
gain: { value: 0 },
|
||||
connect: vi.fn()
|
||||
};
|
||||
}
|
||||
|
||||
createMediaStreamSource() {
|
||||
return { connect: vi.fn() };
|
||||
}
|
||||
|
||||
createMediaStreamDestination() {
|
||||
return { stream: createMockAudioStream(['destination']) };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function installLocalStorageMock(): void {
|
||||
const storage = new Map<string, string>();
|
||||
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
storage.delete(key);
|
||||
},
|
||||
clear: () => {
|
||||
storage.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -112,10 +112,17 @@ export class VoicePlaybackService {
|
||||
return;
|
||||
}
|
||||
|
||||
this.removePipeline(peerId);
|
||||
this.rawRemoteStreams.set(peerId, stream);
|
||||
this.masterVolume = options.outputVolume;
|
||||
this.deafened = options.isDeafened;
|
||||
|
||||
if (this.shouldReusePipeline(peerId, stream)) {
|
||||
this.rawRemoteStreams.set(peerId, stream);
|
||||
this.applyGain(peerId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.removePipeline(peerId);
|
||||
this.rawRemoteStreams.set(peerId, stream);
|
||||
this.createPipeline(peerId, stream);
|
||||
}
|
||||
|
||||
@@ -142,13 +149,19 @@ export class VoicePlaybackService {
|
||||
for (const peerId of peers) {
|
||||
const stream = this.voiceConnection.getRemoteVoiceStream(peerId);
|
||||
|
||||
if (stream && this.hasAudio(stream)) {
|
||||
const trackedRaw = this.rawRemoteStreams.get(peerId);
|
||||
|
||||
if (!trackedRaw || trackedRaw !== stream) {
|
||||
this.handleRemoteStream(peerId, stream, options);
|
||||
}
|
||||
if (!stream || !this.hasAudio(stream)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.shouldReusePipeline(peerId, stream)) {
|
||||
this.masterVolume = options.outputVolume;
|
||||
this.deafened = options.isDeafened;
|
||||
this.rawRemoteStreams.set(peerId, stream);
|
||||
this.applyGain(peerId);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.handleRemoteStream(peerId, stream, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,4 +446,41 @@ export class VoicePlaybackService {
|
||||
private hasAudio(stream: MediaStream): boolean {
|
||||
return stream.getAudioTracks().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote composite-stream notifications (camera, screen share, renegotiation)
|
||||
* can arrive without any change to the underlying voice audio tracks.
|
||||
* Rebuilding the Web Audio graph in that case only churns AudioContexts.
|
||||
*/
|
||||
private shouldReusePipeline(peerId: string, stream: MediaStream): boolean {
|
||||
const trackedRaw = this.rawRemoteStreams.get(peerId);
|
||||
|
||||
return this.peerPipelines.has(peerId)
|
||||
&& !!trackedRaw
|
||||
&& this.streamsShareLiveAudioTracks(trackedRaw, stream);
|
||||
}
|
||||
|
||||
private streamsShareLiveAudioTracks(previous: MediaStream, next: MediaStream): boolean {
|
||||
const previousTrackIds = this.getLiveAudioTrackIds(previous);
|
||||
const nextTrackIds = this.getLiveAudioTrackIds(next);
|
||||
|
||||
if (previousTrackIds.length === 0 || nextTrackIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (previousTrackIds.length !== nextTrackIds.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const previousIds = new Set(previousTrackIds);
|
||||
|
||||
return nextTrackIds.every((trackId) => previousIds.has(trackId));
|
||||
}
|
||||
|
||||
private getLiveAudioTrackIds(stream: MediaStream): string[] {
|
||||
return stream
|
||||
.getAudioTracks()
|
||||
.filter((track) => track.readyState === 'live')
|
||||
.map((track) => track.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,16 @@ When a voice session is active and the user navigates away from the voice-connec
|
||||
|
||||
Joining a new voice target is exclusive: entering another voice channel or private call first disconnects the current call/channel, clears local voice state, and broadcasts the leave for the previous target. Users never need to manually leave one voice target before joining another.
|
||||
|
||||
## Multi-device voice (Discord-style)
|
||||
|
||||
Each install has a stable `clientInstanceId` (`ClientInstanceService`). `VoiceState.clientInstanceId` records which device currently owns the microphone/WebRTC session for that user.
|
||||
|
||||
- **Local voice owner** — this device's `clientInstanceId` matches `voiceState.clientInstanceId`; mic, heartbeat, and WebRTC transmit run normally.
|
||||
- **Passive client** — another device owns voice; this client still receives chat/presence and shows grayed "in voice on another device" UI in the room sidebar and private-call cards.
|
||||
- **Takeover** — clicking **Join** on a passive client sends `voice_client_takeover` through signaling; the active device releases voice via `VoiceClientTakeoverService`, then the passive client completes a normal join.
|
||||
|
||||
Rules live in `domain/logic/client-voice-session.rules.ts`.
|
||||
|
||||
Remote voice playback is scoped to the active voice channel, not the whole server. Users stay connected to the shared peer mesh for text, presence, and screen-share control, but voice transport and playback only stay active for peers whose `voiceState.roomId` and `voiceState.serverId` match the local user's current voice session.
|
||||
|
||||
Owners and admins can also move connected users between voice channels from the room sidebar by dragging a user onto a different voice channel. The moved client updates its local heartbeat and voice-session metadata to the new channel, so routing, floating controls, and occupancy stay in sync after the move.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import type { User } from '../../../../shared-kernel';
|
||||
import { VoiceConnectionFacade } from '../../../voice-connection';
|
||||
import { ClientInstanceService } from '../../../../core/platform/client-instance.service';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { isLocalVoiceOwner } from '../../domain/logic/client-voice-session.rules';
|
||||
import { VoiceSessionFacade } from '../facades/voice-session.facade';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VoiceClientTakeoverService {
|
||||
private readonly store = inject(Store);
|
||||
private readonly voiceConnection = inject(VoiceConnectionFacade);
|
||||
private readonly voiceSession = inject(VoiceSessionFacade);
|
||||
private readonly clientInstance = inject(ClientInstanceService);
|
||||
|
||||
releaseLocalVoiceForTakeover(currentUser: User | null): void {
|
||||
if (!currentUser?.voiceState?.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isLocalVoiceOwner(currentUser.voiceState, this.clientInstance.getClientInstanceId())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousVoiceState = currentUser.voiceState;
|
||||
|
||||
this.voiceConnection.stopVoiceHeartbeat();
|
||||
this.voiceConnection.disableVoice();
|
||||
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: currentUser.id,
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined,
|
||||
clientInstanceId: undefined
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.store.dispatch(
|
||||
UsersActions.updateCameraState({
|
||||
userId: currentUser.id,
|
||||
cameraState: { isEnabled: false }
|
||||
})
|
||||
);
|
||||
|
||||
this.voiceConnection.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: currentUser.oderId || currentUser.id,
|
||||
displayName: currentUser.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: previousVoiceState.roomId,
|
||||
serverId: previousVoiceState.serverId,
|
||||
clientInstanceId: undefined
|
||||
}
|
||||
});
|
||||
|
||||
this.voiceSession.endSession();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { VoiceState } from '../../../../shared-kernel';
|
||||
import {
|
||||
isLocalVoiceOwner,
|
||||
isVoiceOnAnotherClient,
|
||||
shouldTransmitVoice
|
||||
} from './client-voice-session.rules';
|
||||
|
||||
describe('client-voice-session.rules', () => {
|
||||
const localClientInstanceId = 'device-a';
|
||||
|
||||
it('treats the matching client instance as the local voice owner', () => {
|
||||
const voiceState: VoiceState = {
|
||||
isConnected: true,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isSpeaking: false,
|
||||
clientInstanceId: localClientInstanceId
|
||||
};
|
||||
|
||||
expect(isLocalVoiceOwner(voiceState, localClientInstanceId)).toBe(true);
|
||||
expect(isVoiceOnAnotherClient(voiceState, localClientInstanceId)).toBe(false);
|
||||
expect(shouldTransmitVoice(voiceState, localClientInstanceId)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a different client instance as passive voice', () => {
|
||||
const voiceState: VoiceState = {
|
||||
isConnected: true,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isSpeaking: false,
|
||||
clientInstanceId: 'device-b'
|
||||
};
|
||||
|
||||
expect(isLocalVoiceOwner(voiceState, localClientInstanceId)).toBe(false);
|
||||
expect(isVoiceOnAnotherClient(voiceState, localClientInstanceId)).toBe(true);
|
||||
expect(shouldTransmitVoice(voiceState, localClientInstanceId)).toBe(false);
|
||||
});
|
||||
|
||||
it('allows transmission when disconnected', () => {
|
||||
const voiceState: VoiceState = {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isSpeaking: false
|
||||
};
|
||||
|
||||
expect(shouldTransmitVoice(voiceState, localClientInstanceId)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { VoiceState } from '../../../../shared-kernel';
|
||||
|
||||
export function isLocalVoiceOwner(
|
||||
voiceState: Pick<VoiceState, 'isConnected' | 'clientInstanceId'> | null | undefined,
|
||||
clientInstanceId: string
|
||||
): boolean {
|
||||
return !!voiceState?.isConnected
|
||||
&& !!voiceState.clientInstanceId
|
||||
&& voiceState.clientInstanceId === clientInstanceId;
|
||||
}
|
||||
|
||||
export function isVoiceOnAnotherClient(
|
||||
voiceState: Pick<VoiceState, 'isConnected' | 'clientInstanceId'> | null | undefined,
|
||||
clientInstanceId: string
|
||||
): boolean {
|
||||
return !!voiceState?.isConnected
|
||||
&& !!voiceState.clientInstanceId
|
||||
&& voiceState.clientInstanceId !== clientInstanceId;
|
||||
}
|
||||
|
||||
export function shouldTransmitVoice(
|
||||
voiceState: Pick<VoiceState, 'isConnected' | 'clientInstanceId'> | null | undefined,
|
||||
clientInstanceId: string
|
||||
): boolean {
|
||||
if (!voiceState?.isConnected) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!voiceState.clientInstanceId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return voiceState.clientInstanceId === clientInstanceId;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
export * from './application/facades/voice-session.facade';
|
||||
export * from './application/services/voice-client-takeover.service';
|
||||
export * from './application/services/voice-workspace.service';
|
||||
export * from './domain/logic/client-voice-session.rules';
|
||||
export * from './domain/models/voice-session.model';
|
||||
export * from './infrastructure/util/voice-settings-storage.util';
|
||||
|
||||
|
||||
@@ -21,12 +21,15 @@
|
||||
<input
|
||||
#searchInput
|
||||
type="text"
|
||||
appAutoFocus
|
||||
appSelectOnFocus
|
||||
appSubmitOnEnter
|
||||
(submitOnEnter)="submitSearch()"
|
||||
[attr.aria-label]="'dashboard.searchAriaLabel' | translate"
|
||||
class="h-12 w-full min-w-0 rounded-xl border border-border bg-secondary py-2 pl-11 pr-4 text-base text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary sm:pr-20"
|
||||
[placeholder]="isMobile() ? ('dashboard.searchPlaceholderMobile' | translate) : ('dashboard.searchPlaceholderDesktop' | translate)"
|
||||
[ngModel]="searchQuery()"
|
||||
(ngModelChange)="onSearchChange($event)"
|
||||
(keydown.enter)="submitSearch()"
|
||||
/>
|
||||
<kbd
|
||||
class="pointer-events-none absolute right-3 top-1/2 hidden -translate-y-1/2 items-center gap-1 rounded border border-border bg-card px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:flex"
|
||||
|
||||
@@ -46,6 +46,11 @@ import { FriendButtonComponent } from '../../domains/direct-message/feature/frie
|
||||
import { UserAvatarComponent } from '../../shared/components/user-avatar/user-avatar.component';
|
||||
import { parseInviteQuery } from './invite-query.util';
|
||||
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../core/i18n';
|
||||
import {
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective
|
||||
} from '../../shared/directives';
|
||||
|
||||
/** Maximum quick-search rows shown per group on the dashboard. */
|
||||
const QUICK_RESULT_LIMIT = 5;
|
||||
@@ -72,6 +77,9 @@ const RECENT_SEARCHES_STORAGE_KEY = 'metoyou_dashboard_recent_searches';
|
||||
NgIcon,
|
||||
FriendButtonComponent,
|
||||
UserAvatarComponent,
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
[class.shadow-[0_0_0_6px_rgba(16,185,129,0.12)]]="speaking() && compact()"
|
||||
[class.shadow-[0_0_0_8px_rgba(16,185,129,0.12)]]="speaking() && !compact()"
|
||||
[class.ring-border]="!speaking()"
|
||||
[class.opacity-55]="!connected()"
|
||||
[class.opacity-55]="!connected() || passive()"
|
||||
>
|
||||
@if (user().avatarUrl) {
|
||||
<img
|
||||
|
||||
@@ -18,6 +18,7 @@ export class PrivateCallParticipantCardComponent {
|
||||
readonly speaking = input.required<boolean>();
|
||||
readonly issueLabel = input<string | null>(null);
|
||||
readonly compact = input(false);
|
||||
readonly passive = input(false);
|
||||
|
||||
avatarSize(): string {
|
||||
return this.compact() ? '5.75rem' : 'clamp(6.5rem, 38vw, 13rem)';
|
||||
|
||||
@@ -148,6 +148,7 @@
|
||||
[connected]="isParticipantConnected(user)"
|
||||
[speaking]="isSpeaking(user)"
|
||||
[issueLabel]="participantIssueLabel(user)"
|
||||
[passive]="isPassiveCallParticipant(user)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
@@ -164,6 +165,7 @@
|
||||
[connected]="isParticipantConnected(user)"
|
||||
[speaking]="isSpeaking(user)"
|
||||
[issueLabel]="participantIssueLabel(user)"
|
||||
[passive]="isPassiveCallParticipant(user)"
|
||||
[compact]="true"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -44,6 +44,11 @@ import {
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../domains/voice-session';
|
||||
import { ScreenShareQualityDialogComponent } from '../../shared';
|
||||
import { ViewportService } from '../../core/platform';
|
||||
import { RealtimeSessionFacade } from '../../core/realtime';
|
||||
import {
|
||||
isLocalVoiceOwner,
|
||||
isVoiceOnAnotherClient
|
||||
} from '../../domains/voice-session';
|
||||
import { MobileMediaService, MobilePlatformService } from '../../infrastructure/mobile';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../store/users/users.selectors';
|
||||
import { UsersActions } from '../../store/users/users.actions';
|
||||
@@ -85,6 +90,7 @@ export class PrivateCallComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly store = inject(Store);
|
||||
private readonly calls = inject(DirectCallService);
|
||||
private readonly realtime = inject(RealtimeSessionFacade);
|
||||
private readonly voice = inject(VoiceConnectionFacade);
|
||||
private readonly voiceActivity = inject(VoiceActivityService);
|
||||
private readonly playback = inject(VoicePlaybackService);
|
||||
@@ -437,18 +443,38 @@ export class PrivateCallComponent {
|
||||
isParticipantConnected(user: User): boolean {
|
||||
const session = this.session();
|
||||
const userId = this.userKey(user);
|
||||
const current = this.currentUser();
|
||||
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!!session.participants[userId]?.joined ||
|
||||
!!(user.voiceState?.isConnected && user.voiceState.roomId === session.callId && user.voiceState.serverId === session.callId)
|
||||
const inCallVoice = !!(
|
||||
user.voiceState?.isConnected
|
||||
&& user.voiceState.roomId === session.callId
|
||||
&& user.voiceState.serverId === session.callId
|
||||
);
|
||||
const isSelf = !!current && (user.id === current.id || user.oderId === current.oderId);
|
||||
|
||||
if (isSelf && inCallVoice) {
|
||||
return isLocalVoiceOwner(user.voiceState, this.realtime.getClientInstanceId());
|
||||
}
|
||||
|
||||
return !!session.participants[userId]?.joined || inCallVoice;
|
||||
}
|
||||
|
||||
isPassiveCallParticipant(user: User): boolean {
|
||||
const current = this.currentUser();
|
||||
const isSelf = !!current && (user.id === current.id || user.oderId === current.oderId);
|
||||
|
||||
return isSelf && isVoiceOnAnotherClient(user.voiceState, this.realtime.getClientInstanceId());
|
||||
}
|
||||
|
||||
participantIssueLabel(user: User): string | null {
|
||||
if (this.isPassiveCallParticipant(user)) {
|
||||
return this.i18n.instant('call.private.voiceOnOtherDevice');
|
||||
}
|
||||
|
||||
return this.isParticipantConnected(user) ? null : this.i18n.instant('call.private.waiting');
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,8 @@
|
||||
<input
|
||||
#renameInput
|
||||
type="text"
|
||||
appAutoFocus
|
||||
appSelectOnFocus
|
||||
[value]="ch.name"
|
||||
[class.border-destructive]="renamingChannelId() === ch.id && !!channelNameError()"
|
||||
[title]="renamingChannelId() === ch.id ? (channelNameError() ? (channelNameError()! | translate) : '') : ''"
|
||||
@@ -173,7 +175,7 @@
|
||||
(contextmenu)="openChannelContextMenu($event, ch)"
|
||||
[class.bg-secondary]="isCurrentRoom(ch.id)"
|
||||
[disabled]="!voiceEnabled()"
|
||||
[title]="isCurrentRoom(ch.id) ? ('room.panel.openStreamWorkspace' | translate) : ('room.panel.joinVoiceChannel' | translate)"
|
||||
[title]="voiceChannelActionLabel(ch.id)"
|
||||
data-channel-type="voice"
|
||||
[attr.data-channel-name]="ch.name"
|
||||
>
|
||||
@@ -186,6 +188,8 @@
|
||||
<input
|
||||
#renameInput
|
||||
type="text"
|
||||
appAutoFocus
|
||||
appSelectOnFocus
|
||||
[value]="ch.name"
|
||||
[class.border-destructive]="renamingChannelId() === ch.id && !!channelNameError()"
|
||||
[title]="renamingChannelId() === ch.id ? (channelNameError() ? (channelNameError()! | translate) : '') : ''"
|
||||
@@ -205,6 +209,10 @@
|
||||
<span class="rounded-full bg-primary/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
|
||||
{{ isVoiceWorkspaceExpanded() ? ('room.panel.open' | translate) : ('room.panel.view' | translate) }}
|
||||
</span>
|
||||
} @else if (isPassiveInVoiceRoom(ch.id)) {
|
||||
<span class="rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{{ 'room.panel.takeOverVoice' | translate }}
|
||||
</span>
|
||||
} @else if (voiceOccupancy(ch.id) > 0) {
|
||||
<span class="text-xs text-muted-foreground">{{ voiceOccupancy(ch.id) }}</span>
|
||||
}
|
||||
@@ -220,6 +228,7 @@
|
||||
appThemeNode="roomVoiceUserItem"
|
||||
class="flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-secondary/50"
|
||||
[class.cursor-pointer]="canDragVoiceUser(u)"
|
||||
[class.opacity-50]="isPassiveVoiceUser(u)"
|
||||
[class.opacity-60]="draggedVoiceUserId() === (u.id || u.oderId)"
|
||||
[draggable]="canDragVoiceUser(u)"
|
||||
(dragstart)="onVoiceUserDragStart($event, u)"
|
||||
@@ -368,6 +377,7 @@
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">{{ 'room.panel.you' | translate }}</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"
|
||||
[class.opacity-50]="isPassiveVoiceClient()"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
(click)="openProfileCard($event, currentUser()!, true); $event.stopPropagation()"
|
||||
@@ -413,7 +423,11 @@
|
||||
name="lucideMic"
|
||||
class="w-2.5 h-2.5"
|
||||
/>
|
||||
{{ 'room.panel.inVoice' | translate }}
|
||||
@if (isPassiveVoiceClient()) {
|
||||
{{ 'room.panel.voiceOnOtherDevice' | translate }}
|
||||
} @else {
|
||||
{{ 'room.panel.inVoice' | translate }}
|
||||
}
|
||||
</p>
|
||||
}
|
||||
@if (currentUser() && isUserStreaming(currentUser()!.oderId || currentUser()!.id)) {
|
||||
@@ -763,7 +777,6 @@
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
[class.border-destructive]="!!channelNameError()"
|
||||
(ngModelChange)="clearChannelNameError()"
|
||||
(keydown.enter)="confirmCreateChannel()"
|
||||
/>
|
||||
@if (channelNameError()) {
|
||||
<p class="mt-2 text-sm text-destructive">{{ channelNameError()! | translate }}</p>
|
||||
|
||||
@@ -52,7 +52,12 @@ import {
|
||||
VoiceConnectionFacade,
|
||||
VoiceConnectivityHealthService
|
||||
} from '../../../domains/voice-connection';
|
||||
import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session';
|
||||
import {
|
||||
VoiceSessionFacade,
|
||||
VoiceWorkspaceService,
|
||||
isLocalVoiceOwner,
|
||||
isVoiceOnAnotherClient
|
||||
} from '../../../domains/voice-session';
|
||||
import { DirectMessageService } from '../../../domains/direct-message';
|
||||
import { DirectCallService } from '../../../domains/direct-call';
|
||||
import { VoicePlaybackService } from '../../../domains/voice-connection';
|
||||
@@ -88,6 +93,7 @@ import {
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { visibilityAwareInterval$ } from '../../../shared/rxjs';
|
||||
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../core/i18n';
|
||||
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../shared/directives';
|
||||
|
||||
type PanelMode = 'channels' | 'users';
|
||||
|
||||
@@ -109,6 +115,8 @@ const SKELETON_REVEAL_DELAY_MS = 180;
|
||||
ThemeNodeDirective,
|
||||
SkeletonComponent,
|
||||
SkeletonListComponent,
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
@@ -689,7 +697,13 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
private openExistingVoiceWorkspace(room: Room | null, current: User | null, roomId: string): boolean {
|
||||
if (!room || !current?.voiceState?.isConnected || current.voiceState.roomId !== roomId || current.voiceState.serverId !== room.id) {
|
||||
if (
|
||||
!room
|
||||
|| !current?.voiceState?.isConnected
|
||||
|| current.voiceState.roomId !== roomId
|
||||
|| current.voiceState.serverId !== room.id
|
||||
|| !isLocalVoiceOwner(current.voiceState, this.realtime.getClientInstanceId())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -697,6 +711,47 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
return true;
|
||||
}
|
||||
|
||||
isPassiveInVoiceRoom(roomId: string): boolean {
|
||||
const current = this.currentUser();
|
||||
const room = this.currentRoom();
|
||||
|
||||
return !!current?.voiceState?.isConnected
|
||||
&& current.voiceState.roomId === roomId
|
||||
&& current.voiceState.serverId === room?.id
|
||||
&& isVoiceOnAnotherClient(current.voiceState, this.realtime.getClientInstanceId());
|
||||
}
|
||||
|
||||
isPassiveVoiceClient(): boolean {
|
||||
const current = this.currentUser();
|
||||
|
||||
return isVoiceOnAnotherClient(current?.voiceState, this.realtime.getClientInstanceId());
|
||||
}
|
||||
|
||||
isPassiveVoiceUser(user: User | null): boolean {
|
||||
const current = this.currentUser();
|
||||
|
||||
if (!user || !current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (user.id === current.id || user.oderId === current.oderId)
|
||||
&& this.isPassiveVoiceClient();
|
||||
}
|
||||
|
||||
voiceChannelActionLabel(roomId: string): string {
|
||||
if (this.isCurrentRoom(roomId)) {
|
||||
return this.isVoiceWorkspaceExpanded()
|
||||
? this.appI18n.instant('room.panel.open')
|
||||
: this.appI18n.instant('room.panel.view');
|
||||
}
|
||||
|
||||
if (this.isPassiveInVoiceRoom(roomId)) {
|
||||
return this.appI18n.instant('room.panel.takeOverVoice');
|
||||
}
|
||||
|
||||
return this.appI18n.instant('room.panel.joinVoiceChannel');
|
||||
}
|
||||
|
||||
private canJoinRequestedVoiceRoom(room: Room, current: User | null, roomId: string): boolean {
|
||||
return !current || resolveRoomPermission(room, current, 'joinVoice', roomId);
|
||||
}
|
||||
@@ -737,9 +792,19 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
this.directCalls.leaveCurrentJoinedCall();
|
||||
this.prepareVoiceJoin(room, current ?? null);
|
||||
|
||||
this.enableVoiceForJoin(room, current ?? null, roomId)
|
||||
.then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
|
||||
.catch((error) => this.handleVoiceJoinFailure(error));
|
||||
const startJoin = () => {
|
||||
this.enableVoiceForJoin(room, current ?? null, roomId)
|
||||
.then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
|
||||
.catch((error) => this.handleVoiceJoinFailure(error));
|
||||
};
|
||||
|
||||
if (this.isPassiveInVoiceRoom(roomId) || this.isPassiveVoiceClient()) {
|
||||
this.realtime.requestVoiceClientTakeover();
|
||||
window.setTimeout(startJoin, 300);
|
||||
return;
|
||||
}
|
||||
|
||||
startJoin();
|
||||
}
|
||||
|
||||
private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void {
|
||||
@@ -786,7 +851,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
isMuted: current.voiceState?.isMuted ?? false,
|
||||
isDeafened: current.voiceState?.isDeafened ?? false,
|
||||
roomId,
|
||||
serverId: room.id
|
||||
serverId: room.id,
|
||||
clientInstanceId: this.realtime.getClientInstanceId()
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -797,6 +863,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
private broadcastVoiceConnected(roomId: string, room: Room, current: User | null): void {
|
||||
const clientInstanceId = this.realtime.getClientInstanceId();
|
||||
|
||||
this.voiceConnection.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: current?.oderId || current?.id,
|
||||
@@ -806,7 +874,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
isMuted: current?.voiceState?.isMuted ?? false,
|
||||
isDeafened: current?.voiceState?.isDeafened ?? false,
|
||||
roomId,
|
||||
serverId: room.id
|
||||
serverId: room.id,
|
||||
clientInstanceId
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -851,7 +920,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined
|
||||
serverId: undefined,
|
||||
clientInstanceId: undefined
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -873,7 +943,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: previousVoiceState?.roomId,
|
||||
serverId: previousVoiceState?.serverId
|
||||
serverId: previousVoiceState?.serverId,
|
||||
clientInstanceId: undefined
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1110,7 +1181,12 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
const me = this.currentUser();
|
||||
const room = this.currentRoom();
|
||||
|
||||
return !!(me?.voiceState?.isConnected && me.voiceState?.roomId === roomId && me.voiceState?.serverId === room?.id);
|
||||
return !!(
|
||||
me?.voiceState?.isConnected
|
||||
&& me.voiceState.roomId === roomId
|
||||
&& me.voiceState.serverId === room?.id
|
||||
&& isLocalVoiceOwner(me.voiceState, this.realtime.getClientInstanceId())
|
||||
);
|
||||
}
|
||||
|
||||
voiceEnabled(): boolean {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from 'rxjs';
|
||||
|
||||
import { Room, User } from '../../../shared-kernel';
|
||||
import { buildLoginReturnQueryParams } from '../../../domains/authentication/domain/logic/auth-navigation.rules';
|
||||
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';
|
||||
@@ -276,7 +277,9 @@ export class ServersRailComponent {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
|
||||
if (!currentUserId) {
|
||||
this.router.navigate(['/login']);
|
||||
this.router.navigate(['/login'], {
|
||||
queryParams: buildLoginReturnQueryParams(this.router.url)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -154,11 +154,13 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
appSelectOnFocus
|
||||
appSubmitOnEnter
|
||||
(submitOnEnter)="addIgnoredProcess()"
|
||||
class="flex-1 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
[placeholder]="'settings.general.gameDetection.processPlaceholder' | translate"
|
||||
[value]="ignoredProcessDraft()"
|
||||
(input)="onIgnoredProcessDraftChange($event)"
|
||||
(keydown.enter)="addIgnoredProcess()"
|
||||
[attr.aria-label]="'settings.general.gameDetection.processAria' | translate"
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -14,11 +14,21 @@ import { ElectronBridgeService } from '../../../../core/platform/electron/electr
|
||||
import { PlatformService } from '../../../../core/platform';
|
||||
import { ExperimentalMediaSettingsService } from '../../../../domains/experimental-media/application/services/experimental-media-settings.service';
|
||||
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
import {
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective
|
||||
} from '../../../../shared/directives';
|
||||
|
||||
@Component({
|
||||
selector: 'app-general-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS],
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucidePower
|
||||
|
||||
@@ -113,6 +113,10 @@
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
appAutoFocus
|
||||
appSelectOnFocus
|
||||
appSubmitOnEnter
|
||||
(submitOnEnter)="addEntry()"
|
||||
[(ngModel)]="newUrl"
|
||||
data-testid="ice-url-input"
|
||||
[placeholder]="(newType === 'stun' ? 'settings.network.ice.stunPlaceholder' : 'settings.network.ice.turnPlaceholder') | translate"
|
||||
@@ -123,6 +127,7 @@
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
appSelectOnFocus
|
||||
[(ngModel)]="newUsername"
|
||||
data-testid="ice-username-input"
|
||||
[placeholder]="'settings.network.ice.username' | translate"
|
||||
@@ -130,6 +135,8 @@
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
appSubmitOnEnter
|
||||
(submitOnEnter)="addEntry()"
|
||||
[(ngModel)]="newCredential"
|
||||
data-testid="ice-credential-input"
|
||||
[placeholder]="'settings.network.ice.credential' | translate"
|
||||
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
|
||||
import { IceServerSettingsService, IceServerEntry } from '../../../../infrastructure/realtime/ice-server-settings.service';
|
||||
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||
import {
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective
|
||||
} from '../../../../shared/directives';
|
||||
|
||||
@Component({
|
||||
selector: 'app-ice-server-settings',
|
||||
@@ -29,6 +34,9 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
|
||||
@@ -126,12 +126,16 @@
|
||||
<div class="flex-1 space-y-1.5">
|
||||
<input
|
||||
type="text"
|
||||
appSelectOnFocus
|
||||
[(ngModel)]="newServerName"
|
||||
[placeholder]="'settings.network.serverEndpoints.serverNamePlaceholderShort' | translate"
|
||||
class="w-full px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
appSelectOnFocus
|
||||
appSubmitOnEnter
|
||||
(submitOnEnter)="addServer()"
|
||||
[(ngModel)]="newServerUrl"
|
||||
[placeholder]="'settings.network.serverEndpoints.serverUrlPlaceholder' | translate"
|
||||
class="w-full px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
|
||||
@@ -22,6 +22,10 @@ import { ServerDirectoryFacade } from '../../../../domains/server-directory';
|
||||
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
|
||||
import { IceServerSettingsComponent } from '../ice-server-settings/ice-server-settings.component';
|
||||
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||
import {
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective
|
||||
} from '../../../../shared/directives';
|
||||
|
||||
@Component({
|
||||
selector: 'app-network-settings',
|
||||
@@ -31,6 +35,8 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
IceServerSettingsComponent,
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
|
||||
@@ -110,6 +110,9 @@
|
||||
}}</span>
|
||||
<input
|
||||
type="text"
|
||||
appSelectOnFocus
|
||||
appSubmitOnEnter
|
||||
(submitOnEnter)="saveRoleDetails()"
|
||||
[ngModel]="roleName"
|
||||
(ngModelChange)="roleName = $event"
|
||||
[disabled]="!canEditSelectedRoleMetadata()"
|
||||
|
||||
@@ -39,6 +39,10 @@ import {
|
||||
withUpdatedRole
|
||||
} from '../../../../domains/access-control';
|
||||
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||
import {
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective
|
||||
} from '../../../../shared/directives';
|
||||
|
||||
function upsertRoleChannelOverride(
|
||||
overrides: readonly ChannelPermissionOverride[] | undefined,
|
||||
@@ -75,6 +79,8 @@ function upsertRoleChannelOverride(
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
|
||||
@@ -82,6 +82,9 @@
|
||||
[(ngModel)]="roomName"
|
||||
[readOnly]="!isAdmin()"
|
||||
id="room-name"
|
||||
appSelectOnFocus
|
||||
appSubmitOnEnter
|
||||
(submitOnEnter)="saveServerSettings()"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
[class.opacity-60]="!isAdmin()"
|
||||
[class.cursor-not-allowed]="!isAdmin()"
|
||||
|
||||
@@ -26,6 +26,10 @@ import { ConfirmDialogComponent } from '../../../../shared';
|
||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||
import { ServerIconImageService } from '../../../../domains/server-directory/infrastructure/services/server-icon-image.service';
|
||||
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||
import {
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective
|
||||
} from '../../../../shared/directives';
|
||||
|
||||
@Component({
|
||||
selector: 'app-server-settings',
|
||||
@@ -35,6 +39,8 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
ConfirmDialogComponent,
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
|
||||
@@ -163,12 +163,16 @@
|
||||
<div class="flex-1 space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
appSelectOnFocus
|
||||
[(ngModel)]="newServerName"
|
||||
[placeholder]="'settings.network.serverEndpoints.serverNamePlaceholder' | translate"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
appSelectOnFocus
|
||||
appSubmitOnEnter
|
||||
(submitOnEnter)="addServer()"
|
||||
[(ngModel)]="newServerUrl"
|
||||
[placeholder]="'settings.network.serverEndpoints.serverUrlPlaceholder' | translate"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
|
||||
@@ -29,6 +29,10 @@ import { VoiceConnectionFacade } from '../../domains/voice-connection';
|
||||
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
|
||||
import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../../core/constants';
|
||||
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../core/i18n';
|
||||
import {
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective
|
||||
} from '../../shared/directives';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
@@ -37,6 +41,8 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../core/i18n';
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
|
||||
@@ -40,6 +40,7 @@ import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||
import { ServerDirectoryFacade } from '../../../domains/server-directory';
|
||||
import { PlatformService } from '../../../core/platform';
|
||||
import { clearStoredCurrentUserId } from '../../../core/storage/current-user-storage';
|
||||
import { buildLoginReturnQueryParams } from '../../../domains/authentication/domain/logic/auth-navigation.rules';
|
||||
import { SettingsModalService } from '../../../core/services/settings-modal.service';
|
||||
import { LeaveServerDialogComponent, ModalBackdropComponent } from '../../../shared';
|
||||
import { Room, type PluginRequirementSummary } from '../../../shared-kernel';
|
||||
@@ -211,7 +212,9 @@ export class TitleBarComponent {
|
||||
|
||||
/** Navigate to the login page. */
|
||||
goLogin() {
|
||||
this.router.navigate(['/login']);
|
||||
this.router.navigate(['/login'], {
|
||||
queryParams: buildLoginReturnQueryParams(this.router.url)
|
||||
});
|
||||
}
|
||||
|
||||
openPluginStore(): void {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect
|
||||
} from 'vitest';
|
||||
import { aggregateComponentCountsByDomain, detectSuspectedComponentLeaks } from './component-tree.rules';
|
||||
|
||||
describe('aggregateComponentCountsByDomain', () => {
|
||||
it('groups component counts under inferred domains', () => {
|
||||
expect(aggregateComponentCountsByDomain({
|
||||
VoiceChannelPanelComponent: 2,
|
||||
DmChatComponent: 1,
|
||||
App: 1
|
||||
})).toEqual({
|
||||
'voice-connection': 2,
|
||||
'direct-message': 1,
|
||||
core: 1
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectSuspectedComponentLeaks', () => {
|
||||
it('flags components that exceed the expected live count', () => {
|
||||
expect(detectSuspectedComponentLeaks({
|
||||
DmChatComponent: 3,
|
||||
App: 1
|
||||
}, {
|
||||
DmChatComponent: 0,
|
||||
App: 1
|
||||
})).toEqual([
|
||||
{
|
||||
name: 'DmChatComponent',
|
||||
count: 3,
|
||||
expected: 0
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty list when counts are within expectations', () => {
|
||||
expect(detectSuspectedComponentLeaks({
|
||||
App: 1
|
||||
}, {
|
||||
App: 1
|
||||
})).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { mapComponentNameToDomain } from './domain-mapping.rules';
|
||||
|
||||
export interface SuspectedComponentLeak {
|
||||
name: string;
|
||||
count: number;
|
||||
expected: number;
|
||||
}
|
||||
|
||||
export function aggregateComponentCountsByDomain(
|
||||
componentCounts: Record<string, number>
|
||||
): Record<string, number> {
|
||||
const domains: Record<string, number> = {};
|
||||
|
||||
for (const [componentName, count] of Object.entries(componentCounts)) {
|
||||
const domain = mapComponentNameToDomain(componentName);
|
||||
|
||||
domains[domain] = (domains[domain] ?? 0) + count;
|
||||
}
|
||||
|
||||
return domains;
|
||||
}
|
||||
|
||||
export function detectSuspectedComponentLeaks(
|
||||
componentCounts: Record<string, number>,
|
||||
expectedCounts: Record<string, number>
|
||||
): SuspectedComponentLeak[] {
|
||||
const leaks: SuspectedComponentLeak[] = [];
|
||||
|
||||
for (const [name, count] of Object.entries(componentCounts)) {
|
||||
const expected = expectedCounts[name] ?? 0;
|
||||
|
||||
if (count > expected) {
|
||||
leaks.push({ name, count, expected });
|
||||
}
|
||||
}
|
||||
|
||||
return leaks.sort((left, right) => right.count - left.count);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ApplicationRef } from '@angular/core';
|
||||
import { aggregateComponentCountsByDomain, detectSuspectedComponentLeaks } from './component-tree.rules';
|
||||
|
||||
const DEFAULT_DOM_SCAN_BUDGET = 400;
|
||||
|
||||
interface NgDebugApi {
|
||||
getComponent?: (element: Element) => { constructor: { name: string } } | null;
|
||||
}
|
||||
|
||||
export interface ComponentTreeScanResult {
|
||||
components: Record<string, number>;
|
||||
domains: Record<string, number>;
|
||||
suspectedLeaks: ReturnType<typeof detectSuspectedComponentLeaks>;
|
||||
scannedNodes: number;
|
||||
scanMode: 'application-ref' | 'ng-global';
|
||||
}
|
||||
|
||||
export function scanComponentTree(
|
||||
appRef: ApplicationRef,
|
||||
expectedCounts: Record<string, number>,
|
||||
options: { domScanBudget?: number } = {}
|
||||
): ComponentTreeScanResult {
|
||||
const domScanBudget = options.domScanBudget ?? DEFAULT_DOM_SCAN_BUDGET;
|
||||
const ngApi = (globalThis as { ng?: NgDebugApi }).ng;
|
||||
const components: Record<string, number> = {};
|
||||
|
||||
let scannedNodes = 0;
|
||||
let scanMode: ComponentTreeScanResult['scanMode'] = 'application-ref';
|
||||
|
||||
if (ngApi?.getComponent && typeof document !== 'undefined') {
|
||||
scanMode = 'ng-global';
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
|
||||
|
||||
while (walker.nextNode() && scannedNodes < domScanBudget) {
|
||||
scannedNodes += 1;
|
||||
|
||||
try {
|
||||
const component = ngApi.getComponent?.(walker.currentNode as Element) as {
|
||||
constructor: { name: string };
|
||||
} | null;
|
||||
|
||||
if (!component) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = component.constructor.name;
|
||||
|
||||
components[name] = (components[name] ?? 0) + 1;
|
||||
} catch {
|
||||
// Ignore elements that are not component hosts.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const componentRef of appRef.components) {
|
||||
const name = componentRef.componentType.name;
|
||||
|
||||
components[name] = (components[name] ?? 0) + 1;
|
||||
scannedNodes += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
components,
|
||||
domains: aggregateComponentCountsByDomain(components),
|
||||
suspectedLeaks: detectSuspectedComponentLeaks(components, expectedCounts),
|
||||
scannedNodes,
|
||||
scanMode
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
EnvironmentInjector,
|
||||
inject,
|
||||
runInInjectionContext
|
||||
} from '@angular/core';
|
||||
import type { ElectronApi } from '../../core/platform/electron/electron-api.models';
|
||||
import { PerfDiagnosticsCollector, publishRendererDiagnosticsSample } from './diagnostics.collector';
|
||||
import type { PerfDiagEntry, PerfDiagReporter } from './diagnostics.models';
|
||||
|
||||
const SAMPLE_INTERVAL_MS = 10_000;
|
||||
|
||||
let started = false;
|
||||
let sampleTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export async function bootstrapPerfDiagnostics(
|
||||
api: ElectronApi,
|
||||
injector: EnvironmentInjector
|
||||
): Promise<void> {
|
||||
const reportSample = api.reportPerfDiagSample;
|
||||
|
||||
if (started || !api.isPerfDiagEnabled || !reportSample) {
|
||||
return;
|
||||
}
|
||||
|
||||
let enabled = false;
|
||||
|
||||
try {
|
||||
enabled = await api.isPerfDiagEnabled();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
started = true;
|
||||
|
||||
const reporter: PerfDiagReporter = {
|
||||
report: (entry: PerfDiagEntry) => reportSample(entry)
|
||||
};
|
||||
const runSample = (): void => {
|
||||
void runInInjectionContext(injector, async () => {
|
||||
try {
|
||||
const collector = inject(PerfDiagnosticsCollector);
|
||||
|
||||
await publishRendererDiagnosticsSample(reporter, collector);
|
||||
} catch {
|
||||
stopPerfDiagnosticsSampling();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
scheduleSample(runSample);
|
||||
sampleTimer = setInterval(() => scheduleSample(runSample), SAMPLE_INTERVAL_MS);
|
||||
|
||||
window.addEventListener('error', () => {
|
||||
void reporter.report({
|
||||
collectedAt: Date.now(),
|
||||
source: 'renderer',
|
||||
type: 'crash',
|
||||
payload: { scope: 'window-error' }
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', () => {
|
||||
void reporter.report({
|
||||
collectedAt: Date.now(),
|
||||
source: 'renderer',
|
||||
type: 'crash',
|
||||
payload: { scope: 'unhandled-rejection' }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleSample(runSample: () => void): void {
|
||||
const idle = (globalThis as {
|
||||
requestIdleCallback?: (handler: () => void, options?: { timeout: number }) => number;
|
||||
}).requestIdleCallback;
|
||||
|
||||
if (idle) {
|
||||
idle(() => runSample(), { timeout: SAMPLE_INTERVAL_MS });
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(runSample, 0);
|
||||
}
|
||||
|
||||
function stopPerfDiagnosticsSampling(): void {
|
||||
if (sampleTimer) {
|
||||
clearInterval(sampleTimer);
|
||||
sampleTimer = null;
|
||||
}
|
||||
|
||||
started = false;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import {
|
||||
ApplicationRef,
|
||||
inject,
|
||||
Injectable
|
||||
} from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Router } from '@angular/router';
|
||||
import type { AppState } from '../../store';
|
||||
import { mapStoreSliceToDomain } from './domain-mapping.rules';
|
||||
import { estimateStructuredBytes } from './state-size.rules';
|
||||
import { scanComponentTree } from './component-tree.scanner';
|
||||
import type { PerfDiagEntry, PerfDiagReporter } from './diagnostics.models';
|
||||
|
||||
const SAMPLE_BUDGET_MS = 8;
|
||||
|
||||
export interface RendererDiagnosticsSample {
|
||||
storeDomains: Record<string, number>;
|
||||
storeBytes: Record<string, number>;
|
||||
components: Record<string, number>;
|
||||
componentDomains: Record<string, number>;
|
||||
suspectedLeaks: { name: string; count: number; expected: number }[];
|
||||
heap: {
|
||||
usedJsHeapMb: number | null;
|
||||
totalJsHeapMb: number | null;
|
||||
};
|
||||
route: string | null;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PerfDiagnosticsCollector {
|
||||
private readonly appRef = inject(ApplicationRef);
|
||||
private readonly store = inject(Store<AppState>);
|
||||
private readonly router = inject(Router);
|
||||
private disabled = false;
|
||||
private readonly expectedComponentCounts: Record<string, number> = {
|
||||
App: 1
|
||||
};
|
||||
|
||||
collectSample(): RendererDiagnosticsSample | null {
|
||||
if (this.disabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startedAt = performance.now();
|
||||
|
||||
try {
|
||||
const state = this.collectStoreState();
|
||||
const storeBytes: Record<string, number> = {};
|
||||
const storeDomains: Record<string, number> = {};
|
||||
|
||||
for (const [sliceKey, sliceValue] of Object.entries(state)) {
|
||||
const bytes = estimateStructuredBytes(sliceValue, { maxNodes: 200, maxDepth: 5 });
|
||||
const domain = mapStoreSliceToDomain(sliceKey);
|
||||
|
||||
storeBytes[sliceKey] = bytes;
|
||||
storeDomains[domain] = (storeDomains[domain] ?? 0) + bytes;
|
||||
}
|
||||
|
||||
const componentScan = scanComponentTree(this.appRef, this.expectedComponentCounts);
|
||||
const heap = readJsHeapSnapshot();
|
||||
|
||||
return {
|
||||
storeDomains,
|
||||
storeBytes,
|
||||
components: componentScan.components,
|
||||
componentDomains: componentScan.domains,
|
||||
suspectedLeaks: componentScan.suspectedLeaks,
|
||||
heap,
|
||||
route: this.router.url,
|
||||
durationMs: performance.now() - startedAt
|
||||
};
|
||||
} catch {
|
||||
this.disabled = true;
|
||||
return null;
|
||||
} finally {
|
||||
if (performance.now() - startedAt > SAMPLE_BUDGET_MS) {
|
||||
// Skip future samples if this collector cannot stay within budget.
|
||||
this.disabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildEntries(sample: RendererDiagnosticsSample): PerfDiagEntry[] {
|
||||
const collectedAt = Date.now();
|
||||
|
||||
return [
|
||||
{
|
||||
collectedAt,
|
||||
source: 'renderer',
|
||||
type: 'store',
|
||||
payload: {
|
||||
domains: sample.storeDomains,
|
||||
slices: sample.storeBytes,
|
||||
route: sample.route
|
||||
}
|
||||
},
|
||||
{
|
||||
collectedAt,
|
||||
source: 'renderer',
|
||||
type: 'components',
|
||||
payload: {
|
||||
components: sample.components,
|
||||
domains: sample.componentDomains,
|
||||
suspectedLeaks: sample.suspectedLeaks,
|
||||
route: sample.route,
|
||||
durationMs: sample.durationMs
|
||||
}
|
||||
},
|
||||
{
|
||||
collectedAt,
|
||||
source: 'renderer',
|
||||
type: 'heap',
|
||||
payload: {
|
||||
...sample.heap,
|
||||
route: sample.route
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
private collectStoreState(): AppState {
|
||||
let latest: AppState | undefined;
|
||||
|
||||
this.store.subscribe((state) => {
|
||||
latest = state;
|
||||
}).unsubscribe();
|
||||
|
||||
if (!latest) {
|
||||
throw new Error('Store state unavailable');
|
||||
}
|
||||
|
||||
return latest;
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishRendererDiagnosticsSample(
|
||||
reporter: PerfDiagReporter,
|
||||
collector: PerfDiagnosticsCollector
|
||||
): Promise<boolean> {
|
||||
const sample = collector.collectSample();
|
||||
|
||||
if (!sample) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const entries = collector.buildEntries(sample);
|
||||
|
||||
let reported = true;
|
||||
|
||||
for (const entry of entries) {
|
||||
const accepted = await reporter.report(entry);
|
||||
|
||||
if (!accepted) {
|
||||
reported = false;
|
||||
}
|
||||
}
|
||||
|
||||
return reported;
|
||||
}
|
||||
|
||||
function readJsHeapSnapshot(): { usedJsHeapMb: number | null; totalJsHeapMb: number | null } {
|
||||
const memory = (performance as Performance & {
|
||||
memory?: {
|
||||
usedJSHeapSize: number;
|
||||
totalJSHeapSize: number;
|
||||
};
|
||||
}).memory;
|
||||
|
||||
if (!memory) {
|
||||
return {
|
||||
usedJsHeapMb: null,
|
||||
totalJsHeapMb: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
usedJsHeapMb: roundMb(memory.usedJSHeapSize),
|
||||
totalJsHeapMb: roundMb(memory.totalJSHeapSize)
|
||||
};
|
||||
}
|
||||
|
||||
function roundMb(bytes: number): number {
|
||||
return Math.round((bytes / (1024 * 1024)) * 100) / 100;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export type PerfDiagSource = 'main' | 'renderer';
|
||||
|
||||
export type PerfDiagEntryType =
|
||||
| 'session'
|
||||
| 'process'
|
||||
| 'store'
|
||||
| 'components'
|
||||
| 'heap'
|
||||
| 'crash'
|
||||
| 'unresponsive';
|
||||
|
||||
export interface PerfDiagEntry {
|
||||
collectedAt: number;
|
||||
source: PerfDiagSource;
|
||||
type: PerfDiagEntryType;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PerfDiagReporter {
|
||||
report(entry: PerfDiagEntry): Promise<boolean>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect
|
||||
} from 'vitest';
|
||||
import { mapComponentNameToDomain, mapStoreSliceToDomain } from './domain-mapping.rules';
|
||||
|
||||
describe('mapStoreSliceToDomain', () => {
|
||||
it('maps known NgRx slices to domains', () => {
|
||||
expect(mapStoreSliceToDomain('messages')).toBe('chat');
|
||||
expect(mapStoreSliceToDomain('users')).toBe('users');
|
||||
expect(mapStoreSliceToDomain('rooms')).toBe('rooms');
|
||||
});
|
||||
|
||||
it('falls back to the slice name for unknown keys', () => {
|
||||
expect(mapStoreSliceToDomain('custom')).toBe('custom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapComponentNameToDomain', () => {
|
||||
it('maps component class prefixes to domains', () => {
|
||||
expect(mapComponentNameToDomain('VoiceChannelPanelComponent')).toBe('voice-connection');
|
||||
expect(mapComponentNameToDomain('DmChatComponent')).toBe('direct-message');
|
||||
expect(mapComponentNameToDomain('PluginStoreComponent')).toBe('plugins');
|
||||
});
|
||||
|
||||
it('falls back to core for unknown components', () => {
|
||||
expect(mapComponentNameToDomain('App')).toBe('core');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
const STORE_SLICE_DOMAINS: Record<string, string> = {
|
||||
messages: 'chat',
|
||||
users: 'users',
|
||||
rooms: 'rooms'
|
||||
};
|
||||
const COMPONENT_PREFIX_DOMAINS: [string, string][] = [
|
||||
['Voice', 'voice-connection'],
|
||||
['ScreenShare', 'screen-share'],
|
||||
['Dm', 'direct-message'],
|
||||
['DirectCall', 'direct-call'],
|
||||
['DirectMessage', 'direct-message'],
|
||||
['Plugin', 'plugins'],
|
||||
['Chat', 'chat'],
|
||||
['Message', 'chat'],
|
||||
['Server', 'server-directory'],
|
||||
['Room', 'rooms'],
|
||||
['Theme', 'theme'],
|
||||
['Emoji', 'custom-emoji'],
|
||||
['Notification', 'notifications'],
|
||||
['Game', 'game-activity'],
|
||||
['Profile', 'profile-avatar'],
|
||||
['Attachment', 'attachment'],
|
||||
['Auth', 'authentication'],
|
||||
['Login', 'authentication'],
|
||||
['Register', 'authentication']
|
||||
];
|
||||
|
||||
export function mapStoreSliceToDomain(sliceKey: string): string {
|
||||
return STORE_SLICE_DOMAINS[sliceKey] ?? sliceKey;
|
||||
}
|
||||
|
||||
export function mapComponentNameToDomain(componentName: string): string {
|
||||
if (!componentName || componentName === 'App') {
|
||||
return 'core';
|
||||
}
|
||||
|
||||
for (const [prefix, domain] of COMPONENT_PREFIX_DOMAINS) {
|
||||
if (componentName.startsWith(prefix)) {
|
||||
return domain;
|
||||
}
|
||||
}
|
||||
|
||||
if (componentName.endsWith('Component')) {
|
||||
return 'features';
|
||||
}
|
||||
|
||||
return 'core';
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect
|
||||
} from 'vitest';
|
||||
import { estimateStructuredBytes } from './state-size.rules';
|
||||
|
||||
describe('estimateStructuredBytes', () => {
|
||||
it('returns zero for nullish values', () => {
|
||||
expect(estimateStructuredBytes(null)).toBe(0);
|
||||
expect(estimateStructuredBytes(undefined)).toBe(0);
|
||||
});
|
||||
|
||||
it('estimates primitive and shallow object sizes', () => {
|
||||
expect(estimateStructuredBytes('hello')).toBeGreaterThan(0);
|
||||
expect(estimateStructuredBytes({ a: 1, b: 'two' })).toBeGreaterThan(estimateStructuredBytes({ a: 1 }));
|
||||
});
|
||||
|
||||
it('stops walking once the node budget is exhausted', () => {
|
||||
const deep = {
|
||||
level1: {
|
||||
level2: {
|
||||
level3: {
|
||||
payload: 'value'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const full = estimateStructuredBytes(deep, { maxNodes: 50 });
|
||||
const shallow = estimateStructuredBytes(deep, { maxNodes: 2 });
|
||||
|
||||
expect(shallow).toBeLessThan(full);
|
||||
});
|
||||
});
|
||||
147
toju-app/src/app/infrastructure/diagnostics/state-size.rules.ts
Normal file
147
toju-app/src/app/infrastructure/diagnostics/state-size.rules.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
export interface StructuredByteEstimateOptions {
|
||||
maxNodes?: number;
|
||||
maxDepth?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_NODES = 250;
|
||||
const DEFAULT_MAX_DEPTH = 6;
|
||||
|
||||
export function estimateStructuredBytes(
|
||||
value: unknown,
|
||||
options: StructuredByteEstimateOptions = {}
|
||||
): number {
|
||||
const maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES;
|
||||
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
||||
const budget = { remaining: maxNodes };
|
||||
|
||||
return walkValue(value, 0, maxDepth, budget);
|
||||
}
|
||||
|
||||
function walkValue(
|
||||
value: unknown,
|
||||
depth: number,
|
||||
maxDepth: number,
|
||||
budget: { remaining: number }
|
||||
): number {
|
||||
if (budget.remaining <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
budget.remaining -= 1;
|
||||
|
||||
const primitiveBytes = estimatePrimitiveBytes(value);
|
||||
|
||||
if (primitiveBytes != null) {
|
||||
return primitiveBytes;
|
||||
}
|
||||
|
||||
if (depth >= maxDepth) {
|
||||
return 32;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return walkArray(value, depth, maxDepth, budget);
|
||||
}
|
||||
|
||||
if (value instanceof Map || value instanceof Set) {
|
||||
return walkIterable(value, depth, maxDepth, budget);
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return walkObject(value as Record<string, unknown>, depth, maxDepth, budget);
|
||||
}
|
||||
|
||||
return 16;
|
||||
}
|
||||
|
||||
function estimatePrimitiveBytes(value: unknown): number | null {
|
||||
if (value == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value.length * 2;
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return 8;
|
||||
}
|
||||
|
||||
if (typeof value === 'bigint') {
|
||||
return 16;
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return 24;
|
||||
}
|
||||
|
||||
if (ArrayBuffer.isView(value)) {
|
||||
return value.byteLength;
|
||||
}
|
||||
|
||||
if (value instanceof ArrayBuffer) {
|
||||
return value.byteLength;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function walkArray(
|
||||
value: unknown[],
|
||||
depth: number,
|
||||
maxDepth: number,
|
||||
budget: { remaining: number }
|
||||
): number {
|
||||
let total = 16;
|
||||
|
||||
for (const item of value) {
|
||||
if (budget.remaining <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
total += walkValue(item, depth + 1, maxDepth, budget);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
function walkIterable(
|
||||
value: Map<unknown, unknown> | Set<unknown>,
|
||||
depth: number,
|
||||
maxDepth: number,
|
||||
budget: { remaining: number }
|
||||
): number {
|
||||
let total = 32;
|
||||
let walked = 0;
|
||||
|
||||
for (const item of value) {
|
||||
if (budget.remaining <= 0 || walked >= 25) {
|
||||
break;
|
||||
}
|
||||
|
||||
walked += 1;
|
||||
total += walkValue(item, depth + 1, maxDepth, budget);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
function walkObject(
|
||||
value: Record<string, unknown>,
|
||||
depth: number,
|
||||
maxDepth: number,
|
||||
budget: { remaining: number }
|
||||
): number {
|
||||
let total = 16;
|
||||
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
if (budget.remaining <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
total += key.length * 2;
|
||||
total += walkValue(nested, depth + 1, maxDepth, budget);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import '@angular/compiler';
|
||||
import { Injector, runInInjectionContext } from '@angular/core';
|
||||
import {
|
||||
beforeEach,
|
||||
@@ -13,6 +14,21 @@ import { CapacitorDatabaseService } from './capacitor-database.service';
|
||||
import { DatabaseService } from './database.service';
|
||||
import { ElectronDatabaseService } from './electron-database.service';
|
||||
|
||||
function installLocalStorageMock(): void {
|
||||
const store = new Map<string, string>();
|
||||
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => store.set(key, String(value)),
|
||||
removeItem: (key: string) => store.delete(key),
|
||||
clear: () => store.clear(),
|
||||
key: (index: number) => Array.from(store.keys())[index] ?? null,
|
||||
get length() {
|
||||
return store.size;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('DatabaseService', () => {
|
||||
let browserDatabase: {
|
||||
getBansForRoom: ReturnType<typeof vi.fn>;
|
||||
@@ -28,6 +44,9 @@ describe('DatabaseService', () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
installLocalStorageMock();
|
||||
localStorage.clear();
|
||||
|
||||
browserDatabase = {
|
||||
getBansForRoom: vi.fn(() => Promise.resolve([])),
|
||||
initialize: vi.fn(() => Promise.resolve())
|
||||
@@ -69,6 +88,56 @@ describe('DatabaseService', () => {
|
||||
expect(service.isReady()).toBe(true);
|
||||
});
|
||||
|
||||
it('rechecks backend initialization when the user scope changes during an in-flight initialize call', async () => {
|
||||
let finishInitialInitialize!: () => void;
|
||||
|
||||
browserDatabase.initialize = vi.fn()
|
||||
.mockImplementationOnce(() => new Promise<void>((resolve) => {
|
||||
finishInitialInitialize = resolve;
|
||||
}))
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
localStorage.setItem('metoyou_currentUserId', 'user-a');
|
||||
|
||||
const service = createService({ isBrowser: true, isElectron: false, isCapacitor: false });
|
||||
const initialInitialize = service.initialize();
|
||||
|
||||
localStorage.setItem('metoyou_currentUserId', 'user-b');
|
||||
const initializeAfterScopeChange = service.initialize();
|
||||
|
||||
expect(browserDatabase.initialize).toHaveBeenCalledTimes(1);
|
||||
|
||||
finishInitialInitialize();
|
||||
await Promise.all([initialInitialize, initializeAfterScopeChange]);
|
||||
|
||||
expect(browserDatabase.initialize).toHaveBeenCalledTimes(2);
|
||||
expect(service.isReady()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not reinitialize the browser backend for repeated reads in the same user scope', async () => {
|
||||
const service = createService({ isBrowser: true, isElectron: false, isCapacitor: false });
|
||||
|
||||
await service.getBansForRoom('room-1');
|
||||
await service.getBansForRoom('room-2');
|
||||
|
||||
expect(browserDatabase.initialize).toHaveBeenCalledTimes(1);
|
||||
expect(browserDatabase.getBansForRoom).toHaveBeenCalledWith('room-1');
|
||||
expect(browserDatabase.getBansForRoom).toHaveBeenCalledWith('room-2');
|
||||
});
|
||||
|
||||
it('reinitializes the browser backend when the stored user scope changes', async () => {
|
||||
localStorage.setItem('metoyou_currentUserId', 'user-a');
|
||||
|
||||
const service = createService({ isBrowser: true, isElectron: false, isCapacitor: false });
|
||||
|
||||
await service.getBansForRoom('room-1');
|
||||
localStorage.setItem('metoyou_currentUserId', 'user-b');
|
||||
await service.getBansForRoom('room-2');
|
||||
|
||||
expect(browserDatabase.initialize).toHaveBeenCalledTimes(2);
|
||||
expect(browserDatabase.getBansForRoom).toHaveBeenCalledWith('room-2');
|
||||
});
|
||||
|
||||
it('routes Capacitor shells to native SQLite instead of IndexedDB', async () => {
|
||||
const service = createService({ isBrowser: false, isElectron: false, isCapacitor: true });
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../../shared-kernel';
|
||||
import type { ChatAttachmentMeta, CustomEmoji } from '../../shared-kernel';
|
||||
import { PlatformService } from '../../core/platform';
|
||||
import { getStoredCurrentUserId } from '../../core/storage/current-user-storage';
|
||||
import { BrowserDatabaseService } from './browser-database.service';
|
||||
import { CapacitorDatabaseService } from './capacitor-database.service';
|
||||
import { resolveDatabaseBackend } from './database-backend.rules';
|
||||
@@ -42,6 +43,7 @@ export class DatabaseService {
|
||||
private readonly capacitorDb = inject(CapacitorDatabaseService);
|
||||
private readonly electronDb = inject(ElectronDatabaseService);
|
||||
private initializationPromise: Promise<void> | null = null;
|
||||
private validatedUserScope: string | null | undefined;
|
||||
|
||||
/** Reactive flag: `true` once {@link initialize} has completed. */
|
||||
isReady = signal(false);
|
||||
@@ -66,8 +68,15 @@ export class DatabaseService {
|
||||
|
||||
/** Initialise the platform-specific database. */
|
||||
async initialize(): Promise<void> {
|
||||
const userScope = getStoredCurrentUserId();
|
||||
|
||||
if (this.initializationPromise) {
|
||||
await this.initializationPromise;
|
||||
|
||||
if (this.isReady() && this.validatedUserScope === userScope) {
|
||||
return;
|
||||
}
|
||||
} else if (this.isReady() && this.validatedUserScope === userScope) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -75,6 +84,7 @@ export class DatabaseService {
|
||||
|
||||
this.initializationPromise = backend.initialize()
|
||||
.then(() => {
|
||||
this.validatedUserScope = userScope;
|
||||
this.isReady.set(true);
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -85,8 +95,11 @@ export class DatabaseService {
|
||||
}
|
||||
|
||||
private async ensureReady(): Promise<void> {
|
||||
if (this.isReady())
|
||||
const userScope = getStoredCurrentUserId();
|
||||
|
||||
if (this.isReady() && this.validatedUserScope === userScope) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
@@ -164,7 +164,11 @@ The browser also sends a lightweight `keepalive` message on the signaling socket
|
||||
|
||||
### Server-side connection hygiene
|
||||
|
||||
Browsers do not reliably fire WebSocket close events during page refresh or navigation (especially Chromium). The server's `handleIdentify` now closes any existing connection that shares the same `oderId` but a different `connectionId`. This guarantees `findUserByOderId` always routes offers and presence events to the freshest socket, eliminating a class of bugs where signaling messages landed on a dead tab's socket and were silently lost.
|
||||
Browsers do not reliably fire WebSocket close events during page refresh or navigation (especially Chromium). On `identify`, the server evicts stale sockets that share the same `(oderId, connectionScope, clientInstanceId)` tuple so a refreshed tab does not leave a zombie connection behind.
|
||||
|
||||
Multi-device sessions keep **multiple** open connections for the same `oderId` (different `clientInstanceId` values). Server broadcasts exclude only the sending **connection id**, not the whole identity, so chat/typing/voice-state updates reach every logged-in device. Presence `user_joined` / `user_left` broadcasts still exclude the whole identity so other users never see duplicate join/leave events.
|
||||
|
||||
RTC offers/answers/ICE are routed to the connection marked `voiceActive` for the target user (fallback: any open connection). Voice ownership is tracked per connection from `voice_state` payloads that include `clientInstanceId`.
|
||||
|
||||
Join and leave broadcasts are also identity-aware: `handleJoinServer` only broadcasts `user_joined` when the identity is genuinely new to that server (not just a second WebSocket connection for the same user), and `handleLeaveServer` / dead-connection cleanup only broadcast `user_left` when no other open connection for that identity remains in the server. The `user_left` payload includes `serverIds` listing the rooms the identity still belongs to, so the client can subtract correctly without over-removing.
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import { SignalingManager } from './signaling/signaling.manager';
|
||||
import { SignalingTransportHandler } from './signaling/signaling-transport-handler';
|
||||
import { WebRtcStateController } from './state/webrtc-state-controller';
|
||||
import { AuthTokenStoreService } from '../../domains/authentication';
|
||||
import { ClientInstanceService } from '../../core/platform/client-instance.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -51,6 +52,7 @@ export class WebRTCService implements OnDestroy {
|
||||
private readonly screenShareSourcePicker = inject(ScreenShareSourcePickerService);
|
||||
private readonly iceServerSettings = inject(IceServerSettingsService);
|
||||
private readonly authTokenStore = inject(AuthTokenStoreService);
|
||||
private readonly clientInstance = inject(ClientInstanceService);
|
||||
|
||||
private readonly logger = new WebRTCLogger(() => this.debugging.enabled());
|
||||
private readonly state = new WebRtcStateController();
|
||||
@@ -161,7 +163,8 @@ export class WebRTCService implements OnDestroy {
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
},
|
||||
getClientInstanceId: () => this.clientInstance.getClientInstanceId()
|
||||
});
|
||||
|
||||
// Now wire up cross-references (all managers are instantiated)
|
||||
@@ -691,11 +694,17 @@ export class WebRTCService implements OnDestroy {
|
||||
}
|
||||
|
||||
private relayBroadcastEvent(event: ChatEvent): void {
|
||||
const clientInstanceId = this.clientInstance.getClientInstanceId();
|
||||
|
||||
if (event.type === 'chat-message' && event.message?.roomId) {
|
||||
this.signalingTransportHandler.sendRawMessage({
|
||||
type: 'chat_message',
|
||||
serverId: event.message.roomId,
|
||||
message: event.message
|
||||
message: {
|
||||
...event.message,
|
||||
clientInstanceId
|
||||
},
|
||||
clientInstanceId
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -705,11 +714,27 @@ export class WebRTCService implements OnDestroy {
|
||||
this.signalingTransportHandler.sendRawMessage({
|
||||
...event,
|
||||
type: 'voice_state',
|
||||
serverId: event.voiceState.serverId
|
||||
serverId: event.voiceState.serverId,
|
||||
voiceState: {
|
||||
...event.voiceState,
|
||||
clientInstanceId
|
||||
},
|
||||
clientInstanceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
requestVoiceClientTakeover(): void {
|
||||
this.signalingTransportHandler.sendRawMessage({
|
||||
type: 'voice_client_takeover',
|
||||
clientInstanceId: this.clientInstance.getClientInstanceId()
|
||||
});
|
||||
}
|
||||
|
||||
getClientInstanceId(): string {
|
||||
return this.clientInstance.getClientInstanceId();
|
||||
}
|
||||
|
||||
/** Disconnect from the signaling server and clean up all state. */
|
||||
disconnect(): void {
|
||||
this.leaveRoom();
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface IdentifyCredentials {
|
||||
profileUpdatedAt?: number;
|
||||
/** Public signal-server URL where this user registered. */
|
||||
homeSignalServerUrl?: string;
|
||||
/** Stable per-install client id used for multi-device session routing. */
|
||||
clientInstanceId?: string;
|
||||
}
|
||||
|
||||
/** Last-joined server info, used for reconnection. */
|
||||
@@ -76,4 +78,6 @@ export interface VoiceStateSnapshot {
|
||||
roomId?: string;
|
||||
/** The voice channel server ID, if applicable. */
|
||||
serverId?: string;
|
||||
/** Install-scoped client id that owns active voice for this snapshot. */
|
||||
clientInstanceId?: string;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface SignalingTransportHandlerDependencies<TMessage> {
|
||||
logger: WebRTCLogger;
|
||||
getLocalPeerId(): string;
|
||||
resolveSessionToken(signalUrl?: string): string | null;
|
||||
getClientInstanceId(): string;
|
||||
}
|
||||
|
||||
export class SignalingTransportHandler<TMessage> {
|
||||
@@ -201,13 +202,16 @@ export class SignalingTransportHandler<TMessage> {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientInstanceId = this.dependencies.getClientInstanceId();
|
||||
|
||||
this.lastIdentifyCredentials = {
|
||||
oderId,
|
||||
token,
|
||||
displayName: normalizedDisplayName,
|
||||
description: normalizedDescription,
|
||||
profileUpdatedAt: normalizedProfileUpdatedAt,
|
||||
homeSignalServerUrl: normalizedHomeSignalServerUrl
|
||||
homeSignalServerUrl: normalizedHomeSignalServerUrl,
|
||||
clientInstanceId
|
||||
};
|
||||
|
||||
if (signalUrl) {
|
||||
@@ -219,7 +223,8 @@ export class SignalingTransportHandler<TMessage> {
|
||||
description: normalizedDescription,
|
||||
profileUpdatedAt: normalizedProfileUpdatedAt,
|
||||
homeSignalServerUrl: normalizedHomeSignalServerUrl,
|
||||
connectionScope: signalUrl
|
||||
connectionScope: signalUrl,
|
||||
clientInstanceId
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -240,7 +245,8 @@ export class SignalingTransportHandler<TMessage> {
|
||||
description: normalizedDescription,
|
||||
profileUpdatedAt: normalizedProfileUpdatedAt,
|
||||
homeSignalServerUrl: normalizedHomeSignalServerUrl,
|
||||
connectionScope: managerSignalUrl
|
||||
connectionScope: managerSignalUrl,
|
||||
clientInstanceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,7 +379,8 @@ export class SignalingManager {
|
||||
description: credentials.description,
|
||||
profileUpdatedAt: credentials.profileUpdatedAt,
|
||||
homeSignalServerUrl: credentials.homeSignalServerUrl,
|
||||
connectionScope: this.lastSignalingUrl ?? undefined
|
||||
connectionScope: this.lastSignalingUrl ?? undefined,
|
||||
clientInstanceId: credentials.clientInstanceId
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface Message {
|
||||
isDeleted: boolean;
|
||||
replyToId?: string;
|
||||
linkMetadata?: LinkMetadata[];
|
||||
/** Originating client instance when relayed through signaling for multi-device sync. */
|
||||
clientInstanceId?: string;
|
||||
}
|
||||
|
||||
export interface Reaction {
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface VoiceState {
|
||||
volume?: number;
|
||||
roomId?: string;
|
||||
serverId?: string;
|
||||
/** Install-scoped client id that currently owns active voice for this user. */
|
||||
clientInstanceId?: string;
|
||||
}
|
||||
|
||||
export interface ScreenShareState {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
inject,
|
||||
input,
|
||||
@@ -8,6 +11,11 @@ import {
|
||||
import { ThemeNodeDirective } from '../../../domains/theme';
|
||||
import { ViewportService } from '../../../core/platform';
|
||||
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../core/i18n';
|
||||
import {
|
||||
findPrimaryDialogField,
|
||||
shouldSelectOnFocus,
|
||||
shouldSubmitOnEnter
|
||||
} from '../../directives/form-field-focus.rules';
|
||||
import { BottomSheetComponent } from '../bottom-sheet/bottom-sheet.component';
|
||||
import { ModalBackdropComponent } from '../modal-backdrop/modal-backdrop.component';
|
||||
|
||||
@@ -25,7 +33,7 @@ import { ModalBackdropComponent } from '../modal-backdrop/modal-backdrop.compone
|
||||
style: 'display: contents;'
|
||||
}
|
||||
})
|
||||
export class ConfirmDialogComponent {
|
||||
export class ConfirmDialogComponent implements AfterViewInit {
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
title = input.required<string>();
|
||||
@@ -42,4 +50,37 @@ export class ConfirmDialogComponent {
|
||||
onEscape(): void {
|
||||
this.cancelled.emit(undefined);
|
||||
}
|
||||
|
||||
@HostListener('keydown.enter', ['$event'])
|
||||
onEnter(event: Event): void {
|
||||
if (!(event instanceof KeyboardEvent) || !shouldSubmitOnEnter(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.confirmed.emit(undefined);
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
requestAnimationFrame(() => this.focusPrimaryField());
|
||||
}
|
||||
|
||||
private readonly hostRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
|
||||
private focusPrimaryField(): void {
|
||||
const field = findPrimaryDialogField(this.hostRef.nativeElement);
|
||||
|
||||
if (!field) {
|
||||
return;
|
||||
}
|
||||
|
||||
field.focus();
|
||||
|
||||
if (shouldSelectOnFocus({
|
||||
type: field instanceof HTMLInputElement ? field.type : 'textarea',
|
||||
value: field.value
|
||||
})) {
|
||||
field.select();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,10 @@
|
||||
@if (isEditable && activeField === 'displayName') {
|
||||
<input
|
||||
type="text"
|
||||
appAutoFocus
|
||||
appSelectOnFocus
|
||||
appSubmitOnEnter
|
||||
(submitOnEnter)="finishEdit('displayName')"
|
||||
class="w-full rounded-lg border border-border bg-background/70 px-3 py-2 text-center text-lg font-semibold text-foreground outline-none focus:border-primary/70"
|
||||
[value]="displayNameDraft()"
|
||||
(input)="onDisplayNameInput($event)"
|
||||
@@ -141,6 +145,8 @@
|
||||
@if (activeField === 'description') {
|
||||
<textarea
|
||||
rows="3"
|
||||
appAutoFocus
|
||||
appSelectOnFocus
|
||||
class="w-full resize-none rounded-lg border border-border bg-background/70 px-3 py-2 text-sm leading-5 text-foreground outline-none focus:border-primary/70"
|
||||
[value]="descriptionDraft()"
|
||||
[placeholder]="'profile.card.addDescription' | translate"
|
||||
|
||||
@@ -44,6 +44,11 @@ import {
|
||||
import { ServerDirectoryFacade } from '../../../domains/server-directory';
|
||||
import { resolveUserHomeSignalServerTag } from '../../../domains/server-directory/domain/logic/signal-server-tag.rules';
|
||||
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../core/i18n';
|
||||
import {
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective
|
||||
} from '../../directives';
|
||||
|
||||
@Component({
|
||||
selector: 'app-profile-card-mobile',
|
||||
@@ -54,6 +59,9 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../core/i18n';
|
||||
UserAvatarComponent,
|
||||
ProfileSignalServerTagComponent,
|
||||
ThemeNodeDirective,
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
|
||||
@@ -50,6 +50,10 @@
|
||||
@if (activeField === 'displayName') {
|
||||
<input
|
||||
type="text"
|
||||
appAutoFocus
|
||||
appSelectOnFocus
|
||||
appSubmitOnEnter
|
||||
(submitOnEnter)="finishEdit('displayName')"
|
||||
class="w-full rounded-md border border-border bg-background/70 px-2 py-1.5 text-base font-semibold text-foreground outline-none focus:border-primary/70"
|
||||
[value]="displayNameDraft()"
|
||||
(input)="onDisplayNameInput($event)"
|
||||
@@ -95,6 +99,8 @@
|
||||
@if (activeField === 'description') {
|
||||
<textarea
|
||||
rows="3"
|
||||
appAutoFocus
|
||||
appSelectOnFocus
|
||||
class="w-full resize-none rounded-md border border-border bg-background/70 px-2 py-2 text-sm leading-5 text-foreground outline-none focus:border-primary/70"
|
||||
[value]="descriptionDraft()"
|
||||
placeholder="{{ 'profile.card.addDescription' | translate }}"
|
||||
|
||||
@@ -36,6 +36,11 @@ import { ThemeNodeDirective } from '../../../domains/theme';
|
||||
import { formatGameActivityElapsed } from '../../../domains/game-activity';
|
||||
import { ExternalLinkService } from '../../../core/platform/external-link.service';
|
||||
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../core/i18n';
|
||||
import {
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective
|
||||
} from '../../directives';
|
||||
import { visibilityAwareInterval$ } from '../../rxjs';
|
||||
import { ServerDirectoryFacade } from '../../../domains/server-directory';
|
||||
import { resolveUserHomeSignalServerTag } from '../../../domains/server-directory/domain/logic/signal-server-tag.rules';
|
||||
@@ -49,6 +54,9 @@ import { resolveUserHomeSignalServerTag } from '../../../domains/server-director
|
||||
UserAvatarComponent,
|
||||
ProfileSignalServerTagComponent,
|
||||
ThemeNodeDirective,
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideCheck, lucideChevronDown, lucideGamepad2 })],
|
||||
|
||||
23
toju-app/src/app/shared/directives/auto-focus.directive.ts
Normal file
23
toju-app/src/app/shared/directives/auto-focus.directive.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
Directive,
|
||||
ElementRef,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
|
||||
/**
|
||||
* Focuses the host input, textarea, or select after the view initializes.
|
||||
*/
|
||||
@Directive({
|
||||
selector: 'input[appAutoFocus], textarea[appAutoFocus], select[appAutoFocus]',
|
||||
standalone: true
|
||||
})
|
||||
export class AutoFocusDirective implements AfterViewInit {
|
||||
private readonly hostRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
requestAnimationFrame(() => {
|
||||
this.hostRef.nativeElement.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { shouldSelectOnFocus, shouldSubmitOnEnter } from './form-field-focus.rules';
|
||||
|
||||
describe('shouldSelectOnFocus', () => {
|
||||
it('selects when a text input has a value', () => {
|
||||
expect(shouldSelectOnFocus({ type: 'text',
|
||||
value: 'alice' })).toBe(true);
|
||||
});
|
||||
|
||||
it('skips empty values', () => {
|
||||
expect(shouldSelectOnFocus({ type: 'text',
|
||||
value: '' })).toBe(false);
|
||||
});
|
||||
|
||||
it('skips password fields by default', () => {
|
||||
expect(shouldSelectOnFocus({ type: 'password',
|
||||
value: 'secret' })).toBe(false);
|
||||
});
|
||||
|
||||
it('can select password fields when skipPassword is false', () => {
|
||||
expect(shouldSelectOnFocus({ type: 'password',
|
||||
value: 'secret' }, false)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldSubmitOnEnter', () => {
|
||||
it('submits from text inputs', () => {
|
||||
expect(shouldSubmitOnEnter({
|
||||
key: 'Enter',
|
||||
isComposing: false,
|
||||
target: { tagName: 'INPUT',
|
||||
type: 'text' }
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('does not submit from textareas', () => {
|
||||
expect(shouldSubmitOnEnter({
|
||||
key: 'Enter',
|
||||
isComposing: false,
|
||||
target: { tagName: 'TEXTAREA' }
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('does not submit from selects', () => {
|
||||
expect(shouldSubmitOnEnter({
|
||||
key: 'Enter',
|
||||
isComposing: false,
|
||||
target: { tagName: 'SELECT' }
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores IME composition', () => {
|
||||
expect(shouldSubmitOnEnter({
|
||||
key: 'Enter',
|
||||
isComposing: true,
|
||||
target: { tagName: 'INPUT',
|
||||
type: 'text' }
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
97
toju-app/src/app/shared/directives/form-field-focus.rules.ts
Normal file
97
toju-app/src/app/shared/directives/form-field-focus.rules.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
const NON_SUBMITTABLE_INPUT_TYPES = new Set([
|
||||
'button',
|
||||
'checkbox',
|
||||
'file',
|
||||
'hidden',
|
||||
'image',
|
||||
'radio',
|
||||
'reset',
|
||||
'submit'
|
||||
]);
|
||||
|
||||
interface SelectableField {
|
||||
type?: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface EnterTarget {
|
||||
tagName: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an input/textarea should have its value selected when focused.
|
||||
* Password fields are skipped by default so users can append characters safely.
|
||||
*/
|
||||
export function shouldSelectOnFocus(
|
||||
element: SelectableField,
|
||||
skipPassword = true
|
||||
): boolean {
|
||||
const type = element.type?.toLowerCase() ?? 'text';
|
||||
|
||||
if (skipPassword && type === 'password') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return element.value.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the first editable text field inside a dialog or form container.
|
||||
*/
|
||||
export function findPrimaryDialogField(
|
||||
root: ParentNode
|
||||
): HTMLInputElement | HTMLTextAreaElement | null {
|
||||
const input = root.querySelector(
|
||||
'input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]):not([disabled])'
|
||||
);
|
||||
|
||||
if (input instanceof HTMLInputElement) {
|
||||
return input;
|
||||
}
|
||||
|
||||
const textarea = root.querySelector('textarea:not([disabled])');
|
||||
|
||||
return textarea instanceof HTMLTextAreaElement ? textarea : null;
|
||||
}
|
||||
|
||||
function readEnterTarget(target: EventTarget | null): EnterTarget | null {
|
||||
if (!target || typeof target !== 'object' || !('tagName' in target)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = target as EnterTarget;
|
||||
|
||||
return {
|
||||
tagName: String(candidate.tagName).toUpperCase(),
|
||||
type: typeof candidate.type === 'string' ? candidate.type : undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Enter in the current event target should trigger a form/dialog submit.
|
||||
* Textareas and selects are excluded; IME composition is ignored.
|
||||
*/
|
||||
export function shouldSubmitOnEnter(event: Pick<KeyboardEvent, 'key' | 'isComposing'> & {
|
||||
target: EventTarget | null;
|
||||
}): boolean {
|
||||
if (event.isComposing || event.key !== 'Enter') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const target = readEnterTarget(event.target);
|
||||
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (target.tagName === 'INPUT') {
|
||||
return !NON_SUBMITTABLE_INPUT_TYPES.has((target.type ?? 'text').toLowerCase());
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
8
toju-app/src/app/shared/directives/index.ts
Normal file
8
toju-app/src/app/shared/directives/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { AutoFocusDirective } from './auto-focus.directive';
|
||||
export { SelectOnFocusDirective } from './select-on-focus.directive';
|
||||
export { SubmitOnEnterDirective } from './submit-on-enter.directive';
|
||||
export {
|
||||
findPrimaryDialogField,
|
||||
shouldSelectOnFocus,
|
||||
shouldSubmitOnEnter
|
||||
} from './form-field-focus.rules';
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
Directive,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
inject,
|
||||
input
|
||||
} from '@angular/core';
|
||||
|
||||
import { shouldSelectOnFocus } from './form-field-focus.rules';
|
||||
|
||||
/**
|
||||
* Selects the full field value when the user focuses a text input or textarea.
|
||||
*/
|
||||
@Directive({
|
||||
selector: 'input[appSelectOnFocus], textarea[appSelectOnFocus]',
|
||||
standalone: true
|
||||
})
|
||||
export class SelectOnFocusDirective {
|
||||
readonly skipPassword = input(true);
|
||||
|
||||
private readonly hostRef = inject<ElementRef<HTMLInputElement | HTMLTextAreaElement>>(ElementRef);
|
||||
|
||||
@HostListener('focus')
|
||||
onFocus(): void {
|
||||
const element = this.hostRef.nativeElement;
|
||||
|
||||
if (!shouldSelectOnFocus({
|
||||
type: element.type,
|
||||
value: element.value
|
||||
}, this.skipPassword())) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.select();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
Directive,
|
||||
HostListener,
|
||||
output
|
||||
} from '@angular/core';
|
||||
|
||||
import { shouldSubmitOnEnter } from './form-field-focus.rules';
|
||||
|
||||
/**
|
||||
* Emits when Enter is pressed in a submittable input field.
|
||||
* Use on single-field actions or alongside native `<form ngSubmit>`.
|
||||
*/
|
||||
@Directive({
|
||||
selector: 'input[appSubmitOnEnter], select[appSubmitOnEnter]',
|
||||
standalone: true
|
||||
})
|
||||
export class SubmitOnEnterDirective {
|
||||
readonly submitOnEnter = output();
|
||||
|
||||
@HostListener('keydown.enter', ['$event'])
|
||||
onEnter(event: Event): void {
|
||||
if (!(event instanceof KeyboardEvent) || !shouldSubmitOnEnter(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.submitOnEnter.emit();
|
||||
}
|
||||
}
|
||||
@@ -22,3 +22,8 @@ export {
|
||||
SkeletonMessageComponent,
|
||||
SkeletonCardComponent
|
||||
} from './components/skeleton';
|
||||
export {
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective
|
||||
} from './directives';
|
||||
|
||||
@@ -34,10 +34,43 @@ function createContext(overrides: Record<string, unknown> = {}) {
|
||||
currentUser: null,
|
||||
currentRoom: null,
|
||||
savedRooms: [],
|
||||
getClientInstanceId: () => 'device-a',
|
||||
...overrides
|
||||
} as const;
|
||||
}
|
||||
|
||||
describe('dispatchIncomingMessage multi-device sync', () => {
|
||||
it('accepts own messages that originated on another client instance', async () => {
|
||||
const saveMessage = vi.fn(async () => undefined);
|
||||
const rememberMessageRoom = vi.fn();
|
||||
const context = createContext({
|
||||
db: { saveMessage },
|
||||
attachments: { rememberMessageRoom },
|
||||
currentUser: { id: 'user-1', oderId: 'user-1' },
|
||||
currentRoom: { id: 'room-a' },
|
||||
savedRooms: [{ id: 'room-a' }],
|
||||
getClientInstanceId: () => 'device-a'
|
||||
});
|
||||
|
||||
const action = await firstValueFrom(
|
||||
dispatchIncomingMessage(
|
||||
{
|
||||
type: 'chat-message',
|
||||
message: createMessage({
|
||||
senderId: 'user-1',
|
||||
roomId: 'room-a',
|
||||
clientInstanceId: 'device-b'
|
||||
})
|
||||
} as never,
|
||||
context as never
|
||||
).pipe(defaultIfEmpty(null))
|
||||
);
|
||||
|
||||
expect(action).not.toBeNull();
|
||||
expect(saveMessage).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispatchIncomingMessage room-scoped sync', () => {
|
||||
it('requests sync for event room even when another room is viewed', async () => {
|
||||
const getRoomMessageStats = vi.fn(async (roomId: string) => roomId === 'room-b'
|
||||
|
||||
@@ -102,6 +102,7 @@ export interface IncomingMessageContext {
|
||||
currentUser: User | null;
|
||||
currentRoom: Room | null;
|
||||
savedRooms?: Room[];
|
||||
getClientInstanceId: () => string;
|
||||
}
|
||||
|
||||
/** Signature for an incoming-message handler function. */
|
||||
@@ -362,12 +363,12 @@ function handleChatMessage(
|
||||
if (!isKnownRoomId(msg.roomId, ctx))
|
||||
return EMPTY;
|
||||
|
||||
// Skip our own messages (reflected via server relay)
|
||||
const isOwnMessage =
|
||||
msg.senderId === currentUser?.id ||
|
||||
msg.senderId === currentUser?.oderId;
|
||||
// Skip only messages that originated on this client instance.
|
||||
const isOwnMessageOnThisClient =
|
||||
(msg.senderId === currentUser?.id || msg.senderId === currentUser?.oderId)
|
||||
&& (!msg.clientInstanceId || msg.clientInstanceId === ctx.getClientInstanceId());
|
||||
|
||||
if (isOwnMessage)
|
||||
if (isOwnMessageOnThisClient)
|
||||
return EMPTY;
|
||||
|
||||
attachments.rememberMessageRoom(msg.id, msg.roomId);
|
||||
|
||||
@@ -46,7 +46,6 @@ import { TimeSyncService } from '../../core/services/time-sync.service';
|
||||
import { PlatformService } from '../../core/platform';
|
||||
import { AppI18nService } from '../../core/i18n';
|
||||
import {
|
||||
DELETED_MESSAGE_CONTENT,
|
||||
Message,
|
||||
Reaction,
|
||||
Room
|
||||
@@ -57,6 +56,7 @@ import { resolveRoomPermission } from '../../domains/access-control';
|
||||
import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers';
|
||||
import { MessageRevisionService } from '../../domains/chat/application/services/message-revision.service';
|
||||
import { materializeMessageFromRevision } from '../../domains/chat/domain/rules/message-revision.builder.rules';
|
||||
import { setStoredCurrentUserId } from '../../core/storage/current-user-storage';
|
||||
|
||||
const INITIAL_ROOM_MESSAGE_LIMIT = 30;
|
||||
/** Cap on simultaneous browser-cache prefetches for apps with many saved rooms. */
|
||||
@@ -261,24 +261,11 @@ export class MessagesEffects {
|
||||
});
|
||||
const message = materializeMessageFromRevision(null, revision);
|
||||
|
||||
setStoredCurrentUserId(currentUser.id);
|
||||
this.attachments.rememberMessageRoom(message.id, message.roomId);
|
||||
|
||||
this.trackBackgroundOperation(
|
||||
this.db.saveMessage(message),
|
||||
'Failed to persist outgoing chat message',
|
||||
{
|
||||
channelId: message.channelId,
|
||||
contentLength: message.content.length,
|
||||
messageId: message.id,
|
||||
roomId: message.roomId
|
||||
}
|
||||
);
|
||||
|
||||
this.trackBackgroundOperation(
|
||||
this.messageRevisions.persistRevision(revision),
|
||||
'Failed to persist outgoing message revision',
|
||||
{ messageId: message.id, revision: revision.revision }
|
||||
);
|
||||
await this.db.saveMessage(message);
|
||||
await this.messageRevisions.persistRevision(revision);
|
||||
|
||||
this.customEmoji.pushEmojisInContent(content);
|
||||
this.webrtc.broadcastMessage({ type: 'chat-message', message });
|
||||
@@ -482,6 +469,7 @@ export class MessagesEffects {
|
||||
deletedBy: currentUser.id,
|
||||
deletedAt
|
||||
});
|
||||
|
||||
this.messageRevisions.broadcastRevision(revision);
|
||||
|
||||
return MessagesActions.deleteMessageSuccess({ messageId });
|
||||
@@ -667,7 +655,8 @@ export class MessagesEffects {
|
||||
messageRevisions: this.messageRevisions,
|
||||
currentUser: currentUser ?? null,
|
||||
currentRoom,
|
||||
savedRooms
|
||||
savedRooms,
|
||||
getClientInstanceId: () => this.webrtc.getClientInstanceId()
|
||||
};
|
||||
|
||||
return dispatchIncomingMessage(event as Parameters<typeof dispatchIncomingMessage>[0], ctx).pipe(
|
||||
@@ -712,6 +701,19 @@ export class MessagesEffects {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
const chatRelayEvent = event as {
|
||||
message?: Message & { clientInstanceId?: string };
|
||||
clientInstanceId?: string;
|
||||
fromUserId?: string;
|
||||
senderId?: string;
|
||||
serverId?: string;
|
||||
};
|
||||
const signalingMessage = chatRelayEvent.message && typeof chatRelayEvent.message === 'object'
|
||||
? {
|
||||
...chatRelayEvent.message,
|
||||
clientInstanceId: chatRelayEvent.message.clientInstanceId ?? chatRelayEvent.clientInstanceId
|
||||
}
|
||||
: chatRelayEvent.message;
|
||||
const ctx: IncomingMessageContext = {
|
||||
db: this.db,
|
||||
webrtc: this.webrtc,
|
||||
@@ -720,13 +722,15 @@ export class MessagesEffects {
|
||||
messageRevisions: this.messageRevisions,
|
||||
currentUser: currentUser ?? null,
|
||||
currentRoom,
|
||||
savedRooms
|
||||
savedRooms,
|
||||
getClientInstanceId: () => this.webrtc.getClientInstanceId()
|
||||
};
|
||||
|
||||
return dispatchIncomingMessage({
|
||||
...event,
|
||||
type: 'chat-message',
|
||||
fromPeerId: event.fromUserId
|
||||
fromPeerId: event.fromUserId ?? (event as { senderId?: string }).senderId,
|
||||
message: signalingMessage
|
||||
}, ctx).pipe(
|
||||
catchError((error) => {
|
||||
reportDebuggingError(this.debugging, 'messages', 'Failed to process incoming signaling chat message', {
|
||||
|
||||
@@ -42,7 +42,9 @@ import type {
|
||||
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
|
||||
import { hasRoomBanForUser } from '../../domains/access-control';
|
||||
import { RECONNECT_SOUND_GRACE_MS } from '../../core/constants';
|
||||
import { VoiceSessionFacade } from '../../domains/voice-session';
|
||||
import { VoiceSessionFacade, VoiceClientTakeoverService } from '../../domains/voice-session';
|
||||
import { ClientInstanceService } from '../../core/platform/client-instance.service';
|
||||
import { isVoiceOnAnotherClient } from '../../domains/voice-session/domain/logic/client-voice-session.rules';
|
||||
import {
|
||||
buildSignalingUser,
|
||||
buildKnownUserExtras,
|
||||
@@ -76,6 +78,8 @@ export class RoomStateSyncEffects {
|
||||
private db = inject(DatabaseService);
|
||||
private audioService = inject(NotificationAudioService);
|
||||
private voiceSessionService = inject(VoiceSessionFacade);
|
||||
private voiceClientTakeoverService = inject(VoiceClientTakeoverService);
|
||||
private clientInstanceService = inject(ClientInstanceService);
|
||||
|
||||
/**
|
||||
* Tracks user IDs we already know are in voice. Lives outside the
|
||||
@@ -241,12 +245,18 @@ export class RoomStateSyncEffects {
|
||||
const voiceEvent = {
|
||||
...signalingMessage,
|
||||
type: 'voice-state',
|
||||
fromPeerId: signalingMessage.oderId ?? signalingMessage.fromUserId
|
||||
fromPeerId: signalingMessage.oderId ?? signalingMessage.fromUserId,
|
||||
voiceState: this.normalizeSignalingVoiceState(signalingMessage)
|
||||
} as ChatEvent;
|
||||
|
||||
return this.handleVoiceOrScreenState(voiceEvent, allUsers, currentUser ?? null, 'voice');
|
||||
}
|
||||
|
||||
case 'voice_client_takeover': {
|
||||
this.voiceClientTakeoverService.releaseLocalVoiceForTakeover(currentUser ?? null);
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
case 'access_denied': {
|
||||
if (isWrongServer(signalingMessage.serverId, viewedServerId))
|
||||
return EMPTY;
|
||||
@@ -479,7 +489,10 @@ export class RoomStateSyncEffects {
|
||||
return EMPTY;
|
||||
|
||||
const existingUser = allUsers.find((user) => user.id === userId || user.oderId === userId);
|
||||
const resolvedUserId = existingUser?.id ?? userId;
|
||||
const userExists = !!existingUser;
|
||||
const localClientInstanceId = this.clientInstanceService.getClientInstanceId();
|
||||
const isCurrentUserEvent = !!currentUser && (currentUser.id === userId || currentUser.oderId === userId);
|
||||
|
||||
if (kind === 'voice') {
|
||||
const vs = event.voiceState as Partial<VoiceState> | undefined;
|
||||
@@ -503,8 +516,14 @@ export class RoomStateSyncEffects {
|
||||
const wasInCurrentVoiceRoom = this.isSameVoiceRoom(existingUser?.voiceState, currentUser?.voiceState);
|
||||
const mergedVoiceState = { ...existingUser?.voiceState, ...vs };
|
||||
const isInCurrentVoiceRoom = this.isSameVoiceRoom(mergedVoiceState, currentUser?.voiceState);
|
||||
const skipSelfPassiveSounds = isCurrentUserEvent
|
||||
&& (isVoiceOnAnotherClient(
|
||||
{ isConnected: mergedVoiceState.isConnected ?? false, clientInstanceId: mergedVoiceState.clientInstanceId },
|
||||
localClientInstanceId
|
||||
)
|
||||
|| isVoiceOnAnotherClient(existingUser?.voiceState, localClientInstanceId));
|
||||
|
||||
if (weAreInVoice) {
|
||||
if (weAreInVoice && !skipSelfPassiveSounds) {
|
||||
const isReconnect = this.consumeRecentLeave(userId);
|
||||
|
||||
if (!isReconnect) {
|
||||
@@ -537,7 +556,8 @@ export class RoomStateSyncEffects {
|
||||
isMutedByAdmin: vs.isMutedByAdmin,
|
||||
volume: vs.volume,
|
||||
roomId: vs.roomId,
|
||||
serverId: vs.serverId
|
||||
serverId: vs.serverId,
|
||||
clientInstanceId: vs.clientInstanceId
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -551,7 +571,7 @@ export class RoomStateSyncEffects {
|
||||
actions.push(presenceRefreshAction);
|
||||
}
|
||||
|
||||
actions.push(UsersActions.updateVoiceState({ userId, voiceState: vs }));
|
||||
actions.push(UsersActions.updateVoiceState({ userId: resolvedUserId, voiceState: vs }));
|
||||
|
||||
return actions;
|
||||
}
|
||||
@@ -953,6 +973,26 @@ export class RoomStateSyncEffects {
|
||||
|
||||
// ── Internal helpers ───────────────────────────────────────────
|
||||
|
||||
private normalizeSignalingVoiceState(signalingMessage: RoomPresenceSignalingMessage): Partial<VoiceState> {
|
||||
const nested = signalingMessage.voiceState;
|
||||
|
||||
if (nested && typeof nested === 'object') {
|
||||
return nested as Partial<VoiceState>;
|
||||
}
|
||||
|
||||
return {
|
||||
isConnected: signalingMessage.isConnected === true,
|
||||
isMuted: signalingMessage.isMuted === true,
|
||||
isDeafened: signalingMessage.isDeafened === true,
|
||||
isSpeaking: signalingMessage.isSpeaking === true,
|
||||
roomId: typeof signalingMessage.roomId === 'string' ? signalingMessage.roomId : undefined,
|
||||
serverId: typeof signalingMessage.serverId === 'string' ? signalingMessage.serverId : undefined,
|
||||
clientInstanceId: typeof signalingMessage.clientInstanceId === 'string'
|
||||
? signalingMessage.clientInstanceId
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
|
||||
private syncBansToLocalRoom(roomId: string, bans: BanEntry[]) {
|
||||
return from(this.db.getBansForRoom(roomId)).pipe(
|
||||
switchMap((localBans) => {
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
loadLastViewedChatFromStorage,
|
||||
saveLastViewedChatToStorage
|
||||
} from '../../infrastructure/persistence';
|
||||
import { setStoredCurrentUserId } from '../../core/storage/current-user-storage';
|
||||
import { AppI18nService } from '../../core/i18n';
|
||||
import { ServerDirectoryFacade } from '../../domains/server-directory';
|
||||
import { hasRoomBanForUser } from '../../domains/access-control';
|
||||
@@ -219,6 +220,9 @@ export class RoomsEffects {
|
||||
?? allEndpoints[0]
|
||||
?? null;
|
||||
const normalizedPassword = typeof password === 'string' ? password.trim() : '';
|
||||
|
||||
setStoredCurrentUserId(currentUser.id);
|
||||
|
||||
const room: Room = {
|
||||
id: uuidv4(),
|
||||
name,
|
||||
@@ -237,35 +241,36 @@ export class RoomsEffects {
|
||||
sourceUrl: endpoint?.url
|
||||
};
|
||||
|
||||
// Save to local DB
|
||||
this.db.saveRoom(room);
|
||||
return from(this.db.saveRoom(room)).pipe(
|
||||
map(() => {
|
||||
// Register with central server (using the same room ID for discoverability)
|
||||
this.serverDirectory
|
||||
.registerServer({
|
||||
id: room.id, // Use the same ID as the local room
|
||||
name: room.name,
|
||||
description: room.description,
|
||||
ownerId: currentUser.id,
|
||||
ownerPublicKey: currentUser.oderId,
|
||||
hostName: currentUser.displayName,
|
||||
password: normalizedPassword || null,
|
||||
hasPassword: normalizedPassword.length > 0,
|
||||
isPrivate: room.isPrivate,
|
||||
userCount: room.userCount,
|
||||
maxUsers: room.maxUsers || 50,
|
||||
icon: room.icon,
|
||||
iconUpdatedAt: room.iconUpdatedAt,
|
||||
tags: [],
|
||||
channels: room.channels ?? defaultChannels()
|
||||
}, endpoint ? {
|
||||
sourceId: endpoint.id,
|
||||
sourceUrl: endpoint.url
|
||||
} : undefined
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
// Register with central server (using the same room ID for discoverability)
|
||||
this.serverDirectory
|
||||
.registerServer({
|
||||
id: room.id, // Use the same ID as the local room
|
||||
name: room.name,
|
||||
description: room.description,
|
||||
ownerId: currentUser.id,
|
||||
ownerPublicKey: currentUser.oderId,
|
||||
hostName: currentUser.displayName,
|
||||
password: normalizedPassword || null,
|
||||
hasPassword: normalizedPassword.length > 0,
|
||||
isPrivate: room.isPrivate,
|
||||
userCount: 1,
|
||||
maxUsers: room.maxUsers || 50,
|
||||
icon: room.icon,
|
||||
iconUpdatedAt: room.iconUpdatedAt,
|
||||
tags: [],
|
||||
channels: room.channels ?? defaultChannels()
|
||||
}, endpoint ? {
|
||||
sourceId: endpoint.id,
|
||||
sourceUrl: endpoint.url
|
||||
} : undefined
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return of(RoomsActions.createRoomSuccess({ room }));
|
||||
return RoomsActions.createRoomSuccess({ room });
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((error) => of(RoomsActions.createRoomFailure({ error: error.message })))
|
||||
)
|
||||
@@ -303,6 +308,8 @@ export class RoomsEffects {
|
||||
: undefined;
|
||||
|
||||
if (room) {
|
||||
setStoredCurrentUserId(currentUser.id);
|
||||
|
||||
const resolvedSource = this.serverDirectory.normaliseRoomSignalSource({
|
||||
sourceId: serverInfo?.sourceId ?? room.sourceId,
|
||||
sourceName: serverInfo?.sourceName ?? room.sourceName,
|
||||
@@ -329,7 +336,7 @@ export class RoomsEffects {
|
||||
: (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password)
|
||||
};
|
||||
|
||||
this.db.updateRoom(room.id, {
|
||||
return from(this.db.updateRoom(room.id, {
|
||||
sourceId: resolvedRoom.sourceId,
|
||||
sourceName: resolvedRoom.sourceName,
|
||||
sourceUrl: resolvedRoom.sourceUrl,
|
||||
@@ -342,13 +349,13 @@ export class RoomsEffects {
|
||||
iconUpdatedAt: resolvedRoom.iconUpdatedAt,
|
||||
hasPassword: resolvedRoom.hasPassword,
|
||||
isPrivate: resolvedRoom.isPrivate
|
||||
});
|
||||
|
||||
return of(RoomsActions.joinRoomSuccess({ room: resolvedRoom }));
|
||||
})).pipe(map(() => RoomsActions.joinRoomSuccess({ room: resolvedRoom })));
|
||||
}
|
||||
|
||||
// If not in local DB but we have server info from search, create a room entry
|
||||
if (serverInfo) {
|
||||
setStoredCurrentUserId(currentUser.id);
|
||||
|
||||
const resolvedSource = this.serverDirectory.normaliseRoomSignalSource({
|
||||
sourceId: serverInfo.sourceId,
|
||||
sourceName: serverInfo.sourceName,
|
||||
@@ -378,15 +385,17 @@ export class RoomsEffects {
|
||||
...resolvedSource
|
||||
};
|
||||
|
||||
// Save to local DB for future reference
|
||||
this.db.saveRoom(newRoom);
|
||||
return of(RoomsActions.joinRoomSuccess({ room: newRoom }));
|
||||
return from(this.db.saveRoom(newRoom)).pipe(
|
||||
map(() => RoomsActions.joinRoomSuccess({ room: newRoom }))
|
||||
);
|
||||
}
|
||||
|
||||
// Try to get room info from server
|
||||
return this.serverDirectory.getServer(roomId, sourceSelector).pipe(
|
||||
switchMap((serverData) => {
|
||||
if (serverData) {
|
||||
setStoredCurrentUserId(currentUser.id);
|
||||
|
||||
const resolvedSource = this.serverDirectory.normaliseRoomSignalSource({
|
||||
sourceId: serverData.sourceId,
|
||||
sourceName: serverData.sourceName,
|
||||
@@ -415,8 +424,9 @@ export class RoomsEffects {
|
||||
...resolvedSource
|
||||
};
|
||||
|
||||
this.db.saveRoom(newRoom);
|
||||
return of(RoomsActions.joinRoomSuccess({ room: newRoom }));
|
||||
return from(this.db.saveRoom(newRoom)).pipe(
|
||||
map(() => RoomsActions.joinRoomSuccess({ room: newRoom }))
|
||||
);
|
||||
}
|
||||
|
||||
return of(RoomsActions.joinRoomFailure({ error: this.i18n.instant('servers.errors.roomNotFound') }));
|
||||
|
||||
@@ -225,4 +225,19 @@ export interface RoomPresenceSignalingMessage {
|
||||
profileUpdatedAt?: number;
|
||||
homeSignalServerUrl?: string;
|
||||
status?: string;
|
||||
voiceState?: {
|
||||
isConnected?: boolean;
|
||||
isMuted?: boolean;
|
||||
isDeafened?: boolean;
|
||||
isSpeaking?: boolean;
|
||||
roomId?: string;
|
||||
serverId?: string;
|
||||
clientInstanceId?: string;
|
||||
};
|
||||
isConnected?: boolean;
|
||||
isMuted?: boolean;
|
||||
isDeafened?: boolean;
|
||||
isSpeaking?: boolean;
|
||||
roomId?: string;
|
||||
clientInstanceId?: string;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ import { findRoomMember, removeRoomMember } from '../rooms/room-members.helpers'
|
||||
import { AppI18nService } from '../../core/i18n';
|
||||
import { AuthTokenStoreService } from '../../domains/authentication/application/services/auth-token-store.service';
|
||||
import { hasValidPersistedSession, SESSION_EXPIRED_ERROR_CODE } from '../../domains/authentication/domain/logic/auth-session.rules';
|
||||
import { buildLoginReturnQueryParams } from '../../domains/authentication/domain/logic/auth-navigation.rules';
|
||||
|
||||
type IncomingModerationExtraAction =
|
||||
| ReturnType<typeof RoomsActions.forgetRoom>
|
||||
@@ -80,7 +81,7 @@ export class UsersEffects {
|
||||
this.actions$.pipe(
|
||||
ofType(UsersActions.authenticateUser),
|
||||
switchMap(({ user }) =>
|
||||
from(this.prepareAuthenticatedUserStorage(user.id)).pipe(
|
||||
from(this.prepareAuthenticatedUserStorage(user)).pipe(
|
||||
mergeMap(() => [
|
||||
MessagesActions.clearMessages(),
|
||||
UsersActions.resetUsersState(),
|
||||
@@ -165,10 +166,11 @@ export class UsersEffects {
|
||||
};
|
||||
}
|
||||
|
||||
private async prepareAuthenticatedUserStorage(userId: string): Promise<void> {
|
||||
setStoredCurrentUserId(userId);
|
||||
private async prepareAuthenticatedUserStorage(user: User): Promise<void> {
|
||||
setStoredCurrentUserId(user.id);
|
||||
await this.db.initialize();
|
||||
await this.db.setCurrentUserId(userId);
|
||||
await this.db.setCurrentUserId(user.id);
|
||||
await this.db.saveUser(user);
|
||||
}
|
||||
|
||||
/** Loads all users associated with a specific room from the local database. */
|
||||
@@ -476,6 +478,7 @@ export class UsersEffects {
|
||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||
tap(([, user]) => {
|
||||
if (user) {
|
||||
setStoredCurrentUserId(user.id);
|
||||
this.db.saveUser(user);
|
||||
// Ensure current user ID is persisted when explicitly set
|
||||
this.db.setCurrentUserId(user.id);
|
||||
@@ -491,12 +494,16 @@ export class UsersEffects {
|
||||
this.actions$.pipe(
|
||||
ofType(UsersActions.loadCurrentUserFailure),
|
||||
filter(({ error }) => error === SESSION_EXPIRED_ERROR_CODE),
|
||||
tap(() => {
|
||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||
tap(([, currentUser]) => {
|
||||
if (currentUser) {
|
||||
setStoredCurrentUserId(currentUser.id);
|
||||
return;
|
||||
}
|
||||
|
||||
clearStoredCurrentUserId();
|
||||
void this.router.navigate(['/login'], {
|
||||
queryParams: {
|
||||
returnUrl: this.router.url
|
||||
}
|
||||
queryParams: buildLoginReturnQueryParams(this.router.url)
|
||||
});
|
||||
})
|
||||
),
|
||||
|
||||
@@ -518,6 +518,7 @@ export const usersReducer = createReducer(
|
||||
};
|
||||
const hasRoomId = Object.prototype.hasOwnProperty.call(voiceState, 'roomId');
|
||||
const hasServerId = Object.prototype.hasOwnProperty.call(voiceState, 'serverId');
|
||||
const hasClientInstanceId = Object.prototype.hasOwnProperty.call(voiceState, 'clientInstanceId');
|
||||
|
||||
return usersAdapter.updateOne(
|
||||
{
|
||||
@@ -531,7 +532,8 @@ export const usersReducer = createReducer(
|
||||
isMutedByAdmin: voiceState.isMutedByAdmin ?? prev.isMutedByAdmin,
|
||||
volume: voiceState.volume ?? prev.volume,
|
||||
roomId: hasRoomId ? voiceState.roomId : prev.roomId,
|
||||
serverId: hasServerId ? voiceState.serverId : prev.serverId
|
||||
serverId: hasServerId ? voiceState.serverId : prev.serverId,
|
||||
clientInstanceId: hasClientInstanceId ? voiceState.clientInstanceId : prev.clientInstanceId
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { register as registerSwiperElements } from 'swiper/element/bundle';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { App } from './app/app';
|
||||
import { getElectronApi } from './app/core/platform/electron/get-electron-api';
|
||||
import { applyMobileSafeAreaDefaults } from './app/infrastructure/mobile/logic/mobile-safe-area.rules';
|
||||
import mermaid from 'mermaid';
|
||||
|
||||
@@ -26,4 +27,15 @@ mermaid.initialize({
|
||||
});
|
||||
|
||||
bootstrapApplication(App, appConfig)
|
||||
.then(async (appRef) => {
|
||||
const api = getElectronApi();
|
||||
|
||||
if (!api?.isPerfDiagEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { bootstrapPerfDiagnostics } = await import('./app/infrastructure/diagnostics/diagnostics.bootstrap');
|
||||
|
||||
await bootstrapPerfDiagnostics(api, appRef.injector);
|
||||
})
|
||||
.catch((err) => console.error(err));
|
||||
|
||||
Reference in New Issue
Block a user