new plugin TenorGifSearch (#4332)

Co-authored-by: V <vendicated@riseup.net>
This commit is contained in:
Lunascape 2026-07-15 02:13:45 -05:00 • committed by GitHub
parent fc5466ebf8
commit e51821c740
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 258 additions and 1 deletions

View file

@ -12,6 +12,7 @@ type PolicyMap = Record<string, string[]>;
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<string>) => {

View file

@ -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<Record<string, TenorMedia>>;
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<TResult>(path: string, params: Record<string, string>) {
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<TResult>;
}
// function contributed by taep96
async function fetchTenorResults(path: string, limit: number, extra: Record<string, string> = {}) {
const pageSize = Math.min(limit, 50);
const items: TenorResult[] = [];
const seen = new Set<string>();
let pos = "";
while (items.length < limit) {
const params: Record<string, string> = {
...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<TrendingCategories | null> {
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: [] });
});
}
});

View file

@ -646,6 +646,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "NightmareSan",
id: 304239816466235392n
},
Lunascape: {
name: "Lunascape",
id: 383365021415243776n
}
} satisfies Record<string, Dev>);
// iife so #__PURE__ works correctly