From 397e71adc33d2e18554381038dedccc42d312cc9 Mon Sep 17 00:00:00 2001 From: Raznov <58790521+razn-v@users.noreply.github.com> Date: Sun, 13 Jul 2025 18:23:03 +0200 Subject: [PATCH] Add Tenor GIF search feature --- public/res/ic/outlined/gif.svg | 4 + .../components/emoji-board/EmojiBoard.css.tsx | 40 ++ src/app/components/emoji-board/EmojiBoard.tsx | 369 +++++++++++++++--- src/app/features/room/RoomInput.tsx | 78 +++- src/client/state/cons.js | 3 + 5 files changed, 440 insertions(+), 54 deletions(-) create mode 100644 public/res/ic/outlined/gif.svg diff --git a/public/res/ic/outlined/gif.svg b/public/res/ic/outlined/gif.svg new file mode 100644 index 00000000..884e4326 --- /dev/null +++ b/public/res/ic/outlined/gif.svg @@ -0,0 +1,4 @@ + + + diff --git a/src/app/components/emoji-board/EmojiBoard.css.tsx b/src/app/components/emoji-board/EmojiBoard.css.tsx index ba4ca4e0..f8d00ea8 100644 --- a/src/app/components/emoji-board/EmojiBoard.css.tsx +++ b/src/app/components/emoji-board/EmojiBoard.css.tsx @@ -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, +}); diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index 72a60f2b..4468e6dc 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -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 ( + onTabChange(EmojiBoardTab.Gif)} + > + + GIF + + + {children} + + ); +} + 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 ( + + Loading GIFs... + + ); + } + + if (error) { + return ( + + Error: {error} + + ); + } + + if (gifs.length === 0) { + return ( + + + + No GIFs found! + + Try searching for something else. + + + + ); + } + + return ( + + + GIFs + +
+ {gifs.map((gif) => ( + + {gif.title} + + ))} +
+
+ ); + } +); + 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([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 = 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 ? ( } sidebar={ - - {emojiTab && recentEmojis.length > 0 && ( - - )} - {imagePacks.length > 0 && ( - - )} - {emojiTab && ( - - )} - + !gifTab ? ( + + {emojiTab && recentEmojis.length > 0 && ( + + )} + {imagePacks.length > 0 && ( + + )} + {emojiTab && ( + + )} + + ) : undefined } footer={ emojiTab ? ( @@ -895,7 +1151,8 @@ export function EmojiBoard({ ) : ( - imagePacks.length > 0 && ( + imagePacks.length > 0 && + !gifTab && (