mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-05-04 15:15:19 +01:00
Switch from Tenor to Giphy for GIF search
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user