All checks were successful
Queue Release Build / prepare (push) Successful in 1m6s
Deploy Web Apps / deploy (push) Successful in 7m35s
Queue Release Build / build-windows (push) Successful in 29m57s
Queue Release Build / build-linux (push) Successful in 46m28s
Queue Release Build / finalize (push) Successful in 49s
225 lines
5.4 KiB
TypeScript
225 lines
5.4 KiB
TypeScript
/* eslint-disable @typescript-eslint/member-ordering */
|
|
import {
|
|
AfterViewInit,
|
|
Component,
|
|
ElementRef,
|
|
HostListener,
|
|
OnDestroy,
|
|
OnInit,
|
|
ViewChild,
|
|
inject,
|
|
input,
|
|
output,
|
|
signal
|
|
} from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { firstValueFrom } from 'rxjs';
|
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
|
import {
|
|
lucideChevronDown,
|
|
lucideImage,
|
|
lucideSearch,
|
|
lucideX
|
|
} from '@ng-icons/lucide';
|
|
import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
|
|
import type { RoomSignalSourceInput } from '../../../server-directory';
|
|
import { ChatImageProxyFallbackDirective } from '../chat-image-proxy-fallback.directive';
|
|
import { ViewportService } from '../../../../core/platform';
|
|
|
|
const KLIPY_CARD_MIN_WIDTH = 140;
|
|
const KLIPY_CARD_MAX_WIDTH = 248;
|
|
const KLIPY_CARD_MIN_HEIGHT = 104;
|
|
const KLIPY_CARD_MAX_HEIGHT = 220;
|
|
const KLIPY_CARD_FALLBACK_SIZE = 160;
|
|
|
|
@Component({
|
|
selector: 'app-klipy-gif-picker',
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
FormsModule,
|
|
NgIcon,
|
|
ChatImageProxyFallbackDirective
|
|
],
|
|
viewProviders: [
|
|
provideIcons({
|
|
lucideChevronDown,
|
|
lucideImage,
|
|
lucideSearch,
|
|
lucideX
|
|
})
|
|
],
|
|
templateUrl: './klipy-gif-picker.component.html'
|
|
})
|
|
export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy {
|
|
readonly signalSource = input<RoomSignalSourceInput | null>(null);
|
|
|
|
readonly gifSelected = output<KlipyGif>();
|
|
readonly closed = output<undefined>();
|
|
|
|
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>;
|
|
|
|
private readonly klipy = inject(KlipyService);
|
|
private readonly viewport = inject(ViewportService);
|
|
readonly isMobile = this.viewport.isMobile;
|
|
private currentPage = 1;
|
|
private searchTimer: ReturnType<typeof setTimeout> | null = null;
|
|
private requestId = 0;
|
|
|
|
searchQuery = '';
|
|
results = signal<KlipyGif[]>([]);
|
|
loading = signal(false);
|
|
errorMessage = signal('');
|
|
hasNext = signal(false);
|
|
|
|
ngOnInit(): void {
|
|
void this.loadResults(true);
|
|
}
|
|
|
|
ngAfterViewInit(): void {
|
|
requestAnimationFrame(() => {
|
|
this.searchInput?.nativeElement.focus();
|
|
this.searchInput?.nativeElement.select();
|
|
});
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
this.clearSearchTimer();
|
|
}
|
|
|
|
@HostListener('document:keydown.escape')
|
|
onEscape(): void {
|
|
this.close();
|
|
}
|
|
|
|
onSearchQueryChanged(query: string): void {
|
|
this.searchQuery = query;
|
|
this.clearSearchTimer();
|
|
this.searchTimer = setTimeout(() => {
|
|
void this.loadResults(true);
|
|
}, 250);
|
|
}
|
|
|
|
retry(): void {
|
|
void this.loadResults(true);
|
|
}
|
|
|
|
async loadMore(): Promise<void> {
|
|
if (this.loading() || !this.hasNext())
|
|
return;
|
|
|
|
this.currentPage += 1;
|
|
await this.loadResults(false);
|
|
}
|
|
|
|
selectGif(gif: KlipyGif): void {
|
|
this.gifSelected.emit(gif);
|
|
}
|
|
|
|
close(): void {
|
|
this.closed.emit(undefined);
|
|
}
|
|
|
|
gifCardHeight(gif: KlipyGif): number {
|
|
return this.getGifCardSize(gif).height;
|
|
}
|
|
|
|
private async loadResults(reset: boolean): Promise<void> {
|
|
if (reset) {
|
|
this.currentPage = 1;
|
|
}
|
|
|
|
const requestId = ++this.requestId;
|
|
|
|
this.loading.set(true);
|
|
this.errorMessage.set('');
|
|
|
|
try {
|
|
const response = await firstValueFrom(
|
|
this.klipy.searchGifs(this.searchQuery, this.currentPage, undefined, this.signalSource())
|
|
);
|
|
|
|
if (requestId !== this.requestId)
|
|
return;
|
|
|
|
this.results.set(
|
|
reset
|
|
? response.results
|
|
: this.mergeResults(this.results(), response.results)
|
|
);
|
|
|
|
this.hasNext.set(response.hasNext);
|
|
} catch (error) {
|
|
if (requestId !== this.requestId)
|
|
return;
|
|
|
|
this.errorMessage.set(
|
|
error instanceof Error
|
|
? error.message
|
|
: 'Failed to load GIFs from KLIPY.'
|
|
);
|
|
|
|
if (reset) {
|
|
this.results.set([]);
|
|
}
|
|
|
|
this.hasNext.set(false);
|
|
} finally {
|
|
if (requestId === this.requestId) {
|
|
this.loading.set(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
private mergeResults(existing: KlipyGif[], incoming: KlipyGif[]): KlipyGif[] {
|
|
const seen = new Set(existing.map((gif) => gif.id));
|
|
const merged = [...existing];
|
|
|
|
for (const gif of incoming) {
|
|
if (seen.has(gif.id))
|
|
continue;
|
|
|
|
merged.push(gif);
|
|
seen.add(gif.id);
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
private clearSearchTimer(): void {
|
|
if (this.searchTimer) {
|
|
clearTimeout(this.searchTimer);
|
|
this.searchTimer = null;
|
|
}
|
|
}
|
|
|
|
private getGifCardSize(gif: KlipyGif): { width: number; height: number } {
|
|
if (gif.width <= 0 || gif.height <= 0) {
|
|
return {
|
|
width: KLIPY_CARD_FALLBACK_SIZE,
|
|
height: KLIPY_CARD_FALLBACK_SIZE
|
|
};
|
|
}
|
|
|
|
const maxScale = Math.min(
|
|
KLIPY_CARD_MAX_WIDTH / gif.width,
|
|
KLIPY_CARD_MAX_HEIGHT / gif.height
|
|
);
|
|
const minScale = Math.max(
|
|
KLIPY_CARD_MIN_WIDTH / gif.width,
|
|
KLIPY_CARD_MIN_HEIGHT / gif.height
|
|
);
|
|
const scale = minScale <= maxScale
|
|
? Math.min(maxScale, Math.max(minScale, 1))
|
|
: maxScale;
|
|
const scaledWidth = Math.round(gif.width * scale);
|
|
const scaledHeight = Math.round(gif.height * scale);
|
|
|
|
return {
|
|
width: Math.min(KLIPY_CARD_MAX_WIDTH, Math.max(KLIPY_CARD_MIN_WIDTH, scaledWidth)),
|
|
height: Math.min(KLIPY_CARD_MAX_HEIGHT, Math.max(KLIPY_CARD_MIN_HEIGHT, scaledHeight))
|
|
};
|
|
}
|
|
}
|