fix: Fix multiple bugs with new authentication flow

This commit is contained in:
2026-06-07 15:04:21 +02:00
parent 9fc26b1ccf
commit 83456c018c
137 changed files with 4710 additions and 281 deletions

View File

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

View File

@@ -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 () => {

View File

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

View File

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

View File

@@ -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)
});
}
}

View File

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

View File

@@ -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)
});
}
}

View File

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

View File

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

View File

@@ -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'
})

View File

@@ -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
}
}));
}

View File

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

View File

@@ -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 })],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(),

View File

@@ -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)
});
}

View File

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

View File

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

View File

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

View File

@@ -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();
}
});
}

View File

@@ -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);
}
}

View File

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

View File

@@ -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();
}
}

View File

@@ -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);
});
});

View File

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

View File

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