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
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:
@@ -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 />
|
||||
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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 ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 -->
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }>()
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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