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 && ( + + {i18n( + + )} + ); @@ -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; +}