// SPDX-License-Identifier: AGPL-3.0-or-later import styles from '@app/features/channel/components/EmojiPicker.module.css'; import { CATEGORY_HEADER_HEIGHT, EMOJI_ROW_HEIGHT, OVERSCAN_ROWS, } from '@app/features/channel/components/emoji_picker/EmojiPickerConstants'; import {EmojiRenderer} from '@app/features/channel/components/emoji_picker/EmojiRenderer'; import type {Channel} from '@app/features/channel/models/Channel'; import * as EmojiPickerCommands from '@app/features/emoji/commands/EmojiPickerCommands'; import EmojiPicker from '@app/features/emoji/state/EmojiPicker'; import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes'; import {GuildIcon} from '@app/features/guild/components/popouts/GuildIcon'; import Guilds from '@app/features/guild/state/Guilds'; import {observeIntersection} from '@app/features/platform/utils/SharedIntersectionObserver'; import {remFromPx} from '@app/features/theme/layout/RemFromPx'; import {CaretDownIcon, ClockIcon, StarIcon} from '@phosphor-icons/react'; import {observer} from 'mobx-react-lite'; import React, {useEffect, useRef, useState} from 'react'; export type VirtualRow = | {type: 'header'; category: string; name: string; guildId?: string; index: number} | {type: 'emoji-row'; emojis: Array; index: number; isCustomEmoji?: boolean; guildId?: string}; interface VirtualRowRendererProps { row: VirtualRow; handleHover: (emoji: FlatEmoji | null, row?: number, column?: number) => void; handleSelect: (emoji: FlatEmoji, shiftKey?: boolean) => void; skinTone: string; spriteSheetSizes: {nonDiversitySize: string; diversitySize: string}; channel: Channel | null; shouldAnimate: boolean; gridColumns?: number; gridWidth?: number; hoveredEmoji: FlatEmoji | null; selectedRow: number; selectedColumn: number; emojiRowIndex: number; shouldScrollOnSelection?: boolean; emojiRefs: React.MutableRefObject>; } const VirtualRowRenderer: React.FC = React.memo( ({ row, handleHover, handleSelect, skinTone, spriteSheetSizes, channel, shouldAnimate, gridColumns = 9, gridWidth, selectedRow, selectedColumn, emojiRowIndex, shouldScrollOnSelection = false, emojiRefs, }) => { if (row.type === 'header') { const isCollapsed = EmojiPicker.isCategoryCollapsed(row.category); const handleToggleCategory = () => { EmojiPickerCommands.toggleCategory(row.category); }; let leadingIcon: React.ReactNode = null; if (row.category === 'favorites') { leadingIcon = ( ); } else if (row.category === 'frequently-used') { leadingIcon = ( ); } else if (row.guildId) { leadingIcon = (
); } return ( ); } return (
{row.emojis.map((emoji, colIndex) => { const isSelected = emojiRowIndex === selectedRow && colIndex === selectedColumn; const shouldHighlight = isSelected; return ( handleHover(e, emojiRowIndex, colIndex)} handleSelect={handleSelect} skinTone={skinTone} spriteSheetSizes={spriteSheetSizes} channel={channel} shouldAnimate={shouldAnimate} isHighlighted={shouldHighlight} shouldScrollIntoView={isSelected && shouldScrollOnSelection} ref={(node) => { const key = `${emojiRowIndex}-${colIndex}`; if (node) { emojiRefs.current.set(key, node); } else { emojiRefs.current.delete(key); } }} data-flx="channel.emoji-picker.virtual-row.virtual-row-renderer.emoji-renderer" /> ); })}
); }, ); interface VirtualizedRowProps { row: VirtualRow; handleHover: (emoji: FlatEmoji | null, row?: number, column?: number) => void; handleSelect: (emoji: FlatEmoji, shiftKey?: boolean) => void; skinTone: string; spriteSheetSizes: {nonDiversitySize: string; diversitySize: string}; channel: Channel | null; allowAnimation: boolean; gridColumns?: number; gridWidth?: number; hoveredEmoji: FlatEmoji | null; selectedRow: number; selectedColumn: number; emojiRowIndex: number; shouldScrollOnSelection?: boolean; emojiRefs: React.MutableRefObject>; } export const VirtualizedRow: React.FC = observer( ({ row, handleHover, handleSelect, skinTone, spriteSheetSizes, channel, allowAnimation, gridColumns, gridWidth, hoveredEmoji, selectedRow, selectedColumn, emojiRowIndex, shouldScrollOnSelection = false, emojiRefs, }) => { const [isVisible, setIsVisible] = useState(false); const [isInViewport, setIsInViewport] = useState(false); const placeholderRef = useRef(null); useEffect(() => { const placeholder = placeholderRef.current; if (!placeholder) return; const root = placeholder.closest('[data-emoji-picker-scroll-root]') as Element | null; const overscanDistance = OVERSCAN_ROWS * EMOJI_ROW_HEIGHT; const unobserveVisibility = observeIntersection( placeholder, (entry) => { if (entry.isIntersecting) { setIsVisible(true); } else { const rect = entry.boundingClientRect; const rootTop = entry.rootBounds?.top ?? 0; const rootBottom = entry.rootBounds?.bottom ?? window.innerHeight; if (rect.bottom < rootTop - overscanDistance || rect.top > rootBottom + overscanDistance) { setIsVisible(false); } } }, {root, rootMargin: `${OVERSCAN_ROWS * EMOJI_ROW_HEIGHT}px 0px`, threshold: 0}, ); const unobserveAnimation = observeIntersection( placeholder, (entry) => { setIsInViewport(entry.isIntersecting); }, {root, rootMargin: '0px', threshold: 0}, ); return () => { unobserveVisibility(); unobserveAnimation(); }; }, []); const height = row.type === 'header' ? CATEGORY_HEADER_HEIGHT : EMOJI_ROW_HEIGHT; if (!isVisible) { return (
); } return (
); }, );