mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-04 22:40:29 +03:00
add virtualization in emoji board groups
This commit is contained in:
parent
90d9d4243e
commit
012e894d7a
1 changed files with 357 additions and 321 deletions
|
|
@ -2,27 +2,28 @@ import React, {
|
||||||
ChangeEventHandler,
|
ChangeEventHandler,
|
||||||
FocusEventHandler,
|
FocusEventHandler,
|
||||||
MouseEventHandler,
|
MouseEventHandler,
|
||||||
UIEventHandler,
|
ReactNode,
|
||||||
|
RefObject,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { Box, Icons, Scroll } from 'folds';
|
import { Box, config, Icons, Scroll } from 'folds';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import { MatrixClient, Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import { Atom, atom, useAtomValue, useSetAtom } from 'jotai';
|
import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { IEmoji, IEmojiGroup, emojiGroups, emojis } from '../../plugins/emoji';
|
import { IEmoji, emojiGroups, emojis } from '../../plugins/emoji';
|
||||||
import { IEmojiGroupLabels, useEmojiGroupLabels } from './useEmojiGroupLabels';
|
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
|
||||||
import { IEmojiGroupIcons, useEmojiGroupIcons } from './useEmojiGroupIcons';
|
import { useEmojiGroupIcons } from './useEmojiGroupIcons';
|
||||||
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
|
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
|
||||||
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
|
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
|
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
|
||||||
import { isUserId, mxcUrlToHttp } from '../../utils/matrix';
|
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 { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
|
||||||
import { useDebounce } from '../../hooks/useDebounce';
|
import { useDebounce } from '../../hooks/useDebounce';
|
||||||
import { useThrottle } from '../../hooks/useThrottle';
|
import { useThrottle } from '../../hooks/useThrottle';
|
||||||
|
|
@ -46,36 +47,141 @@ import {
|
||||||
ImageGroupIcon,
|
ImageGroupIcon,
|
||||||
GroupIcon,
|
GroupIcon,
|
||||||
getEmojiItemInfo,
|
getEmojiItemInfo,
|
||||||
getDOMGroupId,
|
|
||||||
EmojiGroup,
|
EmojiGroup,
|
||||||
EmojiBoardLayout,
|
EmojiBoardLayout,
|
||||||
} from './components';
|
} from './components';
|
||||||
import { EmojiBoardTab, EmojiType } from './types';
|
import { EmojiBoardTab, EmojiType } from './types';
|
||||||
|
import { VirtualTile } from '../virtualizer';
|
||||||
|
|
||||||
const RECENT_GROUP_ID = 'recent_group';
|
const RECENT_GROUP_ID = 'recent_group';
|
||||||
const SEARCH_GROUP_ID = 'search_group';
|
const SEARCH_GROUP_ID = 'search_group';
|
||||||
|
|
||||||
type EmojiSidebarProps = {
|
type EmojiGroupItem = {
|
||||||
activeGroupAtom: Atom<string | undefined>;
|
id: string;
|
||||||
handleOpenGroup: (groupId: string) => void;
|
name: string;
|
||||||
packs: ImagePack[];
|
items: Array<IEmoji | PackImageReader>;
|
||||||
groups: IEmojiGroup[];
|
|
||||||
icons: IEmojiGroupIcons;
|
|
||||||
labels: IEmojiGroupLabels;
|
|
||||||
};
|
};
|
||||||
function EmojiSidebar({
|
type StickerGroupItem = {
|
||||||
activeGroupAtom,
|
id: string;
|
||||||
handleOpenGroup,
|
name: string;
|
||||||
packs,
|
items: Array<PackImageReader>;
|
||||||
groups,
|
};
|
||||||
icons,
|
|
||||||
labels,
|
const useGroups = (
|
||||||
}: EmojiSidebarProps) {
|
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 mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
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 usage = ImageUsage.Emoticon;
|
||||||
|
const labels = useEmojiGroupLabels();
|
||||||
|
const icons = useEmojiGroupIcons();
|
||||||
|
|
||||||
|
const handleScrollToGroup = (groupId: string) => {
|
||||||
|
setActiveGroupId(groupId);
|
||||||
|
onScrollToGroup(groupId);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
|
|
@ -85,7 +191,7 @@ function EmojiSidebar({
|
||||||
id={RECENT_GROUP_ID}
|
id={RECENT_GROUP_ID}
|
||||||
label="Recent"
|
label="Recent"
|
||||||
icon={Icons.RecentClock}
|
icon={Icons.RecentClock}
|
||||||
onClick={handleOpenGroup}
|
onClick={handleScrollToGroup}
|
||||||
/>
|
/>
|
||||||
</SidebarStack>
|
</SidebarStack>
|
||||||
{packs.length > 0 && (
|
{packs.length > 0 && (
|
||||||
|
|
@ -106,7 +212,7 @@ function EmojiSidebar({
|
||||||
id={pack.id}
|
id={pack.id}
|
||||||
label={label ?? 'Unknown Pack'}
|
label={label ?? 'Unknown Pack'}
|
||||||
url={url}
|
url={url}
|
||||||
onClick={handleOpenGroup}
|
onClick={handleScrollToGroup}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -120,14 +226,14 @@ function EmojiSidebar({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SidebarDivider />
|
<SidebarDivider />
|
||||||
{groups.map((group) => (
|
{emojiGroups.map((group) => (
|
||||||
<GroupIcon
|
<GroupIcon
|
||||||
key={group.id}
|
key={group.id}
|
||||||
active={activeGroupId === group.id}
|
active={activeGroupId === group.id}
|
||||||
id={group.id}
|
id={group.id}
|
||||||
label={labels[group.id]}
|
label={labels[group.id]}
|
||||||
icon={icons[group.id]}
|
icon={icons[group.id]}
|
||||||
onClick={handleOpenGroup}
|
onClick={handleScrollToGroup}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SidebarStack>
|
</SidebarStack>
|
||||||
|
|
@ -136,21 +242,25 @@ function EmojiSidebar({
|
||||||
}
|
}
|
||||||
|
|
||||||
type StickerSidebarProps = {
|
type StickerSidebarProps = {
|
||||||
activeGroupAtom: Atom<string | undefined>;
|
activeGroupAtom: PrimitiveAtom<string | undefined>;
|
||||||
handleOpenGroup: (groupId: string) => void;
|
|
||||||
packs: ImagePack[];
|
packs: ImagePack[];
|
||||||
|
onScrollToGroup: (groupId: string) => void;
|
||||||
};
|
};
|
||||||
function StickerSidebar({ activeGroupAtom, handleOpenGroup, packs }: StickerSidebarProps) {
|
function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSidebarProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
|
||||||
const activeGroupId = useAtomValue(activeGroupAtom);
|
const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom);
|
||||||
const usage = ImageUsage.Sticker;
|
const usage = ImageUsage.Sticker;
|
||||||
|
|
||||||
|
const handleScrollToGroup = (groupId: string) => {
|
||||||
|
setActiveGroupId(groupId);
|
||||||
|
onScrollToGroup(groupId);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
<SidebarStack>
|
<SidebarStack>
|
||||||
<SidebarDivider />
|
|
||||||
{packs.map((pack) => {
|
{packs.map((pack) => {
|
||||||
let label = pack.meta.name;
|
let label = pack.meta.name;
|
||||||
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.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}
|
id={pack.id}
|
||||||
label={label ?? 'Unknown Pack'}
|
label={label ?? 'Unknown Pack'}
|
||||||
url={url}
|
url={url}
|
||||||
onClick={handleOpenGroup}
|
onClick={handleScrollToGroup}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -174,248 +284,19 @@ function StickerSidebar({ activeGroupAtom, handleOpenGroup, packs }: StickerSide
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchGroup({
|
type EmojiGroupHolderProps = {
|
||||||
mx,
|
contentScrollRef: RefObject<HTMLDivElement>;
|
||||||
tab,
|
previewAtom: PrimitiveAtom<PreviewData | undefined>;
|
||||||
label,
|
children?: ReactNode;
|
||||||
id,
|
onGroupItemClick: MouseEventHandler;
|
||||||
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[];
|
|
||||||
};
|
};
|
||||||
function StickerGroups({ packs }: StickerGroupsProps) {
|
function EmojiGroupHolder({
|
||||||
const mx = useMatrixClient();
|
contentScrollRef,
|
||||||
const useAuthentication = useMediaAuthentication();
|
previewAtom,
|
||||||
|
onGroupItemClick,
|
||||||
if (packs.length === 0) {
|
children,
|
||||||
return <NoStickerPacks />;
|
}: EmojiGroupHolderProps) {
|
||||||
}
|
|
||||||
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]
|
|
||||||
);
|
|
||||||
const setPreviewData = useSetAtom(previewAtom);
|
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(
|
const handleEmojiPreview = useCallback(
|
||||||
(element: HTMLButtonElement) => {
|
(element: HTMLButtonElement) => {
|
||||||
|
|
@ -446,13 +327,166 @@ export function EmojiBoard({
|
||||||
handleEmojiPreview(targetEl);
|
handleEmojiPreview(targetEl);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reset scroll top on search and tab change
|
return (
|
||||||
useEffect(() => {
|
<Scroll ref={contentScrollRef} size="400" onKeyDown={preventScrollWithArrowKey} hideTrack>
|
||||||
syncActiveGroupId();
|
<Box
|
||||||
contentScrollRef.current?.scrollTo({
|
onClick={onGroupItemClick}
|
||||||
top: 0,
|
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 (
|
return (
|
||||||
<FocusTrap
|
<FocusTrap
|
||||||
|
|
@ -474,6 +508,7 @@ export function EmojiBoard({
|
||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
{onTabChange && <EmojiBoardTabs tab={tab} onTabChange={onTabChange} />}
|
{onTabChange && <EmojiBoardTabs tab={tab} onTabChange={onTabChange} />}
|
||||||
<SearchInput
|
<SearchInput
|
||||||
|
key={tab}
|
||||||
query={result?.query}
|
query={result?.query}
|
||||||
onChange={handleOnChange}
|
onChange={handleOnChange}
|
||||||
allowTextCustomEmoji={allowTextCustomEmoji}
|
allowTextCustomEmoji={allowTextCustomEmoji}
|
||||||
|
|
@ -485,58 +520,59 @@ export function EmojiBoard({
|
||||||
emojiTab ? (
|
emojiTab ? (
|
||||||
<EmojiSidebar
|
<EmojiSidebar
|
||||||
activeGroupAtom={activeGroupIdAtom}
|
activeGroupAtom={activeGroupIdAtom}
|
||||||
handleOpenGroup={handleScrollToGroup}
|
|
||||||
packs={imagePacks}
|
packs={imagePacks}
|
||||||
groups={emojiGroups}
|
onScrollToGroup={handleScrollToGroup}
|
||||||
icons={emojiGroupIcons}
|
|
||||||
labels={emojiGroupLabels}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<StickerSidebar
|
<StickerSidebar
|
||||||
activeGroupAtom={activeGroupIdAtom}
|
activeGroupAtom={activeGroupIdAtom}
|
||||||
handleOpenGroup={handleScrollToGroup}
|
|
||||||
packs={imagePacks}
|
packs={imagePacks}
|
||||||
|
onScrollToGroup={handleScrollToGroup}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Scroll
|
<EmojiGroupHolder
|
||||||
ref={contentScrollRef}
|
key={tab}
|
||||||
size="400"
|
contentScrollRef={contentScrollRef}
|
||||||
onScroll={handleOnScroll}
|
previewAtom={previewAtom}
|
||||||
onKeyDown={preventScrollWithArrowKey}
|
onGroupItemClick={handleGroupItemClick}
|
||||||
hideTrack
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
onClick={handleEmojiClick}
|
|
||||||
onMouseMove={handleEmojiHover}
|
|
||||||
onFocus={handleEmojiFocus}
|
|
||||||
direction="Column"
|
|
||||||
gap="200"
|
|
||||||
>
|
>
|
||||||
{searchedItems && (
|
{searchedItems && (
|
||||||
<SearchGroup
|
<EmojiGroup
|
||||||
mx={mx}
|
|
||||||
tab={tab}
|
|
||||||
id={SEARCH_GROUP_ID}
|
id={SEARCH_GROUP_ID}
|
||||||
label={searchedItems.length ? 'Search Results' : 'No Results found'}
|
label={searchedItems.length ? 'Search Results' : 'No Results found'}
|
||||||
emojis={searchedItems}
|
>
|
||||||
useAuthentication={useAuthentication}
|
{searchedItems.map(renderItem)}
|
||||||
/>
|
</EmojiGroup>
|
||||||
)}
|
)}
|
||||||
{emojiTab ? (
|
<div
|
||||||
<EmojiGroups
|
ref={virtualBaseRef}
|
||||||
recentEmojis={recentEmojis}
|
style={{
|
||||||
packs={imagePacks}
|
position: 'relative',
|
||||||
groups={emojiGroups}
|
height: virtualizer.getTotalSize(),
|
||||||
labels={emojiGroupLabels}
|
}}
|
||||||
/>
|
>
|
||||||
) : (
|
{vItems.map((vItem) => {
|
||||||
<StickerGroups packs={imagePacks} />
|
const group = groups[vItem.index];
|
||||||
)}
|
|
||||||
</Box>
|
return (
|
||||||
</Scroll>
|
<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>
|
</Box>
|
||||||
<Preview previewAtom={previewAtom} />
|
<Preview previewAtom={previewAtom} />
|
||||||
</EmojiBoardLayout>
|
</EmojiBoardLayout>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue