Make emojiboard lightweight on low end devices (#2484)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Thu, 18 Sep 2025 01:14:08 +0000 (06:44 +0530)
committerGitHub <noreply@github.com>
Thu, 18 Sep 2025 01:14:08 +0000 (11:14 +1000)
* extract emoji search component

* extract emoji board tabs component

* extract sidebar component

* extract no stickers component

* create emoji/sticker preview atom

* extract component from emoji/sticker item and sidebar buttons

* fix image group icon not loading

* separate emojis and sticker groups logic

* extract layout and emoji group components

* add virtualization in emoji board groups

* fix scroll to alignment

14 files changed:
src/app/components/emoji-board/EmojiBoard.css.tsx [deleted file]
src/app/components/emoji-board/EmojiBoard.tsx
src/app/components/emoji-board/components/Group.tsx [new file with mode: 0644]
src/app/components/emoji-board/components/Item.tsx [new file with mode: 0644]
src/app/components/emoji-board/components/Layout.tsx [new file with mode: 0644]
src/app/components/emoji-board/components/NoStickerPacks.tsx [new file with mode: 0644]
src/app/components/emoji-board/components/Preview.tsx [new file with mode: 0644]
src/app/components/emoji-board/components/SearchInput.tsx [new file with mode: 0644]
src/app/components/emoji-board/components/Sidebar.tsx [new file with mode: 0644]
src/app/components/emoji-board/components/Tabs.tsx [new file with mode: 0644]
src/app/components/emoji-board/components/index.tsx [new file with mode: 0644]
src/app/components/emoji-board/components/styles.css.ts [new file with mode: 0644]
src/app/components/emoji-board/index.ts
src/app/components/emoji-board/types.ts [new file with mode: 0644]

diff --git a/src/app/components/emoji-board/EmojiBoard.css.tsx b/src/app/components/emoji-board/EmojiBoard.css.tsx
deleted file mode 100644 (file)
index ba4ca4e..0000000
+++ /dev/null
@@ -1,136 +0,0 @@
-import { style } from '@vanilla-extract/css';
-import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
-
-export const Base = style({
-  maxWidth: toRem(432),
-  width: `calc(100vw - 2 * ${config.space.S400})`,
-  height: toRem(450),
-  backgroundColor: color.Surface.Container,
-  color: color.Surface.OnContainer,
-  border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
-  borderRadius: config.radii.R400,
-  boxShadow: config.shadow.E200,
-  overflow: 'hidden',
-});
-
-export const Sidebar = style({
-  width: toRem(54),
-  backgroundColor: color.Surface.Container,
-  color: color.Surface.OnContainer,
-  position: 'relative',
-});
-
-export const SidebarContent = style({
-  padding: `${config.space.S200} 0`,
-});
-
-export const SidebarStack = style({
-  width: '100%',
-  backgroundColor: color.Surface.Container,
-});
-
-export const NativeEmojiSidebarStack = style({
-  position: 'sticky',
-  bottom: '-67%',
-  zIndex: 1,
-});
-
-export const SidebarDivider = style({
-  width: toRem(18),
-});
-
-export const Header = style({
-  padding: config.space.S300,
-  paddingBottom: 0,
-});
-
-export const EmojiBoardTab = style({
-  cursor: 'pointer',
-});
-
-export const Footer = style({
-  padding: config.space.S200,
-  margin: config.space.S300,
-  marginTop: 0,
-  minHeight: toRem(40),
-
-  borderRadius: config.radii.R400,
-  backgroundColor: color.SurfaceVariant.Container,
-  color: color.SurfaceVariant.OnContainer,
-});
-
-export const EmojiGroup = style({
-  padding: `${config.space.S300} 0`,
-});
-
-export const EmojiGroupLabel = style({
-  position: 'sticky',
-  top: config.space.S200,
-  zIndex: 1,
-
-  margin: 'auto',
-  padding: `${config.space.S100} ${config.space.S200}`,
-  borderRadius: config.radii.Pill,
-  backgroundColor: color.SurfaceVariant.Container,
-  color: color.SurfaceVariant.OnContainer,
-});
-
-export const EmojiGroupContent = style([
-  DefaultReset,
-  {
-    padding: `0 ${config.space.S200}`,
-  },
-]);
-
-export const EmojiPreview = style([
-  DefaultReset,
-  {
-    width: toRem(32),
-    height: toRem(32),
-    fontSize: toRem(32),
-    lineHeight: toRem(32),
-  },
-]);
-
-export const EmojiItem = style([
-  DefaultReset,
-  FocusOutline,
-  {
-    width: toRem(48),
-    height: toRem(48),
-    fontSize: toRem(32),
-    lineHeight: toRem(32),
-    borderRadius: config.radii.R400,
-    cursor: 'pointer',
-
-    ':hover': {
-      backgroundColor: color.Surface.ContainerHover,
-    },
-  },
-]);
-
-export const StickerItem = style([
-  EmojiItem,
-  {
-    width: toRem(112),
-    height: toRem(112),
-  },
-]);
-
-export const CustomEmojiImg = style([
-  DefaultReset,
-  {
-    width: toRem(32),
-    height: toRem(32),
-    objectFit: 'contain',
-  },
-]);
-
-export const StickerImg = style([
-  DefaultReset,
-  {
-    width: toRem(96),
-    height: toRem(96),
-    objectFit: 'contain',
-  },
-]);
index 72a60f2b79023467b6cc2773971e7577afd8f2b8..3db27e2adf137eb0b5109ee1ec2c6e0ae8c9451a 100644 (file)
@@ -2,640 +2,346 @@ import React, {
   ChangeEventHandler,
   FocusEventHandler,
   MouseEventHandler,
-  UIEventHandler,
   ReactNode,
-  memo,
+  RefObject,
   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 { Box, config, Icons, Scroll } 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 { 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';
 import { addRecentEmoji } from '../../plugins/recent-emoji';
-import { mobileOrTablet } from '../../utils/user-agent';
 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';
 
-export enum EmojiBoardTab {
-  Emoji = 'Emoji',
-  Sticker = 'Sticker',
-}
+type EmojiGroupItem = {
+  id: string;
+  name: string;
+  items: Array<IEmoji | PackImageReader>;
+};
+type StickerGroupItem = {
+  id: string;
+  name: string;
+  items: Array<PackImageReader>;
+};
 
-enum EmojiType {
-  Emoji = 'emoji',
-  CustomEmoji = 'customEmoji',
-  Sticker = 'sticker',
-}
+const useGroups = (
+  tab: EmojiBoardTab,
+  imagePacks: ImagePack[]
+): [EmojiGroupItem[], StickerGroupItem[]] => {
+  const mx = useMatrixClient();
 
-export type EmojiItemInfo = {
-  type: EmojiType;
-  data: string;
-  shortcode: string;
-  label: string;
-};
+  const recentEmojis = useRecentEmoji(mx, 21);
+  const labels = useEmojiGroupLabels();
 
-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 emojiGroupItems = useMemo(() => {
+    const g: EmojiGroupItem[] = [];
+    if (tab !== EmojiBoardTab.Emoji) return g;
 
-const activeGroupIdAtom = atom<string | undefined>(undefined);
+    g.push({
+      id: RECENT_GROUP_ID,
+      name: 'Recent',
+      items: recentEmojis,
+    });
 
-function Sidebar({ children }: { children: ReactNode }) {
-  return (
-    <Box className={css.Sidebar} shrink="No">
-      <Scroll size="0">
-        <Box className={css.SidebarContent} direction="Column" alignItems="Center" gap="100">
-          {children}
-        </Box>
-      </Scroll>
-    </Box>
-  );
-}
+    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)),
+      });
+    });
 
