diff --git a/src/extensions/SingletonCache.ts b/src/extensions/SingletonCache.ts
new file mode 100644
index 000000000..7f9f7895c
--- /dev/null
+++ b/src/extensions/SingletonCache.ts
@@ -0,0 +1,53 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2026 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import { TimeSpan } from "./Timespan";
+import { sleep } from "./index";
+
+export class SingletonCache {
+ private expiry: TimeSpan;
+ private lastUpdated: Date = new Date(0);
+ private isLocked: boolean = false;
+ private cachedValue: T;
+
+ constructor(expiry: TimeSpan) {
+ this.expiry = expiry;
+ }
+
+ async getOrUpdate(factory: () => Promise): Promise {
+ if (new TimeSpan(this.lastUpdated.getTime(), Date.now()).totalMillis < this.expiry.totalMillis) return this.cachedValue;
+
+ if (this.isLocked) {
+ // avoid running the factory twice (async)
+ while (this.isLocked) await sleep(50);
+ return this.cachedValue;
+ }
+
+ this.isLocked = true;
+ try {
+ const result = await factory();
+ this.cachedValue = result;
+ return result;
+ } catch (e) {
+ console.log(`[SingletonCache] Factory method failed, returning stale value:`, e);
+ return this.cachedValue;
+ } finally {
+ this.isLocked = false;
+ }
+ }
+}
diff --git a/src/extensions/Sleep.ts b/src/extensions/Sleep.ts
new file mode 100644
index 000000000..3104e0d89
--- /dev/null
+++ b/src/extensions/Sleep.ts
@@ -0,0 +1,21 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2026 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+export async function sleep(ms: number) {
+ return new Promise((resolve) => void setTimeout(resolve, ms));
+}
diff --git a/src/extensions/index.ts b/src/extensions/index.ts
index f22d3d3bc..67f4f9ab0 100644
--- a/src/extensions/index.ts
+++ b/src/extensions/index.ts
@@ -22,12 +22,9 @@ export * from "./DateBuilder";
export * from "./ElapsedTime";
export * from "./Math";
export * from "./Random";
+export * from "./SingletonCache";
+export * from "./Sleep";
export * from "./Stopwatch";
export * from "./String";
export * from "./Timespan";
export * from "./Url";
-
-// TODO: move to a separate file
-export async function sleep(ms: number) {
- return new Promise((resolve) => void setTimeout(resolve, ms));
-}
diff --git a/src/integrations/gifs/providers/TenorGifProvider.ts b/src/integrations/gifs/providers/TenorGifProvider.ts
index 91b365ab9..11f5cd553 100644
--- a/src/integrations/gifs/providers/TenorGifProvider.ts
+++ b/src/integrations/gifs/providers/TenorGifProvider.ts
@@ -19,12 +19,18 @@
import type { IGifProvider } from "../IGifProvider";
import { GifsResponse, GifTrendingCategory, GifMediaTypes } from "@spacebar/schemas";
import { Config } from "@spacebar/util";
+import { SingletonCache, TimeSpan } from "@spacebar/extensions";
+
+const TRENDING_CATEGORIES_CACHE_DURATION = TimeSpan.fromSeconds(24 * 60 * 60); // 1 day
+const TRENDING_GIFS_CACHE_DURATION = TimeSpan.fromSeconds(60 * 60); // 1 hour
// Tenor V1 API... Not yet shut down as of writing, but is going soon...
export default class TenorGifProvider implements IGifProvider {
id = "tenor";
available = true;
#apiKey: string;
+ #trendingCategoryCache = new SingletonCache(TRENDING_CATEGORIES_CACHE_DURATION);
+ #trendingGifsCache = new SingletonCache(TRENDING_GIFS_CACHE_DURATION);
async init(): Promise {
if (!(Config.get().gif.enabled && Config.get().gif.apiKey)) {
@@ -47,26 +53,31 @@ export default class TenorGifProvider implements IGifProvider {
}
async getTrendingCategories(query: { media_format: string; locale: string }): Promise {
- const response = await fetch(`https://g.tenor.com/v1/categories?locale=${query.locale}&key=${this.#apiKey}`, {
- method: "get",
- headers: { "Content-Type": "application/json" },
- });
+ return await this.#trendingCategoryCache.getOrUpdate(async () => {
+ const response = await fetch(`https://g.tenor.com/v1/categories?locale=${query.locale}&key=${this.#apiKey}`, {
+ method: "get",
+ headers: { "Content-Type": "application/json" },
+ });
- const responseData = (await response.json()) as TenorCategoriesResults;
- return responseData.tags.map((x) => ({
- name: x.searchterm,
- src: x.image,
- })) satisfies GifTrendingCategory[];
+ const responseData = (await response.json()) as TenorCategoriesResults;
+ return responseData.tags.map((x) => ({
+ name: x.searchterm,
+ src: x.image,
+ })) satisfies GifTrendingCategory[];
+ });
}
async getTrendingGifs(query: { media_format: string; locale: string }): Promise {
- const response = await fetch(`https://g.tenor.com/v1/trending?media_format=${query.media_format}&locale=${query.locale}&key=${this.#apiKey}`, {
- method: "get",
- headers: { "Content-Type": "application/json" },
- });
+ return await this.#trendingGifsCache.getOrUpdate(async () => {
+ const response = await fetch(`https://g.tenor.com/v1/trending?media_format=${query.media_format}&locale=${query.locale}&key=${this.#apiKey}`, {
+ method: "get",
+ headers: { "Content-Type": "application/json" },
+ });
- const responseData = (await response.json()) as TenorTrendingResults;
- return responseData.results.map(this.convertGifResult);
+ console.log(response);
+ const responseData = (await response.json()) as TenorTrendingResults;
+ return responseData.results.map(this.convertGifResult);
+ });
}
private convertGifResult(result: TenorGif) {