diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index f167ef0884..b893f6000f 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -3676,10 +3676,18 @@
"messageformat": "Search GIFs via Tenor",
"description": "FunPicker > GIFs Panel > Search Input > Label (Must use brand name 'Tenor')"
},
+ "icu:FunPanelGifs__SearchLabel": {
+ "messageformat": "Search GIFs",
+ "description": "FunPicker > GIFs Panel > Search Input > Label"
+ },
"icu:FunPanelGifs__SearchPlaceholder--Tenor": {
"messageformat": "Search Tenor",
"description": "FunPicker > GIFs Panel > Search Input > Placeholder (Must use brand name 'Tenor')"
},
+ "icu:FunPanelGifs__SearchPlaceholder": {
+ "messageformat": "Search GIFs",
+ "description": "FunPicker > GIFs Panel > Search Input > Placeholder"
+ },
"icu:FunPanelGifs__SubNavLabel": {
"messageformat": "GIF Categories",
"description": "FunPicker > Gifs Panel > Sub Nav > Label (Plural form: Categories of GIFs)"
@@ -3740,6 +3748,10 @@
"messageformat": "GIF Preview",
"description": "FunPicker > Gifs Panel > Lightbox Dialog (long press on gif to see large preview) > Label"
},
+ "icu:FunPanelGifs__GiphyAttribution__AccessibilityLabel": {
+ "messageformat": "Powered by GIPHY",
+ "description": "FunPicker > GIFs Panel > Giphy Attribution (Must use brand name 'GIPHY' in all caps)"
+ },
"icu:FunSearch__ClearButtonLabel": {
"messageformat": "Clear Search",
"description": "FunPicker > Search Field > Clear Search Button > Accessibility Label"
diff --git a/images/giphy-attribution.png b/images/giphy-attribution.png
new file mode 100644
index 0000000000..f266ba6527
Binary files /dev/null and b/images/giphy-attribution.png differ
diff --git a/stylesheets/components/fun/FunPanel.scss b/stylesheets/components/fun/FunPanel.scss
index 6df299ee91..9a935070d8 100644
--- a/stylesheets/components/fun/FunPanel.scss
+++ b/stylesheets/components/fun/FunPanel.scss
@@ -29,6 +29,7 @@
}
.FunPanel__Body {
+ position: relative;
grid-area: FunPanel__Body;
}
diff --git a/stylesheets/components/fun/FunScroller.scss b/stylesheets/components/fun/FunScroller.scss
index c02b086ec1..bf745e74c1 100644
--- a/stylesheets/components/fun/FunScroller.scss
+++ b/stylesheets/components/fun/FunScroller.scss
@@ -83,4 +83,5 @@
position: relative;
z-index: 0;
display: block flow-root;
+ min-height: 100%;
}
diff --git a/ts/RemoteConfig.dom.ts b/ts/RemoteConfig.dom.ts
index 90d561350d..14dbd21428 100644
--- a/ts/RemoteConfig.dom.ts
+++ b/ts/RemoteConfig.dom.ts
@@ -65,6 +65,7 @@ const ScalarKeys = [
'desktop.pollSend.alpha',
'desktop.pollSend.beta',
'desktop.pollSend.prod',
+ 'desktop.recentGifs.allowLegacyTenorCdnUrls',
'global.attachments.maxBytes',
'global.attachments.maxReceiveBytes',
'global.backups.mediaTierFallbackCdnNumber',
diff --git a/ts/components/DraftGifMessageSendModal.dom.stories.tsx b/ts/components/DraftGifMessageSendModal.dom.stories.tsx
index 7cdf8481c5..1a7f805c62 100644
--- a/ts/components/DraftGifMessageSendModal.dom.stories.tsx
+++ b/ts/components/DraftGifMessageSendModal.dom.stories.tsx
@@ -18,7 +18,9 @@ import { drop } from '../util/drop.std.js';
const { i18n } = window.SignalContext;
const MOCK_GIF_URL =
- 'https://media.tenor.com/ihqN6a3iiYEAAAPo/pikachu-shocked-face-stunned.mp4';
+ 'https://media2.giphy.com/media/v1.Y2lkPTZhNGNmY2JhaXFlbXZxcHVjNXlmaGdlYWs1dTlwYnNrb2I5aGttbXViYjh4Z2hqbyZlcD12MV9naWZzX3NlYXJjaCZjdD1n/3kzJvEciJa94SMW3hN/giphy.mp4';
+const MOCK_GIF_WIDTH = 480;
+const MOCK_GIF_HEIGHT = 418;
export default {
title: 'components/DraftGifMessageSendModal',
@@ -86,14 +88,14 @@ export function Default(): React.JSX.Element {
title: '',
description: '',
previewMedia: {
- url: '',
- width: 640,
- height: 640,
+ url: MOCK_GIF_URL,
+ width: MOCK_GIF_WIDTH,
+ height: MOCK_GIF_HEIGHT,
},
attachmentMedia: {
- url: '',
- width: 640,
- height: 640,
+ url: MOCK_GIF_URL,
+ width: MOCK_GIF_WIDTH,
+ height: MOCK_GIF_HEIGHT,
},
},
}}
diff --git a/ts/components/fun/FunEmojiPicker.dom.stories.tsx b/ts/components/fun/FunEmojiPicker.dom.stories.tsx
index a1529b4092..3cfcf6972b 100644
--- a/ts/components/fun/FunEmojiPicker.dom.stories.tsx
+++ b/ts/components/fun/FunEmojiPicker.dom.stories.tsx
@@ -58,9 +58,10 @@ function Template(props: TemplateProps): React.JSX.Element {
onClearStickerPickerHint={() => null}
onSelectSticker={() => null}
// Gifs
- fetchGifsSearch={() => Promise.reject()}
- fetchGifsFeatured={() => Promise.reject()}
- fetchGif={() => Promise.reject()}
+ fetchGiphySearch={() => Promise.reject()}
+ fetchGiphyTrending={() => Promise.reject()}
+ fetchGiphyFile={() => Promise.reject()}
+ onRemoveRecentGif={() => null}
onSelectGif={() => null}
>
diff --git a/ts/components/fun/FunGif.dom.stories.tsx b/ts/components/fun/FunGif.dom.stories.tsx
index 3cff06413c..8820d50994 100644
--- a/ts/components/fun/FunGif.dom.stories.tsx
+++ b/ts/components/fun/FunGif.dom.stories.tsx
@@ -10,6 +10,11 @@ export default {
title: 'Components/Fun/FunGif',
} satisfies Meta;
+const MOCK_GIF_URL =
+ 'https://media2.giphy.com/media/v1.Y2lkPTZhNGNmY2JhaXFlbXZxcHVjNXlmaGdlYWs1dTlwYnNrb2I5aGttbXViYjh4Z2hqbyZlcD12MV9naWZzX3NlYXJjaCZjdD1n/3kzJvEciJa94SMW3hN/giphy.mp4';
+const MOCK_GIF_WIDTH = 480;
+const MOCK_GIF_HEIGHT = 418;
+
export function Basic(): React.JSX.Element {
const id = useId();
return (
@@ -17,9 +22,9 @@ export function Basic(): React.JSX.Element {
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
@@ -35,29 +40,29 @@ export function PreviewSizing(): React.JSX.Element {
return (
<>
@@ -71,9 +76,7 @@ export function PreviewLoading(): React.JSX.Element {
useEffect(() => {
setTimeout(() => {
- setSrc(
- 'https://media.tenor.com/tN6E5iSxeI8AAAPo/spongebob-spongebob-squarepants.mp4'
- );
+ setSrc(MOCK_GIF_URL);
}, 2000);
}, []);
@@ -81,8 +84,8 @@ export function PreviewLoading(): React.JSX.Element {
@@ -102,8 +105,8 @@ export function PreviewError(): React.JSX.Element {
diff --git a/ts/components/fun/FunPicker.dom.stories.tsx b/ts/components/fun/FunPicker.dom.stories.tsx
index 82804a5d52..dd745fb1f2 100644
--- a/ts/components/fun/FunPicker.dom.stories.tsx
+++ b/ts/components/fun/FunPicker.dom.stories.tsx
@@ -43,9 +43,10 @@ function Template(props: TemplateProps) {
onClearStickerPickerHint={() => null}
onSelectSticker={() => null}
// Gifs
- fetchGifsSearch={() => Promise.resolve(MOCK_GIFS_PAGINATED_ONE_PAGE)}
- fetchGifsFeatured={() => Promise.resolve(MOCK_GIFS_PAGINATED_ONE_PAGE)}
- fetchGif={() => Promise.resolve(new Blob([new Uint8Array(1)]))}
+ fetchGiphySearch={() => Promise.resolve(MOCK_GIFS_PAGINATED_ONE_PAGE)}
+ fetchGiphyTrending={() => Promise.resolve(MOCK_GIFS_PAGINATED_ONE_PAGE)}
+ fetchGiphyFile={() => Promise.resolve(new Blob([new Uint8Array(1)]))}
+ onRemoveRecentGif={() => null}
onSelectGif={() => null}
>
diff --git a/ts/components/fun/FunProvider.dom.tsx b/ts/components/fun/FunProvider.dom.tsx
index da85f07ede..cab57df51e 100644
--- a/ts/components/fun/FunProvider.dom.tsx
+++ b/ts/components/fun/FunProvider.dom.tsx
@@ -16,14 +16,14 @@ import type {
import type { EmojiSkinTone, EmojiParentKey } from './data/emojis.std.js';
import type { FunGifSelection, GifType } from './panels/FunPanelGifs.dom.js';
import { FunPickerTabKey } from './constants.dom.js';
-import type {
- fetchGifsFeatured,
- fetchGifsSearch,
-} from './data/gifs.preload.js';
-import type { tenorDownload } from './data/tenor.preload.js';
import type { FunEmojiSelection } from './panels/FunPanelEmojis.dom.js';
import type { FunStickerSelection } from './panels/FunPanelStickers.dom.js';
import { FunEmojiLocalizationProvider } from './FunEmojiLocalizationProvider.dom.js';
+import type {
+ fetchGiphyFile,
+ fetchGiphySearch,
+ fetchGiphyTrending,
+} from './data/giphy.preload.js';
export type FunContextSmartProps = Readonly<{
i18n: LocalizerType;
@@ -46,9 +46,10 @@ export type FunContextSmartProps = Readonly<{
onSelectSticker: (stickerSelection: FunStickerSelection) => void;
// GIFs
- fetchGifsFeatured: typeof fetchGifsFeatured;
- fetchGifsSearch: typeof fetchGifsSearch;
- fetchGif: typeof tenorDownload;
+ fetchGiphyTrending: typeof fetchGiphyTrending;
+ fetchGiphySearch: typeof fetchGiphySearch;
+ fetchGiphyFile: typeof fetchGiphyFile;
+ onRemoveRecentGif: (gifId: string) => void;
onSelectGif: (gifSelection: FunGifSelection) => void;
}>;
@@ -157,9 +158,10 @@ export const FunProvider = memo(function FunProvider(
onClearStickerPickerHint={props.onClearStickerPickerHint}
onSelectSticker={props.onSelectSticker}
// GIFs
- fetchGifsFeatured={props.fetchGifsFeatured}
- fetchGifsSearch={props.fetchGifsSearch}
- fetchGif={props.fetchGif}
+ fetchGiphyTrending={props.fetchGiphyTrending}
+ fetchGiphySearch={props.fetchGiphySearch}
+ fetchGiphyFile={props.fetchGiphyFile}
+ onRemoveRecentGif={props.onRemoveRecentGif}
onSelectGif={props.onSelectGif}
>
{props.children}
diff --git a/ts/components/fun/FunStickerPicker.dom.stories.tsx b/ts/components/fun/FunStickerPicker.dom.stories.tsx
index eadb1f22b3..646586e567 100644
--- a/ts/components/fun/FunStickerPicker.dom.stories.tsx
+++ b/ts/components/fun/FunStickerPicker.dom.stories.tsx
@@ -43,9 +43,10 @@ function Template(props: TemplateProps): React.JSX.Element {
onClearStickerPickerHint={() => null}
onSelectSticker={() => null}
// Gifs
- fetchGifsSearch={() => Promise.reject()}
- fetchGifsFeatured={() => Promise.reject()}
- fetchGif={() => Promise.reject()}
+ fetchGiphySearch={() => Promise.reject()}
+ fetchGiphyTrending={() => Promise.reject()}
+ fetchGiphyFile={() => Promise.reject()}
+ onRemoveRecentGif={() => null}
onSelectGif={() => null}
>
diff --git a/ts/components/fun/data/gifs.preload.ts b/ts/components/fun/data/gifs.preload.ts
deleted file mode 100644
index 74ffc75d73..0000000000
--- a/ts/components/fun/data/gifs.preload.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-// Copyright 2025 Signal Messenger, LLC
-// SPDX-License-Identifier: AGPL-3.0-only
-
-import { strictAssert } from '../../../util/assert.std.js';
-import type { GifType } from '../panels/FunPanelGifs.dom.js';
-import type {
- TenorContentFormat,
- TenorNextCursor,
- TenorResponseResult,
-} from './tenor.preload.js';
-import { tenor, isTenorTailCursor } from './tenor.preload.js';
-
-const PREVIEW_CONTENT_FORMAT: TenorContentFormat = 'tinymp4';
-const ATTACHMENT_CONTENT_FORMAT: TenorContentFormat = 'mp4';
-
-function toGif(result: TenorResponseResult): GifType {
- const preview = result.media_formats[PREVIEW_CONTENT_FORMAT];
- strictAssert(preview, `Missing ${PREVIEW_CONTENT_FORMAT}`);
- const attachment = result.media_formats[ATTACHMENT_CONTENT_FORMAT];
- strictAssert(attachment, `Missing ${ATTACHMENT_CONTENT_FORMAT}`);
- return {
- id: result.id,
- title: result.title,
- description: result.content_description,
- previewMedia: {
- url: preview.url,
- width: preview.dims[0],
- height: preview.dims[1],
- },
- attachmentMedia: {
- url: attachment.url,
- width: attachment.dims[0],
- height: attachment.dims[1],
- },
- };
-}
-
-export type GifsPaginated = Readonly<{
- next: TenorNextCursor | null;
- gifs: ReadonlyArray;
-}>;
-
-export async function fetchGifsFeatured(
- limit: number,
- cursor: TenorNextCursor | null,
- signal?: AbortSignal
-): Promise {
- const response = await tenor(
- 'v2/featured',
- {
- contentfilter: 'low',
- media_filter: [PREVIEW_CONTENT_FORMAT, ATTACHMENT_CONTENT_FORMAT],
- limit,
- pos: cursor ?? undefined,
- },
- signal
- );
-
- const next = isTenorTailCursor(response.next) ? null : response.next;
- const gifs = response.results.map(result => toGif(result));
- return { next, gifs };
-}
-
-export async function fetchGifsSearch(
- query: string,
- limit: number,
- cursor: TenorNextCursor | null,
- signal?: AbortSignal
-): Promise {
- const response = await tenor(
- 'v2/search',
- {
- q: query,
- contentfilter: 'low',
- media_filter: [PREVIEW_CONTENT_FORMAT, ATTACHMENT_CONTENT_FORMAT],
- limit,
- pos: cursor ?? undefined,
- },
- signal
- );
-
- const next = isTenorTailCursor(response.next) ? null : response.next;
- const gifs = response.results.map(result => toGif(result));
- return { next, gifs };
-}
diff --git a/ts/components/fun/data/giphy.preload.ts b/ts/components/fun/data/giphy.preload.ts
new file mode 100644
index 0000000000..7fd9e701ec
--- /dev/null
+++ b/ts/components/fun/data/giphy.preload.ts
@@ -0,0 +1,197 @@
+// Copyright 2026 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+import { z } from 'zod';
+import { parseUnknown } from '../../../util/schemas.std.js';
+import {
+ fetchJsonViaProxy,
+ fetchBytesViaProxy,
+} from '../../../textsecure/WebAPI.preload.js';
+import { fetchInSegments } from './segments.std.js';
+import { safeParseInteger } from '../../../util/numbers.std.js';
+import type { PaginatedGifResults } from '../panels/FunPanelGifs.dom.js';
+import {
+ getGifCdnUrlOrigin,
+ isGifCdnUrlOriginAllowed,
+ isGiphyCdnUrlOrigin,
+} from '../../../util/gifCdnUrls.dom.js';
+
+const BASE_API_URL = 'https://api.giphy.com';
+const API_KEY = 'ApVVlSyeBfNKK6UWtnBRq9CvAkWsxayB';
+
+const CONTENT_RATING = 'pg-13';
+const CONTENT_BUNDLE = 'messaging_non_clips';
+
+const GIF_FIELDS = [
+ 'id',
+ 'title',
+ 'alt_text',
+ 'images.original.width',
+ 'images.original.height',
+ 'images.original.mp4',
+ 'images.fixed_width.width',
+ 'images.fixed_width.height',
+ 'images.fixed_width.mp4',
+].join(',');
+
+export type GiphySearchParams = Readonly<{
+ query: string;
+ limit: number;
+ offset: number;
+}>;
+
+export type GiphyTrendingParams = Readonly<{
+ limit: number;
+ offset: number;
+}>;
+
+const GiphyPaginationSchema = z.object({
+ offset: z.number().int(),
+ total_count: z.number().int(),
+ count: z.number().int(),
+});
+
+const StringInteger = z.preprocess(input => {
+ if (typeof input === 'string') {
+ return safeParseInteger(input);
+ }
+ return input;
+}, z.number().int());
+
+const GiphyCdnUrl = z.string().refine(input => {
+ const origin = getGifCdnUrlOrigin(input);
+ return origin != null && isGiphyCdnUrlOrigin(origin);
+});
+
+const GiphyImagesSchema = z.object({
+ original: z.object({
+ width: StringInteger,
+ height: StringInteger,
+ mp4: GiphyCdnUrl,
+ }),
+ // fixed width of 200px
+ fixed_width: z.object({
+ width: StringInteger,
+ height: StringInteger,
+ mp4: GiphyCdnUrl,
+ }),
+});
+
+const GiphyGifSchema = z.object({
+ id: z.string().min(1),
+ title: z.string(),
+ alt_text: z.string(),
+ images: GiphyImagesSchema,
+});
+
+const GiphyResultsSchema = z.object({
+ pagination: GiphyPaginationSchema,
+ data: z.array(GiphyGifSchema),
+});
+
+export type GiphyPagination = z.infer;
+export type GiphyImages = z.infer;
+export type GiphyGif = z.infer;
+export type GiphyResults = z.infer;
+
+function getNextOffset(pagination: GiphyPagination): number | null {
+ const end = pagination.offset + pagination.count;
+ if (end >= pagination.total_count) {
+ return null;
+ }
+ return end;
+}
+
+function normalizeGiphyResults(results: GiphyResults): PaginatedGifResults {
+ return {
+ next: getNextOffset(results.pagination),
+ gifs: results.data.map(item => {
+ return {
+ id: item.id,
+ title: item.title,
+ description: item.alt_text,
+ previewMedia: {
+ url: item.images.fixed_width.mp4,
+ width: item.images.fixed_width.width,
+ height: item.images.fixed_width.height,
+ },
+ attachmentMedia: {
+ url: item.images.original.mp4,
+ width: item.images.original.width,
+ height: item.images.original.height,
+ },
+ };
+ }),
+ };
+}
+
+export async function fetchGiphySearch(
+ query: string,
+ limit: number,
+ offset: number | null,
+ signal?: AbortSignal
+): Promise {
+ const url = new URL('v1/gifs/search', BASE_API_URL);
+
+ url.searchParams.set('api_key', API_KEY);
+ url.searchParams.set('rating', CONTENT_RATING);
+ url.searchParams.set('bundle', CONTENT_BUNDLE);
+ url.searchParams.set('fields', GIF_FIELDS);
+
+ url.searchParams.set('q', query);
+ url.searchParams.set('limit', `${limit}`);
+ if (offset != null) {
+ url.searchParams.set('offset', `${offset}`);
+ }
+
+ const response = await fetchJsonViaProxy({
+ method: 'GET',
+ url: url.toString(),
+ signal,
+ });
+
+ const results = parseUnknown(GiphyResultsSchema, response.data);
+ return normalizeGiphyResults(results);
+}
+
+export async function fetchGiphyTrending(
+ limit: number,
+ offset: number | null,
+ signal?: AbortSignal
+): Promise {
+ const url = new URL('v1/gifs/trending', BASE_API_URL);
+
+ url.searchParams.set('api_key', API_KEY);
+ url.searchParams.set('rating', CONTENT_RATING);
+ url.searchParams.set('bundle', CONTENT_BUNDLE);
+ url.searchParams.set('fields', GIF_FIELDS);
+
+ url.searchParams.set('limit', `${limit}`);
+ if (offset != null) {
+ url.searchParams.set('offset', `${offset}`);
+ }
+
+ const response = await fetchJsonViaProxy({
+ method: 'GET',
+ url: url.toString(),
+ signal,
+ });
+
+ const results = parseUnknown(GiphyResultsSchema, response.data);
+ return normalizeGiphyResults(results);
+}
+
+export function fetchGiphyFile(
+ giphyCdnUrl: string,
+ signal?: AbortSignal
+): Promise {
+ const origin = getGifCdnUrlOrigin(giphyCdnUrl);
+ if (origin == null) {
+ throw new Error('fetchGiphyFile: Cannot fetch invalid URL');
+ }
+ if (!isGifCdnUrlOriginAllowed(origin)) {
+ throw new Error(
+ `fetchGiphyFile: Blocked unsupported url origin: ${origin}`
+ );
+ }
+ return fetchInSegments(giphyCdnUrl, fetchBytesViaProxy, signal);
+}
diff --git a/ts/components/fun/data/tenor.preload.ts b/ts/components/fun/data/tenor.preload.ts
deleted file mode 100644
index 87210240bf..0000000000
--- a/ts/components/fun/data/tenor.preload.ts
+++ /dev/null
@@ -1,234 +0,0 @@
-// Copyright 2025 Signal Messenger, LLC
-// SPDX-License-Identifier: AGPL-3.0-only
-
-import { z } from 'zod';
-import type { Simplify } from 'type-fest';
-import { strictAssert } from '../../../util/assert.std.js';
-import { parseUnknown } from '../../../util/schemas.std.js';
-import {
- fetchJsonViaProxy,
- fetchBytesViaProxy,
-} from '../../../textsecure/WebAPI.preload.js';
-import { fetchInSegments } from './segments.std.js';
-
-const BASE_URL = 'https://tenor.googleapis.com/v2';
-const API_KEY = 'AIzaSyBt6SUfSsCQic2P2VkNkLjsGI7HGWZI95g';
-
-/**
- * Types
- */
-
-export type TenorNextCursor = string & { TenorHasNextCursor: never };
-export type TenorTailCursor = ('0' | '') & { TenorHasEndCursor: never };
-export type TenorCursor = TenorNextCursor | TenorTailCursor;
-
-const TenorCursorSchema = z.custom(
- (value: unknown) => {
- return typeof value === 'string';
- },
- input => {
- return { message: `Expected tenor cursor, got: ${input}` };
- }
-);
-
-export type TenorContentFormat = string;
-export type TenorContentFilter = 'off' | 'low' | 'medium' | 'high';
-export type TenorAspectRatioFilter = 'all' | 'wide' | 'standard';
-export type TenorSearchFilter = 'sticker' | 'static' | '-static';
-
-export function isTenorTailCursor(
- cursor: TenorCursor
-): cursor is TenorTailCursor {
- return cursor === '0' || cursor === '';
-}
-
-/**
- * Params
- */
-
-type TenorApiParams = Readonly<{
- key: string;
- client_key?: string;
-}>;
-
-type TenorLocalizationParams = Readonly<{
- country?: string;
- locale?: string;
-}>;
-
-type TenorSearchFilterParams = Readonly<{
- searchfilter?: ReadonlyArray;
- media_filter?: ReadonlyArray;
- ar_range?: TenorAspectRatioFilter;
-}>;
-
-type TenorContentFilterParams = Readonly<{
- contentfilter?: TenorContentFilter;
-}>;
-
-type TenorPaginationParams = Readonly<{
- limit?: number;
- pos?: TenorNextCursor;
-}>;
-
-/**
- * Response Schemas
- */
-
-const TenorResponseCategorySchema = z.object({
- searchterm: z.string(),
- path: z.string(),
- image: z.string(),
- name: z.string(),
-});
-
-const TenorResponseMediaSchema = z.object({
- url: z.string(),
- dims: z.array(z.number()),
- duration: z.number(),
- size: z.number(),
-});
-
-const TenorResponseResultSchema = z.object({
- created: z.number(),
- hasaudio: z.boolean(),
- id: z.string(),
- media_formats: z.record(TenorResponseMediaSchema),
- tags: z.array(z.string()),
- title: z.string(),
- content_description: z.string(),
- itemurl: z.string(),
- hascaption: z.boolean().optional(),
- flags: z.array(z.string()),
- bg_color: z.string().optional(),
- url: z.string(),
-});
-
-export type TenorResponseCategory = z.infer;
-export type TenorResponseMedia = z.infer;
-export type TenorResponseResult = z.infer;
-
-export type TenorPaginatedResponse = Readonly<{
- next: TenorCursor;
- results: ReadonlyArray;
-}>;
-
-/**
- * Endpoints
- */
-
-type TenorEndpoints = Readonly<{
- 'v2/search': {
- params: Simplify<
- TenorApiParams &
- TenorLocalizationParams &
- TenorSearchFilterParams &
- TenorContentFilterParams &
- TenorPaginationParams &
- Readonly<{
- q: string;
- random?: boolean;
- }>
- >;
- response: TenorPaginatedResponse;
- };
- 'v2/featured': {
- params: Simplify<
- TenorApiParams &
- TenorLocalizationParams &
- TenorSearchFilterParams &
- TenorContentFilterParams &
- TenorPaginationParams
- >;
- response: TenorPaginatedResponse;
- };
- 'v2/categories': {
- params: Simplify<
- TenorApiParams &
- TenorLocalizationParams &
- TenorContentFilterParams & {
- type: 'featured' | 'trending';
- }
- >;
- response: {
- tags: ReadonlyArray;
- };
- };
- // ignored
- // 'v2/search_suggestions'
- // 'v2/autocomplete'
- // 'v2/trending_terms'
- // 'v2/registershare': {},
- // 'v2/posts': {},
-}>;
-
-/**
- * Response Schemas
- */
-
-type ResponseSchemaMapType = Readonly<{
- [Path in keyof TenorEndpoints]: z.ZodSchema;
-}>;
-
-const ResponseSchemaMap: ResponseSchemaMapType = {
- 'v2/search': z.object({
- next: TenorCursorSchema,
- results: z.array(TenorResponseResultSchema),
- }),
- 'v2/featured': z.object({
- next: TenorCursorSchema,
- results: z.array(TenorResponseResultSchema),
- }),
- 'v2/categories': z.object({
- tags: z.array(TenorResponseCategorySchema),
- }),
-};
-
-/**
- * Tenor API Client
- *
- * ```ts
- * const response = await tenor('v2/search', {
- * q: 'hello',
- * limit: 10,
- * });
- * // >> { next: '...', results: [...] }
- * ````
- */
-export async function tenor(
- path: Path,
- params: Omit,
- signal?: AbortSignal
-): Promise {
- const schema = ResponseSchemaMap[path];
- strictAssert(schema, 'Missing schema');
-
- const url = new URL(path, BASE_URL);
-
- // Always add the API key
- url.searchParams.set('key', API_KEY);
-
- for (const [key, value] of Object.entries(params)) {
- if (value == null) {
- continue;
- }
- // Note: Tenor formats arrays as comma-separated strings
- const param = Array.isArray(value) ? value.join(',') : `${value}`;
- url.searchParams.set(key, param);
- }
-
- const response = await fetchJsonViaProxy({
- method: 'GET',
- url: url.toString(),
- signal,
- });
- const result = parseUnknown(schema, response.data);
- return result;
-}
-
-export function tenorDownload(
- tenorCdnUrl: string,
- signal?: AbortSignal
-): Promise {
- return fetchInSegments(tenorCdnUrl, fetchBytesViaProxy, signal);
-}
diff --git a/ts/components/fun/mocks.dom.tsx b/ts/components/fun/mocks.dom.tsx
index 6d237a3d1a..272cb4ccf8 100644
--- a/ts/components/fun/mocks.dom.tsx
+++ b/ts/components/fun/mocks.dom.tsx
@@ -9,7 +9,7 @@ import {
getEmojiVariantKeyByParentKeyAndSkinTone,
isEmojiEnglishShortName,
} from './data/emojis.std.js';
-import type { GifsPaginated } from './data/gifs.preload.js';
+import type { PaginatedGifResults } from './panels/FunPanelGifs.dom.js';
function getEmoji(input: string): EmojiParentKey {
strictAssert(
@@ -75,12 +75,7 @@ export const MOCK_RECENT_EMOJIS: ReadonlyArray = [
getEmoji('zipper_mouth_face'),
];
-export const MOCK_GIFS_PAGINATED_EMPTY: GifsPaginated = {
- next: null,
- gifs: [],
-};
-
-export const MOCK_GIFS_PAGINATED_ONE_PAGE: GifsPaginated = {
+export const MOCK_GIFS_PAGINATED_ONE_PAGE: PaginatedGifResults = {
next: null,
gifs: Array.from({ length: 30 }, (_, i) => {
return {
@@ -88,14 +83,14 @@ export const MOCK_GIFS_PAGINATED_ONE_PAGE: GifsPaginated = {
title: '',
description: '',
previewMedia: {
- url: 'https://media.tenor.com/ihqN6a3iiYEAAAPo/pikachu-shocked-face-stunned.mp4',
- width: 640,
- height: 640,
+ url: 'https://media2.giphy.com/media/v1.Y2lkPTZhNGNmY2JhaXFlbXZxcHVjNXlmaGdlYWs1dTlwYnNrb2I5aGttbXViYjh4Z2hqbyZlcD12MV9naWZzX3NlYXJjaCZjdD1n/3kzJvEciJa94SMW3hN/200w.mp4',
+ width: 200,
+ height: 178,
},
attachmentMedia: {
- url: 'https://media.tenor.com/ihqN6a3iiYEAAAPo/pikachu-shocked-face-stunned.mp4',
- width: 640,
- height: 640,
+ url: 'https://media2.giphy.com/media/v1.Y2lkPTZhNGNmY2JhaXFlbXZxcHVjNXlmaGdlYWs1dTlwYnNrb2I5aGttbXViYjh4Z2hqbyZlcD12MV9naWZzX3NlYXJjaCZjdD1n/3kzJvEciJa94SMW3hN/giphy.mp4',
+ width: 480,
+ height: 418,
},
};
}),
diff --git a/ts/components/fun/panels/FunPanelGifs.dom.tsx b/ts/components/fun/panels/FunPanelGifs.dom.tsx
index 35f13843f0..ec45e0a4aa 100644
--- a/ts/components/fun/panels/FunPanelGifs.dom.tsx
+++ b/ts/components/fun/panels/FunPanelGifs.dom.tsx
@@ -14,6 +14,7 @@ import React, {
} from 'react';
import { VisuallyHidden } from 'react-aria';
import { LRUCache } from 'lru-cache';
+import { AnimatePresence, motion } from 'framer-motion';
import { FunItemButton } from '../base/FunItem.dom.js';
import {
FunPanel,
@@ -41,7 +42,6 @@ import { WaterfallKeyboardDelegate } from '../keyboard/WaterfallKeyboardDelegate
import { useInfiniteQuery } from '../data/infinite.std.js';
import { missingCaseError } from '../../../util/missingCaseError.std.js';
import { strictAssert } from '../../../util/assert.std.js';
-import type { GifsPaginated } from '../data/gifs.preload.js';
import { drop } from '../../../util/drop.std.js';
import { useFunContext } from '../FunProvider.dom.js';
import {
@@ -59,7 +59,6 @@ import {
FunLightboxProvider,
useFunLightboxKey,
} from '../base/FunLightbox.dom.js';
-import type { tenorDownload } from '../data/tenor.preload.js';
import { FunGif } from '../FunGif.dom.js';
import type { LocalizerType } from '../../../types/I18N.std.js';
import { isAbortError } from '../../../util/isAbortError.std.js';
@@ -69,6 +68,14 @@ import {
EMOJI_VARIANT_KEY_CONSTANTS,
getEmojiVariantByKey,
} from '../data/emojis.std.js';
+import type { fetchGiphyFile } from '../data/giphy.preload.js';
+import {
+ getGifCdnUrlOrigin,
+ isGifCdnUrlOriginAllowed,
+ isTenorCdnUrlOrigin,
+} from '../../../util/gifCdnUrls.dom.js';
+import { tw } from '../../../axo/tw.dom.js';
+import { isScrollAtTop } from '../../../hooks/useSizeObserver.dom.js';
const log = createLogger('FunPanelGifs');
@@ -80,6 +87,14 @@ const FunGifBlobCache = new LRUCache({
const FunGifBlobLiveCache = new WeakMap();
function readGifMediaFromCache(gifMedia: GifMediaType): Blob | null {
+ const cdnUrlOrigin = getGifCdnUrlOrigin(gifMedia.url);
+
+ if (cdnUrlOrigin == null || !isGifCdnUrlOriginAllowed(cdnUrlOrigin)) {
+ FunGifBlobLiveCache.delete(gifMedia);
+ FunGifBlobCache.delete(gifMedia.url);
+ return null;
+ }
+
return (
FunGifBlobLiveCache.get(gifMedia) ??
FunGifBlobCache.get(gifMedia.url) ??
@@ -127,6 +142,11 @@ export type GifType = Readonly<{
attachmentMedia: GifMediaType;
}>;
+export type PaginatedGifResults = Readonly<{
+ next: number | null;
+ gifs: ReadonlyArray;
+}>;
+
type GifsQuery = Readonly<{
selectedSection: FunGifsSection;
searchQuery: string;
@@ -151,13 +171,38 @@ export function FunPanelGifs({
storedSearchInput,
onStoredSearchInputChange,
recentGifs,
- fetchGifsFeatured,
- fetchGifsSearch,
- fetchGif,
+ fetchGiphyTrending,
+ fetchGiphySearch,
+ fetchGiphyFile,
+ onRemoveRecentGif,
onSelectGif: onFunSelectGif,
} = fun;
const scrollerRef = useRef(null);
+ const [didScroll, setDidScroll] = useState(false);
+
+ useEffect(() => {
+ strictAssert(scrollerRef.current, 'Missing scroller ref');
+ const scroller = scrollerRef.current;
+
+ function onScroll() {
+ if (!isScrollAtTop(scroller)) {
+ setDidScroll(true);
+ cleanup();
+ }
+ }
+
+ // Can't use `once: true` because scroll event may fire when still at top
+ function cleanup() {
+ scroller.removeEventListener('scroll', onScroll);
+ }
+
+ scroller.addEventListener('scroll', onScroll, {
+ passive: true,
+ });
+
+ return cleanup;
+ }, []);
const [searchInput, setSearchInput] = useState(storedSearchInput);
const searchQuery = useMemo(() => searchInput.trim(), [searchInput]);
@@ -205,54 +250,54 @@ export function FunPanelGifs({
const loader = useCallback(
async (
query: GifsQuery,
- previousPage: GifsPaginated | null,
+ previousPage: PaginatedGifResults | null,
signal: AbortSignal
) => {
const cursor = previousPage?.next ?? null;
const limit = cursor != null ? 30 : 10;
if (query.searchQuery !== '') {
- return fetchGifsSearch(query.searchQuery, limit, cursor, signal);
+ return fetchGiphySearch(query.searchQuery, limit, cursor, signal);
}
strictAssert(
query.selectedSection !== FunSectionCommon.SearchResults,
'Section is search results when not searching'
);
if (query.selectedSection === FunSectionCommon.Recents) {
- return { next: null, gifs: recentGifs };
+ return { next: null, gifs: [] };
}
if (query.selectedSection === FunGifsCategory.Trending) {
- return fetchGifsFeatured(limit, cursor, signal);
+ return fetchGiphyTrending(limit, cursor, signal);
}
if (query.selectedSection === FunGifsCategory.Celebrate) {
- return fetchGifsSearch('celebrate', limit, cursor, signal);
+ return fetchGiphySearch('celebrate', limit, cursor, signal);
}
if (query.selectedSection === FunGifsCategory.Love) {
- return fetchGifsSearch('love', limit, cursor, signal);
+ return fetchGiphySearch('love', limit, cursor, signal);
}
if (query.selectedSection === FunGifsCategory.ThumbsUp) {
- return fetchGifsSearch('thumbs-up', limit, cursor, signal);
+ return fetchGiphySearch('thumbs-up', limit, cursor, signal);
}
if (query.selectedSection === FunGifsCategory.Surprised) {
- return fetchGifsSearch('surprised', limit, cursor, signal);
+ return fetchGiphySearch('surprised', limit, cursor, signal);
}
if (query.selectedSection === FunGifsCategory.Excited) {
- return fetchGifsSearch('excited', limit, cursor, signal);
+ return fetchGiphySearch('excited', limit, cursor, signal);
}
if (query.selectedSection === FunGifsCategory.Sad) {
- return fetchGifsSearch('sad', limit, cursor, signal);
+ return fetchGiphySearch('sad', limit, cursor, signal);
}
if (query.selectedSection === FunGifsCategory.Angry) {
- return fetchGifsSearch('angry', limit, cursor, signal);
+ return fetchGiphySearch('angry', limit, cursor, signal);
}
throw missingCaseError(query.selectedSection);
},
- [recentGifs, fetchGifsSearch, fetchGifsFeatured]
+ [fetchGiphySearch, fetchGiphyTrending]
);
const hasNextPage = useCallback(
- (_query: GifsQuery, previousPage: GifsPaginated | null) => {
+ (_query: GifsQuery, previousPage: PaginatedGifResults | null) => {
return previousPage?.next != null;
},
[]
@@ -265,8 +310,11 @@ export function FunPanelGifs({
});
const items = useMemo(() => {
+ if (queryState.query.selectedSection === FunSectionCommon.Recents) {
+ return recentGifs;
+ }
return queryState.pages.flatMap(page => page.gifs);
- }, [queryState.pages]);
+ }, [queryState.query.selectedSection, recentGifs, queryState.pages]);
const estimateSize = useCallback(
(index: number) => {
@@ -407,8 +455,8 @@ export function FunPanelGifs({
i18n={i18n}
searchInput={searchInput}
onSearchInputChange={handleSearchInputChange}
- placeholder={i18n('icu:FunPanelGifs__SearchPlaceholder--Tenor')}
- aria-label={i18n('icu:FunPanelGifs__SearchLabel--Tenor')}
+ placeholder={i18n('icu:FunPanelGifs__SearchPlaceholder')}
+ aria-label={i18n('icu:FunPanelGifs__SearchLabel')}
/>
{visibleSelectedSection !== FunSectionCommon.SearchResults && (
@@ -545,7 +593,8 @@ export function FunPanelGifs({
itemLane={item.lane}
isTabbable={isTabbable}
onClickGif={handleClickGif}
- fetchGif={fetchGif}
+ onRemoveRecentGif={onRemoveRecentGif}
+ fetchGiphyFile={fetchGiphyFile}
/>
);
})}
@@ -554,6 +603,30 @@ export function FunPanelGifs({
)}
+
+ {!didScroll && (
+
+
+
+ )}
+
);
@@ -567,9 +640,10 @@ const Item = memo(function Item(props: {
itemLane: number;
isTabbable: boolean;
onClickGif: (event: PointerEvent, gifSelection: FunGifSelection) => void;
- fetchGif: typeof tenorDownload;
+ onRemoveRecentGif: (gifId: string) => void;
+ fetchGiphyFile: typeof fetchGiphyFile;
}) {
- const { onClickGif, fetchGif } = props;
+ const { onClickGif, fetchGiphyFile, onRemoveRecentGif } = props;
const handleClick = useCallback(
async (event: PointerEvent) => {
@@ -593,15 +667,30 @@ const Item = memo(function Item(props: {
const { signal } = controller;
async function download() {
+ const cdnUrl = props.gif.previewMedia.url;
+ const cdnUrlOrigin = getGifCdnUrlOrigin(props.gif.previewMedia.url);
+
+ if (cdnUrlOrigin == null || !isGifCdnUrlOriginAllowed(cdnUrlOrigin)) {
+ onRemoveRecentGif(props.gif.id);
+ return;
+ }
+
try {
- const bytes = await fetchGif(props.gif.previewMedia.url, signal);
+ const bytes = await fetchGiphyFile(cdnUrl, signal);
const blob = new Blob([bytes]);
saveGifMediaToCache(props.gif.previewMedia, blob);
setSrc(URL.createObjectURL(blob));
} catch (error) {
- if (!isAbortError(error)) {
- log.error('Failed to download gif', Errors.toLogFormat(error));
+ if (isAbortError(error)) {
+ return; // ignore
}
+
+ if (isTenorCdnUrlOrigin(cdnUrlOrigin)) {
+ onRemoveRecentGif(props.gif.id);
+ return;
+ }
+
+ log.error('Failed to download gif', Errors.toLogFormat(error));
}
}
@@ -610,7 +699,7 @@ const Item = memo(function Item(props: {
return () => {
controller.abort();
};
- }, [props.gif, src, fetchGif]);
+ }, [props.gif, src, fetchGiphyFile, onRemoveRecentGif]);
useEffect(() => {
return () => {
diff --git a/ts/sql/Interface.std.ts b/ts/sql/Interface.std.ts
index 31187a6f4f..b380b1c1d3 100644
--- a/ts/sql/Interface.std.ts
+++ b/ts/sql/Interface.std.ts
@@ -1324,7 +1324,7 @@ type WritableInterface = {
updateEmojiUsage: (shortName: string, timeUsed?: number) => void;
addRecentGif: (gif: GifType, lastUsedAt: number, maxRecents: number) => void;
- removeRecentGif: (gif: Pick) => void;
+ removeRecentGif: (gif: GifType['id']) => void;
updateOrCreateBadges(badges: ReadonlyArray): void;
badgeImageFileDownloaded(url: string, localPath: string): void;
diff --git a/ts/sql/Server.node.ts b/ts/sql/Server.node.ts
index 00fe5b34f5..c1467cbf76 100644
--- a/ts/sql/Server.node.ts
+++ b/ts/sql/Server.node.ts
@@ -7587,10 +7587,10 @@ function addRecentGif(
})();
}
-function removeRecentGif(db: WritableDB, gif: Pick): void {
+function removeRecentGif(db: WritableDB, gifId: GifType['id']): void {
const [query, params] = sql`
DELETE FROM recentGifs
- WHERE id = ${gif.id}
+ WHERE id = ${gifId}
`;
db.prepare(query).run(params);
}
diff --git a/ts/state/ducks/gifs.preload.ts b/ts/state/ducks/gifs.preload.ts
index d0ecb9be15..8372a18b1e 100644
--- a/ts/state/ducks/gifs.preload.ts
+++ b/ts/state/ducks/gifs.preload.ts
@@ -36,7 +36,7 @@ export type GifsRecentGifsAdd = ReadonlyDeep<{
export type GifsRecentGifsRemove = ReadonlyDeep<{
type: typeof GIFS_RECENT_GIFS_REMOVE;
- payload: Pick;
+ payload: { gifId: GifType['id'] };
}>;
type GifsActionType = ReadonlyDeep;
@@ -62,11 +62,11 @@ function onAddRecentGif(
}
function onRemoveRecentGif(
- payload: Pick
+ gifId: GifType['id']
): ThunkAction {
return async dispatch => {
- await removeRecentGif(payload);
- dispatch({ type: GIFS_RECENT_GIFS_REMOVE, payload });
+ await removeRecentGif(gifId);
+ dispatch({ type: GIFS_RECENT_GIFS_REMOVE, payload: { gifId } });
};
}
@@ -107,7 +107,7 @@ export function reducer(
const { payload } = action;
return {
...state,
- recentGifs: filterRecentGif(state.recentGifs, payload.id),
+ recentGifs: filterRecentGif(state.recentGifs, payload.gifId),
};
}
diff --git a/ts/state/smart/DraftGifMessageSendModal.preload.tsx b/ts/state/smart/DraftGifMessageSendModal.preload.tsx
index 12de6bcfe3..001e1f7adf 100644
--- a/ts/state/smart/DraftGifMessageSendModal.preload.tsx
+++ b/ts/state/smart/DraftGifMessageSendModal.preload.tsx
@@ -12,7 +12,7 @@ import { getDraftGifMessageSendModalProps } from '../selectors/globalModals.std.
import { useGlobalModalActions } from '../ducks/globalModals.preload.js';
import { useComposerActions } from '../ducks/composer.preload.js';
import type { FunGifSelection } from '../../components/fun/panels/FunPanelGifs.dom.js';
-import { tenorDownload } from '../../components/fun/data/tenor.preload.js';
+import { fetchGiphyFile } from '../../components/fun/data/giphy.preload.js';
import { drop } from '../../util/drop.std.js';
import { processAttachment } from '../../util/processAttachment.preload.js';
import { SignalService as Proto } from '../../protobuf/index.std.js';
@@ -101,7 +101,7 @@ export const SmartDraftGifMessageSendModal = memo(
async function download() {
setGifDownloadState({ loadingState: LoadingState.Loading });
try {
- const bytes = await tenorDownload(gifUrl, controller.signal);
+ const bytes = await fetchGiphyFile(gifUrl, controller.signal);
const file = new File([bytes], 'gif.mp4', {
type: 'video/mp4',
});
diff --git a/ts/state/smart/FunProvider.preload.tsx b/ts/state/smart/FunProvider.preload.tsx
index b44a533a49..f8facdea93 100644
--- a/ts/state/smart/FunProvider.preload.tsx
+++ b/ts/state/smart/FunProvider.preload.tsx
@@ -25,10 +25,10 @@ import {
import { useItemsActions } from '../ducks/items.preload.js';
import { useGifsActions } from '../ducks/gifs.preload.js';
import {
- fetchGifsFeatured,
- fetchGifsSearch,
-} from '../../components/fun/data/gifs.preload.js';
-import { tenorDownload } from '../../components/fun/data/tenor.preload.js';
+ fetchGiphySearch,
+ fetchGiphyTrending,
+ fetchGiphyFile,
+} from '../../components/fun/data/giphy.preload.js';
import { usePreferredReactionsActions } from '../ducks/preferredReactions.preload.js';
import { useEmojisActions } from '../ducks/emojis.preload.js';
import { useStickersActions } from '../ducks/stickers.preload.js';
@@ -56,7 +56,7 @@ export const SmartFunProvider = memo(function SmartFunProvider(
usePreferredReactionsActions();
const { onUseEmoji } = useEmojisActions();
const { useSticker: onUseSticker } = useStickersActions();
- const { onAddRecentGif } = useGifsActions();
+ const { onAddRecentGif, onRemoveRecentGif } = useGifsActions();
// Translate recent emojis to keys
const recentEmojisKeys = useMemo(() => {
@@ -128,9 +128,10 @@ export const SmartFunProvider = memo(function SmartFunProvider(
onClearStickerPickerHint={handleClearStickerPickerHint}
onSelectSticker={handleSelectSticker}
// Gifs
- fetchGifsSearch={fetchGifsSearch}
- fetchGifsFeatured={fetchGifsFeatured}
- fetchGif={tenorDownload}
+ fetchGiphySearch={fetchGiphySearch}
+ fetchGiphyTrending={fetchGiphyTrending}
+ fetchGiphyFile={fetchGiphyFile}
+ onRemoveRecentGif={onRemoveRecentGif}
onSelectGif={handleSelectGif}
>
{props.children}
diff --git a/ts/util/gifCdnUrls.dom.ts b/ts/util/gifCdnUrls.dom.ts
new file mode 100644
index 0000000000..5e16273ff9
--- /dev/null
+++ b/ts/util/gifCdnUrls.dom.ts
@@ -0,0 +1,44 @@
+// Copyright 2026 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+import * as RemoteConfig from '../RemoteConfig.dom.js';
+
+const GIPHY_CDN_ORIGINS = new Set([
+ 'https://media0.giphy.com',
+ 'https://media1.giphy.com',
+ 'https://media2.giphy.com',
+ 'https://media3.giphy.com',
+ 'https://media4.giphy.com',
+]);
+
+const TENOR_CDN_ORIGINS = new Set(['https://media.tenor.com']);
+
+export function getGifCdnUrlOrigin(input: string): string | null {
+ try {
+ const url = new URL(input);
+ return url.origin;
+ } catch {
+ return null;
+ }
+}
+
+export function isGiphyCdnUrlOrigin(origin: string): boolean {
+ return GIPHY_CDN_ORIGINS.has(origin);
+}
+
+export function isTenorCdnUrlOrigin(origin: string): boolean {
+ return TENOR_CDN_ORIGINS.has(origin);
+}
+
+export function isTenorCdnUrlOriginAllowed(): boolean {
+ return RemoteConfig.isEnabled('desktop.recentGifs.allowLegacyTenorCdnUrls');
+}
+
+export function isGifCdnUrlOriginAllowed(origin: string): boolean {
+ if (isGiphyCdnUrlOrigin(origin)) {
+ return true;
+ }
+ if (isTenorCdnUrlOrigin(origin)) {
+ return isTenorCdnUrlOriginAllowed();
+ }
+ return false;
+}