diff --git a/.changeset/added_a_gif_search_functionality.md b/.changeset/added_a_gif_search_functionality.md new file mode 100644 index 000000000..2b7b937e1 --- /dev/null +++ b/.changeset/added_a_gif_search_functionality.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +# Added a GIF search functionality diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index 193caf71d..a2835f46c 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -8,6 +8,7 @@ import { useEffect, useMemo, useRef, + useState, } from 'react'; import { Box, config, Icons, Scroll } from 'folds'; import FocusTrap from 'focus-trap-react'; @@ -41,11 +42,13 @@ import { SidebarDivider, Sidebar, NoStickerPacks, + GifStatus, createPreviewDataAtom, Preview, PreviewData, EmojiItem, StickerItem, + GifItem, CustomEmojiItem, ImageGroupIcon, GroupIcon, @@ -53,7 +56,7 @@ import { EmojiGroup, EmojiBoardLayout, } from './components'; -import { EmojiBoardTab, EmojiType } from './types'; +import { EmojiBoardTab, EmojiType, GifData } from './types'; const RECENT_GROUP_ID = 'recent_group'; const SEARCH_GROUP_ID = 'search_group'; @@ -68,11 +71,17 @@ type StickerGroupItem = { name: string; items: Array; }; +type GifGroupItem = { + id: string; + name: string; + items: GifData[]; +}; const useGroups = ( tab: EmojiBoardTab, - imagePacks: ImagePack[] -): [EmojiGroupItem[], StickerGroupItem[]] => { + imagePacks: ImagePack[], + gifs: GifData[] +): [EmojiGroupItem[], StickerGroupItem[], GifGroupItem[]] => { const mx = useMatrixClient(); const recentEmojis = useRecentEmoji(mx, 21); @@ -132,17 +141,59 @@ const useGroups = ( return g; }, [mx, imagePacks, tab]); - return [emojiGroupItems, stickerGroupItems]; + const gifGroupItems = useMemo(() => { + if (tab !== EmojiBoardTab.Gif) return []; + return [ + { + id: 'gif_group', + name: 'GIFs', + items: gifs, + }, + ]; + }, [tab, gifs]); + + return [emojiGroupItems, stickerGroupItems, gifGroupItems]; }; const useItemRenderer = (tab: EmojiBoardTab, saveStickerEmojiBandwidth: boolean) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); - const renderItem = (emoji: IEmoji | PackImageReader, index: number) => { - if ('unicode' in emoji) { - return ; + const renderItem = (item: IEmoji | PackImageReader | GifData, index: number) => { + if (tab === EmojiBoardTab.Gif) { + const gif = item as GifData; + const aspectRatio = + gif.width && gif.height && gif.width > 0 && gif.height > 0 + ? `${gif.width} / ${gif.height}` + : '1 / 1'; + + return ( + + + + ); + } + + if ('unicode' in item) { + return ; } + + const emoji = item as PackImageReader; + if (tab === EmojiBoardTab.Sticker) { return ( void; onCustomEmojiSelect?: (mxc: string, shortcode: string) => void; onStickerSelect?: (mxc: string, shortcode: string, label: string) => void; + onGifSelect?: (gif: GifData) => void; allowTextCustomEmoji?: boolean; addToRecentEmoji?: boolean; }; @@ -395,6 +447,7 @@ export function EmojiBoard({ onEmojiSelect, onCustomEmojiSelect, onStickerSelect, + onGifSelect, allowTextCustomEmoji, addToRecentEmoji = true, }: Readonly) { @@ -402,18 +455,16 @@ export function EmojiBoard({ const [saveStickerEmojiBandwidth] = useSetting(settingsAtom, 'saveStickerEmojiBandwidth'); const emojiTab = tab === EmojiBoardTab.Emoji; + const gifTab = tab === EmojiBoardTab.Gif; const usage = emojiTab ? ImageUsage.Emoticon : ImageUsage.Sticker; const previewAtom = useMemo( - () => createPreviewDataAtom(emojiTab ? DefaultEmojiPreview : undefined), - [emojiTab] + () => createPreviewDataAtom(tab === EmojiBoardTab.Emoji ? DefaultEmojiPreview : undefined), + [tab] ); const activeGroupIdAtom = useMemo(() => atom(undefined), []); const setActiveGroupId = useSetAtom(activeGroupIdAtom); const imagePacks = useRelevantImagePacks(usage, imagePackRooms); - const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks); - const groups = emojiTab ? emojiGroupItems : stickerGroupItems; - const renderItem = useItemRenderer(tab, saveStickerEmojiBandwidth); const searchList = useMemo(() => { let list: Array = []; @@ -430,14 +481,123 @@ 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); + // TODO: FIX API URL, must be changed when we migrate it to KLIPY + const proxyUrl = new URL('https://proxy.commet.chat'); + 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) => { + const trimmedQuery = query.trim(); + + setLoading(true); + setError(null); + + try { + // TODO: FIX API URL, must be changed when we migrate it to KLIPY + const url = new URL('https://proxy.commet.chat'); + url.pathname = '/proxy/tenor/api/v2/search'; + url.searchParams.set('q', trimmedQuery); + + 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 { + setError('Failed to search GIFs'); + setGifs([]); + } finally { + setLoading(false); + } + }, + [parseTenorResult] + ); + + return { gifs, loading, error, searchGifs }; + } + + const { gifs, loading: gifsLoading, error: gifsError, searchGifs } = useGifSearch(); + const [emojiGroupItems, stickerGroupItems, gifGroupItems] = useGroups(tab, imagePacks, gifs); + const groupsByTab = { + [EmojiBoardTab.Emoji]: emojiGroupItems, + [EmojiBoardTab.Sticker]: stickerGroupItems, + [EmojiBoardTab.Gif]: gifGroupItems, + }; + const groups = groupsByTab[tab]; + const renderItem = useItemRenderer(tab, saveStickerEmojiBandwidth); + const handleOnChange: ChangeEventHandler = useDebounce( useCallback( (evt) => { const term = evt.target.value; - if (term) search(term); - else resetSearch(); + if (tab === EmojiBoardTab.Gif) { + if (term) { + searchGifs(term); + } + } else if (term) { + search(term); + } else { + resetSearch(); + } }, - [search, resetSearch] + [search, resetSearch, searchGifs, tab] ), { wait: 200 } ); @@ -490,6 +650,11 @@ export function EmojiBoard({ if (emojiInfo.type === EmojiType.Sticker) { onStickerSelect?.(emojiInfo.data, emojiInfo.shortcode, emojiInfo.label); } + 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(); }; @@ -568,12 +733,14 @@ export function EmojiBoard({ onScrollToGroup={handleScrollToGroup} /> ) : ( - + !gifTab && ( + + ) ) } > @@ -584,7 +751,7 @@ export function EmojiBoard({ previewAtom={previewAtom} onGroupItemClick={handleGroupItemClick} > - {searchedItems && ( + {tab !== EmojiBoardTab.Gif && searchedItems && ( - + {group.items.map(renderItem)} @@ -617,9 +784,12 @@ export function EmojiBoard({ })} {tab === EmojiBoardTab.Sticker && groups.length === 0 && } + {gifTab && ( + + )} - + {!gifTab && } ); diff --git a/src/app/components/emoji-board/components/Group.tsx b/src/app/components/emoji-board/components/Group.tsx index 293ac6145..90ddfe867 100644 --- a/src/app/components/emoji-board/components/Group.tsx +++ b/src/app/components/emoji-board/components/Group.tsx @@ -10,9 +10,10 @@ export const EmojiGroup = as< { id: string; label: string; + isGifGroup?: boolean; children: ReactNode; } ->(({ className, id, label, children, ...props }, ref) => ( +>(({ className, id, label, isGifGroup, children, ...props }, ref) => ( {label} -
- - {children} - +
+ {isGifGroup ? ( + children + ) : ( + + {children} + + )}
)); diff --git a/src/app/components/emoji-board/components/Item.tsx b/src/app/components/emoji-board/components/Item.tsx index c593b5db0..7a356a5f0 100644 --- a/src/app/components/emoji-board/components/Item.tsx +++ b/src/app/components/emoji-board/components/Item.tsx @@ -3,7 +3,8 @@ import { MatrixClient } from '$types/matrix-sdk'; import { PackImageReader } from '$plugins/custom-emoji'; import { IEmoji } from '$plugins/emoji'; import { mxcUrlToHttp } from '$utils/matrix'; -import { EmojiItemInfo, EmojiType } from '$components/emoji-board/types'; +import { EmojiItemInfo, EmojiType, GifData } from '$components/emoji-board/types'; +import { CSSProperties, ReactNode } from 'react'; import * as css from './styles.css'; const ANIMATED_MIME_TYPES = new Set(['image/gif', 'image/apng']); @@ -139,3 +140,42 @@ export function StickerItem({ ); } + +export function GifItem({ + label, + type, + data, + shortcode, + gif, + style, + showTitle, + children, +}: { + label: string; + type: EmojiType; + data: string; + shortcode: string; + gif?: GifData; + style?: CSSProperties; + showTitle?: boolean; + children: ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/src/app/components/emoji-board/components/NoGifResults.tsx b/src/app/components/emoji-board/components/NoGifResults.tsx new file mode 100644 index 000000000..2cdcb9d7d --- /dev/null +++ b/src/app/components/emoji-board/components/NoGifResults.tsx @@ -0,0 +1,62 @@ +import { Box, toRem, config, Icons, Icon, Text } from 'folds'; + +export function GifSearching() { + return ( + + Loading GIFs... + + ); +} + +export function GifSearchError({ error }: { error: string }) { + return ( + + Error: {error} + + ); +} + +export function NoGifResults() { + return ( + + + + No GIFs found! + + Try searching for something else. + + + + ); +} + +type GifStatusProps = { + loading: boolean; + error: string | null; + isEmpty: boolean; +}; + +export function GifStatus({ loading, error, isEmpty }: Readonly) { + if (loading) return ; + if (error) return ; + if (isEmpty) return ; + return null; +} diff --git a/src/app/components/emoji-board/components/SearchInput.tsx b/src/app/components/emoji-board/components/SearchInput.tsx index c3c80e939..7fb78fc89 100644 --- a/src/app/components/emoji-board/components/SearchInput.tsx +++ b/src/app/components/emoji-board/components/SearchInput.tsx @@ -1,6 +1,7 @@ import { ChangeEventHandler, useRef } from 'react'; import { Input, Chip, Icon, Icons, Text } from 'folds'; import { mobileOrTablet } from '$utils/user-agent'; +import { EmojiBoardTab } from '../types'; type SearchInputProps = { query?: string; @@ -27,10 +28,12 @@ export function SearchInput({ ref={inputRef} variant="SurfaceVariant" size="400" - placeholder={allowTextCustomEmoji ? 'Search or Text Reaction ' : 'Search'} + placeholder={ + allowTextCustomEmoji && !EmojiBoardTab.Gif ? 'Search or Text Reaction ' : 'Search' + } maxLength={50} after={ - allowTextCustomEmoji && query ? ( + allowTextCustomEmoji && query && !EmojiBoardTab.Gif ? ( + onTabChange(EmojiBoardTab.Gif)} + > + + GIF + + ( mx.sendEvent(roomId, EventType.Sticker, content); }; + 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: StickerEventContent & ReplyEventContent & 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 (
{selectedFiles.length > 0 && ( @@ -1463,6 +1514,7 @@ export const RoomInput = forwardRef( onEmojiSelect={handleEmoticonSelect} onCustomEmojiSelect={handleEmoticonSelect} onStickerSelect={handleStickerSelect} + onGifSelect={handleGifSelect} requestClose={() => { setEmojiBoardTab((t) => { if (t) { @@ -1475,6 +1527,17 @@ export const RoomInput = forwardRef( /> } > + setEmojiBoardTab(EmojiBoardTab.Gif)} + variant="SurfaceVariant" + size="300" + radii="300" + > + {/* TODO: change the icon to a GIF icon, view https://github.com/cinnyapp/cinny/pull/2392 */} + + + {!hideStickerBtn && ( ( setEmojiBoardTab(EmojiBoardTab.Emoji)} variant="SurfaceVariant" @@ -1506,7 +1572,10 @@ export const RoomInput = forwardRef(