mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-07 07:40:29 +03:00
Refactor state & Custom editor (#1190)
* Fix eslint * Enable ts strict mode * install folds, jotai & immer * Enable immer map/set * change cross-signing alert anim to 30 iteration * Add function to access matrix client * Add new types * Add disposable util * Add room utils * Add mDirect list atom * Add invite list atom * add room list atom * add utils for jotai atoms * Add room id to parents atom * Add mute list atom * Add room to unread atom * Use hook to bind atoms with sdk * Add settings atom * Add settings hook * Extract set settings hook * Add Sidebar components * WIP * Add bind atoms hook * Fix init muted room list atom * add navigation atoms * Add custom editor * Fix hotkeys * Update folds * Add editor output function * Add matrix client context * Add tooltip to editor toolbar items * WIP - Add editor to room input * Refocus editor on toolbar item click * Add Mentions - WIP * update folds * update mention focus outline * rename emoji element type * Add auto complete menu * add autocomplete query functions * add index file for editor * fix bug in getPrevWord function * Show room mention autocomplete * Add async search function * add use async search hook * use async search in room mention autocomplete * remove folds prefer font for now * allow number array in async search * reset search with empty query * Autocomplete unknown room mention * Autocomplete first room mention on tab * fix roomAliasFromQueryText * change mention color to primary * add isAlive hook * add getMxIdLocalPart to mx utils * fix getRoomAvatarUrl size * fix types * add room members hook * fix bug in room mention * add user mention autocomplete * Fix async search giving prev result after no match * update folds * add twemoji font * add use state provider hook * add prevent scroll with arrow key util * add ts to custom-emoji and emoji files * add types * add hook for emoji group labels * add hook for emoji group icons * add emoji board with basic emoji * add emojiboard in room input * select multiple emoji with shift press * display custom emoji in emojiboard * Add emoji preview * focus element on hover * update folds * position emojiboard properly * convert recent-emoji.js to ts * add use recent emoji hook * add io.element.recent_emoji to account data evt * Render recent emoji in emoji board * show custom emoji from parent spaces * show room emoji * improve emoji sidebar * update folds * fix pack avatar and name fallback in emoji board * add stickers to emoji board * fix bug in emoji preview * Add sticker icon in room input * add debounce hook * add search in emoji board * Optimize emoji board * fix emoji board sidebar divider * sync emojiboard sidebar with scroll & update ui * Add use throttle hook * support custom emoji in editor * remove duplicate emoji selection function * fix emoji and mention spacing * add emoticon autocomplete in editor * fix string * makes emoji size relative to font size in editor * add option to render link element * add spoiler in editor * fix sticker in emoji board search using wrong type * render custom placeholder * update hotkey for block quote and block code * add terminate search function in async search * add getImageInfo to matrix utils * send stickers * add resize observer hook * move emoji board component hooks in hooks dir * prevent editor expand hides room timeline * send typing notifications * improve emoji style and performance * fix imports * add on paste param to editor * add selectFile utils * add file picker hook * add file paste handler hook * add file drop handler * update folds * Add file upload card * add bytes to size util * add blurHash util * add await to js lib * add browser-encrypt-attachment types * add list atom * convert mimetype file to ts * add matrix types * add matrix file util * add file related dom utils * add common utils * add upload atom * add room input draft atom * add upload card renderer component * add upload board component * add support for file upload in editor * send files with message / enter * fix circular deps * store editor toolbar state in local store * move msg content util to separate file * store msg draft on room switch * fix following member not updating on msg sent * add theme for folds component * fix system default theme * Add reply support in editor * prevent initMatrix to init multiple time * add state event hooks * add async callback hook * Show tombstone info for tombstone room * fix room tombstone component border * add power level hook * Add room input placeholder component * Show input placeholder for muted member
This commit is contained in:
parent
2055d7a07f
commit
0b06bed1db
128 changed files with 8799 additions and 409 deletions
860
src/app/components/emoji-board/EmojiBoard.tsx
Normal file
860
src/app/components/emoji-board/EmojiBoard.tsx
Normal file
|
|
@ -0,0 +1,860 @@
|
|||
import React, {
|
||||
ChangeEventHandler,
|
||||
FocusEventHandler,
|
||||
MouseEventHandler,
|
||||
UIEventHandler,
|
||||
ReactNode,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Input,
|
||||
Line,
|
||||
Scroll,
|
||||
Text,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
as,
|
||||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import isHotkey from 'is-hotkey';
|
||||
import classNames from 'classnames';
|
||||
import { MatrixClient, Room } from 'matrix-js-sdk';
|
||||
import { atom, useAtomValue, useSetAtom } from 'jotai';
|
||||
|
||||
import * as css from './EmojiBoard.css';
|
||||
import { EmojiGroupId, IEmoji, IEmojiGroup, emojiGroups, emojis } from '../../plugins/emoji';
|
||||
import { IEmojiGroupLabels, useEmojiGroupLabels } from './useEmojiGroupLabels';
|
||||
import { IEmojiGroupIcons, useEmojiGroupIcons } from './useEmojiGroupIcons';
|
||||
import { preventScrollWithArrowKey } from '../../utils/keyboard';
|
||||
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
|
||||
import { ExtendedPackImage, ImagePack, PackUsage } from '../../plugins/custom-emoji';
|
||||
import { isUserId } from '../../utils/matrix';
|
||||
import { editableActiveElement, inVisibleScrollArea, targetFromEvent } from '../../utils/dom';
|
||||
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import { useThrottle } from '../../hooks/useThrottle';
|
||||
|
||||
const RECENT_GROUP_ID = 'recent_group';
|
||||
const SEARCH_GROUP_ID = 'search_group';
|
||||
|
||||
export enum EmojiBoardTab {
|
||||
Emoji = 'Emoji',
|
||||
Sticker = 'Sticker',
|
||||
}
|
||||
|
||||
enum EmojiType {
|
||||
Emoji = 'emoji',
|
||||
CustomEmoji = 'customEmoji',
|
||||
Sticker = 'sticker',
|
||||
}
|
||||
|
||||
export type EmojiItemInfo = {
|
||||
type: EmojiType;
|
||||
data: string;
|
||||
shortcode: string;
|
||||
};
|
||||
|
||||
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 shortcode = element.getAttribute('data-emoji-shortcode');
|
||||
|
||||
if (type && data && shortcode)
|
||||
return {
|
||||
type,
|
||||
data,
|
||||
shortcode,
|
||||
};
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const activeGroupIdAtom = atom<string | undefined>(undefined);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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" />;
|
||||
}
|
||||
|
||||
function Header({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Box className={css.Header} direction="Column" shrink="No">
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function Content({ children }: { children: ReactNode }) {
|
||||
return <Box grow="Yes">{children}</Box>;
|
||||
}
|
||||
|
||||
function Footer({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Box shrink="No" className={css.Footer} gap="300" alignItems="Center">
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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.Emoji ? 'Solid' : 'None'}
|
||||
size="500"
|
||||
onClick={() => onTabChange(EmojiBoardTab.Emoji)}
|
||||
>
|
||||
<Text as="span" size="L400">
|
||||
Emoji
|
||||
</Text>
|
||||
</Badge>
|
||||
<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>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentEmojiSidebarStack({ onItemClick }: { onItemClick: (id: string) => void }) {
|
||||
const activeGroupId = useAtomValue(activeGroupIdAtom);
|
||||
|
||||
return (
|
||||
<SidebarStack>
|
||||
<SidebarBtn
|
||||
active={activeGroupId === RECENT_GROUP_ID}
|
||||
id={RECENT_GROUP_ID}
|
||||
label="Recent"
|
||||
onItemClick={() => onItemClick(RECENT_GROUP_ID)}
|
||||
>
|
||||
<Icon src={Icons.RecentClock} filled={activeGroupId === RECENT_GROUP_ID} />
|
||||
</SidebarBtn>
|
||||
</SidebarStack>
|
||||
);
|
||||
}
|
||||
|
||||
function ImagePackSidebarStack({
|
||||
mx,
|
||||
packs,
|
||||
usage,
|
||||
onItemClick,
|
||||
}: {
|
||||
mx: MatrixClient;
|
||||
packs: ImagePack[];
|
||||
usage: PackUsage;
|
||||
onItemClick: (id: string) => void;
|
||||
}) {
|
||||
const activeGroupId = useAtomValue(activeGroupIdAtom);
|
||||
return (
|
||||
<SidebarStack>
|
||||
{usage === PackUsage.Emoticon && <SidebarDivider />}
|
||||
{packs.map((pack) => {
|
||||
let label = pack.displayName;
|
||||
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),
|
||||
}}
|
||||
src={mx.mxcUrlToHttp(pack.getPackAvatarUrl(usage) ?? '') || pack.avatarUrl}
|
||||
alt={label || 'Unknown Pack'}
|
||||
/>
|
||||
</SidebarBtn>
|
||||
);
|
||||
})}
|
||||
</SidebarStack>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchEmojiGroup({
|
||||
mx,
|
||||
tab,
|
||||
label,
|
||||
id,
|
||||
emojis: searchResult,
|
||||
}: {
|
||||
mx: MatrixClient;
|
||||
tab: EmojiBoardTab;
|
||||
label: string;
|
||||
id: string;
|
||||
emojis: Array<ExtendedPackImage | IEmoji>;
|
||||
}) {
|
||||
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={mx.mxcUrlToHttp(emoji.url) ?? 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={mx.mxcUrlToHttp(emoji.url) ?? emoji.url}
|
||||
/>
|
||||
</StickerItem>
|
||||
)
|
||||
)}
|
||||
</EmojiGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export const CustomEmojiGroups = memo(
|
||||
({ mx, groups }: { mx: MatrixClient; groups: ImagePack[] }) => (
|
||||
<>
|
||||
{groups.map((pack) => (
|
||||
<EmojiGroup key={pack.id} id={pack.id} label={pack.displayName || 'Unknown'}>
|
||||
{pack.getEmojis().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={mx.mxcUrlToHttp(image.url) ?? image.url}
|
||||
/>
|
||||
</EmojiItem>
|
||||
))}
|
||||
</EmojiGroup>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
);
|
||||
|
||||
export const StickerGroups = memo(({ mx, groups }: { mx: MatrixClient; groups: ImagePack[] }) => (
|
||||
<>
|
||||
{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.displayName || 'Unknown'}>
|
||||
{pack.getStickers().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={mx.mxcUrlToHttp(image.url) ?? 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 getSearchListItemStr = (item: ExtendedPackImage | IEmoji) => `:${item.shortcode}:`;
|
||||
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||
limit: 26,
|
||||
matchOptions: {
|
||||
contain: true,
|
||||
},
|
||||
};
|
||||
|
||||
export function EmojiBoard({
|
||||
tab = EmojiBoardTab.Emoji,
|
||||
onTabChange,
|
||||
imagePackRooms,
|
||||
requestClose,
|
||||
returnFocusOnDeactivate,
|
||||
onEmojiSelect,
|
||||
onCustomEmojiSelect,
|
||||
onStickerSelect,
|
||||
}: {
|
||||
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) => void;
|
||||
}) {
|
||||
const emojiTab = tab === EmojiBoardTab.Emoji;
|
||||
const stickerTab = tab === EmojiBoardTab.Sticker;
|
||||
const usage = emojiTab ? PackUsage.Emoticon : PackUsage.Sticker;
|
||||
|
||||
const setActiveGroupId = useSetAtom(activeGroupIdAtom);
|
||||
const mx = useMatrixClient();
|
||||
const emojiGroupLabels = useEmojiGroupLabels();
|
||||
const emojiGroupIcons = useEmojiGroupIcons();
|
||||
const imagePacks = useRelevantImagePacks(mx, usage, imagePackRooms);
|
||||
const recentEmojis = useRecentEmoji(mx, 21);
|
||||
|
||||
const contentScrollRef = useRef<HTMLDivElement>(null);
|
||||
const emojiPreviewRef = useRef<HTMLDivElement>(null);
|
||||
const emojiPreviewTextRef = useRef<HTMLParagraphElement>(null);
|
||||
|
||||
const searchList = useMemo(() => {
|
||||
let list: Array<ExtendedPackImage | IEmoji> = [];
|
||||
list = list.concat(imagePacks.flatMap((pack) => pack.getImagesFor(usage)));
|
||||
if (emojiTab) list = list.concat(emojis);
|
||||
return list;
|
||||
}, [emojiTab, usage, imagePacks]);
|
||||
|
||||
const [result, search] = useAsyncSearch(searchList, getSearchListItemStr, SEARCH_OPTIONS);
|
||||
|
||||
const handleOnChange: ChangeEventHandler<HTMLInputElement> = useDebounce(
|
||||
useCallback(
|
||||
(evt) => {
|
||||
const term = evt.target.value;
|
||||
search(term);
|
||||
},
|
||||
[search]
|
||||
),
|
||||
{ wait: 200 }
|
||||
);
|
||||
|
||||
const syncActiveGroupId = useCallback(() => {
|
||||
const targetEl = contentScrollRef.current;
|
||||
if (!targetEl) return;
|
||||
const groupEls = [...targetEl.querySelectorAll('div[data-group-id]')] as HTMLElement[];
|
||||
const groupEl = groupEls.find((el) => inVisibleScrollArea(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) 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);
|
||||
if (!evt.altKey && !evt.shiftKey) requestClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmojiPreview = useCallback(
|
||||
(element: HTMLButtonElement) => {
|
||||
const emojiInfo = getEmojiItemInfo(element);
|
||||
if (!emojiInfo || !emojiPreviewTextRef.current) return;
|
||||
if (emojiInfo.type === EmojiType.Emoji && emojiPreviewRef.current) {
|
||||
emojiPreviewRef.current.textContent = emojiInfo.data;
|
||||
} else if (emojiInfo.type === EmojiType.CustomEmoji && emojiPreviewRef.current) {
|
||||
const img = document.createElement('img');
|
||||
img.className = css.CustomEmojiImg;
|
||||
img.setAttribute('src', mx.mxcUrlToHttp(emojiInfo.data) || emojiInfo.data);
|
||||
img.setAttribute('alt', emojiInfo.shortcode);
|
||||
emojiPreviewRef.current.textContent = '';
|
||||
emojiPreviewRef.current.appendChild(img);
|
||||
}
|
||||
emojiPreviewTextRef.current.textContent = `:${emojiInfo.shortcode}:`;
|
||||
},
|
||||
[mx]
|
||||
);
|
||||
|
||||
const throttleEmojiHover = useThrottle(handleEmojiPreview, {
|
||||
wait: 200,
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
const handleEmojiHover: MouseEventHandler = (evt) => {
|
||||
const targetEl = targetFromEvent(evt.nativeEvent, 'button') as HTMLButtonElement | undefined;
|
||||
if (!targetEl) return;
|
||||
throttleEmojiHover(targetEl);
|
||||
};
|
||||
|
||||
const handleEmojiFocus: FocusEventHandler = (evt) => {
|
||||
const targetEl = evt.target as HTMLButtonElement;
|
||||
handleEmojiPreview(targetEl);
|
||||
};
|
||||
|
||||
// Reset scroll top on search and tab change
|
||||
useEffect(() => {
|
||||
syncActiveGroupId();
|
||||
contentScrollRef.current?.scrollTo({
|
||||
top: 0,
|
||||
});
|
||||
}, [result, emojiTab, syncActiveGroupId]);
|
||||
|
||||
return (
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
returnFocusOnDeactivate,
|
||||
initialFocus: false,
|
||||
onDeactivate: requestClose,
|
||||
clickOutsideDeactivates: true,
|
||||
allowOutsideClick: true,
|
||||
isKeyForward: (evt: KeyboardEvent) =>
|
||||
!editableActiveElement() && isHotkey(['arrowdown', 'arrowright'], evt),
|
||||
isKeyBackward: (evt: KeyboardEvent) =>
|
||||
!editableActiveElement() && isHotkey(['arrowup', 'arrowleft'], evt),
|
||||
}}
|
||||
>
|
||||
<EmojiBoardLayout
|
||||
header={
|
||||
<Header>
|
||||
<Box direction="Column" gap="200">
|
||||
{onTabChange && <EmojiBoardTabs tab={tab} onTabChange={onTabChange} />}
|
||||
<Input
|
||||
variant="SurfaceVariant"
|
||||
size="400"
|
||||
placeholder="Search"
|
||||
maxLength={50}
|
||||
after={<Icon src={Icons.Search} size="50" />}
|
||||
onChange={handleOnChange}
|
||||
autoFocus
|
||||
/>
|
||||
</Box>
|
||||
</Header>
|
||||
}
|
||||
sidebar={
|
||||
<Sidebar>
|
||||
{emojiTab && recentEmojis.length > 0 && (
|
||||
<RecentEmojiSidebarStack onItemClick={handleScrollToGroup} />
|
||||
)}
|
||||
{imagePacks.length > 0 && (
|
||||
<ImagePackSidebarStack
|
||||
mx={mx}
|
||||
usage={usage}
|
||||
packs={imagePacks}
|
||||
onItemClick={handleScrollToGroup}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
) : (
|
||||
imagePacks.length > 0 && (
|
||||
<Footer>
|
||||
<Text ref={emojiPreviewTextRef} size="H5" truncate>
|
||||
:smiley:
|
||||
</Text>
|
||||
</Footer>
|
||||
)
|
||||
)
|
||||
}
|
||||
>
|
||||
<Content>
|
||||
<Scroll
|
||||
ref={contentScrollRef}
|
||||
size="400"
|
||||
onScroll={handleOnScroll}
|
||||
onKeyDown={preventScrollWithArrowKey}
|
||||
hideTrack
|
||||
>
|
||||
<Box
|
||||
onClick={handleEmojiClick}
|
||||
onMouseMove={handleEmojiHover}
|
||||
onFocus={handleEmojiFocus}
|
||||
direction="Column"
|
||||
gap="200"
|
||||
>
|
||||
{result && (
|
||||
<SearchEmojiGroup
|
||||
mx={mx}
|
||||
tab={tab}
|
||||
id={SEARCH_GROUP_ID}
|
||||
label={result.items.length ? 'Search Results' : 'No Results found'}
|
||||
emojis={result.items}
|
||||
/>
|
||||
)}
|
||||
{emojiTab && recentEmojis.length > 0 && (
|
||||
<RecentEmojiGroup id={RECENT_GROUP_ID} label="Recent" emojis={recentEmojis} />
|
||||
)}
|
||||
{emojiTab && <CustomEmojiGroups mx={mx} groups={imagePacks} />}
|
||||
{stickerTab && <StickerGroups mx={mx} groups={imagePacks} />}
|
||||
{emojiTab && <NativeEmojiGroups groups={emojiGroups} labels={emojiGroupLabels} />}
|
||||
</Box>
|
||||
</Scroll>
|
||||
</Content>
|
||||
</EmojiBoardLayout>
|
||||
</FocusTrap>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue