// SPDX-License-Identifier: AGPL-3.0-or-later import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants'; import {useSearchInputAutofocus} from '@app/features/app/hooks/useSearchInputAutofocus'; import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate'; import {EmojiPickerCategoryList} from '@app/features/channel/components/emoji_picker/EmojiPickerCategoryList'; import {EMOJI_SPRITE_SIZE} from '@app/features/channel/components/emoji_picker/EmojiPickerConstants'; import {EmojiPickerSearchBar} from '@app/features/channel/components/emoji_picker/EmojiPickerSearchBar'; import {useEmojiCategories} from '@app/features/channel/components/emoji_picker/hooks/useEmojiCategories'; import {useVirtualRows} from '@app/features/channel/components/emoji_picker/hooks/useVirtualRows'; import {VirtualizedRow} from '@app/features/channel/components/emoji_picker/VirtualRow'; import mobileStyles from '@app/features/channel/components/MobileEmojiPicker.module.css'; import {PremiumUpsellBanner} from '@app/features/channel/components/PremiumUpsellBanner'; import premiumStyles from '@app/features/channel/components/PremiumUpsellBanner.module.css'; import {getMobileEmojiGridColumns} from '@app/features/channel/components/pickers/shared/MobilePickerGridLayout'; import {useScrollerViewport} from '@app/features/channel/components/pickers/shared/useScrollerViewport'; import Channels from '@app/features/channel/state/Channels'; import Emoji, {normalizeEmojiSearchQuery} from '@app/features/emoji/state/Emoji'; import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes'; import { ExpressionPickerHeaderPortal, useExpressionPickerHeaderPortal, } from '@app/features/expressions/components/popouts/ExpressionPickerPopout'; import { checkEmojiAvailability, shouldShowEmojiPremiumUpsell, } from '@app/features/expressions/utils/ExpressionPermissionUtils'; import {getEmojiDisplayDataWithSkinTone} from '@app/features/expressions/utils/SkinToneUtils'; import UnicodeEmojis, {EMOJI_SPRITES} from '@app/features/expressions/utils/UnicodeEmojis'; import Permission from '@app/features/permissions/state/Permission'; import {ComponentDispatch} from '@app/features/platform/utils/ComponentBus'; import {usePremiumUpsellData} from '@app/features/premium/hooks/usePremiumUpsellData'; import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils'; import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller'; import * as AvatarUtils from '@app/features/user/utils/AvatarUtils'; import {Plural, Trans, useLingui} from '@lingui/react/macro'; import {observer} from 'mobx-react-lite'; import type React from 'react'; import {useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore} from 'react'; function getVisibleViewportWidth(): number { if (typeof window === 'undefined') { return 0; } return window.visualViewport?.width ?? window.innerWidth ?? 0; } export const MobileEmojiPicker = observer( ({ channelId, handleSelect, externalSearchTerm, externalSetSearchTerm, hideSearchBar = false, }: { channelId?: string; handleSelect: (emoji: FlatEmoji, shiftKey?: boolean) => void; externalSearchTerm?: string; externalSetSearchTerm?: (term: string) => void; hideSearchBar?: boolean; }) => { const headerPortalContext = useExpressionPickerHeaderPortal(); const hasPortal = Boolean(headerPortalContext?.headerPortalElement); const {i18n} = useLingui(); const [internalSearchTerm, setInternalSearchTerm] = useState(''); const [hoveredEmoji, setHoveredEmoji] = useState(null); const [visibleViewportWidth, setVisibleViewportWidth] = useState(() => getVisibleViewportWidth()); const scrollerRef = useRef(null); const searchInputRef = useRef(null); const emojiRefs = useRef>(new Map()); const {viewportSize, handleResize} = useScrollerViewport(scrollerRef); const channel = channelId ? (Channels.getChannel(channelId) ?? null) : null; const categoryRefs = useRef>(new Map()); const [emojiDataVersion, setEmojiDataVersion] = useState(0); const permissionVersion = useSyncExternalStore(Permission.subscribe.bind(Permission), () => Permission.version); const getEmojiAvailability = useCallback( (emoji: FlatEmoji) => checkEmojiAvailability(i18n, emoji, channel), [channel, i18n, permissionVersion], ); const getEmojiGuildId = useCallback((emoji: FlatEmoji) => emoji.guildId, []); const skinTone = Emoji.skinTone; const shouldAnimateEmoji = useShouldAnimate({kind: 'emoji'}); const spriteSheetSizes = 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}; }, []); const searchTerm = externalSearchTerm ?? internalSearchTerm; const setSearchTerm = externalSetSearchTerm ?? setInternalSearchTerm; const normalizedSearchTerm = useMemo(() => normalizeEmojiSearchQuery(searchTerm), [searchTerm]); useEffect(() => { const handleEmojiDataUpdated = () => { setEmojiDataVersion((version) => version + 1); }; return ComponentDispatch.subscribe('EMOJI_PICKER_RERENDER', handleEmojiDataUpdated); }, []); useEffect(() => { const updateVisibleViewportWidth = () => { setVisibleViewportWidth((previousWidth) => { const nextWidth = getVisibleViewportWidth(); return previousWidth === nextWidth ? previousWidth : nextWidth; }); }; updateVisibleViewportWidth(); window.addEventListener('resize', updateVisibleViewportWidth); window.visualViewport?.addEventListener('resize', updateVisibleViewportWidth); return () => { window.removeEventListener('resize', updateVisibleViewportWidth); window.visualViewport?.removeEventListener('resize', updateVisibleViewportWidth); }; }, []); const searchItems = useMemo( () => Emoji.search(channel, normalizedSearchTerm).slice(), [channel, normalizedSearchTerm, emojiDataVersion], ); const searchUpsell = usePremiumUpsellData({ items: searchItems, getAvailability: getEmojiAvailability, getGuildId: getEmojiGuildId, }); const renderedEmojis = searchUpsell.accessibleItems; const allItems = useMemo(() => Emoji.getAllEmojis(channel).slice(), [channel, emojiDataVersion]); const renderEmojiPreviewItem = useCallback( (emoji: FlatEmoji) => { const key = emoji.id ?? emoji.uniqueName ?? emoji.name ?? `${emoji.guildId ?? 'unicode'}-mobile`; const {url: fallbackDisplayUrl} = getEmojiDisplayDataWithSkinTone(emoji, skinTone); const displayUrl = emoji.id ? AvatarUtils.getEmojiURL({id: emoji.id, animated: Boolean(emoji.animated) && shouldAnimateEmoji}) : fallbackDisplayUrl; let content: React.ReactNode; if (displayUrl) { content = ( {emoji.name} ); } else { content = ( {emoji.name ?? emoji.uniqueName} ); } return (
{content}
); }, [shouldAnimateEmoji, skinTone], ); const allUpsell = usePremiumUpsellData({ items: allItems, getAvailability: getEmojiAvailability, getGuildId: getEmojiGuildId, renderPreviewItem: renderEmojiPreviewItem, previewLimit: 4, }); useSearchInputAutofocus(searchInputRef); const {customEmojisByGuildId, unicodeEmojisByCategory, favoriteEmojis, frequentlyUsedEmojis} = useEmojiCategories( allUpsell.accessibleItems, renderedEmojis, ); const showFrequentlyUsedButton = frequentlyUsedEmojis.length > 0 && !normalizedSearchTerm; const effectiveViewportWidth = useMemo(() => { const candidateWidths = [viewportSize.width, visibleViewportWidth].filter((width) => width > 0); if (candidateWidths.length === 0) { return 0; } return Math.min(...candidateWidths); }, [viewportSize.width, visibleViewportWidth]); const gridColumns = useMemo(() => getMobileEmojiGridColumns(effectiveViewportWidth), [effectiveViewportWidth]); const gridWidth = useMemo(() => { if (effectiveViewportWidth <= 0) { return undefined; } return Math.max(0, effectiveViewportWidth - 24); }, [effectiveViewportWidth]); const virtualRows = useVirtualRows( normalizedSearchTerm, renderedEmojis, favoriteEmojis, frequentlyUsedEmojis, customEmojisByGuildId, unicodeEmojisByCategory, gridColumns, ); const lockedEmojiCount = allUpsell.summary.lockedItems.length; const communityCount = allUpsell.summary.communityCount; const previewContent = allUpsell.previewContent; const emojiUpsellMessage = ( Unlock{' '} {' '} from{' '} {' '} with {PREMIUM_PRODUCT_NAME}. ); const showPremiumUpsell = shouldShowPremiumFeatures() && shouldShowEmojiPremiumUpsell(channel) && !normalizedSearchTerm && lockedEmojiCount > 0; const handleCategoryClick = (category: string) => { const element = categoryRefs.current.get(category); if (element) { scrollerRef.current?.scrollIntoViewNode({node: element, shouldScrollToStart: true}); } }; const handleHover = (emoji: FlatEmoji | null) => { setHoveredEmoji(emoji); }; const searchBar = !hideSearchBar ? ( ) : null; return (
{hasPortal && searchBar ? ( {searchBar} ) : null}
{!hasPortal && searchBar}
{showPremiumUpsell && ( )} {virtualRows.map((row) => (
{ if (el && 'category' in row) { categoryRefs.current.set(row.category, el); } } : undefined } data-flx="channel.mobile-emoji-picker.div--4" >
))}
); }, );