add virtualization in emoji board groups

This commit is contained in:
Ajay Bura 2025-09-14 11:24:52 +05:30
parent 90d9d4243e
commit 012e894d7a

View file

@ -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<string | undefined>;
handleOpenGroup: (groupId: string) => void;
packs: ImagePack[];
groups: IEmojiGroup[];
icons: IEmojiGroupIcons;
labels: IEmojiGroupLabels;
type EmojiGroupItem = {
id: string;
name: string;
items: Array<IEmoji | PackImageReader>;
};
function EmojiSidebar({
activeGroupAtom,
handleOpenGroup,
packs,
groups,
icons,
labels,
}: EmojiSidebarProps) {
type StickerGroupItem = {
id: string;
name: string;
items: Array<PackImageReader>;
};
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 <EmojiItem key={emoji.unicode + index} emoji={emoji} />;
}
if (tab === EmojiBoardTab.Sticker) {
return (
<StickerItem
key={emoji.shortcode + index}
mx={mx}
useAuthentication={useAuthentication}
image={emoji}
/>
);
}
return (
<CustomEmojiItem
key={emoji.shortcode + index}
mx={mx}
useAuthentication={useAuthentication}
image={emoji}
/>
);
};
return renderItem;
};
type EmojiSidebarProps = {
activeGroupAtom: PrimitiveAtom<string | undefined>;
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 (
<Sidebar>
@ -85,7 +191,7 @@ function EmojiSidebar({
id={RECENT_GROUP_ID}
label="Recent"
icon={Icons.RecentClock}
onClick={handleOpenGroup}
onClick={handleScrollToGroup}
/>
</SidebarStack>
{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({
}}
>
<SidebarDivider />
{groups.map((group) => (
{emojiGroups.map((group) => (
<GroupIcon
key={group.id}
active={activeGroupId === group.id}
id={group.id}
label={labels[group.id]}
icon={icons[group.id]}
onClick={handleOpenGroup}
onClick={handleScrollToGroup}
/>
))}
</SidebarStack>
@ -136,21 +242,25 @@ function EmojiSidebar({
}
type StickerSidebarProps = {
activeGroupAtom: Atom<string | undefined>;
handleOpenGroup: (groupId: string) => void;
activeGroupAtom: PrimitiveAtom<string | undefined>;
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 (
<Sidebar>
<SidebarStack>
<SidebarDivider />
{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<PackImageReader | IEmoji>;
useAuthentication?: boolean;
}) {
return (
<EmojiGroup key={id} id={id} label={label}>
{searchResult.map((emoji) => {
if ('unicode' in emoji) {
return <EmojiItem key={emoji.unicode} emoji={emoji} />;
}
if (tab === EmojiBoardTab.Sticker) {
return (
<StickerItem
key={emoji.shortcode}
mx={mx}
useAuthentication={useAuthentication}
image={emoji}
/>
);
}
return (
<CustomEmojiItem
key={emoji.shortcode}
mx={mx}
useAuthentication={useAuthentication}
image={emoji}
/>
);
})}
</EmojiGroup>
);
}
type StickerGroupsProps = {
packs: ImagePack[];
type EmojiGroupHolderProps = {
contentScrollRef: RefObject<HTMLDivElement>;
previewAtom: PrimitiveAtom<PreviewData | undefined>;
children?: ReactNode;
onGroupItemClick: MouseEventHandler;
};
function StickerGroups({ packs }: StickerGroupsProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
if (packs.length === 0) {
return <NoStickerPacks />;
}
return packs.map((pack) => (
<EmojiGroup key={pack.id} id={pack.id} label={pack.meta.name || 'Unknown'}>
{pack
.getImages(ImageUsage.Sticker)
.sort((a, b) => a.shortcode.localeCompare(b.shortcode))
.map((image) => (
<StickerItem
key={image.shortcode}
mx={mx}
useAuthentication={useAuthentication}
image={image}
/>
))}
</EmojiGroup>
));
}
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 (
<>
<EmojiGroup id={RECENT_GROUP_ID} label="Recent">
{recentEmojis.map((emoji) => (
<EmojiItem key={emoji.shortcode} emoji={emoji} />
))}
</EmojiGroup>
{packs.map((pack) => (
<EmojiGroup key={pack.id} id={pack.id} label={pack.meta.name || 'Unknown'}>
{pack
.getImages(ImageUsage.Emoticon)
.sort((a, b) => a.shortcode.localeCompare(b.shortcode))
.map((image) => (
<CustomEmojiItem
key={image.shortcode}
mx={mx}
useAuthentication={useAuthentication}
image={image}
/>
))}
</EmojiGroup>
))}
{groups.map((emojiGroup) => (
<EmojiGroup key={emojiGroup.id} id={emojiGroup.id} label={labels[emojiGroup.id]}>
{emojiGroup.emojis.map((emoji) => (
<EmojiItem key={emoji.unicode} emoji={emoji} />
))}
</EmojiGroup>
))}
</>
);
}
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<string | undefined>(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<HTMLDivElement>(null);
const searchList = useMemo(() => {
let list: Array<PackImageReader | IEmoji> = [];
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<HTMLInputElement> = 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<HTMLDivElement> = 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
useEffect(() => {
syncActiveGroupId();
contentScrollRef.current?.scrollTo({
top: 0,
return (
<Scroll ref={contentScrollRef} size="400" onKeyDown={preventScrollWithArrowKey} hideTrack>
<Box
onClick={onGroupItemClick}
onMouseMove={handleEmojiHover}
onFocus={handleEmojiFocus}
direction="Column"
>
{children}
</Box>
</Scroll>
);
}
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<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);
const searchList = useMemo(() => {
let list: Array<PackImageReader | IEmoji> = [];
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<HTMLInputElement> = useDebounce(
useCallback(
(evt) => {
const term = evt.target.value;
if (term) search(term);
else resetSearch();
},
[search, resetSearch]
),
{ wait: 200 }
);
const contentScrollRef = useRef<HTMLDivElement>(null);
const virtualBaseRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: groups.length,
getScrollElement: () => contentScrollRef.current,
estimateSize: () => 40,
overscan: VIRTUAL_OVER_SCAN,
});
}, [result, emojiTab, syncActiveGroupId]);
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(() => {
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 (
<FocusTrap
@ -474,6 +508,7 @@ export function EmojiBoard({
<Box direction="Column" gap="200">
{onTabChange && <EmojiBoardTabs tab={tab} onTabChange={onTabChange} />}
<SearchInput
key={tab}
query={result?.query}
onChange={handleOnChange}
allowTextCustomEmoji={allowTextCustomEmoji}
@ -485,58 +520,59 @@ export function EmojiBoard({
emojiTab ? (
<EmojiSidebar
activeGroupAtom={activeGroupIdAtom}
handleOpenGroup={handleScrollToGroup}
packs={imagePacks}
groups={emojiGroups}
icons={emojiGroupIcons}
labels={emojiGroupLabels}
onScrollToGroup={handleScrollToGroup}
/>
) : (
<StickerSidebar
activeGroupAtom={activeGroupIdAtom}
handleOpenGroup={handleScrollToGroup}
packs={imagePacks}
onScrollToGroup={handleScrollToGroup}
/>
)
}
>
<Box grow="Yes">
<Scroll
ref={contentScrollRef}
size="400"
onScroll={handleOnScroll}
onKeyDown={preventScrollWithArrowKey}
hideTrack
>
<Box
onClick={handleEmojiClick}
onMouseMove={handleEmojiHover}
onFocus={handleEmojiFocus}
direction="Column"
gap="200"
<EmojiGroupHolder
key={tab}
contentScrollRef={contentScrollRef}
previewAtom={previewAtom}
onGroupItemClick={handleGroupItemClick}
>
{searchedItems && (
<SearchGroup
mx={mx}
tab={tab}
<EmojiGroup
id={SEARCH_GROUP_ID}
label={searchedItems.length ? 'Search Results' : 'No Results found'}
emojis={searchedItems}
useAuthentication={useAuthentication}
/>
>
{searchedItems.map(renderItem)}
</EmojiGroup>
)}
{emojiTab ? (
<EmojiGroups
recentEmojis={recentEmojis}
packs={imagePacks}
groups={emojiGroups}
labels={emojiGroupLabels}
/>
) : (
<StickerGroups packs={imagePacks} />
)}
</Box>
</Scroll>
<div
ref={virtualBaseRef}
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{vItems.map((vItem) => {
const group = groups[vItem.index];
return (
<VirtualTile
virtualItem={vItem}
style={{ paddingTop: config.space.S200 }}
ref={virtualizer.measureElement}
key={vItem.index}
>
<EmojiGroup key={group.id} id={group.id} label={group.name}>
{group.items.map(renderItem)}
</EmojiGroup>
</VirtualTile>
);
})}
</div>
{tab === EmojiBoardTab.Sticker && groups.length === 0 && <NoStickerPacks />}
</EmojiGroupHolder>
</Box>
<Preview previewAtom={previewAtom} />
</EmojiBoardLayout>