feat: signal server tag

This commit is contained in:
2026-06-05 06:16:02 +02:00
parent 6865147e8f
commit bf4e6891d1
69 changed files with 2808 additions and 1269 deletions

View File

@@ -0,0 +1,39 @@
import { firstValueFrom, of } from 'rxjs';
import {
describe,
expect,
it
} from 'vitest';
import { UsersActions } from '../../../../store/users/users.actions';
import { waitForAuthenticationOutcome } from './auth-navigation.rules';
describe('waitForAuthenticationOutcome', () => {
it('resolves when authentication storage preparation succeeds', async () => {
const user = {
id: 'user-1',
oderId: 'user-1',
username: 'alice',
displayName: 'Alice',
status: 'online' as const,
role: 'member' as const,
joinedAt: 1
};
const outcome = await firstValueFrom(waitForAuthenticationOutcome(of(
UsersActions.setCurrentUser({ user })
)));
expect(outcome).toEqual({ kind: 'success', user });
});
it('resolves with a failure when authentication storage preparation fails', async () => {
const outcome = await firstValueFrom(waitForAuthenticationOutcome(of(
UsersActions.loadCurrentUserFailure({ error: 'Failed to prepare local user state.' })
)));
expect(outcome).toEqual({
kind: 'failure',
error: 'Failed to prepare local user state.'
});
});
});

View File

@@ -0,0 +1,45 @@
import {
filter,
map,
Observable,
take
} from 'rxjs';
import { UsersActions } from '../../../../store/users/users.actions';
import type { User } from '../../../../shared-kernel';
export type AuthenticationOutcome =
| { kind: 'success'; user: User }
| { kind: 'failure'; error: string };
export function waitForAuthenticationOutcome(
actions$: Observable<{ type: string; user?: User; error?: string }>
): Observable<AuthenticationOutcome> {
return actions$.pipe(
filter((action) =>
action.type === UsersActions.setCurrentUser.type
|| action.type === UsersActions.loadCurrentUserFailure.type
),
take(1),
map((action) => {
if (action.type === UsersActions.loadCurrentUserFailure.type) {
return {
kind: 'failure' as const,
error: action.error || 'Authentication failed'
};
}
if (!action.user) {
return {
kind: 'failure' as const,
error: 'Authentication failed'
};
}
return {
kind: 'success' as const,
user: action.user
};
})
);
}

View File

