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,
|
||||
FocusEventHandler,
|
||||
MouseEventHandler,
|
||||
UIEventHandler,
|
||||
ReactNode,
|
||||
RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { Box, Icons, Scroll } from 'folds';
|
||||
import { Box, config, Icons, Scroll } from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { MatrixClient, Room } from 'matrix-js-sdk';
|
||||
import { Atom, atom, useAtomValue, useSetAtom } from 'jotai';
|
||||
|
||||
import { IEmoji, IEmojiGroup, emojiGroups, emojis } from '../../plugins/emoji';
|
||||
import { IEmojiGroupLabels, useEmojiGroupLabels } from './useEmojiGroupLabels';
|
||||
import { IEmojiGroupIcons, useEmojiGroupIcons } from './useEmojiGroupIcons';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { IEmoji, emojiGroups, emojis } from '../../plugins/emoji';
|
||||
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
|
||||
import { useEmojiGroupIcons } from './useEmojiGroupIcons';
|
||||
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
|
||||
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
|
||||
import { isUserId, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { editableActiveElement, isIntersectingScrollView, targetFromEvent } from '../../utils/dom';
|
||||
import { editableActiveElement, targetFromEvent } from '../../utils/dom';
|
||||
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import { useThrottle } from '../../hooks/useThrottle';
|
||||
|
|
@ -46,36 +47,141 @@ import {
|
|||
ImageGroupIcon,
|
||||
GroupIcon,
|
||||
getEmojiItemInfo,
|
||||
getDOMGroupId,
|
||||
EmojiGroup,
|
||||
EmojiBoardLayout,
|
||||
} from './components';
|
||||
import { EmojiBoardTab, EmojiType } from './types';
|
||||
import { VirtualTile } from '../virtualizer';
|
||||
|
||||
const RECENT_GROUP_ID = 'recent_group';
|
||||
const SEARCH_GROUP_ID = 'search_group';
|
||||
|
||||
type EmojiSidebarProps = {
|
||||
activeGroupAtom: Atom<string | undefined>;
|
||||
handleOpenGroup: (groupId: string) => void;
|
||||
packs: ImagePack[];
|
||||
groups: IEmojiGroup[];
|
||||
icons: IEmojiGroupIcons;
|
||||
labels: IEmojiGroupLabels;
|
||||
type EmojiGroupItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
items: Array<IEmoji | PackImageReader>;
|
||||
};
|
||||
function EmojiSidebar({
|
||||
activeGroupAtom,
|
||||
handleOpenGroup,
|
||||
packs,
|
||||
groups,
|
||||
icons,
|
||||
labels,
|
||||
}: EmojiSidebarProps) {
|
||||
type StickerGroupItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
items: Array<PackImageReader>;
|
||||
};
|
||||
|
||||
const useGroups = (
|
||||
tab: EmojiBoardTab,
|
||||
imagePacks: ImagePack[]
|
||||
): [EmojiGroupItem[], StickerGroupItem[]] => {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const recentEmojis = useRecentEmoji(mx, 21);
|
||||
const labels = useEmojiGroupLabels();
|
||||
|
||||
const emojiGroupItems = useMemo(() => {
|
||||
const g: EmojiGroupItem[] = [];
|
||||
if (tab !== EmojiBoardTab.Emoji) return g;
|
||||
|
||||
g.push({
|
||||
id: RECENT_GROUP_ID,
|
||||
name: 'Recent',
|
||||
items: recentEmojis,
|
||||
});
|
||||
|
||||
imagePacks.forEach((pack) => {
|
||||
let label = pack.meta.name;
|
||||
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
|
||||
|
||||
g.push({
|
||||
id: pack.id,
|
||||
name: label ?? 'Unknown',
|
||||
items: pack
|
||||
.getImages(ImageUsage.Emoticon)
|
||||
.sort((a, b) => a.shortcode.localeCompare(b.shortcode)),
|
||||
});
|
||||
});
|
||||
|
||||
emojiGroups.forEach((group) => {
|
||||
g.push({
|
||||
id: group.id,
|
||||
name: labels[group.id],
|
||||
items: group.emojis,
|
||||
});
|
||||
});
|
||||
|
||||
return g;
|
||||
}, [mx, recentEmojis, labels, imagePacks, tab]);
|
||||
|
||||
const stickerGroupItems = useMemo(() => {
|
||||
const g: StickerGroupItem[] = [];
|
||||
if (tab !== EmojiBoardTab.Sticker) return g;
|
||||
|
||||
imagePacks.forEach((pack) => {
|
||||
let label = pack.meta.name;
|
||||
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
|
||||
|
||||
g.push({
|
||||
id: pack.id,
|
||||
name: label ?? 'Unknown',
|
||||
items: pack
|
||||
.getImages(ImageUsage.Sticker)
|
||||
.sort((a, b) => a.shortcode.localeCompare(b.shortcode)),
|
||||
});
|
||||
});
|
||||
|
||||
return g;
|
||||
}, [mx, imagePacks, tab]);
|
||||
|
||||
return [emojiGroupItems, stickerGroupItems];
|
||||
};
|
||||
|
||||
const useItemRenderer = (tab: EmojiBoardTab) => {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const activeGroupId = useAtomValue(activeGroupAtom);
|
||||
const renderItem = (emoji: IEmoji | PackImageReader, index: number) => {
|
||||
if ('unicode' in emoji) {
|
||||
return <EmojiItem key={emoji.unicode + index} emoji={emoji} />;
|
||||
}
|
||||
if (tab === EmojiBoardTab.Sticker) {
|
||||
return (
|
||||
<StickerItem
|
||||
key={emoji.shortcode + index}
|
||||
mx={mx}
|
||||
useAuthentication={useAuthentication}
|
||||
image={emoji}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<CustomEmojiItem
|
||||
key={emoji.shortcode + index}
|
||||
mx={mx}
|
||||
useAuthentication={useAuthentication}
|
||||
image={emoji}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return renderItem;
|
||||
};
|
||||
|
||||
type EmojiSidebarProps = {
|
||||
activeGroupAtom: PrimitiveAtom<string | undefined>;
|
||||
packs: ImagePack[];
|
||||
onScrollToGroup: (groupId: string) => void;
|
||||
};
|
||||
function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom);
|
||||
const usage = ImageUsage.Emoticon;
|
||||
const labels = useEmojiGroupLabels();
|
||||
const icons = useEmojiGroupIcons();
|
||||
|
||||
const handleScrollToGroup = (groupId: string) => {
|
||||
setActiveGroupId(groupId);
|
||||
onScrollToGroup(groupId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
|
|
@ -85,7 +191,7 @@ function EmojiSidebar({
|
|||
id={RECENT_GROUP_ID}
|
||||
label="Recent"
|
||||
icon={Icons.RecentClock}
|
||||
onClick={handleOpenGroup}
|
||||
onClick={handleScrollToGroup}
|
||||
/>
|
||||
</SidebarStack>
|
||||
{packs.length > 0 && (
|
||||
|
|
@ -106,7 +212,7 @@ function EmojiSidebar({
|
|||
id={pack.id}
|
||||
label={label ?? 'Unknown Pack'}
|
||||
url={url}
|
||||
onClick={handleOpenGroup}
|
||||
onClick={handleScrollToGroup}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
@ -120,14 +226,14 @@ function EmojiSidebar({
|
|||
}}
|
||||
>
|
||||
<SidebarDivider />
|
||||
{groups.map((group) => (
|
||||
{emojiGroups.map((group) => (
|
||||
<GroupIcon
|
||||
key={group.id}
|
||||
active={activeGroupId === group.id}
|
||||
id={group.id}
|
||||
label={labels[group.id]}
|
||||
icon={icons[group.id]}
|
||||
onClick={handleOpenGroup}
|
||||
onClick={handleScrollToGroup}
|
||||
/>
|
||||
))}
|
||||
</SidebarStack>
|
||||
|
|
@ -136,21 +242,25 @@ function EmojiSidebar({
|
|||
}
|
||||
|
||||
type StickerSidebarProps = {
|
||||
activeGroupAtom: Atom<string | undefined>;
|
||||
handleOpenGroup: (groupId: string) => void;
|
||||
activeGroupAtom: PrimitiveAtom<string | undefined>;
|
||||
packs: ImagePack[];
|
||||
onScrollToGroup: (groupId: string) => void;
|
||||
};
|
||||
function StickerSidebar({ activeGroupAtom, handleOpenGroup, packs }: StickerSidebarProps) {
|
||||
function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSidebarProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const activeGroupId = useAtomValue(activeGroupAtom);
|
||||
const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom);
|
||||
const usage = ImageUsage.Sticker;
|
||||
|
||||
const handleScrollToGroup = (groupId: string) => {
|
||||
setActiveGroupId(groupId);
|
||||
onScrollToGroup(groupId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarStack>
|
||||
<SidebarDivider />
|
||||
{packs.map((pack) => {
|
||||
let label = pack.meta.name;
|
||||
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
|
||||
|
|
@ -165,7 +275,7 @@ function StickerSidebar({ activeGroupAtom, handleOpenGroup, packs }: StickerSide
|
|||
id={pack.id}
|
||||
label={label ?? 'Unknown Pack'}
|
||||
url={url}
|
||||
onClick={handleOpenGroup}
|
||||
onClick={handleScrollToGroup}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
@ -174,248 +284,19 @@ function StickerSidebar({ activeGroupAtom, handleOpenGroup, packs }: StickerSide
|
|||
);
|
||||
}
|
||||
|
||||
export function SearchGroup({
|
||||
mx,
|
||||
tab,
|
||||
label,
|
||||
id,
|
||||
emojis: searchResult,
|
||||
useAuthentication,
|
||||
}: {
|
||||
mx: MatrixClient;
|
||||
tab: EmojiBoardTab;
|
||||
label: string;
|
||||
id: string;
|
||||
emojis: Array<PackImageReader | IEmoji>;
|
||||
useAuthentication?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<EmojiGroup key={id} id={id} label={label}>
|
||||
{searchResult.map((emoji) => {
|
||||
if ('unicode' in emoji) {
|
||||
return <EmojiItem key={emoji.unicode} emoji={emoji} />;
|
||||
}
|
||||
if (tab === EmojiBoardTab.Sticker) {
|
||||
return (
|
||||
<StickerItem
|
||||
key={emoji.shortcode}
|
||||
mx={mx}
|
||||
useAuthentication={useAuthentication}
|
||||
image={emoji}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<CustomEmojiItem
|
||||
key={emoji.shortcode}
|
||||
mx={mx}
|
||||
useAuthentication={useAuthentication}
|
||||
image={emoji}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</EmojiGroup>
|
||||
);
|
||||
}
|
||||
|
||||
type StickerGroupsProps = {
|
||||
packs: ImagePack[];
|
||||
type EmojiGroupHolderProps = {
|
||||
contentScrollRef: RefObject<HTMLDivElement>;
|
||||
previewAtom: PrimitiveAtom<PreviewData | undefined>;
|
||||
children?: ReactNode;
|
||||
onGroupItemClick: MouseEventHandler;
|
||||
};
|
||||
function StickerGroups({ packs }: StickerGroupsProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
if (packs.length === 0) {
|
||||
return <NoStickerPacks />;
|
||||
}
|
||||
return packs.map((pack) => (
|
||||
<EmojiGroup key={pack.id} id={pack.id} label={pack.meta.name || 'Unknown'}>
|
||||
{pack
|
||||
.getImages(ImageUsage.Sticker)
|
||||
.sort((a, b) => a.shortcode.localeCompare(b.shortcode))
|
||||
.map((image) => (
|
||||
<StickerItem
|
||||
key={image.shortcode}
|
||||
mx={mx}
|
||||
useAuthentication={useAuthentication}
|
||||
image={image}
|
||||
/>
|
||||
))}
|
||||
</EmojiGroup>
|
||||
));
|
||||
}
|
||||
|
||||
type EmojiGroupsProps = {
|
||||
recentEmojis: IEmoji[];
|
||||
packs: ImagePack[];
|
||||
groups: IEmojiGroup[];
|
||||
labels: IEmojiGroupLabels;
|
||||
};
|
||||
export function EmojiGroups({ recentEmojis, packs, groups, labels }: EmojiGroupsProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
return (
|
||||
<>
|
||||
<EmojiGroup id={RECENT_GROUP_ID} label="Recent">
|
||||
{recentEmojis.map((emoji) => (
|
||||
<EmojiItem key={emoji.shortcode} emoji={emoji} />
|
||||
))}
|
||||
</EmojiGroup>
|
||||
{packs.map((pack) => (
|
||||
<EmojiGroup key={pack.id} id={pack.id} label={pack.meta.name || 'Unknown'}>
|
||||
{pack
|
||||
.getImages(ImageUsage.Emoticon)
|
||||
.sort((a, b) => a.shortcode.localeCompare(b.shortcode))
|
||||
.map((image) => (
|
||||
<CustomEmojiItem
|
||||
key={image.shortcode}
|
||||
mx={mx}
|
||||
useAuthentication={useAuthentication}
|
||||
image={image}
|
||||
/>
|
||||
))}
|
||||
</EmojiGroup>
|
||||
))}
|
||||
{groups.map((emojiGroup) => (
|
||||
<EmojiGroup key={emojiGroup.id} id={emojiGroup.id} label={labels[emojiGroup.id]}>
|
||||
{emojiGroup.emojis.map((emoji) => (
|
||||
<EmojiItem key={emoji.unicode} emoji={emoji} />
|
||||
))}
|
||||
</EmojiGroup>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const DefaultEmojiPreview: PreviewData = { key: '🙂', shortcode: 'slight_smile' };
|
||||
|
||||
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||
limit: 1000,
|
||||
matchOptions: {
|
||||
contain: true,
|
||||
},
|
||||
};
|
||||
|
||||
type EmojiBoardProps = {
|
||||
tab?: EmojiBoardTab;
|
||||
onTabChange?: (tab: EmojiBoardTab) => void;
|
||||
imagePackRooms: Room[];
|
||||
requestClose: () => void;
|
||||
returnFocusOnDeactivate?: boolean;
|
||||
onEmojiSelect?: (unicode: string, shortcode: string) => void;
|
||||
onCustomEmojiSelect?: (mxc: string, shortcode: string) => void;
|
||||
onStickerSelect?: (mxc: string, shortcode: string, label: string) => void;
|
||||
allowTextCustomEmoji?: boolean;
|
||||
addToRecentEmoji?: boolean;
|
||||
};
|
||||
|
||||
export function EmojiBoard({
|
||||
tab = EmojiBoardTab.Emoji,
|
||||
onTabChange,
|
||||
imagePackRooms,
|
||||
requestClose,
|
||||
returnFocusOnDeactivate,
|
||||
onEmojiSelect,
|
||||
onCustomEmojiSelect,
|
||||
onStickerSelect,
|
||||
allowTextCustomEmoji,
|
||||
addToRecentEmoji = true,
|
||||
}: EmojiBoardProps) {
|
||||
const emojiTab = tab === EmojiBoardTab.Emoji;
|
||||
const usage = emojiTab ? ImageUsage.Emoticon : ImageUsage.Sticker;
|
||||
|
||||
const previewAtom = useMemo(
|
||||
() => createPreviewDataAtom(emojiTab ? DefaultEmojiPreview : undefined),
|
||||
[emojiTab]
|
||||
);
|
||||
function EmojiGroupHolder({
|
||||
contentScrollRef,
|
||||
previewAtom,
|
||||
onGroupItemClick,
|
||||
children,
|
||||
}: EmojiGroupHolderProps) {
|
||||
const setPreviewData = useSetAtom(previewAtom);
|
||||
const activeGroupIdAtom = useMemo(() => atom<string | undefined>(undefined), []);
|
||||
const setActiveGroupId = useSetAtom(activeGroupIdAtom);
|
||||
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const emojiGroupLabels = useEmojiGroupLabels();
|
||||
const emojiGroupIcons = useEmojiGroupIcons();
|
||||
const imagePacks = useRelevantImagePacks(usage, imagePackRooms);
|
||||
const recentEmojis = useRecentEmoji(mx, 21);
|
||||
|
||||
const contentScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const searchList = useMemo(() => {
|
||||
let list: Array<PackImageReader | IEmoji> = [];
|
||||
list = list.concat(imagePacks.flatMap((pack) => pack.getImages(usage)));
|
||||
if (emojiTab) list = list.concat(emojis);
|
||||
return list;
|
||||
}, [emojiTab, usage, imagePacks]);
|
||||
|
||||
const [result, search, resetSearch] = useAsyncSearch(
|
||||
searchList,
|
||||
getEmoticonSearchStr,
|
||||
SEARCH_OPTIONS
|
||||
);
|
||||
|
||||
const searchedItems = result?.items.slice(0, 100);
|
||||
|
||||
const handleOnChange: ChangeEventHandler<HTMLInputElement> = useDebounce(
|
||||
useCallback(
|
||||
(evt) => {
|
||||
const term = evt.target.value;
|
||||
if (term) search(term);
|
||||
else resetSearch();
|
||||
},
|
||||
[search, resetSearch]
|
||||
),
|
||||
{ wait: 200 }
|
||||
);
|
||||
|
||||
const syncActiveGroupId = useCallback(() => {
|
||||
const targetEl = contentScrollRef.current;
|
||||
if (!targetEl) return;
|
||||
const groupEls = Array.from(targetEl.querySelectorAll('div[data-group-id]')) as HTMLElement[];
|
||||
const groupEl = groupEls.find((el) => isIntersectingScrollView(targetEl, el));
|
||||
const groupId = groupEl?.getAttribute('data-group-id') ?? undefined;
|
||||
setActiveGroupId(groupId);
|
||||
}, [setActiveGroupId]);
|
||||
|
||||
const handleOnScroll: UIEventHandler<HTMLDivElement> = useThrottle(syncActiveGroupId, {
|
||||
wait: 500,
|
||||
});
|
||||
|
||||
const handleScrollToGroup = (groupId: string) => {
|
||||
setActiveGroupId(groupId);
|
||||
const groupElement = document.getElementById(getDOMGroupId(groupId));
|
||||
groupElement?.scrollIntoView();
|
||||
};
|
||||
|
||||
const handleEmojiClick: MouseEventHandler = (evt) => {
|
||||
const targetEl = targetFromEvent(evt.nativeEvent, 'button');
|
||||
if (!targetEl) return;
|
||||
const emojiInfo = getEmojiItemInfo(targetEl);
|
||||
if (!emojiInfo) return;
|
||||
if (emojiInfo.type === EmojiType.Emoji) {
|
||||
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
||||
if (!evt.altKey && !evt.shiftKey) {
|
||||
if (addToRecentEmoji) {
|
||||
addRecentEmoji(mx, emojiInfo.data);
|
||||
}
|
||||
requestClose();
|
||||
}
|
||||
}
|
||||
if (emojiInfo.type === EmojiType.CustomEmoji) {
|
||||
onCustomEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
||||
if (!evt.altKey && !evt.shiftKey) requestClose();
|
||||
}
|
||||
if (emojiInfo.type === EmojiType.Sticker) {
|
||||
onStickerSelect?.(emojiInfo.data, emojiInfo.shortcode, emojiInfo.label);
|
||||
if (!evt.altKey && !evt.shiftKey) requestClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextCustomEmojiSelect = (textEmoji: string) => {
|
||||
onCustomEmojiSelect?.(textEmoji, textEmoji);
|
||||
requestClose();
|
||||
};
|
||||
|
||||
const handleEmojiPreview = useCallback(
|
||||
(element: HTMLButtonElement) => {
|
||||
|
|
@ -446,13 +327,166 @@ export function EmojiBoard({
|
|||
handleEmojiPreview(targetEl);
|
||||
};
|
||||
|
||||
// Reset scroll top on search and tab change
|
||||
useEffect(() => {
|
||||
syncActiveGroupId();
|
||||
contentScrollRef.current?.scrollTo({
|
||||
top: 0,
|
||||
return (
|
||||
<Scroll ref={contentScrollRef} size="400" onKeyDown={preventScrollWithArrowKey} hideTrack>
|
||||
<Box
|
||||
onClick={onGroupItemClick}
|
||||
onMouseMove={handleEmojiHover}
|
||||
onFocus={handleEmojiFocus}
|
||||
direction="Column"
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Scroll>
|
||||
);
|
||||
}
|
||||
|
||||
const DefaultEmojiPreview: PreviewData = { key: '🙂', shortcode: 'slight_smile' };
|
||||
|
||||
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||
limit: 1000,
|
||||
matchOptions: {
|
||||
contain: true,
|
||||
},
|
||||
};
|
||||
|
||||
const VIRTUAL_OVER_SCAN = 2;
|
||||
|
||||
type EmojiBoardProps = {
|
||||
tab?: EmojiBoardTab;
|
||||
onTabChange?: (tab: EmojiBoardTab) => void;
|
||||
imagePackRooms: Room[];
|
||||
requestClose: () => void;
|
||||
returnFocusOnDeactivate?: boolean;
|
||||
onEmojiSelect?: (unicode: string, shortcode: string) => void;
|
||||
onCustomEmojiSelect?: (mxc: string, shortcode: string) => void;
|
||||
onStickerSelect?: (mxc: string, shortcode: string, label: string) => void;
|
||||
allowTextCustomEmoji?: boolean;
|
||||
addToRecentEmoji?: boolean;
|
||||
};
|
||||
|
||||
export function EmojiBoard({
|
||||
tab = EmojiBoardTab.Emoji,
|
||||
onTabChange,
|
||||
imagePackRooms,
|
||||
requestClose,
|
||||
returnFocusOnDeactivate,
|
||||
onEmojiSelect,
|
||||
onCustomEmojiSelect,
|
||||
onStickerSelect,
|
||||
allowTextCustomEmoji,
|
||||
addToRecentEmoji = true,
|
||||
}: EmojiBoardProps) {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const emojiTab = tab === EmojiBoardTab.Emoji;
|
||||
const usage = emojiTab ? ImageUsage.Emoticon : ImageUsage.Sticker;
|
||||
|
||||
const previewAtom = useMemo(
|
||||
() => createPreviewDataAtom(emojiTab ? DefaultEmojiPreview : undefined),
|
||||
[emojiTab]
|
||||
);
|
||||
const activeGroupIdAtom = useMemo(() => atom<string | undefined>(undefined), []);
|
||||
const setActiveGroupId = useSetAtom(activeGroupIdAtom);
|
||||
const imagePacks = useRelevantImagePacks(usage, imagePackRooms);
|
||||
const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks);
|
||||
const groups = emojiTab ? emojiGroupItems : stickerGroupItems;
|
||||
const renderItem = useItemRenderer(tab);
|
||||
|
||||
const searchList = useMemo(() => {
|
||||
let list: Array<PackImageReader | IEmoji> = [];
|
||||
list = list.concat(imagePacks.flatMap((pack) => pack.getImages(usage)));
|
||||
if (emojiTab) list = list.concat(emojis);
|
||||
return list;
|
||||
}, [emojiTab, usage, imagePacks]);
|
||||
|
||||
const [result, search, resetSearch] = useAsyncSearch(
|
||||
searchList,
|
||||
getEmoticonSearchStr,
|
||||
SEARCH_OPTIONS
|
||||
);
|
||||
|
||||
const searchedItems = result?.items.slice(0, 100);
|
||||
|
||||
const handleOnChange: ChangeEventHandler<HTMLInputElement> = useDebounce(
|
||||
useCallback(
|
||||
(evt) => {
|
||||
const term = evt.target.value;
|
||||
if (term) search(term);
|
||||
else resetSearch();
|
||||
},
|
||||
[search, resetSearch]
|
||||
),
|
||||
{ wait: 200 }
|
||||
);
|
||||
|
||||
const contentScrollRef = useRef<HTMLDivElement>(null);
|
||||
const virtualBaseRef = useRef<HTMLDivElement>(null);
|
||||
const virtualizer = useVirtualizer({
|
||||
count: groups.length,
|
||||
getScrollElement: () => contentScrollRef.current,
|
||||
estimateSize: () => 40,
|
||||
overscan: VIRTUAL_OVER_SCAN,
|
||||
});
|
||||
}, [result, emojiTab, syncActiveGroupId]);
|
||||
const vItems = virtualizer.getVirtualItems();
|
||||
|
||||
const handleGroupItemClick: MouseEventHandler = (evt) => {
|
||||
const targetEl = targetFromEvent(evt.nativeEvent, 'button');
|
||||
const emojiInfo = targetEl && getEmojiItemInfo(targetEl);
|
||||
if (!emojiInfo) return;
|
||||
|
||||
if (emojiInfo.type === EmojiType.Emoji) {
|
||||
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
||||
if (!evt.altKey && !evt.shiftKey && addToRecentEmoji) {
|
||||
addRecentEmoji(mx, emojiInfo.data);
|
||||
}
|
||||
}
|
||||
if (emojiInfo.type === EmojiType.CustomEmoji) {
|
||||
onCustomEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
||||
}
|
||||
if (emojiInfo.type === EmojiType.Sticker) {
|
||||
onStickerSelect?.(emojiInfo.data, emojiInfo.shortcode, emojiInfo.label);
|
||||
}
|
||||
if (!evt.altKey && !evt.shiftKey) requestClose();
|
||||
};
|
||||
|
||||
const handleTextCustomEmojiSelect = (textEmoji: string) => {
|
||||
onCustomEmojiSelect?.(textEmoji, textEmoji);
|
||||
requestClose();
|
||||
};
|
||||
|
||||
const handleScrollToGroup = (groupId: string) => {
|
||||
const groupIndex = groups.findIndex((group) => group.id === groupId);
|
||||
virtualizer.scrollToIndex(groupIndex);
|
||||
};
|
||||
|
||||
// sync active sidebar tab with scroll
|
||||
useEffect(() => {
|
||||
const scrollElement = contentScrollRef.current;
|
||||
if (scrollElement) {
|
||||
const scrollTop = scrollElement.offsetTop + scrollElement.scrollTop;
|
||||
const offsetTop = virtualBaseRef.current?.offsetTop ?? 0;
|
||||
const inViewVItem = vItems.find((vItem) => scrollTop < offsetTop + vItem.end);
|
||||
|
||||
const group = inViewVItem ? groups[inViewVItem?.index] : undefined;
|
||||
setActiveGroupId(group?.id);
|
||||
}
|
||||
}, [vItems, groups, setActiveGroupId, result?.query]);
|
||||
|
||||
// reset scroll position on search
|
||||
useEffect(() => {
|
||||
const scrollElement = contentScrollRef.current;
|
||||
if (scrollElement) {
|
||||
scrollElement.scrollTo({ top: 0 });
|
||||
}
|
||||
}, [result?.query]);
|
||||
|
||||
// reset scroll position on tab change
|
||||
useEffect(() => {
|
||||
if (groups.length > 0) {
|
||||
virtualizer.scrollToIndex(0);
|
||||
}
|
||||
}, [tab, virtualizer, groups]);
|
||||
|
||||
return (
|
||||
<FocusTrap
|
||||
|
|
@ -474,6 +508,7 @@ export function EmojiBoard({
|
|||
<Box direction="Column" gap="200">
|
||||
{onTabChange && <EmojiBoardTabs tab={tab} onTabChange={onTabChange} />}
|
||||
<SearchInput
|
||||
key={tab}
|
||||
query={result?.query}
|
||||
onChange={handleOnChange}
|
||||
allowTextCustomEmoji={allowTextCustomEmoji}
|
||||
|
|
@ -485,58 +520,59 @@ export function EmojiBoard({
|
|||
emojiTab ? (
|
||||
<EmojiSidebar
|
||||
activeGroupAtom={activeGroupIdAtom}
|
||||
handleOpenGroup={handleScrollToGroup}
|
||||
packs={imagePacks}
|
||||
groups={emojiGroups}
|
||||
icons={emojiGroupIcons}
|
||||
labels={emojiGroupLabels}
|
||||
onScrollToGroup={handleScrollToGroup}
|
||||
/>
|
||||
) : (
|
||||
<StickerSidebar
|
||||
activeGroupAtom={activeGroupIdAtom}
|
||||
handleOpenGroup={handleScrollToGroup}
|
||||
packs={imagePacks}
|
||||
onScrollToGroup={handleScrollToGroup}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Scroll
|
||||
ref={contentScrollRef}
|
||||
size="400"
|
||||
onScroll={handleOnScroll}
|
||||
onKeyDown={preventScrollWithArrowKey}
|
||||
hideTrack
|
||||
>
|
||||
<Box
|
||||
onClick={handleEmojiClick}
|
||||
onMouseMove={handleEmojiHover}
|
||||
onFocus={handleEmojiFocus}
|
||||
direction="Column"
|
||||
gap="200"
|
||||
<EmojiGroupHolder
|
||||
key={tab}
|
||||
contentScrollRef={contentScrollRef}
|
||||
previewAtom={previewAtom}
|
||||
onGroupItemClick={handleGroupItemClick}
|
||||
>
|
||||
{searchedItems && (
|
||||
<SearchGroup
|
||||
mx={mx}
|
||||
tab={tab}
|
||||
<EmojiGroup
|
||||
id={SEARCH_GROUP_ID}
|
||||
label={searchedItems.length ? 'Search Results' : 'No Results found'}
|
||||
emojis={searchedItems}
|
||||
useAuthentication={useAuthentication}
|
||||
/>
|
||||
>
|
||||
{searchedItems.map(renderItem)}
|
||||
</EmojiGroup>
|
||||
)}
|
||||
{emojiTab ? (
|
||||
<EmojiGroups
|
||||
recentEmojis={recentEmojis}
|
||||
packs={imagePacks}
|
||||
groups={emojiGroups}
|
||||
labels={emojiGroupLabels}
|
||||
/>
|
||||
) : (
|
||||
<StickerGroups packs={imagePacks} />
|
||||
)}
|
||||
</Box>
|
||||
</Scroll>
|
||||
<div
|
||||
ref={virtualBaseRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: virtualizer.getTotalSize(),
|
||||
}}
|
||||
>
|
||||
{vItems.map((vItem) => {
|
||||
const group = groups[vItem.index];
|
||||
|
||||
return (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
style={{ paddingTop: config.space.S200 }}
|
||||
ref={virtualizer.measureElement}
|
||||
key={vItem.index}
|
||||
>
|
||||
<EmojiGroup key={group.id} id={group.id} label={group.name}>
|
||||
{group.items.map(renderItem)}
|
||||
</EmojiGroup>
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{tab === EmojiBoardTab.Sticker && groups.length === 0 && <NoStickerPacks />}
|
||||
</EmojiGroupHolder>
|
||||
</Box>
|
||||
<Preview previewAtom={previewAtom} />
|
||||
</EmojiBoardLayout>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue