Allow multiple signal servers (might need rollback)
All checks were successful
Queue Release Build / prepare (push) Successful in 17s
Deploy Web Apps / deploy (push) Successful in 9m58s
Queue Release Build / build-linux (push) Successful in 26m26s
Queue Release Build / build-windows (push) Successful in 25m3s
Queue Release Build / finalize (push) Successful in 1m43s

This commit is contained in:
2026-03-19 01:20:20 +01:00
parent c862c2fe03
commit c3ef8e8800
18 changed files with 1309 additions and 297 deletions

View File

@@ -50,47 +50,6 @@
<app-floating-voice-controls />
</div>
@if (desktopUpdateState().serverBlocked) {
<div class="fixed inset-0 z-[80] flex items-center justify-center bg-background/95 px-6 py-10 backdrop-blur-sm">
<div class="w-full max-w-xl rounded-2xl border border-red-500/30 bg-card p-6 shadow-2xl">
<h2 class="text-xl font-semibold text-foreground">Server update required</h2>
<p class="mt-3 text-sm text-muted-foreground">
{{ desktopUpdateState().serverBlockMessage || 'The connected server must be updated before this desktop app can continue.' }}
</p>
<div class="mt-5 grid gap-4 rounded-xl border border-border bg-secondary/20 p-4 text-sm text-muted-foreground sm:grid-cols-2">
<div>
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Connected server</p>
<p class="mt-2 text-foreground">{{ desktopUpdateState().serverVersion || 'Not reported' }}</p>
</div>
<div>
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Required minimum</p>
<p class="mt-2 text-foreground">{{ desktopUpdateState().minimumServerVersion || 'Unknown' }}</p>
</div>
</div>
<div class="mt-6 flex flex-wrap gap-3">
<button
type="button"
(click)="refreshDesktopUpdateContext()"
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
>
Retry
</button>
<button
type="button"
(click)="openNetworkSettings()"
class="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Open network settings
</button>
</div>
</div>
</div>
}
<!-- Unified Settings Modal -->
<app-settings-modal />

View File

