Allow multiple signal servers (might need rollback)
All checks were successful
Queue Release Build / prepare (push) Successful in 16s
Deploy Web Apps / deploy (push) Successful in 10m2s
Queue Release Build / build-linux (push) Successful in 26m35s
Queue Release Build / build-windows (push) Successful in 25m37s
Queue Release Build / finalize (push) Successful in 1m42s
All checks were successful
Queue Release Build / prepare (push) Successful in 16s
Deploy Web Apps / deploy (push) Successful in 10m2s
Queue Release Build / build-linux (push) Successful in 26m35s
Queue Release Build / build-windows (push) Successful in 25m37s
Queue Release Build / finalize (push) Successful in 1m42s
This commit is contained in:
@@ -12,10 +12,21 @@ 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;
|
||||
}
|
||||
|
||||
type DefaultEndpointTemplate = Omit<ServerEndpoint, 'id' | 'defaultKey'> & {
|
||||
defaultKey: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A configured server endpoint that the user can connect to.
|
||||
*/
|
||||
@@ -30,6 +41,8 @@ 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';
|
||||
/** Last measured round-trip latency (ms). */
|
||||
@@ -101,6 +114,8 @@ 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;
|
||||
|
||||
@@ -139,7 +154,7 @@ function normaliseDefaultServerUrl(rawUrl: string): string {
|
||||
* 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 +164,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
|
||||
@@ -171,22 +233,31 @@ export class ServerDirectoryService {
|
||||
private readonly _servers = signal<ServerEndpoint[]>([]);
|
||||
|
||||
/** 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. */
|
||||
/** Endpoints currently enabled for discovery. */
|
||||
readonly activeServers = computed(() => this._servers().filter((endpoint) => endpoint.isActive));
|
||||
|
||||
/** 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._servers().find((endpoint) => endpoint.isActive) ?? this._servers()[0]
|
||||
() => this.activeServers()[0] ?? this._servers()[0]
|
||||
);
|
||||
|
||||
constructor(private readonly http: HttpClient) {
|
||||
this.loadConnectionSettings();
|
||||
this.loadEndpoints();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +267,7 @@ export class ServerDirectoryService {
|
||||
id: uuidv4(),
|
||||
name: server.name,
|
||||
url: sanitisedUrl,
|
||||
isActive: false,
|
||||
isActive: true,
|
||||
isDefault: false,
|
||||
status: 'unknown'
|
||||
};
|
||||
@@ -241,24 +312,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 +344,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) {
|
||||
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();
|
||||
@@ -635,7 +774,7 @@ export class ServerDirectoryService {
|
||||
return this.sanitiseUrl(selector.sourceUrl);
|
||||
}
|
||||
|
||||
return this.resolveEndpoint(selector)?.url ?? buildDefaultServerUrl();
|
||||
return this.resolveEndpoint(selector)?.url ?? getPrimaryDefaultServerUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -672,7 +811,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 +831,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 +919,201 @@ export class ServerDirectoryService {
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
/** 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()));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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 ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
@@ -57,9 +71,10 @@
|
||||
<div class="flex items-center gap-1 flex-shrink-0">
|
||||
@if (!server.isActive) {
|
||||
<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 +82,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 +132,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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 -->
|
||||
@@ -84,9 +96,10 @@
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
@if (!server.isActive) {
|
||||
<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 +107,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 +157,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 +256,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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }>(),
|
||||
|
||||
|
||||
@@ -182,12 +182,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 +210,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 +233,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 }));
|
||||
@@ -1327,38 +1340,32 @@ export class RoomsEffects {
|
||||
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 +1383,37 @@ 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;
|
||||
}
|
||||
|
||||
this.connectToRoomSignaling(preferredRoom, user, user.oderId || this.webrtc.peerId(), roomsToSync);
|
||||
}
|
||||
}
|
||||
|
||||
private includeRoom(rooms: Room[], room: Room): Room[] {
|
||||
@@ -1421,10 +1445,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);
|
||||
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user