Skip to content

Commit 628af20

Browse files
committed
feat(gifs): add configurable gif provider and upload sent gifs
1 parent 63ab063 commit 628af20

17 files changed

Lines changed: 695 additions & 244 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
default: minor
3+
---
4+
5+
Add a configurable gif provider (Tenor, Giphy or Klipy), selectable in settings, and send picked gifs as uploaded images again

config.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@
4646
},
4747

4848
"gifs": {
49-
"klipyApiKey": "IfeIBlDMvq0av2BcKPDuxwRqbnYRbS90yNqFHEkK2Ja207tkR5nssh3NIlJRCr76"
49+
"provider": "tenor",
50+
"tenorApiKey": "AIzaSyCZt6SSh5VgVPzD9fhyzG1DprdPRhtoaR4",
51+
"klipyApiKey": "pmpZyPifwSulBfELHCbpaOllUfgsjqt9yeImc2XWIcHSWjnAUBw9oueRf4kD5r25",
52+
"giphyApiKey": "Gc7131jiJuvI7IdN0HZ1D7nh0ow5BU6g"
5053
}
5154
}

src/app/components/emoji-board/EmojiBoard.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ import { useGifSearch } from './useGifSearch';
6464
import { useFavoriteGifs } from '$hooks/useFavoriteGifs';
6565
import * as css from './components/styles.css';
6666
import { useMobileSheetClose } from '$components/MobileSwipeDownModal';
67-
import { isAllowedKlipyMediaUrl } from '$utils/externalGif';
67+
import { isAllowedGifMediaUrl } from '$utils/gifProviders';
6868

6969
const RECENT_GROUP_ID = 'recent_group';
7070
const SEARCH_GROUP_ID = 'search_group';
@@ -176,8 +176,8 @@ const useItemRenderer = (tab: EmojiBoardTab, saveStickerEmojiBandwidth: boolean)
176176

