2026-01-03 06:44:40 +01:00

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>
);
},
);