-const SidebarStack = as<'div'>(({ className, children, ...props }, ref) => (
-  <Box
-    className={classNames(css.SidebarStack, className)}
-    direction="Column"
-    alignItems="Center"
-    gap="100"
-    {...props}
-    ref={ref}
-  >
-    {children}
-  </Box>
-));
-function SidebarDivider() {
-  return <Line className={css.SidebarDivider} size="300" variant="Surface" />;
-}
+    emojiGroups.forEach((group) => {
+      g.push({
+        id: group.id,
+        name: labels[group.id],
+        items: group.emojis,
+      });
+    });
 
-function Header({ children }: { children: ReactNode }) {
-  return (
-    <Box className={css.Header} direction="Column" shrink="No">
-      {children}
-    </Box>
-  );
-}
+    return g;
+  }, [mx, recentEmojis, labels, imagePacks, tab]);
 
-function Content({ children }: { children: ReactNode }) {
-  return <Box grow="Yes">{children}</Box>;
-}
+  const stickerGroupItems = useMemo(() => {
+    const g: StickerGroupItem[] = [];
+    if (tab !== EmojiBoardTab.Sticker) return g;
 
-function Footer({ children }: { children: ReactNode }) {
-  return (
-    <Box shrink="No" className={css.Footer} gap="300" alignItems="Center">
-      {children}
-    </Box>
-  );
-}
+    imagePacks.forEach((pack) => {
+      let label = pack.meta.name;
+      if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
 
-const EmojiBoardLayout = as<
-  'div',
-  {
-    header: ReactNode;
-    sidebar?: ReactNode;
-    footer?: ReactNode;
-    children: ReactNode;
-  }
->(({ className, header, sidebar, footer, children, ...props }, ref) => (
-  <Box
-    display="InlineFlex"
-    className={classNames(css.Base, className)}
-    direction="Row"
-    {...props}
-    ref={ref}
-  >
-    <Box direction="Column" grow="Yes">
-      {header}
-      {children}
-      {footer}
-    </Box>
-    <Line size="300" direction="Vertical" />
-    {sidebar}
-  </Box>
-));
-
-function EmojiBoardTabs({
-  tab,
-  onTabChange,
-}: {
-  tab: EmojiBoardTab;
-  onTabChange: (tab: EmojiBoardTab) => void;
-}) {
-  return (
-    <Box gap="100">
-      <Badge
-        className={css.EmojiBoardTab}
-        as="button"
-        variant="Secondary"
-        fill={tab === EmojiBoardTab.Sticker ? 'Solid' : 'None'}
-        size="500"
-        onClick={() => onTabChange(EmojiBoardTab.Sticker)}
-      >
-        <Text as="span" size="L400">
-          Sticker
-        </Text>
-      </Badge>
-      <Badge
-        className={css.EmojiBoardTab}
-        as="button"
-        variant="Secondary"
-        fill={tab === EmojiBoardTab.Emoji ? 'Solid' : 'None'}
-        size="500"
-        onClick={() => onTabChange(EmojiBoardTab.Emoji)}
-      >
-        <Text as="span" size="L400">
-          Emoji
-        </Text>
-      </Badge>
-    </Box>
-  );
-}
+      g.push({
+        id: pack.id,
+        name: label ?? 'Unknown',
+        items: pack
+          .getImages(ImageUsage.Sticker)
+          .sort((a, b) => a.shortcode.localeCompare(b.shortcode)),
+      });
+    });
 
-export function SidebarBtn<T extends string>({
-  active,
-  label,
-  id,
-  onItemClick,
-  children,
-}: {
-  active?: boolean;
-  label: string;
-  id: T;
-  onItemClick: (id: T) => void;
-  children: ReactNode;
-}) {
-  return (
-    <TooltipProvider
-      delay={500}
-      position="Left"
-      tooltip={
-        <Tooltip id={`SidebarStackItem-${id}-label`}>
-          <Text size="T300">{label}</Text>
-        </Tooltip>
-      }
-    >
-      {(ref) => (
-        <IconButton
-          aria-pressed={active}
-          aria-labelledby={`SidebarStackItem-${id}-label`}
-          ref={ref}
-          onClick={() => onItemClick(id)}
-          size="400"
-          radii="300"
-          variant="Surface"
-        >
-          {children}
-        </IconButton>
-      )}
-    </TooltipProvider>
-  );
-}
+    return g;
+  }, [mx, imagePacks, tab]);
 
-export const EmojiGroup = as<
-  'div',
-  {
-    id: string;
-    label: string;
-    children: ReactNode;
-  }
->(({ className, id, label, children, ...props }, ref) => (
-  <Box
-    id={getDOMGroupId(id)}
-    data-group-id={id}
-    className={classNames(css.EmojiGroup, className)}
-    direction="Column"
-    gap="200"
-    {...props}
-    ref={ref}
-  >
-    <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>
-  </Box>
-));
-
-export function EmojiItem({
-  label,
-  type,
-  data,
-  shortcode,
-  children,
-}: {
-  label: string;
-  type: EmojiType;
-  data: string;
-  shortcode: string;
-  children: ReactNode;
-}) {
-  return (
-    <Box
-      as="button"
-      className={css.EmojiItem}
-      type="button"
-      alignItems="Center"
-      justifyContent="Center"
-      title={label}
-      aria-label={`${label} emoji`}
-      data-emoji-type={type}
-      data-emoji-data={data}
-      data-emoji-shortcode={shortcode}
-    >
-      {children}
-    </Box>
-  );
-}
+  return [emojiGroupItems, stickerGroupItems];
+};
 
-export function StickerItem({
-  label,
-  type,
-  data,
-  shortcode,
-  children,
-}: {
-  label: string;
-  type: EmojiType;
-  data: string;
-  shortcode: string;
-  children: ReactNode;
-}) {
-  return (
-    <Box
-      as="button"
-      className={css.StickerItem}
-      type="button"
-      alignItems="Center"
-      justifyContent="Center"
-      title={label}
-      aria-label={`${label} sticker`}
-      data-emoji-type={type}
-      data-emoji-data={data}
-      data-emoji-shortcode={shortcode}
-    >
-      {children}
-    </Box>
-  );
-}
+const useItemRenderer = (tab: EmojiBoardTab) => {
+  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} />;
+    }
+    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;
+};
 
