refactor: stricter domain: chat

This commit is contained in:
2026-04-11 14:01:19 +02:00
parent 39b85e2e3a
commit 98ed8eeb68
16 changed files with 34 additions and 29 deletions

View File

@@ -0,0 +1,204 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Injectable,
computed,
effect,
inject,
signal
} from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import {
Observable,
firstValueFrom,
throwError
} from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { ServerDirectoryFacade } from '../../../server-directory';
export interface KlipyGif {
id: string;
slug: string;
title?: string;
url: string;
previewUrl: string;
width: number;
height: number;
}
interface KlipyAvailabilityResponse {
enabled: boolean;
}
export interface KlipyGifSearchResponse {
enabled: boolean;
results: KlipyGif[];
hasNext: boolean;
}
const DEFAULT_PAGE_SIZE = 24;
const KLIPY_CUSTOMER_ID_STORAGE_KEY = 'metoyou_klipy_customer_id';
@Injectable({ providedIn: 'root' })
export class KlipyService {
private readonly http = inject(HttpClient);
private readonly serverDirectory = inject(ServerDirectoryFacade);
private readonly availabilityState = signal({
enabled: false,
loading: true
});
private lastAvailabilityKey = '';
readonly isEnabled = computed(() => this.availabilityState().enabled);
readonly isLoading = computed(() => this.availabilityState().loading);
constructor() {
effect(() => {
const activeServer = this.serverDirectory.activeServer();
const apiBaseUrl = this.serverDirectory.getApiBaseUrl();
const nextKey = `${activeServer?.id ?? 'default'}:${apiBaseUrl}`;
if (nextKey === this.lastAvailabilityKey)
return;
this.lastAvailabilityKey = nextKey;
void this.refreshAvailability();
});
}
async refreshAvailability(): Promise<void> {
this.availabilityState.set({ enabled: false,
loading: true });
try {
const response = await firstValueFrom(
this.http.get<KlipyAvailabilityResponse>(
`${this.serverDirectory.getApiBaseUrl()}/klipy/config`
)
);
this.availabilityState.set({
enabled: response.enabled === true,
loading: false
});
} catch {
this.availabilityState.set({ enabled: false,
loading: false });
}
}
searchGifs(
query: string,
page = 1,
perPage = DEFAULT_PAGE_SIZE
): Observable<KlipyGifSearchResponse> {
let params = new HttpParams()
.set('page', String(Math.max(1, Math.floor(page))))
.set('per_page', String(Math.max(1, Math.floor(perPage))))
.set('customer_id', this.getOrCreateCustomerId());
const trimmedQuery = query.trim();
if (trimmedQuery) {
params = params.set('q', trimmedQuery);
}
const locale = this.getPreferredLocale();
if (locale) {
params = params.set('locale', locale);
}
return this.http
.get<KlipyGifSearchResponse>(`${this.serverDirectory.getApiBaseUrl()}/klipy/gifs`, { params })
.pipe(
map((response) => ({
enabled: response.enabled !== false,
results: Array.isArray(response.results) ? response.results : [],
hasNext: response.hasNext === true
})),
catchError((error) =>
throwError(() => new Error(this.extractErrorMessage(error)))
)
);
}
normalizeMediaUrl(url: string): string {
const trimmed = url.trim();
if (!trimmed)
return '';
if (trimmed.startsWith('//'))
return `https:${trimmed}`;
return trimmed;
}
buildRenderableImageUrl(url: string): string {
return this.normalizeMediaUrl(url);
}
buildImageProxyUrl(url: string): string {
const trimmed = this.normalizeMediaUrl(url);
if (!trimmed)
return '';
if (!/^https?:\/\//i.test(trimmed))
return trimmed;
return `${this.serverDirectory.getApiBaseUrl()}/image-proxy?url=${encodeURIComponent(trimmed)}`;
}
private getPreferredLocale(): string | null {
if (typeof navigator === 'undefined' || !navigator.language)
return null;
const locale = navigator.language.trim();
return locale || null;
}
private getOrCreateCustomerId(): string {
if (typeof window === 'undefined') {
return 'server';
}
try {
const existing = window.localStorage.getItem(KLIPY_CUSTOMER_ID_STORAGE_KEY);
if (existing?.trim())
return existing;
const created = window.crypto?.randomUUID?.()
?? `klipy-${Date.now().toString(36)}-${Math.random().toString(36)
.slice(2, 10)}`;
window.localStorage.setItem(KLIPY_CUSTOMER_ID_STORAGE_KEY, created);
return created;
} catch {
return `klipy-${Date.now().toString(36)}`;
}
}
private extractErrorMessage(error: unknown): string {
const httpError = error as {
error?: {
error?: unknown;
message?: unknown;
};
message?: unknown;
};
if (typeof httpError?.error?.error === 'string')
return httpError.error.error;
if (typeof httpError?.error?.message === 'string')
return httpError.error.message;
if (typeof httpError?.message === 'string')
return httpError.message;
return 'Failed to load GIFs from KLIPY.';
}
}

View File

@@ -0,0 +1,39 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { ServerDirectoryFacade } from '../../../server-directory';
import { LinkMetadata } from '../../../../shared-kernel';
const URL_PATTERN = /https?:\/\/[^\s<>)"']+/g;
@Injectable({ providedIn: 'root' })
export class LinkMetadataService {
private readonly http = inject(HttpClient);
private readonly serverDirectory = inject(ServerDirectoryFacade);
extractUrls(content: string): string[] {
return [...content.matchAll(URL_PATTERN)].map((m) => m[0]);
}
async fetchMetadata(url: string): Promise<LinkMetadata> {
try {
const apiBase = this.serverDirectory.getApiBaseUrl();
const result = await firstValueFrom(
this.http.get<Omit<LinkMetadata, 'url'>>(
`${apiBase}/link-metadata`,
{ params: { url } }
)
);
return { url, ...result };
} catch {
return { url, failed: true };
}
}
async fetchAllMetadata(urls: string[]): Promise<LinkMetadata[]> {
const unique = [...new Set(urls)];
return Promise.all(unique.map((url) => this.fetchMetadata(url)));
}
}