TelegramPWA/src/components/modals/gift/preview/GiftPreviewModal.tsx
2026-04-14 14:47:32 +02:00

502 lines
17 KiB
TypeScript

import { memo, useEffect, useMemo, useRef, useState } from '../../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../../global';
import type {
ApiStarGiftAttributeBackdrop,
ApiStarGiftAttributeModel,
ApiStarGiftAttributePattern,
} from '../../../../api/types';
import type { TabState } from '../../../../global/types';
import type { AnimationLevel } from '../../../../types';
import { selectAnimationLevel } from '../../../../global/selectors/sharedState';
import buildClassName from '../../../../util/buildClassName';
import { NEXT_ARROW_REPLACEMENT } from '../../../../util/localization/format';
import { resolveTransitionName } from '../../../../util/resolveTransitionName';
import { getGiftAttributes, getRandomGiftPreviewAttributes } from '../../../common/helpers/gifts';
import useCurrentOrPrev from '../../../../hooks/useCurrentOrPrev';
import useFlag from '../../../../hooks/useFlag';
import { useIntersectionObserver } from '../../../../hooks/useIntersectionObserver';
import useLang from '../../../../hooks/useLang';
import useLastCallback from '../../../../hooks/useLastCallback';
import GiftRarityBadge from '../../../common/GiftRarityBadge';
import Button from '../../../ui/Button';
import InfiniteScroll from '../../../ui/InfiniteScroll';
import Link from '../../../ui/Link';
import Modal from '../../../ui/Modal';
import SquareTabList, { type TabWithProperties } from '../../../ui/SquareTabList';
import Transition from '../../../ui/Transition';
import GiftAttributeItem from '../GiftAttributeItem';
import UniqueGiftHeader from '../UniqueGiftHeader';
import styles from './GiftPreviewModal.module.scss';
export type OwnProps = {
modal: TabState['giftPreviewModal'];
};
type StateProps = {
animationLevel: AnimationLevel;
};
const MODEL_STICKER_SIZE = 80;
const PATTERN_STICKER_SIZE = 60;
const INTERSECTION_THROTTLE = 200;
enum AttributeTab {
Model,
Backdrop,
Pattern,
}
const GiftPreviewModal = ({ modal, animationLevel }: OwnProps & StateProps) => {
const {
closeGiftPreviewModal,
openGiftInMarket,
updateResaleGiftsFilter,
} = getActions();
const [isCraftableModelsMode, showCraftableModels, showRegularModels] = useFlag();
const [isPlayingRandomPreviews, playRandomPreviews, stopRandomPreviews] = useFlag(true);
const modelsContainerRef = useRef<HTMLDivElement>();
const patternsContainerRef = useRef<HTMLDivElement>();
const backdropsContainerRef = useRef<HTMLDivElement>();
const isOpen = Boolean(modal);
const renderingModal = useCurrentOrPrev(modal);
const originGift = renderingModal?.originGift;
const initialAttributes = useMemo(() => originGift && getGiftAttributes(originGift), [originGift]);
const lang = useLang();
const [selectedTabIndex, setSelectedTabIndex] = useState(AttributeTab.Model);
const { regularModels, craftableModels, patterns, backdrops } = useMemo(() => {
if (!renderingModal?.attributes) {
return { regularModels: [], craftableModels: [], patterns: [], backdrops: [] };
}
const result: {
regularModels: ApiStarGiftAttributeModel[];
craftableModels: ApiStarGiftAttributeModel[];
patterns: ApiStarGiftAttributePattern[];
backdrops: ApiStarGiftAttributeBackdrop[];
} = { regularModels: [], craftableModels: [], patterns: [], backdrops: [] };
for (const attr of renderingModal.attributes) {
if (attr.type === 'model') {
if (attr.rarity.type === 'regular') {
result.regularModels.push(attr);
} else {
result.craftableModels.push(attr);
}
}
if (attr.type === 'pattern') result.patterns.push(attr);
if (attr.type === 'backdrop') result.backdrops.push(attr);
}
return result;
}, [renderingModal?.attributes]);
const firstModel = regularModels[0];
const firstPattern = patterns[0];
const firstBackdrop = backdrops[0];
const [selectedModel, setSelectedModel] = useState<ApiStarGiftAttributeModel | undefined>(firstModel);
const [selectedPattern, setSelectedPattern] = useState<ApiStarGiftAttributePattern | undefined>(firstPattern);
const [selectedBackdrop, setSelectedBackdrop] = useState<ApiStarGiftAttributeBackdrop | undefined>(firstBackdrop);
useEffect(() => {
if (isOpen) return;
setSelectedTabIndex(AttributeTab.Model);
showRegularModels();
}, [isOpen]);
useEffect(() => {
const newModel = initialAttributes?.model || firstModel;
setSelectedModel(newModel);
setSelectedPattern(initialAttributes?.pattern || firstPattern);
setSelectedBackdrop(initialAttributes?.backdrop || firstBackdrop);
if (newModel && newModel.rarity.type !== 'regular') showCraftableModels();
}, [initialAttributes, firstModel, firstPattern, firstBackdrop]);
useEffect(() => {
if (renderingModal?.shouldShowCraftableOnStart) {
showCraftableModels();
}
}, [renderingModal?.shouldShowCraftableOnStart]);
const handleStickerAnimationEnded = useLastCallback((modelName: string) => {
if (modelName !== selectedModel?.name || !isPlayingRandomPreviews) return;
if (!originGift || !selectedModel || !selectedPattern || !selectedBackdrop) return;
const attributesToUse = renderingModal?.shouldShowCraftableOnStart && isCraftableModelsMode
? renderingModal.attributes.filter((attr) => attr.type !== 'model' || attr.rarity.type !== 'regular')
: renderingModal?.attributes;
const newAttributes = getRandomGiftPreviewAttributes(attributesToUse, {
model: selectedModel,
pattern: selectedPattern,
backdrop: selectedBackdrop,
});
setSelectedModel(newAttributes.model);
setSelectedPattern(newAttributes.pattern);
setSelectedBackdrop(newAttributes.backdrop);
});
const {
observe: observeModelsIntersection,
} = useIntersectionObserver({
rootRef: modelsContainerRef,
throttleMs: INTERSECTION_THROTTLE,
isDisabled: selectedTabIndex !== AttributeTab.Model,
});
const {
observe: observePatternsIntersection,
} = useIntersectionObserver({
rootRef: patternsContainerRef,
throttleMs: INTERSECTION_THROTTLE,
isDisabled: selectedTabIndex !== AttributeTab.Pattern,
});
const {
observe: observeBackdropsIntersection,
} = useIntersectionObserver({
rootRef: backdropsContainerRef,
throttleMs: INTERSECTION_THROTTLE,
isDisabled: selectedTabIndex !== AttributeTab.Backdrop,
});
const tabs = useMemo<TabWithProperties[]>(() => [
{ title: lang('GiftAttributeModel') },
{ title: lang('GiftAttributeBackdrop') },
{ title: lang('GiftAttributeSymbol') },
], [lang]);
const handleClose = useLastCallback(() => closeGiftPreviewModal());
const handleSelectModel = useLastCallback((model: ApiStarGiftAttributeModel) => {
setSelectedModel(model);
stopRandomPreviews();
});
const handleSelectPattern = useLastCallback((pattern: ApiStarGiftAttributePattern) => {
setSelectedPattern(pattern);
stopRandomPreviews();
});
const handleSelectBackdrop = useLastCallback((backdrop: ApiStarGiftAttributeBackdrop) => {
setSelectedBackdrop(backdrop);
stopRandomPreviews();
});
const handleSymbolClick = useLastCallback(() => {
if (!originGift || !selectedPattern) return;
openGiftInMarket({ gift: originGift });
updateResaleGiftsFilter({
filter: {
sortType: 'byDate',
modelAttributes: [],
backdropAttributes: [],
patternAttributes: [{
type: 'pattern',
documentId: selectedPattern.sticker.id,
}],
},
});
handleClose();
});
const handleBackdropClick = useLastCallback(() => {
if (!originGift || !selectedBackdrop) return;
openGiftInMarket({ gift: originGift });
updateResaleGiftsFilter({
filter: {
sortType: 'byDate',
modelAttributes: [],
backdropAttributes: [{
type: 'backdrop',
backdropId: selectedBackdrop.backdropId,
}],
patternAttributes: [],
},
});
handleClose();
});
const handleModelClick = useLastCallback(() => {
if (!originGift) return;
openGiftInMarket({ gift: originGift });
updateResaleGiftsFilter({
filter: {
sortType: 'byDate',
modelAttributes: [{
type: 'model',
documentId: selectedModel!.sticker.id,
}],
backdropAttributes: [],
patternAttributes: [],
},
});
handleClose();
});
const modalHeader = useMemo(() => {
return (
<>
<Button
className="modal-absolute-close-button"
round
color="translucent-white"
size="tiny"
iconName="close"
ariaLabel={lang('Close')}
onClick={handleClose}
/>
<Button
className={styles.playButton}
round
color="translucent-white"
size="tiny"
iconName={isPlayingRandomPreviews ? 'pause' : 'play'}
ariaLabel={isPlayingRandomPreviews ? lang('AriaGiftPreviewStop') : lang('AriaGiftPreviewPlay')}
onClick={isPlayingRandomPreviews ? stopRandomPreviews : playRandomPreviews}
/>
</>
);
}, [lang, isPlayingRandomPreviews]);
function renderHeader() {
if (!selectedModel || !selectedPattern || !selectedBackdrop) return undefined;
return (
<UniqueGiftHeader
className={styles.header}
modelAttribute={selectedModel}
backdropAttribute={selectedBackdrop}
patternAttribute={selectedPattern}
title={originGift?.title}
subtitle={lang('GiftPreviewSelectedTraits')}
noLoop
onStickerAnimationEnded={handleStickerAnimationEnded}
>
<div
className={styles.traitButtons}
style={`--_badge-bg: ${selectedBackdrop.centerColor}`}
>
<Button
className={styles.traitButton}
color="transparentBlured"
onClick={handleModelClick}
>
<span className={styles.traitName}>{selectedModel.name}</span>
<span className={styles.traitType}>{lang('GiftAttributeModel')}</span>
<GiftRarityBadge
shouldInvertRare
rarity={selectedModel.rarity}
className={styles.traitRarity}
/>
</Button>
<Button
className={styles.traitButton}
color="transparentBlured"
onClick={handleBackdropClick}
>
<span className={styles.traitName}>{selectedBackdrop.name}</span>
<span className={styles.traitType}>{lang('GiftAttributeBackdrop')}</span>
<GiftRarityBadge
shouldInvertRare
rarity={selectedBackdrop.rarity}
className={styles.traitRarity}
/>
</Button>
<Button
className={styles.traitButton}
color="transparentBlured"
onClick={handleSymbolClick}
>
<span className={styles.traitName}>{selectedPattern.name}</span>
<span className={styles.traitType}>{lang('GiftAttributeSymbol')}</span>
<GiftRarityBadge
shouldInvertRare
rarity={selectedPattern.rarity}
className={styles.traitRarity}
/>
</Button>
</div>
</UniqueGiftHeader>
);
}
function renderTabContent() {
switch (selectedTabIndex) {
case AttributeTab.Model:
return (
<InfiniteScroll
ref={modelsContainerRef}
className={buildClassName(styles.grid, 'custom-scroll')}
beforeChildren={(
<div className={styles.gridHeader}>
<span className={styles.count}>
{lang(
isCraftableModelsMode ? 'GiftPreviewCountCraftableModels' : 'GiftPreviewCountModels',
{ count: isCraftableModelsMode ? craftableModels.length : regularModels.length }, {
pluralValue: isCraftableModelsMode ? craftableModels.length : regularModels.length,
withNodes: true,
withMarkdown: true,
})}
</span>
{Boolean(craftableModels?.length) && (
<Link
isPrimary
onClick={() => isCraftableModelsMode ? showRegularModels() : showCraftableModels()}
>
{lang(
isCraftableModelsMode ? 'GiftPreviewToggleRegularModels' : 'GiftPreviewToggleCraftableModels',
undefined,
{ withNodes: true, specialReplacement: NEXT_ARROW_REPLACEMENT },
)}
</Link>
)}
</div>
)}
items={isCraftableModelsMode ? craftableModels : regularModels}
noFastList
>
{(isCraftableModelsMode ? craftableModels : regularModels).map((model) => (
<GiftAttributeItem
className={styles.item}
key={model.name}
sticker={model.sticker}
stickerSize={MODEL_STICKER_SIZE}
rarity={model.rarity}
isSelected={selectedModel?.name === model.name}
clickArg={model}
onClick={handleSelectModel}
observeIntersection={observeModelsIntersection}
/>
))}
</InfiniteScroll>
);
case AttributeTab.Pattern:
return (
<InfiniteScroll
ref={patternsContainerRef}
className={buildClassName(styles.grid, 'custom-scroll')}
beforeChildren={(
<div className={styles.gridHeader}>
<span className={styles.count}>
{lang('GiftPreviewCountPatterns', { count: patterns.length }, {
pluralValue: patterns.length,
withNodes: true,
withMarkdown: true,
})}
</span>
</div>
)}
items={patterns}
noFastList
>
{patterns.map((pattern) => (
<GiftAttributeItem
key={pattern.name}
className={styles.item}
backdrop={selectedBackdrop}
sticker={pattern.sticker}
patternSticker={pattern.sticker}
stickerNoPlay
stickerSize={PATTERN_STICKER_SIZE}
rarity={pattern.rarity}
isSelected={selectedPattern?.name === pattern.name}
clickArg={pattern}
onClick={handleSelectPattern}
observeIntersection={observePatternsIntersection}
/>
))}
</InfiniteScroll>
);
case AttributeTab.Backdrop:
return (
<InfiniteScroll
ref={backdropsContainerRef}
className={buildClassName(styles.grid, 'custom-scroll')}
beforeChildren={(
<div className={styles.gridHeader}>
<span className={styles.count}>
{lang('GiftPreviewCountBackdrops', { count: backdrops.length }, {
pluralValue: backdrops.length,
withNodes: true,
withMarkdown: true,
})}
</span>
</div>
)}
items={backdrops}
noFastList
>
{backdrops.map((backdrop) => (
<GiftAttributeItem
key={backdrop.backdropId}
className={styles.item}
backdrop={backdrop}
sticker={selectedModel?.sticker}
stickerNoPlay
patternSticker={selectedPattern?.sticker}
rarity={backdrop.rarity}
isSelected={selectedBackdrop?.backdropId === backdrop.backdropId}
clickArg={backdrop}
onClick={handleSelectBackdrop}
observeIntersection={observeBackdropsIntersection}
/>
))}
</InfiniteScroll>
);
default:
return undefined;
}
}
return (
<Modal
isOpen={isOpen}
className={buildClassName(styles.root, 'tall')}
hasAbsoluteCloseButton
absoluteCloseButtonColor="translucent-white"
dialogClassName={styles.dialog}
contentClassName={styles.content}
header={modalHeader}
isSlim
onClose={handleClose}
>
{renderHeader()}
<SquareTabList
className={styles.tabs}
activeTab={selectedTabIndex}
tabs={tabs}
onSwitchTab={setSelectedTabIndex}
/>
<Transition
className={styles.transition}
name={resolveTransitionName('slideOptimized', animationLevel, undefined, lang.isRtl)}
activeKey={selectedTabIndex}
renderCount={tabs.length}
>
{renderTabContent()}
</Transition>
</Modal>
);
};
export default memo(withGlobal<OwnProps>(
(global): Complete<StateProps> => {
return {
animationLevel: selectAnimationLevel(global),
};
},
)(GiftPreviewModal));