refactor: stricter domain: chat
This commit is contained in:
@@ -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.';
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user