import React, { ChangeEventHandler, FocusEventHandler, MouseEventHandler, UIEventHandler, ReactNode, memo, useCallback, useEffect, useMemo, useRef, } from 'react'; import { Badge, Box, Chip, Icon, IconButton, Icons, Input, Line, Scroll, Text, Tooltip, TooltipProvider, as, config, toRem, } from 'folds'; import FocusTrap from 'focus-trap-react'; import { isKeyHotkey } from 'is-hotkey'; import classNames from 'classnames'; import { MatrixClient, Room } from 'matrix-js-sdk'; import { atom, useAtomValue, useSetAtom } from 'jotai'; import * as css from './EmojiBoard.css'; import { EmojiGroupId, IEmoji, IEmojiGroup, emojiGroups, emojis } from '../../plugins/emoji'; import { IEmojiGroupLabels, useEmojiGroupLabels } from './useEmojiGroupLabels'; import { IEmojiGroupIcons, 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 { ExtendedPackImage, ImagePack, PackUsage } from '../../plugins/custom-emoji'; import { isUserId } from '../../utils/matrix'; import { editableActiveElement, isIntersectingScrollView, 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 { mobileOrTablet } from '../../utils/user-agent'; const RECENT_GROUP_ID = 'recent_group'; const SEARCH_GROUP_ID = 'search_group'; export enum EmojiBoardTab { Emoji = 'Emoji', Sticker = 'Sticker', } enum EmojiType { Emoji = 'emoji', CustomEmoji = 'customEmoji', Sticker = 'sticker', } export type EmojiItemInfo = { type: EmojiType; data: string; shortcode: string; label: string; }; const getDOMGroupId = (id: string): string => `EmojiBoardGroup-${id}`; const getEmojiItemInfo = (element: Element): EmojiItemInfo | undefined => { const type = element.getAttribute('data-emoji-type') as EmojiType | undefined; const data = element.getAttribute('data-emoji-data'); const label = element.getAttribute('title'); const shortcode = element.getAttribute('data-emoji-shortcode'); if (type && data && shortcode && label) return { type, data, shortcode, label, }; return undefined; }; const activeGroupIdAtom = atom(undefined); function Sidebar({ children }: { children: ReactNode }) { return ( {children} ); } const SidebarStack = as<'div'>(({ className, children, ...props }, ref) => ( {children} )); function SidebarDivider() { return ; } function Header({ children }: { children: ReactNode }) { return ( {children} ); } function Content({ children }: { children: ReactNode }) { return {children}; } function Footer({ children }: { children: ReactNode }) { return ( {children} ); } const EmojiBoardLayout = as< 'div', { header: ReactNode; sidebar?: ReactNode; footer?: ReactNode; children: ReactNode; } >(({ className, header, sidebar, footer, children, ...props }, ref) => ( {header} {children} {footer} {sidebar} )); function EmojiBoardTabs({ tab, onTabChange, }: { tab: EmojiBoardTab; onTabChange: (tab: EmojiBoardTab) => void; }) { return ( onTabChange(EmojiBoardTab.Sticker)} > Sticker onTabChange(EmojiBoardTab.Emoji)} > Emoji ); } export function SidebarBtn({ active, label, id, onItemClick, children, }: { active?: boolean; label: string; id: T; onItemClick: (id: T) => void; children: ReactNode; }) { return ( {label} } > {(ref) => ( onItemClick(id)} size="400" radii="300" variant="Surface" > {children} )} ); } export const EmojiGroup = as< 'div', { id: string; label: string; children: ReactNode; } >(({ className, id, label, children, ...props }, ref) => ( {label}
{children}
)); export function EmojiItem({ label, type, data, shortcode, children, }: { label: string; type: EmojiType; data: string; shortcode: string; children: ReactNode; }) { return ( {children} ); } export function StickerItem({ label, type, data, shortcode, children, }: { label: string; type: EmojiType; data: string; shortcode: string; children: ReactNode; }) { return ( {children} ); } function RecentEmojiSidebarStack({ onItemClick }: { onItemClick: (id: string) => void }) { const activeGroupId = useAtomValue(activeGroupIdAtom); return ( onItemClick(RECENT_GROUP_ID)} > ); } function ImagePackSidebarStack({ mx, packs, usage, onItemClick, }: { mx: MatrixClient; packs: ImagePack[]; usage: PackUsage; onItemClick: (id: string) => void; }) { const activeGroupId = useAtomValue(activeGroupIdAtom); return ( {usage === PackUsage.Emoticon && } {packs.map((pack) => { let label = pack.displayName; if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name; return ( {label ); })} ); } function NativeEmojiSidebarStack({ groups, icons, labels, onItemClick, }: { groups: IEmojiGroup[]; icons: IEmojiGroupIcons; labels: IEmojiGroupLabels; onItemClick: (id: EmojiGroupId) => void; }) { const activeGroupId = useAtomValue(activeGroupIdAtom); return ( {groups.map((group) => ( ))} ); } export function RecentEmojiGroup({ label, id, emojis: recentEmojis, }: { label: string; id: string; emojis: IEmoji[]; }) { return ( {recentEmojis.map((emoji) => ( {emoji.unicode} ))} ); } export function SearchEmojiGroup({ mx, tab, label, id, emojis: searchResult, }: { mx: MatrixClient; tab: EmojiBoardTab; label: string; id: string; emojis: Array; }) { return ( {tab === EmojiBoardTab.Emoji ? searchResult.map((emoji) => 'unicode' in emoji ? ( {emoji.unicode} ) : ( {emoji.body ) ) : searchResult.map((emoji) => 'unicode' in emoji ? null : ( {emoji.body ) )} ); } export const CustomEmojiGroups = memo( ({ mx, groups }: { mx: MatrixClient; groups: ImagePack[] }) => ( <> {groups.map((pack) => ( {pack.getEmojis().map((image) => ( {image.body ))} ))} ) ); export const StickerGroups = memo(({ mx, groups }: { mx: MatrixClient; groups: ImagePack[] }) => ( <> {groups.length === 0 && ( No Sticker Packs! Add stickers from user, room or space settings. )} {groups.map((pack) => ( {pack.getStickers().map((image) => ( {image.body ))} ))} )); export const NativeEmojiGroups = memo( ({ groups, labels }: { groups: IEmojiGroup[]; labels: IEmojiGroupLabels }) => ( <> {groups.map((emojiGroup) => ( {emojiGroup.emojis.map((emoji) => ( {emoji.unicode} ))} ))} ) ); const getSearchListItemStr = (item: ExtendedPackImage | IEmoji) => { const shortcode = `:${item.shortcode}:`; if ('body' in item) { return [shortcode, item.body ?? '']; } return shortcode; }; const SEARCH_OPTIONS: UseAsyncSearchOptions = { limit: 26, matchOptions: { contain: true, }, }; export function EmojiBoard({ tab = EmojiBoardTab.Emoji, onTabChange, imagePackRooms, requestClose, returnFocusOnDeactivate, onEmojiSelect, onCustomEmojiSelect, onStickerSelect, allowTextCustomEmoji, }: { 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; }) { const emojiTab = tab === EmojiBoardTab.Emoji; const stickerTab = tab === EmojiBoardTab.Sticker; const usage = emojiTab ? PackUsage.Emoticon : PackUsage.Sticker; const setActiveGroupId = useSetAtom(activeGroupIdAtom); const mx = useMatrixClient(); const emojiGroupLabels = useEmojiGroupLabels(); const emojiGroupIcons = useEmojiGroupIcons(); const imagePacks = useRelevantImagePacks(mx, usage, imagePackRooms); const recentEmojis = useRecentEmoji(mx, 21); const contentScrollRef = useRef(null); const emojiPreviewRef = useRef(null); const emojiPreviewTextRef = useRef(null); const searchList = useMemo(() => { let list: Array = []; list = list.concat(imagePacks.flatMap((pack) => pack.getImagesFor(usage))); if (emojiTab) list = list.concat(emojis); return list; }, [emojiTab, usage, imagePacks]); const [result, search, resetSearch] = useAsyncSearch( searchList, getSearchListItemStr, SEARCH_OPTIONS ); 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 = [...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) { 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 handleEmojiPreview = useCallback( (element: HTMLButtonElement) => { const emojiInfo = getEmojiItemInfo(element); if (!emojiInfo || !emojiPreviewTextRef.current) return; if (emojiInfo.type === EmojiType.Emoji && emojiPreviewRef.current) { emojiPreviewRef.current.textContent = emojiInfo.data; } else if (emojiInfo.type === EmojiType.CustomEmoji && emojiPreviewRef.current) { const img = document.createElement('img'); img.className = css.CustomEmojiImg; img.setAttribute('src', mx.mxcUrlToHttp(emojiInfo.data) || emojiInfo.data); img.setAttribute('alt', emojiInfo.shortcode); emojiPreviewRef.current.textContent = ''; emojiPreviewRef.current.appendChild(img); } emojiPreviewTextRef.current.textContent = `:${emojiInfo.shortcode}:`; }, [mx] ); 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); }; // Reset scroll top on search and tab change useEffect(() => { syncActiveGroupId(); contentScrollRef.current?.scrollTo({ top: 0, }); }, [result, emojiTab, syncActiveGroupId]); return ( !editableActiveElement() && isKeyHotkey(['arrowdown', 'arrowright'], evt), isKeyBackward: (evt: KeyboardEvent) => !editableActiveElement() && isKeyHotkey(['arrowup', 'arrowleft'], evt), escapeDeactivates: stopPropagation, }} > {onTabChange && } } outlined onClick={() => { const searchInput = document.querySelector( '[data-emoji-board-search="true"]' ); const textReaction = searchInput?.value.trim(); if (!textReaction) return; onCustomEmojiSelect?.(textReaction, textReaction); requestClose(); }} > React ) : ( ) } onChange={handleOnChange} autoFocus={!mobileOrTablet()} /> } sidebar={ {emojiTab && recentEmojis.length > 0 && ( )} {imagePacks.length > 0 && ( )} {emojiTab && ( )} } footer={ emojiTab ? (
😃 :smiley:
) : ( imagePacks.length > 0 && (
:smiley:
) ) } > {result && ( )} {emojiTab && recentEmojis.length > 0 && ( )} {emojiTab && } {stickerTab && } {emojiTab && }
); }