fix: Fix multiple bugs with new authentication flow
This commit is contained in:
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user