fix(desktop): swap GIF provider from Tenor (closed Jan 2026) to GIPHY
Google closed Tenor v2 to new API clients in Jan 2026, so the only people who could use the picker were those with a pre-existing Google Cloud Console key. Swapped to GIPHY's Developer API (still open, free keys at https://developers.giphy.com/dashboard/). - Env var renamed VITE_TENOR_API_KEY → VITE_GIPHY_API_KEY - Endpoint, response mapping, error sentinel updated - File still named tenor.ts for import-path stability — renaming later if it bothers anyone - Public GifResult interface unchanged so the picker UI didn't need edits beyond the error-message switch
This commit is contained in:
@@ -99,8 +99,8 @@ export function GifPicker({ open, onClose, onPick }: Props) {
|
|||||||
)}
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<p className="col-span-3 px-2 py-4 text-center text-xs text-rose-300">
|
<p className="col-span-3 px-2 py-4 text-center text-xs text-rose-300">
|
||||||
{error === 'tenor_api_key_missing'
|
{error === 'giphy_api_key_missing'
|
||||||
? 'GIFs sind nicht konfiguriert (VITE_TENOR_API_KEY fehlt).'
|
? 'GIFs sind nicht konfiguriert (VITE_GIPHY_API_KEY fehlt).'
|
||||||
: 'GIFs gerade nicht verfügbar.'}
|
: 'GIFs gerade nicht verfügbar.'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
// Tenor v2 client — Google's free GIF API. Public read endpoints accept any
|
// GIPHY v1 client — Google closed Tenor to new API clients in Jan 2026, so
|
||||||
// `client_key` so we don't ship a per-user API key. Trending + Search both
|
// the picker uses GIPHY's Developer API instead. Provision a free key at
|
||||||
// hit https://tenor.googleapis.com/v2/<path>. Cache recent picks (last 24
|
// https://developers.giphy.com/dashboard/ and expose it via the renderer
|
||||||
// URLs) in localStorage so the picker has a "Zuletzt" tab.
|
// env var VITE_GIPHY_API_KEY (e.g. in apps/desktop/.env.local). When unset,
|
||||||
|
// every call throws — the picker degrades to a friendly
|
||||||
|
// "GIFs sind nicht konfiguriert" message via the consumer's catch.
|
||||||
|
//
|
||||||
|
// The file is still named `tenor.ts` for import-path stability; rename later
|
||||||
|
// if it bothers anyone.
|
||||||
|
|
||||||
const ENDPOINT = 'https://tenor.googleapis.com/v2';
|
const ENDPOINT = 'https://api.giphy.com/v1/gifs';
|
||||||
const CLIENT_KEY = 'netralax-chat';
|
|
||||||
const RECENT_KEY = 'chatapp.gifRecent.v1';
|
const RECENT_KEY = 'chatapp.gifRecent.v1';
|
||||||
const RECENT_MAX = 24;
|
const RECENT_MAX = 24;
|
||||||
// Tenor requires a Google API key. Provision one at
|
|
||||||
// https://developers.google.com/tenor/guides/quickstart and expose it via
|
|
||||||
// the renderer env var VITE_TENOR_API_KEY (e.g. in apps/desktop/.env.local).
|
|
||||||
// When unset, every Tenor call throws — the picker degrades to a friendly
|
|
||||||
// "GIFs gerade nicht verfügbar" message via the consumer's catch.
|
|
||||||
const API_KEY = (
|
const API_KEY = (
|
||||||
(import.meta as unknown as { env?: { VITE_TENOR_API_KEY?: string } }).env
|
(import.meta as unknown as { env?: { VITE_GIPHY_API_KEY?: string } }).env
|
||||||
?.VITE_TENOR_API_KEY ?? ''
|
?.VITE_GIPHY_API_KEY ?? ''
|
||||||
).trim();
|
).trim();
|
||||||
|
|
||||||
export interface GifResult {
|
export interface GifResult {
|
||||||
@@ -28,48 +27,64 @@ export interface GifResult {
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TenorApiResult {
|
interface GiphyImage {
|
||||||
id: string;
|
url?: string;
|
||||||
content_description?: string;
|
width?: string;
|
||||||
media_formats?: Record<string, { url: string; dims?: [number, number] }>;
|
height?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapResult(r: TenorApiResult): GifResult | null {
|
interface GiphyResult {
|
||||||
const full = r.media_formats?.gif ?? r.media_formats?.mediumgif ?? r.media_formats?.tinygif;
|
id: string;
|
||||||
const preview = r.media_formats?.tinygif ?? r.media_formats?.gif;
|
title?: string;
|
||||||
|
images?: {
|
||||||
|
original?: GiphyImage;
|
||||||
|
fixed_height?: GiphyImage;
|
||||||
|
fixed_height_small?: GiphyImage;
|
||||||
|
preview_gif?: GiphyImage;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapResult(r: GiphyResult): GifResult | null {
|
||||||
|
const full = r.images?.original ?? r.images?.fixed_height;
|
||||||
|
const preview =
|
||||||
|
r.images?.fixed_height_small ?? r.images?.preview_gif ?? r.images?.fixed_height ?? full;
|
||||||
if (!full?.url || !preview?.url) return null;
|
if (!full?.url || !preview?.url) return null;
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
url: full.url,
|
url: full.url,
|
||||||
previewUrl: preview.url,
|
previewUrl: preview.url,
|
||||||
width: full.dims?.[0] ?? 0,
|
width: full.width ? Number(full.width) : 0,
|
||||||
height: full.dims?.[1] ?? 0,
|
height: full.height ? Number(full.height) : 0,
|
||||||
description: r.content_description ?? '',
|
description: r.title ?? '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchTenor(path: string, params: Record<string, string>): Promise<GifResult[]> {
|
async function fetchGiphy(path: string, params: Record<string, string>): Promise<GifResult[]> {
|
||||||
if (!API_KEY) {
|
if (!API_KEY) {
|
||||||
throw new Error('tenor_api_key_missing');
|
throw new Error('giphy_api_key_missing');
|
||||||
}
|
}
|
||||||
const search = new URLSearchParams({ key: API_KEY, client_key: CLIENT_KEY, ...params });
|
const search = new URLSearchParams({ api_key: API_KEY, rating: 'pg-13', ...params });
|
||||||
const res = await fetch(`${ENDPOINT}/${path}?${search.toString()}`);
|
const res = await fetch(`${ENDPOINT}/${path}?${search.toString()}`);
|
||||||
if (!res.ok) throw new Error('tenor http ' + res.status);
|
if (!res.ok) throw new Error('giphy http ' + res.status);
|
||||||
const json = (await res.json()) as { results?: TenorApiResult[] };
|
const json = (await res.json()) as { data?: GiphyResult[] };
|
||||||
return (json.results ?? []).map(mapResult).filter((x): x is GifResult => x !== null);
|
return (json.data ?? []).map(mapResult).filter((x): x is GifResult => x !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isTenorConfigured(): boolean {
|
export function isGifProviderConfigured(): boolean {
|
||||||
return API_KEY.length > 0;
|
return API_KEY.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchGifs(query: string, locale: string = 'de_DE'): Promise<GifResult[]> {
|
// Back-compat alias for the original Tenor function name. New code should
|
||||||
|
// use isGifProviderConfigured.
|
||||||
|
export const isTenorConfigured = isGifProviderConfigured;
|
||||||
|
|
||||||
|
export async function searchGifs(query: string, locale: string = 'de'): Promise<GifResult[]> {
|
||||||
if (!query.trim()) return featuredGifs(locale);
|
if (!query.trim()) return featuredGifs(locale);
|
||||||
return fetchTenor('search', { q: query, limit: '30', locale });
|
return fetchGiphy('search', { q: query, limit: '30', lang: locale });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function featuredGifs(locale: string = 'de_DE'): Promise<GifResult[]> {
|
export async function featuredGifs(_locale: string = 'de'): Promise<GifResult[]> {
|
||||||
return fetchTenor('featured', { limit: '30', locale });
|
return fetchGiphy('trending', { limit: '30' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRecentGifs(): GifResult[] {
|
export function getRecentGifs(): GifResult[] {
|
||||||
|
|||||||
Reference in New Issue
Block a user