Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/configurable-gif-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Add a configurable gif provider (Tenor, Giphy or Klipy), selectable in settings, and send picked gifs as uploaded images again
5 changes: 4 additions & 1 deletion config.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@
},

"gifs": {
"klipyApiKey": "IfeIBlDMvq0av2BcKPDuxwRqbnYRbS90yNqFHEkK2Ja207tkR5nssh3NIlJRCr76"
"provider": "tenor",
"tenorApiKey": "AIzaSyCZt6SSh5VgVPzD9fhyzG1DprdPRhtoaR4",
"klipyApiKey": "pmpZyPifwSulBfELHCbpaOllUfgsjqt9yeImc2XWIcHSWjnAUBw9oueRf4kD5r25",
"giphyApiKey": "Gc7131jiJuvI7IdN0HZ1D7nh0ow5BU6g"
}
}
6 changes: 3 additions & 3 deletions src/app/components/emoji-board/EmojiBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ import { useGifSearch } from './useGifSearch';
import { useFavoriteGifs } from '$hooks/useFavoriteGifs';
import * as css from './components/styles.css';
import { useMobileSheetClose } from '$components/MobileSwipeDownModal';
import { isAllowedKlipyMediaUrl } from '$utils/externalGif';
import { isAllowedGifMediaUrl } from '$utils/gifProviders';

const RECENT_GROUP_ID = 'recent_group';
const SEARCH_GROUP_ID = 'search_group';
Expand Down Expand Up @@ -176,8 +176,8 @@ const useItemRenderer = (tab: EmojiBoardTab, saveStickerEmojiBandwidth: boolean)

const previewUrl = gif.preview_url;
const gifUrl =
(previewUrl && isAllowedKlipyMediaUrl(previewUrl) ? previewUrl : undefined) ??
(isAllowedKlipyMediaUrl(gif.mediaUrl)
(previewUrl && isAllowedGifMediaUrl(previewUrl) ? previewUrl : undefined) ??
(isAllowedGifMediaUrl(gif.mediaUrl)
? gif.mediaUrl
: gif.mediaUrl.startsWith('mxc://')
? (mxcUrlToHttp(mx, gif.mediaUrl, useAuthentication) ?? '')
Expand Down
9 changes: 8 additions & 1 deletion src/app/components/emoji-board/components/SearchInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { useRef } from 'react';
import { Input, Chip, Text } from 'folds';
import { isMobileOrTablet } from '$utils/platform';
import { ArrowRight, sizedIcon, MagnifyingGlass } from '$components/icons/phosphor';
import { useClientConfig } from '$hooks/useClientConfig';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
import { getGifProvider } from '$utils/gifProviders';
import { EmojiBoardTab } from '../types';

type SearchInputProps = {
Expand All @@ -22,6 +26,9 @@ export function SearchInput({
tab,
}: SearchInputProps) {
const inputRef = useRef<HTMLInputElement>(null);
const clientConfig = useClientConfig();
const [gifProviderSetting] = useSetting(settingsAtom, 'gifProvider');
const gifProvider = getGifProvider(clientConfig.gifs, gifProviderSetting);

const handleReact = () => {
const textEmoji = inputRef.current?.value.trim();
Expand All @@ -36,7 +43,7 @@ export function SearchInput({
size="400"
placeholder={
tab === EmojiBoardTab.Gif
? 'Search KLIPY'
? `Search ${gifProvider.label}`
: allowTextCustomEmoji
? 'Search or Text Reaction '
: 'Search'
Expand Down
2 changes: 1 addition & 1 deletion src/app/components/emoji-board/useGifSearch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const { fetchMock } = vi.hoisted(() => ({

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

type Deferred<T> = {
Expand Down
69 changes: 11 additions & 58 deletions src/app/components/emoji-board/useGifSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,55 +2,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { AsyncSearchHandler } from '$utils/AsyncSearch';
import { fetch } from '$utils/fetch';
import { useClientConfig } from '$hooks/useClientConfig';
import { getGifProvider } from '$utils/gifProviders';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
import type { GifData } from './types';

const SIZE_LIMIT = 3 * 1024 * 1024;

type KlipyFile = {
url?: string;
width?: number;
height?: number;
size?: number;
};

/** Klipy serves each size as a bag of encodings; we only ever want the gif. */
type KlipyFormat = { gif?: KlipyFile };

type KlipyResult = {
id?: string | number;
slug?: string;
title?: string;
file?: Partial<Record<'xs' | 'sm' | 'md' | 'hd', KlipyFormat>>;
};

type KlipySearchResponse = { data?: { data?: KlipyResult[] } };

const parseKlipyResult = (klipyResult: KlipyResult): GifData => {
const formats = klipyResult.file ?? {};
const preview = formats.xs?.gif ?? formats.sm?.gif ?? formats.md?.gif;

// Full resolution, dropped to medium when it would be too large to send.
let fullRes = formats.hd?.gif;
if (fullRes?.size && fullRes.size > SIZE_LIMIT && formats.md?.gif) {
fullRes = formats.md.gif;
}
fullRes ??= formats.md?.gif ?? preview;

return {
id: klipyResult.id === undefined ? '' : String(klipyResult.id),
title: klipyResult.title || 'GIF',
shareUrl: klipyResult.slug
? `https://klipy.com/gifs/${encodeURIComponent(klipyResult.slug)}`
: (fullRes?.url ?? ''),
mediaUrl: fullRes?.url ?? '',
preview_url: preview?.url ?? fullRes?.url ?? '',
width: fullRes?.width ?? preview?.width ?? 0,
height: fullRes?.height ?? preview?.height ?? 0,
size: fullRes?.size ?? preview?.size ?? 0,
mimetype: 'image/gif',
};
};

export function useGifSearch(
favoriteGifs: GifData[],
showGifPicker: boolean,
Expand All @@ -60,7 +16,9 @@ export function useGifSearch(
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const clientConfig = useClientConfig();
const klipyApiKey = clientConfig.gifs?.klipyApiKey ?? '';
const [gifProvider] = useSetting(settingsAtom, 'gifProvider');
const provider = getGifProvider(clientConfig.gifs, gifProvider);
const apiKey = provider.getApiKey(clientConfig.gifs ?? {}) ?? '';
const requestGenerationRef = useRef(0);
const abortControllerRef = useRef<AbortController | undefined>(undefined);
const mountedRef = useRef(true);
Expand Down Expand Up @@ -96,19 +54,14 @@ export function useGifSearch(
gifSearch(trimmedQuery);

try {
const url = new URL('https://api.klipy.com');
url.pathname = `/api/v1/${klipyApiKey}/gifs/search`;
url.searchParams.set('q', trimmedQuery);
url.searchParams.set('per_page', '50'); // TODO: infinite scroll?

const response = await fetch(url.toString(), { signal: controller.signal });
const url = provider.buildSearchUrl(apiKey, trimmedQuery);
const response = await fetch(url, { signal: controller.signal });

if (response.status === 200) {
const data = (await response.json()) as KlipySearchResponse;
const results = data.data?.data;
const results = provider.parseResults(await response.json());

if (generation === requestGenerationRef.current && mountedRef.current) {
setSearchResults(results ? results.map(parseKlipyResult) : []);
setSearchResults(results);
}
} else {
throw new Error(`HTTP ${response.status}`);
Expand All @@ -125,7 +78,7 @@ export function useGifSearch(
}
}
},
[cancelRequest, klipyApiKey, gifSearch]
[cancelRequest, provider, apiKey, gifSearch]
);

useEffect(() => {
Expand Down
19 changes: 7 additions & 12 deletions src/app/features/room/RoomInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -255,16 +255,11 @@ vi.mock('$components/upload-card', () => ({
}));
vi.mock('./msgContent', async (importOriginal) => ({
...(await importOriginal<typeof MsgContentModule>()),
getGifMsgContent: (gif: { title: string }) => ({
msgtype: 'm.text',
body: gif.title,
'pet.plz.gif': {
v: 1,
provider: 'klipy',
media_url: 'https://static.klipy.com/ii/gif.gif',
w: 320,
h: 240,
},
getGifMsgContent: (_mx: unknown, gif: { title: string }) => ({
msgtype: 'm.image',
body: `${gif.title}.gif`,
url: 'mxc://server/gif',
info: { w: 320, h: 240, mimetype: 'image/gif' },
}),
}));
vi.mock('$components/attachment-sheet/AttachmentSheet', () => ({ AttachmentSheet: () => null }));
Expand All @@ -280,8 +275,8 @@ vi.mock('$components/emoji-board', () => ({
onGifSelect({
id: 'gif-id',
title: 'gif',
shareUrl: 'https://klipy.com/gif/gif-id',
mediaUrl: 'https://static.klipy.com/ii/gif.gif',
shareUrl: 'https://tenor.com/view/gif-id',
mediaUrl: 'https://media.tenor.com/gif-id/gif.gif',
width: 320,
height: 240,
mimetype: 'image/gif',
Expand Down
7 changes: 4 additions & 3 deletions src/app/features/room/RoomInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,6 @@ import { AttachmentContent } from '$components/attachment-sheet/AttachmentConten
import { MobileSwipeDownModal } from '$components/MobileSwipeDownModal';
import { SchedulePickerDialog } from './schedule-send';
import * as css from './schedule-send/SchedulePickerDialog.css';
import { getKlipyGifBlurhash } from '$utils/klipy';
import {
getAudioMsgContent,
getFileMsgContent,
Expand Down Expand Up @@ -1901,8 +1900,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const submission = takeSubmission({ clearEditor: false });
return composerControllerRef.current?.enqueue(async (isLive) => {
try {
const blurhash = gif.blurhash ?? (await getKlipyGifBlurhash(gif));
const content = getGifMsgContent(blurhash ? { ...gif, blurhash } : gif, spoiler);
const content = await getGifMsgContent(mx, gif, {
encrypt: room.hasEncryptionStateEvent(),
spoiler,
});
if (!content) throw new Error('Unsendable GIF content');

const sent = await handleSendContents({ contents: [content], submission, isLive });
Expand Down
130 changes: 86 additions & 44 deletions src/app/features/room/msgContent.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,30 @@
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type * as MatrixUtils from '$utils/matrix';
import { MsgType, type MatrixClient } from '$types/matrix-sdk';
import type { TUploadItem } from '$state/room/roomInputDrafts';
import { TGS_MIMETYPE } from '$utils/mimeTypes';
import { MATRIX_UNSTABLE_SPOILER_PROPERTY_NAME } from '$unstable/prefixes';
import { getGalleryItemContent, getGifMsgContent, getImageMsgContent } from './msgContent';

const { fetchMock, uploadMock, encryptFileMock } = vi.hoisted(() => ({
fetchMock: vi.fn<(url: string) => Promise<Response>>(),
uploadMock: vi.fn<(mx: unknown, file: File) => Promise<{ content_uri?: string }>>(),
encryptFileMock: vi.fn<(file: File) => Promise<{ file: File; encInfo: object }>>(),
}));

vi.mock('$utils/fetch', () => ({ fetch: fetchMock }));
vi.mock('$utils/matrix', async (importOriginal) => ({
...(await importOriginal<typeof MatrixUtils>()),
uploadContentToServer: uploadMock,
encryptFile: encryptFileMock,
}));

beforeEach(() => {
fetchMock.mockReset();
uploadMock.mockReset();
encryptFileMock.mockReset();
});

vi.mock('$utils/dom', () => ({
getImageFileUrl: vi.fn<(file: File | Blob) => string>(() => 'blob:test'),
loadImageElement: vi
Expand Down Expand Up @@ -56,54 +77,75 @@ describe('TGS message content', () => {
});
});

describe('KLIPY message content', () => {
it('sends a direct-link text event without fetching media', () => {
expect(
getGifMsgContent({
id: 'gif-id',
title: 'Reaction',
shareUrl: 'https://klipy.com/gif/gif-id',
mediaUrl: 'https://static.klipy.com/ii/reaction.gif',
width: 480,
height: 270,
mimetype: 'image/gif',
})
).toEqual({
msgtype: MsgType.Text,
body: 'https://klipy.com/gif/gif-id',
'pet.plz.gif': {
v: 1,
provider: 'klipy',
media_url: 'https://static.klipy.com/ii/reaction.gif',
w: 480,
h: 270,
mimetype: 'image/gif',
id: 'gif-id',
title: 'Reaction',
},
describe('GIF message content', () => {
const searchResult = {
id: 'gif-id',
title: 'Reaction',
shareUrl: 'https://tenor.com/view/gif-id',
mediaUrl: 'https://media.tenor.com/gif-id/reaction.gif',
width: 480,
height: 270,
mimetype: 'image/gif',
};

it('uploads the gif and sends it as an image event', async () => {
fetchMock.mockResolvedValue(new Response('gif-bytes', { status: 200 }));
uploadMock.mockResolvedValue({ content_uri: 'mxc://server/uploaded' });

const content = await getGifMsgContent({} as MatrixClient, searchResult, { encrypt: false });

expect(fetchMock).toHaveBeenCalledWith(searchResult.mediaUrl);
expect(encryptFileMock).not.toHaveBeenCalled();
expect(content).toEqual({
msgtype: MsgType.Image,
body: 'Reaction.gif',
url: 'mxc://server/uploaded',
info: { w: 480, h: 270, mimetype: 'image/gif', size: 9 },
});
});

it('keeps Matrix MXC favorites on the standard image path', () => {
expect(
getGifMsgContent({
id: 'matrix-gif',
title: 'Favorite',
shareUrl: 'mxc://matrix.example/media-id',
mediaUrl: 'mxc://matrix.example/media-id',
width: 320,
height: 240,
mimetype: 'image/gif',
})
).toMatchObject({
it('encrypts the upload for encrypted rooms', async () => {
fetchMock.mockResolvedValue(new Response('gif-bytes', { status: 200 }));
uploadMock.mockResolvedValue({ content_uri: 'mxc://server/encrypted' });
encryptFileMock.mockImplementation(async (file: File) => ({
file,
encInfo: { key: { k: 'secret' } },
}));

const content = await getGifMsgContent({} as MatrixClient, searchResult, {
encrypt: true,
spoiler: true,
});

expect(content?.url).toBeUndefined();
expect(content?.file).toEqual({ key: { k: 'secret' }, url: 'mxc://server/encrypted' });
expect(content?.[MATRIX_UNSTABLE_SPOILER_PROPERTY_NAME]).toBe(true);
});

it('sends favorited homeserver gifs without re-uploading', async () => {
const content = await getGifMsgContent(
{} as MatrixClient,
{ ...searchResult, mediaUrl: 'mxc://matrix.example/media-id' },
{ encrypt: false }
);

expect(fetchMock).not.toHaveBeenCalled();
expect(content).toMatchObject({
msgtype: MsgType.Image,
body: 'Favorite',
body: 'Reaction.gif',
url: 'mxc://matrix.example/media-id',
info: {
w: 320,
h: 240,
mimetype: 'image/gif',
},
info: { w: 480, h: 270, mimetype: 'image/gif' },
});
});

it('refuses media URLs outside the configured providers', async () => {
await expect(
getGifMsgContent(
{} as MatrixClient,
{ ...searchResult, mediaUrl: 'https://media.tenor.com.attacker.example/a.gif' },
{ encrypt: false }
)
).resolves.toBeUndefined();
expect(fetchMock).not.toHaveBeenCalled();
});
});
Loading
Loading