Add Tenor GIF search feature

This commit is contained in:
Raznov 2025-07-13 18:23:03 +02:00
parent fbd7e0a14b
commit 397e71adc3
No known key found for this signature in database
GPG key ID: 3D5C79FBC6C7E68F
5 changed files with 440 additions and 54 deletions

View file

@ -0,0 +1,4 @@
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.5 3.75C2.84315 3.75 1.5 5.09315 1.5 6.75V17.25C1.5 18.9069 2.84315 20.25 4.5 20.25H19.5C21.1569 20.25 22.5 18.9069 22.5 17.25V6.75C22.5 5.09315 21.1569 3.75 19.5 3.75H4.5ZM13.5 8.25C13.5 7.83579 13.1642 7.5 12.75 7.5C12.3358 7.5 12 7.83579 12 8.25V15.75C12 16.1642 12.3358 16.5 12.75 16.5C13.1642 16.5 13.5 16.1642 13.5 15.75V8.25ZM15 8.25C15 7.83579 15.3358 7.5 15.75 7.5H18.75C19.1642 7.5 19.5 7.83579 19.5 8.25C19.5 8.66421 19.1642 9 18.75 9H16.5V11.25H18C18.4142 11.25 18.75 11.5858 18.75 12C18.75 12.4142 18.4142 12.75 18 12.75H16.5V15.75C16.5 16.1642 16.1642 16.5 15.75 16.5C15.3358 16.5 15 16.1642 15 15.75V8.25ZM6.63565 9.77966C7.03978 9.20475 7.5033 9 7.88604 9C8.26878 9 8.7323 9.20475 9.13643 9.77966C9.37463 10.1185 9.84244 10.2001 10.1813 9.96192C10.5202 9.72372 10.6018 9.25591 10.3636 8.91704C9.73827 8.02748 8.85254 7.5 7.88604 7.5C6.91953 7.5 6.03381 8.02748 5.4085 8.91704C4.7885 9.79905 4.5 10.9173 4.5 12C4.5 13.0827 4.7885 14.201 5.4085 15.083C6.03381 15.9725 6.91953 16.5 7.88604 16.5C8.85254 16.5 9.73827 15.9725 10.3636 15.083C10.4524 14.9567 10.5 14.806 10.5 14.6517V12C10.5 11.5858 10.1642 11.25 9.75 11.25H8.25C7.83579 11.25 7.5 11.5858 7.5 12C7.5 12.4142 7.83579 12.75 8.25 12.75H9V14.3981C8.6305 14.8381 8.22634 15 7.88604 15C7.5033 15 7.03978 14.7953 6.63565 14.2203C6.22622 13.6379 6 12.8367 6 12C6 11.1633 6.22622 10.3621 6.63565 9.77966Z" />
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -134,3 +134,43 @@ export const StickerImg = style([
objectFit: 'contain',
},
]);
export const GifContainer = style({
columnCount: 3,
columnGap: toRem(8),
padding: toRem(16),
'@media': {
'(max-width: 768px)': {
columnCount: 2,
},
'(max-width: 480px)': {
columnCount: 1,
},
},
});
export const GifItem = style([
DefaultReset,
FocusOutline,
{
width: '100%',
marginBottom: toRem(8),
breakInside: 'avoid',
borderRadius: config.radii.R400,
cursor: 'pointer',
overflow: 'hidden',
display: 'block',
':hover': {
backgroundColor: color.Surface.ContainerHover,
},
},
]);
export const GifImg = style({
width: '100%',
height: '100%',
objectFit: 'cover',
borderRadius: config.radii.R400,
});

View file

