From 5834e32aa5737dccbca1cde8aa208291f3dfa7a6 Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 24 Aug 2026 12:35:02 +0200 Subject: [PATCH] perf(app): window emoji picker rows by offset instead of observers (#1870) --- .../channel/components/EmojiPicker.module.css | 15 ++ .../channel/components/EmojiPicker.tsx | 197 ++++++++++++------ .../channel/components/MobileEmojiPicker.tsx | 139 ++++++++---- .../emoji_picker/EmojiPickerConstants.ts | 92 ++++++-- .../components/emoji_picker/VirtualRow.tsx | 111 +++------- 5 files changed, 355 insertions(+), 199 deletions(-) diff --git a/fluxer_app/src/features/channel/components/EmojiPicker.module.css b/fluxer_app/src/features/channel/components/EmojiPicker.module.css index 60e47c168..df314b825 100644 --- a/fluxer_app/src/features/channel/components/EmojiPicker.module.css +++ b/fluxer_app/src/features/channel/components/EmojiPicker.module.css @@ -236,6 +236,7 @@ .headerIcon { height: 1rem; width: 1rem; + color: var(--text-primary-muted); } .caretIcon { @@ -384,6 +385,11 @@ width: 100%; } +.emojiGridFixedTrack { + grid-auto-flow: column; + justify-content: start; +} + .emojiGridCategory { margin-bottom: 1rem; } @@ -437,6 +443,15 @@ object-fit: contain; } +.emojiImageLoading { + color: transparent; + object-position: 6.25rem; + background-color: var(--background-modifier-accent); + background-repeat: no-repeat; + background-size: 100% 100%; + border-radius: 0.125rem; +} + .spriteEmoji { width: 2rem; height: 2rem; diff --git a/fluxer_app/src/features/channel/components/EmojiPicker.tsx b/fluxer_app/src/features/channel/components/EmojiPicker.tsx index dd51db49a..2d771ef9c 100644 --- a/fluxer_app/src/features/channel/components/EmojiPicker.tsx +++ b/fluxer_app/src/features/channel/components/EmojiPicker.tsx @@ -1,11 +1,20 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import Accessibility from '@app/features/accessibility/state/Accessibility'; 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 styles from '@app/features/channel/components/EmojiPicker.module.css'; import {EmojiPickerCategoryList} from '@app/features/channel/components/emoji_picker/EmojiPickerCategoryList'; -import {getEmojiGridColumns} from '@app/features/channel/components/emoji_picker/EmojiPickerConstants'; +import { + buildEmojiRowOffsets, + EMOJI_GRID_TRACK_WIDTH, + EMOJI_ROW_HEIGHT, + EMOJI_SECTION_GAP, + type EmojiRowKind, + getEmojiGridColumns, + getEmojiRowWindow, +} from '@app/features/channel/components/emoji_picker/EmojiPickerConstants'; import {EmojiPickerInspector} from '@app/features/channel/components/emoji_picker/EmojiPickerInspector'; import {EmojiPickerSearchBar} from '@app/features/channel/components/emoji_picker/EmojiPickerSearchBar'; import {useEmojiCategories} from '@app/features/channel/components/emoji_picker/hooks/useEmojiCategories'; @@ -27,18 +36,28 @@ import { shouldShowEmojiPremiumUpsell, } from '@app/features/expressions/utils/ExpressionPermissionUtils'; import {getEmojiDisplayDataWithSkinTone} from '@app/features/expressions/utils/SkinToneUtils'; +import {getEmojiRenderUrl} from '@app/features/messaging/utils/markdown/EmojiDetector'; import Permission from '@app/features/permissions/state/Permission'; import {ComponentBus} 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 {getAppRemScale} from '@app/features/ui/utils/AppZoomUtils'; import {msg} from '@lingui/core/macro'; import {Plural, Trans, useLingui} from '@lingui/react/macro'; import {SmileySadIcon} from '@phosphor-icons/react'; import {observer} from 'mobx-react-lite'; import type React from 'react'; -import {useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore} from 'react'; +import { + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react'; const NO_EMOJIS_MATCH_YOUR_SEARCH_DESCRIPTOR = msg({ message: 'No emojis match that search', @@ -58,14 +77,15 @@ export const EmojiPicker = observer( const [selectedColumn, setSelectedColumn] = useState(-1); const [shouldScrollOnSelection, setShouldScrollOnSelection] = useState(false); const scrollerRef = useRef(null); - const {viewportSize, handleResize} = useScrollerViewport(scrollerRef); + const {viewportSize, scrollTop, handleScroll, handleResize} = useScrollerViewport(scrollerRef); const gridColumns = useMemo(() => getEmojiGridColumns(viewportSize.width), [viewportSize.width]); const searchInputRef = useRef(null); const emojiRefs = useRef>(new Map()); const normalizedSearchTerm = useMemo(() => normalizeEmojiSearchQuery(searchTerm), [searchTerm]); const {i18n} = useLingui(); const channel = channelId ? (Channels.getChannel(channelId) ?? null) : null; - const categoryRefs = useRef>(new Map()); + const rowListRef = useRef(null); + const [listMetrics, setListMetrics] = useState({origin: 0, viewportHeight: 0}); const [emojiDataVersion, setEmojiDataVersion] = useState(0); const permissionVersion = useSyncExternalStore(Permission.subscribe.bind(Permission), () => Permission.version); const skinTone = Emoji.skinTone; @@ -80,15 +100,16 @@ export const EmojiPicker = observer( const key = emoji.id ?? emoji.uniqueName ?? emoji.name ?? `${emoji.guildId ?? 'unicode'}-preview`; const {url: fallbackDisplayUrl} = getEmojiDisplayDataWithSkinTone(emoji, skinTone); const displayUrl = emoji.id - ? AvatarUtils.getEmojiURL({id: emoji.id, animated: Boolean(emoji.animated) && shouldAnimateEmoji}) + ? getEmojiRenderUrl({ + id: emoji.id, + surrogateUrl: null, + isAnimatable: Boolean(emoji.animated), + animated: shouldAnimateEmoji, + jumbo: false, + }) : fallbackDisplayUrl; const content = displayUrl ? ( - {emoji.name} + {emoji.name} ) : ( { + const indexes = new Array(pickerRows.length); + const starts: Array = []; + const categories = new Map(); + let emojiRowIndex = 0; + for (let rowIndex = 0; rowIndex < pickerRows.length; rowIndex += 1) { + const row = pickerRows[rowIndex]; + indexes[rowIndex] = emojiRowIndex; + if (row.type === 'emoji-row') { + starts.push(rowIndex); + emojiRowIndex += 1; + } else { + categories.set(row.category, rowIndex); + } + } + return {emojiRowIndexes: indexes, emojiRowStarts: starts, categoryRowIndexes: categories}; + }, [pickerRows]); + const zoomLevel = Accessibility.zoomLevel; + const remScale = getAppRemScale(); + const rowOffsets = useMemo(() => { + const rowKinds = pickerRows.map((row): EmojiRowKind => row.type); + return buildEmojiRowOffsets(rowKinds, {remScale, sectionGap: EMOJI_SECTION_GAP}); + }, [pickerRows, remScale, zoomLevel]); + const contentHeight = rowOffsets[rowOffsets.length - 1]!; + useLayoutEffect(() => { + const rowListNode = rowListRef.current; + const scrollerNode = scrollerRef.current?.getViewportElement(); + if (!rowListNode || !scrollerNode) { + return; + } + const origin = Math.round( + rowListNode.getBoundingClientRect().top - scrollerNode.getBoundingClientRect().top + scrollerNode.scrollTop, + ); + const viewportHeight = scrollerNode.clientHeight; + setListMetrics((current) => + current.origin === origin && current.viewportHeight === viewportHeight ? current : {origin, viewportHeight}, + ); + }); + const rowWindow = useMemo( + () => getEmojiRowWindow(rowOffsets, scrollTop - listMetrics.origin, listMetrics.viewportHeight), + [rowOffsets, scrollTop, listMetrics], + ); const handleCategoryClick = (category: string) => { - const element = categoryRefs.current.get(category); - if (element) { - scrollerRef.current?.revealElement({node: element, preferStartEdge: true}); - } - }; - const handleHover = (emoji: FlatEmoji | null, row?: number, column?: number) => { - setHoveredEmoji(emoji); - if (emoji && row !== undefined && column !== undefined) { - handleSelectionChange(row, column, false); + const rowIndex = categoryRowIndexes.get(category); + if (rowIndex == null) { + return; } + scrollerRef.current?.scrollTo({to: listMetrics.origin + rowOffsets[rowIndex]!}); }; const handleEmojiSelect = useCallback( (emoji: FlatEmoji, shiftKey?: boolean) => { @@ -229,11 +287,31 @@ export const EmojiPicker = observer( }, [pickerRows], ); + const handleHover = useCallback( + (emoji: FlatEmoji | null, row?: number, column?: number) => { + setHoveredEmoji(emoji); + if (emoji && row !== undefined && column !== undefined) { + handleSelectionChange(row, column, false); + } + }, + [handleSelectionChange], + ); useEffect(() => { if (renderedEmojis.length > 0 && selectedRow === 0 && selectedColumn === 0 && !hoveredEmoji) { handleSelectionChange(0, 0, false); } }, [renderedEmojis, selectedRow, selectedColumn, hoveredEmoji, handleSelectionChange]); + useEffect(() => { + if (!shouldScrollOnSelection || selectedRow < 0) { + return; + } + const rowIndex = emojiRowStarts[selectedRow]; + if (rowIndex == null) { + return; + } + const start = listMetrics.origin + rowOffsets[rowIndex]!; + scrollerRef.current?.revealRange({start, end: start + EMOJI_ROW_HEIGHT * remScale}); + }, [shouldScrollOnSelection, selectedRow, emojiRowStarts, rowOffsets, listMetrics.origin, remScale]); const handleSelectEmoji = useCallback( (row: number | null, column: number | null, event?: React.KeyboardEvent) => { if (row === null || column === null) { @@ -281,8 +359,8 @@ export const EmojiPicker = observer( className={`${styles.list} ${styles.listWrapper}`} fade={false} key="emoji_picker-scroller" + onScroll={handleScroll} onResize={handleResize} - data-emoji-picker-scroll-root="true" data-flx="channel.emoji-picker.list" > {showPremiumUpsell && ( @@ -294,43 +372,44 @@ export const EmojiPicker = observer( data-flx="channel.emoji-picker.premium-upsell-banner" /> )} - {pickerRows.map((row, index) => { - const emojiRowIndex = pickerRows.slice(0, index).filter((r) => r.type === 'emoji-row').length; - const needsSpacingAfter = row.type === 'emoji-row' && pickerRows[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} - data-flx="channel.emoji-picker.div" - > - -
- ); - })} +
+ {pickerRows.slice(rowWindow.firstRow, rowWindow.lastRow + 1).map((row, windowIndex) => { + const rowIndex = rowWindow.firstRow + windowIndex; + return ( +
+ +
+ ); + })} +
{renderedEmojis.length === 0 && (
diff --git a/fluxer_app/src/features/channel/components/MobileEmojiPicker.tsx b/fluxer_app/src/features/channel/components/MobileEmojiPicker.tsx index d98202a53..e613b2ed8 100644 --- a/fluxer_app/src/features/channel/components/MobileEmojiPicker.tsx +++ b/fluxer_app/src/features/channel/components/MobileEmojiPicker.tsx @@ -5,6 +5,11 @@ import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstant 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 { + buildEmojiRowOffsets, + type EmojiRowKind, + getEmojiRowWindow, +} 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'; @@ -26,16 +31,17 @@ import { shouldShowEmojiPremiumUpsell, } from '@app/features/expressions/utils/ExpressionPermissionUtils'; import {getEmojiDisplayDataWithSkinTone} from '@app/features/expressions/utils/SkinToneUtils'; +import {getEmojiRenderUrl} from '@app/features/messaging/utils/markdown/EmojiDetector'; import Permission from '@app/features/permissions/state/Permission'; import {ComponentBus} 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 {getAppRemScale} from '@app/features/ui/utils/AppZoomUtils'; 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'; +import {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore} from 'react'; function getVisibleViewportWidth(): number { if (typeof window === 'undefined') { @@ -67,9 +73,10 @@ export const MobileEmojiPicker = observer( const scrollerRef = useRef(null); const searchInputRef = useRef(null); const emojiRefs = useRef>(new Map()); - const {viewportSize, handleResize} = useScrollerViewport(scrollerRef); + const {viewportSize, scrollTop, handleScroll, handleResize} = useScrollerViewport(scrollerRef); const channel = channelId ? (Channels.getChannel(channelId) ?? null) : null; - const categoryRefs = useRef>(new Map()); + const rowListRef = useRef(null); + const [listMetrics, setListMetrics] = useState({origin: 0, viewportHeight: 0}); const [emojiDataVersion, setEmojiDataVersion] = useState(0); const permissionVersion = useSyncExternalStore(Permission.subscribe.bind(Permission), () => Permission.version); const getEmojiAvailability = useCallback( @@ -119,7 +126,13 @@ export const MobileEmojiPicker = observer( 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}) + ? getEmojiRenderUrl({ + id: emoji.id, + surrogateUrl: null, + isAnimatable: Boolean(emoji.animated), + animated: shouldAnimateEmoji, + jumbo: false, + }) : fallbackDisplayUrl; let content: React.ReactNode; if (displayUrl) { @@ -127,7 +140,6 @@ export const MobileEmojiPicker = observer( {emoji.name} ); @@ -220,15 +232,50 @@ export const MobileEmojiPicker = observer( shouldShowEmojiPremiumUpsell(channel) && !normalizedSearchTerm && lockedEmojiCount > 0; - const handleCategoryClick = (category: string) => { - const element = categoryRefs.current.get(category); - if (element) { - scrollerRef.current?.revealElement({node: element, preferStartEdge: true}); + const categoryRowIndexes = useMemo(() => { + const categories = new Map(); + for (let rowIndex = 0; rowIndex < pickerRows.length; rowIndex += 1) { + const row = pickerRows[rowIndex]; + if (row.type === 'header') { + categories.set(row.category, rowIndex); + } } + return categories; + }, [pickerRows]); + const remScale = getAppRemScale(); + const rowOffsets = useMemo(() => { + const rowKinds = pickerRows.map((row): EmojiRowKind => row.type); + return buildEmojiRowOffsets(rowKinds, {remScale, sectionGap: 0}); + }, [pickerRows, remScale, zoomLevel]); + const contentHeight = rowOffsets[rowOffsets.length - 1]!; + useLayoutEffect(() => { + const rowListNode = rowListRef.current; + const scrollerNode = scrollerRef.current?.getViewportElement(); + if (!rowListNode || !scrollerNode) { + return; + } + const origin = Math.round( + rowListNode.getBoundingClientRect().top - scrollerNode.getBoundingClientRect().top + scrollerNode.scrollTop, + ); + const viewportHeight = scrollerNode.clientHeight; + setListMetrics((current) => + current.origin === origin && current.viewportHeight === viewportHeight ? current : {origin, viewportHeight}, + ); + }); + const rowWindow = useMemo( + () => getEmojiRowWindow(rowOffsets, scrollTop - listMetrics.origin, listMetrics.viewportHeight), + [rowOffsets, scrollTop, listMetrics], + ); + const handleCategoryClick = (category: string) => { + const rowIndex = categoryRowIndexes.get(category); + if (rowIndex == null) { + return; + } + scrollerRef.current?.scrollTo({to: listMetrics.origin + rowOffsets[rowIndex]!}); }; - const handleHover = (emoji: FlatEmoji | null) => { + const handleHover = useCallback((emoji: FlatEmoji | null) => { setHoveredEmoji(emoji); - }; + }, []); const searchBar = !hideSearchBar ? ( @@ -269,38 +316,40 @@ export const MobileEmojiPicker = observer( data-flx="channel.mobile-emoji-picker.premium-upsell-banner" /> )} - {pickerRows.map((row) => ( -
{ - if (el && 'category' in row) { - categoryRefs.current.set(row.category, el); - } - } - : undefined - } - data-flx="channel.mobile-emoji-picker.div--4" - > - -
- ))} +
+ {pickerRows.slice(rowWindow.firstRow, rowWindow.lastRow + 1).map((row, windowIndex) => ( +
+ +
+ ))} +
diff --git a/fluxer_app/src/features/channel/components/emoji_picker/EmojiPickerConstants.ts b/fluxer_app/src/features/channel/components/emoji_picker/EmojiPickerConstants.ts index 93a1017f5..d0cb2a3ba 100644 --- a/fluxer_app/src/features/channel/components/emoji_picker/EmojiPickerConstants.ts +++ b/fluxer_app/src/features/channel/components/emoji_picker/EmojiPickerConstants.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {convertToCodePoints} from '@app/features/expressions/utils/EmojiCodepointUtils'; -import {REM_BASE_PX, remFromPx} from '@app/features/theme/layout/RemFromPx'; +import {getRemScaleForDocument, remFromPx} from '@app/features/theme/layout/RemFromPx'; import sprite1f3fb2x from '@app/media/images/emoji-sprites/spritesheet-1f3fb@2x.png'; import sprite1f3fb1x from '@app/media/images/emoji-sprites/spritesheet-1f3fb.png'; import sprite1f3fc2x from '@app/media/images/emoji-sprites/spritesheet-1f3fc@2x.png'; @@ -23,28 +23,90 @@ export const EMOJI_PICKER_CUSTOM_EMOJI_SIZE = 48; export const CATEGORY_HEADER_HEIGHT = 32; export const EMOJIS_PER_ROW = 9; const EMOJIS_PER_ROW_MIN = 5; -export const OVERSCAN_ROWS = 5; +export const EMOJI_SECTION_GAP = 12; +const CATEGORY_HEADER_GAP = 8; -const EMOJI_GRID_INLINE_PADDING = 16; const EMOJI_GRID_MIN_CELL_WIDTH = EMOJI_ROW_HEIGHT; - -function getRemScale(): number { - if (typeof document === 'undefined') return 1; - const rootFontSize = Number.parseFloat(getComputedStyle(document.documentElement).fontSize); - if (Number.isFinite(rootFontSize) && rootFontSize > 0) { - return rootFontSize / REM_BASE_PX; - } - return 1; -} +export const EMOJI_GRID_TRACK_WIDTH = EMOJI_GRID_MIN_CELL_WIDTH; +export const EMOJI_GRID_SCROLLER_PADDING_WIDTH = 8; export function getEmojiGridColumns(containerWidth: number): number { if (containerWidth <= 0) return EMOJIS_PER_ROW; - const remScale = getRemScale(); - const availableWidth = Math.max(0, containerWidth - EMOJI_GRID_INLINE_PADDING * remScale); - const columns = Math.floor(availableWidth / (EMOJI_GRID_MIN_CELL_WIDTH * remScale)); + const remScale = getRemScaleForDocument(typeof document === 'undefined' ? null : document); + const columns = Math.floor(containerWidth / (EMOJI_GRID_MIN_CELL_WIDTH * remScale)); return Math.max(EMOJIS_PER_ROW_MIN, columns); } +export type EmojiRowKind = 'header' | 'emoji-row'; + +export interface EmojiRowMetrics { + remScale: number; + sectionGap: number; +} + +export interface EmojiRowWindow { + firstRow: number; + lastRow: number; +} + +export function buildEmojiRowOffsets( + rowKinds: ReadonlyArray, + {remScale, sectionGap}: EmojiRowMetrics, +): Array { + const rowOffsets = new Array(rowKinds.length + 1); + rowOffsets[0] = 0; + let runningOffset = 0; + for (let rowIndex = 0; rowIndex < rowKinds.length; rowIndex += 1) { + if (rowKinds[rowIndex] === 'header') { + runningOffset += (CATEGORY_HEADER_HEIGHT + CATEGORY_HEADER_GAP) * remScale; + } else { + runningOffset += EMOJI_ROW_HEIGHT * remScale; + if (rowKinds[rowIndex + 1] === 'header') { + runningOffset += sectionGap; + } + } + rowOffsets[rowIndex + 1] = runningOffset; + } + return rowOffsets; +} + +export function findEmojiRowForOffset(rowOffsets: ReadonlyArray, pixel: number): number { + const lastRow = rowOffsets.length - 2; + if (lastRow < 0 || pixel <= 0) { + return 0; + } + if (pixel >= rowOffsets[lastRow + 1]!) { + return lastRow; + } + let low = 0; + let high = lastRow; + while (low < high) { + const mid = (low + high + 1) >> 1; + if (rowOffsets[mid]! <= pixel) { + low = mid; + } else { + high = mid - 1; + } + } + return low; +} + +export function getEmojiRowWindow( + rowOffsets: ReadonlyArray, + scrollTop: number, + viewportHeight: number, +): EmojiRowWindow { + const totalRows = rowOffsets.length - 1; + if (totalRows <= 0 || !(viewportHeight > 0)) { + return {firstRow: 0, lastRow: -1}; + } + const safeScrollTop = Number.isFinite(scrollTop) ? Math.max(0, scrollTop) : 0; + return { + firstRow: findEmojiRowForOffset(rowOffsets, safeScrollTop), + lastRow: findEmojiRowForOffset(rowOffsets, safeScrollTop + viewportHeight), + }; +} + export const getEmojiSpriteSheetLayout = (index: number, perRow: number, rows: number): CSSProperties => { const column = index % perRow; const row = Math.floor(index / perRow); diff --git a/fluxer_app/src/features/channel/components/emoji_picker/VirtualRow.tsx b/fluxer_app/src/features/channel/components/emoji_picker/VirtualRow.tsx index 06d4e05d5..ed21593f5 100644 --- a/fluxer_app/src/features/channel/components/emoji_picker/VirtualRow.tsx +++ b/fluxer_app/src/features/channel/components/emoji_picker/VirtualRow.tsx @@ -4,7 +4,6 @@ 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'; @@ -13,11 +12,10 @@ 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'; +import React from 'react'; export type VirtualRow = | {type: 'header'; category: string; name: string; guildId?: string; index: number} @@ -32,9 +30,8 @@ interface VirtualRowRendererProps { shouldAnimate: boolean; gridColumns?: number; gridWidth?: number; - hoveredEmoji: FlatEmoji | null; - selectedRow: number; - selectedColumn: number; + cellTrackWidth?: number; + selectedColumnInRow: number; emojiRowIndex: number; shouldScrollOnSelection?: boolean; emojiRefs: React.MutableRefObject>; @@ -50,8 +47,8 @@ const VirtualRowRenderer: React.FC = React.memo( shouldAnimate, gridColumns = 9, gridWidth, - selectedRow, - selectedColumn, + cellTrackWidth, + selectedColumnInRow, emojiRowIndex, shouldScrollOnSelection = false, emojiRefs, @@ -98,7 +95,6 @@ const VirtualRowRenderer: React.FC = React.memo( ); } + const hasFixedTrack = cellTrackWidth != null && cellTrackWidth > 0; return (
= React.memo( data-flx="channel.emoji-picker.virtual-row.virtual-row-renderer.div--2" >
{row.emojis.map((emoji, colIndex) => { - const isSelected = emojiRowIndex === selectedRow && colIndex === selectedColumn; + const isSelected = colIndex === selectedColumnInRow; const shouldHighlight = isSelected; return ( handleHover(e, emojiRowIndex, colIndex)} handleSelect={handleSelect} @@ -204,7 +203,7 @@ interface VirtualizedRowProps { allowAnimation: boolean; gridColumns?: number; gridWidth?: number; - hoveredEmoji: FlatEmoji | null; + cellTrackWidth?: number; selectedRow: number; selectedColumn: number; emojiRowIndex: number; @@ -222,79 +221,31 @@ export const VirtualizedRow: React.FC = observer( allowAnimation, gridColumns, gridWidth, - hoveredEmoji, + cellTrackWidth, 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 ( -
- ); - } + const selectedColumnInRow = emojiRowIndex === selectedRow ? selectedColumn : -1; return ( -
- -
+ ); }, );