mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-03 05:10:25 +03:00
perf(app): window sticker picker rows by offset instead of observers (#1902)
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user