@ -9,6 +9,7 @@ import React, {
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
Badge,
@ -32,6 +33,7 @@ import { isKeyHotkey } from 'is-hotkey';
import classNames from 'classnames';
import { MatrixClient, Room } from 'matrix-js-sdk';
import { atom, useAtomValue, useSetAtom } from 'jotai';
import cons from '../../../client/state/cons';
import * as css from './EmojiBoard.css';
import { EmojiGroupId, IEmoji, IEmojiGroup, emojiGroups, emojis } from '../../plugins/emoji';
@ -52,20 +54,34 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { ImagePack, ImageUsage, PackImageReader } from '../../plugins/custom-emoji';
import { getEmoticonSearchStr } from '../../plugins/utils';
import GifIC from '../../../../public/res/ic/outlined/gif.svg';
import RawIcon from '../../atoms/system-icons/RawIcon';
const RECENT_GROUP_ID = 'recent_group';
const SEARCH_GROUP_ID = 'search_group';
export enum EmojiBoardTab {
Emoji = 'Emoji',
Sticker = 'Sticker',
Gif = 'Gif',
}
enum EmojiType {
Emoji = 'emoji',
CustomEmoji = 'customEmoji',
Sticker = 'sticker',
Gif = 'gif',
}
export type GifData = {
id: string;
title: string;
url: string;
preview_url?: string;
width?: number;
height?: number;
};
export type EmojiItemInfo = {
type: EmojiType;
data: string;
@ -176,6 +192,18 @@ function EmojiBoardTabs({
}) {
return (
<Box gap="100">
<Badge
className={css.EmojiBoardTab}
as="button"
variant="Secondary"
fill={tab === EmojiBoardTab.Gif ? 'Solid' : 'None'}
size="500"
onClick={() => onTabChange(EmojiBoardTab.Gif)}
>
<Text as="span" size="L400">
GIF
</Text>
</Badge>
<Badge
className={css.EmojiBoardTab}
as="button"
@ -334,6 +362,40 @@ export function StickerItem({
);
}
export function GifItem({
label,
type,
data,
shortcode,
gif,
children,
}: {
label: string;
type: EmojiType;
data: string;
shortcode: string;
gif?: GifData;
children: ReactNode;
}) {
return (
<Box
as="button"
className={css.GifItem}
type="button"
alignItems="Center"
justifyContent="Center"
title={label}
aria-label={`${label} gif`}
data-emoji-type={type}
data-emoji-data={data}
data-emoji-shortcode={shortcode}
data-gif-data={gif ? JSON.stringify(gif) : undefined}
>
{children}
</Box>
);
}
function RecentEmojiSidebarStack({ onItemClick }: { onItemClick: (id: string) => void }) {
const activeGroupId = useAtomValue(activeGroupIdAtom);
@ -637,6 +699,85 @@ export const NativeEmojiGroups = memo(
)
);
export const GifGroups = memo(
({ gifs, loading, error }: { gifs: GifData[]; loading: boolean; error: string | null }) => {
if (loading) {
return (
<Box
style={{ padding: `${toRem(60)} ${config.space.S500}` }}
alignItems="Center"
justifyContent="Center"
direction="Column"
gap="300"
>
<Text>Loading GIFs...</Text>
</Box>
);
}
if (error) {
return (
<Box
style={{ padding: `${toRem(60)} ${config.space.S500}` }}
alignItems="Center"
justifyContent="Center"
direction="Column"
gap="300"
>
<Text>Error: {error}</Text>
</Box>
);
}
if (gifs.length === 0) {
return (
<Box
style={{ padding: `${toRem(60)} ${config.space.S500}` }}
alignItems="Center"
justifyContent="Center"
direction="Column"
gap="300"
>
<RawIcon size="large" src={GifIC} />
<Box direction="Inherit">
<Text align="Center">No GIFs found!</Text>
<Text priority="300" align="Center" size="T200">
Try searching for something else.
</Text>
</Box>
</Box>
);
}
return (
<Box direction="Column" gap="200">
<Text className={css.EmojiGroupLabel} size="O400">
GIFs
</Text>
<div className={css.GifContainer}>
{gifs.map((gif) => (
<GifItem
key={gif.id}
label={gif.title}
type={EmojiType.Gif}
data={gif.url}
shortcode={gif.title}
gif={gif}
>
<img
loading="lazy"
className={css.GifImg}
alt={gif.title}
src={gif.preview_url || gif.url}
/>
</GifItem>
))}
</div>
</Box>
);
}
);
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000,
matchOptions: {
@ -653,6 +794,7 @@ export function EmojiBoard({
onEmojiSelect,
onCustomEmojiSelect,
onStickerSelect,
onGifSelect,
allowTextCustomEmoji,
addToRecentEmoji = true,
}: {
@ -664,11 +806,13 @@ export function EmojiBoard({
onEmojiSelect?: (unicode: string, shortcode: string) => void;
onCustomEmojiSelect?: (mxc: string, shortcode: string) => void;
onStickerSelect?: (mxc: string, shortcode: string, label: string) => void;
onGifSelect?: (gif: GifData) => void;
allowTextCustomEmoji?: boolean;
addToRecentEmoji?: boolean;
}) {
const emojiTab = tab === EmojiBoardTab.Emoji;
const stickerTab = tab === EmojiBoardTab.Sticker;
const gifTab = tab === EmojiBoardTab.Gif;
const usage = emojiTab ? ImageUsage.Emoticon : ImageUsage.Sticker;
const setActiveGroupId = useSetAtom(activeGroupIdAtom);
@ -698,14 +842,116 @@ export function EmojiBoard({
const searchedItems = result?.items.slice(0, 100);
function useGifSearch() {
const [gifs, setGifs] = useState<GifData[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const parseTenorResult = useCallback((tenorResult: any): GifData => {
const SIZE_LIMIT = 3 * 1024 * 1024; // 3MB
const formats = tenorResult.media_formats || {};
const preview = formats.tinygif || formats.nanogif || formats.mediumgif;
// Start with full resolution GIF
let fullRes = formats.gif;
// If full res is too large and medium exists, use medium instead
if (fullRes && fullRes.size > SIZE_LIMIT && formats.mediumgif) {
fullRes = formats.mediumgif;
}
// Fallback if no suitable format found
if (!fullRes) {
fullRes = formats.mediumgif || formats.gif || preview;
}
// Get dimensions from the selected full resolution format
const dimensions = fullRes?.dims || preview?.dims || [0, 0];
// Convert URLs to use proxy
const convertUrl = (url: string): string => {
if (!url) return '';
try {
const originalUrl = new URL(url);
const proxyUrl = new URL(cons.api.GIF_PROXY_URL);
proxyUrl.pathname = `/proxy/tenor/media${originalUrl.pathname}`;
return proxyUrl.toString();
} catch {
// Return original URL as fallback
return url;
}
};
return {
id: tenorResult.id,
title: tenorResult.content_description || tenorResult.h1_title || 'GIF',
url: convertUrl(fullRes?.url || ''),
preview_url: convertUrl(preview?.url || fullRes?.url || ''),
width: dimensions[0] || 0,
height: dimensions[1] || 0,
};
}, []);
const searchGifs = useCallback(
async (query: string) => {
if (!query.trim()) {
setGifs([]);
return;
}
setLoading(true);
setError(null);
try {
const url = new URL(cons.api.GIF_PROXY_URL);
url.pathname = '/proxy/tenor/api/v2/search';
url.searchParams.set('q', query);
const response = await fetch(url.toString());
if (response.status === 200) {
const data = await response.json();
const results = data.results as any[] | undefined;
if (results) {
const gifData: GifData[] = results.map(parseTenorResult);
setGifs(gifData);
} else {
setGifs([]);
}
} else {
throw new Error(`HTTP ${response.status}`);
}
} catch (err) {
setError('Failed to search GIFs');
setGifs([]);
} finally {
setLoading(false);
}
},
[parseTenorResult]
);
return { gifs, loading, error, searchGifs };
}
const { gifs, loading: gifsLoading, error: gifsError, searchGifs } = useGifSearch();
const handleOnChange: ChangeEventHandler<HTMLInputElement> = useDebounce(
useCallback(
(evt) => {
const term = evt.target.value;
if (term) search(term);
else resetSearch();
if (gifTab) {
if (term) {
searchGifs(term);
}
} else if (term) {
search(term);
} else {
resetSearch();
}
},
[search, resetSearch]
[search, resetSearch, searchGifs, gifTab]
),
{ wait: 200 }
);
@ -751,6 +997,12 @@ export function EmojiBoard({
onStickerSelect?.(emojiInfo.data, emojiInfo.shortcode, emojiInfo.label);
if (!evt.altKey && !evt.shiftKey) requestClose();
}
if (emojiInfo.type === EmojiType.Gif) {
const gifDataStr = targetEl.getAttribute('data-gif-data');
const gifData = gifDataStr ? JSON.parse(gifDataStr) : null;
onGifSelect?.(gifData);
if (!evt.altKey && !evt.shiftKey) requestClose();
}
};
const handleEmojiPreview = useCallback(
@ -823,10 +1075,12 @@ export function EmojiBoard({
data-emoji-board-search
variant="SurfaceVariant"
size="400"
placeholder={allowTextCustomEmoji ? 'Search or Text Reaction ' : 'Search'}
placeholder={
allowTextCustomEmoji && !gifTab ? 'Search or Text Reaction ' : 'Search'
}
maxLength={50}
after={
allowTextCustomEmoji && result?.query ? (
allowTextCustomEmoji && result?.query && !gifTab ? (
<Chip
variant="Primary"
radii="Pill"
@ -855,28 +1109,30 @@ export function EmojiBoard({
</Header>
}
sidebar={
<Sidebar>
{emojiTab && recentEmojis.length > 0 && (
<RecentEmojiSidebarStack onItemClick={handleScrollToGroup} />
)}
{imagePacks.length > 0 && (
<ImagePackSidebarStack
mx={mx}
usage={usage}
packs={imagePacks}
onItemClick={handleScrollToGroup}
useAuthentication={useAuthentication}
/>
)}
{emojiTab && (
<NativeEmojiSidebarStack
groups={emojiGroups}
icons={emojiGroupIcons}
labels={emojiGroupLabels}
onItemClick={handleScrollToGroup}
/>
)}
</Sidebar>
!gifTab ? (
<Sidebar>
{emojiTab && recentEmojis.length > 0 && (
<RecentEmojiSidebarStack onItemClick={handleScrollToGroup} />
)}
{imagePacks.length > 0 && (
<ImagePackSidebarStack
mx={mx}
usage={usage}
packs={imagePacks}
onItemClick={handleScrollToGroup}
useAuthentication={useAuthentication}
/>
)}
{emojiTab && (
<NativeEmojiSidebarStack
groups={emojiGroups}
icons={emojiGroupIcons}
labels={emojiGroupLabels}
onItemClick={handleScrollToGroup}
/>
)}
</Sidebar>
) : undefined
}
footer={
emojiTab ? (
@ -895,7 +1151,8 @@ export function EmojiBoard({
</Text>
</Footer>
) : (
imagePacks.length > 0 && (
imagePacks.length > 0 &&
!gifTab && (
<Footer>
<Text ref={emojiPreviewTextRef} size="H5" truncate>
:smiley:
@ -920,30 +1177,40 @@ export function EmojiBoard({
direction="Column"
gap="200"
>
{searchedItems && (
<SearchEmojiGroup
mx={mx}
tab={tab}
id={SEARCH_GROUP_ID}
label={searchedItems.length ? 'Search Results' : 'No Results found'}
emojis={searchedItems}
useAuthentication={useAuthentication}
/>
{gifTab ? (
<GifGroups gifs={gifs} loading={gifsLoading} error={gifsError} />
) : (
<>
{searchedItems && (
<SearchEmojiGroup
mx={mx}
tab={tab}
id={SEARCH_GROUP_ID}
label={searchedItems.length ? 'Search Results' : 'No Results found'}
emojis={searchedItems}
useAuthentication={useAuthentication}
/>
)}
{emojiTab && recentEmojis.length > 0 && (
<RecentEmojiGroup id={RECENT_GROUP_ID} label="Recent" emojis={recentEmojis} />
)}
{emojiTab && (
<CustomEmojiGroups
mx={mx}
groups={imagePacks}
useAuthentication={useAuthentication}
/>
)}
{stickerTab && (
<StickerGroups
mx={mx}
groups={imagePacks}
useAuthentication={useAuthentication}
/>
)}
{emojiTab && <NativeEmojiGroups groups={emojiGroups} labels={emojiGroupLabels} />}
</>
)}
{emojiTab && recentEmojis.length > 0 && (
<RecentEmojiGroup id={RECENT_GROUP_ID} label="Recent" emojis={recentEmojis} />
)}
{emojiTab && (
<CustomEmojiGroups
mx={mx}
groups={imagePacks}
useAuthentication={useAuthentication}
/>
)}
{stickerTab && (
<StickerGroups mx={mx} groups={imagePacks} useAuthentication={useAuthentication} />
)}
{emojiTab && <NativeEmojiGroups groups={emojiGroups} labels={emojiGroupLabels} />}
</Box>
</Scroll>
</Content>

View file

@ -54,7 +54,7 @@ import {
trimCommand,
getMentions,
} from '../../components/editor';
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
import { EmojiBoard, EmojiBoardTab, GifData } from '../../components/emoji-board';
import { UseStateProvider } from '../../components/UseStateProvider';
import {
TUploadContent,
@ -112,6 +112,8 @@ import { GetPowerLevelTag } from '../../hooks/usePowerLevelTags';
import { powerLevelAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
import colorMXID from '../../../util/colorMXID';
import { useIsDirectRoom } from '../../hooks/useRoom';
import GifIC from '../../../../public/res/ic/outlined/gif.svg';
import RawIcon from '../../atoms/system-icons/RawIcon';
interface RoomInputProps {
editor: Editor;
@ -430,6 +432,57 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
});
};
const handleGifSelect = async (gif: GifData) => {
// Download the GIF data
const response = await fetch(gif.url);
if (response.status !== 200) {
throw new Error(`Failed to fetch GIF: ${response.status}`);
}
const data = await response.arrayBuffer();
const uint8Array = new Uint8Array(data);
// Create a File object for the GIF
const filename = `${gif.title}.gif`;
const file = new File([uint8Array], filename, { type: 'image/gif' });
// Upload to Matrix
const uploadResponse = await mx.uploadContent(file, {
name: filename,
type: 'image/gif',
});
const mxcUrl = uploadResponse.content_uri;
const content: IContent = {
body: filename,
url: mxcUrl,
info: {
w: gif.width,
h: gif.height,
mimetype: 'image/gif',
size: data.byteLength,
},
};
// Handle replies if there's a reply draft
if (replyDraft) {
content['m.relates_to'] = {
'm.in_reply_to': {
event_id: replyDraft.eventId,
},
};
if (replyDraft.relation?.rel_type === RelationType.Thread) {
content['m.relates_to'].event_id = replyDraft.relation.event_id;
content['m.relates_to'].rel_type = RelationType.Thread;
content['m.relates_to'].is_falling_back = false;
}
}
// Send the gif as sticker event.
await mx.sendEvent(roomId, EventType.Sticker, content);
setReplyDraft(undefined);
};
return (
<div ref={ref}>
{selectedFiles.length > 0 && (
@ -607,6 +660,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
onStickerSelect={handleStickerSelect}
onGifSelect={handleGifSelect}
requestClose={() => {
setEmojiBoardTab((t) => {
if (t) {
@ -619,6 +673,18 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
/>
}
>
<IconButton
aria-pressed={emojiBoardTab === EmojiBoardTab.Gif}
onClick={() => setEmojiBoardTab(EmojiBoardTab.Gif)}
variant="SurfaceVariant"
size="300"
radii="300"
>
<RawIcon
src={GifIC}
/>
</IconButton>
{!hideStickerBtn && (
<IconButton
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
@ -636,7 +702,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
<IconButton
ref={emojiBtnRef}
aria-pressed={
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
hideStickerBtn ?
(emojiBoardTab === EmojiBoardTab.Emoji
|| emojiBoardTab === EmojiBoardTab.Gif) :
emojiBoardTab === EmojiBoardTab.Emoji
}
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
variant="SurfaceVariant"
@ -646,7 +715,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
<Icon
src={Icons.Smile}
filled={
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
hideStickerBtn ?
(emojiBoardTab === EmojiBoardTab.Emoji
|| emojiBoardTab === EmojiBoardTab.Gif) :
emojiBoardTab === EmojiBoardTab.Emoji
}
/>
</IconButton>

View file

@ -35,6 +35,9 @@ const cons = {
REUSABLE_DIALOG_OPENED: 'REUSABLE_DIALOG_OPENED',
},
},
api: {
GIF_PROXY_URL: 'https://proxy.commet.chat',
},
};
Object.freeze(cons);