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
+355 -199

No files matched your search

@@ -236,6 +236,7 @@
.headerIcon { .headerIcon {
height: 1rem; height: 1rem;
width: 1rem; width: 1rem;
color: var(--text-primary-muted);
} }
.caretIcon { .caretIcon {
@@ -384,6 +385,11 @@
width: 100%; width: 100%;
} }
.emojiGridFixedTrack {
grid-auto-flow: column;
justify-content: start;
}
.emojiGridCategory { .emojiGridCategory {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@@ -437,6 +443,15 @@
object-fit: contain; 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 { .spriteEmoji {
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
@@ -1,11 +1,20 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // 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 {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import {useSearchInputAutofocus} from '@app/features/app/hooks/useSearchInputAutofocus'; import {useSearchInputAutofocus} from '@app/features/app/hooks/useSearchInputAutofocus';
import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate'; import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate';
import styles from '@app/features/channel/components/EmojiPicker.module.css'; import styles from '@app/features/channel/components/EmojiPicker.module.css';
import {EmojiPickerCategoryList} from '@app/features/channel/components/emoji_picker/EmojiPickerCategoryList'; 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 {EmojiPickerInspector} from '@app/features/channel/components/emoji_picker/EmojiPickerInspector';
import {EmojiPickerSearchBar} from '@app/features/channel/components/emoji_picker/EmojiPickerSearchBar'; import {EmojiPickerSearchBar} from '@app/features/channel/components/emoji_picker/EmojiPickerSearchBar';
import {useEmojiCategories} from '@app/features/channel/components/emoji_picker/hooks/useEmojiCategories'; import {useEmojiCategories} from '@app/features/channel/components/emoji_picker/hooks/useEmojiCategories';
@@ -27,18 +36,28 @@ import {
shouldShowEmojiPremiumUpsell, shouldShowEmojiPremiumUpsell,
} from '@app/features/expressions/utils/ExpressionPermissionUtils'; } from '@app/features/expressions/utils/ExpressionPermissionUtils';
import {getEmojiDisplayDataWithSkinTone} from '@app/features/expressions/utils/SkinToneUtils'; 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 Permission from '@app/features/permissions/state/Permission';
import {ComponentBus} from '@app/features/platform/utils/ComponentBus'; import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
import {usePremiumUpsellData} from '@app/features/premium/hooks/usePremiumUpsellData'; import {usePremiumUpsellData} from '@app/features/premium/hooks/usePremiumUpsellData';
import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils'; import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller'; 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 {msg} from '@lingui/core/macro';
import {Plural, Trans, useLingui} from '@lingui/react/macro'; import {Plural, Trans, useLingui} from '@lingui/react/macro';
import {SmileySadIcon} from '@phosphor-icons/react'; import {SmileySadIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite'; import {observer} from 'mobx-react-lite';
import type React from 'react'; 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({ const NO_EMOJIS_MATCH_YOUR_SEARCH_DESCRIPTOR = msg({
message: 'No emojis match that search', message: 'No emojis match that search',
@@ -58,14 +77,15 @@ export const EmojiPicker = observer(
const [selectedColumn, setSelectedColumn] = useState(-1); const [selectedColumn, setSelectedColumn] = useState(-1);
const [shouldScrollOnSelection, setShouldScrollOnSelection] = useState(false); const [shouldScrollOnSelection, setShouldScrollOnSelection] = useState(false);
const scrollerRef = useRef<ScrollerHandle>(null); 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 gridColumns = useMemo(() => getEmojiGridColumns(viewportSize.width), [viewportSize.width]);
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
const emojiRefs = useRef<Map<string, HTMLButtonElement>>(new Map()); const emojiRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const normalizedSearchTerm = useMemo(() => normalizeEmojiSearchQuery(searchTerm), [searchTerm]); const normalizedSearchTerm = useMemo(() => normalizeEmojiSearchQuery(searchTerm), [searchTerm]);
const {i18n} = useLingui(); const {i18n} = useLingui();
const channel = channelId ? (Channels.getChannel(channelId) ?? null) : null; 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 [emojiDataVersion, setEmojiDataVersion] = useState(0);
const permissionVersion = useSyncExternalStore(Permission.subscribe.bind(Permission), () => Permission.version); const permissionVersion = useSyncExternalStore(Permission.subscribe.bind(Permission), () => Permission.version);
const skinTone = Emoji.skinTone; 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 key = emoji.id ?? emoji.uniqueName ?? emoji.name ?? `${emoji.guildId ?? 'unicode'}-preview`;
const {url: fallbackDisplayUrl} = getEmojiDisplayDataWithSkinTone(emoji, skinTone); const {url: fallbackDisplayUrl} = getEmojiDisplayDataWithSkinTone(emoji, skinTone);
const displayUrl = emoji.id 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; : fallbackDisplayUrl;
const content = displayUrl ? ( const content = displayUrl ? (
<img <img src={displayUrl} alt={emoji.name} data-flx="channel.emoji-picker.render-emoji-preview-item.img" />
src={displayUrl}
alt={emoji.name}
loading="lazy"
data-flx="channel.emoji-picker.render-emoji-preview-item.img"
/>
) : ( ) : (
<span <span
className={premiumStyles.previewEmojiText} className={premiumStyles.previewEmojiText}
@@ -184,17 +205,54 @@ export const EmojiPicker = observer(
} }
return result; return result;
}, [pickerRows]); }, [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 handleCategoryClick = (category: string) => {
const element = categoryRefs.current.get(category); const rowIndex = categoryRowIndexes.get(category);
if (element) { if (rowIndex == null) {
scrollerRef.current?.revealElement({node: element, preferStartEdge: true}); return;
}
};
const handleHover = (emoji: FlatEmoji | null, row?: number, column?: number) => {
setHoveredEmoji(emoji);
if (emoji && row !== undefined && column !== undefined) {
handleSelectionChange(row, column, false);
} }
scrollerRef.current?.scrollTo({to: listMetrics.origin + rowOffsets[rowIndex]!});
}; };
const handleEmojiSelect = useCallback( const handleEmojiSelect = useCallback(
(emoji: FlatEmoji, shiftKey?: boolean) => { (emoji: FlatEmoji, shiftKey?: boolean) => {
@@ -229,11 +287,31 @@ export const EmojiPicker = observer(
}, },
[pickerRows], [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(() => { useEffect(() => {
if (renderedEmojis.length > 0 && selectedRow === 0 && selectedColumn === 0 && !hoveredEmoji) { if (renderedEmojis.length > 0 && selectedRow === 0 && selectedColumn === 0 && !hoveredEmoji) {
handleSelectionChange(0, 0, false); handleSelectionChange(0, 0, false);
} }
}, [renderedEmojis, selectedRow, selectedColumn, hoveredEmoji, handleSelectionChange]); }, [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( const handleSelectEmoji = useCallback(
(row: number | null, column: number | null, event?: React.KeyboardEvent) => { (row: number | null, column: number | null, event?: React.KeyboardEvent) => {
if (row === null || column === null) { if (row === null || column === null) {
@@ -281,8 +359,8 @@ export const EmojiPicker = observer(
className={`${styles.list} ${styles.listWrapper}`} className={`${styles.list} ${styles.listWrapper}`}
fade={false} fade={false}
key="emoji_picker-scroller" key="emoji_picker-scroller"
onScroll={handleScroll}
onResize={handleResize} onResize={handleResize}
data-emoji-picker-scroll-root="true"
data-flx="channel.emoji-picker.list" data-flx="channel.emoji-picker.list"
> >
{showPremiumUpsell && ( {showPremiumUpsell && (
@@ -294,43 +372,44 @@ export const EmojiPicker = observer(
data-flx="channel.emoji-picker.premium-upsell-banner" data-flx="channel.emoji-picker.premium-upsell-banner"
/> />
)} )}
{pickerRows.map((row, index) => { <div
const emojiRowIndex = pickerRows.slice(0, index).filter((r) => r.type === 'emoji-row').length; ref={rowListRef}
const needsSpacingAfter = row.type === 'emoji-row' && pickerRows[index + 1]?.type === 'header'; style={{position: 'relative', flexShrink: 0, height: `${contentHeight}px`}}
return ( data-flx="channel.emoji-picker.row-list"
<div >
key={`${row.type}-${row.index}`} {pickerRows.slice(rowWindow.firstRow, rowWindow.lastRow + 1).map((row, windowIndex) => {
ref={ const rowIndex = rowWindow.firstRow + windowIndex;
row.type === 'header' return (
? (el) => { <div
if (el && 'category' in row) { key={`${row.type}-${row.index}`}
categoryRefs.current.set(row.category, el); style={{
} position: 'absolute',
} left: 0,
: undefined right: 0,
} transform: `translateY(${rowOffsets[rowIndex]!}px)`,
style={row.type === 'emoji-row' && needsSpacingAfter ? {marginBottom: '12px'} : undefined} }}
data-flx="channel.emoji-picker.div" data-flx="channel.emoji-picker.div"
> >
<VirtualizedRow <VirtualizedRow
row={row} row={row}
handleHover={handleHover} handleHover={handleHover}
handleSelect={handleEmojiSelect} handleSelect={handleEmojiSelect}
skinTone={skinTone} skinTone={skinTone}
channel={channel} channel={channel}
allowAnimation={shouldAnimateEmoji} allowAnimation={shouldAnimateEmoji}
gridColumns={gridColumns} gridColumns={gridColumns}
hoveredEmoji={hoveredEmoji} cellTrackWidth={EMOJI_GRID_TRACK_WIDTH}
selectedRow={selectedRow} selectedRow={selectedRow}
selectedColumn={selectedColumn} selectedColumn={selectedColumn}
emojiRowIndex={emojiRowIndex} emojiRowIndex={emojiRowIndexes[rowIndex]!}
shouldScrollOnSelection={shouldScrollOnSelection} shouldScrollOnSelection={shouldScrollOnSelection}
emojiRefs={emojiRefs} emojiRefs={emojiRefs}
data-flx="channel.emoji-picker.virtualized-row" data-flx="channel.emoji-picker.virtualized-row"
/> />
</div> </div>
); );
})} })}
</div>
</Scroller> </Scroller>
{renderedEmojis.length === 0 && ( {renderedEmojis.length === 0 && (
<div className={styles.emptyState} data-flx="channel.emoji-picker.empty-state"> <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 {useSearchInputAutofocus} from '@app/features/app/hooks/useSearchInputAutofocus';
import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate'; import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate';
import {EmojiPickerCategoryList} from '@app/features/channel/components/emoji_picker/EmojiPickerCategoryList'; 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 {EmojiPickerSearchBar} from '@app/features/channel/components/emoji_picker/EmojiPickerSearchBar';
import {useEmojiCategories} from '@app/features/channel/components/emoji_picker/hooks/useEmojiCategories'; import {useEmojiCategories} from '@app/features/channel/components/emoji_picker/hooks/useEmojiCategories';
import {useVirtualRows} from '@app/features/channel/components/emoji_picker/hooks/useVirtualRows'; import {useVirtualRows} from '@app/features/channel/components/emoji_picker/hooks/useVirtualRows';
@@ -26,16 +31,17 @@ import {
shouldShowEmojiPremiumUpsell, shouldShowEmojiPremiumUpsell,
} from '@app/features/expressions/utils/ExpressionPermissionUtils'; } from '@app/features/expressions/utils/ExpressionPermissionUtils';
import {getEmojiDisplayDataWithSkinTone} from '@app/features/expressions/utils/SkinToneUtils'; 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 Permission from '@app/features/permissions/state/Permission';
import {ComponentBus} from '@app/features/platform/utils/ComponentBus'; import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
import {usePremiumUpsellData} from '@app/features/premium/hooks/usePremiumUpsellData'; import {usePremiumUpsellData} from '@app/features/premium/hooks/usePremiumUpsellData';
import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils'; import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller'; 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 {Plural, Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite'; import {observer} from 'mobx-react-lite';
import type React from 'react'; 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 { function getVisibleViewportWidth(): number {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -67,9 +73,10 @@ export const MobileEmojiPicker = observer(
const scrollerRef = useRef<ScrollerHandle>(null); const scrollerRef = useRef<ScrollerHandle>(null);
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
const emojiRefs = useRef<Map<string, HTMLButtonElement>>(new Map()); 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 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 [emojiDataVersion, setEmojiDataVersion] = useState(0);
const permissionVersion = useSyncExternalStore(Permission.subscribe.bind(Permission), () => Permission.version); const permissionVersion = useSyncExternalStore(Permission.subscribe.bind(Permission), () => Permission.version);
const getEmojiAvailability = useCallback( const getEmojiAvailability = useCallback(
@@ -119,7 +126,13 @@ export const MobileEmojiPicker = observer(
const key = emoji.id ?? emoji.uniqueName ?? emoji.name ?? `${emoji.guildId ?? 'unicode'}-mobile`; const key = emoji.id ?? emoji.uniqueName ?? emoji.name ?? `${emoji.guildId ?? 'unicode'}-mobile`;
const {url: fallbackDisplayUrl} = getEmojiDisplayDataWithSkinTone(emoji, skinTone); const {url: fallbackDisplayUrl} = getEmojiDisplayDataWithSkinTone(emoji, skinTone);
const displayUrl = emoji.id 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; : fallbackDisplayUrl;
let content: React.ReactNode; let content: React.ReactNode;
if (displayUrl) { if (displayUrl) {
@@ -127,7 +140,6 @@ export const MobileEmojiPicker = observer(
<img <img
src={displayUrl} src={displayUrl}
alt={emoji.name} alt={emoji.name}
loading="lazy"
data-flx="channel.mobile-emoji-picker.render-emoji-preview-item.img" data-flx="channel.mobile-emoji-picker.render-emoji-preview-item.img"
/> />
); );
@@ -220,15 +232,50 @@ export const MobileEmojiPicker = observer(
shouldShowEmojiPremiumUpsell(channel) && shouldShowEmojiPremiumUpsell(channel) &&
!normalizedSearchTerm && !normalizedSearchTerm &&
lockedEmojiCount > 0; lockedEmojiCount > 0;
const handleCategoryClick = (category: string) => { const categoryRowIndexes = useMemo(() => {
const element = categoryRefs.current.get(category); const categories = new Map<string, number>();
if (element) { for (let rowIndex = 0; rowIndex < pickerRows.length; rowIndex += 1) {
scrollerRef.current?.revealElement({node: element, preferStartEdge: true}); 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); setHoveredEmoji(emoji);
}; }, []);
const searchBar = !hideSearchBar ? ( const searchBar = !hideSearchBar ? (
<EmojiPickerSearchBar <EmojiPickerSearchBar
searchTerm={searchTerm} searchTerm={searchTerm}
@@ -256,7 +303,7 @@ export const MobileEmojiPicker = observer(
ref={scrollerRef} ref={scrollerRef}
className={`${mobileStyles.list} ${mobileStyles.listWrapper}`} className={`${mobileStyles.list} ${mobileStyles.listWrapper}`}
key="mobile-emoji_picker-scroller" key="mobile-emoji_picker-scroller"
data-emoji-picker-scroll-root="true" onScroll={handleScroll}
onResize={handleResize} onResize={handleResize}
data-flx="channel.mobile-emoji-picker.scroller" data-flx="channel.mobile-emoji-picker.scroller"
> >
@@ -269,38 +316,40 @@ export const MobileEmojiPicker = observer(
data-flx="channel.mobile-emoji-picker.premium-upsell-banner" data-flx="channel.mobile-emoji-picker.premium-upsell-banner"
/> />
)} )}
{pickerRows.map((row) => ( <div
<div ref={rowListRef}
key={`${row.type}-${row.index}`} style={{position: 'relative', flexShrink: 0, height: `${contentHeight}px`}}
ref={ data-flx="channel.mobile-emoji-picker.row-list"
row.type === 'header' >
? (el) => { {pickerRows.slice(rowWindow.firstRow, rowWindow.lastRow + 1).map((row, windowIndex) => (
if (el && 'category' in row) { <div
categoryRefs.current.set(row.category, el); key={`${row.type}-${row.index}`}
} style={{
} position: 'absolute',
: undefined left: 0,
} right: 0,
data-flx="channel.mobile-emoji-picker.div--4" transform: `translateY(${rowOffsets[rowWindow.firstRow + windowIndex]!}px)`,
> }}
<VirtualizedRow data-flx="channel.mobile-emoji-picker.div--4"
row={row} >
handleHover={handleHover} <VirtualizedRow
handleSelect={handleSelect} row={row}
skinTone={skinTone} handleHover={handleHover}
channel={channel} handleSelect={handleSelect}
allowAnimation={shouldAnimateEmoji} skinTone={skinTone}
gridColumns={gridColumns} channel={channel}
gridWidth={gridWidth} allowAnimation={shouldAnimateEmoji}
hoveredEmoji={hoveredEmoji} gridColumns={gridColumns}
selectedRow={-1} gridWidth={gridWidth}
selectedColumn={-1} selectedRow={-1}
emojiRowIndex={0} selectedColumn={-1}
emojiRefs={emojiRefs} emojiRowIndex={0}
data-flx="channel.mobile-emoji-picker.virtualized-row" emojiRefs={emojiRefs}
/> data-flx="channel.mobile-emoji-picker.virtualized-row"
</div> />
))} </div>
))}
</div>
</Scroller> </Scroller>
</div> </div>
</div> </div>
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import {convertToCodePoints} from '@app/features/expressions/utils/EmojiCodepointUtils'; 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 sprite1f3fb2x from '@app/media/images/emoji-sprites/[email protected]';
import sprite1f3fb1x from '@app/media/images/emoji-sprites/spritesheet-1f3fb.png'; import sprite1f3fb1x from '@app/media/images/emoji-sprites/spritesheet-1f3fb.png';
import sprite1f3fc2x from '@app/media/images/emoji-sprites/[email protected]'; 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 CATEGORY_HEADER_HEIGHT = 32;
export const EMOJIS_PER_ROW = 9; export const EMOJIS_PER_ROW = 9;
const EMOJIS_PER_ROW_MIN = 5; 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; const EMOJI_GRID_MIN_CELL_WIDTH = EMOJI_ROW_HEIGHT;
export const EMOJI_GRID_TRACK_WIDTH = EMOJI_GRID_MIN_CELL_WIDTH;
function getRemScale(): number { export const EMOJI_GRID_SCROLLER_PADDING_WIDTH = 8;
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 function getEmojiGridColumns(containerWidth: number): number { export function getEmojiGridColumns(containerWidth: number): number {
if (containerWidth <= 0) return EMOJIS_PER_ROW; if (containerWidth <= 0) return EMOJIS_PER_ROW;
const remScale = getRemScale(); const remScale = getRemScaleForDocument(typeof document === 'undefined' ? null : document);
const availableWidth = Math.max(0, containerWidth - EMOJI_GRID_INLINE_PADDING * remScale); const columns = Math.floor(containerWidth / (EMOJI_GRID_MIN_CELL_WIDTH * remScale));
const columns = Math.floor(availableWidth / (EMOJI_GRID_MIN_CELL_WIDTH * remScale));
return Math.max(EMOJIS_PER_ROW_MIN, columns); 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 => { export const getEmojiSpriteSheetLayout = (index: number, perRow: number, rows: number): CSSProperties => {
const column = index % perRow; const column = index % perRow;
const row = Math.floor(index / perRow); const row = Math.floor(index / perRow);
@@ -4,7 +4,6 @@ import styles from '@app/features/channel/components/EmojiPicker.module.css';
import { import {
CATEGORY_HEADER_HEIGHT, CATEGORY_HEADER_HEIGHT,
EMOJI_ROW_HEIGHT, EMOJI_ROW_HEIGHT,
OVERSCAN_ROWS,
} from '@app/features/channel/components/emoji_picker/EmojiPickerConstants'; } from '@app/features/channel/components/emoji_picker/EmojiPickerConstants';
import {EmojiRenderer} from '@app/features/channel/components/emoji_picker/EmojiRenderer'; import {EmojiRenderer} from '@app/features/channel/components/emoji_picker/EmojiRenderer';
import type {Channel} from '@app/features/channel/models/Channel'; 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 type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
import {GuildIcon} from '@app/features/guild/components/popouts/GuildIcon'; import {GuildIcon} from '@app/features/guild/components/popouts/GuildIcon';
import Guilds from '@app/features/guild/state/Guilds'; 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 {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {CaretDownIcon, ClockIcon, StarIcon} from '@phosphor-icons/react'; import {CaretDownIcon, ClockIcon, StarIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite'; import {observer} from 'mobx-react-lite';
import React, {useEffect, useRef, useState} from 'react'; import React from 'react';
export type VirtualRow = export type VirtualRow =
| {type: 'header'; category: string; name: string; guildId?: string; index: number} | {type: 'header'; category: string; name: string; guildId?: string; index: number}
@@ -32,9 +30,8 @@ interface VirtualRowRendererProps {
shouldAnimate: boolean; shouldAnimate: boolean;
gridColumns?: number; gridColumns?: number;
gridWidth?: number; gridWidth?: number;
hoveredEmoji: FlatEmoji | null; cellTrackWidth?: number;
selectedRow: number; selectedColumnInRow: number;
selectedColumn: number;
emojiRowIndex: number; emojiRowIndex: number;
shouldScrollOnSelection?: boolean; shouldScrollOnSelection?: boolean;
emojiRefs: React.MutableRefObject<Map<string, HTMLButtonElement>>; emojiRefs: React.MutableRefObject<Map<string, HTMLButtonElement>>;
@@ -50,8 +47,8 @@ const VirtualRowRenderer: React.FC<VirtualRowRendererProps> = React.memo(
shouldAnimate, shouldAnimate,
gridColumns = 9, gridColumns = 9,
gridWidth, gridWidth,
selectedRow, cellTrackWidth,
selectedColumn, selectedColumnInRow,
emojiRowIndex, emojiRowIndex,
shouldScrollOnSelection = false, shouldScrollOnSelection = false,
emojiRefs, emojiRefs,
@@ -98,7 +95,6 @@ const VirtualRowRenderer: React.FC<VirtualRowRendererProps> = React.memo(
<button <button
type="button" type="button"
onClick={handleToggleCategory} onClick={handleToggleCategory}
className={styles.categoryTitle}
style={{ style={{
height: remFromPx(CATEGORY_HEADER_HEIGHT), height: remFromPx(CATEGORY_HEADER_HEIGHT),
display: 'flex', display: 'flex',
@@ -146,6 +142,7 @@ const VirtualRowRenderer: React.FC<VirtualRowRendererProps> = React.memo(
</button> </button>
); );
} }
const hasFixedTrack = cellTrackWidth != null && cellTrackWidth > 0;
return ( return (
<div <div
style={{ style={{
@@ -155,20 +152,22 @@ const VirtualRowRenderer: React.FC<VirtualRowRendererProps> = React.memo(
data-flx="channel.emoji-picker.virtual-row.virtual-row-renderer.div--2" data-flx="channel.emoji-picker.virtual-row.virtual-row-renderer.div--2"
> >
<div <div
className={styles.emojiGrid} className={hasFixedTrack ? `${styles.emojiGrid} ${styles.emojiGridFixedTrack}` : styles.emojiGrid}
style={{ style={{
gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`, gridTemplateColumns: hasFixedTrack
? `repeat(auto-fill, ${remFromPx(cellTrackWidth)})`
: `repeat(${gridColumns}, minmax(0, 1fr))`,
marginInline: gridWidth ? 'auto' : undefined, marginInline: gridWidth ? 'auto' : undefined,
width: gridWidth ? `${gridWidth}px` : undefined, width: gridWidth ? `${gridWidth}px` : undefined,
}} }}
data-flx="channel.emoji-picker.virtual-row.virtual-row-renderer.emoji-grid" data-flx="channel.emoji-picker.virtual-row.virtual-row-renderer.emoji-grid"
> >
{row.emojis.map((emoji, colIndex) => { {row.emojis.map((emoji, colIndex) => {
const isSelected = emojiRowIndex === selectedRow && colIndex === selectedColumn; const isSelected = colIndex === selectedColumnInRow;
const shouldHighlight = isSelected; const shouldHighlight = isSelected;
return ( return (
<EmojiRenderer <EmojiRenderer
key={emoji.name} key={emoji.id ?? emoji.uniqueName}
emoji={emoji} emoji={emoji}
handleHover={(e) => handleHover(e, emojiRowIndex, colIndex)} handleHover={(e) => handleHover(e, emojiRowIndex, colIndex)}
handleSelect={handleSelect} handleSelect={handleSelect}
@@ -204,7 +203,7 @@ interface VirtualizedRowProps {
allowAnimation: boolean; allowAnimation: boolean;
gridColumns?: number; gridColumns?: number;
gridWidth?: number; gridWidth?: number;
hoveredEmoji: FlatEmoji | null; cellTrackWidth?: number;
selectedRow: number; selectedRow: number;
selectedColumn: number; selectedColumn: number;
emojiRowIndex: number; emojiRowIndex: number;
@@ -222,79 +221,31 @@ export const VirtualizedRow: React.FC<VirtualizedRowProps> = observer(
allowAnimation, allowAnimation,
gridColumns, gridColumns,
gridWidth, gridWidth,
hoveredEmoji, cellTrackWidth,
selectedRow, selectedRow,
selectedColumn, selectedColumn,
emojiRowIndex, emojiRowIndex,
shouldScrollOnSelection = false, shouldScrollOnSelection = false,
emojiRefs, emojiRefs,
}) => { }) => {
const [isVisible, setIsVisible] = useState(false); const selectedColumnInRow = emojiRowIndex === selectedRow ? selectedColumn : -1;
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) {
return (
<div
ref={placeholderRef}
style={{height: remFromPx(height)}}
data-flx="channel.emoji-picker.virtual-row.virtualized-row.div"
/>
);
}
return ( return (
<div ref={placeholderRef} data-flx="channel.emoji-picker.virtual-row.virtualized-row.div--2"> <VirtualRowRenderer
<VirtualRowRenderer row={row}
row={row} handleHover={handleHover}
handleHover={handleHover} handleSelect={handleSelect}
handleSelect={handleSelect} skinTone={skinTone}
skinTone={skinTone} channel={channel}
channel={channel} shouldAnimate={allowAnimation}
shouldAnimate={allowAnimation && isInViewport} gridColumns={gridColumns}
gridColumns={gridColumns} gridWidth={gridWidth}
gridWidth={gridWidth} cellTrackWidth={cellTrackWidth}
hoveredEmoji={hoveredEmoji} selectedColumnInRow={selectedColumnInRow}
selectedRow={selectedRow} emojiRowIndex={emojiRowIndex}
selectedColumn={selectedColumn} shouldScrollOnSelection={shouldScrollOnSelection}
emojiRowIndex={emojiRowIndex} emojiRefs={emojiRefs}
shouldScrollOnSelection={shouldScrollOnSelection} data-flx="channel.emoji-picker.virtual-row.virtualized-row.virtual-row-renderer"
emojiRefs={emojiRefs} />
data-flx="channel.emoji-picker.virtual-row.virtualized-row.virtual-row-renderer"
/>
</div>
); );
}, },
); );