fix: search

This commit is contained in:
2026-06-09 22:00:06 +02:00
parent eb51f043ac
commit 1274ad9b46
9 changed files with 141 additions and 19 deletions

View File

@@ -160,7 +160,7 @@ Beyond free-text search, the directory exposes curated discovery lists that powe
- `ServerDirectoryFacade.getFeaturedServers()``GET /api/servers/featured`
- `ServerDirectoryFacade.getTrendingServers()``GET /api/servers/trending`
Both pass through `ServerDirectoryService` to `ServerDirectoryApiService.getFeaturedServers()` / `getTrendingServers()`, which share a private `getDiscoveryServers(path)` HTTP helper and normalise results into `ServerInfo[]` exactly like search. The server ranks featured servers (stable curation) and trending servers (recent activity) via `server-ranking.util.ts`; each route caps results at 50 (`parseDiscoveryLimit`). The discovery routes are registered before the parameterised `/:id` route so `featured`/`trending` are not captured as server IDs.
Both pass through `ServerDirectoryService` to `ServerDirectoryApiService.getFeaturedServers()` / `getTrendingServers()`, which share a private `getDiscoveryServers(path)` HTTP helper and normalise results into `ServerInfo[]` exactly like search. Discovery **fans out across every online endpoint** (`getSearchableEndpoints()` + `forkJoin`, deduplicated by ID), mirroring all-endpoint search — querying only the active endpoint made the default `/servers` view appear empty whenever that endpoint was a discovery-unsupported production host (`endpointSupportsServerDiscovery`), even though plenty of servers existed on other online endpoints. Each endpoint that is in `DISCOVERY_UNSUPPORTED_HOSTS` is skipped (returns `[]`) per-endpoint rather than short-circuiting the whole request. The server ranks featured servers (stable curation) and trending servers (recent activity) via `server-ranking.util.ts`; each route caps results at 50 (`parseDiscoveryLimit`). The discovery routes are registered before the parameterised `/:id` route so `featured`/`trending` are not captured as server IDs.
`FindServersComponent` (`/servers`) composes these into discovery sections — **Recently active** (the user's saved rooms, capped at 6), **Featured servers**, and **Trending** — and renders them through the reusable `app-server-browser`. `DashboardComponent` (`/dashboard`) uses the same facade methods for its quick search results.

View File

@@ -80,3 +80,80 @@ describe('ServerDirectoryApiService discovery endpoints', () => {
expect(result).toEqual([]);
});
});
function createMultiEndpointHarness(
endpoints: { id: string; name: string; url: string; status: string }[],
getImpl: (url: string) => unknown
) {
const get = vi.fn((url: string) => of(getImpl(url) ?? { servers: [], total: 0 }));
const http = { get } as unknown as HttpClient;
const endpointState = {
activeServer: () => endpoints[0],
activeServers: () => endpoints,
servers: () => [],
resolveCanonicalEndpoint: (endpoint: unknown) => endpoint ?? null,
findServerByUrl: () => null,
sanitiseUrl: (value: string) => value
} as unknown as ServerEndpointStateService;
const injector = Injector.create({
providers: [
ServerDirectoryApiService,
{ provide: HttpClient, useValue: http },
{ provide: ServerEndpointStateService, useValue: endpointState }
]
});
const service = runInInjectionContext(injector, () => injector.get(ServerDirectoryApiService));
return { service, get };
}
describe('ServerDirectoryApiService discovery fan-out', () => {
const endpoints = [
{ id: 'ep-1', name: 'Local', url: 'https://local.test', status: 'online' },
{ id: 'ep-2', name: 'Other', url: 'https://other.test', status: 'online' },
{ id: 'ep-3', name: 'Prod', url: 'https://signal.toju.app', status: 'online' }
];
it('aggregates discovery results across all online endpoints', async () => {
const { service, get } = createMultiEndpointHarness(endpoints, (url) => {
if (url.startsWith('https://local.test'))
return { servers: [{ id: 's1', name: 'Alpha' }], total: 1 };
if (url.startsWith('https://other.test'))
return { servers: [{ id: 's2', name: 'Beta' }], total: 1 };
return { servers: [], total: 0 };
});
const result = await firstValueFrom(service.getFeaturedServers());
expect(result.map((server) => server.id).sort()).toEqual(['s1', 's2']);
const calledUrls = get.mock.calls.map((call) => call[0] as string);
expect(calledUrls).toContain('https://local.test/api/servers/featured');
expect(calledUrls).toContain('https://other.test/api/servers/featured');
});
it('does not query discovery-unsupported hosts but still returns the supported endpoints', async () => {
const { service, get } = createMultiEndpointHarness(endpoints, (url) => {
if (url.startsWith('https://local.test'))
return { servers: [{ id: 's1', name: 'Alpha' }], total: 1 };
return { servers: [], total: 0 };
});
const result = await firstValueFrom(service.getTrendingServers());
expect(result.map((server) => server.id)).toEqual(['s1']);
const calledUrls = get.mock.calls.map((call) => call[0] as string);
expect(calledUrls.some((url) => url.includes('signal.toju.app'))).toBe(false);
});
it('deduplicates the same server returned by multiple endpoints', async () => {
const { service } = createMultiEndpointHarness(endpoints, () =>
({ servers: [{ id: 'dup', name: 'Shared' }], total: 1 })
);
const result = await firstValueFrom(service.getFeaturedServers());
expect(result.map((server) => server.id)).toEqual(['dup']);
});
});

