diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index 5785d657..97e4155e 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -2,27 +2,28 @@ import React, { ChangeEventHandler, FocusEventHandler, MouseEventHandler, - UIEventHandler, + ReactNode, + RefObject, useCallback, useEffect, useMemo, useRef, } from 'react'; -import { Box, Icons, Scroll } from 'folds'; +import { Box, config, Icons, Scroll } from 'folds'; import FocusTrap from 'focus-trap-react'; import { isKeyHotkey } from 'is-hotkey'; -import { MatrixClient, Room } from 'matrix-js-sdk'; -import { Atom, atom, useAtomValue, useSetAtom } from 'jotai'; - -import { IEmoji, IEmojiGroup, emojiGroups, emojis } from '../../plugins/emoji'; -import { IEmojiGroupLabels, useEmojiGroupLabels } from './useEmojiGroupLabels'; -import { IEmojiGroupIcons, useEmojiGroupIcons } from './useEmojiGroupIcons'; +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, isIntersectingScrollView, targetFromEvent } from '../../utils/dom'; +import { editableActiveElement, targetFromEvent } from '../../utils/dom'; import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch'; import { useDebounce } from '../../hooks/useDebounce'; import { useThrottle } from '../../hooks/useThrottle'; @@ -46,36 +47,141 @@ import { ImageGroupIcon, GroupIcon, getEmojiItemInfo, - getDOMGroupId, 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 EmojiSidebarProps = { - activeGroupAtom: Atom; - handleOpenGroup: (groupId: string) => void; - packs: ImagePack[]; - groups: IEmojiGroup[]; - icons: IEmojiGroupIcons; - labels: IEmojiGroupLabels; +type EmojiGroupItem = { + id: string; + name: string; + items: Array; }; -function EmojiSidebar({ - activeGroupAtom, - handleOpenGroup, - packs, - groups, - icons, - labels, -}: EmojiSidebarProps) { +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 activeGroupId = useAtomValue(activeGroupAtom); + 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 ( @@ -85,7 +191,7 @@ function EmojiSidebar({ id={RECENT_GROUP_ID} label="Recent" icon={Icons.RecentClock} - onClick={handleOpenGroup} + onClick={handleScrollToGroup} /> {packs.length > 0 && ( @@ -106,7 +212,7 @@ function EmojiSidebar({ id={pack.id} label={label ?? 'Unknown Pack'} url={url} - onClick={handleOpenGroup} + onClick={handleScrollToGroup} /> ); })} @@ -120,14 +226,14 @@ function EmojiSidebar({ }} > - {groups.map((group) => ( + {emojiGroups.map((group) => ( ))} @@ -136,21 +242,25 @@ function EmojiSidebar({ } type StickerSidebarProps = { - activeGroupAtom: Atom; - handleOpenGroup: (groupId: string) => void; + activeGroupAtom: PrimitiveAtom; packs: ImagePack[]; + onScrollToGroup: (groupId: string) => void; }; -function StickerSidebar({ activeGroupAtom, handleOpenGroup, packs }: StickerSidebarProps) { +function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSidebarProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); - const activeGroupId = useAtomValue(activeGroupAtom); + 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; @@ -165,7 +275,7 @@ function StickerSidebar({ activeGroupAtom, handleOpenGroup, packs }: StickerSide id={pack.id} label={label ?? 'Unknown Pack'} url={url} - onClick={handleOpenGroup} + onClick={handleScrollToGroup} /> ); })} @@ -174,248 +284,19 @@ function StickerSidebar({ activeGroupAtom, handleOpenGroup, packs }: StickerSide ); } -export function SearchGroup({ - mx, - tab, - label, - id, - emojis: searchResult, - useAuthentication, -}: { - mx: MatrixClient; - tab: EmojiBoardTab; - label: string; - id: string; - emojis: Array; - useAuthentication?: boolean; -}) { - return ( - - {searchResult.map((emoji) => { - if ('unicode' in emoji) { - return ; - } - if (tab === EmojiBoardTab.Sticker) { - return ( - - ); - } - return ( - - ); - })} - - ); -} - -type StickerGroupsProps = { - packs: ImagePack[]; +type EmojiGroupHolderProps = { + contentScrollRef: RefObject; + previewAtom: PrimitiveAtom; + children?: ReactNode; + onGroupItemClick: MouseEventHandler; }; -function StickerGroups({ packs }: StickerGroupsProps) { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - - if (packs.length === 0) { - return ; - } - return packs.map((pack) => ( - - {pack - .getImages(ImageUsage.Sticker) - .sort((a, b) => a.shortcode.localeCompare(b.shortcode)) - .map((image) => ( - - ))} - - )); -} - -type EmojiGroupsProps = { - recentEmojis: IEmoji[]; - packs: ImagePack[]; - groups: IEmojiGroup[]; - labels: IEmojiGroupLabels; -}; -export function EmojiGroups({ recentEmojis, packs, groups, labels }: EmojiGroupsProps) { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - - return ( - <> - - {recentEmojis.map((emoji) => ( - - ))} - - {packs.map((pack) => ( - - {pack - .getImages(ImageUsage.Emoticon) - .sort((a, b) => a.shortcode.localeCompare(b.shortcode)) - .map((image) => ( - - ))} - - ))} - {groups.map((emojiGroup) => ( - - {emojiGroup.emojis.map((emoji) => ( - - ))} - - ))} - - ); -} - -const DefaultEmojiPreview: PreviewData = { key: '🙂', shortcode: 'slight_smile' }; - -const SEARCH_OPTIONS: UseAsyncSearchOptions = { - limit: 1000, - matchOptions: { - contain: true, - }, -}; - -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 emojiTab = tab === EmojiBoardTab.Emoji; - const usage = emojiTab ? ImageUsage.Emoticon : ImageUsage.Sticker; - - const previewAtom = useMemo( - () => createPreviewDataAtom(emojiTab ? DefaultEmojiPreview : undefined), - [emojiTab] - ); +function EmojiGroupHolder({ + contentScrollRef, + previewAtom, + onGroupItemClick, + children, +}: EmojiGroupHolderProps) { const setPreviewData = useSetAtom(previewAtom); - const activeGroupIdAtom = useMemo(() => atom(undefined), []); - const setActiveGroupId = useSetAtom(activeGroupIdAtom); - - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const emojiGroupLabels = useEmojiGroupLabels(); - const emojiGroupIcons = useEmojiGroupIcons(); - const imagePacks = useRelevantImagePacks(usage, imagePackRooms); - const recentEmojis = useRecentEmoji(mx, 21); - - const contentScrollRef = useRef(null); - - 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 syncActiveGroupId = useCallback(() => { - const targetEl = contentScrollRef.current; - if (!targetEl) return; - const groupEls = Array.from(targetEl.querySelectorAll('div[data-group-id]')) as HTMLElement[]; - const groupEl = groupEls.find((el) => isIntersectingScrollView(targetEl, el)); - const groupId = groupEl?.getAttribute('data-group-id') ?? undefined; - setActiveGroupId(groupId); - }, [setActiveGroupId]); - - const handleOnScroll: UIEventHandler = useThrottle(syncActiveGroupId, { - wait: 500, - }); - - const handleScrollToGroup = (groupId: string) => { - setActiveGroupId(groupId); - const groupElement = document.getElementById(getDOMGroupId(groupId)); - groupElement?.scrollIntoView(); - }; - - const handleEmojiClick: MouseEventHandler = (evt) => { - const targetEl = targetFromEvent(evt.nativeEvent, 'button'); - if (!targetEl) return; - const emojiInfo = getEmojiItemInfo(targetEl); - if (!emojiInfo) return; - if (emojiInfo.type === EmojiType.Emoji) { - onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode); - if (!evt.altKey && !evt.shiftKey) { - if (addToRecentEmoji) { - addRecentEmoji(mx, emojiInfo.data); - } - requestClose(); - } - } - if (emojiInfo.type === EmojiType.CustomEmoji) { - onCustomEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode); - if (!evt.altKey && !evt.shiftKey) requestClose(); - } - 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 handleEmojiPreview = useCallback( (element: HTMLButtonElement) => { @@ -446,13 +327,166 @@ export function EmojiBoard({ handleEmojiPreview(targetEl); }; - // Reset scroll top on search and tab change + 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); + }; + + // sync active sidebar tab with scroll useEffect(() => { - syncActiveGroupId(); - contentScrollRef.current?.scrollTo({ - top: 0, - }); - }, [result, emojiTab, syncActiveGroupId]); + 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); + } + }, [tab, virtualizer, groups]); return ( {onTabChange && } ) : ( ) } > - - + {searchedItems.map(renderItem)} + + )} +
- {searchedItems && ( - - )} - {emojiTab ? ( - - ) : ( - - )} - - + {vItems.map((vItem) => { + const group = groups[vItem.index]; + + return ( + + + {group.items.map(renderItem)} + + + ); + })} +
+ {tab === EmojiBoardTab.Sticker && groups.length === 0 && } +