/* * 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 . */ 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(null); const [renderedEmojis, setRenderedEmojis] = React.useState>([]); const [allEmojis, setAllEmojis] = React.useState>([]); const [selectedRow, setSelectedRow] = React.useState(-1); const [selectedColumn, setSelectedColumn] = React.useState(-1); const [shouldScrollOnSelection, setShouldScrollOnSelection] = React.useState(false); const scrollerRef = React.useRef(null); const searchInputRef = React.useRef(null); const emojiRefs = React.useRef>(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>(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 = []; 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 (
{showPremiumUpsell && } {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 (
{ if (el && 'category' in row) { categoryRefs.current.set(row.category, el); } } : undefined } style={row.type === 'emoji-row' && needsSpacingAfter ? {marginBottom: '12px'} : undefined} >
); })}
{renderedEmojis.length === 0 && (
{t`No emojis match your search`}
)}
); }, );