177177
const previewUrl = gif.preview_url;
178178
const gifUrl =
179-
(previewUrl && isAllowedKlipyMediaUrl(previewUrl) ? previewUrl : undefined) ??
180-
(isAllowedKlipyMediaUrl(gif.mediaUrl)
179+
(previewUrl && isAllowedGifMediaUrl(previewUrl) ? previewUrl : undefined) ??
180+
(isAllowedGifMediaUrl(gif.mediaUrl)
181181
? gif.mediaUrl
182182
: gif.mediaUrl.startsWith('mxc://')
183183
? (mxcUrlToHttp(mx, gif.mediaUrl, useAuthentication) ?? '')

src/app/components/emoji-board/useGifSearch.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const { fetchMock } = vi.hoisted(() => ({
88

99
vi.mock('$utils/fetch', () => ({ fetch: fetchMock }));
1010
vi.mock('$hooks/useClientConfig', () => ({
11-
useClientConfig: () => ({ gifs: { klipyApiKey: 'test-key' } }),
11+
useClientConfig: () => ({ gifs: { provider: 'klipy', klipyApiKey: 'test-key' } }),
1212
}));
1313

1414
type Deferred<T> = {

src/app/components/emoji-board/useGifSearch.ts

Lines changed: 11 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -2,55 +2,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
22
import type { AsyncSearchHandler } from '$utils/AsyncSearch';
33
import { fetch } from '$utils/fetch';
44
import { useClientConfig } from '$hooks/useClientConfig';
5+
import { getGifProvider } from '$utils/gifProviders';
6+
import { useSetting } from '$state/hooks/settings';
7+
import { settingsAtom } from '$state/settings';
58
import type { GifData } from './types';
69

7-
const SIZE_LIMIT = 3 * 1024 * 1024;
8-
9-
type KlipyFile = {
10-
url?: string;
11-
width?: number;
12-
height?: number;
13-
size?: number;
14-
};
15-
16-
/** Klipy serves each size as a bag of encodings; we only ever want the gif. */
17-
type KlipyFormat = { gif?: KlipyFile };
18-
19-
type KlipyResult = {
20-
id?: string | number;
21-
slug?: string;
22-
title?: string;
23-
file?: Partial<Record<'xs' | 'sm' | 'md' | 'hd', KlipyFormat>>;
24-
};
25-
26-
type KlipySearchResponse = { data?: { data?: KlipyResult[] } };
27-
28-
const parseKlipyResult = (klipyResult: KlipyResult): GifData => {
29-
const formats = klipyResult.file ?? {};
30-
const preview = formats.xs?.gif ?? formats.sm?.gif ?? formats.md?.gif;
31-
32-
// Full resolution, dropped to medium when it would be too large to send.
33-
let fullRes = formats.hd?.gif;
34-
if (fullRes?.size && fullRes.size > SIZE_LIMIT && formats.md?.gif) {
35-
fullRes = formats.md.gif;
36-
}
37-
fullRes ??= formats.md?.gif ?? preview;
38-
39-
return {
40-
id: klipyResult.id === undefined ? '' : String(klipyResult.id),
41-
title: klipyResult.title || 'GIF',
42-
shareUrl: klipyResult.slug
43-
? `https://klipy.com/gifs/${encodeURIComponent(klipyResult.slug)}`
44-
: (fullRes?.url ?? ''),
45-
mediaUrl: fullRes?.url ?? '',
46-
preview_url: preview?.url ?? fullRes?.url ?? '',
47-
width: fullRes?.width ?? preview?.width ?? 0,
48-
height: fullRes?.height ?? preview?.height ?? 0,
49-
size: fullRes?.size ?? preview?.size ?? 0,
50-
mimetype: 'image/gif',
51-
};
52-
};
53-
5410
export function useGifSearch(
5511
favoriteGifs: GifData[],
5612
showGifPicker: boolean,
@@ -60,7 +16,9 @@ export function useGifSearch(
6016
const [loading, setLoading] = useState(false);
6117
const [error, setError] = useState<string | null>(null);
6218
const clientConfig = useClientConfig();
63-
const klipyApiKey = clientConfig.gifs?.klipyApiKey ?? '';
19+
const [gifProvider] = useSetting(settingsAtom, 'gifProvider');
20+
const provider = getGifProvider(clientConfig.gifs, gifProvider);
21+
const apiKey = provider.getApiKey(clientConfig.gifs ?? {}) ?? '';
6422
const requestGenerationRef = useRef(0);
6523
const abortControllerRef = useRef<AbortController | undefined>(undefined);
6624
const mountedRef = useRef(true);
@@ -96,19 +54,14 @@ export function useGifSearch(
9654
gifSearch(trimmedQuery);
9755

9856
try {
99-
const url = new URL('https://api.klipy.com');
100-
url.pathname = `/api/v1/${klipyApiKey}/gifs/search`;
101-
url.searchParams.set('q', trimmedQuery);
102-
url.searchParams.set('per_page', '50'); // TODO: infinite scroll?
103-
104-
const response = await fetch(url.toString(), { signal: controller.signal });
57+
const url = provider.buildSearchUrl(apiKey, trimmedQuery);
58+
const response = await fetch(url, { signal: controller.signal });
10559

10660
if (response.status === 200) {
107-
const data = (await response.json()) as KlipySearchResponse;
108-
const results = data.data?.data;
61+
const results = provider.parseResults(await response.json());
10962

11063
if (generation === requestGenerationRef.current && mountedRef.current) {
111-
setSearchResults(results ? results.map(parseKlipyResult) : []);
64+
setSearchResults(results);
11265
}
11366
} else {
11467
throw new Error(`HTTP ${response.status}`);
@@ -125,7 +78,7 @@ export function useGifSearch(
12578
}
12679
}
12780
},
128-
[cancelRequest, klipyApiKey, gifSearch]
81+
[cancelRequest, provider, apiKey, gifSearch]
12982
);
13083

13184
useEffect(() => {

src/app/features/room/RoomInput.test.tsx

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -255,16 +255,11 @@ vi.mock('$components/upload-card', () => ({
255255
}));
256256
vi.mock('./msgContent', async (importOriginal) => ({
257257
...(await importOriginal<typeof MsgContentModule>()),
258-
getGifMsgContent: (gif: { title: string }) => ({
259-
msgtype: 'm.text',
260-
body: gif.title,
261-
'pet.plz.gif': {
262-
v: 1,
263-
provider: 'klipy',
264-
media_url: 'https://static.klipy.com/ii/gif.gif',
265-
w: 320,
266-
h: 240,
267-
},
258+
getGifMsgContent: (_mx: unknown, gif: { title: string }) => ({
259+
msgtype: 'm.image',
260+
body: `${gif.title}.gif`,
261+
url: 'mxc://server/gif',
262+
info: { w: 320, h: 240, mimetype: 'image/gif' },
268263
}),
269264
}));
270265
vi.mock('$components/attachment-sheet/AttachmentSheet', () => ({ AttachmentSheet: () => null }));
@@ -280,8 +275,8 @@ vi.mock('$components/emoji-board', () => ({
280275
onGifSelect({
281276
id: 'gif-id',
282277
title: 'gif',
283-
shareUrl: 'https://klipy.com/gif/gif-id',
284-
mediaUrl: 'https://static.klipy.com/ii/gif.gif',
278+
shareUrl: 'https://tenor.com/view/gif-id',
279+
mediaUrl: 'https://media.tenor.com/gif-id/gif.gif',
285280
width: 320,
286281
height: 240,
287282
mimetype: 'image/gif',

src/app/features/room/RoomInput.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,6 @@ import { AttachmentContent } from '$components/attachment-sheet/AttachmentConten
185185
import { MobileSwipeDownModal } from '$components/MobileSwipeDownModal';
186186
import { SchedulePickerDialog } from './schedule-send';
187187
import * as css from './schedule-send/SchedulePickerDialog.css';
188-
import { getKlipyGifBlurhash } from '$utils/klipy';
189188
import {
190189
getAudioMsgContent,
191190
getFileMsgContent,
@@ -1901,8 +1900,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
19011900
const submission = takeSubmission({ clearEditor: false });
19021901
return composerControllerRef.current?.enqueue(async (isLive) => {
19031902
try {
1904-
const blurhash = gif.blurhash ?? (await getKlipyGifBlurhash(gif));
1905-
const content = getGifMsgContent(blurhash ? { ...gif, blurhash } : gif, spoiler);
1903+
const content = await getGifMsgContent(mx, gif, {
1904+
encrypt: room.hasEncryptionStateEvent(),
1905+
spoiler,
1906+
});
19061907
if (!content) throw new Error('Unsendable GIF content');
19071908

19081909
const sent = await handleSendContents({ contents: [content], submission, isLive });

src/app/features/room/msgContent.test.ts

Lines changed: 86 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,30 @@
1-
import { describe, expect, it, vi } from 'vitest';
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import type * as MatrixUtils from '$utils/matrix';
23
import { MsgType, type MatrixClient } from '$types/matrix-sdk';
34
import type { TUploadItem } from '$state/room/roomInputDrafts';
45
import { TGS_MIMETYPE } from '$utils/mimeTypes';
6+
import { MATRIX_UNSTABLE_SPOILER_PROPERTY_NAME } from '$unstable/prefixes';
57
import { getGalleryItemContent, getGifMsgContent, getImageMsgContent } from './msgContent';
68

9+
const { fetchMock, uploadMock, encryptFileMock } = vi.hoisted(() => ({
10+
fetchMock: vi.fn<(url: string) => Promise<Response>>(),
11+
uploadMock: vi.fn<(mx: unknown, file: File) => Promise<{ content_uri?: string }>>(),
12+
encryptFileMock: vi.fn<(file: File) => Promise<{ file: File; encInfo: object }>>(),
13+
}));
14+
15+
vi.mock('$utils/fetch', () => ({ fetch: fetchMock }));
16+
vi.mock('$utils/matrix', async (importOriginal) => ({
17+
...(await importOriginal<typeof MatrixUtils>()),
18+
uploadContentToServer: uploadMock,
19+
encryptFile: encryptFileMock,
20+
}));
21+
22+
beforeEach(() => {
23+
fetchMock.mockReset();
24+
uploadMock.mockReset();
25+
encryptFileMock.mockReset();
26+
});
27+
728
vi.mock('$utils/dom', () => ({
829
getImageFileUrl: vi.fn<(file: File | Blob) => string>(() => 'blob:test'),
930
loadImageElement: vi
@@ -56,54 +77,75 @@ describe('TGS message content', () => {
5677
});
5778
});
5879

59-
describe('KLIPY message content', () => {
60-
it('sends a direct-link text event without fetching media', () => {
61-
expect(
62-
getGifMsgContent({
63-
id: 'gif-id',
64-
title: 'Reaction',
65-
shareUrl: 'https://klipy.com/gif/gif-id',
66-
mediaUrl: 'https://static.klipy.com/ii/reaction.gif',
67-
width: 480,
68-
height: 270,
69-
mimetype: 'image/gif',
70-
})
71-
).toEqual({
72-
msgtype: MsgType.Text,
73-
body: 'https://klipy.com/gif/gif-id',
74-
'pet.plz.gif': {
75-
v: 1,
76-
provider: 'klipy',
77-
media_url: 'https://static.klipy.com/ii/reaction.gif',
78-
w: 480,
79-
h: 270,
80-
mimetype: 'image/gif',
81-
id: 'gif-id',
82-
title: 'Reaction',
83-
},
80+
describe('GIF message content', () => {
81+
const searchResult = {
82+
id: 'gif-id',
83+
title: 'Reaction',
84+
shareUrl: 'https://tenor.com/view/gif-id',
85+
mediaUrl: 'https://media.tenor.com/gif-id/reaction.gif',
86+
width: 480,
87+
height: 270,
88+
mimetype: 'image/gif',
89+
};
90+
91+
it('uploads the gif and sends it as an image event', async () => {
92+
fetchMock.mockResolvedValue(new Response('gif-bytes', { status: 200 }));
93+
uploadMock.mockResolvedValue({ content_uri: 'mxc://server/uploaded' });
94+
95+
const content = await getGifMsgContent({} as MatrixClient, searchResult, { encrypt: false });
96+
97+
expect(fetchMock).toHaveBeenCalledWith(searchResult.mediaUrl);
98+
expect(encryptFileMock).not.toHaveBeenCalled();
99+
expect(content).toEqual({
100+
msgtype: MsgType.Image,
101+
body: 'Reaction.gif',
102+
url: 'mxc://server/uploaded',
103+
info: { w: 480, h: 270, mimetype: 'image/gif', size: 9 },
84104
});
85105
});
86106

87-
it('keeps Matrix MXC favorites on the standard image path', () => {
88-
expect(
89-
getGifMsgContent({
90-
id: 'matrix-gif',
91-
title: 'Favorite',
92-
shareUrl: 'mxc://matrix.example/media-id',
93-
mediaUrl: 'mxc://matrix.example/media-id',
94-
width: 320,
95-
height: 240,
96-
mimetype: 'image/gif',
97-
})
98-
).toMatchObject({
107+
it('encrypts the upload for encrypted rooms', async () => {
108+
fetchMock.mockResolvedValue(new Response('gif-bytes', { status: 200 }));
109+
uploadMock.mockResolvedValue({ content_uri: 'mxc://server/encrypted' });
110+
encryptFileMock.mockImplementation(async (file: File) => ({
111+
file,
112+
encInfo: { key: { k: 'secret' } },
113+
}));
114+
115+
const content = await getGifMsgContent({} as MatrixClient, searchResult, {
116+
encrypt: true,
117+
spoiler: true,
118+
});
119+
120+
expect(content?.url).toBeUndefined();
121+
expect(content?.file).toEqual({ key: { k: 'secret' }, url: 'mxc://server/encrypted' });
122+
expect(content?.[MATRIX_UNSTABLE_SPOILER_PROPERTY_NAME]).toBe(true);
123+
});
124+
125+
it('sends favorited homeserver gifs without re-uploading', async () => {
126+
const content = await getGifMsgContent(
127+
{} as MatrixClient,
128+
{ ...searchResult, mediaUrl: 'mxc://matrix.example/media-id' },
129+
{ encrypt: false }
130+
);
131+
132+
expect(fetchMock).not.toHaveBeenCalled();
133+
expect(content).toMatchObject({
99134
msgtype: MsgType.Image,
100-
body: 'Favorite',
135+
body: 'Reaction.gif',
101136
url: 'mxc://matrix.example/media-id',
102-
info: {
103-
w: 320,
104-
h: 240,
105-
mimetype: 'image/gif',
106-
},
137+
info: { w: 480, h: 270, mimetype: 'image/gif' },
107138
});
108139
});
140+
141+
it('refuses media URLs outside the configured providers', async () => {
142+
await expect(
143+
getGifMsgContent(
144+
{} as MatrixClient,
145+
{ ...searchResult, mediaUrl: 'https://media.tenor.com.attacker.example/a.gif' },
146+
{ encrypt: false }
147+
)
148+
).resolves.toBeUndefined();
149+
expect(fetchMock).not.toHaveBeenCalled();
150+
});
109151
});

0 commit comments

Comments
 (0)