perf(app): window sticker picker rows by offset instead of observers (#1902)

This commit is contained in:
Hampus
2026-08-24 12:42:31 +02:00
committed by GitHub
parent b71ced9b2e
commit aabc13e3cb
4 changed files with 333 additions and 156 deletions
@@ -13,6 +13,12 @@ import {PickerEmptyState} from '@app/features/channel/components/shared/PickerEm
import {useStickerCategories} from '@app/features/channel/components/sticker_picker/hooks/useStickerCategories';
import {useVirtualRows} from '@app/features/channel/components/sticker_picker/hooks/useVirtualRows';
import {StickerPickerCategoryList} from '@app/features/channel/components/sticker_picker/StickerPickerCategoryList';
import {
buildStickerRowOffsets,
getStickerRowHeight,
getStickerRowWindow,
type StickerRowKind,
} from '@app/features/channel/components/sticker_picker/StickerPickerConstants';
import {StickerPickerInspector} from '@app/features/channel/components/sticker_picker/StickerPickerInspector';
import {StickerPickerSearchBar} from '@app/features/channel/components/sticker_picker/StickerPickerSearchBar';
import {VirtualRowRenderer} from '@app/features/channel/components/sticker_picker/VirtualRow';
@@ -34,12 +40,13 @@ 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 {getAppRemScale} from '@app/features/ui/utils/AppZoomUtils';
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
import {msg} from '@lingui/core/macro';
import {Plural, Trans, useLingui} from '@lingui/react/macro';
import {SmileySadIcon, StickerIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import {useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore} from 'react';
import {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore} from 'react';
const NO_STICKERS_AVAILABLE_DESCRIPTOR = msg({
message: 'No stickers yet',
@@ -73,9 +80,10 @@ export const MobileStickersPicker = observer(
const scrollerRef = useRef<ScrollerHandle>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const stickerRefs = 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, gridWidth: 0});
const [stickerDataVersion, setStickerDataVersion] = useState(0);
const permissionVersion = useSyncExternalStore(Permission.subscribe.bind(Permission), () => Permission.version);
const {shouldAnimate: shouldAnimateStickerPreview} = useStickerAnimation();
@@ -95,10 +103,10 @@ export const MobileStickersPicker = observer(
src={AvatarUtils.getStickerURL({
id: sticker.id,
animated: shouldAnimateStickerPreview,
isAnimatable: sticker.animated,
size: 320,
})}
alt={sticker.name}
loading="lazy"
data-flx="channel.mobile-stickers-picker.render-sticker-preview-item.img"
/>
</div>
@@ -170,11 +178,58 @@ export const MobileStickersPicker = observer(
const isSearching = searchTerm.trim().length > 0;
const showPremiumUpsell =
shouldShowPremiumFeatures() && shouldShowStickerPremiumUpsell(channel) && !isSearching && lockedStickerCount > 0;
const handleCategoryClick = (category: string) => {
const element = categoryRefs.current.get(category);
if (element) {
scrollerRef.current?.revealElement({node: element, preferStartEdge: true});
const {stickerRowIndexes, categoryRowIndexes} = useMemo(() => {
const indexes = new Array<number>(pickerRows.length);
const categories = new Map<string, number>();
let stickerRowIndex = 0;
for (let rowIndex = 0; rowIndex < pickerRows.length; rowIndex += 1) {
const row = pickerRows[rowIndex];
indexes[rowIndex] = stickerRowIndex;
if (row.type === 'sticker-row') {
stickerRowIndex += 1;
} else {
categories.set(row.category, rowIndex);
}
}
return {stickerRowIndexes: indexes, categoryRowIndexes: categories};
}, [pickerRows]);
const remScale = getAppRemScale();
const stickerRowHeight = useMemo(
() => getStickerRowHeight(listMetrics.gridWidth, gridColumns, remScale),
[listMetrics.gridWidth, gridColumns, remScale, zoomLevel],
);
const rowOffsets = useMemo(() => {
const rowKinds = pickerRows.map((row): StickerRowKind => row.type);
return buildStickerRowOffsets(rowKinds, {remScale, stickerRowHeight, sectionGap: 0});
}, [pickerRows, remScale, stickerRowHeight, 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;
const gridWidth = rowListNode.clientWidth;
setListMetrics((current) =>
current.origin === origin && current.viewportHeight === viewportHeight && current.gridWidth === gridWidth
? current
: {origin, viewportHeight, gridWidth},
);
});
const rowWindow = useMemo(
() => getStickerRowWindow(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 = (sticker: GuildSticker | null) => {
setHoveredSticker(sticker);
@@ -252,6 +307,7 @@ export const MobileStickersPicker = observer(
ref={scrollerRef}
className={`${mobileStyles.list} ${mobileStyles.listWrapper}`}
key="mobile-stickers-picker-scroller"
onScroll={handleScroll}
onResize={handleResize}
data-flx="channel.mobile-stickers-picker.scroller"
>
@@ -264,39 +320,41 @@ export const MobileStickersPicker = observer(
data-flx="channel.mobile-stickers-picker.premium-upsell-banner"
/>
)}
{pickerRows.map((row, index) => {
const stickerRowIndex = pickerRows.slice(0, index).filter((r) => r.type === 'sticker-row').length;
return (
<div
key={`${row.type}-${row.index}`}
ref={
row.type === 'header'
? (el) => {
if (el && 'category' in row) {
categoryRefs.current.set(row.category, el);
}
}
: undefined
}
data-flx="channel.mobile-stickers-picker.div--5"
>
<VirtualRowRenderer
row={row}
handleHover={handleHover}
handleSelect={handleStickerSelect}
gridColumns={gridColumns}
hoveredSticker={hoveredSticker}
selectedRow={-1}
selectedColumn={-1}
stickerRowIndex={stickerRowIndex}
shouldScrollOnSelection={false}
stickerRefs={stickerRefs}
channel={channel}
data-flx="channel.mobile-stickers-picker.virtual-row-renderer"
/>
</div>
);
})}
<div
ref={rowListRef}
style={{position: 'relative', flexShrink: 0, height: `${contentHeight}px`}}
data-flx="channel.mobile-stickers-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}`}
style={{
position: 'absolute',
left: 0,
right: 0,
transform: `translateY(${rowOffsets[rowIndex]!}px)`,
}}
data-flx="channel.mobile-stickers-picker.div--5"
>
<VirtualRowRenderer
row={row}
handleHover={handleHover}
handleSelect={handleStickerSelect}
gridColumns={gridColumns}
selectedRow={-1}
selectedColumn={-1}
stickerRowIndex={stickerRowIndexes[rowIndex]!}
shouldScrollOnSelection={false}
stickerRefs={stickerRefs}
channel={channel}
data-flx="channel.mobile-stickers-picker.virtual-row-renderer"
/>
</div>
);
})}
</div>
</Scroller>
</div>
</div>
@@ -1,16 +1,25 @@
// 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 styles from '@app/features/channel/components/EmojiPicker.module.css';
import gifStyles from '@app/features/channel/components/GifPicker.module.css';
import {PremiumUpsellBanner} from '@app/features/channel/components/PremiumUpsellBanner';
import premiumStyles from '@app/features/channel/components/PremiumUpsellBanner.module.css';
import {useScrollerViewport} from '@app/features/channel/components/pickers/shared/useScrollerViewport';
import {PickerEmptyState} from '@app/features/channel/components/shared/PickerEmptyState';
import {useStickerCategories} from '@app/features/channel/components/sticker_picker/hooks/useStickerCategories';
import {useVirtualRows} from '@app/features/channel/components/sticker_picker/hooks/useVirtualRows';
import {StickerPickerCategoryList} from '@app/features/channel/components/sticker_picker/StickerPickerCategoryList';
import {STICKERS_PER_ROW} from '@app/features/channel/components/sticker_picker/StickerPickerConstants';
import {
buildStickerRowOffsets,
getStickerRowHeight,
getStickerRowWindow,
STICKER_SECTION_GAP,
STICKERS_PER_ROW,
type StickerRowKind,
} from '@app/features/channel/components/sticker_picker/StickerPickerConstants';
import {StickerPickerInspector} from '@app/features/channel/components/sticker_picker/StickerPickerInspector';
import {StickerPickerSearchBar} from '@app/features/channel/components/sticker_picker/StickerPickerSearchBar';
import {VirtualRowWrapper} from '@app/features/channel/components/sticker_picker/VirtualRow';
@@ -29,13 +38,14 @@ 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 {getAppRemScale} from '@app/features/ui/utils/AppZoomUtils';
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
import {msg} from '@lingui/core/macro';
import {Plural, Trans, useLingui} from '@lingui/react/macro';
import {SmileySadIcon, StickerIcon} from '@phosphor-icons/react';
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';
const NO_STICKERS_AVAILABLE_DESCRIPTOR = msg({
message: 'No stickers yet',
@@ -64,10 +74,12 @@ export const StickersPicker = observer(
const [selectedColumn, setSelectedColumn] = useState(-1);
const [shouldScrollOnSelection, setShouldScrollOnSelection] = useState(false);
const scrollerRef = useRef<ScrollerHandle>(null);
const {scrollTop, handleScroll, handleResize} = useScrollerViewport(scrollerRef);
const searchInputRef = useRef<HTMLInputElement>(null);
const stickerRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
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, gridWidth: 0});
const [stickerDataVersion, setStickerDataVersion] = useState(0);
const permissionVersion = useSyncExternalStore(Permission.subscribe.bind(Permission), () => Permission.version);
const {shouldAnimate: shouldAnimateStickerPreview} = useStickerAnimation();
@@ -87,10 +99,10 @@ export const StickersPicker = observer(
src={AvatarUtils.getStickerURL({
id: sticker.id,
animated: shouldAnimateStickerPreview,
isAnimatable: sticker.animated,
size: 320,
})}
alt={sticker.name}
loading="lazy"
data-flx="channel.stickers-picker.render-sticker-preview-item.img"
/>
</div>
@@ -169,17 +181,61 @@ export const StickersPicker = observer(
}
return result;
}, [pickerRows]);
const {stickerRowIndexes, stickerRowStarts, categoryRowIndexes} = useMemo(() => {
const indexes = new Array<number>(pickerRows.length);
const starts: Array<number> = [];
const categories = new Map<string, number>();
let stickerRowIndex = 0;
for (let rowIndex = 0; rowIndex < pickerRows.length; rowIndex += 1) {
const row = pickerRows[rowIndex];
indexes[rowIndex] = stickerRowIndex;
if (row.type === 'sticker-row') {
starts.push(rowIndex);
stickerRowIndex += 1;
} else {
categories.set(row.category, rowIndex);
}
}
return {stickerRowIndexes: indexes, stickerRowStarts: starts, categoryRowIndexes: categories};
}, [pickerRows]);
const zoomLevel = Accessibility.zoomLevel;
const remScale = getAppRemScale();
const stickerRowHeight = useMemo(
() => getStickerRowHeight(listMetrics.gridWidth, STICKERS_PER_ROW, remScale),
[listMetrics.gridWidth, remScale, zoomLevel],
);
const rowOffsets = useMemo(() => {
const rowKinds = pickerRows.map((row): StickerRowKind => row.type);
return buildStickerRowOffsets(rowKinds, {remScale, stickerRowHeight, sectionGap: STICKER_SECTION_GAP});
}, [pickerRows, remScale, stickerRowHeight, 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;
const gridWidth = rowListNode.clientWidth;
setListMetrics((current) =>
current.origin === origin && current.viewportHeight === viewportHeight && current.gridWidth === gridWidth
? current
: {origin, viewportHeight, gridWidth},
);
});
const rowWindow = useMemo(
() => getStickerRowWindow(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 = (sticker: GuildSticker | null, row?: number, column?: number) => {
setHoveredSticker(sticker);
if (sticker && 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 handleStickerSelect = useCallback(
(sticker: GuildSticker, shiftKey?: boolean) => {
@@ -214,11 +270,31 @@ export const StickersPicker = observer(
},
[pickerRows],
);
const handleHover = useCallback(
(sticker: GuildSticker | null, row?: number, column?: number) => {
setHoveredSticker(sticker);
if (sticker && row !== undefined && column !== undefined) {
handleSelectionChange(row, column, false);
}
},
[handleSelectionChange],
);
useEffect(() => {
if (renderedStickers.length > 0 && selectedRow === 0 && selectedColumn === 0 && !hoveredSticker) {
handleSelectionChange(0, 0, false);
}
}, [renderedStickers, selectedRow, selectedColumn, hoveredSticker, handleSelectionChange]);
useEffect(() => {
if (!shouldScrollOnSelection || selectedRow < 0) {
return;
}
const rowIndex = stickerRowStarts[selectedRow];
if (rowIndex == null) {
return;
}
const start = listMetrics.origin + rowOffsets[rowIndex]!;
scrollerRef.current?.revealRange({start, end: start + stickerRowHeight});
}, [shouldScrollOnSelection, selectedRow, stickerRowStarts, rowOffsets, listMetrics.origin, stickerRowHeight]);
const handleSelectSticker = useCallback(
(row: number | null, column: number | null, event?: React.KeyboardEvent) => {
if (row === null || column === null) {
@@ -283,6 +359,8 @@ export const StickersPicker = observer(
className={`${styles.list} ${styles.listWrapper}`}
fade={false}
key="stickers-picker-scroller"
onScroll={handleScroll}
onResize={handleResize}
data-flx="channel.stickers-picker.list"
>
{showPremiumUpsell && (
@@ -294,41 +372,41 @@ export const StickersPicker = observer(
data-flx="channel.stickers-picker.premium-upsell-banner"
/>
)}
{pickerRows.map((row, index) => {
const stickerRowIndex = pickerRows.slice(0, index).filter((r) => r.type === 'sticker-row').length;
const needsSpacingAfter = row.type === 'sticker-row' && pickerRows[index + 1]?.type === 'header';
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 === 'sticker-row' && needsSpacingAfter ? {marginBottom: '12px'} : undefined}
data-flx="channel.stickers-picker.div--3"
>
<VirtualRowWrapper
row={row}
handleHover={handleHover}
handleSelect={handleStickerSelect}
gridColumns={STICKERS_PER_ROW}
hoveredSticker={hoveredSticker}
selectedRow={selectedRow}
selectedColumn={selectedColumn}
stickerRowIndex={stickerRowIndex}
shouldScrollOnSelection={shouldScrollOnSelection}
stickerRefs={stickerRefs}
channel={channel}
data-flx="channel.stickers-picker.virtual-row-wrapper"
/>
</div>
);
})}
<div
ref={rowListRef}
style={{position: 'relative', flexShrink: 0, height: `${contentHeight}px`}}
data-flx="channel.stickers-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}`}
style={{
position: 'absolute',
left: 0,
right: 0,
transform: `translateY(${rowOffsets[rowIndex]!}px)`,
}}
data-flx="channel.stickers-picker.div--3"
>
<VirtualRowWrapper
row={row}
handleHover={handleHover}
handleSelect={handleStickerSelect}
gridColumns={STICKERS_PER_ROW}
selectedRow={selectedRow}
selectedColumn={selectedColumn}
stickerRowIndex={stickerRowIndexes[rowIndex]!}
shouldScrollOnSelection={shouldScrollOnSelection}
stickerRefs={stickerRefs}
channel={channel}
data-flx="channel.stickers-picker.virtual-row-wrapper"
/>
</div>
);
})}
</div>
</Scroller>
{renderedStickers.length === 0 && (
<div className={styles.emptyState} data-flx="channel.stickers-picker.empty-state">
@@ -2,3 +2,91 @@
export const STICKERS_PER_ROW = 4;
export const STICKERS_PER_ROW_MOBILE = 5;
export const STICKER_CATEGORY_HEADER_HEIGHT = 32;
export const STICKER_SECTION_GAP = 12;
const STICKER_CATEGORY_HEADER_GAP = 8;
const STICKER_GRID_GAP = 8;
const STICKER_GRID_BLOCK_END_PADDING = 8;
const STICKER_ROW_WINDOW_LEAD_ROWS = 2;
export type StickerRowKind = 'header' | 'sticker-row';
export interface StickerRowMetrics {
remScale: number;
stickerRowHeight: number;
sectionGap: number;
}
export interface StickerRowWindow {
firstRow: number;
lastRow: number;
}
export function getStickerRowHeight(gridWidth: number, gridColumns: number, remScale: number): number {
if (!(gridWidth > 0) || gridColumns <= 0) {
return 0;
}
const cellWidth = Math.max(0, (gridWidth - STICKER_GRID_GAP * remScale * (gridColumns - 1)) / gridColumns);
return cellWidth + STICKER_GRID_BLOCK_END_PADDING * remScale;
}
export function buildStickerRowOffsets(
rowKinds: ReadonlyArray<StickerRowKind>,
{remScale, stickerRowHeight, sectionGap}: StickerRowMetrics,
): 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 += (STICKER_CATEGORY_HEADER_HEIGHT + STICKER_CATEGORY_HEADER_GAP) * remScale;
} else {
runningOffset += stickerRowHeight;
if (rowKinds[rowIndex + 1] === 'header') {
runningOffset += sectionGap;
}
}
rowOffsets[rowIndex + 1] = runningOffset;
}
return rowOffsets;
}
export function findStickerRowForOffset(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 getStickerRowWindow(
rowOffsets: ReadonlyArray<number>,
scrollTop: number,
viewportHeight: number,
): StickerRowWindow {
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;
const leadingVisibleRow = findStickerRowForOffset(rowOffsets, safeScrollTop);
const trailingVisibleRow = findStickerRowForOffset(rowOffsets, safeScrollTop + viewportHeight);
return {
firstRow: Math.max(0, leadingVisibleRow - STICKER_ROW_WINDOW_LEAD_ROWS),
lastRow: Math.min(totalRows - 1, trailingVisibleRow + STICKER_ROW_WINDOW_LEAD_ROWS),
};
}
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {VirtualRow} from '@app/features/channel/components/sticker_picker/hooks/useVirtualRows';
import {STICKER_CATEGORY_HEADER_HEIGHT} from '@app/features/channel/components/sticker_picker/StickerPickerConstants';
import styles from '@app/features/channel/components/sticker_picker/VirtualRow.module.css';
import type {Channel} from '@app/features/channel/models/Channel';
import * as StickerPickerCommands from '@app/features/emoji/commands/StickerPickerCommands';
@@ -19,18 +20,13 @@ import {useLingui} from '@lingui/react/macro';
import {CaretDownIcon, ClockIcon, StarIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React, {useEffect, useRef, useState} from 'react';
const STICKER_ROW_HEIGHT = 92;
const CATEGORY_HEADER_HEIGHT = 32;
const OVERSCAN_ROWS = 5;
import React from 'react';
interface VirtualRowRendererProps {
row: VirtualRow;
handleHover: (sticker: GuildSticker | null, row?: number, column?: number) => void;
handleSelect: (sticker: GuildSticker, shiftKey?: boolean) => void;
gridColumns?: number;
hoveredSticker: GuildSticker | null;
selectedRow: number;
selectedColumn: number;
stickerRowIndex: number;
@@ -65,7 +61,9 @@ const StickerButton: React.FC<StickerButtonProps> = React.memo(
stickerRowIndex,
columnIndex,
}) => {
const {shouldAnimate, interactionHandlers} = useStickerAnimation({respectUserSettings: false});
const {shouldAnimate, interactionHandlers} = useStickerAnimation({
isAnimated: sticker.animated,
});
return (
<FocusRing offset={-2} data-flx="channel.sticker-picker.virtual-row.sticker-button.focus-ring">
<button
@@ -107,11 +105,11 @@ const StickerButton: React.FC<StickerButtonProps> = React.memo(
src={AvatarUtils.getStickerURL({
id: sticker.id,
animated: shouldAnimate,
isAnimatable: sticker.animated,
size: 320,
})}
alt={sticker.name}
className={styles.stickerImage}
loading="lazy"
data-flx="channel.sticker-picker.virtual-row.sticker-button.sticker-image"
/>
</button>
@@ -175,7 +173,7 @@ const VirtualRowRendererBase: React.FC<VirtualRowRendererProps> = React.memo(
type="button"
onClick={handleToggleCategory}
style={{
height: remFromPx(CATEGORY_HEADER_HEIGHT),
height: remFromPx(STICKER_CATEGORY_HEADER_HEIGHT),
display: 'flex',
alignItems: 'center',
paddingLeft: '0.75rem',
@@ -288,7 +286,6 @@ interface VirtualRowWrapperProps {
handleHover: (sticker: GuildSticker | null, row?: number, column?: number) => void;
handleSelect: (sticker: GuildSticker, shiftKey?: boolean) => void;
gridColumns?: number;
hoveredSticker: GuildSticker | null;
selectedRow: number;
selectedColumn: number;
stickerRowIndex: number;
@@ -303,7 +300,6 @@ export const VirtualRowWrapper: React.FC<VirtualRowWrapperProps> = observer(
handleHover,
handleSelect,
gridColumns,
hoveredSticker,
selectedRow,
selectedColumn,
stickerRowIndex,
@@ -311,63 +307,20 @@ export const VirtualRowWrapper: React.FC<VirtualRowWrapperProps> = observer(
stickerRefs,
channel,
}) => {
const [isVisible, setIsVisible] = useState(false);
const placeholderRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const placeholder = placeholderRef.current;
if (!placeholder) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setIsVisible(true);
} else {
const rect = entry.boundingClientRect;
const viewportHeight = window.innerHeight;
const overscanDistance = OVERSCAN_ROWS * STICKER_ROW_HEIGHT;
if (rect.bottom < -overscanDistance || rect.top > viewportHeight + overscanDistance) {
setIsVisible(false);
}
}
});
},
{
rootMargin: `${OVERSCAN_ROWS * STICKER_ROW_HEIGHT}px 0px`,
threshold: 0,
},
);
observer.observe(placeholder);
return () => {
observer.disconnect();
};
}, []);
const height = row.type === 'header' ? CATEGORY_HEADER_HEIGHT : STICKER_ROW_HEIGHT;
if (!isVisible) {
return (
<div
ref={placeholderRef}
style={{height: remFromPx(height)}}
data-flx="channel.sticker-picker.virtual-row.virtual-row-wrapper.div"
/>
);
}
return (
<div ref={placeholderRef} data-flx="channel.sticker-picker.virtual-row.virtual-row-wrapper.div--2">
<VirtualRowRenderer
row={row}
handleHover={handleHover}
handleSelect={handleSelect}
gridColumns={gridColumns}
hoveredSticker={hoveredSticker}
selectedRow={selectedRow}
selectedColumn={selectedColumn}
stickerRowIndex={stickerRowIndex}
shouldScrollOnSelection={shouldScrollOnSelection}
stickerRefs={stickerRefs}
channel={channel}
data-flx="channel.sticker-picker.virtual-row.virtual-row-wrapper.virtual-row-renderer"
/>
</div>
<VirtualRowRenderer
row={row}
handleHover={handleHover}
handleSelect={handleSelect}
gridColumns={gridColumns}
selectedRow={selectedRow}
selectedColumn={selectedColumn}
stickerRowIndex={stickerRowIndex}
shouldScrollOnSelection={shouldScrollOnSelection}
stickerRefs={stickerRefs}
channel={channel}
data-flx="channel.sticker-picker.virtual-row.virtual-row-wrapper.virtual-row-renderer"
/>
);
},
);