import React, { ChangeEventHandler, FocusEventHandler, MouseEventHandler, ReactNode, RefObject, useCallback, useEffect, useMemo, useRef, } from 'react'; import { Box, config, Icons, Scroll } from 'folds'; import FocusTrap from 'focus-trap-react'; import { isKeyHotkey } from 'is-hotkey'; import { Room } from 'matrix-js-sdk'; import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai'; import { useVirtualizer } from '@tanstack/react-virtual'; import { IEmoji, emojiGroups, emojis } from '../../plugins/emoji'; import { useEmojiGroupLabels } from './useEmojiGroupLabels'; import { useEmojiGroupIcons } from './useEmojiGroupIcons'; import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard'; import { useRelevantImagePacks } from '../../hooks/useImagePacks'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useRecentEmoji } from '../../hooks/useRecentEmoji'; import { isUserId, mxcUrlToHttp } from '../../utils/matrix'; import { editableActiveElement, targetFromEvent } from '../../utils/dom'; import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch'; import { useDebounce } from '../../hooks/useDebounce'; import { useThrottle } from '../../hooks/useThrottle'; import { addRecentEmoji } from '../../plugins/recent-emoji'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { ImagePack, ImageUsage, PackImageReader } from '../../plugins/custom-emoji'; import { getEmoticonSearchStr } from '../../plugins/utils'; import { SearchInput, EmojiBoardTabs, SidebarStack, SidebarDivider, Sidebar, NoStickerPacks, createPreviewDataAtom, Preview, PreviewData, EmojiItem, StickerItem, CustomEmojiItem, ImageGroupIcon, GroupIcon, getEmojiItemInfo, EmojiGroup, EmojiBoardLayout, } from './components'; import { EmojiBoardTab, EmojiType } from './types'; import { VirtualTile } from '../virtualizer'; const RECENT_GROUP_ID = 'recent_group'; const SEARCH_GROUP_ID = 'search_group'; type EmojiGroupItem = { id: string; name: string; items: Array; }; type StickerGroupItem = { id: string; name: string; items: Array; }; const useGroups = ( tab: EmojiBoardTab, imagePacks: ImagePack[] ): [EmojiGroupItem[], StickerGroupItem[]] => { const mx = useMatrixClient(); const recentEmojis = useRecentEmoji(mx, 21); const labels = useEmojiGroupLabels(); const emojiGroupItems = useMemo(() => { const g: EmojiGroupItem[] = []; if (tab !== EmojiBoardTab.Emoji) return g; g.push({ id: RECENT_GROUP_ID, name: 'Recent', items: recentEmojis, }); imagePacks.forEach((pack) => { let label = pack.meta.name; if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name; g.push({ id: pack.id, name: label ?? 'Unknown', items: pack .getImages(ImageUsage.Emoticon) .sort((a, b) => a.shortcode.localeCompare(b.shortcode)), }); }); emojiGroups.forEach((group) => { g.push({ id: group.id, name: labels[group.id], items: group.emojis, }); }); return g; }, [mx, recentEmojis, labels, imagePacks, tab]); const stickerGroupItems = useMemo(() => { const g: StickerGroupItem[] = []; if (tab !== EmojiBoardTab.Sticker) return g; imagePacks.forEach((pack) => { let label = pack.meta.name; if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name; g.push({ id: pack.id, name: label ?? 'Unknown', items: pack .getImages(ImageUsage.Sticker) .sort((a, b) => a.shortcode.localeCompare(b.shortcode)), }); }); return g; }, [mx, imagePacks, tab]); return [emojiGroupItems, stickerGroupItems]; }; const useItemRenderer = (tab: EmojiBoardTab) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const renderItem = (emoji: IEmoji | PackImageReader, index: number) => { if ('unicode' in emoji) { return ; } if (tab === EmojiBoardTab.Sticker) { return ( ); } return ( ); }; return renderItem; }; type EmojiSidebarProps = { activeGroupAtom: PrimitiveAtom; packs: ImagePack[]; onScrollToGroup: (groupId: string) => void; }; function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom); const usage = ImageUsage.Emoticon; const labels = useEmojiGroupLabels(); const icons = useEmojiGroupIcons(); const handleScrollToGroup = (groupId: string) => { setActiveGroupId(groupId); onScrollToGroup(groupId); }; return ( {packs.length > 0 && ( {packs.map((pack) => { let label = pack.meta.name; if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name; const url = mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) || pack.meta.avatar; return ( ); })} )} {emojiGroups.map((group) => ( ))} ); } type StickerSidebarProps = { activeGroupAtom: PrimitiveAtom; packs: ImagePack[]; onScrollToGroup: (groupId: string) => void; }; function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSidebarProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom); const usage = ImageUsage.Sticker; const handleScrollToGroup = (groupId: string) => { setActiveGroupId(groupId); onScrollToGroup(groupId); }; return ( {packs.map((pack) => { let label = pack.meta.name; if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name; const url = mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) || pack.meta.avatar; return ( ); })} ); } type EmojiGroupHolderProps = { contentScrollRef: RefObject; previewAtom: PrimitiveAtom; children?: ReactNode; onGroupItemClick: MouseEventHandler; }; function EmojiGroupHolder({ contentScrollRef, previewAtom, onGroupItemClick, children, }: EmojiGroupHolderProps) { const setPreviewData = useSetAtom(previewAtom); const handleEmojiPreview = useCallback( (element: HTMLButtonElement) => { const emojiInfo = getEmojiItemInfo(element); if (!emojiInfo) return; setPreviewData({ key: emojiInfo.data, shortcode: emojiInfo.shortcode, }); }, [setPreviewData] ); const throttleEmojiHover = useThrottle(handleEmojiPreview, { wait: 200, immediate: true, }); const handleEmojiHover: MouseEventHandler = (evt) => { const targetEl = targetFromEvent(evt.nativeEvent, 'button') as HTMLButtonElement | undefined; if (!targetEl) return; throttleEmojiHover(targetEl); }; const handleEmojiFocus: FocusEventHandler = (evt) => { const targetEl = evt.target as HTMLButtonElement; handleEmojiPreview(targetEl); }; return ( {children} ); } const DefaultEmojiPreview: PreviewData = { key: '🙂', shortcode: 'slight_smile' }; const SEARCH_OPTIONS: UseAsyncSearchOptions = { limit: 1000, matchOptions: { contain: true, }, }; const VIRTUAL_OVER_SCAN = 2; type EmojiBoardProps = { tab?: EmojiBoardTab; onTabChange?: (tab: EmojiBoardTab) => void; imagePackRooms: Room[]; requestClose: () => void; returnFocusOnDeactivate?: boolean; onEmojiSelect?: (unicode: string, shortcode: string) => void; onCustomEmojiSelect?: (mxc: string, shortcode: string) => void; onStickerSelect?: (mxc: string, shortcode: string, label: string) => void; allowTextCustomEmoji?: boolean; addToRecentEmoji?: boolean; }; export function EmojiBoard({ tab = EmojiBoardTab.Emoji, onTabChange, imagePackRooms, requestClose, returnFocusOnDeactivate, onEmojiSelect, onCustomEmojiSelect, onStickerSelect, allowTextCustomEmoji, addToRecentEmoji = true, }: EmojiBoardProps) { const mx = useMatrixClient(); const emojiTab = tab === EmojiBoardTab.Emoji; const usage = emojiTab ? ImageUsage.Emoticon : ImageUsage.Sticker; const previewAtom = useMemo( () => createPreviewDataAtom(emojiTab ? DefaultEmojiPreview : undefined), [emojiTab] ); 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); const searchList = useMemo(() => { let list: Array = []; list = list.concat(imagePacks.flatMap((pack) => pack.getImages(usage))); if (emojiTab) list = list.concat(emojis); return list; }, [emojiTab, usage, imagePacks]); const [result, search, resetSearch] = useAsyncSearch( searchList, getEmoticonSearchStr, SEARCH_OPTIONS ); const searchedItems = result?.items.slice(0, 100); const handleOnChange: ChangeEventHandler = useDebounce( useCallback( (evt) => { const term = evt.target.value; if (term) search(term); else resetSearch(); }, [search, resetSearch] ), { wait: 200 } ); const contentScrollRef = useRef(null); const virtualBaseRef = useRef(null); const virtualizer = useVirtualizer({ count: groups.length, getScrollElement: () => contentScrollRef.current, estimateSize: () => 40, overscan: VIRTUAL_OVER_SCAN, }); const vItems = virtualizer.getVirtualItems(); const handleGroupItemClick: MouseEventHandler = (evt) => { const targetEl = targetFromEvent(evt.nativeEvent, 'button'); const emojiInfo = targetEl && getEmojiItemInfo(targetEl); if (!emojiInfo) return; if (emojiInfo.type === EmojiType.Emoji) { onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode); if (!evt.altKey && !evt.shiftKey && addToRecentEmoji) { addRecentEmoji(mx, emojiInfo.data); } } if (emojiInfo.type === EmojiType.CustomEmoji) { onCustomEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode); } if (emojiInfo.type === EmojiType.Sticker) { onStickerSelect?.(emojiInfo.data, emojiInfo.shortcode, emojiInfo.label); } if (!evt.altKey && !evt.shiftKey) requestClose(); }; const handleTextCustomEmojiSelect = (textEmoji: string) => { onCustomEmojiSelect?.(textEmoji, textEmoji); requestClose(); }; const handleScrollToGroup = (groupId: string) => { const groupIndex = groups.findIndex((group) => group.id === groupId); virtualizer.scrollToIndex(groupIndex, { align: 'start' }); }; // sync active sidebar tab with scroll useEffect(() => { const scrollElement = contentScrollRef.current; if (scrollElement) { const scrollTop = scrollElement.offsetTop + scrollElement.scrollTop; const offsetTop = virtualBaseRef.current?.offsetTop ?? 0; const inViewVItem = vItems.find((vItem) => scrollTop < offsetTop + vItem.end); const group = inViewVItem ? groups[inViewVItem?.index] : undefined; setActiveGroupId(group?.id); } }, [vItems, groups, setActiveGroupId, result?.query]); // reset scroll position on search useEffect(() => { const scrollElement = contentScrollRef.current; if (scrollElement) { scrollElement.scrollTo({ top: 0 }); } }, [result?.query]); // reset scroll position on tab change useEffect(() => { if (groups.length > 0) { virtualizer.scrollToIndex(0, { align: 'start' }); } }, [tab, virtualizer, groups]); return ( !editableActiveElement() && isKeyHotkey(['arrowdown', 'arrowright'], evt), isKeyBackward: (evt: KeyboardEvent) => !editableActiveElement() && isKeyHotkey(['arrowup', 'arrowleft'], evt), escapeDeactivates: stopPropagation, }} > {onTabChange && } } sidebar={ emojiTab ? ( ) : ( ) } > {searchedItems && ( {searchedItems.map(renderItem)} )}
{vItems.map((vItem) => { const group = groups[vItem.index]; return ( {group.items.map(renderItem)} ); })}
{tab === EmojiBoardTab.Sticker && groups.length === 0 && }
); }