View File

@@ -297,18 +297,43 @@ export class ServerDirectoryApiService {
}
private getDiscoveryServers(kind: 'featured' | 'trending', limit?: number): Observable<ServerInfo[]> {
const baseUrl = this.resolveBaseServerUrl();
const params = typeof limit === 'number' ? new HttpParams().set('limit', String(limit)) : undefined;
const onlineEndpoints = this.getSearchableEndpoints();
// Fan discovery out across every online endpoint (mirroring search) so the
// default find-servers view isn't empty just because the *active* endpoint
// is a discovery-unsupported production host. Querying only the active
// endpoint made servers invisible until the user typed a search query.
if (onlineEndpoints.length === 0) {
return this.fetchDiscoveryFromEndpoint(
kind,
this.resolveBaseServerUrl(),
this.endpointState.activeServer(),
params
);
}
return forkJoin(
onlineEndpoints.map((endpoint) =>
this.fetchDiscoveryFromEndpoint(kind, endpoint.url, endpoint, params)
)
).pipe(map((resultArrays) => this.deduplicateById(resultArrays.flat())));
}
private fetchDiscoveryFromEndpoint(
kind: 'featured' | 'trending',
baseUrl: string,
source: ServerEndpoint | null | undefined,
params?: HttpParams
): Observable<ServerInfo[]> {
if (!endpointSupportsServerDiscovery(baseUrl)) {
return of([]);
}
const params = typeof limit === 'number' ? new HttpParams().set('limit', String(limit)) : undefined;
return this.http
.get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers/${kind}`, params ? { params } : {})
.get<{ servers: ServerInfo[]; total: number }>(`${baseUrl}/api/servers/${kind}`, params ? { params } : {})
.pipe(
map((response) => this.normalizeServerList(response, this.endpointState.activeServer())),
map((response) => this.normalizeServerList(response, source ?? null)),
catchError((error) => {
console.error(`Failed to get ${kind} servers:`, error);
return of([]);

View File

@@ -165,11 +165,11 @@
<div
class="grid w-full overflow-hidden duration-200 ease-out motion-reduce:transition-none"
style="transition-property: grid-template-rows, opacity"
[style.gridTemplateRows]="isOnSearch() ? '1fr' : '0fr'"
[style.opacity]="isOnSearch() ? '1' : '0'"
[style.visibility]="isOnSearch() ? 'visible' : 'hidden'"
[class.pointer-events-none]="!isOnSearch()"
[attr.aria-hidden]="isOnSearch() ? null : 'true'"
[style.gridTemplateRows]="isOnServers() ? '1fr' : '0fr'"
[style.opacity]="isOnServers() ? '1' : '0'"
[style.visibility]="isOnServers() ? 'visible' : 'hidden'"
[class.pointer-events-none]="!isOnServers()"
[attr.aria-hidden]="isOnServers() ? null : 'true'"
>
<div class="overflow-hidden">
<app-user-bar />

View File

@@ -100,12 +100,12 @@ export class ServersRailComponent {
currentUser = this.store.selectSignal(selectCurrentUser);
onlineUsers = this.store.selectSignal(selectOnlineUsers);
bannedRoomLookup = signal<Record<string, boolean>>({});
isOnSearch = toSignal(
isOnServers = toSignal(
this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/search'))
map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/servers'))
),
{ initialValue: this.router.url.startsWith('/search') }
{ initialValue: this.router.url.startsWith('/servers') }
);
isOnDirectMessage = toSignal(
this.router.events.pipe(
@@ -393,7 +393,7 @@ export class ServersRailComponent {
if (isCurrentRoom) {
this.optimisticSelectedRoomId.set(null);
this.router.navigate(['/search']);
this.router.navigate(['/servers']);
}
this.showLeaveConfirm.set(false);