Skip to content
5 changes: 5 additions & 0 deletions .changeset/added_a_gif_search_functionality.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

# Added a GIF search functionality
218 changes: 194 additions & 24 deletions src/app/components/emoji-board/EmojiBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Box, config, Icons, Scroll } from 'folds';
import FocusTrap from 'focus-trap-react';
Expand Down Expand Up @@ -41,19 +42,21 @@ import {
SidebarDivider,
Sidebar,
NoStickerPacks,
GifStatus,
createPreviewDataAtom,
Preview,
PreviewData,
EmojiItem,
StickerItem,
GifItem,
CustomEmojiItem,
ImageGroupIcon,
GroupIcon,
getEmojiItemInfo,
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';
Expand All @@ -68,11 +71,17 @@ type StickerGroupItem = {
name: string;
items: Array<PackImageReader>;
};
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);
Expand Down Expand Up @@ -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 <EmojiItem key={emoji.unicode + index} emoji={emoji} />;
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 (
<GifItem
key={gif.id + index}
label={gif.title}
type={EmojiType.Gif}
data={gif.url}
shortcode={gif.title}
gif={gif}
style={{ aspectRatio }}
>
<img
loading="lazy"
alt=""
aria-hidden
src={gif.preview_url ?? gif.url}
style={{ display: 'block', width: '100%', height: '100%', objectFit: 'cover' }}
/>
</GifItem>
);
}

if ('unicode' in item) {
return <EmojiItem key={item.unicode + index} emoji={item} />;
}

const emoji = item as PackImageReader;

if (tab === EmojiBoardTab.Sticker) {
return (
<StickerItem
Expand Down Expand Up @@ -382,6 +433,7 @@ type EmojiBoardProps = {
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;
};
Expand All @@ -395,25 +447,24 @@ export function EmojiBoard({
onEmojiSelect,
onCustomEmojiSelect,
onStickerSelect,
onGifSelect,
allowTextCustomEmoji,
addToRecentEmoji = true,
}: Readonly<EmojiBoardProps>) {
const mx = useMatrixClient();
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<string | undefined>(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<PackImageReader | IEmoji> = [];
Expand All @@ -430,14 +481,123 @@ 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);
// 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<HTMLInputElement> = 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 }
);
Expand Down Expand Up @@ -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();
};

Expand Down Expand Up @@ -568,12 +733,14 @@ export function EmojiBoard({
onScrollToGroup={handleScrollToGroup}
/>
) : (
<StickerSidebar
activeGroupAtom={activeGroupIdAtom}
packs={imagePacks}
saveStickerEmojiBandwidth={saveStickerEmojiBandwidth}
onScrollToGroup={handleScrollToGroup}
/>
!gifTab && (
<StickerSidebar
activeGroupAtom={activeGroupIdAtom}
packs={imagePacks}
saveStickerEmojiBandwidth={saveStickerEmojiBandwidth}
onScrollToGroup={handleScrollToGroup}
/>
)
)
}
>
Expand All @@ -584,7 +751,7 @@ export function EmojiBoard({
previewAtom={previewAtom}
onGroupItemClick={handleGroupItemClick}
>
{searchedItems && (
{tab !== EmojiBoardTab.Gif && searchedItems && (
<EmojiGroup
id={SEARCH_GROUP_ID}
label={searchedItems.length ? 'Search Results' : 'No Results found'}
Expand All @@ -609,17 +776,20 @@ export function EmojiBoard({
ref={virtualizer.measureElement}
key={vItem.index}
>
<EmojiGroup key={group.id} id={group.id} label={group.name}>
<EmojiGroup key={group.id} id={group.id} label={group.name} isGifGroup={gifTab}>
{group.items.map(renderItem)}
</EmojiGroup>
</VirtualTile>
);
})}
</div>
{tab === EmojiBoardTab.Sticker && groups.length === 0 && <NoStickerPacks />}
{gifTab && (
<GifStatus loading={gifsLoading} error={gifsError} isEmpty={gifs.length === 0} />
)}
</EmojiGroupHolder>
</Box>
<Preview previewAtom={previewAtom} />
{!gifTab && <Preview previewAtom={previewAtom} />}
</EmojiBoardLayout>
</FocusTrap>
);
Expand Down
18 changes: 13 additions & 5 deletions src/app/components/emoji-board/components/Group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
<Box
id={getDOMGroupId(id)}
data-group-id={id}
Expand All @@ -25,10 +26,17 @@ export const EmojiGroup = as<
<Text id={`EmojiGroup-${id}-label`} as="label" className={css.EmojiGroupLabel} size="O400">
{label}
</Text>
<div aria-labelledby={`EmojiGroup-${id}-label`} className={css.EmojiGroupContent}>
<Box wrap="Wrap" justifyContent="Center">
{children}
</Box>
<div
aria-labelledby={`EmojiGroup-${id}-label`}
className={isGifGroup ? css.GifGroupContent : css.EmojiGroupContent}
>
{isGifGroup ? (
children
) : (
<Box wrap="Wrap" justifyContent="Center">
{children}
</Box>
)}
</div>
</Box>
));
Loading