From e51821c740eaf0f8c91fe60539514cb498e41273 Mon Sep 17 00:00:00 2001 From: Lunascape <21148758+Lunascaped@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:13:45 -0500 Subject: [PATCH] new plugin TenorGifSearch (#4332) Co-authored-by: V --- src/main/csp/index.ts | 6 +- src/plugins/tenorGifSearch/index.tsx | 249 +++++++++++++++++++++++++++ src/utils/constants.ts | 4 + 3 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 src/plugins/tenorGifSearch/index.tsx diff --git a/src/main/csp/index.ts b/src/main/csp/index.ts index 887010b6d..bc5eb153c 100644 --- a/src/main/csp/index.ts +++ b/src/main/csp/index.ts @@ -12,6 +12,7 @@ type PolicyMap = Record; export const ConnectSrc = ["connect-src"]; export const ImageSrc = [...ConnectSrc, "img-src"]; export const CssSrc = ["style-src", "font-src"]; +export const ImageAndMediaSrc = [...ImageSrc, "media-src"]; export const ImageAndCssSrc = [...ImageSrc, ...CssSrc]; export const ImageScriptsAndCssSrc = [...ImageAndCssSrc, "script-src", "worker-src"]; @@ -40,7 +41,6 @@ export const CspPolicies: PolicyMap = { "i.imgur.com": ImageSrc, // Imgur, used by some themes "i.ibb.co": ImageSrc, // ImgBB, used by some themes "i.pinimg.com": ImageSrc, // Pinterest, used by some themes - "*.tenor.com": ImageSrc, // Tenor, used by some themes "files.catbox.moe": ImageAndCssSrc, // Catbox, used by some themes "cdn.discordapp.com": ImageAndCssSrc, // Discord CDN, used by Vencord and some themes to load media @@ -63,6 +63,10 @@ export const CspPolicies: PolicyMap = { "dearrow-thumb.ajay.app": ImageSrc, // Dearrow Thumbnail CDN "usrbg.is-hardly.online": ImageSrc, // USRBG API "icons.duckduckgo.com": ImageSrc, // DuckDuckGo Favicon API (Reverse Image Search) + + // Tenor, used by TenorSearch plugin and some themes + "*.tenor.com": ImageAndMediaSrc, + "*.tenor.co": ImageAndMediaSrc, }; const findHeader = (headers: PolicyMap, headerName: Lowercase) => { diff --git a/src/plugins/tenorGifSearch/index.tsx b/src/plugins/tenorGifSearch/index.tsx new file mode 100644 index 000000000..b691cedc4 --- /dev/null +++ b/src/plugins/tenorGifSearch/index.tsx @@ -0,0 +1,249 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Devs } from "@utils/constants"; +import { isNonNullish } from "@utils/guards"; +import definePlugin from "@utils/types"; +import { FluxDispatcher, LocaleStore } from "@webpack/common"; + +// API key is taken from the GBoard app on iOS +const TENOR_KEY = "3Z0688EVWYKH"; + +let cachedCategories: TrendingCategories | null = null; + +interface TenorMedia { + url: string; + preview: string; + dims: [number, number]; +} +interface TenorResult { + id: string; + media: Array>; + itemurl: string; +} +interface TenorCategoryTag { + searchterm: string; + image: string; +} + +interface DiscordGif { + id: string; + title: string; + url: string; + src: string; + gif_src: string; + width: number; + height: number; + preview: string; +} + +interface TrendingCategories { + trendingCategories: Record<"name" | "src", string>[]; + trendingGIFPreview: { src: string; }; +} + +function toDiscordGif(item: TenorResult): DiscordGif | null { + const { gif, webm } = item.media[0]; + + return { + id: item.id, + title: "", // discord always returns a blank string + url: item.itemurl, + gif_src: gif.url, + src: webm.url, + width: webm.dims[0], + height: webm.dims[1], + preview: webm.preview + }; +} + +function mapToDiscordGifs(items: TenorResult[]) { + return items.map(toDiscordGif).filter(isNonNullish); +} + +async function tenorFetch(path: string, params: Record) { + const url = `https://api.tenor.com/v1${path}?` + new URLSearchParams({ + key: TENOR_KEY, + locale: LocaleStore.locale.replace("-", "_").toLowerCase(), + ...params + }); + + const res = await fetch(url); + if (!res.ok) + throw new Error(`GET ${path}: Tenor API request failed with status ${res.status}`); + + return res.json() as Promise; +} + +// function contributed by taep96 +async function fetchTenorResults(path: string, limit: number, extra: Record = {}) { + const pageSize = Math.min(limit, 50); + const items: TenorResult[] = []; + const seen = new Set(); + let pos = ""; + + while (items.length < limit) { + const params: Record = { + ...extra, + limit: String(Math.min(limit - items.length, pageSize)) + }; + if (pos) params.pos = pos; + + const { next, results: page } = await tenorFetch<{ next?: string; results: TenorResult[]; }>(path, params); + if (!page.length) break; + + const previousLength = items.length; + for (const item of page) { + if (seen.has(item.id)) continue; + seen.add(item.id); + + items.push(item); + if (items.length >= limit) break; + } + if (items.length === previousLength) break; + + if (!next || next === pos) break; + pos = next; + } + + return items; +} + +async function fetchCategories(): Promise { + return tenorFetch<{ tags?: TenorCategoryTag[]; }>("/categories", { type: "featured" }) + .then(({ tags }) => { + if (!tags?.length) return null; + + return { + trendingCategories: tags.map(t => ({ name: t.searchterm, src: t.image })), + trendingGIFPreview: { src: tags[0].image } + }; + }) + .catch(() => null); + +} + +export default definePlugin({ + name: "TenorGifSearch", + description: "Restore Tenor GIF search", + authors: [Devs.Lunascape], + + patches: [ + { + find: "renderHeaderContent()", + replacement: { + match: /placeholder:(\i),"aria-label":(\i)/, + replace: 'placeholder:$1?.replace(/Giphy|Klipy/gi,"Tenor"),"aria-label":$2?.replace(/Giphy|Klipy/gi,"Tenor")' + } + }, + { + find: '"GIF_PICKER_TRENDING_FETCH_SUCCESS",trendingCategories:', + replacement: [ + { + match: /let \i=Date\.now\(\);\i\([^)]+\),\i\.\i\.get\(\{url:\i\.\i\.GIFS_SEARCH,query:\{q:(\i),/, + replace: "return $self.handleSearchFetch($1);$&" + }, + { + match: /""!==(\i)&&null!=\1&&\i\.\i\.get\(\{url:\i\.\i\.GIFS_SUGGEST,/, + replace: "return $self.handleSuggestionsFetch($1);$&" + }, + { + match: /\i\.\i\.get\(\{url:\i\.\i\.GIFS_TRENDING,/, + replace: "return $self.handleTrendingFetch();$&" + }, + { + match: /let \i=Date\.now\(\);\i\([^)]+\),\i\.\i\.get\(\{url:\i\.\i\.GIFS_TRENDING_GIFS,/, + replace: "return $self.handleTrendingGifsFetch();$&" + }, + { + match: /\i\.\i\.post\(\{url:\i\.\i\.GIFS_SELECT,body:\{id:(\i),q:(\i),provider:\i\}/, + replace: "return $self.handleGifSelect($1,$2);$&" + } + ] + }, + { + find: '"IntegrationQueryStore"', + replacement: { + match: /(?<=search\((\i),(\i)\)\{)null==\i\.getResults\(\1,\2\)&&/, + replace: "return $self.tenorIntegrationSearch($1,$2);null==void 0&&" + } + } + ], + + async start() { + cachedCategories = await fetchCategories() ?? cachedCategories; + }, + + handleSearchFetch(query: string) { + // discord has a 100 result limit for normal search + fetchTenorResults("/search", 100, { q: query }) + .then(results => { + const items = mapToDiscordGifs(results); + FluxDispatcher.dispatch( + items.length + ? { type: "GIF_PICKER_QUERY_SUCCESS", query, items } + : { type: "GIF_PICKER_QUERY_FAILURE", query } + ); + }) + .catch(() => { + FluxDispatcher.dispatch({ type: "GIF_PICKER_QUERY_FAILURE", query }); + }); + }, + + async handleSuggestionsFetch(query: string) { + if (!query) return; + + const { results } = await tenorFetch<{ results?: string[]; }>("/search_suggestions", { q: query, limit: "5" }); + + FluxDispatcher.dispatch({ type: "GIF_PICKER_SUGGESTIONS_SUCCESS", query, items: results }); + }, + + async handleTrendingFetch() { + if (!cachedCategories) { + cachedCategories = await fetchCategories(); + + if (!cachedCategories) return; + } + + FluxDispatcher.dispatch({ type: "GIF_PICKER_TRENDING_FETCH_SUCCESS", ...cachedCategories }); + }, + + handleGifSelect(id: string, query: string) { + tenorFetch("/registershare", { id, q: query }); + }, + + handleTrendingGifsFetch() { + fetchTenorResults("/trending", 50) + .then(results => { + const items = mapToDiscordGifs(results); + FluxDispatcher.dispatch( + items.length + ? { type: "GIF_PICKER_QUERY_SUCCESS", items } + : { type: "GIF_PICKER_QUERY_FAILURE" } + ); + }) + .catch(() => { + FluxDispatcher.dispatch({ type: "GIF_PICKER_QUERY_FAILURE" }); + }); + }, + + tenorIntegrationSearch(integration: string, query: string) { + FluxDispatcher.dispatch({ type: "INTEGRATION_QUERY", integration, query }); + + fetchTenorResults("/search", 20, { q: query }) + .then(results => { + const items = mapToDiscordGifs(results); + FluxDispatcher.dispatch( + items.length + ? { type: "INTEGRATION_QUERY_SUCCESS", integration, query, results: items } + : { type: "INTEGRATION_QUERY_FAILURE", integration, query } + ); + }) + .catch(() => { + FluxDispatcher.dispatch({ type: "INTEGRATION_QUERY_FAILURE", integration, query, results: [] }); + }); + } +}); diff --git a/src/utils/constants.ts b/src/utils/constants.ts index a4ff9aa4e..85901da7f 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -646,6 +646,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({ name: "NightmareSan", id: 304239816466235392n }, + Lunascape: { + name: "Lunascape", + id: 383365021415243776n + } } satisfies Record); // iife so #__PURE__ works correctly