perf(app): window emoji picker rows by offset instead of observers (#1870)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:35:02 +02:00
1 parent a59f3d0d0c
commit 5834e32aa5
5 files changed
+299 -143

No files matched your search

@@ -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;
@@ -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<ScrollerHandle>(null);
const {viewportSize, handleResize} = useScrollerViewport(scrollerRef);
const {viewportSize, scrollTop, handleScroll, handleResize} = useScrollerViewport(scrollerRef);
const gridColumns = useMemo(() => getEmojiGridColumns(viewportSize.width), [viewportSize.width]);
const searchInputRef = useRef<HTMLInputElement>(null);
const emojiRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const normalizedSearchTerm = useMemo(() => normalizeEmojiSearchQuery(searchTerm), [searchTerm]);
const {i18n} = useLingui();
const channel = channelId ? (Channels.getChannel(channelId) ?? null) : null;
const categoryRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const rowListRef = useRef<HTMLDivElement>(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 ? (
<img
src={displayUrl}
alt={emoji.name}
loading="lazy"
data-flx="channel.emoji-picker.render-emoji-preview-item.img"
/>
<img src={displayUrl} alt={emoji.name} data-flx="channel.emoji-picker.render-emoji-preview-item.img" />
) : (
<span
className={premiumStyles.previewEmojiText}
@@ -184,17 +205,54 @@ export const EmojiPicker = observer(
}
return result;
}, [pickerRows]);
const {emojiRowIndexes, emojiRowStarts, categoryRowIndexes} = useMemo(() => {
const indexes = new Array<number>(pickerRows.length);
const starts: Array<number> = [];
const categories = new Map<string, number>();
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,22 +372,22 @@ 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';
<div
ref={rowListRef}
style={{position: 'relative', flexShrink: 0, height: `${contentHeight}px`}}
data-flx="channel.emoji-picker.row-list"
>
{pickerRows.slice(rowWindow.firstRow, rowWindow.lastRow + 1).map((row, windowIndex) => {
const rowIndex = rowWindow.firstRow + windowIndex;
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}
style={{
position: 'absolute',
left: 0,
right: 0,
transform: `translateY(${rowOffsets[rowIndex]!}px)`,
}}
data-flx="channel.emoji-picker.div"
>
<VirtualizedRow
@@ -320,10 +398,10 @@ export const EmojiPicker = observer(
channel={channel}
allowAnimation={shouldAnimateEmoji}
gridColumns={gridColumns}
hoveredEmoji={hoveredEmoji}
cellTrackWidth={EMOJI_GRID_TRACK_WIDTH}
selectedRow={selectedRow}
selectedColumn={selectedColumn}
emojiRowIndex={emojiRowIndex}
emojiRowIndex={emojiRowIndexes[rowIndex]!}
shouldScrollOnSelection={shouldScrollOnSelection}
emojiRefs={emojiRefs}
data-flx="channel.emoji-picker.virtualized-row"
@@ -331,6 +409,7 @@ export const EmojiPicker = observer(
</div>
);
})}
</div>
</Scroller>
{renderedEmojis.length === 0 && (
<div className={styles.emptyState} data-flx="channel.emoji-picker.empty-state">
@@ -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<ScrollerHandle>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const emojiRefs = useRef<Map<string, HTMLButtonElement>>(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<Map<string, HTMLDivElement>>(new Map());
const rowListRef = useRef<HTMLDivElement>(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(
<img
src={displayUrl}
alt={emoji.name}
loading="lazy"
data-flx="channel.mobile-emoji-picker.render-emoji-preview-item.img"
/>
);
@@ -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<string, number>();
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 ? (
<EmojiPickerSearchBar
searchTerm={searchTerm}
@@ -256,7 +303,7 @@ export const MobileEmojiPicker = observer(
ref={scrollerRef}
className={`${mobileStyles.list} ${mobileStyles.listWrapper}`}
key="mobile-emoji_picker-scroller"
data-emoji-picker-scroll-root="true"
onScroll={handleScroll}
onResize={handleResize}
data-flx="channel.mobile-emoji-picker.scroller"
>
@@ -269,18 +316,20 @@ export const MobileEmojiPicker = observer(
data-flx="channel.mobile-emoji-picker.premium-upsell-banner"
/>
)}
{pickerRows.map((row) => (
<div
ref={rowListRef}
style={{position: 'relative', flexShrink: 0, height: `${contentHeight}px`}}
data-flx="channel.mobile-emoji-picker.row-list"
>
{pickerRows.slice(rowWindow.firstRow, rowWindow.lastRow + 1).map((row, windowIndex) => (
<div
key={`${row.type}-${row.index}`}
ref={
row.type === 'header'
? (el) => {
if (el && 'category' in row) {
categoryRefs.current.set(row.category, el);
}
}
: undefined
}
style={{
position: 'absolute',
left: 0,
right: 0,
transform: `translateY(${rowOffsets[rowWindow.firstRow + windowIndex]!}px)`,
}}
data-flx="channel.mobile-emoji-picker.div--4"
>
<VirtualizedRow
@@ -292,7 +341,6 @@ export const MobileEmojiPicker = observer(
allowAnimation={shouldAnimateEmoji}
gridColumns={gridColumns}
gridWidth={gridWidth}
hoveredEmoji={hoveredEmoji}
selectedRow={-1}
selectedColumn={-1}
emojiRowIndex={0}
@@ -301,6 +349,7 @@ export const MobileEmojiPicker = observer(
/>
</div>
))}
</div>
</Scroller>
</div>
</div>
@@ -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/[email protected]';
import sprite1f3fb1x from '@app/media/images/emoji-sprites/spritesheet-1f3fb.png';
import sprite1f3fc2x from '@app/media/images/emoji-sprites/[email protected]';
@@ -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<EmojiRowKind>,
{remScale, sectionGap}: EmojiRowMetrics,
): Array<number> {
const rowOffsets = new Array<number>(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<number>, 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<number>,
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);
@@ -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<Map<string, HTMLButtonElement>>;
@@ -50,8 +47,8 @@ const VirtualRowRenderer: React.FC<VirtualRowRendererProps> = React.memo(
shouldAnimate,
gridColumns = 9,
gridWidth,
selectedRow,
selectedColumn,
cellTrackWidth,
selectedColumnInRow,
emojiRowIndex,
shouldScrollOnSelection = false,
emojiRefs,
@@ -98,7 +95,6 @@ const VirtualRowRenderer: React.FC<VirtualRowRendererProps> = React.memo(
<button
type="button"
onClick={handleToggleCategory}
className={styles.categoryTitle}
style={{
height: remFromPx(CATEGORY_HEADER_HEIGHT),
display: 'flex',
@@ -146,6 +142,7 @@ const VirtualRowRenderer: React.FC<VirtualRowRendererProps> = React.memo(
</button>
);
}
const hasFixedTrack = cellTrackWidth != null && cellTrackWidth > 0;
return (
<div
style={{
@@ -155,20 +152,22 @@ const VirtualRowRenderer: React.FC<VirtualRowRendererProps> = React.memo(
data-flx="channel.emoji-picker.virtual-row.virtual-row-renderer.div--2"
>
<div
className={styles.emojiGrid}
className={hasFixedTrack ? `${styles.emojiGrid} ${styles.emojiGridFixedTrack}` : styles.emojiGrid}
style={{
gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`,
gridTemplateColumns: hasFixedTrack
? `repeat(auto-fill, ${remFromPx(cellTrackWidth)})`
: `repeat(${gridColumns}, minmax(0, 1fr))`,
marginInline: gridWidth ? 'auto' : undefined,
width: gridWidth ? `${gridWidth}px` : undefined,
}}
data-flx="channel.emoji-picker.virtual-row.virtual-row-renderer.emoji-grid"
>
{row.emojis.map((emoji, colIndex) => {
const isSelected = emojiRowIndex === selectedRow && colIndex === selectedColumn;
const isSelected = colIndex === selectedColumnInRow;
const shouldHighlight = isSelected;
return (
<EmojiRenderer
key={emoji.name}
key={emoji.id ?? emoji.uniqueName}
emoji={emoji}
handleHover={(e) => 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<VirtualizedRowProps> = 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<HTMLDivElement>(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) {
const selectedColumnInRow = emojiRowIndex === selectedRow ? selectedColumn : -1;
return (
<div
ref={placeholderRef}
style={{height: remFromPx(height)}}
data-flx="channel.emoji-picker.virtual-row.virtualized-row.div"
/>
);
}
return (
<div ref={placeholderRef} data-flx="channel.emoji-picker.virtual-row.virtualized-row.div--2">
<VirtualRowRenderer
row={row}
handleHover={handleHover}
handleSelect={handleSelect}
skinTone={skinTone}
channel={channel}
shouldAnimate={allowAnimation && isInViewport}
shouldAnimate={allowAnimation}
gridColumns={gridColumns}
gridWidth={gridWidth}
hoveredEmoji={hoveredEmoji}
selectedRow={selectedRow}
selectedColumn={selectedColumn}
cellTrackWidth={cellTrackWidth}
selectedColumnInRow={selectedColumnInRow}
emojiRowIndex={emojiRowIndex}
shouldScrollOnSelection={shouldScrollOnSelection}
emojiRefs={emojiRefs}
data-flx="channel.emoji-picker.virtual-row.virtualized-row.virtual-row-renderer"
/>
</div>
);
},
);