301 lines
11 KiB
TypeScript
301 lines
11 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 {useLingui} from '@lingui/react/macro';
|
|
import {SmileySadIcon} from '@phosphor-icons/react';
|
|
import {observer} from 'mobx-react-lite';
|
|
import React from 'react';
|
|
import * as EmojiPickerActionCreators from '~/actions/EmojiPickerActionCreators';
|
|
import styles from '~/components/channel/EmojiPicker.module.css';
|
|
import {EmojiPickerCategoryList} from '~/components/channel/emoji-picker/EmojiPickerCategoryList';
|
|
import {EMOJI_SPRITE_SIZE} from '~/components/channel/emoji-picker/EmojiPickerConstants';
|
|
import {EmojiPickerInspector} from '~/components/channel/emoji-picker/EmojiPickerInspector';
|
|
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 {PremiumUpsellBanner} from '~/components/channel/PremiumUpsellBanner';
|
|
import {ExpressionPickerHeaderContext, ExpressionPickerHeaderPortal} from '~/components/popouts/ExpressionPickerPopout';
|
|
import {Scroller, type ScrollerHandle} from '~/components/uikit/Scroller';
|
|
import {useForceUpdate} from '~/hooks/useForceUpdate';
|
|
import {useSearchInputAutofocus} from '~/hooks/useSearchInputAutofocus';
|
|
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';
|
|
import {checkEmojiAvailability, shouldShowEmojiPremiumUpsell} from '~/utils/ExpressionPermissionUtils';
|
|
import {shouldShowPremiumFeatures} from '~/utils/PremiumUtils';
|
|
|
|
export const EmojiPicker = observer(
|
|
({channelId, handleSelect}: {channelId?: string; handleSelect: (emoji: Emoji, shiftKey?: boolean) => void}) => {
|
|
const headerContext = React.useContext(ExpressionPickerHeaderContext);
|
|
if (!headerContext) {
|
|
throw new Error(
|
|
'EmojiPicker must be rendered inside ExpressionPickerPopout so that the header portal is available.',
|
|
);
|
|
}
|
|
|
|
const [searchTerm, setSearchTerm] = 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 [selectedRow, setSelectedRow] = React.useState(-1);
|
|
const [selectedColumn, setSelectedColumn] = React.useState(-1);
|
|
const [shouldScrollOnSelection, setShouldScrollOnSelection] = React.useState(false);
|
|
const scrollerRef = React.useRef<ScrollerHandle>(null);
|
|
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
|
const emojiRefs = React.useRef<Map<string, HTMLButtonElement>>(new Map());
|
|
const normalizedSearchTerm = React.useMemo(() => normalizeEmojiSearchQuery(searchTerm), [searchTerm]);
|
|
|
|
const {i18n, t} = useLingui();
|
|
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 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);
|
|
if (emojis.length > 0) {
|
|
setSelectedRow(0);
|
|
setSelectedColumn(0);
|
|
} else {
|
|
setSelectedRow(-1);
|
|
setSelectedColumn(-1);
|
|
setHoveredEmoji(null);
|
|
}
|
|
}, [channel, normalizedSearchTerm]);
|
|
|
|
React.useEffect(() => {
|
|
const emojis = EmojiStore.search(channel, '').slice();
|
|
setAllEmojis(emojis);
|
|
}, [channel]);
|
|
|
|
React.useEffect(() => {
|
|
return ComponentDispatch.subscribe('EMOJI_PICKER_RERENDER', forceUpdate);
|
|
});
|
|
|
|
useSearchInputAutofocus(searchInputRef);
|
|
|
|
const {favoriteEmojis, frequentlyUsedEmojis, customEmojisByGuildId, unicodeEmojisByCategory} = useEmojiCategories(
|
|
allEmojis,
|
|
renderedEmojis,
|
|
);
|
|
const showFrequentlyUsedButton = frequentlyUsedEmojis.length > 0 && !normalizedSearchTerm;
|
|
const virtualRows = useVirtualRows(
|
|
normalizedSearchTerm,
|
|
renderedEmojis,
|
|
favoriteEmojis,
|
|
frequentlyUsedEmojis,
|
|
customEmojisByGuildId,
|
|
unicodeEmojisByCategory,
|
|
);
|
|
|
|
const showPremiumUpsell =
|
|
shouldShowPremiumFeatures() && shouldShowEmojiPremiumUpsell(channel) && !normalizedSearchTerm;
|
|
|
|
const sections = React.useMemo(() => {
|
|
const result: Array<number> = [];
|
|
for (const row of virtualRows) {
|
|
if (row.type === 'emoji-row') {
|
|
result.push(row.emojis.length);
|
|
}
|
|
}
|
|
return result;
|
|
}, [virtualRows]);
|
|
|
|
const handleCategoryClick = (category: string) => {
|
|
const element = categoryRefs.current.get(category);
|
|
if (element) {
|
|
scrollerRef.current?.scrollIntoViewNode({node: element, shouldScrollToStart: true});
|
|
}
|
|
};
|
|
|
|
const handleHover = (emoji: Emoji | null, row?: number, column?: number) => {
|
|
setHoveredEmoji(emoji);
|
|
if (emoji && row !== undefined && column !== undefined) {
|
|
handleSelectionChange(row, column, false);
|
|
}
|
|
};
|
|
|
|
const handleEmojiSelect = React.useCallback(
|
|
(emoji: Emoji, shiftKey?: boolean) => {
|
|
const availability = checkEmojiAvailability(i18n, emoji, channel);
|
|
if (!availability.canUse) {
|
|
return;
|
|
}
|
|
|
|
EmojiPickerActionCreators.trackEmojiUsage(emoji);
|
|
handleSelect(emoji, shiftKey);
|
|
},
|
|
[channel, handleSelect, i18n],
|
|
);
|
|
|
|
const handleSelectionChange = React.useCallback(
|
|
(row: number, column: number, shouldScroll = false) => {
|
|
if (row < 0 || column < 0) {
|
|
return;
|
|
}
|
|
setSelectedRow(row);
|
|
setSelectedColumn(column);
|
|
setShouldScrollOnSelection(shouldScroll);
|
|
|
|
let currentRow = 0;
|
|
for (const virtualRow of virtualRows) {
|
|
if (virtualRow.type === 'emoji-row') {
|
|
if (currentRow === row && column < virtualRow.emojis.length) {
|
|
const emoji = virtualRow.emojis[column];
|
|
setHoveredEmoji(emoji);
|
|
break;
|
|
}
|
|
currentRow++;
|
|
}
|
|
}
|
|
},
|
|
[virtualRows],
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
if (renderedEmojis.length > 0 && selectedRow === 0 && selectedColumn === 0 && !hoveredEmoji) {
|
|
handleSelectionChange(0, 0, false);
|
|
}
|
|
}, [renderedEmojis, selectedRow, selectedColumn, hoveredEmoji, handleSelectionChange]);
|
|
|
|
const handleSelectEmoji = React.useCallback(
|
|
(row: number | null, column: number | null, event?: React.KeyboardEvent) => {
|
|
if (row === null || column === null) {
|
|
return;
|
|
}
|
|
|
|
let currentRow = 0;
|
|
for (const virtualRow of virtualRows) {
|
|
if (virtualRow.type === 'emoji-row') {
|
|
if (currentRow === row && column < virtualRow.emojis.length) {
|
|
const emoji = virtualRow.emojis[column];
|
|
handleEmojiSelect(emoji, event?.shiftKey);
|
|
return;
|
|
}
|
|
currentRow++;
|
|
}
|
|
}
|
|
},
|
|
[virtualRows, handleEmojiSelect],
|
|
);
|
|
|
|
return (
|
|
<div className={styles.container}>
|
|
<ExpressionPickerHeaderPortal>
|
|
<EmojiPickerSearchBar
|
|
searchTerm={searchTerm}
|
|
setSearchTerm={setSearchTerm}
|
|
hoveredEmoji={hoveredEmoji}
|
|
inputRef={searchInputRef}
|
|
selectedRow={selectedRow}
|
|
selectedColumn={selectedColumn}
|
|
sections={sections}
|
|
onSelect={handleSelectEmoji}
|
|
onSelectionChange={handleSelectionChange}
|
|
/>
|
|
</ExpressionPickerHeaderPortal>
|
|
<div className={styles.emojiPicker}>
|
|
<div className={styles.bodyWrapper}>
|
|
<div className={styles.emojiPickerListWrapper} role="presentation">
|
|
<Scroller
|
|
ref={scrollerRef}
|
|
className={`${styles.list} ${styles.listWrapper}`}
|
|
fade={false}
|
|
key="emoji-picker-scroller"
|
|
reserveScrollbarTrack={true}
|
|
>
|
|
{showPremiumUpsell && <PremiumUpsellBanner />}
|
|
{virtualRows.map((row, index) => {
|
|
const emojiRowIndex = virtualRows.slice(0, index).filter((r) => r.type === 'emoji-row').length;
|
|
const needsSpacingAfter = row.type === 'emoji-row' && virtualRows[index + 1]?.type === 'header';
|
|
|
|
return (
|
|
<div
|
|
key={`${row.type}-${row.index}`}
|
|
ref={
|
|
row.type === 'header'
|
|
? (el) => {
|
|
if (el && 'category' in row) {
|
|
categoryRefs.current.set(row.category, el);
|
|
}
|
|
}
|
|
: undefined
|
|
}
|
|
style={row.type === 'emoji-row' && needsSpacingAfter ? {marginBottom: '12px'} : undefined}
|
|
>
|
|
<VirtualizedRow
|
|
row={row}
|
|
handleHover={handleHover}
|
|
handleSelect={handleEmojiSelect}
|
|
skinTone={skinTone}
|
|
spriteSheetSizes={spriteSheetSizes}
|
|
channel={channel}
|
|
hoveredEmoji={hoveredEmoji}
|
|
selectedRow={selectedRow}
|
|
selectedColumn={selectedColumn}
|
|
emojiRowIndex={emojiRowIndex}
|
|
shouldScrollOnSelection={shouldScrollOnSelection}
|
|
emojiRefs={emojiRefs}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</Scroller>
|
|
{renderedEmojis.length === 0 && (
|
|
<div className={styles.emptyState}>
|
|
<div className={styles.emptyStateInner}>
|
|
<div className={styles.emptyIcon}>
|
|
<SmileySadIcon weight="duotone" />
|
|
</div>
|
|
<div className={styles.emptyLabel}>{t`No emojis match your search`}</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<EmojiPickerInspector hoveredEmoji={hoveredEmoji} />
|
|
</div>
|
|
<EmojiPickerCategoryList
|
|
customEmojisByGuildId={customEmojisByGuildId}
|
|
unicodeEmojisByCategory={unicodeEmojisByCategory}
|
|
handleCategoryClick={handleCategoryClick}
|
|
showFrequentlyUsedButton={showFrequentlyUsedButton}
|
|
/>
|
|
</div>
|
|
);
|
|
},
|
|
);
|