feat(desktop): Tenor v2 GIF search/featured/recent client

This commit is contained in:
byGalax
2026-05-16 18:02:01 +02:00
parent ef98efb938
commit 45bcbb449c
+88
View File
@@ -0,0 +1,88 @@
// Tenor v2 client — Google's free GIF API. Public read endpoints accept any
// `client_key` so we don't ship a per-user API key. Trending + Search both
// hit https://tenor.googleapis.com/v2/<path>. Cache recent picks (last 24
// URLs) in localStorage so the picker has a "Zuletzt" tab.
const ENDPOINT = 'https://tenor.googleapis.com/v2';
const CLIENT_KEY = 'netralax-chat';
const RECENT_KEY = 'chatapp.gifRecent.v1';
const RECENT_MAX = 24;
// Tenor's public key for browser-side reads. Documented as "anonymous" and
// usable without account binding. Rate-limited at ~3k/day per IP which is
// plenty for a chat app's picker traffic.
const PUBLIC_API_KEY = 'AIzaSyAyimkuYQYF_FXVALexPuGQctUWRURdCYQ';
export interface GifResult {
id: string;
// Animated full-size URL (typically <2 MB).
url: string;
// Small preview shown in the picker grid.
previewUrl: string;
width: number;
height: number;
description: string;
}
interface TenorApiResult {
id: string;
content_description?: string;
media_formats?: Record<string, { url: string; dims?: [number, number] }>;
}
function mapResult(r: TenorApiResult): GifResult | null {
const full = r.media_formats?.gif ?? r.media_formats?.mediumgif ?? r.media_formats?.tinygif;
const preview = r.media_formats?.tinygif ?? r.media_formats?.gif;
if (!full?.url || !preview?.url) return null;
return {
id: r.id,
url: full.url,
previewUrl: preview.url,
width: full.dims?.[0] ?? 0,
height: full.dims?.[1] ?? 0,
description: r.content_description ?? '',
};
}
async function fetchTenor(path: string, params: Record<string, string>): Promise<GifResult[]> {
const search = new URLSearchParams({ key: PUBLIC_API_KEY, client_key: CLIENT_KEY, ...params });
const res = await fetch(`${ENDPOINT}/${path}?${search.toString()}`);
if (!res.ok) throw new Error('tenor http ' + res.status);
const json = (await res.json()) as { results?: TenorApiResult[] };
return (json.results ?? []).map(mapResult).filter((x): x is GifResult => x !== null);
}
export async function searchGifs(query: string, locale: string = 'de_DE'): Promise<GifResult[]> {
if (!query.trim()) return featuredGifs(locale);
return fetchTenor('search', { q: query, limit: '30', locale });
}
export async function featuredGifs(locale: string = 'de_DE'): Promise<GifResult[]> {
return fetchTenor('featured', { limit: '30', locale });
}
export function getRecentGifs(): GifResult[] {
try {
const raw = window.localStorage.getItem(RECENT_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(x): x is GifResult =>
x != null &&
typeof x === 'object' &&
typeof (x as GifResult).url === 'string',
);
} catch {
return [];
}
}
export function rememberRecentGif(gif: GifResult): void {
const current = getRecentGifs().filter((g) => g.id !== gif.id);
const next = [gif, ...current].slice(0, RECENT_MAX);
try {
window.localStorage.setItem(RECENT_KEY, JSON.stringify(next));
} catch {
/* quota */
}
}