mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-02-15 07:28:59 +00:00
Switch from Tenor to Giphy for GIF search
Co-authored-by: Jamie <113370520+jamiebuilds-signal@users.noreply.github.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
BIN
images/giphy-attribution.png
Normal file
BIN
images/giphy-attribution.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
@@ -29,6 +29,7 @@
|
||||
}
|
||||
|
||||
.FunPanel__Body {
|
||||
position: relative;
|
||||
grid-area: FunPanel__Body;
|
||||
}
|
||||
|
||||
|
||||
@@ -83,4 +83,5 @@
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
display: block flow-root;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'row', gap: '1rem' }}>
|
||||
|
||||
@@ -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 */}
|
||||
<div tabIndex={0}>
|
||||
<FunGif
|
||||
src="https://media.tenor.com/tN6E5iSxeI8AAAPo/spongebob-spongebob-squarepants.mp4"
|
||||
width={498}
|
||||
height={376}
|
||||
src={MOCK_GIF_URL}
|
||||
width={MOCK_GIF_WIDTH}
|
||||
height={MOCK_GIF_HEIGHT}
|
||||
aria-label="Spongebob Spongebob Squarepants GIF"
|
||||
aria-describedby={id}
|
||||
/>
|
||||
@@ -35,29 +40,29 @@ export function PreviewSizing(): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<FunGifPreview
|
||||
src="https://media.tenor.com/tN6E5iSxeI8AAAPo/spongebob-spongebob-squarepants.mp4"
|
||||
src={MOCK_GIF_URL}
|
||||
state={LoadingState.Loaded}
|
||||
width={498}
|
||||
height={376}
|
||||
width={MOCK_GIF_WIDTH}
|
||||
height={MOCK_GIF_HEIGHT}
|
||||
maxHeight={400}
|
||||
aria-describedby=""
|
||||
/>
|
||||
<div style={{ maxWidth: 200 }}>
|
||||
<FunGifPreview
|
||||
src="https://media.tenor.com/tN6E5iSxeI8AAAPo/spongebob-spongebob-squarepants.mp4"
|
||||
src={MOCK_GIF_URL}
|
||||
state={LoadingState.Loaded}
|
||||
width={498}
|
||||
height={376}
|
||||
width={MOCK_GIF_WIDTH}
|
||||
height={MOCK_GIF_HEIGHT}
|
||||
maxHeight={400}
|
||||
aria-describedby=""
|
||||
/>
|
||||
</div>
|
||||
<div style={{ maxHeight: 200 }}>
|
||||
<FunGifPreview
|
||||
src="https://media.tenor.com/tN6E5iSxeI8AAAPo/spongebob-spongebob-squarepants.mp4"
|
||||
src={MOCK_GIF_URL}
|
||||
state={LoadingState.Loaded}
|
||||
width={498}
|
||||
height={376}
|
||||
width={MOCK_GIF_WIDTH}
|
||||
height={MOCK_GIF_HEIGHT}
|
||||
maxHeight={200}
|
||||
aria-describedby=""
|
||||
/>
|
||||
@@ -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 {
|
||||
<FunGifPreview
|
||||
src={src}
|
||||
state={src == null ? LoadingState.Loading : LoadingState.Loaded}
|
||||
width={498}
|
||||
height={376}
|
||||
width={MOCK_GIF_WIDTH}
|
||||
height={MOCK_GIF_HEIGHT}
|
||||
maxHeight={400}
|
||||
aria-describedby=""
|
||||
/>
|
||||
@@ -102,8 +105,8 @@ export function PreviewError(): React.JSX.Element {
|
||||
<FunGifPreview
|
||||
src={null}
|
||||
state={error == null ? LoadingState.Loading : LoadingState.LoadFailed}
|
||||
width={498}
|
||||
height={376}
|
||||
width={MOCK_GIF_WIDTH}
|
||||
height={MOCK_GIF_HEIGHT}
|
||||
maxHeight={400}
|
||||
aria-describedby=""
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<FunPicker {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<FunStickerPicker {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
|
||||
@@ -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<GifType>;
|
||||
}>;
|
||||
|
||||
export async function fetchGifsFeatured(
|
||||
limit: number,
|
||||
cursor: TenorNextCursor | null,
|
||||
signal?: AbortSignal
|
||||
): Promise<GifsPaginated> {
|
||||
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<GifsPaginated> {
|
||||
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 };
|
||||
}
|
||||
197
ts/components/fun/data/giphy.preload.ts
Normal file
197
ts/components/fun/data/giphy.preload.ts
Normal file
@@ -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<typeof GiphyPaginationSchema>;
|
||||
export type GiphyImages = z.infer<typeof GiphyImagesSchema>;
|
||||
export type GiphyGif = z.infer<typeof GiphyGifSchema>;
|
||||
export type GiphyResults = z.infer<typeof GiphyResultsSchema>;
|
||||
|
||||
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<PaginatedGifResults> {
|
||||
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<PaginatedGifResults> {
|
||||
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<Blob> {
|
||||
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);
|
||||
}
|
||||
@@ -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<TenorCursor>(
|
||||
(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<TenorSearchFilter>;
|
||||
media_filter?: ReadonlyArray<TenorContentFormat>;
|
||||
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<typeof TenorResponseCategorySchema>;
|
||||
export type TenorResponseMedia = z.infer<typeof TenorResponseMediaSchema>;
|
||||
export type TenorResponseResult = z.infer<typeof TenorResponseResultSchema>;
|
||||
|
||||
export type TenorPaginatedResponse<T> = Readonly<{
|
||||
next: TenorCursor;
|
||||
results: ReadonlyArray<T>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Endpoints
|
||||
*/
|
||||
|
||||
type TenorEndpoints = Readonly<{
|
||||
'v2/search': {
|
||||
params: Simplify<
|
||||
TenorApiParams &
|
||||
TenorLocalizationParams &
|
||||
TenorSearchFilterParams &
|
||||
TenorContentFilterParams &
|
||||
TenorPaginationParams &
|
||||
Readonly<{
|
||||
q: string;
|
||||
random?: boolean;
|
||||
}>
|
||||
>;
|
||||
response: TenorPaginatedResponse<TenorResponseResult>;
|
||||
};
|
||||
'v2/featured': {
|
||||
params: Simplify<
|
||||
TenorApiParams &
|
||||
TenorLocalizationParams &
|
||||
TenorSearchFilterParams &
|
||||
TenorContentFilterParams &
|
||||
TenorPaginationParams
|
||||
>;
|
||||
response: TenorPaginatedResponse<TenorResponseResult>;
|
||||
};
|
||||
'v2/categories': {
|
||||
params: Simplify<
|
||||
TenorApiParams &
|
||||
TenorLocalizationParams &
|
||||
TenorContentFilterParams & {
|
||||
type: 'featured' | 'trending';
|
||||
}
|
||||
>;
|
||||
response: {
|
||||
tags: ReadonlyArray<TenorResponseCategory>;
|
||||
};
|
||||
};
|
||||
// ignored
|
||||
// 'v2/search_suggestions'
|
||||
// 'v2/autocomplete'
|
||||
// 'v2/trending_terms'
|
||||
// 'v2/registershare': {},
|
||||
// 'v2/posts': {},
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Response Schemas
|
||||
*/
|
||||
|
||||
type ResponseSchemaMapType = Readonly<{
|
||||
[Path in keyof TenorEndpoints]: z.ZodSchema<TenorEndpoints[Path]['response']>;
|
||||
}>;
|
||||
|
||||
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 extends keyof TenorEndpoints>(
|
||||
path: Path,
|
||||
params: Omit<TenorEndpoints[Path]['params'], 'key'>,
|
||||
signal?: AbortSignal
|
||||
): Promise<TenorEndpoints[Path]['response']> {
|
||||
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<Blob> {
|
||||
return fetchInSegments(tenorCdnUrl, fetchBytesViaProxy, signal);
|
||||
}
|
||||
@@ -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<EmojiParentKey> = [
|
||||
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,
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -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<string, Blob>({
|
||||
const FunGifBlobLiveCache = new WeakMap<GifMediaType, Blob>();
|
||||
|
||||
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<GifType>;
|
||||
}>;
|
||||
|
||||
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<HTMLDivElement>(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')}
|
||||
/>
|
||||
</FunPanelHeader>
|
||||
{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({
|
||||
</FunLightboxProvider>
|
||||
)}
|
||||
</FunScroller>
|
||||
<AnimatePresence initial={false}>
|
||||
{!didScroll && (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className={tw(
|
||||
'absolute bottom-1',
|
||||
// eslint-disable-next-line better-tailwindcss/no-restricted-classes
|
||||
'left-1/2 -translate-x-1/2',
|
||||
'rounded-full bg-[black]/70 px-4.5 py-1.5',
|
||||
'pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<img
|
||||
alt={i18n(
|
||||
'icu:FunPanelGifs__GiphyAttribution__AccessibilityLabel'
|
||||
)}
|
||||
src="images/giphy-attribution.png"
|
||||
width="160.25"
|
||||
height="17.75"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</FunPanelBody>
|
||||
</FunPanel>
|
||||
);
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -1324,7 +1324,7 @@ type WritableInterface = {
|
||||
updateEmojiUsage: (shortName: string, timeUsed?: number) => void;
|
||||
|
||||
addRecentGif: (gif: GifType, lastUsedAt: number, maxRecents: number) => void;
|
||||
removeRecentGif: (gif: Pick<GifType, 'id'>) => void;
|
||||
removeRecentGif: (gif: GifType['id']) => void;
|
||||
|
||||
updateOrCreateBadges(badges: ReadonlyArray<BadgeType>): void;
|
||||
badgeImageFileDownloaded(url: string, localPath: string): void;
|
||||
|
||||
@@ -7587,10 +7587,10 @@ function addRecentGif(
|
||||
})();
|
||||
}
|
||||
|
||||
function removeRecentGif(db: WritableDB, gif: Pick<GifType, 'id'>): 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);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export type GifsRecentGifsAdd = ReadonlyDeep<{
|
||||
|
||||
export type GifsRecentGifsRemove = ReadonlyDeep<{
|
||||
type: typeof GIFS_RECENT_GIFS_REMOVE;
|
||||
payload: Pick<GifType, 'id'>;
|
||||
payload: { gifId: GifType['id'] };
|
||||
}>;
|
||||
|
||||
type GifsActionType = ReadonlyDeep<GifsRecentGifsAdd | GifsRecentGifsRemove>;
|
||||
@@ -62,11 +62,11 @@ function onAddRecentGif(
|
||||
}
|
||||
|
||||
function onRemoveRecentGif(
|
||||
payload: Pick<GifType, 'id'>
|
||||
gifId: GifType['id']
|
||||
): ThunkAction<void, unknown, unknown, GifsRecentGifsRemove> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
44
ts/util/gifCdnUrls.dom.ts
Normal file
44
ts/util/gifCdnUrls.dom.ts
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user