From 45bcbb449c04df2378aa12233ebe01dc0362a3b7 Mon Sep 17 00:00:00 2001 From: byGalax Date: Sat, 16 May 2026 18:02:01 +0200 Subject: [PATCH] feat(desktop): Tenor v2 GIF search/featured/recent client --- apps/desktop/src/lib/tenor.ts | 88 +++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 apps/desktop/src/lib/tenor.ts diff --git a/apps/desktop/src/lib/tenor.ts b/apps/desktop/src/lib/tenor.ts new file mode 100644 index 0000000..df85555 --- /dev/null +++ b/apps/desktop/src/lib/tenor.ts @@ -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/. 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; +} + +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): Promise { + 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 { + if (!query.trim()) return featuredGifs(locale); + return fetchTenor('search', { q: query, limit: '30', locale }); +} + +export async function featuredGifs(locale: string = 'de_DE'): Promise { + 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 */ + } +}