@@ -7,12 +7,15 @@ import {
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { Actions } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideLogIn } from '@ng-icons/lucide';
import { firstValueFrom } from 'rxjs';
import { AuthenticationService } from '../../application/services/authentication.service';
import { ServerDirectoryFacade } from '../../../server-directory';
import { waitForAuthenticationOutcome } from '../../domain/logic/auth-navigation.rules';
import { UsersActions } from '../../../../store/users/users.actions';
import { User } from '../../../../shared-kernel';
@@ -40,6 +43,7 @@ export class LoginComponent {
error = signal<string | null>(null);
private auth = inject(AuthenticationService);
private actions$ = inject(Actions);
private store = inject(Store);
private route = inject(ActivatedRoute);
private router = inject(Router);
@@ -55,10 +59,12 @@ export class LoginComponent {
this.auth.login({ username: this.username.trim(),
password: this.password,
serverId: sid }).subscribe({
next: (resp) => {
next: async (resp) => {
if (sid)
this.serversSvc.setActiveServer(sid);
const homeSignalServerUrl = this.serversSvc.servers().find((server) => server.id === sid)?.url
?? this.serversSvc.activeServer()?.url;
const user: User = {
id: resp.id,
oderId: resp.id,
@@ -66,19 +72,27 @@ export class LoginComponent {
displayName: resp.displayName,
status: 'online',
role: 'member',
joinedAt: Date.now()
joinedAt: Date.now(),
homeSignalServerUrl
};
this.store.dispatch(UsersActions.authenticateUser({ user }));
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
if (returnUrl?.startsWith('/')) {
this.router.navigateByUrl(returnUrl);
const outcome = await firstValueFrom(waitForAuthenticationOutcome(this.actions$));
if (outcome.kind === 'failure') {
this.error.set(outcome.error);
return;
}
this.router.navigate(['/dashboard']);
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
if (returnUrl?.startsWith('/')) {
await this.router.navigateByUrl(returnUrl);
return;
}
await this.router.navigate(['/dashboard']);
},
error: (err) => {
this.error.set(err?.error?.error || 'Login failed');

View File

@@ -7,12 +7,15 @@ import {
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { Actions } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideUserPlus } from '@ng-icons/lucide';
import { firstValueFrom } from 'rxjs';
import { AuthenticationService } from '../../application/services/authentication.service';
import { ServerDirectoryFacade } from '../../../server-directory';
import { waitForAuthenticationOutcome } from '../../domain/logic/auth-navigation.rules';
import { UsersActions } from '../../../../store/users/users.actions';
import { User } from '../../../../shared-kernel';
@@ -41,6 +44,7 @@ export class RegisterComponent {
error = signal<string | null>(null);
private auth = inject(AuthenticationService);
private actions$ = inject(Actions);
private store = inject(Store);
private route = inject(ActivatedRoute);
private router = inject(Router);
@@ -57,10 +61,12 @@ export class RegisterComponent {
password: this.password,
displayName: this.displayName.trim(),
serverId: sid }).subscribe({
next: (resp) => {
next: async (resp) => {
if (sid)
this.serversSvc.setActiveServer(sid);
const homeSignalServerUrl = this.serversSvc.servers().find((server) => server.id === sid)?.url
?? this.serversSvc.activeServer()?.url;
const user: User = {
id: resp.id,
oderId: resp.id,
@@ -68,19 +74,27 @@ export class RegisterComponent {
displayName: resp.displayName,
status: 'online',
role: 'member',
joinedAt: Date.now()
joinedAt: Date.now(),
homeSignalServerUrl
};
this.store.dispatch(UsersActions.authenticateUser({ user }));
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
if (returnUrl?.startsWith('/')) {
this.router.navigateByUrl(returnUrl);
const outcome = await firstValueFrom(waitForAuthenticationOutcome(this.actions$));
if (outcome.kind === 'failure') {
this.error.set(outcome.error);
return;
}
this.router.navigate(['/dashboard']);
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
if (returnUrl?.startsWith('/')) {
await this.router.navigateByUrl(returnUrl);
return;
}
await this.router.navigate(['/dashboard']);
},
error: (err) => {
this.error.set(err?.error?.error || 'Registration failed');

View File

@@ -14,9 +14,7 @@
@if (refreshLoading()) {
<div class="pointer-events-none sticky top-0 z-10 flex justify-center py-1">
<div class="rounded-full border border-border bg-background/85 px-2.5 py-1 text-[11px] text-muted-foreground shadow-sm">
Loading...
</div>
<div class="rounded-full border border-border bg-background/85 px-2.5 py-1 text-[11px] text-muted-foreground shadow-sm">Loading...</div>
</div>
}

View File

@@ -87,9 +87,12 @@
<button
type="button"
(click)="selectGif(gif)"
[class]="(isMobile()
? 'group block w-full overflow-hidden rounded-xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30'
: 'group mx-auto mb-4 inline-block w-full max-w-[15.5rem] break-inside-avoid align-top overflow-hidden rounded-2xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30') + ' [content-visibility:auto] [contain-intrinsic-size:auto_180px]'"
[class]="
(isMobile()
? 'group block w-full overflow-hidden rounded-xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30'
: 'group mx-auto mb-4 inline-block w-full max-w-[15.5rem] break-inside-avoid align-top overflow-hidden rounded-2xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30') +
' [content-visibility:auto] [contain-intrinsic-size:auto_180px]'
"
>
<div
class="relative flex items-center justify-center overflow-hidden bg-secondary/30"

View File

@@ -1,4 +1,3 @@
<div class="relative">
@if (compact()) {
<div class="flex gap-1 rounded-lg border border-border bg-card p-2 shadow-lg">
@@ -70,7 +69,9 @@
/>
</label>
<label class="mb-3 flex cursor-pointer items-center justify-center gap-2 rounded-md border border-dashed border-border px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground">
<label
class="mb-3 flex cursor-pointer items-center justify-center gap-2 rounded-md border border-dashed border-border px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
>
<ng-icon
name="lucideUpload"
class="h-4 w-4"
@@ -133,5 +134,4 @@
}
</div>
}
</div>
</div>

View File

@@ -1,5 +1,8 @@
@if (session()) {
<app-modal-backdrop [zIndex]="120" [dismissable]="false" />
<app-modal-backdrop
[zIndex]="120"
[dismissable]="false"
/>
<div class="pointer-events-none fixed inset-0 z-[121] flex items-center justify-center p-4">
<section

View File

@@ -255,7 +255,9 @@
@if (filteredPlugins().length > 0) {
<div class="grid gap-3">
@for (plugin of filteredPlugins(); track trackPlugin($index, plugin)) {
<article class="grid min-w-0 overflow-hidden rounded-lg border border-border bg-background sm:grid-cols-[5.5rem_minmax(0,1fr)] [content-visibility:auto] [contain-intrinsic-size:auto_140px]">
<article
class="grid min-w-0 overflow-hidden rounded-lg border border-border bg-background sm:grid-cols-[5.5rem_minmax(0,1fr)] [content-visibility:auto] [contain-intrinsic-size:auto_140px]"
>
<div class="grid min-h-24 place-items-center bg-secondary text-muted-foreground sm:min-h-full">
@if (plugin.imageUrl && !hasBrokenImage(plugin)) {
<img

View File

@@ -93,9 +93,10 @@ export class ServerDirectoryService {
endpointId: string,
status: ServerEndpoint['status'],
latency?: number,
versions?: ServerEndpointVersions
versions?: ServerEndpointVersions,
serverTag?: string
): void {
this.endpointState.updateServerStatus(endpointId, status, latency, versions);
this.endpointState.updateServerStatus(endpointId, status, latency, versions, serverTag);
}
async ensureEndpointVersionCompatibility(selector?: ServerSourceSelector): Promise<boolean> {
@@ -212,7 +213,8 @@ export class ServerDirectoryService {
endpointId,
healthResult.status,
healthResult.latency,
healthResult.versions
healthResult.versions,
healthResult.serverTag
);
return healthResult.status === 'online';

View File

@@ -228,7 +228,8 @@ export class ServerEndpointStateService {
endpointId: string,
status: ServerEndpoint['status'],
latency?: number,
versions?: ServerEndpointVersions
versions?: ServerEndpointVersions,
serverTag?: string
): void {
this._servers.update((endpoints) => ensureCompatibleActiveEndpoint(endpoints.map((endpoint) => {
if (endpoint.id !== endpointId) {
@@ -240,6 +241,7 @@ export class ServerEndpointStateService {
instanceId: versions?.serverInstanceId ?? endpoint.instanceId,
status,
latency,
serverTag: serverTag ?? endpoint.serverTag,
isActive: status === 'incompatible' && !endpoint.isDefault ? false : endpoint.isActive,
serverVersion: versions?.serverVersion ?? endpoint.serverVersion,
clientVersion: versions?.clientVersion ?? endpoint.clientVersion

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import {
isSignalServerTagUrl,
presentSignalServerTag,
resolveEndpointSignalServerTag,
resolveUserHomeSignalServerTag
} from './signal-server-tag.rules';
import type { ServerEndpoint } from '../models/server-directory.model';
describe('signal-server-tag.rules', () => {
const endpoints: ServerEndpoint[] = [
{
id: 'endpoint-1',
name: 'Primary',
url: 'http://signal.example.com:3001',
isActive: true,
isDefault: true,
status: 'online',
serverTag: 'EU'
},
{
id: 'endpoint-2',
name: 'Fallback',
url: 'https://signal-backup.example.com',
isActive: true,
isDefault: false,
status: 'online'
}
];
it('uses configured serverTag when present on an endpoint', () => {
expect(resolveEndpointSignalServerTag(endpoints[0])).toBe('EU');
});
it('falls back to endpoint url when serverTag is missing', () => {
expect(resolveEndpointSignalServerTag(endpoints[1])).toBe('https://signal-backup.example.com');
});
it('resolves a user home signal server tag from known endpoints', () => {
expect(resolveUserHomeSignalServerTag('http://signal.example.com:3001/', endpoints)).toBe('EU');
});
it('falls back to the home signal server url when the endpoint is unknown', () => {
expect(resolveUserHomeSignalServerTag('http://unknown.example.com:3001', endpoints))
.toBe('http://unknown.example.com:3001');
});
it('returns undefined when the user has no home signal server url', () => {
expect(resolveUserHomeSignalServerTag(undefined, endpoints)).toBeUndefined();
});
it('detects http and https values as urls', () => {
expect(isSignalServerTagUrl('http://signal.example.com:3001')).toBe(true);
expect(isSignalServerTagUrl('https://signal.example.com')).toBe(true);
expect(isSignalServerTagUrl('EU')).toBe(false);
});
it('prefixes non-url tags with #', () => {
expect(presentSignalServerTag('EU')).toEqual({
kind: 'label',
label: 'EU',
display: '#EU'
});
});
it('strips an existing # before re-prefixing label tags', () => {
expect(presentSignalServerTag('#sweden')).toEqual({
kind: 'label',
label: 'sweden',
display: '#sweden'
});
});
it('presents url tags as globe tooltip targets', () => {
expect(presentSignalServerTag('https://signal.example.com')).toEqual({
kind: 'url',
url: 'https://signal.example.com'
});
});
});

View File

@@ -0,0 +1,52 @@
import type { ServerEndpoint } from '../models/server-directory.model';
import { sanitiseServerBaseUrl } from './server-endpoint-defaults.logic';
export type SignalServerTagPresentation =
| { kind: 'url'; url: string }
| { kind: 'label'; label: string; display: string };
export function isSignalServerTagUrl(value: string): boolean {
return /^https?:\/\//i.test(value.trim());
}
export function presentSignalServerTag(tag: string): SignalServerTagPresentation {
const normalized = tag.trim();
if (isSignalServerTagUrl(normalized)) {
return { kind: 'url', url: normalized };
}
const label = normalized.replace(/^#+/, '');
return { kind: 'label', label, display: `#${label}` };
}
export function resolveEndpointSignalServerTag(
endpoint: Pick<ServerEndpoint, 'serverTag' | 'url'>
): string {
const configuredTag = endpoint.serverTag?.trim();
return configuredTag || endpoint.url;
}
export function resolveUserHomeSignalServerTag(
homeSignalServerUrl: string | undefined,
endpoints: ServerEndpoint[]
): string | undefined {
const normalizedHomeUrl = homeSignalServerUrl?.trim();
if (!normalizedHomeUrl) {
return undefined;
}
const sanitizedHomeUrl = sanitiseServerBaseUrl(normalizedHomeUrl);
const matchingEndpoint = endpoints.find(
(endpoint) => sanitiseServerBaseUrl(endpoint.url) === sanitizedHomeUrl
);
if (matchingEndpoint) {
return resolveEndpointSignalServerTag(matchingEndpoint);
}
return sanitizedHomeUrl;
}

View File

@@ -57,6 +57,8 @@ export interface ServerEndpoint {
instanceId?: string;
name: string;
url: string;
/** Display tag advertised by the signal server health endpoint. */
serverTag?: string;
isActive: boolean;
isDefault: boolean;
defaultKey?: string;
@@ -141,10 +143,12 @@ export interface ServerVersionCompatibilityResult {
export interface ServerHealthCheckPayload {
serverInstanceId?: unknown;
serverVersion?: unknown;
serverTag?: unknown;
}
export interface ServerEndpointHealthResult {
status: ServerEndpointStatus;
latency?: number;
serverTag?: string;
versions?: ServerEndpointVersions;
}

View File

@@ -91,7 +91,9 @@
<div class="relative shrink-0">
@if (isJoinedServer(server)) {
<div class="flex items-center overflow-hidden rounded-md border border-emerald-500/30 bg-emerald-500/10 text-xs font-semibold text-emerald-500">
<div
class="flex items-center overflow-hidden rounded-md border border-emerald-500/30 bg-emerald-500/10 text-xs font-semibold text-emerald-500"
>
<span class="px-2.5 py-1.5">Joined</span>
<button
type="button"

View File

@@ -585,7 +585,8 @@ export class ServerBrowserComponent implements OnInit {
await firstValueFrom(this.webrtc.connectToSignalingServer(wsUrl));
this.webrtc.identify(currentUser.oderId || currentUser.id, currentUser.displayName || 'User', wsUrl, {
description: currentUser.description,
profileUpdatedAt: currentUser.profileUpdatedAt
profileUpdatedAt: currentUser.profileUpdatedAt,
homeSignalServerUrl: currentUser.homeSignalServerUrl
});
this.webrtc.sendRawMessageToSignalUrl(wsUrl, {

View File

@@ -33,11 +33,15 @@ export class ServerEndpointHealthService {
const serverInstanceId = typeof payload.serverInstanceId === 'string' && payload.serverInstanceId.trim().length > 0
? payload.serverInstanceId.trim()
: undefined;
const serverTag = typeof payload.serverTag === 'string' && payload.serverTag.trim().length > 0
? payload.serverTag.trim()
: undefined;
if (!versionCompatibility.isCompatible) {
return {
status: 'incompatible',
latency,
serverTag,
versions: {
serverInstanceId,
serverVersion: versionCompatibility.serverVersion,
@@ -49,6 +53,7 @@ export class ServerEndpointHealthService {
return {
status: 'online',
latency,
serverTag,
versions: {
serverInstanceId,
serverVersion: versionCompatibility.serverVersion,