-function RecentEmojiSidebarStack({ onItemClick }: { onItemClick: (id: string) => void }) {
-  const activeGroupId = useAtomValue(activeGroupIdAtom);
+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 (
-    <SidebarStack>
-      <SidebarBtn
-        active={activeGroupId === RECENT_GROUP_ID}
-        id={RECENT_GROUP_ID}
-        label="Recent"
-        onItemClick={() => onItemClick(RECENT_GROUP_ID)}
+    <Sidebar>
+      <SidebarStack>
+        <GroupIcon
+          active={activeGroupId === RECENT_GROUP_ID}
+          id={RECENT_GROUP_ID}
+          label="Recent"
+          icon={Icons.RecentClock}
+          onClick={handleScrollToGroup}
+        />
+      </SidebarStack>
+      {packs.length > 0 && (
+        <SidebarStack>
+          <SidebarDivider />
+          {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 (
+              <ImageGroupIcon
+                key={pack.id}
+                active={activeGroupId === pack.id}
+                id={pack.id}
+                label={label ?? 'Unknown Pack'}
+                url={url}
+                onClick={handleScrollToGroup}
+              />
+            );
+          })}
+        </SidebarStack>
+      )}
+      <SidebarStack
+        style={{
+          position: 'sticky',
+          bottom: '-67%',
+          zIndex: 1,
+        }}
       >
-        <Icon src={Icons.RecentClock} filled={activeGroupId === RECENT_GROUP_ID} />
-      </SidebarBtn>
-    </SidebarStack>
+        <SidebarDivider />
+        {emojiGroups.map((group) => (
+          <GroupIcon
+            key={group.id}
+            active={activeGroupId === group.id}
+            id={group.id}
+            label={labels[group.id]}
+            icon={icons[group.id]}
+            onClick={handleScrollToGroup}
+          />
+        ))}
+      </SidebarStack>
+    </Sidebar>
   );
 }
 
-function ImagePackSidebarStack({
-  mx,
-  packs,
-  usage,
-  onItemClick,
-  useAuthentication,
-}: {
-  mx: MatrixClient;
+type StickerSidebarProps = {
+  activeGroupAtom: PrimitiveAtom<string | undefined>;
   packs: ImagePack[];
-  usage: ImageUsage;
-  onItemClick: (id: string) => void;
-  useAuthentication?: boolean;
-}) {
-  const activeGroupId = useAtomValue(activeGroupIdAtom);
+  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 (
-    <SidebarStack>
-      {usage === ImageUsage.Emoticon && <SidebarDivider />}
-      {packs.map((pack) => {
-        let label = pack.meta.name;
-        if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
-        return (
-          <SidebarBtn
-            active={activeGroupId === pack.id}
-            key={pack.id}
-            id={pack.id}
-            label={label || 'Unknown Pack'}
-            onItemClick={onItemClick}
-          >
-            <img
-              style={{
-                width: toRem(24),
-                height: toRem(24),
-                objectFit: 'contain',
-              }}
-              src={
-                mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ||
-                pack.meta.avatar
-              }
-              alt={label || 'Unknown Pack'}
+    <Sidebar>
+      <SidebarStack>
+        {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 (
+            <ImageGroupIcon
+              key={pack.id}
+              active={activeGroupId === pack.id}
+              id={pack.id}
+              label={label ?? 'Unknown Pack'}
+              url={url}
+              onClick={handleScrollToGroup}
             />
-          </SidebarBtn>
-        );
-      })}
-    </SidebarStack>
+          );
+        })}
+      </SidebarStack>
+    </Sidebar>
   );
 }
 