@@ -12,10 +12,37 @@ import {
forkJoin
} from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../constants';
import { ServerInfo, User } from '../models/index';
import { v4 as uuidv4 } from 'uuid';
import { environment } from '../../../environments/environment';
interface DefaultServerDefinition {
key: string;
name: string;
url: string;
}
interface HealthCheckPayload {
serverVersion?: unknown;
}
interface DesktopUpdateStateSnapshot {
currentVersion?: unknown;
}
interface DesktopUpdateBridge {
getAutoUpdateState?: () => Promise<DesktopUpdateStateSnapshot>;
}
type VersionAwareWindow = Window & {
electronAPI?: DesktopUpdateBridge;
};
type DefaultEndpointTemplate = Omit<ServerEndpoint, 'id' | 'defaultKey'> & {
defaultKey: string;
};
/**
* A configured server endpoint that the user can connect to.
*/
@@ -30,10 +57,16 @@ export interface ServerEndpoint {
isActive: boolean;
/** Whether this is the built-in default endpoint. */
isDefault: boolean;
/** Stable identifier for a built-in default endpoint. */
defaultKey?: string;
/** Most recent health-check result. */
status: 'online' | 'offline' | 'checking' | 'unknown';
status: 'online' | 'offline' | 'checking' | 'unknown' | 'incompatible';
/** Last measured round-trip latency (ms). */
latency?: number;
/** Last reported signaling-server version from /api/health. */
serverVersion?: string;
/** Local desktop client version used for compatibility checks. */
clientVersion?: string;
}
export interface ServerSourceSelector {
@@ -101,9 +134,13 @@ export interface UnbanServerMemberRequest {
/** localStorage key that persists the user's configured endpoints. */
const ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints';
/** localStorage key that tracks which built-in endpoints the user removed. */
const REMOVED_DEFAULT_ENDPOINT_KEYS_STORAGE_KEY = 'metoyou_removed_default_server_keys';
/** Timeout (ms) for server health-check and alternative-endpoint pings. */
const HEALTH_CHECK_TIMEOUT_MS = 5000;
export const CLIENT_UPDATE_REQUIRED_MESSAGE = 'Update the client in order to connect to other users';
function getDefaultHttpProtocol(): 'http' | 'https' {
return typeof window !== 'undefined' && window.location?.protocol === 'https:'
? 'https'
@@ -135,11 +172,43 @@ function normaliseDefaultServerUrl(rawUrl: string): string {
return cleaned;
}
function normalizeSemanticVersion(rawVersion: unknown): string | null {
if (typeof rawVersion !== 'string') {
return null;
}
const trimmed = rawVersion.trim();
if (!trimmed) {
return null;
}
const match = trimmed.match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/i);
if (!match) {
return null;
}
const major = Number.parseInt(match[1], 10);
const minor = Number.parseInt(match[2], 10);
const patch = Number.parseInt(match[3], 10);
if (
Number.isNaN(major)
|| Number.isNaN(minor)
|| Number.isNaN(patch)
) {
return null;
}
return `${major}.${minor}.${patch}`;
}
/**
* Derive the default server URL from the environment when provided,
* otherwise match the current page protocol automatically.
*/
function buildDefaultServerUrl(): string {
function buildFallbackDefaultServerUrl(): string {
const configuredUrl = environment.defaultServerUrl?.trim();
if (configuredUrl) {
@@ -149,14 +218,61 @@ function buildDefaultServerUrl(): string {
return `${getDefaultHttpProtocol()}://localhost:3001`;
}
/** Blueprint for the built-in default endpoint. */
const DEFAULT_ENDPOINT: Omit<ServerEndpoint, 'id'> = {
name: 'Default Server',
url: buildDefaultServerUrl(),
isActive: true,
isDefault: true,
status: 'unknown'
};
function buildDefaultServerDefinitions(): DefaultServerDefinition[] {
const configuredDefaults = Array.isArray(environment.defaultServers)
? environment.defaultServers
: [];
const seenKeys = new Set<string>();
const seenUrls = new Set<string>();
const definitions = configuredDefaults
.map((server, index) => {
const key = server.key?.trim() || `default-${index + 1}`;
const url = normaliseDefaultServerUrl(server.url ?? '');
if (!key || !url || seenKeys.has(key) || seenUrls.has(url)) {
return null;
}
seenKeys.add(key);
seenUrls.add(url);
return {
key,
name: server.name?.trim() || (index === 0 ? 'Default Server' : `Default Server ${index + 1}`),
url
} satisfies DefaultServerDefinition;
})
.filter((definition): definition is DefaultServerDefinition => definition !== null);
if (definitions.length > 0) {
return definitions;
}
return [
{
key: 'default',
name: 'Default Server',
url: buildFallbackDefaultServerUrl()
}
];
}
const DEFAULT_SERVER_DEFINITIONS = buildDefaultServerDefinitions();
/** Blueprints for built-in default endpoints. */
const DEFAULT_ENDPOINTS: DefaultEndpointTemplate[] = DEFAULT_SERVER_DEFINITIONS.map(
(definition) => ({
name: definition.name,
url: definition.url,
isActive: true,
isDefault: true,
defaultKey: definition.key,
status: 'unknown'
})
);
function getPrimaryDefaultServerUrl(): string {
return DEFAULT_ENDPOINTS[0]?.url ?? buildFallbackDefaultServerUrl();
}
/**
* Manages the user's list of configured server endpoints and
@@ -169,24 +285,35 @@ const DEFAULT_ENDPOINT: Omit<ServerEndpoint, 'id'> = {
@Injectable({ providedIn: 'root' })
export class ServerDirectoryService {
private readonly _servers = signal<ServerEndpoint[]>([]);
private clientVersionPromise: Promise<string | null> | null = null;
/** Whether search queries should be fanned out to all non-offline endpoints. */
private shouldSearchAllServers = false;
private shouldSearchAllServers = true;
/** Reactive list of all configured endpoints. */
readonly servers = computed(() => this._servers());
/** The currently active endpoint, falling back to the first in the list. */
readonly activeServer = computed(
() => this._servers().find((endpoint) => endpoint.isActive) ?? this._servers()[0]
/** Endpoints currently enabled for discovery. */
readonly activeServers = computed(() =>
this._servers().filter((endpoint) => endpoint.isActive && endpoint.status !== 'incompatible')
);
/** Whether any built-in endpoints are currently missing from the list. */
readonly hasMissingDefaultServers = computed(() =>
DEFAULT_ENDPOINTS.some((endpoint) => !this.hasEndpointForDefault(this._servers(), endpoint))
);
/** The primary active endpoint, falling back to the first configured endpoint. */
readonly activeServer = computed(() => this.activeServers()[0] ?? null);
constructor(private readonly http: HttpClient) {
this.loadConnectionSettings();
this.loadEndpoints();
void this.testAllServers();
}
/**
* Add a new server endpoint (inactive by default).
* Add a new server endpoint (active by default).
*
* @param server - Name and URL of the endpoint to add.
*/
@@ -196,7 +323,7 @@ export class ServerDirectoryService {
id: uuidv4(),
name: server.name,
url: sanitisedUrl,
isActive: false,
isActive: true,
isDefault: false,
status: 'unknown'
};
@@ -241,24 +368,30 @@ export class ServerDirectoryService {
/**
* Remove an endpoint by ID.
* The built-in default endpoint cannot be removed. If the removed
* endpoint was active, the first remaining endpoint is activated.
* When the removed endpoint was active, the first remaining endpoint
* becomes active.
*/
removeServer(endpointId: string): void {
const endpoints = this._servers();
const target = endpoints.find((endpoint) => endpoint.id === endpointId);
if (target?.isDefault)
if (!target || endpoints.length <= 1)
return;
const wasActive = target?.isActive;
const wasActive = target.isActive;
if (target.isDefault) {
this.markDefaultEndpointRemoved(target);
}
this._servers.update((list) => list.filter((endpoint) => endpoint.id !== endpointId));
if (wasActive) {
this._servers.update((list) => {
if (list.length > 0)
list[0].isActive = true;
if (list.length > 0 && !list.some((endpoint) => endpoint.isActive)) {
list[0] = { ...list[0],
isActive: true };
}
return [...list];
});
@@ -267,13 +400,75 @@ export class ServerDirectoryService {
this.saveEndpoints();
}
/** Activate a specific endpoint and deactivate all others. */
/** Restore any missing built-in endpoints without touching existing ones. */
restoreDefaultServers(): ServerEndpoint[] {
const currentEndpoints = this._servers();
const restoredEndpoints: ServerEndpoint[] = [];
for (const defaultEndpoint of DEFAULT_ENDPOINTS) {
if (this.hasEndpointForDefault(currentEndpoints, defaultEndpoint)) {
continue;
}
restoredEndpoints.push({
...defaultEndpoint,
id: uuidv4(),
isActive: true
});
}
if (restoredEndpoints.length === 0) {
this.clearRemovedDefaultEndpointKeys();
return [];
}
this._servers.update((endpoints) => {
const next = [...endpoints, ...restoredEndpoints];
if (!next.some((endpoint) => endpoint.isActive)) {
next[0] = { ...next[0],
isActive: true };
}
return next;
});
this.clearRemovedDefaultEndpointKeys();
this.saveEndpoints();
return restoredEndpoints;
}
/** Mark an endpoint as active without changing other active endpoints. */
setActiveServer(endpointId: string): void {
this._servers.update((endpoints) => {
const target = endpoints.find((endpoint) => endpoint.id === endpointId);
if (!target || target.status === 'incompatible') {
return endpoints;
}
return endpoints.map((endpoint) =>
endpoint.id === endpointId ? { ...endpoint,
isActive: true } : endpoint
);
});
this.saveEndpoints();
}
/** Deactivate an endpoint while keeping at least one endpoint active. */
deactivateServer(endpointId: string): void {
const activeEndpointCount = this.activeServers().length;
if (activeEndpointCount <= 1) {
return;
}
this._servers.update((endpoints) =>
endpoints.map((endpoint) => ({
...endpoint,
isActive: endpoint.id === endpointId
}))
endpoints.map((endpoint) =>
endpoint.id === endpointId ? { ...endpoint,
isActive: false } : endpoint
)
);
this.saveEndpoints();
@@ -283,19 +478,76 @@ export class ServerDirectoryService {
updateServerStatus(
endpointId: string,
status: ServerEndpoint['status'],
latency?: number
latency?: number,
versions?: {
serverVersion?: string | null;
clientVersion?: string | null;
}
): void {
this._servers.update((endpoints) =>
endpoints.map((endpoint) =>
endpoint.id === endpointId ? { ...endpoint,
this._servers.update((endpoints) => {
const updatedEndpoints = endpoints.map((endpoint) => {
if (endpoint.id !== endpointId) {
return endpoint;
}
return {
...endpoint,
status,
latency } : endpoint
)
);
latency,
isActive: status === 'incompatible' ? false : endpoint.isActive,
serverVersion: versions?.serverVersion ?? endpoint.serverVersion,
clientVersion: versions?.clientVersion ?? endpoint.clientVersion
};
});
if (updatedEndpoints.some((endpoint) => endpoint.isActive)) {
return updatedEndpoints;
}
const fallbackIndex = updatedEndpoints.findIndex((endpoint) => endpoint.status !== 'incompatible');
if (fallbackIndex < 0) {
return updatedEndpoints;
}
const nextEndpoints = [...updatedEndpoints];
nextEndpoints[fallbackIndex] = {
...nextEndpoints[fallbackIndex],
isActive: true
};
return nextEndpoints;
});
this.saveEndpoints();
}
/** Verify whether a selector resolves to an endpoint compatible with this client version. */
async ensureEndpointVersionCompatibility(selector?: ServerSourceSelector): Promise<boolean> {
const endpoint = this.resolveEndpoint(selector);
if (!endpoint) {
return false;
}
if (endpoint.status === 'incompatible') {
return false;
}
const clientVersion = await this.getClientVersion();
if (!clientVersion) {
return true;
}
await this.testServer(endpoint.id);
const refreshedEndpoint = this._servers().find((candidate) => candidate.id === endpoint.id);
return !!refreshedEndpoint && refreshedEndpoint.status !== 'incompatible';
}
/** Enable or disable fan-out search across all endpoints. */
setSearchAllServers(enabled: boolean): void {
this.shouldSearchAllServers = enabled;
@@ -315,6 +567,7 @@ export class ServerDirectoryService {
this.updateServerStatus(endpointId, 'checking');
const startTime = Date.now();
const clientVersion = await this.getClientVersion();
try {
const response = await fetch(`${endpoint.url}/api/health`, {
@@ -324,7 +577,25 @@ export class ServerDirectoryService {
const latency = Date.now() - startTime;
if (response.ok) {
this.updateServerStatus(endpointId, 'online', latency);
const payload = await response.json() as HealthCheckPayload;
const serverVersion = normalizeSemanticVersion(payload.serverVersion);
const isVersionCompatible = !clientVersion
|| (serverVersion !== null && serverVersion === clientVersion);
if (!isVersionCompatible) {
this.updateServerStatus(endpointId, 'incompatible', latency, {
serverVersion,
clientVersion
});
return false;
}
this.updateServerStatus(endpointId, 'online', latency, {
serverVersion,
clientVersion
});
return true;
}
@@ -627,7 +898,10 @@ export class ServerDirectoryService {
return this.findServerByUrl(selector.sourceUrl) ?? null;
}
return this.activeServer() ?? this._servers()[0] ?? null;
return this.activeServer()
?? this._servers().find((endpoint) => endpoint.status !== 'incompatible')
?? this._servers()[0]
?? null;
}
private resolveBaseServerUrl(selector?: ServerSourceSelector): string {
@@ -635,7 +909,7 @@ export class ServerDirectoryService {
return this.sanitiseUrl(selector.sourceUrl);
}
return this.resolveEndpoint(selector)?.url ?? buildDefaultServerUrl();
return this.resolveEndpoint(selector)?.url ?? getPrimaryDefaultServerUrl();
}
/**
@@ -672,7 +946,7 @@ export class ServerDirectoryService {
/** Fan-out search across all non-offline endpoints, deduplicating results. */
private searchAllEndpoints(query: string): Observable<ServerInfo[]> {
const onlineEndpoints = this._servers().filter(
const onlineEndpoints = this.activeServers().filter(
(endpoint) => endpoint.status !== 'offline'
);
@@ -692,7 +966,7 @@ export class ServerDirectoryService {
/** Retrieve all servers from all non-offline endpoints. */
private getAllServersFromAllEndpoints(): Observable<ServerInfo[]> {
const onlineEndpoints = this._servers().filter(
const onlineEndpoints = this.activeServers().filter(
(endpoint) => endpoint.status !== 'offline'
);
@@ -780,50 +1054,229 @@ export class ServerDirectoryService {
return typeof value === 'string' ? value : undefined;
}
private async getClientVersion(): Promise<string | null> {
if (!this.clientVersionPromise) {
this.clientVersionPromise = this.resolveClientVersion();
}
return this.clientVersionPromise;
}
private async resolveClientVersion(): Promise<string | null> {
if (typeof window === 'undefined') {
return null;
}
const electronApi = (window as VersionAwareWindow).electronAPI;
if (!electronApi?.getAutoUpdateState) {
return null;
}
try {
const state = await electronApi.getAutoUpdateState();
return normalizeSemanticVersion(state?.currentVersion);
} catch {
return null;
}
}
/** Apply persisted connection settings before any directory queries run. */
private loadConnectionSettings(): void {
const stored = localStorage.getItem(STORAGE_KEY_CONNECTION_SETTINGS);
if (!stored) {
this.shouldSearchAllServers = true;
return;
}
try {
const parsed = JSON.parse(stored) as { searchAllServers?: boolean };
this.shouldSearchAllServers = parsed.searchAllServers ?? true;
} catch {
this.shouldSearchAllServers = true;
}
}
/** Load endpoints from localStorage, syncing the built-in default endpoint if needed. */
private loadEndpoints(): void {
const stored = localStorage.getItem(ENDPOINTS_STORAGE_KEY);
if (!stored) {
this.initialiseDefaultEndpoint();
this.initialiseDefaultEndpoints();
return;
}
try {
let endpoints = JSON.parse(stored) as ServerEndpoint[];
// Ensure at least one endpoint is active
if (endpoints.length > 0 && !endpoints.some((ep) => ep.isActive)) {
endpoints[0].isActive = true;
}
const defaultServerUrl = buildDefaultServerUrl();
endpoints = endpoints.map((endpoint) => {
if (endpoint.isDefault) {
return { ...endpoint,
url: defaultServerUrl };
}
return endpoint;
});
const parsed = JSON.parse(stored) as ServerEndpoint[];
const endpoints = this.reconcileStoredEndpoints(parsed);
this._servers.set(endpoints);
this.saveEndpoints();
} catch {
this.initialiseDefaultEndpoint();
this.initialiseDefaultEndpoints();
}
}
/** Create and persist the built-in default endpoint. */
private initialiseDefaultEndpoint(): void {
const defaultEndpoint: ServerEndpoint = { ...DEFAULT_ENDPOINT,
id: uuidv4() };
/** Create and persist the built-in default endpoints. */
private initialiseDefaultEndpoints(): void {
const defaultEndpoints = DEFAULT_ENDPOINTS.map((endpoint) => ({
...endpoint,
id: uuidv4()
}));
this._servers.set([defaultEndpoint]);
this._servers.set(defaultEndpoints);
this.saveEndpoints();
}
private reconcileStoredEndpoints(storedEndpoints: ServerEndpoint[]): ServerEndpoint[] {
const reconciled: ServerEndpoint[] = [];
const claimedDefaultKeys = new Set<string>();
const removedDefaultKeys = this.loadRemovedDefaultEndpointKeys();
for (const endpoint of Array.isArray(storedEndpoints) ? storedEndpoints : []) {
if (!endpoint || typeof endpoint.id !== 'string' || typeof endpoint.url !== 'string') {
continue;
}
const sanitisedUrl = this.sanitiseUrl(endpoint.url);
const matchedDefault = this.matchDefaultEndpoint(endpoint, sanitisedUrl, claimedDefaultKeys);
if (matchedDefault) {
claimedDefaultKeys.add(matchedDefault.defaultKey);
reconciled.push({
...endpoint,
name: matchedDefault.name,
url: matchedDefault.url,
isDefault: true,
defaultKey: matchedDefault.defaultKey,
status: endpoint.status ?? 'unknown'
});
continue;
}
reconciled.push({
...endpoint,
url: sanitisedUrl,
status: endpoint.status ?? 'unknown'
});
}
for (const defaultEndpoint of DEFAULT_ENDPOINTS) {
if (
!claimedDefaultKeys.has(defaultEndpoint.defaultKey)
&& !removedDefaultKeys.has(defaultEndpoint.defaultKey)
&& !this.hasEndpointForDefault(reconciled, defaultEndpoint)
) {
reconciled.push({
...defaultEndpoint,
id: uuidv4(),
isActive: defaultEndpoint.isActive
});
}
}
if (reconciled.length > 0 && !reconciled.some((endpoint) => endpoint.isActive)) {
reconciled[0] = { ...reconciled[0],
isActive: true };
}
return reconciled;
}
private matchDefaultEndpoint(
endpoint: ServerEndpoint,
sanitisedUrl: string,
claimedDefaultKeys: Set<string>
): DefaultEndpointTemplate | null {
if (endpoint.defaultKey) {
return DEFAULT_ENDPOINTS.find(
(candidate) => candidate.defaultKey === endpoint.defaultKey && !claimedDefaultKeys.has(candidate.defaultKey)
) ?? null;
}
if (!endpoint.isDefault) {
return null;
}
const matchingCurrentDefault = DEFAULT_ENDPOINTS.find(
(candidate) => candidate.url === sanitisedUrl && candidate.defaultKey && !claimedDefaultKeys.has(candidate.defaultKey)
);
if (matchingCurrentDefault) {
return matchingCurrentDefault;
}
return DEFAULT_ENDPOINTS.find(
(candidate) => candidate.defaultKey && !claimedDefaultKeys.has(candidate.defaultKey)
) ?? null;
}
private hasEndpointForDefault(
endpoints: ServerEndpoint[],
defaultEndpoint: DefaultEndpointTemplate
): boolean {
return endpoints.some((endpoint) =>
endpoint.defaultKey === defaultEndpoint.defaultKey
|| this.sanitiseUrl(endpoint.url) === defaultEndpoint.url
);
}
private markDefaultEndpointRemoved(endpoint: ServerEndpoint): void {
const defaultKey = endpoint.defaultKey ?? this.findDefaultEndpointKeyByUrl(endpoint.url);
if (!defaultKey) {
return;
}
const removedDefaultKeys = this.loadRemovedDefaultEndpointKeys();
removedDefaultKeys.add(defaultKey);
this.saveRemovedDefaultEndpointKeys(removedDefaultKeys);
}
private findDefaultEndpointKeyByUrl(url: string): string | null {
const sanitisedUrl = this.sanitiseUrl(url);
return DEFAULT_ENDPOINTS.find((endpoint) => endpoint.url === sanitisedUrl)?.defaultKey ?? null;
}
private loadRemovedDefaultEndpointKeys(): Set<string> {
const stored = localStorage.getItem(REMOVED_DEFAULT_ENDPOINT_KEYS_STORAGE_KEY);
if (!stored) {
return new Set<string>();
}
try {
const parsed = JSON.parse(stored) as unknown;
if (!Array.isArray(parsed)) {
return new Set<string>();
}
return new Set(parsed.filter((value): value is string => typeof value === 'string'));
} catch {
return new Set<string>();
}
}
private saveRemovedDefaultEndpointKeys(keys: Set<string>): void {
if (keys.size === 0) {
localStorage.removeItem(REMOVED_DEFAULT_ENDPOINT_KEYS_STORAGE_KEY);
return;
}
localStorage.setItem(REMOVED_DEFAULT_ENDPOINT_KEYS_STORAGE_KEY, JSON.stringify([...keys]));
}
private clearRemovedDefaultEndpointKeys(): void {
localStorage.removeItem(REMOVED_DEFAULT_ENDPOINT_KEYS_STORAGE_KEY);
}
/** Persist the current endpoint list to localStorage. */
private saveEndpoints(): void {
localStorage.setItem(ENDPOINTS_STORAGE_KEY, JSON.stringify(this._servers()));

View File

@@ -19,7 +19,12 @@ import {
inject,
OnDestroy
} from '@angular/core';
import { Observable, Subject } from 'rxjs';
import {
Observable,
of,
Subject,
Subscription
} from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import { SignalingMessage, ChatEvent } from '../models/index';
import { TimeSyncService } from './time-sync.service';
@@ -88,8 +93,13 @@ export class WebRTCService implements OnDestroy {
private readonly logger = new WebRTCLogger(() => this.debugging.enabled());
private lastIdentifyCredentials: IdentifyCredentials | null = null;
private lastJoinedServer: JoinedServerInfo | null = null;
private readonly memberServerIds = new Set<string>();
private readonly lastJoinedServerBySignalUrl = new Map<string, JoinedServerInfo>();
private readonly memberServerIdsBySignalUrl = new Map<string, Set<string>>();
private readonly serverSignalingUrlMap = new Map<string, string>();
private readonly peerSignalingUrlMap = new Map<string, string>();
private readonly signalingManagers = new Map<string, SignalingManager>();
private readonly signalingSubscriptions = new Map<string, Subscription[]>();
private readonly signalingConnectionStates = new Map<string, boolean>();
private activeServerId: string | null = null;
/** The server ID where voice is currently active, or `null` when not in voice. */
private voiceServerId: string | null = null;
@@ -168,20 +178,12 @@ export class WebRTCService implements OnDestroy {
return this.mediaManager.voiceConnected$.asObservable();
}
private readonly signalingManager: SignalingManager;
private readonly peerManager: PeerConnectionManager;
private readonly mediaManager: MediaManager;
private readonly screenShareManager: ScreenShareManager;
constructor() {
// Create managers with null callbacks first to break circular initialization
this.signalingManager = new SignalingManager(
this.logger,
() => this.lastIdentifyCredentials,
() => this.lastJoinedServer,
() => this.memberServerIds
);
this.peerManager = new PeerConnectionManager(this.logger, null!);
this.mediaManager = new MediaManager(this.logger, null!);
@@ -190,7 +192,7 @@ export class WebRTCService implements OnDestroy {
// Now wire up cross-references (all managers are instantiated)
this.peerManager.setCallbacks({
sendRawMessage: (msg: Record<string, unknown>) => this.signalingManager.sendRawMessage(msg),
sendRawMessage: (msg: Record<string, unknown>) => this.sendRawMessage(msg),
getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(),
isSignalingConnected: (): boolean => this._isSignalingConnected(),
getVoiceStateSnapshot: (): VoiceStateSnapshot => this.getCurrentVoiceState(),
@@ -231,23 +233,6 @@ export class WebRTCService implements OnDestroy {
}
private wireManagerEvents(): void {
// Signaling → connection status
this.signalingManager.connectionStatus$.subscribe(({ connected, errorMessage }) => {
this._isSignalingConnected.set(connected);
if (connected)
this._hasEverConnected.set(true);
this._hasConnectionError.set(!connected);
this._connectionErrorMessage.set(connected ? null : (errorMessage ?? null));
});
// Signaling → message routing
this.signalingManager.messageReceived$.subscribe((msg) => this.handleSignalingMessage(msg));
// Signaling → heartbeat → broadcast states
this.signalingManager.heartbeatTick$.subscribe(() => this.peerManager.broadcastCurrentStates());
// Internal control-plane messages for on-demand screen-share delivery.
this.peerManager.messageReceived$.subscribe((event) => this.handlePeerControlMessage(event));
@@ -277,6 +262,7 @@ export class WebRTCService implements OnDestroy {
this.peerManager.peerDisconnected$.subscribe((peerId) => {
this.activeRemoteScreenSharePeers.delete(peerId);
this.peerServerMap.delete(peerId);
this.peerSignalingUrlMap.delete(peerId);
this.screenShareManager.clearScreenShareRequest(peerId);
});
@@ -293,37 +279,145 @@ export class WebRTCService implements OnDestroy {
});
}
private handleSignalingMessage(message: IncomingSignalingMessage): void {
private ensureSignalingManager(signalUrl: string): SignalingManager {
const existingManager = this.signalingManagers.get(signalUrl);
if (existingManager) {
return existingManager;
}
const manager = new SignalingManager(
this.logger,
() => this.lastIdentifyCredentials,
() => this.lastJoinedServerBySignalUrl.get(signalUrl) ?? null,
() => this.getMemberServerIdsForSignalUrl(signalUrl)
);
const subscriptions: Subscription[] = [
manager.connectionStatus$.subscribe(({ connected, errorMessage }) =>
this.handleSignalingConnectionStatus(signalUrl, connected, errorMessage)
),
manager.messageReceived$.subscribe((message) => this.handleSignalingMessage(message, signalUrl)),
manager.heartbeatTick$.subscribe(() => this.peerManager.broadcastCurrentStates())
];
this.signalingManagers.set(signalUrl, manager);
this.signalingSubscriptions.set(signalUrl, subscriptions);
return manager;
}
private handleSignalingConnectionStatus(
signalUrl: string,
connected: boolean,
errorMessage?: string
): void {
this.signalingConnectionStates.set(signalUrl, connected);
if (connected)
this._hasEverConnected.set(true);
const anyConnected = this.isAnySignalingConnected();
this._isSignalingConnected.set(anyConnected);
this._hasConnectionError.set(!anyConnected);
this._connectionErrorMessage.set(anyConnected ? null : (errorMessage ?? 'Disconnected from signaling server'));
}
private isAnySignalingConnected(): boolean {
for (const manager of this.signalingManagers.values()) {
if (manager.isSocketOpen()) {
return true;
}
}
return false;
}
private getConnectedSignalingManagers(): { signalUrl: string; manager: SignalingManager }[] {
const connectedManagers: { signalUrl: string; manager: SignalingManager }[] = [];
for (const [signalUrl, manager] of this.signalingManagers.entries()) {
if (!manager.isSocketOpen()) {
continue;
}
connectedManagers.push({ signalUrl,
manager });
}
return connectedManagers;
}
private getOrCreateMemberServerSet(signalUrl: string): Set<string> {
const existingSet = this.memberServerIdsBySignalUrl.get(signalUrl);
if (existingSet) {
return existingSet;
}
const createdSet = new Set<string>();
this.memberServerIdsBySignalUrl.set(signalUrl, createdSet);
return createdSet;
}
private getMemberServerIdsForSignalUrl(signalUrl: string): ReadonlySet<string> {
return this.memberServerIdsBySignalUrl.get(signalUrl) ?? new Set<string>();
}
private isJoinedServer(serverId: string): boolean {
for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) {
if (memberServerIds.has(serverId)) {
return true;
}
}
return false;
}
private getJoinedServerCount(): number {
let joinedServerCount = 0;
for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) {
joinedServerCount += memberServerIds.size;
}
return joinedServerCount;
}
private handleSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
this.signalingMessage$.next(message);
this.logger.info('Signaling message', { type: message.type });
this.logger.info('Signaling message', {
signalUrl,
type: message.type
});
switch (message.type) {
case SIGNALING_TYPE_CONNECTED:
this.handleConnectedSignalingMessage(message);
this.handleConnectedSignalingMessage(message, signalUrl);
return;
case SIGNALING_TYPE_SERVER_USERS:
this.handleServerUsersSignalingMessage(message);
this.handleServerUsersSignalingMessage(message, signalUrl);
return;
case SIGNALING_TYPE_USER_JOINED:
this.handleUserJoinedSignalingMessage(message);
this.handleUserJoinedSignalingMessage(message, signalUrl);
return;
case SIGNALING_TYPE_USER_LEFT:
this.handleUserLeftSignalingMessage(message);
this.handleUserLeftSignalingMessage(message, signalUrl);
return;
case SIGNALING_TYPE_OFFER:
this.handleOfferSignalingMessage(message);
this.handleOfferSignalingMessage(message, signalUrl);
return;
case SIGNALING_TYPE_ANSWER:
this.handleAnswerSignalingMessage(message);
this.handleAnswerSignalingMessage(message, signalUrl);
return;
case SIGNALING_TYPE_ICE_CANDIDATE:
this.handleIceCandidateSignalingMessage(message);
this.handleIceCandidateSignalingMessage(message, signalUrl);
return;
default:
@@ -331,26 +425,40 @@ export class WebRTCService implements OnDestroy {
}
}
private handleConnectedSignalingMessage(message: IncomingSignalingMessage): void {
this.logger.info('Server connected', { oderId: message.oderId });
private handleConnectedSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
this.logger.info('Server connected', {
oderId: message.oderId,
signalUrl
});
if (message.serverId) {
this.serverSignalingUrlMap.set(message.serverId, signalUrl);
}
if (typeof message.serverTime === 'number') {
this.timeSync.setFromServerTime(message.serverTime);
}
}
private handleServerUsersSignalingMessage(message: IncomingSignalingMessage): void {
private handleServerUsersSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
const users = Array.isArray(message.users) ? message.users : [];
this.logger.info('Server users', {
count: users.length,
signalUrl,
serverId: message.serverId
});
if (message.serverId) {
this.serverSignalingUrlMap.set(message.serverId, signalUrl);
}
for (const user of users) {
if (!user.oderId)
continue;
this.peerSignalingUrlMap.set(user.oderId, signalUrl);
if (message.serverId) {
this.trackPeerInServer(user.oderId, message.serverId);
}
@@ -376,21 +484,31 @@ export class WebRTCService implements OnDestroy {
}
}
private handleUserJoinedSignalingMessage(message: IncomingSignalingMessage): void {
private handleUserJoinedSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
this.logger.info('User joined', {
displayName: message.displayName,
oderId: message.oderId
oderId: message.oderId,
signalUrl
});
if (message.serverId) {
this.serverSignalingUrlMap.set(message.serverId, signalUrl);
}
if (message.oderId) {
this.peerSignalingUrlMap.set(message.oderId, signalUrl);
}
if (message.oderId && message.serverId) {
this.trackPeerInServer(message.oderId, message.serverId);
}
}
private handleUserLeftSignalingMessage(message: IncomingSignalingMessage): void {
private handleUserLeftSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
this.logger.info('User left', {
displayName: message.displayName,
oderId: message.oderId,
signalUrl,
serverId: message.serverId
});
@@ -404,17 +522,20 @@ export class WebRTCService implements OnDestroy {
if (!hasRemainingSharedServers) {
this.peerManager.removePeer(message.oderId);
this.peerServerMap.delete(message.oderId);
this.peerSignalingUrlMap.delete(message.oderId);
}
}
}
private handleOfferSignalingMessage(message: IncomingSignalingMessage): void {
private handleOfferSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
const fromUserId = message.fromUserId;
const sdp = message.payload?.sdp;
if (!fromUserId || !sdp)
return;
this.peerSignalingUrlMap.set(fromUserId, signalUrl);
const offerEffectiveServer = this.voiceServerId || this.activeServerId;
if (offerEffectiveServer && !this.peerServerMap.has(fromUserId)) {
@@ -424,23 +545,27 @@ export class WebRTCService implements OnDestroy {
this.peerManager.handleOffer(fromUserId, sdp);
}
private handleAnswerSignalingMessage(message: IncomingSignalingMessage): void {
private handleAnswerSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
const fromUserId = message.fromUserId;
const sdp = message.payload?.sdp;
if (!fromUserId || !sdp)
return;
this.peerSignalingUrlMap.set(fromUserId, signalUrl);
this.peerManager.handleAnswer(fromUserId, sdp);
}
private handleIceCandidateSignalingMessage(message: IncomingSignalingMessage): void {
private handleIceCandidateSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
const fromUserId = message.fromUserId;
const candidate = message.payload?.candidate;
if (!fromUserId || !candidate)
return;
this.peerSignalingUrlMap.set(fromUserId, signalUrl);
this.peerManager.handleIceCandidate(fromUserId, candidate);
}
@@ -467,6 +592,7 @@ export class WebRTCService implements OnDestroy {
this.peerManager.removePeer(peerId);
this.peerServerMap.delete(peerId);
this.peerSignalingUrlMap.delete(peerId);
}
}
@@ -490,7 +616,18 @@ export class WebRTCService implements OnDestroy {
* @returns An observable that emits `true` once connected.
*/
connectToSignalingServer(serverUrl: string): Observable<boolean> {
return this.signalingManager.connect(serverUrl);
const manager = this.ensureSignalingManager(serverUrl);
if (manager.isSocketOpen()) {
return of(true);
}
return manager.connect(serverUrl);
}
/** Returns true when the signaling socket for a given URL is currently open. */
isSignalingConnectedTo(serverUrl: string): boolean {
return this.signalingManagers.get(serverUrl)?.isSocketOpen() ?? false;
}
private trackPeerInServer(peerId: string, serverId: string): void {
@@ -504,7 +641,7 @@ export class WebRTCService implements OnDestroy {
}
private replacePeerSharedServers(peerId: string, serverIds: string[]): boolean {
const sharedServerIds = serverIds.filter((serverId) => this.memberServerIds.has(serverId));
const sharedServerIds = serverIds.filter((serverId) => this.isJoinedServer(serverId));
if (sharedServerIds.length === 0) {
this.peerServerMap.delete(peerId);
@@ -539,7 +676,17 @@ export class WebRTCService implements OnDestroy {
* @returns `true` if connected within the timeout.
*/
async ensureSignalingConnected(timeoutMs?: number): Promise<boolean> {
return this.signalingManager.ensureConnected(timeoutMs);
if (this.isAnySignalingConnected()) {
return true;
}
for (const manager of this.signalingManagers.values()) {
if (await manager.ensureConnected(timeoutMs)) {
return true;
}
}
return false;
}
/**
@@ -548,7 +695,32 @@ export class WebRTCService implements OnDestroy {
* @param message - The signaling message payload (excluding `from` / `timestamp`).
*/
sendSignalingMessage(message: Omit<SignalingMessage, 'from' | 'timestamp'>): void {
this.signalingManager.sendSignalingMessage(message, this._localPeerId());
const targetPeerId = message.to;
if (targetPeerId) {
const targetSignalUrl = this.peerSignalingUrlMap.get(targetPeerId);
if (targetSignalUrl) {
const targetManager = this.ensureSignalingManager(targetSignalUrl);
targetManager.sendSignalingMessage(message, this._localPeerId());
return;
}
}
const connectedManagers = this.getConnectedSignalingManagers();
if (connectedManagers.length === 0) {
this.logger.error('[signaling] No active signaling connection for outbound message', new Error('No signaling manager available'), {
type: message.type
});
return;
}
for (const { manager } of connectedManagers) {
manager.sendSignalingMessage(message, this._localPeerId());
}
}
/**
@@ -557,7 +729,50 @@ export class WebRTCService implements OnDestroy {
* @param message - Arbitrary JSON message.
*/
sendRawMessage(message: Record<string, unknown>): void {
this.signalingManager.sendRawMessage(message);
const targetPeerId = typeof message['targetUserId'] === 'string' ? message['targetUserId'] : null;
if (targetPeerId) {
const targetSignalUrl = this.peerSignalingUrlMap.get(targetPeerId);
if (targetSignalUrl && this.sendRawMessageToSignalUrl(targetSignalUrl, message)) {
return;
}
}
const serverId = typeof message['serverId'] === 'string' ? message['serverId'] : null;
if (serverId) {
const serverSignalUrl = this.serverSignalingUrlMap.get(serverId);
if (serverSignalUrl && this.sendRawMessageToSignalUrl(serverSignalUrl, message)) {
return;
}
}
const connectedManagers = this.getConnectedSignalingManagers();
if (connectedManagers.length === 0) {
this.logger.error('[signaling] No active signaling connection for outbound message', new Error('No signaling manager available'), {
type: typeof message['type'] === 'string' ? message['type'] : 'unknown'
});
return;
}
for (const { manager } of connectedManagers) {
manager.sendRawMessage(message);
}
}
private sendRawMessageToSignalUrl(signalUrl: string, message: Record<string, unknown>): boolean {
const manager = this.signalingManagers.get(signalUrl);
if (!manager) {
return false;
}
manager.sendRawMessage(message);
return true;
}
/**
@@ -576,7 +791,15 @@ export class WebRTCService implements OnDestroy {
/** The last signaling URL used by the client, if any. */
getCurrentSignalingUrl(): string | null {
return this.signalingManager.getLastUrl();
if (this.activeServerId) {
const activeServerSignalUrl = this.serverSignalingUrlMap.get(this.activeServerId);
if (activeServerSignalUrl) {
return activeServerSignalUrl;
}
}
return this.getConnectedSignalingManagers()[0]?.signalUrl ?? null;
}
/**
@@ -587,13 +810,22 @@ export class WebRTCService implements OnDestroy {
* @param oderId - The user's unique order/peer ID.
* @param displayName - The user's display name.
*/
identify(oderId: string, displayName: string): void {
identify(oderId: string, displayName: string, signalUrl?: string): void {
this.lastIdentifyCredentials = { oderId,
displayName };
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY,
const identifyMessage = {
type: SIGNALING_TYPE_IDENTIFY,
oderId,
displayName });
displayName
};
if (signalUrl) {
this.sendRawMessageToSignalUrl(signalUrl, identifyMessage);
return;
}
this.sendRawMessage(identifyMessage);
}
/**
@@ -602,13 +834,27 @@ export class WebRTCService implements OnDestroy {
* @param roomId - The server / room ID to join.
* @param userId - The local user ID.
*/
joinRoom(roomId: string, userId: string): void {
this.lastJoinedServer = { serverId: roomId,
userId };
joinRoom(roomId: string, userId: string, signalUrl?: string): void {
const resolvedSignalUrl = signalUrl
?? this.serverSignalingUrlMap.get(roomId)
?? this.getCurrentSignalingUrl();
this.memberServerIds.add(roomId);
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
serverId: roomId });
if (!resolvedSignalUrl) {
this.logger.warn('[signaling] Cannot join room without a signaling URL', { roomId });
return;
}
this.serverSignalingUrlMap.set(roomId, resolvedSignalUrl);
this.lastJoinedServerBySignalUrl.set(resolvedSignalUrl, {
serverId: roomId,
userId
});
this.getOrCreateMemberServerSet(resolvedSignalUrl).add(roomId);
this.sendRawMessageToSignalUrl(resolvedSignalUrl, {
type: SIGNALING_TYPE_JOIN_SERVER,
serverId: roomId
});
}
/**
@@ -618,26 +864,46 @@ export class WebRTCService implements OnDestroy {
* @param serverId - The target server ID.
* @param userId - The local user ID.
*/
switchServer(serverId: string, userId: string): void {
this.lastJoinedServer = { serverId,
userId };
switchServer(serverId: string, userId: string, signalUrl?: string): void {
const resolvedSignalUrl = signalUrl
?? this.serverSignalingUrlMap.get(serverId)
?? this.getCurrentSignalingUrl();
if (this.memberServerIds.has(serverId)) {
this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER,
serverId });
if (!resolvedSignalUrl) {
this.logger.warn('[signaling] Cannot switch server without a signaling URL', { serverId });
return;
}
this.serverSignalingUrlMap.set(serverId, resolvedSignalUrl);
this.lastJoinedServerBySignalUrl.set(resolvedSignalUrl, {
serverId,
userId
});
const memberServerIds = this.getOrCreateMemberServerSet(resolvedSignalUrl);
if (memberServerIds.has(serverId)) {
this.sendRawMessageToSignalUrl(resolvedSignalUrl, {
type: SIGNALING_TYPE_VIEW_SERVER,
serverId
});
this.logger.info('Viewed server (already joined)', {
serverId,
signalUrl: resolvedSignalUrl,
userId,
voiceConnected: this._isVoiceConnected()
});
} else {
this.memberServerIds.add(serverId);
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
serverId });
memberServerIds.add(serverId);
this.sendRawMessageToSignalUrl(resolvedSignalUrl, {
type: SIGNALING_TYPE_JOIN_SERVER,
serverId
});
this.logger.info('Joined new server via switch', {
serverId,
signalUrl: resolvedSignalUrl,
userId,
voiceConnected: this._isVoiceConnected()
});
@@ -654,25 +920,47 @@ export class WebRTCService implements OnDestroy {
*/
leaveRoom(serverId?: string): void {
if (serverId) {
this.memberServerIds.delete(serverId);
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER,
serverId });
const resolvedSignalUrl = this.serverSignalingUrlMap.get(serverId);
if (resolvedSignalUrl) {
this.getOrCreateMemberServerSet(resolvedSignalUrl).delete(serverId);
this.sendRawMessageToSignalUrl(resolvedSignalUrl, {
type: SIGNALING_TYPE_LEAVE_SERVER,
serverId
});
} else {
this.sendRawMessage({
type: SIGNALING_TYPE_LEAVE_SERVER,
serverId
});
for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) {
memberServerIds.delete(serverId);
}
}
this.serverSignalingUrlMap.delete(serverId);
this.logger.info('Left server', { serverId });
if (this.memberServerIds.size === 0) {
if (this.getJoinedServerCount() === 0) {
this.fullCleanup();
}
return;
}
this.memberServerIds.forEach((sid) => {
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER,
serverId: sid });
});
for (const [signalUrl, memberServerIds] of this.memberServerIdsBySignalUrl.entries()) {
for (const sid of memberServerIds) {
this.sendRawMessageToSignalUrl(signalUrl, {
type: SIGNALING_TYPE_LEAVE_SERVER,
serverId: sid
});
}
}
this.memberServerIds.clear();
this.memberServerIdsBySignalUrl.clear();
this.serverSignalingUrlMap.clear();
this.fullCleanup();
}
@@ -682,12 +970,18 @@ export class WebRTCService implements OnDestroy {
* @param serverId - The server to check.
*/
hasJoinedServer(serverId: string): boolean {
return this.memberServerIds.has(serverId);
return this.isJoinedServer(serverId);
}
/** Returns a read-only set of all currently-joined server IDs. */
getJoinedServerIds(): ReadonlySet<string> {
return this.memberServerIds;
const joinedServerIds = new Set<string>();
for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) {
memberServerIds.forEach((serverId) => joinedServerIds.add(serverId));
}
return joinedServerIds;
}
/**
@@ -942,11 +1236,15 @@ export class WebRTCService implements OnDestroy {
/** Disconnect from the signaling server and clean up all state. */
disconnect(): void {
this.leaveRoom();
this.voiceServerId = null;
this.peerServerMap.clear();
this.leaveRoom();
this.peerSignalingUrlMap.clear();
this.lastJoinedServerBySignalUrl.clear();
this.memberServerIdsBySignalUrl.clear();
this.serverSignalingUrlMap.clear();
this.mediaManager.stopVoiceHeartbeat();
this.signalingManager.close();
this.destroyAllSignalingManagers();
this._isSignalingConnected.set(false);
this._hasEverConnected.set(false);
this._hasConnectionError.set(false);
@@ -962,6 +1260,7 @@ export class WebRTCService implements OnDestroy {
private fullCleanup(): void {
this.voiceServerId = null;
this.peerServerMap.clear();
this.peerSignalingUrlMap.clear();
this.remoteScreenShareRequestsEnabled = false;
this.desiredRemoteScreenSharePeers.clear();
this.activeRemoteScreenSharePeers.clear();
@@ -1040,10 +1339,25 @@ export class WebRTCService implements OnDestroy {
}
}
private destroyAllSignalingManagers(): void {
for (const subscriptions of this.signalingSubscriptions.values()) {
for (const subscription of subscriptions) {
subscription.unsubscribe();
}
}
for (const manager of this.signalingManagers.values()) {
manager.destroy();
}
this.signalingSubscriptions.clear();
this.signalingManagers.clear();
this.signalingConnectionStates.clear();
}
ngOnDestroy(): void {
this.disconnect();
this.serviceDestroyed$.complete();
this.signalingManager.destroy();
this.peerManager.destroy();
this.mediaManager.destroy();
this.screenShareManager.destroy();

View File

@@ -298,6 +298,24 @@
/>
</div>
<div>
<label
for="create-server-signal-endpoint"
class="block text-sm font-medium text-foreground mb-1"
>Signal Server Endpoint</label
>
<select
id="create-server-signal-endpoint"
[(ngModel)]="newServerSourceId"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
@for (endpoint of activeEndpoints(); track endpoint.id) {
<option [value]="endpoint.id">{{ endpoint.name }} ({{ endpoint.url }})</option>
}
</select>
<p class="mt-1 text-xs text-muted-foreground">This endpoint handles all signaling for this chat server.</p>
</div>
<div class="flex items-center gap-2">
<input
type="checkbox"
@@ -339,7 +357,7 @@
</button>
<button
(click)="createServer()"
[disabled]="!newServerName()"
[disabled]="!newServerName() || !newServerSourceId"
type="button"
class="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>

View File

@@ -85,6 +85,7 @@ export class ServerSearchComponent implements OnInit {
error = this.store.selectSignal(selectRoomsError);
savedRooms = this.store.selectSignal(selectSavedRooms);
currentUser = this.store.selectSignal(selectCurrentUser);
activeEndpoints = this.serverDirectory.activeServers;
bannedServerLookup = signal<Record<string, boolean>>({});
bannedServerName = signal('');
showBannedDialog = signal(false);
@@ -101,6 +102,7 @@ export class ServerSearchComponent implements OnInit {
newServerTopic = signal('');
newServerPrivate = signal(false);
newServerPassword = signal('');
newServerSourceId = '';
constructor() {
effect(() => {
@@ -148,6 +150,7 @@ export class ServerSearchComponent implements OnInit {
/** Open the create-server dialog. */
openCreateDialog(): void {
this.newServerSourceId = this.activeEndpoints()[0]?.id ?? '';
this.showCreateDialog.set(true);
}
@@ -175,7 +178,8 @@ export class ServerSearchComponent implements OnInit {
description: this.newServerDescription() || undefined,
topic: this.newServerTopic() || undefined,
isPrivate: this.newServerPrivate(),
password: this.newServerPassword().trim() || undefined
password: this.newServerPassword().trim() || undefined,
sourceId: this.newServerSourceId || undefined
})
);
@@ -346,5 +350,6 @@ export class ServerSearchComponent implements OnInit {
this.newServerTopic.set('');
this.newServerPrivate.set(false);
this.newServerPassword.set('');
this.newServerSourceId = this.activeEndpoints()[0]?.id ?? '';
}
}

View File

@@ -124,7 +124,8 @@ export class ServersRailComponent {
if (this.webrtc.hasJoinedServer(room.id) && roomWsUrl === currentWsUrl) {
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
this.store.dispatch(RoomsActions.viewServer({ room }));
this.store.dispatch(RoomsActions.viewServer({ room,
skipBanCheck: true }));
} else {
await this.attemptJoinRoom(room);
}
@@ -308,7 +309,8 @@ export class ServersRailComponent {
if (this.shouldFallbackToOfflineView(error)) {
this.closePasswordDialog();
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: true }));
this.store.dispatch(RoomsActions.viewServer({ room }));
this.store.dispatch(RoomsActions.viewServer({ room,
skipBanCheck: true }));
}
}
}

View File

@@ -9,21 +9,35 @@
/>
<h4 class="text-sm font-semibold text-foreground">Server Endpoints</h4>
</div>
<button
(click)="testAllServers()"
[disabled]="isTesting()"
class="flex items-center gap-1.5 px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50"
>
<ng-icon
name="lucideRefreshCw"
class="w-3.5 h-3.5"
[class.animate-spin]="isTesting()"
/>
Test All
</button>
<div class="flex items-center gap-2">
@if (hasMissingDefaultServers()) {
<button
type="button"
(click)="restoreDefaultServers()"
class="px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
>
Restore Defaults
</button>
}
<button
type="button"
(click)="testAllServers()"
[disabled]="isTesting()"
class="flex items-center gap-1.5 px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50"
>
<ng-icon
name="lucideRefreshCw"
class="w-3.5 h-3.5"
[class.animate-spin]="isTesting()"
/>
Test All
</button>
</div>
</div>
<p class="text-xs text-muted-foreground mb-3">Server directories to search for rooms. The active server is used for creating new rooms.</p>
<p class="text-xs text-muted-foreground mb-3">
Active server endpoints stay enabled at the same time. You pick the endpoint when creating a new server.
</p>
<!-- Server List -->
<div class="space-y-2 mb-3">
@@ -41,6 +55,7 @@
[class.bg-red-500]="server.status === 'offline'"
[class.bg-yellow-500]="server.status === 'checking'"
[class.bg-muted]="server.status === 'unknown'"
[class.bg-orange-500]="server.status === 'incompatible'"
></div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
@@ -53,13 +68,17 @@
@if (server.latency !== undefined && server.status === 'online') {
<p class="text-[10px] text-muted-foreground">{{ server.latency }}ms</p>
}
@if (server.status === 'incompatible') {
<p class="text-[10px] text-destructive">Update the client in order to connect to other users</p>
}
</div>
<div class="flex items-center gap-1 flex-shrink-0">
@if (!server.isActive) {
@if (!server.isActive && server.status !== 'incompatible') {
<button
type="button"
(click)="setActiveServer(server.id)"
class="p-1.5 hover:bg-secondary rounded-lg transition-colors"
title="Set as active"
title="Activate"
>
<ng-icon
name="lucideCheck"
@@ -67,8 +86,22 @@
/>
</button>
}
@if (!server.isDefault) {
@if (server.isActive && hasMultipleActiveServers()) {
<button
type="button"
(click)="deactivateServer(server.id)"
class="p-1.5 hover:bg-secondary rounded-lg transition-colors"
title="Deactivate"
>
<ng-icon
name="lucideX"
class="w-3.5 h-3.5 text-muted-foreground hover:text-foreground"
/>
</button>
}
@if (hasMultipleServers()) {
<button
type="button"
(click)="removeServer(server.id)"
class="p-1.5 hover:bg-destructive/10 rounded-lg transition-colors"
title="Remove"
@@ -103,6 +136,7 @@
/>
</div>
<button
type="button"
(click)="addServer()"
[disabled]="!newServerName || !newServerUrl"
class="px-3 py-1.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed self-end"

View File

@@ -2,7 +2,8 @@
import {
Component,
inject,
signal
signal,
computed
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
@@ -13,7 +14,8 @@ import {
lucideRefreshCw,
lucidePlus,
lucideTrash2,
lucideCheck
lucideCheck,
lucideX
} from '@ng-icons/lucide';
import { ServerDirectoryService } from '../../../../core/services/server-directory.service';
@@ -34,7 +36,8 @@ import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
lucideRefreshCw,
lucidePlus,
lucideTrash2,
lucideCheck
lucideCheck,
lucideX
})
],
templateUrl: './network-settings.component.html'
@@ -43,6 +46,10 @@ export class NetworkSettingsComponent {
private serverDirectory = inject(ServerDirectoryService);
servers = this.serverDirectory.servers;
activeServers = this.serverDirectory.activeServers;
hasMissingDefaultServers = this.serverDirectory.hasMissingDefaultServers;
hasMultipleServers = computed(() => this.servers().length > 1);
hasMultipleActiveServers = computed(() => this.activeServers().length > 1);
isTesting = signal(false);
addError = signal<string | null>(null);
newServerName = '';
@@ -91,6 +98,14 @@ export class NetworkSettingsComponent {
this.serverDirectory.setActiveServer(id);
}
deactivateServer(id: string): void {
this.serverDirectory.deactivateServer(id);
}
restoreDefaultServers(): void {
this.serverDirectory.restoreDefaultServers();
}
async testAllServers(): Promise<void> {
this.isTesting.set(true);
await this.serverDirectory.testAllServers();

View File

@@ -1,6 +1,7 @@
<div class="p-6 max-w-2xl mx-auto">
<div class="flex items-center gap-3 mb-6">
<button
type="button"
(click)="goBack()"
class="p-2 hover:bg-secondary rounded-lg transition-colors"
title="Go back"
@@ -27,23 +28,34 @@
/>
<h2 class="text-lg font-semibold text-foreground">Server Endpoints</h2>
</div>
<button
(click)="testAllServers()"
[disabled]="isTesting()"
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50"
>
<ng-icon
name="lucideRefreshCw"
class="w-4 h-4"
[class.animate-spin]="isTesting()"
/>
Test All
</button>
<div class="flex items-center gap-2">
@if (hasMissingDefaultServers()) {
<button
type="button"
(click)="restoreDefaultServers()"
class="px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
>
Restore Defaults
</button>
}
<button
type="button"
(click)="testAllServers()"
[disabled]="isTesting()"
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50"
>
<ng-icon
name="lucideRefreshCw"
class="w-4 h-4"
[class.animate-spin]="isTesting()"
/>
Test All
</button>
</div>
</div>
<p class="text-sm text-muted-foreground mb-4">
Add multiple server directories to search for rooms across different networks. The active server will be used for creating and registering new
rooms.
Active server endpoints stay enabled at the same time. You pick the endpoint when creating and registering a new server.
</p>
<!-- Server List -->
@@ -63,6 +75,7 @@
[class.bg-red-500]="server.status === 'offline'"
[class.bg-yellow-500]="server.status === 'checking'"
[class.bg-muted]="server.status === 'unknown'"
[class.bg-orange-500]="server.status === 'incompatible'"
[title]="server.status"
></div>
@@ -78,15 +91,19 @@
@if (server.latency !== undefined && server.status === 'online') {
<p class="text-xs text-muted-foreground">{{ server.latency }}ms</p>
}
@if (server.status === 'incompatible') {
<p class="text-xs text-destructive">Update the client in order to connect to other users</p>
}
</div>
<!-- Actions -->
<div class="flex items-center gap-2 flex-shrink-0">
@if (!server.isActive) {
@if (!server.isActive && server.status !== 'incompatible') {
<button
type="button"
(click)="setActiveServer(server.id)"
class="p-2 hover:bg-secondary rounded-lg transition-colors"
title="Set as active"
title="Activate"
>
<ng-icon
name="lucideCheck"
@@ -94,8 +111,22 @@
/>
</button>
}
@if (!server.isDefault) {
@if (server.isActive && hasMultipleActiveServers()) {
<button
type="button"
(click)="deactivateServer(server.id)"
class="p-2 hover:bg-secondary rounded-lg transition-colors"
title="Deactivate"
>
<ng-icon
name="lucideX"
class="w-4 h-4 text-muted-foreground hover:text-foreground"
/>
</button>
}
@if (hasMultipleServers()) {
<button
type="button"
(click)="removeServer(server.id)"
class="p-2 hover:bg-destructive/10 rounded-lg transition-colors"
title="Remove server"
@@ -130,6 +161,7 @@
/>
</div>
<button
type="button"
(click)="addServer()"
[disabled]="!newServerName || !newServerUrl"
class="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed self-end"
@@ -228,6 +260,7 @@
class="flex-1 h-2 rounded-full appearance-none bg-secondary accent-primary cursor-pointer"
/>
<button
type="button"
(click)="previewNotificationSound()"
class="px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
title="Preview sound"

View File

@@ -3,7 +3,8 @@ import {
Component,
inject,
signal,
OnInit
OnInit,
computed
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
@@ -61,6 +62,10 @@ export class SettingsComponent implements OnInit {
audioService = inject(NotificationAudioService);
servers = this.serverDirectory.servers;
activeServers = this.serverDirectory.activeServers;
hasMissingDefaultServers = this.serverDirectory.hasMissingDefaultServers;
hasMultipleServers = computed(() => this.servers().length > 1);
hasMultipleActiveServers = computed(() => this.activeServers().length > 1);
isTesting = signal(false);
addError = signal<string | null>(null);
@@ -122,6 +127,14 @@ export class SettingsComponent implements OnInit {
this.serverDirectory.setActiveServer(id);
}
deactivateServer(id: string): void {
this.serverDirectory.deactivateServer(id);
}
restoreDefaultServers(): void {
this.serverDirectory.restoreDefaultServers();
}
/** Test connectivity to all configured servers. */
async testAllServers(): Promise<void> {
this.isTesting.set(true);

View File

@@ -13,6 +13,12 @@
/>
<span class="text-sm font-semibold text-foreground truncate">{{ roomName() }}</span>
@if (showRoomCompatibilityNotice()) {
<span class="inline-flex items-center gap-1 rounded bg-destructive/15 px-2 py-0.5 text-xs text-destructive">
{{ signalServerCompatibilityError() }}
</span>
}
@if (showRoomReconnectNotice()) {
<span class="inline-flex items-center gap-1 rounded bg-destructive/15 px-2 py-0.5 text-xs text-destructive">
<ng-icon
@@ -31,9 +37,11 @@
} @else {
<div class="flex items-center gap-2 min-w-0">
<span class="text-sm text-muted-foreground truncate">{{ username() }} | {{ serverName() }}</span>
@if (isReconnecting()) {
<span class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive">Reconnecting…</span>
}
<span
class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive"
[class.hidden]="!isReconnecting()"
>Reconnecting…</span
>
</div>
}
</div>
@@ -41,16 +49,15 @@
class="flex items-center gap-2"
style="-webkit-app-region: no-drag"
>
@if (!isAuthed()) {
<button
type="button"
class="px-3 h-8 grid place-items-center hover:bg-secondary rounded text-sm text-foreground"
(click)="goLogin()"
title="Login"
>
Login
</button>
}
<button
type="button"
class="px-3 h-8 grid place-items-center hover:bg-secondary rounded text-sm text-foreground"
[class.hidden]="isAuthed()"
(click)="goLogin()"
title="Login"
>
Login
</button>
<button
type="button"
@@ -87,11 +94,12 @@
Leave Server
</button>
}
@if (inviteStatus()) {
<div class="border-t border-border px-3 py-2 text-xs leading-5 text-muted-foreground">
{{ inviteStatus() }}
</div>
}
<div
class="border-t border-border px-3 py-2 text-xs leading-5 text-muted-foreground"
[class.hidden]="!inviteStatus()"
>
{{ inviteStatus() }}
</div>
<div class="border-t border-border"></div>
<button
type="button"

View File

@@ -19,7 +19,11 @@ import {
lucideRefreshCw
} from '@ng-icons/lucide';
import { Router } from '@angular/router';
import { selectCurrentRoom, selectIsSignalServerReconnecting } from '../../store/rooms/rooms.selectors';
import {
selectCurrentRoom,
selectIsSignalServerReconnecting,
selectSignalServerCompatibilityError
} from '../../store/rooms/rooms.selectors';
import { RoomsActions } from '../../store/rooms/rooms.actions';
import { selectCurrentUser } from '../../store/users/users.selectors';
import { ServerDirectoryService } from '../../core/services/server-directory.service';
@@ -83,11 +87,17 @@ export class TitleBarComponent {
isAuthed = computed(() => !!this.currentUser());
currentRoom = this.store.selectSignal(selectCurrentRoom);
isSignalServerReconnecting = this.store.selectSignal(selectIsSignalServerReconnecting);
signalServerCompatibilityError = this.store.selectSignal(selectSignalServerCompatibilityError);
inRoom = computed(() => !!this.currentRoom());
roomName = computed(() => this.currentRoom()?.name || '');
roomDescription = computed(() => this.currentRoom()?.description || '');
showRoomCompatibilityNotice = computed(() =>
this.inRoom() && !!this.signalServerCompatibilityError()
);
showRoomReconnectNotice = computed(() =>
this.inRoom() && (
this.inRoom()
&& !this.signalServerCompatibilityError()
&& (
this.isSignalServerReconnecting()
|| this.webrtc.shouldShowConnectionError()
|| this.isReconnecting()

View File

@@ -25,7 +25,15 @@ export const RoomsActions = createActionGroup({
'Search Servers Success': props<{ servers: ServerInfo[] }>(),
'Search Servers Failure': props<{ error: string }>(),
'Create Room': props<{ name: string; description?: string; topic?: string; isPrivate?: boolean; password?: string }>(),
'Create Room': props<{
name: string;
description?: string;
topic?: string;
isPrivate?: boolean;
password?: string;
sourceId?: string;
sourceUrl?: string;
}>(),
'Create Room Success': props<{ room: Room }>(),
'Create Room Failure': props<{ error: string }>(),
@@ -36,7 +44,7 @@ export const RoomsActions = createActionGroup({
'Leave Room': emptyProps(),
'Leave Room Success': emptyProps(),
'View Server': props<{ room: Room }>(),
'View Server': props<{ room: Room; skipBanCheck?: boolean }>(),
'View Server Success': props<{ room: Room }>(),
'Delete Room': props<{ roomId: string }>(),
@@ -68,6 +76,7 @@ export const RoomsActions = createActionGroup({
'Clear Search Results': emptyProps(),
'Set Connecting': props<{ isConnecting: boolean }>(),
'Set Signal Server Reconnecting': props<{ isReconnecting: boolean }>()
'Set Signal Server Reconnecting': props<{ isReconnecting: boolean }>(),
'Set Signal Server Compatibility Error': props<{ message: string | null }>()
}
});

View File

@@ -32,7 +32,11 @@ import { selectCurrentUser, selectAllUsers } from '../users/users.selectors';
import { selectCurrentRoom, selectSavedRooms } from './rooms.selectors';
import { DatabaseService } from '../../core/services/database.service';
import { WebRTCService } from '../../core/services/webrtc.service';
import { ServerDirectoryService } from '../../core/services/server-directory.service';
import {
CLIENT_UPDATE_REQUIRED_MESSAGE,
ServerDirectoryService,
ServerSourceSelector
} from '../../core/services/server-directory.service';
import {
ChatEvent,
Room,
@@ -182,12 +186,21 @@ export class RoomsEffects {
this.actions$.pipe(
ofType(RoomsActions.createRoom),
withLatestFrom(this.store.select(selectCurrentUser)),
switchMap(([{ name, description, topic, isPrivate, password }, currentUser]) => {
switchMap(([{ name, description, topic, isPrivate, password, sourceId, sourceUrl }, currentUser]) => {
if (!currentUser) {
return of(RoomsActions.createRoomFailure({ error: 'Not logged in' }));
}
const activeEndpoint = this.serverDirectory.activeServer();
const allEndpoints = this.serverDirectory.servers();
const activeEndpoints = this.serverDirectory.activeServers();
const selectedEndpoint = allEndpoints.find((endpoint) =>
(sourceId && endpoint.id === sourceId)
|| (!!sourceUrl && endpoint.url === sourceUrl)
);
const endpoint = selectedEndpoint
?? activeEndpoints[0]
?? allEndpoints[0]
?? null;
const normalizedPassword = typeof password === 'string' ? password.trim() : '';
const room: Room = {
id: uuidv4(),
@@ -201,9 +214,9 @@ export class RoomsEffects {
createdAt: Date.now(),
userCount: 1,
maxUsers: 50,
sourceId: activeEndpoint?.id,
sourceName: activeEndpoint?.name,
sourceUrl: activeEndpoint?.url
sourceId: endpoint?.id,
sourceName: endpoint?.name,
sourceUrl: endpoint?.url
};
// Save to local DB
@@ -224,7 +237,11 @@ export class RoomsEffects {
userCount: 1,
maxUsers: room.maxUsers || 50,
tags: []
})
}, endpoint ? {
sourceId: endpoint.id,
sourceUrl: endpoint.url
} : undefined
)
.subscribe();
return of(RoomsActions.createRoomSuccess({ room }));
@@ -353,7 +370,9 @@ export class RoomsEffects {
user,
savedRooms
]) => {
this.connectToRoomSignaling(room, user ?? null, undefined, savedRooms);
void this.connectToRoomSignaling(room, user ?? null, undefined, savedRooms, {
showCompatibilityError: true
});
this.router.navigate(['/room', room.id]);
})
@@ -367,7 +386,7 @@ export class RoomsEffects {
ofType(RoomsActions.viewServer),
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectSavedRooms)),
switchMap(([
{ room },
{ room, skipBanCheck },
user,
savedRooms
]) => {
@@ -375,18 +394,28 @@ export class RoomsEffects {
return of(RoomsActions.joinRoomFailure({ error: 'Not logged in' }));
}
const activateViewedRoom = () => {
const oderId = user.oderId || this.webrtc.peerId();
void this.connectToRoomSignaling(room, user, oderId, savedRooms, {
showCompatibilityError: true
});
this.router.navigate(['/room', room.id]);
return of(RoomsActions.viewServerSuccess({ room }));
};
if (skipBanCheck) {
return activateViewedRoom();
}
return from(this.getBlockedRoomAccessActions(room.id, user)).pipe(
switchMap((blockedActions) => {
if (blockedActions.length > 0) {
return from(blockedActions);
}
const oderId = user.oderId || this.webrtc.peerId();
this.connectToRoomSignaling(room, user, oderId, savedRooms);
this.router.navigate(['/room', room.id]);
return of(RoomsActions.viewServerSuccess({ room }));
return activateViewedRoom();
}),
catchError((error) => of(RoomsActions.joinRoomFailure({ error: error.message })))
);
@@ -1317,48 +1346,64 @@ export class RoomsEffects {
{ dispatch: false }
);
private connectToRoomSignaling(
private async connectToRoomSignaling(
room: Room,
user: User | null,
resolvedOderId?: string,
savedRooms: Room[] = []
): void {
savedRooms: Room[] = [],
options: { showCompatibilityError?: boolean } = {}
): Promise<void> {
const shouldShowCompatibilityError = options.showCompatibilityError ?? false;
const compatibilitySelector = this.resolveCompatibilitySelector(room);
const isCompatible = compatibilitySelector === null
? true
: await this.serverDirectory.ensureEndpointVersionCompatibility(compatibilitySelector);
if (!isCompatible) {
if (shouldShowCompatibilityError) {
this.store.dispatch(
RoomsActions.setSignalServerCompatibilityError({ message: CLIENT_UPDATE_REQUIRED_MESSAGE })
);
}
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
return;
}
if (shouldShowCompatibilityError) {
this.store.dispatch(RoomsActions.setSignalServerCompatibilityError({ message: null }));
}
const wsUrl = this.serverDirectory.getWebSocketUrl({
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
});
const currentWsUrl = this.webrtc.getCurrentSignalingUrl();
const oderId = resolvedOderId || user?.oderId || this.webrtc.peerId();
const displayName = user?.displayName || 'Anonymous';
const sameSignalServer = currentWsUrl === wsUrl;
const sameSignalRooms = this.getRoomsForSignalingUrl(this.includeRoom(savedRooms, room), wsUrl);
const backgroundRooms = sameSignalRooms.filter((candidate) => candidate.id !== room.id);
const joinCurrentEndpointRooms = () => {
this.webrtc.setCurrentServer(room.id);
this.webrtc.identify(oderId, displayName);
this.webrtc.identify(oderId, displayName, wsUrl);
for (const backgroundRoom of backgroundRooms) {
if (!this.webrtc.hasJoinedServer(backgroundRoom.id)) {
this.webrtc.joinRoom(backgroundRoom.id, oderId);
this.webrtc.joinRoom(backgroundRoom.id, oderId, wsUrl);
}
}
if (this.webrtc.hasJoinedServer(room.id)) {
this.webrtc.switchServer(room.id, oderId);
this.webrtc.switchServer(room.id, oderId, wsUrl);
} else {
this.webrtc.joinRoom(room.id, oderId);
this.webrtc.joinRoom(room.id, oderId, wsUrl);
}
};
if (this.webrtc.isConnected() && sameSignalServer) {
if (this.webrtc.isSignalingConnectedTo(wsUrl)) {
joinCurrentEndpointRooms();
return;
}
if (currentWsUrl && currentWsUrl !== wsUrl) {
this.webrtc.disconnectAll();
}
this.webrtc.connectToSignalingServer(wsUrl).subscribe({
next: (connected) => {
if (!connected)
@@ -1376,20 +1421,66 @@ export class RoomsEffects {
}
const watchedRoomId = this.extractRoomIdFromUrl(this.router.url);
const currentWsUrl = this.webrtc.getCurrentSignalingUrl();
const targetRoom = (watchedRoomId
? savedRooms.find((room) => room.id === watchedRoomId) ?? null
: null)
?? (currentWsUrl ? this.findRoomBySignalingUrl(savedRooms, currentWsUrl) : null)
?? currentRoom
?? savedRooms[0]
?? null;
const roomsToSync = currentRoom ? this.includeRoom(savedRooms, currentRoom) : savedRooms;
const roomsBySignalingUrl = new Map<string, Room[]>();
if (!targetRoom) {
return;
for (const room of roomsToSync) {
const wsUrl = this.serverDirectory.getWebSocketUrl({
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
});
const groupedRooms = roomsBySignalingUrl.get(wsUrl) ?? [];
if (!groupedRooms.some((groupedRoom) => groupedRoom.id === room.id)) {
groupedRooms.push(room);
}
roomsBySignalingUrl.set(wsUrl, groupedRooms);
}
this.connectToRoomSignaling(targetRoom, user, user.oderId || this.webrtc.peerId(), savedRooms);
for (const groupedRooms of roomsBySignalingUrl.values()) {
const preferredRoom = groupedRooms.find((room) => room.id === watchedRoomId)
?? (currentRoom && groupedRooms.some((room) => room.id === currentRoom.id)
? currentRoom
: null)
?? groupedRooms[0]
?? null;
if (!preferredRoom) {
continue;
}
const shouldShowCompatibilityError = preferredRoom.id === watchedRoomId
|| (!!currentRoom && preferredRoom.id === currentRoom.id);
void this.connectToRoomSignaling(preferredRoom, user, user.oderId || this.webrtc.peerId(), roomsToSync, {
showCompatibilityError: shouldShowCompatibilityError
});
}
}
private resolveCompatibilitySelector(room: Room): ServerSourceSelector | undefined | null {
if (room.sourceId) {
const endpointById = this.serverDirectory.servers().find((entry) => entry.id === room.sourceId);
if (endpointById) {
return { sourceId: room.sourceId };
}
if (room.sourceUrl && this.serverDirectory.findServerByUrl(room.sourceUrl)) {
return { sourceUrl: room.sourceUrl };
}
return null;
}
if (room.sourceUrl) {
return this.serverDirectory.findServerByUrl(room.sourceUrl)
? { sourceUrl: room.sourceUrl }
: null;
}
return undefined;
}
private includeRoom(rooms: Room[], room: Room): Room[] {
@@ -1421,10 +1512,6 @@ export class RoomsEffects {
return matchingRooms;
}
private findRoomBySignalingUrl(rooms: Room[], wsUrl: string): Room | null {
return this.getRoomsForSignalingUrl(rooms, wsUrl)[0] ?? null;
}
private extractRoomIdFromUrl(url: string): string | null {
const roomMatch = url.match(ROOM_URL_PATTERN);

View File

@@ -84,6 +84,8 @@ export interface RoomsState {
isConnected: boolean;
/** Whether the current room is using locally cached data while reconnecting. */
isSignalServerReconnecting: boolean;
/** Banner message when the viewed room's signaling endpoint is incompatible. */
signalServerCompatibilityError: string | null;
/** Whether rooms are being loaded from local storage. */
loading: boolean;
/** Most recent error message, if any. */
@@ -101,6 +103,7 @@ export const initialState: RoomsState = {
isConnecting: false,
isConnected: false,
isSignalServerReconnecting: false,
signalServerCompatibilityError: null,
loading: false,
error: null,
activeChannelId: 'general'
@@ -151,6 +154,7 @@ export const roomsReducer = createReducer(
on(RoomsActions.createRoom, (state) => ({
...state,
isConnecting: true,
signalServerCompatibilityError: null,
error: null
})),
@@ -163,6 +167,7 @@ export const roomsReducer = createReducer(
savedRooms: upsertRoom(state.savedRooms, enriched),
isConnecting: false,
isSignalServerReconnecting: false,
signalServerCompatibilityError: null,
isConnected: true,
activeChannelId: 'general'
};
@@ -178,6 +183,7 @@ export const roomsReducer = createReducer(
on(RoomsActions.joinRoom, (state) => ({
...state,
isConnecting: true,
signalServerCompatibilityError: null,
error: null
})),
@@ -190,6 +196,7 @@ export const roomsReducer = createReducer(
savedRooms: upsertRoom(state.savedRooms, enriched),
isConnecting: false,
isSignalServerReconnecting: false,
signalServerCompatibilityError: null,
isConnected: true,
activeChannelId: 'general'
};
@@ -212,6 +219,7 @@ export const roomsReducer = createReducer(
currentRoom: null,
roomSettings: null,
isSignalServerReconnecting: false,
signalServerCompatibilityError: null,
isConnecting: false,
isConnected: false
})),
@@ -220,6 +228,7 @@ export const roomsReducer = createReducer(
on(RoomsActions.viewServer, (state) => ({
...state,
isConnecting: true,
signalServerCompatibilityError: null,
error: null
})),
@@ -231,6 +240,7 @@ export const roomsReducer = createReducer(
currentRoom: enriched,
savedRooms: upsertRoom(state.savedRooms, enriched),
isConnecting: false,
signalServerCompatibilityError: null,
isConnected: true,
activeChannelId: 'general'
};
@@ -286,6 +296,7 @@ export const roomsReducer = createReducer(
on(RoomsActions.deleteRoomSuccess, (state, { roomId }) => ({
...state,
isSignalServerReconnecting: state.currentRoom?.id === roomId ? false : state.isSignalServerReconnecting,
signalServerCompatibilityError: state.currentRoom?.id === roomId ? null : state.signalServerCompatibilityError,
savedRooms: state.savedRooms.filter((room) => room.id !== roomId),
currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom
})),
@@ -294,6 +305,7 @@ export const roomsReducer = createReducer(
on(RoomsActions.forgetRoomSuccess, (state, { roomId }) => ({
...state,
isSignalServerReconnecting: state.currentRoom?.id === roomId ? false : state.isSignalServerReconnecting,
signalServerCompatibilityError: state.currentRoom?.id === roomId ? null : state.signalServerCompatibilityError,
savedRooms: state.savedRooms.filter((room) => room.id !== roomId),
currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom
})),
@@ -304,6 +316,7 @@ export const roomsReducer = createReducer(
currentRoom: enrichRoom(room),
savedRooms: upsertRoom(state.savedRooms, room),
isSignalServerReconnecting: false,
signalServerCompatibilityError: null,
isConnected: true
})),
@@ -313,6 +326,7 @@ export const roomsReducer = createReducer(
currentRoom: null,
roomSettings: null,
isSignalServerReconnecting: false,
signalServerCompatibilityError: null,
isConnected: false
})),
@@ -382,6 +396,11 @@ export const roomsReducer = createReducer(
isSignalServerReconnecting: isReconnecting
})),
on(RoomsActions.setSignalServerCompatibilityError, (state, { message }) => ({
...state,
signalServerCompatibilityError: message
})),
// Channel management
on(RoomsActions.selectChannel, (state, { channelId }) => ({
...state,

View File

@@ -30,6 +30,10 @@ export const selectIsSignalServerReconnecting = createSelector(
selectRoomsState,
(state) => state.isSignalServerReconnecting
);
export const selectSignalServerCompatibilityError = createSelector(
selectRoomsState,
(state) => state.signalServerCompatibilityError
);
export const selectRoomsError = createSelector(
selectRoomsState,
(state) => state.error

View File

@@ -1,4 +1,16 @@
export const environment = {
production: true,
defaultServers: [
{
key: 'toju-primary',
name: 'Toju Signal',
url: 'https://signal.toju.app'
},
{
key: 'toju-sweden',
name: 'Toju Signal Sweden',
url: 'https://signal-sweden.toju.app'
}
],
defaultServerUrl: 'https://signal.toju.app'
};

View File

@@ -1,4 +1,21 @@
export const environment = {
production: false,
defaultServers: [
{
key: 'default',
name: 'Default Server',
url: 'https://46.59.68.77:3001'
},
{
key: 'toju-primary',
name: 'Toju Signal',
url: 'https://signal.toju.app'
},
{
key: 'toju-sweden',
name: 'Toju Signal Sweden',
url: 'https://signal-sweden.toju.app'
}
],
defaultServerUrl: 'https://46.59.68.77:3001'
};