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

@@ -24,6 +24,8 @@ Owns the user-facing Angular 21 desktop chat experience: rendering and orchestra
| **Custom emoji** | User-created image emoji assets stored locally, synced peer-to-peer, and referenced from messages/reactions by stable `:emoji[id](name)` tokens. | "sticker", "emote" |
| **App locale** | The active UI language for the product client, resolved by `resolveAppLocale()` in `core/i18n/`; only `en` is shipped today. | "language", "i18n locale" |
| **Translation catalog** | JSON string tables under `public/i18n/catalog/*.json`, merged to `public/i18n/en.json` via `npm run i18n:sync`, loaded at startup by `AppI18nService`. | "locale file", "messages file" |
| **Client instance** | Stable per-install UUID (`metoyou.clientInstanceId`) sent on WebSocket `identify` and voice-state payloads so the signaling server can route multi-device sessions. | "device id", "session id" |
| **Voice owner connection** | The single client instance whose `clientInstanceId` matches the user's active `voiceState.clientInstanceId` and therefore owns mic/WebRTC for that identity. | "active voice client" |
## Relationships

View File

@@ -35,7 +35,8 @@
"resizeChat": "Resize chat",
"yourCamera": "Your camera",
"yourScreen": "Your screen",
"waiting": "Waiting"
"waiting": "Waiting",
"voiceOnOtherDevice": "Active on another device"
},
"notifications": {
"inProgress": "Call in progress"

View File

@@ -33,6 +33,8 @@
"latencyMs": "{{ms}} ms",
"playing": "Playing {{game}}",
"inVoice": "In voice",
"voiceOnOtherDevice": "In voice on another device",
"takeOverVoice": "Join",
"plugins": "Plugins",
"viewPlugins": "View plugins",
"you": "You",

View File

@@ -97,7 +97,8 @@
"resizeChat": "Resize chat",
"yourCamera": "Your camera",
"yourScreen": "Your screen",
"waiting": "Waiting"
"waiting": "Waiting",
"voiceOnOtherDevice": "Active on another device"
},
"notifications": {
"inProgress": "Call in progress"
@@ -768,6 +769,8 @@
"latencyMs": "{{ms}} ms",
"playing": "Playing {{game}}",
"inVoice": "In voice",
"voiceOnOtherDevice": "In voice on another device",
"takeOverVoice": "Join",
"plugins": "Plugins",
"viewPlugins": "View plugins",
"you": "You",

View File

@@ -58,6 +58,7 @@ import { RoomsActions } from './store/rooms/rooms.actions';
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
import { ROOM_URL_PATTERN } from './core/constants';
import { clearStoredCurrentUserId, getStoredCurrentUserId } from './core/storage/current-user-storage';
import { buildLoginReturnQueryParams } from './domains/authentication/domain/logic/auth-navigation.rules';
import { runWhenIdle } from './shared/rxjs';
import {
ThemeNodeDirective,
@@ -319,9 +320,7 @@ export class App implements OnInit, OnDestroy {
this.router.navigate(['/dashboard'], { replaceUrl: true }).catch(() => {});
} else {
this.router.navigate(['/login'], {
queryParams: {
returnUrl: currentUrl
}
queryParams: buildLoginReturnQueryParams(currentUrl)
}).catch(() => {});
}
}

View File

@@ -0,0 +1,43 @@
import {
afterEach,
beforeEach,
describe,
expect,
it
} from 'vitest';
import { ClientInstanceService } from './client-instance.service';
const STORAGE_KEY = 'metoyou.clientInstanceId';
describe('ClientInstanceService', () => {
const storage = new Map<string, string>();
beforeEach(() => {
storage.clear();
vi.stubGlobal('localStorage', {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, value); },
removeItem: (key: string) => { storage.delete(key); }
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('creates and persists a stable client instance id', () => {
const service = new ClientInstanceService();
const first = service.getClientInstanceId();
const second = new ClientInstanceService().getClientInstanceId();
expect(first).toMatch(/^[0-9a-f-]{36}$/i);
expect(second).toBe(first);
expect(storage.get(STORAGE_KEY)).toBe(first);
});
it('reuses a stored client instance id', () => {
storage.set(STORAGE_KEY, 'device-123');
expect(new ClientInstanceService().getClientInstanceId()).toBe('device-123');
});
});

View File

@@ -0,0 +1,38 @@
import { Injectable } from '@angular/core';
const STORAGE_KEY = 'metoyou.clientInstanceId';
@Injectable({ providedIn: 'root' })
export class ClientInstanceService {
private cachedId: string | null = null;
getClientInstanceId(): string {
if (this.cachedId) {
return this.cachedId;
}
const stored = this.readStoredId();
if (stored) {
this.cachedId = stored;
return stored;
}
const created = crypto.randomUUID();
localStorage.setItem(STORAGE_KEY, created);
this.cachedId = created;
return created;
}
private readStoredId(): string | null {
try {
const raw = localStorage.getItem(STORAGE_KEY)?.trim();
return raw || null;
} catch {
return null;
}
}
}

View File

@@ -244,6 +244,13 @@ export interface ElectronAppMetricsSnapshot {
processes: ElectronAppMetricsProcess[];
}
export interface ElectronPerfDiagEntry {
collectedAt: number;
source: 'main' | 'renderer';
type: string;
payload: Record<string, unknown>;
}
export interface ElectronApi {
linuxDisplayServer: string;
minimizeWindow: () => void;
@@ -263,6 +270,8 @@ export interface ElectronApi {
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
getAppMetrics: () => Promise<ElectronAppMetricsSnapshot>;
isPerfDiagEnabled?: () => Promise<boolean>;
reportPerfDiagSample?: (entry: ElectronPerfDiagEntry) => Promise<boolean>;
getAppDataPath: () => Promise<string>;
openCurrentDataFolder: () => Promise<boolean>;
exportUserData: () => Promise<ExportUserDataResult>;

View File

@@ -1,3 +1,4 @@
export * from './platform.service';
export * from './external-link.service';
export * from './viewport.service';
export * from './client-instance.service';

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

View File

@@ -21,12 +21,15 @@
<input
#searchInput
type="text"
appAutoFocus
appSelectOnFocus
appSubmitOnEnter
(submitOnEnter)="submitSearch()"
[attr.aria-label]="'dashboard.searchAriaLabel' | translate"
class="h-12 w-full min-w-0 rounded-xl border border-border bg-secondary py-2 pl-11 pr-4 text-base text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary sm:pr-20"
[placeholder]="isMobile() ? ('dashboard.searchPlaceholderMobile' | translate) : ('dashboard.searchPlaceholderDesktop' | translate)"
[ngModel]="searchQuery()"
(ngModelChange)="onSearchChange($event)"
(keydown.enter)="submitSearch()"
/>
<kbd
class="pointer-events-none absolute right-3 top-1/2 hidden -translate-y-1/2 items-center gap-1 rounded border border-border bg-card px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:flex"

View File

@@ -46,6 +46,11 @@ import { FriendButtonComponent } from '../../domains/direct-message/feature/frie
import { UserAvatarComponent } from '../../shared/components/user-avatar/user-avatar.component';
import { parseInviteQuery } from './invite-query.util';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../core/i18n';
import {
AutoFocusDirective,
SelectOnFocusDirective,
SubmitOnEnterDirective
} from '../../shared/directives';
/** Maximum quick-search rows shown per group on the dashboard. */
const QUICK_RESULT_LIMIT = 5;
@@ -72,6 +77,9 @@ const RECENT_SEARCHES_STORAGE_KEY = 'metoyou_dashboard_recent_searches';
NgIcon,
FriendButtonComponent,
UserAvatarComponent,
AutoFocusDirective,
SelectOnFocusDirective,
SubmitOnEnterDirective,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [

View File

@@ -17,7 +17,7 @@
[class.shadow-[0_0_0_6px_rgba(16,185,129,0.12)]]="speaking() && compact()"
[class.shadow-[0_0_0_8px_rgba(16,185,129,0.12)]]="speaking() && !compact()"
[class.ring-border]="!speaking()"
[class.opacity-55]="!connected()"
[class.opacity-55]="!connected() || passive()"
>
@if (user().avatarUrl) {
<img

View File

@@ -18,6 +18,7 @@ export class PrivateCallParticipantCardComponent {
readonly speaking = input.required<boolean>();
readonly issueLabel = input<string | null>(null);
readonly compact = input(false);
readonly passive = input(false);
avatarSize(): string {
return this.compact() ? '5.75rem' : 'clamp(6.5rem, 38vw, 13rem)';

View File

@@ -148,6 +148,7 @@
[connected]="isParticipantConnected(user)"
[speaking]="isSpeaking(user)"
[issueLabel]="participantIssueLabel(user)"
[passive]="isPassiveCallParticipant(user)"
/>
}
</div>
@@ -164,6 +165,7 @@
[connected]="isParticipantConnected(user)"
[speaking]="isSpeaking(user)"
[issueLabel]="participantIssueLabel(user)"
[passive]="isPassiveCallParticipant(user)"
[compact]="true"
/>
}

View File

@@ -44,6 +44,11 @@ import {
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../domains/voice-session';
import { ScreenShareQualityDialogComponent } from '../../shared';
import { ViewportService } from '../../core/platform';
import { RealtimeSessionFacade } from '../../core/realtime';
import {
isLocalVoiceOwner,
isVoiceOnAnotherClient
} from '../../domains/voice-session';
import { MobileMediaService, MobilePlatformService } from '../../infrastructure/mobile';
import { selectAllUsers, selectCurrentUser } from '../../store/users/users.selectors';
import { UsersActions } from '../../store/users/users.actions';
@@ -85,6 +90,7 @@ export class PrivateCallComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly store = inject(Store);
private readonly calls = inject(DirectCallService);
private readonly realtime = inject(RealtimeSessionFacade);
private readonly voice = inject(VoiceConnectionFacade);
private readonly voiceActivity = inject(VoiceActivityService);
private readonly playback = inject(VoicePlaybackService);
@@ -437,18 +443,38 @@ export class PrivateCallComponent {
isParticipantConnected(user: User): boolean {
const session = this.session();
const userId = this.userKey(user);
const current = this.currentUser();
if (!session) {
return false;
}
return (
!!session.participants[userId]?.joined ||
!!(user.voiceState?.isConnected && user.voiceState.roomId === session.callId && user.voiceState.serverId === session.callId)
const inCallVoice = !!(
user.voiceState?.isConnected
&& user.voiceState.roomId === session.callId
&& user.voiceState.serverId === session.callId
);
const isSelf = !!current && (user.id === current.id || user.oderId === current.oderId);
if (isSelf && inCallVoice) {
return isLocalVoiceOwner(user.voiceState, this.realtime.getClientInstanceId());
}
return !!session.participants[userId]?.joined || inCallVoice;
}
isPassiveCallParticipant(user: User): boolean {
const current = this.currentUser();
const isSelf = !!current && (user.id === current.id || user.oderId === current.oderId);
return isSelf && isVoiceOnAnotherClient(user.voiceState, this.realtime.getClientInstanceId());
}
participantIssueLabel(user: User): string | null {
if (this.isPassiveCallParticipant(user)) {
return this.i18n.instant('call.private.voiceOnOtherDevice');
}
return this.isParticipantConnected(user) ? null : this.i18n.instant('call.private.waiting');
}

View File

@@ -98,6 +98,8 @@
<input
#renameInput
type="text"
appAutoFocus
appSelectOnFocus
[value]="ch.name"
[class.border-destructive]="renamingChannelId() === ch.id && !!channelNameError()"
[title]="renamingChannelId() === ch.id ? (channelNameError() ? (channelNameError()! | translate) : '') : ''"
@@ -173,7 +175,7 @@
(contextmenu)="openChannelContextMenu($event, ch)"
[class.bg-secondary]="isCurrentRoom(ch.id)"
[disabled]="!voiceEnabled()"
[title]="isCurrentRoom(ch.id) ? ('room.panel.openStreamWorkspace' | translate) : ('room.panel.joinVoiceChannel' | translate)"
[title]="voiceChannelActionLabel(ch.id)"
data-channel-type="voice"
[attr.data-channel-name]="ch.name"
>
@@ -186,6 +188,8 @@
<input
#renameInput
type="text"
appAutoFocus
appSelectOnFocus
[value]="ch.name"
[class.border-destructive]="renamingChannelId() === ch.id && !!channelNameError()"
[title]="renamingChannelId() === ch.id ? (channelNameError() ? (channelNameError()! | translate) : '') : ''"
@@ -205,6 +209,10 @@
<span class="rounded-full bg-primary/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
{{ isVoiceWorkspaceExpanded() ? ('room.panel.open' | translate) : ('room.panel.view' | translate) }}
</span>
} @else if (isPassiveInVoiceRoom(ch.id)) {
<span class="rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{{ 'room.panel.takeOverVoice' | translate }}
</span>
} @else if (voiceOccupancy(ch.id) > 0) {
<span class="text-xs text-muted-foreground">{{ voiceOccupancy(ch.id) }}</span>
}
@@ -220,6 +228,7 @@
appThemeNode="roomVoiceUserItem"
class="flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-secondary/50"
[class.cursor-pointer]="canDragVoiceUser(u)"
[class.opacity-50]="isPassiveVoiceUser(u)"
[class.opacity-60]="draggedVoiceUserId() === (u.id || u.oderId)"
[draggable]="canDragVoiceUser(u)"
(dragstart)="onVoiceUserDragStart($event, u)"
@@ -368,6 +377,7 @@
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">{{ 'room.panel.you' | translate }}</h4>
<div
class="flex items-center gap-2 rounded-md bg-secondary/60 px-3 py-2 hover:bg-secondary/80 transition-colors cursor-pointer"
[class.opacity-50]="isPassiveVoiceClient()"
role="button"
tabindex="0"
(click)="openProfileCard($event, currentUser()!, true); $event.stopPropagation()"
@@ -413,7 +423,11 @@
name="lucideMic"
class="w-2.5 h-2.5"
/>
{{ 'room.panel.inVoice' | translate }}
@if (isPassiveVoiceClient()) {
{{ 'room.panel.voiceOnOtherDevice' | translate }}
} @else {
{{ 'room.panel.inVoice' | translate }}
}
</p>
}
@if (currentUser() && isUserStreaming(currentUser()!.oderId || currentUser()!.id)) {
@@ -763,7 +777,6 @@
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
[class.border-destructive]="!!channelNameError()"
(ngModelChange)="clearChannelNameError()"
(keydown.enter)="confirmCreateChannel()"
/>
@if (channelNameError()) {
<p class="mt-2 text-sm text-destructive">{{ channelNameError()! | translate }}</p>

View File

@@ -52,7 +52,12 @@ import {
VoiceConnectionFacade,
VoiceConnectivityHealthService
} from '../../../domains/voice-connection';
import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session';
import {
VoiceSessionFacade,
VoiceWorkspaceService,
isLocalVoiceOwner,
isVoiceOnAnotherClient
} from '../../../domains/voice-session';
import { DirectMessageService } from '../../../domains/direct-message';
import { DirectCallService } from '../../../domains/direct-call';
import { VoicePlaybackService } from '../../../domains/voice-connection';
@@ -88,6 +93,7 @@ import {
import { v4 as uuidv4 } from 'uuid';
import { visibilityAwareInterval$ } from '../../../shared/rxjs';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../core/i18n';
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../shared/directives';
type PanelMode = 'channels' | 'users';
@@ -109,6 +115,8 @@ const SKELETON_REVEAL_DELAY_MS = 180;
ThemeNodeDirective,
SkeletonComponent,
SkeletonListComponent,
AutoFocusDirective,
SelectOnFocusDirective,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
@@ -689,7 +697,13 @@ export class RoomsSidePanelComponent implements OnDestroy {
}
private openExistingVoiceWorkspace(room: Room | null, current: User | null, roomId: string): boolean {
if (!room || !current?.voiceState?.isConnected || current.voiceState.roomId !== roomId || current.voiceState.serverId !== room.id) {
if (
!room
|| !current?.voiceState?.isConnected
|| current.voiceState.roomId !== roomId
|| current.voiceState.serverId !== room.id
|| !isLocalVoiceOwner(current.voiceState, this.realtime.getClientInstanceId())
) {
return false;
}
@@ -697,6 +711,47 @@ export class RoomsSidePanelComponent implements OnDestroy {
return true;
}
isPassiveInVoiceRoom(roomId: string): boolean {
const current = this.currentUser();
const room = this.currentRoom();
return !!current?.voiceState?.isConnected
&& current.voiceState.roomId === roomId
&& current.voiceState.serverId === room?.id
&& isVoiceOnAnotherClient(current.voiceState, this.realtime.getClientInstanceId());
}
isPassiveVoiceClient(): boolean {
const current = this.currentUser();
return isVoiceOnAnotherClient(current?.voiceState, this.realtime.getClientInstanceId());
}
isPassiveVoiceUser(user: User | null): boolean {
const current = this.currentUser();
if (!user || !current) {
return false;
}
return (user.id === current.id || user.oderId === current.oderId)
&& this.isPassiveVoiceClient();
}
voiceChannelActionLabel(roomId: string): string {
if (this.isCurrentRoom(roomId)) {
return this.isVoiceWorkspaceExpanded()
? this.appI18n.instant('room.panel.open')
: this.appI18n.instant('room.panel.view');
}
if (this.isPassiveInVoiceRoom(roomId)) {
return this.appI18n.instant('room.panel.takeOverVoice');
}
return this.appI18n.instant('room.panel.joinVoiceChannel');
}
private canJoinRequestedVoiceRoom(room: Room, current: User | null, roomId: string): boolean {
return !current || resolveRoomPermission(room, current, 'joinVoice', roomId);
}
@@ -737,9 +792,19 @@ export class RoomsSidePanelComponent implements OnDestroy {
this.directCalls.leaveCurrentJoinedCall();
this.prepareVoiceJoin(room, current ?? null);
this.enableVoiceForJoin(room, current ?? null, roomId)
.then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
.catch((error) => this.handleVoiceJoinFailure(error));
const startJoin = () => {
this.enableVoiceForJoin(room, current ?? null, roomId)
.then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
.catch((error) => this.handleVoiceJoinFailure(error));
};
if (this.isPassiveInVoiceRoom(roomId) || this.isPassiveVoiceClient()) {
this.realtime.requestVoiceClientTakeover();
window.setTimeout(startJoin, 300);
return;
}
startJoin();
}
private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void {
@@ -786,7 +851,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
isMuted: current.voiceState?.isMuted ?? false,
isDeafened: current.voiceState?.isDeafened ?? false,
roomId,
serverId: room.id
serverId: room.id,
clientInstanceId: this.realtime.getClientInstanceId()
}
})
);
@@ -797,6 +863,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
}
private broadcastVoiceConnected(roomId: string, room: Room, current: User | null): void {
const clientInstanceId = this.realtime.getClientInstanceId();
this.voiceConnection.broadcastMessage({
type: 'voice-state',
oderId: current?.oderId || current?.id,
@@ -806,7 +874,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
isMuted: current?.voiceState?.isMuted ?? false,
isDeafened: current?.voiceState?.isDeafened ?? false,
roomId,
serverId: room.id
serverId: room.id,
clientInstanceId
}
});
}
@@ -851,7 +920,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
isMuted: false,
isDeafened: false,
roomId: undefined,
serverId: undefined
serverId: undefined,
clientInstanceId: undefined
}
})
);
@@ -873,7 +943,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
isMuted: false,
isDeafened: false,
roomId: previousVoiceState?.roomId,
serverId: previousVoiceState?.serverId
serverId: previousVoiceState?.serverId,
clientInstanceId: undefined
}
});
@@ -1110,7 +1181,12 @@ export class RoomsSidePanelComponent implements OnDestroy {
const me = this.currentUser();
const room = this.currentRoom();
return !!(me?.voiceState?.isConnected && me.voiceState?.roomId === roomId && me.voiceState?.serverId === room?.id);
return !!(
me?.voiceState?.isConnected
&& me.voiceState.roomId === roomId
&& me.voiceState.serverId === room?.id
&& isLocalVoiceOwner(me.voiceState, this.realtime.getClientInstanceId())
);
}
voiceEnabled(): boolean {

View File

@@ -27,6 +27,7 @@ import {
} from 'rxjs';
import { Room, User } from '../../../shared-kernel';
import { buildLoginReturnQueryParams } from '../../../domains/authentication/domain/logic/auth-navigation.rules';
import { UserBarComponent } from '../../../domains/authentication/feature/user-bar/user-bar.component';
import { VoiceSessionFacade } from '../../../domains/voice-session';
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
@@ -276,7 +277,9 @@ export class ServersRailComponent {
const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUserId) {
this.router.navigate(['/login']);
this.router.navigate(['/login'], {
queryParams: buildLoginReturnQueryParams(this.router.url)
});
return;
}

View File

@@ -154,11 +154,13 @@
<div class="flex items-center gap-2">
<input
type="text"
appSelectOnFocus
appSubmitOnEnter
(submitOnEnter)="addIgnoredProcess()"
class="flex-1 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
[placeholder]="'settings.general.gameDetection.processPlaceholder' | translate"
[value]="ignoredProcessDraft()"
(input)="onIgnoredProcessDraftChange($event)"
(keydown.enter)="addIgnoredProcess()"
[attr.aria-label]="'settings.general.gameDetection.processAria' | translate"
/>
<button

View File

@@ -14,11 +14,21 @@ import { ElectronBridgeService } from '../../../../core/platform/electron/electr
import { PlatformService } from '../../../../core/platform';
import { ExperimentalMediaSettingsService } from '../../../../domains/experimental-media/application/services/experimental-media-settings.service';
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import {
SelectOnFocusDirective,
SubmitOnEnterDirective
} from '../../../../shared/directives';
@Component({
selector: 'app-general-settings',
standalone: true,
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS],
imports: [
CommonModule,
NgIcon,
SelectOnFocusDirective,
SubmitOnEnterDirective,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
lucidePower

View File

@@ -113,6 +113,10 @@
</select>
<input
type="text"
appAutoFocus
appSelectOnFocus
appSubmitOnEnter
(submitOnEnter)="addEntry()"
[(ngModel)]="newUrl"
data-testid="ice-url-input"
[placeholder]="(newType === 'stun' ? 'settings.network.ice.stunPlaceholder' : 'settings.network.ice.turnPlaceholder') | translate"
@@ -123,6 +127,7 @@
<div class="flex gap-2">
<input
type="text"
appSelectOnFocus
[(ngModel)]="newUsername"
data-testid="ice-username-input"
[placeholder]="'settings.network.ice.username' | translate"
@@ -130,6 +135,8 @@
/>
<input
type="password"
appSubmitOnEnter
(submitOnEnter)="addEntry()"
[(ngModel)]="newCredential"
data-testid="ice-credential-input"
[placeholder]="'settings.network.ice.credential' | translate"

View File

@@ -18,6 +18,11 @@ import {
import { IceServerSettingsService, IceServerEntry } from '../../../../infrastructure/realtime/ice-server-settings.service';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
import {
AutoFocusDirective,
SelectOnFocusDirective,
SubmitOnEnterDirective
} from '../../../../shared/directives';
@Component({
selector: 'app-ice-server-settings',
@@ -29,6 +34,9 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
CommonModule,
FormsModule,
NgIcon,
AutoFocusDirective,
SelectOnFocusDirective,
SubmitOnEnterDirective,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [

View File

@@ -126,12 +126,16 @@
<div class="flex-1 space-y-1.5">
<input
type="text"
appSelectOnFocus
[(ngModel)]="newServerName"
[placeholder]="'settings.network.serverEndpoints.serverNamePlaceholderShort' | translate"
class="w-full px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<input
type="url"
appSelectOnFocus
appSubmitOnEnter
(submitOnEnter)="addServer()"
[(ngModel)]="newServerUrl"
[placeholder]="'settings.network.serverEndpoints.serverUrlPlaceholder' | translate"
class="w-full px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"

View File

@@ -22,6 +22,10 @@ import { ServerDirectoryFacade } from '../../../../domains/server-directory';
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
import { IceServerSettingsComponent } from '../ice-server-settings/ice-server-settings.component';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
import {
SelectOnFocusDirective,
SubmitOnEnterDirective
} from '../../../../shared/directives';
@Component({
selector: 'app-network-settings',
@@ -31,6 +35,8 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
FormsModule,
NgIcon,
IceServerSettingsComponent,
SelectOnFocusDirective,
SubmitOnEnterDirective,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [

View File

@@ -110,6 +110,9 @@
}}</span>
<input
type="text"
appSelectOnFocus
appSubmitOnEnter
(submitOnEnter)="saveRoleDetails()"
[ngModel]="roleName"
(ngModelChange)="roleName = $event"
[disabled]="!canEditSelectedRoleMetadata()"

View File

@@ -39,6 +39,10 @@ import {
withUpdatedRole
} from '../../../../domains/access-control';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
import {
SelectOnFocusDirective,
SubmitOnEnterDirective
} from '../../../../shared/directives';
function upsertRoleChannelOverride(
overrides: readonly ChannelPermissionOverride[] | undefined,
@@ -75,6 +79,8 @@ function upsertRoleChannelOverride(
CommonModule,
FormsModule,
NgIcon,
SelectOnFocusDirective,
SubmitOnEnterDirective,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [

View File

@@ -82,6 +82,9 @@
[(ngModel)]="roomName"
[readOnly]="!isAdmin()"
id="room-name"
appSelectOnFocus
appSubmitOnEnter
(submitOnEnter)="saveServerSettings()"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
[class.opacity-60]="!isAdmin()"
[class.cursor-not-allowed]="!isAdmin()"

View File

@@ -26,6 +26,10 @@ import { ConfirmDialogComponent } from '../../../../shared';
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
import { ServerIconImageService } from '../../../../domains/server-directory/infrastructure/services/server-icon-image.service';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
import {
SelectOnFocusDirective,
SubmitOnEnterDirective
} from '../../../../shared/directives';
@Component({
selector: 'app-server-settings',
@@ -35,6 +39,8 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
FormsModule,
NgIcon,
ConfirmDialogComponent,
SelectOnFocusDirective,
SubmitOnEnterDirective,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [

View File

@@ -163,12 +163,16 @@
<div class="flex-1 space-y-2">
<input
type="text"
appSelectOnFocus
[(ngModel)]="newServerName"
[placeholder]="'settings.network.serverEndpoints.serverNamePlaceholder' | translate"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<input
type="url"
appSelectOnFocus
appSubmitOnEnter
(submitOnEnter)="addServer()"
[(ngModel)]="newServerUrl"
[placeholder]="'settings.network.serverEndpoints.serverUrlPlaceholder' | translate"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"

View File

@@ -29,6 +29,10 @@ import { VoiceConnectionFacade } from '../../domains/voice-connection';
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../../core/constants';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../core/i18n';
import {
SelectOnFocusDirective,
SubmitOnEnterDirective
} from '../../shared/directives';
@Component({
selector: 'app-settings',
@@ -37,6 +41,8 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../core/i18n';
CommonModule,
FormsModule,
NgIcon,
SelectOnFocusDirective,
SubmitOnEnterDirective,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [

View File

@@ -40,6 +40,7 @@ import { RealtimeSessionFacade } from '../../../core/realtime';
import { ServerDirectoryFacade } from '../../../domains/server-directory';
import { PlatformService } from '../../../core/platform';
import { clearStoredCurrentUserId } from '../../../core/storage/current-user-storage';
import { buildLoginReturnQueryParams } from '../../../domains/authentication/domain/logic/auth-navigation.rules';
import { SettingsModalService } from '../../../core/services/settings-modal.service';
import { LeaveServerDialogComponent, ModalBackdropComponent } from '../../../shared';
import { Room, type PluginRequirementSummary } from '../../../shared-kernel';
@@ -211,7 +212,9 @@ export class TitleBarComponent {
/** Navigate to the login page. */
goLogin() {
this.router.navigate(['/login']);
this.router.navigate(['/login'], {
queryParams: buildLoginReturnQueryParams(this.router.url)
});
}
openPluginStore(): void {

View File

@@ -0,0 +1,46 @@
import {
describe,
it,
expect
} from 'vitest';
import { aggregateComponentCountsByDomain, detectSuspectedComponentLeaks } from './component-tree.rules';
describe('aggregateComponentCountsByDomain', () => {
it('groups component counts under inferred domains', () => {
expect(aggregateComponentCountsByDomain({
VoiceChannelPanelComponent: 2,
DmChatComponent: 1,
App: 1
})).toEqual({
'voice-connection': 2,
'direct-message': 1,
core: 1
});
});
});
describe('detectSuspectedComponentLeaks', () => {
it('flags components that exceed the expected live count', () => {
expect(detectSuspectedComponentLeaks({
DmChatComponent: 3,
App: 1
}, {
DmChatComponent: 0,
App: 1
})).toEqual([
{
name: 'DmChatComponent',
count: 3,
expected: 0
}
]);
});
it('returns an empty list when counts are within expectations', () => {
expect(detectSuspectedComponentLeaks({
App: 1
}, {
App: 1
})).toEqual([]);
});
});

View File

@@ -0,0 +1,38 @@
import { mapComponentNameToDomain } from './domain-mapping.rules';
export interface SuspectedComponentLeak {
name: string;
count: number;
expected: number;
}
export function aggregateComponentCountsByDomain(
componentCounts: Record<string, number>
): Record<string, number> {
const domains: Record<string, number> = {};
for (const [componentName, count] of Object.entries(componentCounts)) {
const domain = mapComponentNameToDomain(componentName);
domains[domain] = (domains[domain] ?? 0) + count;
}
return domains;
}
export function detectSuspectedComponentLeaks(
componentCounts: Record<string, number>,
expectedCounts: Record<string, number>
): SuspectedComponentLeak[] {
const leaks: SuspectedComponentLeak[] = [];
for (const [name, count] of Object.entries(componentCounts)) {
const expected = expectedCounts[name] ?? 0;
if (count > expected) {
leaks.push({ name, count, expected });
}
}
return leaks.sort((left, right) => right.count - left.count);
}

View File

@@ -0,0 +1,69 @@
import { ApplicationRef } from '@angular/core';
import { aggregateComponentCountsByDomain, detectSuspectedComponentLeaks } from './component-tree.rules';
const DEFAULT_DOM_SCAN_BUDGET = 400;
interface NgDebugApi {
getComponent?: (element: Element) => { constructor: { name: string } } | null;
}
export interface ComponentTreeScanResult {
components: Record<string, number>;
domains: Record<string, number>;
suspectedLeaks: ReturnType<typeof detectSuspectedComponentLeaks>;
scannedNodes: number;
scanMode: 'application-ref' | 'ng-global';
}
export function scanComponentTree(
appRef: ApplicationRef,
expectedCounts: Record<string, number>,
options: { domScanBudget?: number } = {}
): ComponentTreeScanResult {
const domScanBudget = options.domScanBudget ?? DEFAULT_DOM_SCAN_BUDGET;
const ngApi = (globalThis as { ng?: NgDebugApi }).ng;
const components: Record<string, number> = {};
let scannedNodes = 0;
let scanMode: ComponentTreeScanResult['scanMode'] = 'application-ref';
if (ngApi?.getComponent && typeof document !== 'undefined') {
scanMode = 'ng-global';
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
while (walker.nextNode() && scannedNodes < domScanBudget) {
scannedNodes += 1;
try {
const component = ngApi.getComponent?.(walker.currentNode as Element) as {
constructor: { name: string };
} | null;
if (!component) {
continue;
}
const name = component.constructor.name;
components[name] = (components[name] ?? 0) + 1;
} catch {
// Ignore elements that are not component hosts.
}
}
} else {
for (const componentRef of appRef.components) {
const name = componentRef.componentType.name;
components[name] = (components[name] ?? 0) + 1;
scannedNodes += 1;
}
}
return {
components,
domains: aggregateComponentCountsByDomain(components),
suspectedLeaks: detectSuspectedComponentLeaks(components, expectedCounts),
scannedNodes,
scanMode
};
}

View File

@@ -0,0 +1,96 @@
import {
EnvironmentInjector,
inject,
runInInjectionContext
} from '@angular/core';
import type { ElectronApi } from '../../core/platform/electron/electron-api.models';
import { PerfDiagnosticsCollector, publishRendererDiagnosticsSample } from './diagnostics.collector';
import type { PerfDiagEntry, PerfDiagReporter } from './diagnostics.models';
const SAMPLE_INTERVAL_MS = 10_000;
let started = false;
let sampleTimer: ReturnType<typeof setInterval> | null = null;
export async function bootstrapPerfDiagnostics(
api: ElectronApi,
injector: EnvironmentInjector
): Promise<void> {
const reportSample = api.reportPerfDiagSample;
if (started || !api.isPerfDiagEnabled || !reportSample) {
return;
}
let enabled = false;
try {
enabled = await api.isPerfDiagEnabled();
} catch {
return;
}
if (!enabled) {
return;
}
started = true;
const reporter: PerfDiagReporter = {
report: (entry: PerfDiagEntry) => reportSample(entry)
};
const runSample = (): void => {
void runInInjectionContext(injector, async () => {
try {
const collector = inject(PerfDiagnosticsCollector);
await publishRendererDiagnosticsSample(reporter, collector);
} catch {
stopPerfDiagnosticsSampling();
}
});
};
scheduleSample(runSample);
sampleTimer = setInterval(() => scheduleSample(runSample), SAMPLE_INTERVAL_MS);
window.addEventListener('error', () => {
void reporter.report({
collectedAt: Date.now(),
source: 'renderer',
type: 'crash',
payload: { scope: 'window-error' }
});
});
window.addEventListener('unhandledrejection', () => {
void reporter.report({
collectedAt: Date.now(),
source: 'renderer',
type: 'crash',
payload: { scope: 'unhandled-rejection' }
});
});
}
function scheduleSample(runSample: () => void): void {
const idle = (globalThis as {
requestIdleCallback?: (handler: () => void, options?: { timeout: number }) => number;
}).requestIdleCallback;
if (idle) {
idle(() => runSample(), { timeout: SAMPLE_INTERVAL_MS });
return;
}
setTimeout(runSample, 0);
}
function stopPerfDiagnosticsSampling(): void {
if (sampleTimer) {
clearInterval(sampleTimer);
sampleTimer = null;
}
started = false;
}

View File

@@ -0,0 +1,185 @@
import {
ApplicationRef,
inject,
Injectable
} from '@angular/core';
import { Store } from '@ngrx/store';
import { Router } from '@angular/router';
import type { AppState } from '../../store';
import { mapStoreSliceToDomain } from './domain-mapping.rules';
import { estimateStructuredBytes } from './state-size.rules';
import { scanComponentTree } from './component-tree.scanner';
import type { PerfDiagEntry, PerfDiagReporter } from './diagnostics.models';
const SAMPLE_BUDGET_MS = 8;
export interface RendererDiagnosticsSample {
storeDomains: Record<string, number>;
storeBytes: Record<string, number>;
components: Record<string, number>;
componentDomains: Record<string, number>;
suspectedLeaks: { name: string; count: number; expected: number }[];
heap: {
usedJsHeapMb: number | null;
totalJsHeapMb: number | null;
};
route: string | null;
durationMs: number;
}
@Injectable({ providedIn: 'root' })
export class PerfDiagnosticsCollector {
private readonly appRef = inject(ApplicationRef);
private readonly store = inject(Store<AppState>);
private readonly router = inject(Router);
private disabled = false;
private readonly expectedComponentCounts: Record<string, number> = {
App: 1
};
collectSample(): RendererDiagnosticsSample | null {
if (this.disabled) {
return null;
}
const startedAt = performance.now();
try {
const state = this.collectStoreState();
const storeBytes: Record<string, number> = {};
const storeDomains: Record<string, number> = {};
for (const [sliceKey, sliceValue] of Object.entries(state)) {
const bytes = estimateStructuredBytes(sliceValue, { maxNodes: 200, maxDepth: 5 });
const domain = mapStoreSliceToDomain(sliceKey);
storeBytes[sliceKey] = bytes;
storeDomains[domain] = (storeDomains[domain] ?? 0) + bytes;
}
const componentScan = scanComponentTree(this.appRef, this.expectedComponentCounts);
const heap = readJsHeapSnapshot();
return {
storeDomains,
storeBytes,
components: componentScan.components,
componentDomains: componentScan.domains,
suspectedLeaks: componentScan.suspectedLeaks,
heap,
route: this.router.url,
durationMs: performance.now() - startedAt
};
} catch {
this.disabled = true;
return null;
} finally {
if (performance.now() - startedAt > SAMPLE_BUDGET_MS) {
// Skip future samples if this collector cannot stay within budget.
this.disabled = true;
}
}
}
buildEntries(sample: RendererDiagnosticsSample): PerfDiagEntry[] {
const collectedAt = Date.now();
return [
{
collectedAt,
source: 'renderer',
type: 'store',
payload: {
domains: sample.storeDomains,
slices: sample.storeBytes,
route: sample.route
}
},
{
collectedAt,
source: 'renderer',
type: 'components',
payload: {
components: sample.components,
domains: sample.componentDomains,
suspectedLeaks: sample.suspectedLeaks,
route: sample.route,
durationMs: sample.durationMs
}
},
{
collectedAt,
source: 'renderer',
type: 'heap',
payload: {
...sample.heap,
route: sample.route
}
}
];
}
private collectStoreState(): AppState {
let latest: AppState | undefined;
this.store.subscribe((state) => {
latest = state;
}).unsubscribe();
if (!latest) {
throw new Error('Store state unavailable');
}
return latest;
}
}
export async function publishRendererDiagnosticsSample(
reporter: PerfDiagReporter,
collector: PerfDiagnosticsCollector
): Promise<boolean> {
const sample = collector.collectSample();
if (!sample) {
return false;
}
const entries = collector.buildEntries(sample);
let reported = true;
for (const entry of entries) {
const accepted = await reporter.report(entry);
if (!accepted) {
reported = false;
}
}
return reported;
}
function readJsHeapSnapshot(): { usedJsHeapMb: number | null; totalJsHeapMb: number | null } {
const memory = (performance as Performance & {
memory?: {
usedJSHeapSize: number;
totalJSHeapSize: number;
};
}).memory;
if (!memory) {
return {
usedJsHeapMb: null,
totalJsHeapMb: null
};
}
return {
usedJsHeapMb: roundMb(memory.usedJSHeapSize),
totalJsHeapMb: roundMb(memory.totalJSHeapSize)
};
}
function roundMb(bytes: number): number {
return Math.round((bytes / (1024 * 1024)) * 100) / 100;
}

View File

@@ -0,0 +1,21 @@
export type PerfDiagSource = 'main' | 'renderer';
export type PerfDiagEntryType =
| 'session'
| 'process'
| 'store'
| 'components'
| 'heap'
| 'crash'
| 'unresponsive';
export interface PerfDiagEntry {
collectedAt: number;
source: PerfDiagSource;
type: PerfDiagEntryType;
payload: Record<string, unknown>;
}
export interface PerfDiagReporter {
report(entry: PerfDiagEntry): Promise<boolean>;
}

View File

@@ -0,0 +1,30 @@
import {
describe,
it,
expect
} from 'vitest';
import { mapComponentNameToDomain, mapStoreSliceToDomain } from './domain-mapping.rules';
describe('mapStoreSliceToDomain', () => {
it('maps known NgRx slices to domains', () => {
expect(mapStoreSliceToDomain('messages')).toBe('chat');
expect(mapStoreSliceToDomain('users')).toBe('users');
expect(mapStoreSliceToDomain('rooms')).toBe('rooms');
});
it('falls back to the slice name for unknown keys', () => {
expect(mapStoreSliceToDomain('custom')).toBe('custom');
});
});
describe('mapComponentNameToDomain', () => {
it('maps component class prefixes to domains', () => {
expect(mapComponentNameToDomain('VoiceChannelPanelComponent')).toBe('voice-connection');
expect(mapComponentNameToDomain('DmChatComponent')).toBe('direct-message');
expect(mapComponentNameToDomain('PluginStoreComponent')).toBe('plugins');
});
it('falls back to core for unknown components', () => {
expect(mapComponentNameToDomain('App')).toBe('core');
});
});

View File

@@ -0,0 +1,48 @@
const STORE_SLICE_DOMAINS: Record<string, string> = {
messages: 'chat',
users: 'users',
rooms: 'rooms'
};
const COMPONENT_PREFIX_DOMAINS: [string, string][] = [
['Voice', 'voice-connection'],
['ScreenShare', 'screen-share'],
['Dm', 'direct-message'],
['DirectCall', 'direct-call'],
['DirectMessage', 'direct-message'],
['Plugin', 'plugins'],
['Chat', 'chat'],
['Message', 'chat'],
['Server', 'server-directory'],
['Room', 'rooms'],
['Theme', 'theme'],
['Emoji', 'custom-emoji'],
['Notification', 'notifications'],
['Game', 'game-activity'],
['Profile', 'profile-avatar'],
['Attachment', 'attachment'],
['Auth', 'authentication'],
['Login', 'authentication'],
['Register', 'authentication']
];
export function mapStoreSliceToDomain(sliceKey: string): string {
return STORE_SLICE_DOMAINS[sliceKey] ?? sliceKey;
}
export function mapComponentNameToDomain(componentName: string): string {
if (!componentName || componentName === 'App') {
return 'core';
}
for (const [prefix, domain] of COMPONENT_PREFIX_DOMAINS) {
if (componentName.startsWith(prefix)) {
return domain;
}
}
if (componentName.endsWith('Component')) {
return 'features';
}
return 'core';
}

View File

@@ -0,0 +1,34 @@
import {
describe,
it,
expect
} from 'vitest';
import { estimateStructuredBytes } from './state-size.rules';
describe('estimateStructuredBytes', () => {
it('returns zero for nullish values', () => {
expect(estimateStructuredBytes(null)).toBe(0);
expect(estimateStructuredBytes(undefined)).toBe(0);
});
it('estimates primitive and shallow object sizes', () => {
expect(estimateStructuredBytes('hello')).toBeGreaterThan(0);
expect(estimateStructuredBytes({ a: 1, b: 'two' })).toBeGreaterThan(estimateStructuredBytes({ a: 1 }));
});
it('stops walking once the node budget is exhausted', () => {
const deep = {
level1: {
level2: {
level3: {
payload: 'value'
}
}
}
};
const full = estimateStructuredBytes(deep, { maxNodes: 50 });
const shallow = estimateStructuredBytes(deep, { maxNodes: 2 });
expect(shallow).toBeLessThan(full);
});
});

View File

@@ -0,0 +1,147 @@
export interface StructuredByteEstimateOptions {
maxNodes?: number;
maxDepth?: number;
}
const DEFAULT_MAX_NODES = 250;
const DEFAULT_MAX_DEPTH = 6;
export function estimateStructuredBytes(
value: unknown,
options: StructuredByteEstimateOptions = {}
): number {
const maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES;
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
const budget = { remaining: maxNodes };
return walkValue(value, 0, maxDepth, budget);
}
function walkValue(
value: unknown,
depth: number,
maxDepth: number,
budget: { remaining: number }
): number {
if (budget.remaining <= 0) {
return 0;
}
budget.remaining -= 1;
const primitiveBytes = estimatePrimitiveBytes(value);
if (primitiveBytes != null) {
return primitiveBytes;
}
if (depth >= maxDepth) {
return 32;
}
if (Array.isArray(value)) {
return walkArray(value, depth, maxDepth, budget);
}
if (value instanceof Map || value instanceof Set) {
return walkIterable(value, depth, maxDepth, budget);
}
if (typeof value === 'object') {
return walkObject(value as Record<string, unknown>, depth, maxDepth, budget);
}
return 16;
}
function estimatePrimitiveBytes(value: unknown): number | null {
if (value == null) {
return 0;
}
if (typeof value === 'string') {
return value.length * 2;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return 8;
}
if (typeof value === 'bigint') {
return 16;
}
if (value instanceof Date) {
return 24;
}
if (ArrayBuffer.isView(value)) {
return value.byteLength;
}
if (value instanceof ArrayBuffer) {
return value.byteLength;
}
return null;
}
function walkArray(
value: unknown[],
depth: number,
maxDepth: number,
budget: { remaining: number }
): number {
let total = 16;
for (const item of value) {
if (budget.remaining <= 0) {
break;
}
total += walkValue(item, depth + 1, maxDepth, budget);
}
return total;
}
function walkIterable(
value: Map<unknown, unknown> | Set<unknown>,
depth: number,
maxDepth: number,
budget: { remaining: number }
): number {
let total = 32;
let walked = 0;
for (const item of value) {
if (budget.remaining <= 0 || walked >= 25) {
break;
}
walked += 1;
total += walkValue(item, depth + 1, maxDepth, budget);
}
return total;
}
function walkObject(
value: Record<string, unknown>,
depth: number,
maxDepth: number,
budget: { remaining: number }
): number {
let total = 16;
for (const [key, nested] of Object.entries(value)) {
if (budget.remaining <= 0) {
break;
}
total += key.length * 2;
total += walkValue(nested, depth + 1, maxDepth, budget);
}
return total;
}

View File

@@ -1,3 +1,4 @@
import '@angular/compiler';
import { Injector, runInInjectionContext } from '@angular/core';
import {
beforeEach,
@@ -13,6 +14,21 @@ import { CapacitorDatabaseService } from './capacitor-database.service';
import { DatabaseService } from './database.service';
import { ElectronDatabaseService } from './electron-database.service';
function installLocalStorageMock(): void {
const store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, String(value)),
removeItem: (key: string) => store.delete(key),
clear: () => store.clear(),
key: (index: number) => Array.from(store.keys())[index] ?? null,
get length() {
return store.size;
}
});
}
describe('DatabaseService', () => {
let browserDatabase: {
getBansForRoom: ReturnType<typeof vi.fn>;
@@ -28,6 +44,9 @@ describe('DatabaseService', () => {
};
beforeEach(() => {
installLocalStorageMock();
localStorage.clear();
browserDatabase = {
getBansForRoom: vi.fn(() => Promise.resolve([])),
initialize: vi.fn(() => Promise.resolve())
@@ -69,6 +88,56 @@ describe('DatabaseService', () => {
expect(service.isReady()).toBe(true);
});
it('rechecks backend initialization when the user scope changes during an in-flight initialize call', async () => {
let finishInitialInitialize!: () => void;
browserDatabase.initialize = vi.fn()
.mockImplementationOnce(() => new Promise<void>((resolve) => {
finishInitialInitialize = resolve;
}))
.mockResolvedValue(undefined);
localStorage.setItem('metoyou_currentUserId', 'user-a');
const service = createService({ isBrowser: true, isElectron: false, isCapacitor: false });
const initialInitialize = service.initialize();
localStorage.setItem('metoyou_currentUserId', 'user-b');
const initializeAfterScopeChange = service.initialize();
expect(browserDatabase.initialize).toHaveBeenCalledTimes(1);
finishInitialInitialize();
await Promise.all([initialInitialize, initializeAfterScopeChange]);
expect(browserDatabase.initialize).toHaveBeenCalledTimes(2);
expect(service.isReady()).toBe(true);
});
it('does not reinitialize the browser backend for repeated reads in the same user scope', async () => {
const service = createService({ isBrowser: true, isElectron: false, isCapacitor: false });
await service.getBansForRoom('room-1');
await service.getBansForRoom('room-2');
expect(browserDatabase.initialize).toHaveBeenCalledTimes(1);
expect(browserDatabase.getBansForRoom).toHaveBeenCalledWith('room-1');
expect(browserDatabase.getBansForRoom).toHaveBeenCalledWith('room-2');
});
it('reinitializes the browser backend when the stored user scope changes', async () => {
localStorage.setItem('metoyou_currentUserId', 'user-a');
const service = createService({ isBrowser: true, isElectron: false, isCapacitor: false });
await service.getBansForRoom('room-1');
localStorage.setItem('metoyou_currentUserId', 'user-b');
await service.getBansForRoom('room-2');
expect(browserDatabase.initialize).toHaveBeenCalledTimes(2);
expect(browserDatabase.getBansForRoom).toHaveBeenCalledWith('room-2');
});
it('routes Capacitor shells to native SQLite instead of IndexedDB', async () => {
const service = createService({ isBrowser: false, isElectron: false, isCapacitor: true });

View File

@@ -14,6 +14,7 @@ import {
} from '../../shared-kernel';
import type { ChatAttachmentMeta, CustomEmoji } from '../../shared-kernel';
import { PlatformService } from '../../core/platform';
import { getStoredCurrentUserId } from '../../core/storage/current-user-storage';
import { BrowserDatabaseService } from './browser-database.service';
import { CapacitorDatabaseService } from './capacitor-database.service';
import { resolveDatabaseBackend } from './database-backend.rules';
@@ -42,6 +43,7 @@ export class DatabaseService {
private readonly capacitorDb = inject(CapacitorDatabaseService);
private readonly electronDb = inject(ElectronDatabaseService);
private initializationPromise: Promise<void> | null = null;
private validatedUserScope: string | null | undefined;
/** Reactive flag: `true` once {@link initialize} has completed. */
isReady = signal(false);
@@ -66,8 +68,15 @@ export class DatabaseService {
/** Initialise the platform-specific database. */
async initialize(): Promise<void> {
const userScope = getStoredCurrentUserId();
if (this.initializationPromise) {
await this.initializationPromise;
if (this.isReady() && this.validatedUserScope === userScope) {
return;
}
} else if (this.isReady() && this.validatedUserScope === userScope) {
return;
}
@@ -75,6 +84,7 @@ export class DatabaseService {
this.initializationPromise = backend.initialize()
.then(() => {
this.validatedUserScope = userScope;
this.isReady.set(true);
})
.finally(() => {
@@ -85,8 +95,11 @@ export class DatabaseService {
}
private async ensureReady(): Promise<void> {
if (this.isReady())
const userScope = getStoredCurrentUserId();
if (this.isReady() && this.validatedUserScope === userScope) {
return;
}
await this.initialize();
}

View File

@@ -164,7 +164,11 @@ The browser also sends a lightweight `keepalive` message on the signaling socket
### Server-side connection hygiene
Browsers do not reliably fire WebSocket close events during page refresh or navigation (especially Chromium). The server's `handleIdentify` now closes any existing connection that shares the same `oderId` but a different `connectionId`. This guarantees `findUserByOderId` always routes offers and presence events to the freshest socket, eliminating a class of bugs where signaling messages landed on a dead tab's socket and were silently lost.
Browsers do not reliably fire WebSocket close events during page refresh or navigation (especially Chromium). On `identify`, the server evicts stale sockets that share the same `(oderId, connectionScope, clientInstanceId)` tuple so a refreshed tab does not leave a zombie connection behind.
Multi-device sessions keep **multiple** open connections for the same `oderId` (different `clientInstanceId` values). Server broadcasts exclude only the sending **connection id**, not the whole identity, so chat/typing/voice-state updates reach every logged-in device. Presence `user_joined` / `user_left` broadcasts still exclude the whole identity so other users never see duplicate join/leave events.
RTC offers/answers/ICE are routed to the connection marked `voiceActive` for the target user (fallback: any open connection). Voice ownership is tracked per connection from `voice_state` payloads that include `clientInstanceId`.
Join and leave broadcasts are also identity-aware: `handleJoinServer` only broadcasts `user_joined` when the identity is genuinely new to that server (not just a second WebSocket connection for the same user), and `handleLeaveServer` / dead-connection cleanup only broadcast `user_left` when no other open connection for that identity remains in the server. The `user_left` payload includes `serverIds` listing the rooms the identity still belongs to, so the client can subtract correctly without over-removing.

View File

@@ -41,6 +41,7 @@ import { SignalingManager } from './signaling/signaling.manager';
import { SignalingTransportHandler } from './signaling/signaling-transport-handler';
import { WebRtcStateController } from './state/webrtc-state-controller';
import { AuthTokenStoreService } from '../../domains/authentication';
import { ClientInstanceService } from '../../core/platform/client-instance.service';
@Injectable({
providedIn: 'root'
@@ -51,6 +52,7 @@ export class WebRTCService implements OnDestroy {
private readonly screenShareSourcePicker = inject(ScreenShareSourcePickerService);
private readonly iceServerSettings = inject(IceServerSettingsService);
private readonly authTokenStore = inject(AuthTokenStoreService);
private readonly clientInstance = inject(ClientInstanceService);
private readonly logger = new WebRTCLogger(() => this.debugging.enabled());
private readonly state = new WebRtcStateController();
@@ -161,7 +163,8 @@ export class WebRTCService implements OnDestroy {
}
return null;
}
},
getClientInstanceId: () => this.clientInstance.getClientInstanceId()
});
// Now wire up cross-references (all managers are instantiated)
@@ -691,11 +694,17 @@ export class WebRTCService implements OnDestroy {
}
private relayBroadcastEvent(event: ChatEvent): void {
const clientInstanceId = this.clientInstance.getClientInstanceId();
if (event.type === 'chat-message' && event.message?.roomId) {
this.signalingTransportHandler.sendRawMessage({
type: 'chat_message',
serverId: event.message.roomId,
message: event.message
message: {
...event.message,
clientInstanceId
},
clientInstanceId
});
return;
@@ -705,11 +714,27 @@ export class WebRTCService implements OnDestroy {
this.signalingTransportHandler.sendRawMessage({
...event,
type: 'voice_state',
serverId: event.voiceState.serverId
serverId: event.voiceState.serverId,
voiceState: {
...event.voiceState,
clientInstanceId
},
clientInstanceId
});
}
}
requestVoiceClientTakeover(): void {
this.signalingTransportHandler.sendRawMessage({
type: 'voice_client_takeover',
clientInstanceId: this.clientInstance.getClientInstanceId()
});
}
getClientInstanceId(): string {
return this.clientInstance.getClientInstanceId();
}
/** Disconnect from the signaling server and clean up all state. */
disconnect(): void {
this.leaveRoom();

View File

@@ -44,6 +44,8 @@ export interface IdentifyCredentials {
profileUpdatedAt?: number;
/** Public signal-server URL where this user registered. */
homeSignalServerUrl?: string;
/** Stable per-install client id used for multi-device session routing. */
clientInstanceId?: string;
}
/** Last-joined server info, used for reconnection. */
@@ -76,4 +78,6 @@ export interface VoiceStateSnapshot {
roomId?: string;
/** The voice channel server ID, if applicable. */
serverId?: string;
/** Install-scoped client id that owns active voice for this snapshot. */
clientInstanceId?: string;
}

View File

@@ -14,6 +14,7 @@ interface SignalingTransportHandlerDependencies<TMessage> {
logger: WebRTCLogger;
getLocalPeerId(): string;
resolveSessionToken(signalUrl?: string): string | null;
getClientInstanceId(): string;
}
export class SignalingTransportHandler<TMessage> {
@@ -201,13 +202,16 @@ export class SignalingTransportHandler<TMessage> {
return;
}
const clientInstanceId = this.dependencies.getClientInstanceId();
this.lastIdentifyCredentials = {
oderId,
token,
displayName: normalizedDisplayName,
description: normalizedDescription,
profileUpdatedAt: normalizedProfileUpdatedAt,
homeSignalServerUrl: normalizedHomeSignalServerUrl
homeSignalServerUrl: normalizedHomeSignalServerUrl,
clientInstanceId
};
if (signalUrl) {
@@ -219,7 +223,8 @@ export class SignalingTransportHandler<TMessage> {
description: normalizedDescription,
profileUpdatedAt: normalizedProfileUpdatedAt,
homeSignalServerUrl: normalizedHomeSignalServerUrl,
connectionScope: signalUrl
connectionScope: signalUrl,
clientInstanceId
});
return;
@@ -240,7 +245,8 @@ export class SignalingTransportHandler<TMessage> {
description: normalizedDescription,
profileUpdatedAt: normalizedProfileUpdatedAt,
homeSignalServerUrl: normalizedHomeSignalServerUrl,
connectionScope: managerSignalUrl
connectionScope: managerSignalUrl,
clientInstanceId
});
}
}

View File

@@ -379,7 +379,8 @@ export class SignalingManager {
description: credentials.description,
profileUpdatedAt: credentials.profileUpdatedAt,
homeSignalServerUrl: credentials.homeSignalServerUrl,
connectionScope: this.lastSignalingUrl ?? undefined
connectionScope: this.lastSignalingUrl ?? undefined,
clientInstanceId: credentials.clientInstanceId
});
}

View File

@@ -26,6 +26,8 @@ export interface Message {
isDeleted: boolean;
replyToId?: string;
linkMetadata?: LinkMetadata[];
/** Originating client instance when relayed through signaling for multi-device sync. */
clientInstanceId?: string;
}
export interface Reaction {

View File

@@ -7,6 +7,8 @@ export interface VoiceState {
volume?: number;
roomId?: string;
serverId?: string;
/** Install-scoped client id that currently owns active voice for this user. */
clientInstanceId?: string;
}
export interface ScreenShareState {

View File

@@ -1,5 +1,8 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
AfterViewInit,
Component,
ElementRef,
HostListener,
inject,
input,
@@ -8,6 +11,11 @@ import {
import { ThemeNodeDirective } from '../../../domains/theme';
import { ViewportService } from '../../../core/platform';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../core/i18n';
import {
findPrimaryDialogField,
shouldSelectOnFocus,
shouldSubmitOnEnter
} from '../../directives/form-field-focus.rules';
import { BottomSheetComponent } from '../bottom-sheet/bottom-sheet.component';
import { ModalBackdropComponent } from '../modal-backdrop/modal-backdrop.component';
@@ -25,7 +33,7 @@ import { ModalBackdropComponent } from '../modal-backdrop/modal-backdrop.compone
style: 'display: contents;'
}
})
export class ConfirmDialogComponent {
export class ConfirmDialogComponent implements AfterViewInit {
private readonly appI18n = inject(AppI18nService);
title = input.required<string>();
@@ -42,4 +50,37 @@ export class ConfirmDialogComponent {
onEscape(): void {
this.cancelled.emit(undefined);
}
@HostListener('keydown.enter', ['$event'])
onEnter(event: Event): void {
if (!(event instanceof KeyboardEvent) || !shouldSubmitOnEnter(event)) {
return;
}
event.preventDefault();
this.confirmed.emit(undefined);
}
ngAfterViewInit(): void {
requestAnimationFrame(() => this.focusPrimaryField());
}
private readonly hostRef = inject<ElementRef<HTMLElement>>(ElementRef);
private focusPrimaryField(): void {
const field = findPrimaryDialogField(this.hostRef.nativeElement);
if (!field) {
return;
}
field.focus();
if (shouldSelectOnFocus({
type: field instanceof HTMLInputElement ? field.type : 'textarea',
value: field.value
})) {
field.select();
}
}
}

View File

@@ -55,6 +55,10 @@
@if (isEditable && activeField === 'displayName') {
<input
type="text"
appAutoFocus
appSelectOnFocus
appSubmitOnEnter
(submitOnEnter)="finishEdit('displayName')"
class="w-full rounded-lg border border-border bg-background/70 px-3 py-2 text-center text-lg font-semibold text-foreground outline-none focus:border-primary/70"
[value]="displayNameDraft()"
(input)="onDisplayNameInput($event)"
@@ -141,6 +145,8 @@
@if (activeField === 'description') {
<textarea
rows="3"
appAutoFocus
appSelectOnFocus
class="w-full resize-none rounded-lg border border-border bg-background/70 px-3 py-2 text-sm leading-5 text-foreground outline-none focus:border-primary/70"
[value]="descriptionDraft()"
[placeholder]="'profile.card.addDescription' | translate"

View File

@@ -44,6 +44,11 @@ import {
import { ServerDirectoryFacade } from '../../../domains/server-directory';
import { resolveUserHomeSignalServerTag } from '../../../domains/server-directory/domain/logic/signal-server-tag.rules';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../core/i18n';
import {
AutoFocusDirective,
SelectOnFocusDirective,
SubmitOnEnterDirective
} from '../../directives';
@Component({
selector: 'app-profile-card-mobile',
@@ -54,6 +59,9 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../core/i18n';
UserAvatarComponent,
ProfileSignalServerTagComponent,
ThemeNodeDirective,
AutoFocusDirective,
SelectOnFocusDirective,
SubmitOnEnterDirective,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [

View File

@@ -50,6 +50,10 @@
@if (activeField === 'displayName') {
<input
type="text"
appAutoFocus
appSelectOnFocus
appSubmitOnEnter
(submitOnEnter)="finishEdit('displayName')"
class="w-full rounded-md border border-border bg-background/70 px-2 py-1.5 text-base font-semibold text-foreground outline-none focus:border-primary/70"
[value]="displayNameDraft()"
(input)="onDisplayNameInput($event)"
@@ -95,6 +99,8 @@
@if (activeField === 'description') {
<textarea
rows="3"
appAutoFocus
appSelectOnFocus
class="w-full resize-none rounded-md border border-border bg-background/70 px-2 py-2 text-sm leading-5 text-foreground outline-none focus:border-primary/70"
[value]="descriptionDraft()"
placeholder="{{ 'profile.card.addDescription' | translate }}"

View File

@@ -36,6 +36,11 @@ import { ThemeNodeDirective } from '../../../domains/theme';
import { formatGameActivityElapsed } from '../../../domains/game-activity';
import { ExternalLinkService } from '../../../core/platform/external-link.service';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../core/i18n';
import {
AutoFocusDirective,
SelectOnFocusDirective,
SubmitOnEnterDirective
} from '../../directives';
import { visibilityAwareInterval$ } from '../../rxjs';
import { ServerDirectoryFacade } from '../../../domains/server-directory';
import { resolveUserHomeSignalServerTag } from '../../../domains/server-directory/domain/logic/signal-server-tag.rules';
@@ -49,6 +54,9 @@ import { resolveUserHomeSignalServerTag } from '../../../domains/server-director
UserAvatarComponent,
ProfileSignalServerTagComponent,
ThemeNodeDirective,
AutoFocusDirective,
SelectOnFocusDirective,
SubmitOnEnterDirective,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [provideIcons({ lucideCheck, lucideChevronDown, lucideGamepad2 })],

View File

@@ -0,0 +1,23 @@
import {
AfterViewInit,
Directive,
ElementRef,
inject
} from '@angular/core';
/**
* Focuses the host input, textarea, or select after the view initializes.
*/
@Directive({
selector: 'input[appAutoFocus], textarea[appAutoFocus], select[appAutoFocus]',
standalone: true
})
export class AutoFocusDirective implements AfterViewInit {
private readonly hostRef = inject<ElementRef<HTMLElement>>(ElementRef);
ngAfterViewInit(): void {
requestAnimationFrame(() => {
this.hostRef.nativeElement.focus();
});
}
}

View File

@@ -0,0 +1,65 @@
import {
describe,
expect,
it
} from 'vitest';
import { shouldSelectOnFocus, shouldSubmitOnEnter } from './form-field-focus.rules';
describe('shouldSelectOnFocus', () => {
it('selects when a text input has a value', () => {
expect(shouldSelectOnFocus({ type: 'text',
value: 'alice' })).toBe(true);
});
it('skips empty values', () => {
expect(shouldSelectOnFocus({ type: 'text',
value: '' })).toBe(false);
});
it('skips password fields by default', () => {
expect(shouldSelectOnFocus({ type: 'password',
value: 'secret' })).toBe(false);
});
it('can select password fields when skipPassword is false', () => {
expect(shouldSelectOnFocus({ type: 'password',
value: 'secret' }, false)).toBe(true);
});
});
describe('shouldSubmitOnEnter', () => {
it('submits from text inputs', () => {
expect(shouldSubmitOnEnter({
key: 'Enter',
isComposing: false,
target: { tagName: 'INPUT',
type: 'text' }
})).toBe(true);
});
it('does not submit from textareas', () => {
expect(shouldSubmitOnEnter({
key: 'Enter',
isComposing: false,
target: { tagName: 'TEXTAREA' }
})).toBe(false);
});
it('does not submit from selects', () => {
expect(shouldSubmitOnEnter({
key: 'Enter',
isComposing: false,
target: { tagName: 'SELECT' }
})).toBe(false);
});
it('ignores IME composition', () => {
expect(shouldSubmitOnEnter({
key: 'Enter',
isComposing: true,
target: { tagName: 'INPUT',
type: 'text' }
})).toBe(false);
});
});

View File

@@ -0,0 +1,97 @@
const NON_SUBMITTABLE_INPUT_TYPES = new Set([
'button',
'checkbox',
'file',
'hidden',
'image',
'radio',
'reset',
'submit'
]);
interface SelectableField {
type?: string;
value: string;
}
interface EnterTarget {
tagName: string;
type?: string;
}
/**
* Whether an input/textarea should have its value selected when focused.
* Password fields are skipped by default so users can append characters safely.
*/
export function shouldSelectOnFocus(
element: SelectableField,
skipPassword = true
): boolean {
const type = element.type?.toLowerCase() ?? 'text';
if (skipPassword && type === 'password') {
return false;
}
return element.value.length > 0;
}
/**
* Finds the first editable text field inside a dialog or form container.
*/
export function findPrimaryDialogField(
root: ParentNode
): HTMLInputElement | HTMLTextAreaElement | null {
const input = root.querySelector(
'input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]):not([disabled])'
);
if (input instanceof HTMLInputElement) {
return input;
}
const textarea = root.querySelector('textarea:not([disabled])');
return textarea instanceof HTMLTextAreaElement ? textarea : null;
}
function readEnterTarget(target: EventTarget | null): EnterTarget | null {
if (!target || typeof target !== 'object' || !('tagName' in target)) {
return null;
}
const candidate = target as EnterTarget;
return {
tagName: String(candidate.tagName).toUpperCase(),
type: typeof candidate.type === 'string' ? candidate.type : undefined
};
}
/**
* Whether Enter in the current event target should trigger a form/dialog submit.
* Textareas and selects are excluded; IME composition is ignored.
*/
export function shouldSubmitOnEnter(event: Pick<KeyboardEvent, 'key' | 'isComposing'> & {
target: EventTarget | null;
}): boolean {
if (event.isComposing || event.key !== 'Enter') {
return false;
}
const target = readEnterTarget(event.target);
if (!target) {
return false;
}
if (target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') {
return false;
}
if (target.tagName === 'INPUT') {
return !NON_SUBMITTABLE_INPUT_TYPES.has((target.type ?? 'text').toLowerCase());
}
return false;
}

View File

@@ -0,0 +1,8 @@
export { AutoFocusDirective } from './auto-focus.directive';
export { SelectOnFocusDirective } from './select-on-focus.directive';
export { SubmitOnEnterDirective } from './submit-on-enter.directive';
export {
findPrimaryDialogField,
shouldSelectOnFocus,
shouldSubmitOnEnter
} from './form-field-focus.rules';

View File

@@ -0,0 +1,36 @@
import {
Directive,
ElementRef,
HostListener,
inject,
input
} from '@angular/core';
import { shouldSelectOnFocus } from './form-field-focus.rules';
/**
* Selects the full field value when the user focuses a text input or textarea.
*/
@Directive({
selector: 'input[appSelectOnFocus], textarea[appSelectOnFocus]',
standalone: true
})
export class SelectOnFocusDirective {
readonly skipPassword = input(true);
private readonly hostRef = inject<ElementRef<HTMLInputElement | HTMLTextAreaElement>>(ElementRef);
@HostListener('focus')
onFocus(): void {
const element = this.hostRef.nativeElement;
if (!shouldSelectOnFocus({
type: element.type,
value: element.value
}, this.skipPassword())) {
return;
}
element.select();
}
}

View File

@@ -0,0 +1,29 @@
import {
Directive,
HostListener,
output
} from '@angular/core';
import { shouldSubmitOnEnter } from './form-field-focus.rules';
/**
* Emits when Enter is pressed in a submittable input field.
* Use on single-field actions or alongside native `<form ngSubmit>`.
*/
@Directive({
selector: 'input[appSubmitOnEnter], select[appSubmitOnEnter]',
standalone: true
})
export class SubmitOnEnterDirective {
readonly submitOnEnter = output();
@HostListener('keydown.enter', ['$event'])
onEnter(event: Event): void {
if (!(event instanceof KeyboardEvent) || !shouldSubmitOnEnter(event)) {
return;
}
event.preventDefault();
this.submitOnEnter.emit();
}
}

View File

@@ -22,3 +22,8 @@ export {
SkeletonMessageComponent,
SkeletonCardComponent
} from './components/skeleton';
export {
AutoFocusDirective,
SelectOnFocusDirective,
SubmitOnEnterDirective
} from './directives';

View File

@@ -34,10 +34,43 @@ function createContext(overrides: Record<string, unknown> = {}) {
currentUser: null,
currentRoom: null,
savedRooms: [],
getClientInstanceId: () => 'device-a',
...overrides
} as const;
}
describe('dispatchIncomingMessage multi-device sync', () => {
it('accepts own messages that originated on another client instance', async () => {
const saveMessage = vi.fn(async () => undefined);
const rememberMessageRoom = vi.fn();
const context = createContext({
db: { saveMessage },
attachments: { rememberMessageRoom },
currentUser: { id: 'user-1', oderId: 'user-1' },
currentRoom: { id: 'room-a' },
savedRooms: [{ id: 'room-a' }],
getClientInstanceId: () => 'device-a'
});
const action = await firstValueFrom(
dispatchIncomingMessage(
{
type: 'chat-message',
message: createMessage({
senderId: 'user-1',
roomId: 'room-a',
clientInstanceId: 'device-b'
})
} as never,
context as never
).pipe(defaultIfEmpty(null))
);
expect(action).not.toBeNull();
expect(saveMessage).toHaveBeenCalled();
});
});
describe('dispatchIncomingMessage room-scoped sync', () => {
it('requests sync for event room even when another room is viewed', async () => {
const getRoomMessageStats = vi.fn(async (roomId: string) => roomId === 'room-b'

View File

@@ -102,6 +102,7 @@ export interface IncomingMessageContext {
currentUser: User | null;
currentRoom: Room | null;
savedRooms?: Room[];
getClientInstanceId: () => string;
}
/** Signature for an incoming-message handler function. */
@@ -362,12 +363,12 @@ function handleChatMessage(
if (!isKnownRoomId(msg.roomId, ctx))
return EMPTY;
// Skip our own messages (reflected via server relay)
const isOwnMessage =
msg.senderId === currentUser?.id ||
msg.senderId === currentUser?.oderId;
// Skip only messages that originated on this client instance.
const isOwnMessageOnThisClient =
(msg.senderId === currentUser?.id || msg.senderId === currentUser?.oderId)
&& (!msg.clientInstanceId || msg.clientInstanceId === ctx.getClientInstanceId());
if (isOwnMessage)
if (isOwnMessageOnThisClient)
return EMPTY;
attachments.rememberMessageRoom(msg.id, msg.roomId);

View File

@@ -46,7 +46,6 @@ import { TimeSyncService } from '../../core/services/time-sync.service';
import { PlatformService } from '../../core/platform';
import { AppI18nService } from '../../core/i18n';
import {
DELETED_MESSAGE_CONTENT,
Message,
Reaction,
Room
@@ -57,6 +56,7 @@ import { resolveRoomPermission } from '../../domains/access-control';
import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers';
import { MessageRevisionService } from '../../domains/chat/application/services/message-revision.service';
import { materializeMessageFromRevision } from '../../domains/chat/domain/rules/message-revision.builder.rules';
import { setStoredCurrentUserId } from '../../core/storage/current-user-storage';
const INITIAL_ROOM_MESSAGE_LIMIT = 30;
/** Cap on simultaneous browser-cache prefetches for apps with many saved rooms. */
@@ -261,24 +261,11 @@ export class MessagesEffects {
});
const message = materializeMessageFromRevision(null, revision);
setStoredCurrentUserId(currentUser.id);
this.attachments.rememberMessageRoom(message.id, message.roomId);
this.trackBackgroundOperation(
this.db.saveMessage(message),
'Failed to persist outgoing chat message',
{
channelId: message.channelId,
contentLength: message.content.length,
messageId: message.id,
roomId: message.roomId
}
);
this.trackBackgroundOperation(
this.messageRevisions.persistRevision(revision),
'Failed to persist outgoing message revision',
{ messageId: message.id, revision: revision.revision }
);
await this.db.saveMessage(message);
await this.messageRevisions.persistRevision(revision);
this.customEmoji.pushEmojisInContent(content);
this.webrtc.broadcastMessage({ type: 'chat-message', message });
@@ -482,6 +469,7 @@ export class MessagesEffects {
deletedBy: currentUser.id,
deletedAt
});
this.messageRevisions.broadcastRevision(revision);
return MessagesActions.deleteMessageSuccess({ messageId });
@@ -667,7 +655,8 @@ export class MessagesEffects {
messageRevisions: this.messageRevisions,
currentUser: currentUser ?? null,
currentRoom,
savedRooms
savedRooms,
getClientInstanceId: () => this.webrtc.getClientInstanceId()
};
return dispatchIncomingMessage(event as Parameters<typeof dispatchIncomingMessage>[0], ctx).pipe(
@@ -712,6 +701,19 @@ export class MessagesEffects {
return EMPTY;
}
const chatRelayEvent = event as {
message?: Message & { clientInstanceId?: string };
clientInstanceId?: string;
fromUserId?: string;
senderId?: string;
serverId?: string;
};
const signalingMessage = chatRelayEvent.message && typeof chatRelayEvent.message === 'object'
? {
...chatRelayEvent.message,
clientInstanceId: chatRelayEvent.message.clientInstanceId ?? chatRelayEvent.clientInstanceId
}
: chatRelayEvent.message;
const ctx: IncomingMessageContext = {
db: this.db,
webrtc: this.webrtc,
@@ -720,13 +722,15 @@ export class MessagesEffects {
messageRevisions: this.messageRevisions,
currentUser: currentUser ?? null,
currentRoom,
savedRooms
savedRooms,
getClientInstanceId: () => this.webrtc.getClientInstanceId()
};
return dispatchIncomingMessage({
...event,
type: 'chat-message',
fromPeerId: event.fromUserId
fromPeerId: event.fromUserId ?? (event as { senderId?: string }).senderId,
message: signalingMessage
}, ctx).pipe(
catchError((error) => {
reportDebuggingError(this.debugging, 'messages', 'Failed to process incoming signaling chat message', {

View File

@@ -42,7 +42,9 @@ import type {
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
import { hasRoomBanForUser } from '../../domains/access-control';
import { RECONNECT_SOUND_GRACE_MS } from '../../core/constants';
import { VoiceSessionFacade } from '../../domains/voice-session';
import { VoiceSessionFacade, VoiceClientTakeoverService } from '../../domains/voice-session';
import { ClientInstanceService } from '../../core/platform/client-instance.service';
import { isVoiceOnAnotherClient } from '../../domains/voice-session/domain/logic/client-voice-session.rules';
import {
buildSignalingUser,
buildKnownUserExtras,
@@ -76,6 +78,8 @@ export class RoomStateSyncEffects {
private db = inject(DatabaseService);
private audioService = inject(NotificationAudioService);
private voiceSessionService = inject(VoiceSessionFacade);
private voiceClientTakeoverService = inject(VoiceClientTakeoverService);
private clientInstanceService = inject(ClientInstanceService);
/**
* Tracks user IDs we already know are in voice. Lives outside the
@@ -241,12 +245,18 @@ export class RoomStateSyncEffects {
const voiceEvent = {
...signalingMessage,
type: 'voice-state',
fromPeerId: signalingMessage.oderId ?? signalingMessage.fromUserId
fromPeerId: signalingMessage.oderId ?? signalingMessage.fromUserId,
voiceState: this.normalizeSignalingVoiceState(signalingMessage)
} as ChatEvent;
return this.handleVoiceOrScreenState(voiceEvent, allUsers, currentUser ?? null, 'voice');
}
case 'voice_client_takeover': {
this.voiceClientTakeoverService.releaseLocalVoiceForTakeover(currentUser ?? null);
return EMPTY;
}
case 'access_denied': {
if (isWrongServer(signalingMessage.serverId, viewedServerId))
return EMPTY;
@@ -479,7 +489,10 @@ export class RoomStateSyncEffects {
return EMPTY;
const existingUser = allUsers.find((user) => user.id === userId || user.oderId === userId);
const resolvedUserId = existingUser?.id ?? userId;
const userExists = !!existingUser;
const localClientInstanceId = this.clientInstanceService.getClientInstanceId();
const isCurrentUserEvent = !!currentUser && (currentUser.id === userId || currentUser.oderId === userId);
if (kind === 'voice') {
const vs = event.voiceState as Partial<VoiceState> | undefined;
@@ -503,8 +516,14 @@ export class RoomStateSyncEffects {
const wasInCurrentVoiceRoom = this.isSameVoiceRoom(existingUser?.voiceState, currentUser?.voiceState);
const mergedVoiceState = { ...existingUser?.voiceState, ...vs };
const isInCurrentVoiceRoom = this.isSameVoiceRoom(mergedVoiceState, currentUser?.voiceState);
const skipSelfPassiveSounds = isCurrentUserEvent
&& (isVoiceOnAnotherClient(
{ isConnected: mergedVoiceState.isConnected ?? false, clientInstanceId: mergedVoiceState.clientInstanceId },
localClientInstanceId
)
|| isVoiceOnAnotherClient(existingUser?.voiceState, localClientInstanceId));
if (weAreInVoice) {
if (weAreInVoice && !skipSelfPassiveSounds) {
const isReconnect = this.consumeRecentLeave(userId);
if (!isReconnect) {
@@ -537,7 +556,8 @@ export class RoomStateSyncEffects {
isMutedByAdmin: vs.isMutedByAdmin,
volume: vs.volume,
roomId: vs.roomId,
serverId: vs.serverId
serverId: vs.serverId,
clientInstanceId: vs.clientInstanceId
}
}
)
@@ -551,7 +571,7 @@ export class RoomStateSyncEffects {
actions.push(presenceRefreshAction);
}
actions.push(UsersActions.updateVoiceState({ userId, voiceState: vs }));
actions.push(UsersActions.updateVoiceState({ userId: resolvedUserId, voiceState: vs }));
return actions;
}
@@ -953,6 +973,26 @@ export class RoomStateSyncEffects {
// ── Internal helpers ───────────────────────────────────────────
private normalizeSignalingVoiceState(signalingMessage: RoomPresenceSignalingMessage): Partial<VoiceState> {
const nested = signalingMessage.voiceState;
if (nested && typeof nested === 'object') {
return nested as Partial<VoiceState>;
}
return {
isConnected: signalingMessage.isConnected === true,
isMuted: signalingMessage.isMuted === true,
isDeafened: signalingMessage.isDeafened === true,
isSpeaking: signalingMessage.isSpeaking === true,
roomId: typeof signalingMessage.roomId === 'string' ? signalingMessage.roomId : undefined,
serverId: typeof signalingMessage.serverId === 'string' ? signalingMessage.serverId : undefined,
clientInstanceId: typeof signalingMessage.clientInstanceId === 'string'
? signalingMessage.clientInstanceId
: undefined
};
}
private syncBansToLocalRoom(roomId: string, bans: BanEntry[]) {
return from(this.db.getBansForRoom(roomId)).pipe(
switchMap((localBans) => {

View File

@@ -41,6 +41,7 @@ import {
loadLastViewedChatFromStorage,
saveLastViewedChatToStorage
} from '../../infrastructure/persistence';
import { setStoredCurrentUserId } from '../../core/storage/current-user-storage';
import { AppI18nService } from '../../core/i18n';
import { ServerDirectoryFacade } from '../../domains/server-directory';
import { hasRoomBanForUser } from '../../domains/access-control';
@@ -219,6 +220,9 @@ export class RoomsEffects {
?? allEndpoints[0]
?? null;
const normalizedPassword = typeof password === 'string' ? password.trim() : '';
setStoredCurrentUserId(currentUser.id);
const room: Room = {
id: uuidv4(),
name,
@@ -237,35 +241,36 @@ export class RoomsEffects {
sourceUrl: endpoint?.url
};
// Save to local DB
this.db.saveRoom(room);
return from(this.db.saveRoom(room)).pipe(
map(() => {
// Register with central server (using the same room ID for discoverability)
this.serverDirectory
.registerServer({
id: room.id, // Use the same ID as the local room
name: room.name,
description: room.description,
ownerId: currentUser.id,
ownerPublicKey: currentUser.oderId,
hostName: currentUser.displayName,
password: normalizedPassword || null,
hasPassword: normalizedPassword.length > 0,
isPrivate: room.isPrivate,
userCount: room.userCount,
maxUsers: room.maxUsers || 50,
icon: room.icon,
iconUpdatedAt: room.iconUpdatedAt,
tags: [],
channels: room.channels ?? defaultChannels()
}, endpoint ? {
sourceId: endpoint.id,
sourceUrl: endpoint.url
} : undefined
)
.subscribe();
// Register with central server (using the same room ID for discoverability)
this.serverDirectory
.registerServer({
id: room.id, // Use the same ID as the local room
name: room.name,
description: room.description,
ownerId: currentUser.id,
ownerPublicKey: currentUser.oderId,
hostName: currentUser.displayName,
password: normalizedPassword || null,
hasPassword: normalizedPassword.length > 0,
isPrivate: room.isPrivate,
userCount: 1,
maxUsers: room.maxUsers || 50,
icon: room.icon,
iconUpdatedAt: room.iconUpdatedAt,
tags: [],
channels: room.channels ?? defaultChannels()
}, endpoint ? {
sourceId: endpoint.id,
sourceUrl: endpoint.url
} : undefined
)
.subscribe();
return of(RoomsActions.createRoomSuccess({ room }));
return RoomsActions.createRoomSuccess({ room });
})
);
}),
catchError((error) => of(RoomsActions.createRoomFailure({ error: error.message })))
)
@@ -303,6 +308,8 @@ export class RoomsEffects {
: undefined;
if (room) {
setStoredCurrentUserId(currentUser.id);
const resolvedSource = this.serverDirectory.normaliseRoomSignalSource({
sourceId: serverInfo?.sourceId ?? room.sourceId,
sourceName: serverInfo?.sourceName ?? room.sourceName,
@@ -329,7 +336,7 @@ export class RoomsEffects {
: (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password)
};
this.db.updateRoom(room.id, {
return from(this.db.updateRoom(room.id, {
sourceId: resolvedRoom.sourceId,
sourceName: resolvedRoom.sourceName,
sourceUrl: resolvedRoom.sourceUrl,
@@ -342,13 +349,13 @@ export class RoomsEffects {
iconUpdatedAt: resolvedRoom.iconUpdatedAt,
hasPassword: resolvedRoom.hasPassword,
isPrivate: resolvedRoom.isPrivate
});
return of(RoomsActions.joinRoomSuccess({ room: resolvedRoom }));
})).pipe(map(() => RoomsActions.joinRoomSuccess({ room: resolvedRoom })));
}
// If not in local DB but we have server info from search, create a room entry
if (serverInfo) {
setStoredCurrentUserId(currentUser.id);
const resolvedSource = this.serverDirectory.normaliseRoomSignalSource({
sourceId: serverInfo.sourceId,
sourceName: serverInfo.sourceName,
@@ -378,15 +385,17 @@ export class RoomsEffects {
...resolvedSource
};
// Save to local DB for future reference
this.db.saveRoom(newRoom);
return of(RoomsActions.joinRoomSuccess({ room: newRoom }));
return from(this.db.saveRoom(newRoom)).pipe(
map(() => RoomsActions.joinRoomSuccess({ room: newRoom }))
);
}
// Try to get room info from server
return this.serverDirectory.getServer(roomId, sourceSelector).pipe(
switchMap((serverData) => {
if (serverData) {
setStoredCurrentUserId(currentUser.id);
const resolvedSource = this.serverDirectory.normaliseRoomSignalSource({
sourceId: serverData.sourceId,
sourceName: serverData.sourceName,
@@ -415,8 +424,9 @@ export class RoomsEffects {
...resolvedSource
};
this.db.saveRoom(newRoom);
return of(RoomsActions.joinRoomSuccess({ room: newRoom }));
return from(this.db.saveRoom(newRoom)).pipe(
map(() => RoomsActions.joinRoomSuccess({ room: newRoom }))
);
}
return of(RoomsActions.joinRoomFailure({ error: this.i18n.instant('servers.errors.roomNotFound') }));

View File

@@ -225,4 +225,19 @@ export interface RoomPresenceSignalingMessage {
profileUpdatedAt?: number;
homeSignalServerUrl?: string;
status?: string;
voiceState?: {
isConnected?: boolean;
isMuted?: boolean;
isDeafened?: boolean;
isSpeaking?: boolean;
roomId?: string;
serverId?: string;
clientInstanceId?: string;
};
isConnected?: boolean;
isMuted?: boolean;
isDeafened?: boolean;
isSpeaking?: boolean;
roomId?: string;
clientInstanceId?: string;
}

View File

@@ -54,6 +54,7 @@ import { findRoomMember, removeRoomMember } from '../rooms/room-members.helpers'
import { AppI18nService } from '../../core/i18n';
import { AuthTokenStoreService } from '../../domains/authentication/application/services/auth-token-store.service';
import { hasValidPersistedSession, SESSION_EXPIRED_ERROR_CODE } from '../../domains/authentication/domain/logic/auth-session.rules';
import { buildLoginReturnQueryParams } from '../../domains/authentication/domain/logic/auth-navigation.rules';
type IncomingModerationExtraAction =
| ReturnType<typeof RoomsActions.forgetRoom>
@@ -80,7 +81,7 @@ export class UsersEffects {
this.actions$.pipe(
ofType(UsersActions.authenticateUser),
switchMap(({ user }) =>
from(this.prepareAuthenticatedUserStorage(user.id)).pipe(
from(this.prepareAuthenticatedUserStorage(user)).pipe(
mergeMap(() => [
MessagesActions.clearMessages(),
UsersActions.resetUsersState(),
@@ -165,10 +166,11 @@ export class UsersEffects {
};
}
private async prepareAuthenticatedUserStorage(userId: string): Promise<void> {
setStoredCurrentUserId(userId);
private async prepareAuthenticatedUserStorage(user: User): Promise<void> {
setStoredCurrentUserId(user.id);
await this.db.initialize();
await this.db.setCurrentUserId(userId);
await this.db.setCurrentUserId(user.id);
await this.db.saveUser(user);
}
/** Loads all users associated with a specific room from the local database. */
@@ -476,6 +478,7 @@ export class UsersEffects {
withLatestFrom(this.store.select(selectCurrentUser)),
tap(([, user]) => {
if (user) {
setStoredCurrentUserId(user.id);
this.db.saveUser(user);
// Ensure current user ID is persisted when explicitly set
this.db.setCurrentUserId(user.id);
@@ -491,12 +494,16 @@ export class UsersEffects {
this.actions$.pipe(
ofType(UsersActions.loadCurrentUserFailure),
filter(({ error }) => error === SESSION_EXPIRED_ERROR_CODE),
tap(() => {
withLatestFrom(this.store.select(selectCurrentUser)),
tap(([, currentUser]) => {
if (currentUser) {
setStoredCurrentUserId(currentUser.id);
return;
}
clearStoredCurrentUserId();
void this.router.navigate(['/login'], {
queryParams: {
returnUrl: this.router.url
}
queryParams: buildLoginReturnQueryParams(this.router.url)
});
})
),

View File

@@ -518,6 +518,7 @@ export const usersReducer = createReducer(
};
const hasRoomId = Object.prototype.hasOwnProperty.call(voiceState, 'roomId');
const hasServerId = Object.prototype.hasOwnProperty.call(voiceState, 'serverId');
const hasClientInstanceId = Object.prototype.hasOwnProperty.call(voiceState, 'clientInstanceId');
return usersAdapter.updateOne(
{
@@ -531,7 +532,8 @@ export const usersReducer = createReducer(
isMutedByAdmin: voiceState.isMutedByAdmin ?? prev.isMutedByAdmin,
volume: voiceState.volume ?? prev.volume,
roomId: hasRoomId ? voiceState.roomId : prev.roomId,
serverId: hasServerId ? voiceState.serverId : prev.serverId
serverId: hasServerId ? voiceState.serverId : prev.serverId,
clientInstanceId: hasClientInstanceId ? voiceState.clientInstanceId : prev.clientInstanceId
}
}
},

Some files were not shown because too many files have changed in this diff Show More