-function NativeEmojiSidebarStack({
-  groups,
-  icons,
-  labels,
-  onItemClick,
-}: {
-  groups: IEmojiGroup[];
-  icons: IEmojiGroupIcons;
-  labels: IEmojiGroupLabels;
-  onItemClick: (id: EmojiGroupId) => void;
-}) {
-  const activeGroupId = useAtomValue(activeGroupIdAtom);
-  return (
-    <SidebarStack className={css.NativeEmojiSidebarStack}>
-      <SidebarDivider />
-      {groups.map((group) => (
-        <SidebarBtn
-          key={group.id}
-          active={activeGroupId === group.id}
-          id={group.id}
-          label={labels[group.id]}
-          onItemClick={onItemClick}
-        >
-          <Icon src={icons[group.id]} filled={activeGroupId === group.id} />
-        </SidebarBtn>
-      ))}
-    </SidebarStack>
-  );
-}
+type EmojiGroupHolderProps = {
+  contentScrollRef: RefObject<HTMLDivElement>;
+  previewAtom: PrimitiveAtom<PreviewData | undefined>;
+  children?: ReactNode;
+  onGroupItemClick: MouseEventHandler;
+};
+function EmojiGroupHolder({
+  contentScrollRef,
+  previewAtom,
+  onGroupItemClick,
+  children,
+}: EmojiGroupHolderProps) {
+  const setPreviewData = useSetAtom(previewAtom);
 
-export function RecentEmojiGroup({
-  label,
-  id,
-  emojis: recentEmojis,
-}: {
-  label: string;
-  id: string;
-  emojis: IEmoji[];
-}) {
-  return (
-    <EmojiGroup key={id} id={id} label={label}>
-      {recentEmojis.map((emoji) => (
-        <EmojiItem
-          key={emoji.unicode}
-          label={emoji.label}
-          type={EmojiType.Emoji}
-          data={emoji.unicode}
-          shortcode={emoji.shortcode}
-        >
-          {emoji.unicode}
-        </EmojiItem>
-      ))}
-    </EmojiGroup>
+  const handleEmojiPreview = useCallback(
+    (element: HTMLButtonElement) => {
+      const emojiInfo = getEmojiItemInfo(element);
+      if (!emojiInfo) return;
+
+      setPreviewData({
+        key: emojiInfo.data,
+        shortcode: emojiInfo.shortcode,
+      });
+    },
+    [setPreviewData]
   );
-}
 
-export function SearchEmojiGroup({
-  mx,
-  tab,
-  label,
-  id,
-  emojis: searchResult,
-  useAuthentication,
-}: {
-  mx: MatrixClient;
-  tab: EmojiBoardTab;
-  label: string;
-  id: string;
-  emojis: Array<PackImageReader | IEmoji>;
-  useAuthentication?: boolean;
-}) {
+  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 (
-    <EmojiGroup key={id} id={id} label={label}>
-      {tab === EmojiBoardTab.Emoji
-        ? searchResult.map((emoji) =>
-            'unicode' in emoji ? (
-              <EmojiItem
-                key={emoji.unicode}
-                label={emoji.label}
-                type={EmojiType.Emoji}
-                data={emoji.unicode}
-                shortcode={emoji.shortcode}
-              >
-                {emoji.unicode}
-              </EmojiItem>
-            ) : (
-              <EmojiItem
-                key={emoji.shortcode}
-                label={emoji.body || emoji.shortcode}
-                type={EmojiType.CustomEmoji}
-                data={emoji.url}
-                shortcode={emoji.shortcode}
-              >
-                <img
-                  loading="lazy"
-                  className={css.CustomEmojiImg}
-                  alt={emoji.body || emoji.shortcode}
-                  src={mxcUrlToHttp(mx, emoji.url, useAuthentication) ?? emoji.url}
-                />
-              </EmojiItem>
-            )
-          )
-        : searchResult.map((emoji) =>
-            'unicode' in emoji ? null : (
-              <StickerItem
-                key={emoji.shortcode}
-                label={emoji.body || emoji.shortcode}
-                type={EmojiType.Sticker}
-                data={emoji.url}
-                shortcode={emoji.shortcode}
-              >
-                <img
-                  loading="lazy"
-                  className={css.StickerImg}
-                  alt={emoji.body || emoji.shortcode}
-                  src={mxcUrlToHttp(mx, emoji.url, useAuthentication) ?? emoji.url}
-                />
-              </StickerItem>
-            )
-          )}
-    </EmojiGroup>
+    <Scroll ref={contentScrollRef} size="400" onKeyDown={preventScrollWithArrowKey} hideTrack>
+      <Box
+        onClick={onGroupItemClick}
+        onMouseMove={handleEmojiHover}
+        onFocus={handleEmojiFocus}
+        direction="Column"
+      >
+        {children}
+      </Box>
+    </Scroll>
   );
 }
 
-export const CustomEmojiGroups = memo(
-  ({
-    mx,
-    groups,
-    useAuthentication,
-  }: {
-    mx: MatrixClient;
-    groups: ImagePack[];
-    useAuthentication?: boolean;
-  }) => (
-    <>
-      {groups.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) => (
-              <EmojiItem
-                key={image.shortcode}
-                label={image.body || image.shortcode}
-                type={EmojiType.CustomEmoji}
-                data={image.url}
-                shortcode={image.shortcode}
-              >
-                <img
-                  loading="lazy"
-                  className={css.CustomEmojiImg}
-                  alt={image.body || image.shortcode}
-                  src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url}
-                />
-              </EmojiItem>
-            ))}
-        </EmojiGroup>
-      ))}
-    </>
-  )
-);
-
-export const StickerGroups = memo(
-  ({
-    mx,
-    groups,
-    useAuthentication,
-  }: {
-    mx: MatrixClient;
-    groups: ImagePack[];
-    useAuthentication?: boolean;
-  }) => (
-    <>
-      {groups.length === 0 && (
-        <Box
-          style={{ padding: `${toRem(60)} ${config.space.S500}` }}
-          alignItems="Center"
-          justifyContent="Center"
-          direction="Column"
-          gap="300"
-        >
-          <Icon size="600" src={Icons.Sticker} />
-          <Box direction="Inherit">
-            <Text align="Center">No Sticker Packs!</Text>
-            <Text priority="300" align="Center" size="T200">
-              Add stickers from user, room or space settings.
-            </Text>
-          </Box>
-        </Box>
-      )}
-      {groups.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}
-                label={image.body || image.shortcode}
-                type={EmojiType.Sticker}
-                data={image.url}
-                shortcode={image.shortcode}
-              >
-                <img
-                  loading="lazy"
-                  className={css.StickerImg}
-                  alt={image.body || image.shortcode}
-                  src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url}
-                />
-              </StickerItem>
-            ))}
-        </EmojiGroup>
-      ))}
-    </>
-  )
-);
-
-export const NativeEmojiGroups = memo(
-  ({ groups, labels }: { groups: IEmojiGroup[]; labels: IEmojiGroupLabels }) => (
-    <>
-      {groups.map((emojiGroup) => (
-        <EmojiGroup key={emojiGroup.id} id={emojiGroup.id} label={labels[emojiGroup.id]}>
-          {emojiGroup.emojis.map((emoji) => (
-            <EmojiItem
-              key={emoji.unicode}
-              label={emoji.label}
-              type={EmojiType.Emoji}
-              data={emoji.unicode}
-              shortcode={emoji.shortcode}
-            >
-              {emoji.unicode}
-            </EmojiItem>
-          ))}
-        </EmojiGroup>
-      ))}
-    </>
-  )
-);
+const DefaultEmojiPreview: PreviewData = { key: '🙂', shortcode: 'slight_smile' };
 
 const SEARCH_OPTIONS: UseAsyncSearchOptions = {
   limit: 1000,
@@ -644,6 +350,21 @@ const SEARCH_OPTIONS: UseAsyncSearchOptions = {
   },
 };
 
