Files
fluxer/fluxer_app/src/components/channel/MobileEmojiPicker.tsx
2026-01-03 06:44:40 +01:00

189 lines
6.8 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {observer} from 'mobx-react-lite';
import React from 'react';
import {EmojiPickerCategoryList} from '~/components/channel/emoji-picker/EmojiPickerCategoryList';
import {EMOJI_SPRITE_SIZE} from '~/components/channel/emoji-picker/EmojiPickerConstants';
import {EmojiPickerSearchBar} from '~/components/channel/emoji-picker/EmojiPickerSearchBar';
import {useEmojiCategories} from '~/components/channel/emoji-picker/hooks/useEmojiCategories';
import {useVirtualRows} from '~/components/channel/emoji-picker/hooks/useVirtualRows';
import {VirtualizedRow} from '~/components/channel/emoji-picker/VirtualRow';
import mobileStyles from '~/components/channel/MobileEmojiPicker.module.css';
import {
ExpressionPickerHeaderPortal,
useExpressionPickerHeaderPortal,
} from '~/components/popouts/ExpressionPickerPopout';
import {Scroller, type ScrollerHandle} from '~/components/uikit/Scroller';
import {useForceUpdate} from '~/hooks/useForceUpdate';
import {ComponentDispatch} from '~/lib/ComponentDispatch';
import UnicodeEmojis, {EMOJI_SPRITES} from '~/lib/UnicodeEmojis';
import ChannelStore from '~/stores/ChannelStore';
import EmojiStore, {type Emoji, normalizeEmojiSearchQuery} from '~/stores/EmojiStore';
export const MobileEmojiPicker = observer(
({
channelId,
handleSelect,
externalSearchTerm,
externalSetSearchTerm,
hideSearchBar = false,
}: {
channelId?: string;
handleSelect: (emoji: Emoji, shiftKey?: boolean) => void;
externalSearchTerm?: string;
externalSetSearchTerm?: (term: string) => void;
hideSearchBar?: boolean;
}) => {
const headerPortalContext = useExpressionPickerHeaderPortal();
const hasPortal = Boolean(headerPortalContext?.headerPortalElement);
const [internalSearchTerm, setInternalSearchTerm] = React.useState('');
const [hoveredEmoji, setHoveredEmoji] = React.useState<Emoji | null>(null);
const [renderedEmojis, setRenderedEmojis] = React.useState<Array<Emoji>>([]);
const [allEmojis, setAllEmojis] = React.useState<Array<Emoji>>([]);
const scrollerRef = React.useRef<ScrollerHandle>(null);
const emojiRefs = React.useRef<Map<string, HTMLButtonElement>>(new Map());
const channel = channelId ? (ChannelStore.getChannel(channelId) ?? null) : null;
const categoryRefs = React.useRef<Map<string, HTMLDivElement>>(new Map());
const forceUpdate = useForceUpdate();
const skinTone = EmojiStore.skinTone;
const searchTerm = externalSearchTerm ?? internalSearchTerm;
const setSearchTerm = externalSetSearchTerm ?? setInternalSearchTerm;
const normalizedSearchTerm = React.useMemo(() => normalizeEmojiSearchQuery(searchTerm), [searchTerm]);
const spriteSheetSizes = React.useMemo(() => {
const nonDiversitySize = [
`${EMOJI_SPRITE_SIZE * EMOJI_SPRITES.NonDiversityPerRow}px`,
`${EMOJI_SPRITE_SIZE * Math.ceil(UnicodeEmojis.numNonDiversitySprites / EMOJI_SPRITES.NonDiversityPerRow)}px`,
].join(' ');
const diversitySize = [
`${EMOJI_SPRITE_SIZE * EMOJI_SPRITES.DiversityPerRow}px`,
`${EMOJI_SPRITE_SIZE * Math.ceil(UnicodeEmojis.numDiversitySprites / EMOJI_SPRITES.DiversityPerRow)}px`,
].join(' ');
return {nonDiversitySize, diversitySize};
}, []);
React.useEffect(() => {
const emojis = EmojiStore.search(channel, normalizedSearchTerm).slice();
setRenderedEmojis(emojis);
}, [channel, normalizedSearchTerm]);
React.useEffect(() => {
const emojis = EmojiStore.search(channel, '').slice();
setAllEmojis(emojis);
}, [channel]);
React.useEffect(() => {
return ComponentDispatch.subscribe('EMOJI_PICKER_RERENDER', forceUpdate);
});
const {customEmojisByGuildId, unicodeEmojisByCategory, favoriteEmojis, frequentlyUsedEmojis} = useEmojiCategories(
allEmojis,
renderedEmojis,
);
const showFrequentlyUsedButton = frequentlyUsedEmojis.length > 0 && !normalizedSearchTerm;
const virtualRows = useVirtualRows(
normalizedSearchTerm,
renderedEmojis,
favoriteEmojis,
frequentlyUsedEmojis,
customEmojisByGuildId,
unicodeEmojisByCategory,
8,
);
const handleCategoryClick = (category: string) => {
const element = categoryRefs.current.get(category);
if (element) {
scrollerRef.current?.scrollIntoViewNode({node: element, shouldScrollToStart: true});
}
};
const handleHover = (emoji: Emoji | null) => {
setHoveredEmoji(emoji);
};
const searchBar = !hideSearchBar ? (
<EmojiPickerSearchBar searchTerm={searchTerm} setSearchTerm={setSearchTerm} hoveredEmoji={hoveredEmoji} />
) : null;
return (
<div className={mobileStyles.container}>
{hasPortal && searchBar ? <ExpressionPickerHeaderPortal>{searchBar}</ExpressionPickerHeaderPortal> : null}
<div className={mobileStyles.mobileEmojiPicker}>
{!hasPortal && searchBar}
<div className={mobileStyles.bodyWrapper}>
<div className={mobileStyles.emojiPickerListWrapper} role="presentation">
<Scroller
ref={scrollerRef}
className={`${mobileStyles.list} ${mobileStyles.listWrapper}`}
key="mobile-emoji-picker-scroller"
>
{virtualRows.map((row) => (
<div
key={`${row.type}-${row.index}`}
ref={
row.type === 'header'
? (el) => {
if (el && 'category' in row) {
categoryRefs.current.set(row.category, el);
}
}
: undefined
}
>
<VirtualizedRow
row={row}
handleHover={handleHover}
handleSelect={handleSelect}
skinTone={skinTone}
spriteSheetSizes={spriteSheetSizes}
channel={channel}
gridColumns={8}
hoveredEmoji={hoveredEmoji}
selectedRow={-1}
selectedColumn={-1}
emojiRowIndex={0}
emojiRefs={emojiRefs}
/>
</div>
))}
</Scroller>
</div>
</div>
<div className={mobileStyles.categoryListBottom}>
<EmojiPickerCategoryList
customEmojisByGuildId={customEmojisByGuildId}
unicodeEmojisByCategory={unicodeEmojisByCategory}
handleCategoryClick={handleCategoryClick}
horizontal={true}
showFrequentlyUsedButton={showFrequentlyUsedButton}
/>
</div>
</div>
</div>
);
},
);