+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,
@@ -655,33 +376,22 @@ export function EmojiBoard({
   onStickerSelect,
   allowTextCustomEmoji,
   addToRecentEmoji = true,
-}: {
-  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;
-}) {
+}: EmojiBoardProps) {
+  const mx = useMatrixClient();
+
   const emojiTab = tab === EmojiBoardTab.Emoji;
-  const stickerTab = tab === EmojiBoardTab.Sticker;
   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 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 emojiPreviewRef = useRef<HTMLDivElement>(null);
-  const emojiPreviewTextRef = useRef<HTMLParagraphElement>(null);
+  const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks);
+  const groups = emojiTab ? emojiGroupItems : stickerGroupItems;
+  const renderItem = useItemRenderer(tab);
 
   const searchList = useMemo(() => {
     let list: Array<PackImageReader | IEmoji> = [];
@@ -710,94 +420,73 @@ export function EmojiBoard({
     { 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 contentScrollRef = useRef<HTMLDivElement>(null);
+  const virtualBaseRef = useRef<HTMLDivElement>(null);
+  const virtualizer = useVirtualizer({
+    count: groups.length,
+    getScrollElement: () => contentScrollRef.current,
+    estimateSize: () => 40,
+    overscan: VIRTUAL_OVER_SCAN,
   });
+  const vItems = virtualizer.getVirtualItems();
 
-  const handleScrollToGroup = (groupId: string) => {
-    setActiveGroupId(groupId);
-    const groupElement = document.getElementById(getDOMGroupId(groupId));
-    groupElement?.scrollIntoView();
-  };
-
-  const handleEmojiClick: MouseEventHandler = (evt) => {
+  const handleGroupItemClick: MouseEventHandler = (evt) => {
     const targetEl = targetFromEvent(evt.nativeEvent, 'button');
-    if (!targetEl) return;
-    const emojiInfo = getEmojiItemInfo(targetEl);
+    const emojiInfo = targetEl && 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 (!evt.altKey && !evt.shiftKey && addToRecentEmoji) {
+        addRecentEmoji(mx, emojiInfo.data);
       }
     }
     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();
     }
+    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',
-          mxcUrlToHttp(mx, emojiInfo.data, useAuthentication) || emojiInfo.data
-        );
-        img.setAttribute('alt', emojiInfo.shortcode);
-        emojiPreviewRef.current.textContent = '';
-        emojiPreviewRef.current.appendChild(img);
-      }
-      emojiPreviewTextRef.current.textContent = `:${emojiInfo.shortcode}:`;
-    },
-    [mx, useAuthentication]
-  );
-
-  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 handleTextCustomEmojiSelect = (textEmoji: string) => {
+    onCustomEmojiSelect?.(textEmoji, textEmoji);
+    requestClose();
   };
 
-  const handleEmojiFocus: FocusEventHandler = (evt) => {
-    const targetEl = evt.target as HTMLButtonElement;
-    handleEmojiPreview(targetEl);
+  const handleScrollToGroup = (groupId: string) => {
+    const groupIndex = groups.findIndex((group) => group.id === groupId);
+    virtualizer.scrollToIndex(groupIndex, { align: 'start' });
   };
 
-  // Reset scroll top on search and tab change
+  // 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, { align: 'start' });
+    }
+  }, [tab, virtualizer, groups]);
 
   return (
     <FocusTrap
@@ -816,137 +505,76 @@ export function EmojiBoard({
     >
       <EmojiBoardLayout
         header={
-          <Header>
-            <Box direction="Column" gap="200">
-              {onTabChange && <EmojiBoardTabs tab={tab} onTabChange={onTabChange} />}
-              <Input
-                data-emoji-board-search
-                variant="SurfaceVariant"
-                size="400"
-                placeholder={allowTextCustomEmoji ? 'Search or Text Reaction ' : 'Search'}
-                maxLength={50}
-                after={
-                  allowTextCustomEmoji && result?.query ? (
-                    <Chip
-                      variant="Primary"
-                      radii="Pill"
-                      after={<Icon src={Icons.ArrowRight} size="50" />}
-                      outlined
-                      onClick={() => {
-                        const searchInput = document.querySelector<HTMLInputElement>(
-                          '[data-emoji-board-search="true"]'
-                        );
-                        const textReaction = searchInput?.value.trim();
-                        if (!textReaction) return;
-                        onCustomEmojiSelect?.(textReaction, textReaction);
-                        requestClose();
-                      }}
-                    >
-                      <Text size="L400">React</Text>
-                    </Chip>
-                  ) : (
-                    <Icon src={Icons.Search} size="50" />
-                  )
-                }
-                onChange={handleOnChange}
-                autoFocus={!mobileOrTablet()}
-              />
-            </Box>
-          </Header>
+          <Box direction="Column" gap="200">
+            {onTabChange && <EmojiBoardTabs tab={tab} onTabChange={onTabChange} />}
+            <SearchInput
+              key={tab}
+              query={result?.query}
+              onChange={handleOnChange}
+              allowTextCustomEmoji={allowTextCustomEmoji}
+              onTextCustomEmojiSelect={handleTextCustomEmojiSelect}
+            />
+          </Box>
         }
         sidebar={
-          <Sidebar>
-            {emojiTab && recentEmojis.length > 0 && (
-              <RecentEmojiSidebarStack onItemClick={handleScrollToGroup} />
-            )}
-            {imagePacks.length > 0 && (
-              <ImagePackSidebarStack
-                mx={mx}
-                usage={usage}
-                packs={imagePacks}
-                onItemClick={handleScrollToGroup}
-                useAuthentication={useAuthentication}
-              />
-            )}
-            {emojiTab && (
-              <NativeEmojiSidebarStack
-                groups={emojiGroups}
-                icons={emojiGroupIcons}
-                labels={emojiGroupLabels}
-                onItemClick={handleScrollToGroup}
-              />
-            )}
-          </Sidebar>
-        }
-        footer={
           emojiTab ? (
-            <Footer>
-              <Box
-                display="InlineFlex"
-                ref={emojiPreviewRef}
-                className={css.EmojiPreview}
-                alignItems="Center"
-                justifyContent="Center"
-              >
-                ðŸ˜ƒ
-              </Box>
-              <Text ref={emojiPreviewTextRef} size="H5" truncate>
-                :smiley:
-              </Text>
-            </Footer>
+            <EmojiSidebar
+              activeGroupAtom={activeGroupIdAtom}
+              packs={imagePacks}
+              onScrollToGroup={handleScrollToGroup}
+            />
           ) : (
-            imagePacks.length > 0 && (
-              <Footer>
-                <Text ref={emojiPreviewTextRef} size="H5" truncate>
-                  :smiley:
-                </Text>
-              </Footer>
-            )
+            <StickerSidebar
+              activeGroupAtom={activeGroupIdAtom}
+              packs={imagePacks}
+              onScrollToGroup={handleScrollToGroup}
+            />
           )
         }
       >
-        <Content>
-          <Scroll
-            ref={contentScrollRef}
-            size="400"
-            onScroll={handleOnScroll}
-            onKeyDown={preventScrollWithArrowKey}
-            hideTrack
+        <Box grow="Yes">
+          <EmojiGroupHolder
+            key={tab}
+            contentScrollRef={contentScrollRef}
+            previewAtom={previewAtom}
+            onGroupItemClick={handleGroupItemClick}
           >
-            <Box
-              onClick={handleEmojiClick}
-              onMouseMove={handleEmojiHover}
-              onFocus={handleEmojiFocus}
-              direction="Column"
-              gap="200"
+            {searchedItems && (
+              <EmojiGroup
+                id={SEARCH_GROUP_ID}
+                label={searchedItems.length ? 'Search Results' : 'No Results found'}
+              >
+                {searchedItems.map(renderItem)}
+              </EmojiGroup>
+            )}
+            <div
+              ref={virtualBaseRef}
+              style={{
+                position: 'relative',
+                height: virtualizer.getTotalSize(),
+              }}
             >
-              {searchedItems && (
-                <SearchEmojiGroup
-                  mx={mx}
-                  tab={tab}
-                  id={SEARCH_GROUP_ID}
-                  label={searchedItems.length ? 'Search Results' : 'No Results found'}
-                  emojis={searchedItems}
-                  useAuthentication={useAuthentication}
-                />
-              )}
-              {emojiTab && recentEmojis.length > 0 && (
-                <RecentEmojiGroup id={RECENT_GROUP_ID} label="Recent" emojis={recentEmojis} />
-              )}
-              {emojiTab && (
-                <CustomEmojiGroups
-                  mx={mx}
-                  groups={imagePacks}
-                  useAuthentication={useAuthentication}
-                />
-              )}
-              {stickerTab && (
-                <StickerGroups mx={mx} groups={imagePacks} useAuthentication={useAuthentication} />
-              )}
-              {emojiTab && <NativeEmojiGroups groups={emojiGroups} labels={emojiGroupLabels} />}
-            </Box>
-          </Scroll>
-        </Content>
+              {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>
     </FocusTrap>
   );
diff --git a/src/app/components/emoji-board/components/Group.tsx b/src/app/components/emoji-board/components/Group.tsx
new file mode 100644 (file)
index 0000000..cf19c6e
--- /dev/null
@@ -0,0 +1,34 @@
+import { as, Box, Text } from 'folds';
+import React, { ReactNode } from 'react';
+import classNames from 'classnames';
+import * as css from './styles.css';
+
+export const getDOMGroupId = (id: string): string => `EmojiBoardGroup-${id}`;
+
+export const EmojiGroup = as<
+  'div',
+  {
+    id: string;
+    label: string;
+    children: ReactNode;
+  }
+>(({ className, id, label, children, ...props }, ref) => (
+  <Box
+    id={getDOMGroupId(id)}
+    data-group-id={id}
+    className={classNames(css.EmojiGroup, className)}
+    direction="Column"
+    gap="200"
+    {...props}
+    ref={ref}
+  >
+    <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>
+  </Box>
+));
diff --git a/src/app/components/emoji-board/components/Item.tsx b/src/app/components/emoji-board/components/Item.tsx
new file mode 100644 (file)
index 0000000..c3fd3c3
--- /dev/null
@@ -0,0 +1,105 @@
+import React from 'react';
+import { Box } from 'folds';
+import { MatrixClient } from 'matrix-js-sdk';
+import { EmojiItemInfo, EmojiType } from '../types';
+import * as css from './styles.css';
+import { PackImageReader } from '../../../plugins/custom-emoji';
+import { IEmoji } from '../../../plugins/emoji';
+import { mxcUrlToHttp } from '../../../utils/matrix';
+
+export const getEmojiItemInfo = (element: Element): EmojiItemInfo | undefined => {
+  const label = element.getAttribute('title');
+  const type = element.getAttribute('data-emoji-type') as EmojiType | undefined;
+  const data = element.getAttribute('data-emoji-data');
+  const shortcode = element.getAttribute('data-emoji-shortcode');
+
+  if (type && data && shortcode && label)
+    return {
+      type,
+      data,
+      shortcode,
+      label,
+    };
+  return undefined;
+};
+
+type EmojiItemProps = {
+  emoji: IEmoji;
+};
+export function EmojiItem({ emoji }: EmojiItemProps) {
+  return (
+    <Box
+      as="button"
+      type="button"
+      alignItems="Center"
+      justifyContent="Center"
+      className={css.EmojiItem}
+      title={emoji.label}
+      aria-label={`${emoji.label} emoji`}
+      data-emoji-type={EmojiType.Emoji}
+      data-emoji-data={emoji.unicode}
+      data-emoji-shortcode={emoji.shortcode}
+    >
+      {emoji.unicode}
+    </Box>
+  );
+}
+
+type CustomEmojiItemProps = {
+  mx: MatrixClient;
+  useAuthentication?: boolean;
+  image: PackImageReader;
+};
+export function CustomEmojiItem({ mx, useAuthentication, image }: CustomEmojiItemProps) {
+  return (
+    <Box
+      as="button"
+      type="button"
+      alignItems="Center"
+      justifyContent="Center"
+      className={css.EmojiItem}
+      title={image.body || image.shortcode}
+      aria-label={`${image.body || image.shortcode} emoji`}
+      data-emoji-type={EmojiType.CustomEmoji}
+      data-emoji-data={image.url}
+      data-emoji-shortcode={image.shortcode}
+    >
+      <img
+        loading="lazy"
+        className={css.CustomEmojiImg}
+        alt={image.body || image.shortcode}
+        src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url}
+      />
+    </Box>
+  );
+}
+
+type StickerItemProps = {
+  mx: MatrixClient;
+  useAuthentication?: boolean;
+  image: PackImageReader;
+};
+
+export function StickerItem({ mx, useAuthentication, image }: StickerItemProps) {
+  return (
+    <Box
+      as="button"
+      type="button"
+      alignItems="Center"
+      justifyContent="Center"
+      className={css.StickerItem}
+      title={image.body || image.shortcode}
+      aria-label={`${image.body || image.shortcode} emoji`}
+      data-emoji-type={EmojiType.Sticker}
+      data-emoji-data={image.url}
+      data-emoji-shortcode={image.shortcode}
+    >
+      <img
+        loading="lazy"
+        className={css.StickerImg}
+        alt={image.body || image.shortcode}
+        src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url}
+      />
+    </Box>
+  );
+}
diff --git a/src/app/components/emoji-board/components/Layout.tsx b/src/app/components/emoji-board/components/Layout.tsx
new file mode 100644 (file)
index 0000000..392d4a3
--- /dev/null
@@ -0,0 +1,30 @@
+import { as, Box, Line } from 'folds';
+import React, { ReactNode } from 'react';
+import classNames from 'classnames';
+import * as css from './styles.css';
+
+export const EmojiBoardLayout = as<
+  'div',
+  {
+    header: ReactNode;
+    sidebar?: ReactNode;
+    children: ReactNode;
+  }
+>(({ className, header, sidebar, children, ...props }, ref) => (
+  <Box
+    display="InlineFlex"
+    className={classNames(css.Base, className)}
+    direction="Row"
+    {...props}
+    ref={ref}
+  >
+    <Box direction="Column" grow="Yes">
+      <Box className={css.Header} direction="Column" shrink="No">
+        {header}
+      </Box>
+      {children}
+    </Box>
+    <Line size="300" direction="Vertical" />
+    {sidebar}
+  </Box>
+));
diff --git a/src/app/components/emoji-board/components/NoStickerPacks.tsx b/src/app/components/emoji-board/components/NoStickerPacks.tsx
new file mode 100644 (file)
index 0000000..6703362
--- /dev/null
@@ -0,0 +1,22 @@
+import React from 'react';
+import { Box, toRem, config, Icons, Icon, Text } from 'folds';
+
+export function NoStickerPacks() {
+  return (
+    <Box
+      style={{ padding: `${toRem(60)} ${config.space.S500}` }}
+      alignItems="Center"
+      justifyContent="Center"
+      direction="Column"
+      gap="300"
+    >
+      <Icon size="600" src={Icons.Sticker} />
+      <Box direction="Inherit">
+        <Text align="Center">No Sticker Packs!</Text>
+        <Text priority="300" align="Center" size="T200">
+          Add stickers from user, room or space settings.
+        </Text>
+      </Box>
+    </Box>
+  );
+}
diff --git a/src/app/components/emoji-board/components/Preview.tsx b/src/app/components/emoji-board/components/Preview.tsx
new file mode 100644 (file)
index 0000000..3f5f8d3
--- /dev/null
@@ -0,0 +1,53 @@
+import { Box, Text } from 'folds';
+import React from 'react';
+import { Atom, atom, useAtomValue } from 'jotai';
+import * as css from './styles.css';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
+import { mxcUrlToHttp } from '../../../utils/matrix';
+
+export type PreviewData = {
+  key: string;
+  shortcode: string;
+};
+
+export const createPreviewDataAtom = (initial?: PreviewData) =>
+  atom<PreviewData | undefined>(initial);
+
+type PreviewProps = {
+  previewAtom: Atom<PreviewData | undefined>;
+};
+export function Preview({ previewAtom }: PreviewProps) {
+  const mx = useMatrixClient();
+  const useAuthentication = useMediaAuthentication();
+
+  const { key, shortcode } = useAtomValue(previewAtom) ?? {};
+
+  if (!shortcode) return null;
+
+  return (
+    <Box shrink="No" className={css.Preview} gap="300" alignItems="Center">
+      {key && (
+        <Box
+          display="InlineFlex"
+          className={css.PreviewEmoji}
+          alignItems="Center"
+          justifyContent="Center"
+        >
+          {key.startsWith('mxc://') ? (
+            <img
+              className={css.PreviewImg}
+              src={mxcUrlToHttp(mx, key, useAuthentication) ?? key}
+              alt={shortcode}
+            />
+          ) : (
+            key
+          )}
+        </Box>
+      )}
+      <Text size="H5" truncate>
+        :{shortcode}:
+      </Text>
+    </Box>
+  );
+}
diff --git a/src/app/components/emoji-board/components/SearchInput.tsx b/src/app/components/emoji-board/components/SearchInput.tsx
new file mode 100644 (file)
index 0000000..6de4d97
--- /dev/null
@@ -0,0 +1,51 @@
+import React, { ChangeEventHandler, useRef } from 'react';
+import { Input, Chip, Icon, Icons, Text } from 'folds';
+import { mobileOrTablet } from '../../../utils/user-agent';
+
+type SearchInputProps = {
+  query?: string;
+  onChange: ChangeEventHandler<HTMLInputElement>;
+  allowTextCustomEmoji?: boolean;
+  onTextCustomEmojiSelect?: (text: string) => void;
+};
+export function SearchInput({
+  query,
+  onChange,
+  allowTextCustomEmoji,
+  onTextCustomEmojiSelect,
+}: SearchInputProps) {
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  const handleReact = () => {
+    const textEmoji = inputRef.current?.value.trim();
+    if (!textEmoji) return;
+    onTextCustomEmojiSelect?.(textEmoji);
+  };
+
+  return (
+    <Input
+      ref={inputRef}
+      variant="SurfaceVariant"
+      size="400"
+      placeholder={allowTextCustomEmoji ? 'Search or Text Reaction ' : 'Search'}
+      maxLength={50}
+      after={
+        allowTextCustomEmoji && query ? (
+          <Chip
+            variant="Primary"
+            radii="Pill"
+            after={<Icon src={Icons.ArrowRight} size="50" />}
+            outlined
+            onClick={handleReact}
+          >
+            <Text size="L400">React</Text>
+          </Chip>
+        ) : (
+          <Icon src={Icons.Search} size="50" />
+        )
+      }
+      onChange={onChange}
+      autoFocus={!mobileOrTablet()}
+    />
+  );
+}
diff --git a/src/app/components/emoji-board/components/Sidebar.tsx b/src/app/components/emoji-board/components/Sidebar.tsx
new file mode 100644 (file)
index 0000000..de22b48
--- /dev/null
@@ -0,0 +1,130 @@
+import React, { ReactNode } from 'react';
+import {
+  Box,
+  Scroll,
+  Line,
+  as,
+  TooltipProvider,
+  Tooltip,
+  Text,
+  IconButton,
+  Icon,
+  IconSrc,
+  Icons,
+} from 'folds';
+import classNames from 'classnames';
+import * as css from './styles.css';
+
+export function Sidebar({ children }: { children: ReactNode }) {
+  return (
+    <Box className={css.Sidebar} shrink="No">
+      <Scroll size="0">
+        <Box className={css.SidebarContent} direction="Column" alignItems="Center" gap="100">
+          {children}
+        </Box>
+      </Scroll>
+    </Box>
+  );
+}
+
+export const SidebarStack = as<'div'>(({ className, children, ...props }, ref) => (
+  <Box
+    className={classNames(css.SidebarStack, className)}
+    direction="Column"
+    alignItems="Center"
+    gap="100"
+    {...props}
+    ref={ref}
+  >
+    {children}
+  </Box>
+));
+export function SidebarDivider() {
+  return <Line className={css.SidebarDivider} size="300" variant="Surface" />;
+}
+
+function SidebarBtn<T extends string>({
+  active,
+  label,
+  id,
+  onClick,
+  children,
+}: {
+  active?: boolean;
+  label: string;
+  id: T;
+  onClick: (id: T) => void;
+  children: ReactNode;
+}) {
+  return (
+    <TooltipProvider
+      delay={500}
+      position="Left"
+      tooltip={
+        <Tooltip id={`SidebarStackItem-${id}-label`}>
+          <Text size="T300">{label}</Text>
+        </Tooltip>
+      }
+    >
+      {(ref) => (
+        <IconButton
+          aria-pressed={active}
+          aria-labelledby={`SidebarStackItem-${id}-label`}
+          ref={ref}
+          onClick={() => onClick(id)}
+          size="400"
+          radii="300"
+          variant="Surface"
+        >
+          {children}
+        </IconButton>
+      )}
+    </TooltipProvider>
+  );
+}
+
+type GroupIconProps<T extends string> = {
+  active: boolean;
+  id: T;
+  label: string;
+  icon: IconSrc;
+  onClick: (id: T) => void;
+};
+export function GroupIcon<T extends string>({
+  active,
+  id,
+  label,
+  icon,
+  onClick,
+}: GroupIconProps<T>) {
+  return (
+    <SidebarBtn active={active} id={id} label={label} onClick={onClick}>
+      <Icon src={icon} filled={active} />
+    </SidebarBtn>
+  );
+}
+
+type ImageGroupIconProps<T extends string> = {
+  active: boolean;
+  id: T;
+  label: string;
+  url?: string;
+  onClick: (id: T) => void;
+};
+export function ImageGroupIcon<T extends string>({
+  active,
+  id,
+  label,
+  url,
+  onClick,
+}: ImageGroupIconProps<T>) {
+  return (
+    <SidebarBtn active={active} id={id} label={label} onClick={onClick}>
+      {url ? (
+        <img className={css.SidebarBtnImg} src={url} alt={label} />
+      ) : (
+        <Icon src={Icons.Photo} filled={active} />
+      )}
+    </SidebarBtn>
+  );
+}
diff --git a/src/app/components/emoji-board/components/Tabs.tsx b/src/app/components/emoji-board/components/Tabs.tsx
new file mode 100644 (file)
index 0000000..d433354
--- /dev/null
@@ -0,0 +1,44 @@
+import React, { CSSProperties } from 'react';
+import { Badge, Box, Text } from 'folds';
+import { EmojiBoardTab } from '../types';
+
+const styles: CSSProperties = {
+  cursor: 'pointer',
+};
+
+export function EmojiBoardTabs({
+  tab,
+  onTabChange,
+}: {
+  tab: EmojiBoardTab;
+  onTabChange: (tab: EmojiBoardTab) => void;
+}) {
+  return (
+    <Box gap="100">
+      <Badge
+        style={styles}
+        as="button"
+        variant="Secondary"
+        fill={tab === EmojiBoardTab.Sticker ? 'Solid' : 'None'}
+        size="500"
+        onClick={() => onTabChange(EmojiBoardTab.Sticker)}
+      >
+        <Text as="span" size="L400">
+          Sticker
+        </Text>
+      </Badge>
+      <Badge
+        style={styles}
+        as="button"
+        variant="Secondary"
+        fill={tab === EmojiBoardTab.Emoji ? 'Solid' : 'None'}
+        size="500"
+        onClick={() => onTabChange(EmojiBoardTab.Emoji)}
+      >
+        <Text as="span" size="L400">
+          Emoji
+        </Text>
+      </Badge>
+    </Box>
+  );
+}
diff --git a/src/app/components/emoji-board/components/index.tsx b/src/app/components/emoji-board/components/index.tsx
new file mode 100644 (file)
index 0000000..5550666
--- /dev/null
@@ -0,0 +1,8 @@
+export * from './SearchInput';
+export * from './Tabs';
+export * from './Sidebar';
+export * from './NoStickerPacks';
+export * from './Preview';
+export * from './Item';
+export * from './Group';
+export * from './Layout';
diff --git a/src/app/components/emoji-board/components/styles.css.ts b/src/app/components/emoji-board/components/styles.css.ts
new file mode 100644 (file)
index 0000000..c86a08d
--- /dev/null
@@ -0,0 +1,161 @@
+import { style } from '@vanilla-extract/css';
+import { toRem, color, config, DefaultReset, FocusOutline } from 'folds';
+
+/**
+ * Layout
+ */
+
+export const Base = style({
+  maxWidth: toRem(432),
+  width: `calc(100vw - 2 * ${config.space.S400})`,
+  height: toRem(450),
+  backgroundColor: color.Surface.Container,
+  color: color.Surface.OnContainer,
+  border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
+  borderRadius: config.radii.R400,
+  boxShadow: config.shadow.E200,
+  overflow: 'hidden',
+});
+
+export const Header = style({
+  padding: config.space.S300,
+  paddingBottom: 0,
+});
+
+/**
+ * Sidebar
+ */
+
+export const Sidebar = style({
+  width: toRem(54),
+  backgroundColor: color.Surface.Container,
+  color: color.Surface.OnContainer,
+  position: 'relative',
+});
+
+export const SidebarContent = style({
+  padding: `${config.space.S200} 0`,
+});
+
+export const SidebarStack = style({
+  width: '100%',
+  backgroundColor: color.Surface.Container,
+});
+
+export const SidebarDivider = style({
+  width: toRem(18),
+});
+
+export const SidebarBtnImg = style({
+  width: toRem(24),
+  height: toRem(24),
+  objectFit: 'contain',
+});
+
+/**
+ * Preview
+ */
+
+export const Preview = style({
+  padding: config.space.S200,
+  margin: config.space.S300,
+  marginTop: 0,
+  minHeight: toRem(40),
+
+  borderRadius: config.radii.R400,
+  backgroundColor: color.SurfaceVariant.Container,
+  color: color.SurfaceVariant.OnContainer,
+});
+
+export const PreviewEmoji = style([
+  DefaultReset,
+  {
+    width: toRem(32),
+    height: toRem(32),
+    fontSize: toRem(32),
+    lineHeight: toRem(32),
+  },
+]);
+export const PreviewImg = style([
+  DefaultReset,
+  {
+    width: toRem(32),
+    height: toRem(32),
+    objectFit: 'contain',
+  },
+]);
+
+/**
+ * Group
+ */
+
+export const EmojiGroup = style({
+  position: 'relative',
+  padding: `${config.space.S300} 0`,
+});
+
+export const EmojiGroupLabel = style({
+  position: 'sticky',
+  top: config.space.S200,
+  zIndex: 1,
+
+  margin: 'auto',
+  padding: `${config.space.S100} ${config.space.S200}`,
+  borderRadius: config.radii.Pill,
+  backgroundColor: color.SurfaceVariant.Container,
+  color: color.SurfaceVariant.OnContainer,
+});
+
+export const EmojiGroupContent = style([
+  DefaultReset,
+  {
+    padding: `0 ${config.space.S200}`,
+  },
+]);
+
+/**
+ * Item
+ */
+
+export const EmojiItem = style([
+  DefaultReset,
+  FocusOutline,
+  {
+    width: toRem(48),
+    height: toRem(48),
+    fontSize: toRem(32),
+    lineHeight: toRem(32),
+    borderRadius: config.radii.R400,
+    cursor: 'pointer',
+
+    ':hover': {
+      backgroundColor: color.Surface.ContainerHover,
+    },
+  },
+]);
+
+export const StickerItem = style([
+  EmojiItem,
+  {
+    width: toRem(112),
+    height: toRem(112),
+  },
+]);
+
+export const CustomEmojiImg = style([
+  DefaultReset,
+  {
+    width: toRem(32),
+    height: toRem(32),
+    objectFit: 'contain',
+  },
+]);
+
+export const StickerImg = style([
+  DefaultReset,
+  {
+    width: toRem(96),
+    height: toRem(96),
+    objectFit: 'contain',
+  },
+]);
index 430cec07b2e1240976248c8688a608aa0045d470..7b1cce3b5065b5c36bb84f7d5631f392ec1609ee 100644 (file)
@@ -1 +1,2 @@
 export * from './EmojiBoard';
+export * from './types';
diff --git a/src/app/components/emoji-board/types.ts b/src/app/components/emoji-board/types.ts
new file mode 100644 (file)
index 0000000..de94cc5
--- /dev/null
@@ -0,0 +1,17 @@
+export enum EmojiBoardTab {
+  Emoji = 'Emoji',
+  Sticker = 'Sticker',
+}
+
+export enum EmojiType {
+  Emoji = 'emoji',
+  CustomEmoji = 'customEmoji',
+  Sticker = 'sticker',
+}
+
+export type EmojiItemInfo = {
+  type: EmojiType;
+  data: string;
+  shortcode: string;
+  label: string;
+};