Gifts: Add attribute preview modal (#6710)

This commit is contained in:
zubiden 2026-02-27 19:51:24 +01:00 committed by Alexander Zinchuk
parent 52da4643c1
commit 64c5b740d3
37 changed files with 1347 additions and 447 deletions

View File

@ -190,7 +190,7 @@ const NewComp = (props: OwnProps & StateProps) => { … }
```
### 5. Memoization
* Wrap most components with `memo()` to avoid unnecessary updates.
* Wrap most components with `memo()` to avoid unnecessary updates. Consider skipping memo for simple wrapper components whose children change on almost every render.
* Don't pass freshly created objects or arrays as props to memoized components.
* **Exceptions** (no memo): `ListItem`, `Button`, `MenuItem`, etc.

View File

@ -19,6 +19,7 @@ import {
buildApiResaleGifts,
buildApiSavedStarGift,
buildApiStarGift,
buildApiStarGiftAttribute,
buildApiStarGiftAuctionAcquiredGift,
buildApiStarGiftAuctionState,
buildApiStarGiftCollection,
@ -629,3 +630,21 @@ export function resolveStarGiftOffer({
shouldReturnTrue: true,
});
}
export async function fetchStarGiftUpgradeAttributes({
giftId,
}: {
giftId: string;
}) {
const result = await invokeRequest(new GramJs.payments.GetStarGiftUpgradeAttributes({
giftId: BigInt(giftId),
}));
if (!result) {
return undefined;
}
return {
attributes: result.attributes.map(buildApiStarGiftAttribute).filter(Boolean),
};
}

View File

@ -2665,3 +2665,17 @@
"ChatListAuctionView" = "View";
"BotAuthSuccessTitle" = "Login Successful";
"BotAuthSuccessText" = "You've successfully logged into **{url}**";
"GiftPreviewSelectedTraits" = "Selected traits";
"GiftPreviewCountModels_one" = "This collection features **{count}** unique model";
"GiftPreviewCountModels_other" = "This collection features **{count}** unique models";
"GiftPreviewCountCraftableModels_one" = "This collection features **{count}** craftable model";
"GiftPreviewCountCraftableModels_other" = "This collection features **{count}** craftable models";
"GiftPreviewCountPatterns_one" = "This collection features **{count}** unique pattern";
"GiftPreviewCountPatterns_other" = "This collection features **{count}** unique patterns";
"GiftPreviewCountBackdrops_one" = "This collection features **{count}** unique backdrop";
"GiftPreviewCountBackdrops_other" = "This collection features **{count}** unique backdrops";
"GiftUpgradeViewAll" = "View all variants >";
"GiftPreviewToggleCraftableModels" = "View Craftable Models >";
"GiftPreviewToggleRegularModels" = "View Primary Models >";
"AriaGiftPreviewPlay" = "Play random previews";
"AriaGiftPreviewStop" = "Pause random previews";

View File

@ -13,6 +13,7 @@ export { default as GiftInfoValueModal } from '../components/modals/gift/value/G
export { default as GiftLockedModal } from '../components/modals/gift/locked/GiftLockedModal';
export { default as GiftResalePriceComposerModal } from '../components/modals/gift/resale/GiftResalePriceComposerModal';
export { default as GiftUpgradeModal } from '../components/modals/gift/upgrade/GiftUpgradeModal';
export { default as GiftPreviewModal } from '../components/modals/gift/preview/GiftPreviewModal';
export { default as GiftAuctionModal } from '../components/modals/gift/auction/GiftAuctionModal';
export { default as GiftAuctionBidModal } from '../components/modals/gift/auction/GiftAuctionBidModal';
export { default as GiftAuctionInfoModal } from '../components/modals/gift/auction/GiftAuctionInfoModal';

View File

@ -1,5 +1,4 @@
import type { FC } from '../../../lib/teact/teact';
import type React from '../../../lib/teact/teact';
import type { TeactNode } from '../../../lib/teact/teact';
import { memo } from '../../../lib/teact/teact';
import type { IconName } from '../../../types/icons';
@ -12,18 +11,17 @@ import Button from '../../ui/Button';
import styles from './PhoneCallButton.module.scss';
type OwnProps = {
onClick: VoidFunction;
label: string;
icon?: IconName;
iconClassName?: string;
customIcon?: React.ReactNode;
customIcon?: TeactNode;
className?: string;
isDisabled?: boolean;
isActive?: boolean;
onClick: VoidFunction;
};
const PhoneCallButton: FC<OwnProps> = ({
onClick,
const PhoneCallButton = ({
label,
customIcon,
icon,
@ -31,7 +29,8 @@ const PhoneCallButton: FC<OwnProps> = ({
className,
isDisabled,
isActive,
}) => {
onClick,
}: OwnProps) => {
return (
<div className={styles.root}>
<Button

View File

@ -1,21 +1,32 @@
.root {
&.uncommon {
color: var(--color-gift-uncommon);
background-color: var(--color-gift-uncommon-bg);
}
.crafted {
--_color: revert;
--_background-color: revert;
&.rare {
color: var(--color-gift-rare);
background-color: var(--color-gift-rare-bg);
}
&.epic {
color: var(--color-gift-epic);
background-color: var(--color-gift-epic-bg);
}
&.legendary {
color: var(--color-gift-legendary);
background-color: var(--color-gift-legendary-bg);
}
color: var(--_color);
background-color: var(--_background-color);
}
.inverted {
color: var(--color-white);
background-color: var(--_color);
}
.uncommon {
--_color: var(--color-gift-uncommon);
--_background-color: var(--color-gift-uncommon-bg);
}
.rare {
--_color: var(--color-gift-rare);
--_background-color: var(--color-gift-rare-bg);
}
.epic {
--_color: var(--color-gift-epic);
--_background-color: var(--color-gift-epic-bg);
}
.legendary {
--_color: var(--color-gift-legendary);
--_background-color: var(--color-gift-legendary-bg);
}

View File

@ -1,5 +1,3 @@
import { memo } from '../../lib/teact/teact';
import type { ApiStarGiftAttributeRarity } from '../../api/types';
import buildClassName from '../../util/buildClassName';
@ -13,16 +11,28 @@ import styles from './GiftRarityBadge.module.scss';
type OwnProps = {
rarity: ApiStarGiftAttributeRarity;
shouldInvertRare?: boolean;
className?: string;
onClick?: NoneToVoidFunction;
};
const GiftRarityBadge = ({ rarity }: OwnProps) => {
const GiftRarityBadge = ({ rarity, shouldInvertRare, className, onClick }: OwnProps) => {
const lang = useLang();
return (
<BadgeButton className={buildClassName(styles.root, rarity.type !== 'regular' && styles[rarity.type])}>
<BadgeButton
className={buildClassName(
styles.root,
rarity.type !== 'regular' && styles[rarity.type],
rarity.type !== 'regular' && styles.crafted,
shouldInvertRare && rarity.type !== 'regular' && styles.inverted,
className,
)}
onClick={onClick}
>
{getGiftRarityTitle(lang, rarity)}
</BadgeButton>
);
};
export default memo(GiftRarityBadge);
export default GiftRarityBadge;

View File

@ -32,6 +32,7 @@ import GiftInfoModal from './gift/info/GiftInfoModal.async';
import GiftLockedModal from './gift/locked/GiftLockedModal.async';
import GiftDescriptionRemoveModal from './gift/message/GiftDescriptionRemoveModal.async';
import GiftOfferAcceptModal from './gift/offer/GiftOfferAcceptModal.async';
import GiftPreviewModal from './gift/preview/GiftPreviewModal.async';
import GiftRecipientPicker from './gift/recipient/GiftRecipientPicker.async';
import GiftResalePriceComposerModal from './gift/resale/GiftResalePriceComposerModal.async';
import StarGiftPriceDecreaseInfoModal from './gift/StarGiftPriceDecreaseInfoModal.async';
@ -105,6 +106,7 @@ type ModalKey = keyof Pick<TabState,
'emojiStatusAccessModal' |
'locationAccessModal' |
'aboutAdsModal' |
'giftPreviewModal' |
'giftUpgradeModal' |
'giftAuctionModal' |
'giftAuctionBidModal' |
@ -184,6 +186,7 @@ const MODALS: ModalRegistry = {
emojiStatusAccessModal: EmojiStatusAccessModal,
locationAccessModal: LocationAccessModal,
aboutAdsModal: AboutAdsModal,
giftPreviewModal: GiftPreviewModal,
giftUpgradeModal: GiftUpgradeModal,
giftAuctionModal: GiftAuctionModal,
giftAuctionBidModal: GiftAuctionBidModal,

View File

@ -4,7 +4,6 @@
flex-direction: column;
gap: 1rem;
max-height: min(92vh, 45rem) !important;
padding-inline: 1rem !important;
}

View File

@ -0,0 +1,91 @@
.root {
--_selected-color: var(--color-primary);
cursor: var(--custom-cursor, pointer);
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.875rem;
border-radius: 1rem;
color: #fff;
background-color: var(--color-background-secondary);
outline: 0.125rem solid transparent;
transition: outline-color 0.15s ease-out;
&::before {
pointer-events: none;
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
opacity: 0;
background-color: var(--color-hover-overlay);
transition: opacity 0.15s;
}
&::after {
pointer-events: none;
content: "";
position: absolute;
inset: 0.125rem;
border: 0.125rem solid transparent;
border-radius: 0.9375rem;
transition: border-color 0.15s linear;
}
&.hasBackground {
--_selected-color: var(--color-background-secondary);
}
:global(html.theme-dark) & {
background-color: rgb(33, 33, 33);
&.hasBackground {
--_selected-color: rgb(33, 33, 33);
}
}
&:hover::before {
opacity: 1;
}
}
.selected::after {
border-color: var(--_selected-color);
}
.radialPattern {
position: absolute;
inset: 0;
}
.stickerWrapper {
position: relative;
}
.rarity {
position: absolute;
top: 0.5rem;
right: 0.4375rem;
backdrop-filter: blur(0.5rem);
}
.regular {
color: white;
backdrop-filter: blur(0.5rem) brightness(0.8);
}

View File

@ -0,0 +1,123 @@
import type { ElementRef, TeactNode } from '@teact';
import { memo, useMemo, useRef } from '@teact';
import type {
ApiStarGiftAttributeBackdrop,
ApiStarGiftAttributeRarity,
ApiSticker,
} from '../../../api/types';
import buildClassName from '../../../util/buildClassName';
import useDynamicColorListener from '../../../hooks/stickers/useDynamicColorListener';
import { type ObserveFn } from '../../../hooks/useIntersectionObserver';
import useLastCallback from '../../../hooks/useLastCallback';
import GiftRarityBadge from '../../common/GiftRarityBadge';
import RadialPatternBackground from '../../common/profile/RadialPatternBackground';
import StickerView from '../../common/StickerView';
import styles from './GiftAttributeItem.module.scss';
type OwnProps<T> = {
ref?: ElementRef<HTMLDivElement>;
children?: TeactNode;
backdrop?: ApiStarGiftAttributeBackdrop;
patternSticker?: ApiSticker;
sticker?: ApiSticker;
stickerSize?: number;
stickerNoPlay?: boolean;
rarity?: ApiStarGiftAttributeRarity;
isSelected?: boolean;
className?: string;
clickArg?: T;
observeIntersection?: ObserveFn;
onClick?: (arg: T) => void;
};
const DEFAULT_STICKER_SIZE = 90;
const GiftAttributeItem = <T,>({
ref,
backdrop,
patternSticker,
sticker,
stickerSize = DEFAULT_STICKER_SIZE,
stickerNoPlay,
rarity,
isSelected,
className,
clickArg,
children,
observeIntersection,
onClick,
}: OwnProps<T>) => {
const stickerRef = useRef<HTMLDivElement>();
const customColor = useDynamicColorListener(stickerRef, undefined, !sticker?.shouldUseTextColor);
const radialPatternBackdrop = useMemo(() => {
if (!backdrop) return undefined;
const backdropColors: [string, string] = [backdrop.centerColor, backdrop.edgeColor];
return (
<RadialPatternBackground
className={styles.radialPattern}
backgroundColors={backdropColors}
patternIcon={patternSticker}
ringsCount={1}
ovalFactor={1}
/>
);
}, [backdrop, patternSticker]);
const handleClick = useLastCallback(() => {
onClick?.(clickArg!);
});
return (
<div
ref={ref}
className={buildClassName(
styles.root,
isSelected && styles.selected,
radialPatternBackdrop && styles.hasBackground,
className,
)}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
onClick={handleClick}
>
{radialPatternBackdrop}
<div
ref={stickerRef}
className={styles.stickerWrapper}
style={`width: ${stickerSize}px; height: ${stickerSize}px`}
>
{sticker && (
<StickerView
containerRef={stickerRef}
sticker={sticker}
size={stickerSize}
observeIntersectionForPlaying={observeIntersection}
observeIntersectionForLoading={observeIntersection}
shouldPreloadPreview
noPlay={stickerNoPlay}
customColor={customColor}
/>
)}
</div>
{rarity && (
<GiftRarityBadge
rarity={rarity}
className={buildClassName(styles.rarity, rarity.type === 'regular' && styles.regular)}
/>
)}
{children}
</div>
);
};
export default memo(GiftAttributeItem);

View File

@ -51,6 +51,8 @@
}
.starGift {
overflow: visible;
flex-basis: auto;
padding: 0.875rem;
padding-bottom: 0.625rem;
}
@ -90,11 +92,11 @@
.auction,
.premiumRequired {
outline: 0.125rem solid #D18D21;
outline: 0.125rem solid #D18D21 !important;
outline-offset: -0.125rem;
&:focus-visible {
outline: 0.125rem solid #D18D21;
outline: 0.125rem solid #D18D21 !important;
outline-offset: -0.125rem;
}
}

View File

@ -5,22 +5,18 @@ import type { ApiStarGift, ApiTypeCurrencyAmount } from '../../../api/types';
import { STARS_CURRENCY_CODE, TON_CURRENCY_CODE } from '../../../config';
import { selectIsCurrentUserPremium } from '../../../global/selectors';
import { IS_TOUCH_ENV } from '../../../util/browser/windowEnvironment.ts';
import buildClassName from '../../../util/buildClassName';
import { formatStarsAsIcon, formatTonAsIcon } from '../../../util/localization/format';
import Icon from '../../common/icons/Icon'; ;
import { getGiftAttributes, getStickerFromGift } from '../../common/helpers/gifts';
import useFlag from '../../../hooks/useFlag.ts';
import { type ObserveFn, useOnIntersect } from '../../../hooks/useIntersectionObserver';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import GiftRibbon from '../../common/gift/GiftRibbon';
import RadialPatternBackground from '../../common/profile/RadialPatternBackground';
import StickerView from '../../common/StickerView';
import Icon from '../../common/icons/Icon';
import Button from '../../ui/Button';
import GiftAttributeItem from './GiftAttributeItem';
import styles from './GiftItem.module.scss';
@ -48,7 +44,6 @@ function GiftItemStar({
} = getActions();
const ref = useRef<HTMLDivElement>();
const stickerRef = useRef<HTMLDivElement>();
function getPriceAmount(amounts?: ApiTypeCurrencyAmount[]) {
if (!amounts) return { amount: 0, currency: STARS_CURRENCY_CODE };
@ -65,7 +60,6 @@ function GiftItemStar({
const lang = useLang();
const [isVisible, setIsVisible] = useState(false);
const [isHover, markHover, unmarkHover] = useFlag();
const sticker = getStickerFromGift(gift);
const isGiftUnique = gift.type === 'starGiftUnique';
@ -132,38 +126,19 @@ function GiftItemStar({
onClick?.(gift, isResale ? 'resell' : 'original');
});
const radialPatternBackdrop = useMemo(() => {
const { backdrop, pattern } = getGiftAttributes(gift) || {};
if (!backdrop || !pattern) {
return undefined;
}
const backdropColors = [backdrop.centerColor, backdrop.edgeColor];
return (
<RadialPatternBackground
className={styles.radialPattern}
backgroundColors={backdropColors}
patternIcon={pattern.sticker}
ringsCount={1}
ovalFactor={1}
/>
);
}, [gift]);
const giftAttrs = useMemo(() => getGiftAttributes(gift), [gift]);
const giftNumber = isGiftUnique ? gift.number : 0;
const isLocked = Boolean(gift.type === 'starGift' && gift.lockedUntilDate);
const giftRibbon = useMemo(() => {
if (isGiftUnique) {
const { backdrop } = getGiftAttributes(gift) || {};
if (!backdrop) {
if (!giftAttrs?.backdrop) {
return undefined;
}
return (
<GiftRibbon
color={[backdrop.centerColor, backdrop.edgeColor]}
color={[giftAttrs.backdrop.centerColor, giftAttrs.backdrop.edgeColor]}
text={
lang('GiftSavedNumber', { number: giftNumber })
}
@ -186,7 +161,7 @@ function GiftItemStar({
return <GiftRibbon color="blue" text={lang('GiftLimited')} />;
}
return undefined;
}, [isGiftUnique, isResale, gift, isSoldOut,
}, [isGiftUnique, isResale, giftAttrs, isSoldOut,
isLimited, lang, giftNumber, isPremiumRequired, isAuction]);
useOnIntersect(ref, observeIntersection, (entry) => {
@ -217,8 +192,13 @@ function GiftItemStar({
}, [withTransferBadge, priceCurrency, formattedPrice, isAuction, lang]);
return (
<div
<GiftAttributeItem
ref={ref}
backdrop={giftAttrs?.backdrop}
patternSticker={giftAttrs?.pattern?.sticker}
sticker={sticker}
stickerSize={GIFT_STICKER_SIZE}
observeIntersection={observeIntersection}
className={buildClassName(
'interactive-gift',
styles.container,
@ -228,32 +208,8 @@ function GiftItemStar({
isAuction && styles.auction,
noClickable && styles.noClickable,
)}
tabIndex={noClickable ? undefined : 0}
role={noClickable ? undefined : 'button'}
onClick={noClickable ? undefined : handleGiftClick}
onMouseEnter={!IS_TOUCH_ENV && !noClickable ? markHover : undefined}
onMouseLeave={!IS_TOUCH_ENV && !noClickable ? unmarkHover : undefined}
>
{radialPatternBackdrop}
<div
ref={stickerRef}
className={styles.stickerWrapper}
style={`width: ${GIFT_STICKER_SIZE}px; height: ${GIFT_STICKER_SIZE}px`}
>
{sticker && (
<StickerView
observeIntersectionForPlaying={observeIntersection}
observeIntersectionForLoading={observeIntersection}
containerRef={stickerRef}
sticker={sticker}
size={GIFT_STICKER_SIZE}
shouldLoop={isHover}
shouldPreloadPreview
/>
)}
</div>
{!hideBadge && (
<Button
className={buildClassName(
@ -272,7 +228,7 @@ function GiftItemStar({
)}
{giftRibbon}
{isLocked && <Icon name="lock-badge" className={styles.lockIcon} />}
</div>
</GiftAttributeItem>
);
}

View File

@ -1,8 +1,10 @@
.root {
--_height: 15rem;
overflow: hidden;
display: flex;
flex-direction: column;
flex-shrink: 0;
gap: 0.25rem;
align-items: center;
@ -10,6 +12,7 @@
height: var(--_height);
margin-bottom: 0.5rem;
padding-bottom: 1rem;
border-radius: 0 0 0.625rem 0.625rem;
&.withManageButtons {
--_height: 19.25rem;
@ -114,6 +117,7 @@
.subtitle {
font-size: 1rem;
line-height: 1.375rem;
color: var(--tinted-text-color);
text-align: center;
text-wrap: balance;

View File

@ -1,3 +1,4 @@
import type React from '@teact';
import type { TeactNode } from '@teact';
import { memo, useMemo } from '@teact';
import { getActions } from '../../../global';
@ -6,7 +7,8 @@ import type {
ApiPeer,
ApiSavedStarGift,
ApiStarGiftAttributeBackdrop, ApiStarGiftAttributeModel, ApiStarGiftAttributePattern,
ApiTypeCurrencyAmount } from '../../../api/types';
ApiTypeCurrencyAmount,
} from '../../../api/types';
import {
formatStarsTransactionAmount,
@ -42,6 +44,7 @@ type OwnProps = {
resellPrice?: ApiTypeCurrencyAmount;
showManageButtons?: boolean;
savedGift?: ApiSavedStarGift;
children?: React.ReactNode;
};
const STICKER_SIZE = 120;
@ -58,6 +61,7 @@ const UniqueGiftHeader = ({
resellPrice,
showManageButtons,
savedGift,
children,
}: OwnProps) => {
const {
openChat,
@ -88,11 +92,13 @@ const UniqueGiftHeader = ({
}, [backdropAttribute, patternAttribute, isMobile]);
return (
<div className={buildClassName(styles.root,
'interactive-gift',
showManageButtons && styles.withManageButtons,
badge && styles.withBadge,
className)}
<div
className={buildClassName(styles.root,
'interactive-gift',
showManageButtons && styles.withManageButtons,
badge && styles.withBadge,
className)}
style={buildStyle(subtitleColor && `--tinted-text-color: ${subtitleColor}`)}
>
<Transition
className={styles.transition}
@ -118,7 +124,6 @@ const UniqueGiftHeader = ({
{Boolean(subtitle) && (
<div
className={buildClassName(styles.subtitle, subtitlePeer && styles.subtitleBadge)}
style={buildStyle(subtitleColor && `color: ${subtitleColor}`)}
onClick={() => {
if (subtitlePeer) {
openChat({ id: subtitlePeer.id });
@ -142,6 +147,7 @@ const UniqueGiftHeader = ({
{resellPrice.currency === 'TON' && <Icon name="toncoin" />}
</p>
)}
{children}
</div>
);
};

View File

@ -1,3 +1,7 @@
.root {
z-index: calc(var(--z-modal-low-priority) + 1);
}
.content {
display: flex;
flex-direction: column;

View File

@ -333,6 +333,7 @@ const GiftAuctionBidModal = ({
isOpen={isOpen}
hasAbsoluteCloseButton
isSlim
className={styles.root}
contentClassName={styles.content}
onClose={closeGiftAuctionBidModal}
isLowStackPriority

View File

@ -4,7 +4,7 @@
.modalContent {
position: relative;
max-height: min(97vh, 48rem) !important;
max-height: min(97vh, 50rem) !important;
}
.header {

View File

@ -56,11 +56,6 @@
color: var(--color-error) !important;
}
.modalContent {
position: relative;
max-height: min(97vh, 48rem) !important;
}
.moreMenuButton {
position: absolute;
z-index: 1;

View File

@ -24,7 +24,7 @@ import {
import { CUSTOM_PEER_HIDDEN } from '../../../../util/objects/customPeer';
import { getServerTime } from '../../../../util/serverTime';
import { renderGiftOriginalInfo } from '../../../common/helpers/giftOriginalInfo';
import { getGiftAttributes, getGiftRarityTitle, getStickerFromGift } from '../../../common/helpers/gifts';
import { getGiftAttributes, getStickerFromGift } from '../../../common/helpers/gifts';
import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities';
import useCurrentOrPrev from '../../../../hooks/useCurrentOrPrev';
@ -95,9 +95,8 @@ const GiftInfoModal = ({
buyStarGift,
closeGiftModal,
openGiftInfoValueModal,
updateResaleGiftsFilter,
openGiftInMarket,
openGiftDescriptionRemoveModal,
openGiftPreviewModal,
} = getActions();
const [isConvertConfirmOpen, openConvertConfirm, closeConvertConfirm] = useFlag();
@ -109,57 +108,6 @@ const GiftInfoModal = ({
const uniqueGiftHeaderRef = useRef<HTMLDivElement>();
const handleSymbolClick = useLastCallback(() => {
if (!gift || !giftAttributes?.pattern) return;
openGiftInMarket({ gift });
updateResaleGiftsFilter({
filter: {
sortType: 'byDate',
modelAttributes: [],
backdropAttributes: [],
patternAttributes: [{
type: 'pattern',
documentId: giftAttributes.pattern.sticker.id,
}],
},
});
});
const handleBackdropClick = useLastCallback(() => {
if (!gift || !giftAttributes?.backdrop) return;
openGiftInMarket({ gift });
updateResaleGiftsFilter({
filter: {
sortType: 'byDate',
modelAttributes: [],
backdropAttributes: [{
type: 'backdrop',
backdropId: giftAttributes.backdrop.backdropId,
}],
patternAttributes: [],
},
});
});
const handleModelClick = useLastCallback(() => {
if (!gift || !giftAttributes?.model) return;
openGiftInMarket({ gift });
updateResaleGiftsFilter({
filter: {
sortType: 'byDate',
modelAttributes: [{
type: 'model',
documentId: giftAttributes.model.sticker.id,
}],
backdropAttributes: [],
patternAttributes: [],
},
});
});
const isOpen = Boolean(modal);
const renderingModal = useCurrentOrPrev(modal);
const renderingFromPeer = useCurrentOrPrev(fromPeer);
@ -303,6 +251,13 @@ const GiftInfoModal = ({
return gift && getGiftAttributes(gift);
}, [gift]);
const handleOpenPreviewModal = useLastCallback(() => {
if (!gift) return;
openGiftPreviewModal({
originGift: gift,
});
});
const uniqueGiftTitle = useMemo(() => {
if (!gift || gift.type !== 'starGiftUnique' || !giftAttributes?.backdrop) return undefined;
@ -709,13 +664,8 @@ const GiftInfoModal = ({
if (model) {
tableData.push([
lang('GiftAttributeModel'),
<span className={styles.uniqueAttribute}>
<span
className={styles.attributeName}
onClick={handleModelClick}
>
{model.name}
</span>
<span className={styles.uniqueAttribute} onClick={handleOpenPreviewModal}>
<span className={styles.attributeName}>{model.name}</span>
<GiftRarityBadge rarity={model.rarity} />
</span>,
]);
@ -724,14 +674,9 @@ const GiftInfoModal = ({
if (backdrop) {
tableData.push([
lang('GiftAttributeBackdrop'),
<span className={styles.uniqueAttribute}>
<span
className={styles.attributeName}
onClick={handleBackdropClick}
>
{backdrop.name}
</span>
<BadgeButton>{getGiftRarityTitle(lang, backdrop.rarity)}</BadgeButton>
<span className={styles.uniqueAttribute} onClick={handleOpenPreviewModal}>
<span className={styles.attributeName}>{backdrop.name}</span>
<GiftRarityBadge rarity={backdrop.rarity} />
</span>,
]);
}
@ -739,14 +684,9 @@ const GiftInfoModal = ({
if (pattern) {
tableData.push([
lang('GiftAttributeSymbol'),
<span className={styles.uniqueAttribute}>
<span
className={styles.attributeName}
onClick={handleSymbolClick}
>
{pattern.name}
</span>
<BadgeButton>{getGiftRarityTitle(lang, pattern.rarity)}</BadgeButton>
<span className={styles.uniqueAttribute} onClick={handleOpenPreviewModal}>
<span className={styles.attributeName}>{pattern.name}</span>
<GiftRarityBadge rarity={pattern.rarity} />
</span>,
]);
}
@ -874,7 +814,6 @@ const GiftInfoModal = ({
gift, giftAttributes, renderFooterButton, isTargetChat,
isGiftUnique, saleDateInfo,
canBuyGift, giftOwnerTitle, resellPrice, uniqueGiftTitle, uniqueGiftSubtitle, releasedByPeer,
handleSymbolClick, handleBackdropClick, handleModelClick,
]);
const moreMenuItems = typeGift && (
@ -896,8 +835,7 @@ const GiftInfoModal = ({
hasBackdrop={isGiftUnique}
tableData={modalData?.tableData}
footer={modalData?.footer}
className={styles.modal}
contentClassName={styles.modalContent}
className={buildClassName(styles.modal, 'tall')}
closeButtonColor={isGiftUnique ? 'translucent-white' : undefined}
moreMenuItems={moreMenuItems}
onClose={handleClose}

View File

@ -17,14 +17,14 @@ import {
} from '../../../../util/localization/format';
import { round } from '../../../../util/math';
import { formatPercent } from '../../../../util/textFormat';
import { getGiftAttributes, getGiftRarityTitle } from '../../../common/helpers/gifts';
import { getGiftAttributes } from '../../../common/helpers/gifts';
import useCurrentOrPrev from '../../../../hooks/useCurrentOrPrev';
import useLang from '../../../../hooks/useLang';
import useLastCallback from '../../../../hooks/useLastCallback';
import BadgeButton from '../../../common/BadgeButton';
import GiftTransferPreview from '../../../common/gift/GiftTransferPreview';
import GiftRarityBadge from '../../../common/GiftRarityBadge';
import ConfirmDialog from '../../../ui/ConfirmDialog';
import TableInfo, { type TableData } from '../../common/TableInfo';
@ -85,7 +85,7 @@ const GiftOfferAcceptModal = ({
lang('GiftAttributeModel'),
<span className={styles.attributeValue}>
<span>{model.name}</span>
<BadgeButton>{getGiftRarityTitle(lang, model.rarity)}</BadgeButton>
<GiftRarityBadge rarity={model.rarity} />
</span>,
]);
}
@ -95,7 +95,7 @@ const GiftOfferAcceptModal = ({
lang('GiftAttributeBackdrop'),
<span className={styles.attributeValue}>
<span>{backdrop.name}</span>
<BadgeButton>{getGiftRarityTitle(lang, backdrop.rarity)}</BadgeButton>
<GiftRarityBadge rarity={backdrop.rarity} />
</span>,
]);
}
@ -105,7 +105,7 @@ const GiftOfferAcceptModal = ({
lang('GiftAttributeSymbol'),
<span className={styles.attributeValue}>
<span>{pattern.name}</span>
<BadgeButton>{getGiftRarityTitle(lang, pattern.rarity)}</BadgeButton>
<GiftRarityBadge rarity={pattern.rarity} />
</span>,
]);
}

View File

@ -0,0 +1,14 @@
import type { OwnProps } from './GiftPreviewModal';
import { Bundles } from '../../../../util/moduleLoader';
import useModuleLoader from '../../../../hooks/useModuleLoader';
const GiftPreviewModalAsync = (props: OwnProps) => {
const { modal } = props;
const GiftPreviewModal = useModuleLoader(Bundles.Stars, 'GiftPreviewModal', !modal);
return GiftPreviewModal ? <GiftPreviewModal {...props} /> : undefined;
};
export default GiftPreviewModalAsync;

View File

@ -0,0 +1,110 @@
@use "../../../../styles/mixins";
.content {
overflow: hidden;
display: flex;
flex-direction: column;
height: min(92vh, 46rem); // Required to keep Transition height
padding: 0 !important;
}
.tabs {
position: sticky;
z-index: 1;
top: 0;
padding-top: 0.5rem;
background-color: var(--color-background);
}
.dialog,
.transition {
overflow: hidden;
}
.grid {
overflow-y: scroll;
display: grid;
grid-auto-rows: max-content;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
padding: 0.5rem;
@include mixins.adapt-padding-to-scrollbar(0.5rem, 0.125rem); // Prevent outline cutouts
}
.gridHeader {
display: flex;
grid-column: 1 / 4;
grid-row: 1;
flex-direction: column;
place-self: center;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
}
.item {
aspect-ratio: 1 / 1;
}
.header {
--_height: 20rem;
justify-content: flex-end;
margin-bottom: 0;
}
.traitButtons {
z-index: 1;
display: flex;
gap: 0.375rem;
justify-content: center;
width: 100%;
margin-top: 0.25rem;
padding: 0 1rem;
}
.traitButton {
overflow: visible;
display: flex;
flex-direction: column;
flex-shrink: 1;
gap: 0.125rem;
}
.traitName {
font-size: 0.8125rem;
line-height: 1;
}
.traitType {
font-size: 0.625rem;
color: var(--tinted-text-color);
}
.traitRarity {
--accent-background-active-color: var(--_badge-bg);
position: absolute;
top: 0;
right: 0;
transform: translate(0.25rem, -50%);
color: white;
transition: background-color 150ms linear;
}
.playButton {
position: absolute;
z-index: 1;
top: 0.875rem;
right: 0.875rem;
}

View File

@ -0,0 +1,490 @@
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 { getNextArrowReplacement } from '../../../../util/localization/format';
import { resolveTransitionName } from '../../../../util/resolveTransitionName';
import { getGiftAttributes, getRandomGiftPreviewAttributes } from '../../../common/helpers/gifts';
import useInterval from '../../../../hooks/schedulers/useInterval';
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 TabList, { type TabWithProperties } from '../../../ui/TabList';
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;
const PLAYBACK_INTERVAL = 5000;
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]);
useInterval(() => {
if (!originGift || !selectedModel || !selectedPattern || !selectedBackdrop) return;
const newAttributes = getRandomGiftPreviewAttributes(renderingModal?.attributes, {
model: selectedModel,
pattern: selectedPattern,
backdrop: selectedBackdrop,
});
setSelectedModel(newAttributes.model);
setSelectedPattern(newAttributes.pattern);
setSelectedBackdrop(newAttributes.backdrop);
}, isPlayingRandomPreviews ? PLAYBACK_INTERVAL : undefined, true);
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')}
>
<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: getNextArrowReplacement() },
)}
</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()}
<TabList
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));

View File

@ -1,3 +1,7 @@
.header {
--_height: 17.5rem;
}
.footer {
display: flex;
flex-direction: column;
@ -19,3 +23,14 @@
text-transform: none;
opacity: 0.6;
}
.subtitle {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
}
.viewAllText {
margin-inline-start: 0.125rem;
}

View File

@ -3,7 +3,7 @@ import {
} from '../../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../../global';
import type { ApiPeer } from '../../../../api/types';
import type { ApiPeer, ApiStarGiftAttributeModel } from '../../../../api/types';
import type { TabState } from '../../../../global/types';
import { getPeerTitle } from '../../../../global/helpers/peers';
@ -19,6 +19,7 @@ import useLang from '../../../../hooks/useLang';
import useLastCallback from '../../../../hooks/useLastCallback';
import AnimatedCounter from '../../../common/AnimatedCounter';
import CustomEmoji from '../../../common/CustomEmoji';
import Icon from '../../../common/icons/Icon';
import Button from '../../../ui/Button';
import Checkbox from '../../../ui/Checkbox';
@ -38,6 +39,7 @@ type StateProps = {
};
const PREVIEW_UPDATE_INTERVAL = 3000;
const BUTTON_MODELS_COUNT = 3;
const GiftUpgradeModal = ({ modal, recipient }: OwnProps & StateProps) => {
const {
@ -47,6 +49,7 @@ const GiftUpgradeModal = ({ modal, recipient }: OwnProps & StateProps) => {
upgradePrepaidGift,
openStarGiftPriceDecreaseInfoModal,
shiftGiftUpgradeNextPrice,
openGiftPreviewModal,
} = getActions();
const isOpen = Boolean(modal);
@ -70,6 +73,12 @@ const GiftUpgradeModal = ({ modal, recipient }: OwnProps & StateProps) => {
const nextPriceDate = nextPrice?.date;
const upgradeStars = renderingModal?.currentUpgradeStars;
const previewModels = useMemo(() => {
return renderingModal?.sampleAttributes
?.filter((attr): attr is ApiStarGiftAttributeModel => attr.type === 'model')
.slice(0, BUTTON_MODELS_COUNT);
}, [renderingModal?.sampleAttributes]);
const handleUpgrade = useLastCallback(() => {
const gift = renderingModal?.gift;
@ -103,6 +112,11 @@ const GiftUpgradeModal = ({ modal, recipient }: OwnProps & StateProps) => {
setPreviewAttributes(getRandomGiftPreviewAttributes(renderingModal.sampleAttributes, previewAttributes));
});
const handleViewAllVariants = useLastCallback(() => {
if (!renderingModal?.gift) return;
openGiftPreviewModal({ originGift: renderingModal.gift.gift });
});
const handleOpenPriceInfo = useLastCallback(() => {
if (!renderingModal?.prices) return;
@ -155,16 +169,43 @@ const GiftUpgradeModal = ({ modal, recipient }: OwnProps & StateProps) => {
['auction', lang('GiftUpgradeTradeableTitle'), lang('GiftUpgradeTradeableDescription')],
]) satisfies TableAboutData;
const subtitle = renderingRecipient
const subtitleText = renderingRecipient
? lang('GiftPeerUpgradeText', { peer: getPeerTitle(lang, renderingRecipient) })
: lang('GiftUpgradeTextOwn');
const subtitle = (
<div className={styles.subtitle}>
<span className={styles.subtitleText}>
{subtitleText}
</span>
<Button
size="tiny"
color="transparentBlured"
pill
fluid
className={styles.viewAllButton}
onClick={handleViewAllVariants}
>
{previewModels?.map((model) => (
<CustomEmoji
sticker={model.sticker}
noPlay
/>
))}
<span className={styles.viewAllText}>
{lang('GiftUpgradeViewAll', undefined, { withNodes: true, specialReplacement: getNextArrowReplacement() })}
</span>
</Button>
</div>
);
const hasPriceDecreaseInfo = Boolean(nextPriceDate)
&& Boolean(renderingModal?.prices?.length)
&& !gift?.alreadyPaidUpgradeStars;
const header = (
<UniqueGiftHeader
className={styles.header}
modelAttribute={previewAttributes.model}
backdropAttribute={previewAttributes.backdrop}
patternAttribute={previewAttributes.pattern}
@ -227,11 +268,10 @@ const GiftUpgradeModal = ({ modal, recipient }: OwnProps & StateProps) => {
header,
footer,
};
}, [previewAttributes, isOpen, lang,
renderingRecipient, renderingModal?.gift,
shouldKeepOriginalDetails, isPrepaid,
renderingModal?.prices?.length,
nextPriceDate, formattedPriceElement]);
}, [
previewAttributes, isOpen, lang, renderingRecipient, renderingModal?.gift, shouldKeepOriginalDetails, isPrepaid,
renderingModal?.prices?.length, nextPriceDate, formattedPriceElement, previewModels,
]);
return (
<TableAboutModal

View File

@ -1,6 +1,5 @@
import type { MouseEvent as ReactMouseEvent } from 'react';
import type { ElementRef, FC } from '../../lib/teact/teact';
import type React from '../../lib/teact/teact';
import type { ElementRef, TeactNode } from '../../lib/teact/teact';
import { useRef, useState } from '../../lib/teact/teact';
import type { IconName } from '../../types/icons';
@ -22,7 +21,7 @@ import './Button.scss';
export type OwnProps = {
ref?: ElementRef<HTMLButtonElement | HTMLAnchorElement>;
type?: 'button' | 'submit' | 'reset';
children?: React.ReactNode;
children?: TeactNode;
size?: 'default' | 'smaller' | 'tiny';
color?: (
'primary' | 'secondary' | 'gray' | 'danger' | 'translucent' | 'translucent-white' | 'translucent-black'
@ -76,7 +75,7 @@ export type OwnProps = {
// Longest animation duration;
const CLICKED_TIMEOUT = 400;
const Button: FC<OwnProps> = ({
const Button = ({
ref,
type = 'button',
id,
@ -125,7 +124,7 @@ const Button: FC<OwnProps> = ({
onMouseLeave,
onFocus,
onTransitionEnd,
}) => {
}: OwnProps) => {
let elementRef = useRef<HTMLButtonElement | HTMLAnchorElement>();
if (ref) {
elementRef = ref;

View File

@ -1,258 +1,266 @@
.Modal {
position: relative;
z-index: var(--z-modal);
color: var(--color-text);
&.confirm,
&.pin {
z-index: var(--z-modal-confirm);
}
&.low-priority {
z-index: var(--z-modal-low-priority);
}
&.delete,
&.error,
&.confirm,
&.pin,
&.unpin-all {
.modal-dialog {
max-width: 24rem;
}
}
&.error {
.modal-content .dialog-buttons {
margin-top: 1rem;
}
}
&.narrow {
.modal-dialog {
max-width: 20rem;
}
}
&.slim {
.modal-dialog {
max-width: 26.25rem;
}
.modal-content {
max-height: min(92vh, 36rem);
}
}
.modal-container {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
}
&.with-balance-bar {
.modal-container {
flex-direction: column;
}
.modal-dialog {
margin-top: 0;
}
}
.modal-backdrop {
position: fixed;
z-index: -1;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: rgba(0, 0, 0, 0.25);
}
&.transparent-backdrop .modal-backdrop {
background-color: transparent;
}
.modal-dialog {
@layer ui.modal {
.Modal {
position: relative;
transform: translate3d(0, -1rem, 0);
z-index: var(--z-modal);
color: var(--color-text);
display: inline-flex;
flex-direction: column;
width: 100%;
min-width: 17.5rem;
max-width: 35rem;
margin: 2rem auto;
border-radius: var(--border-radius-modal);
background-color: var(--color-background);
box-shadow: 0 0.25rem 0.5rem 0.125rem var(--color-default-shadow);
transition: transform 0.2s ease, opacity 0.2s ease;
body.no-page-transitions & {
transform: none !important;
transition: none;
&.confirm,
&.pin {
z-index: var(--z-modal-confirm);
}
@media (max-width: 450px) {
max-width: calc(100vw - 3rem) !important;
&.low-priority {
z-index: var(--z-modal-low-priority);
}
}
&.open .modal-dialog {
transform: translate3d(0, 0, 0);
}
&.closing .modal-dialog {
transform: translate3d(0, 1rem, 0);
}
.modal-header,
%modal-header {
display: flex;
flex-shrink: 0;
align-items: center;
padding: 1.3125rem 1.375rem 0;
}
.modal-title {
overflow: hidden;
flex: 1 1 auto;
font-size: 1.25rem;
font-weight: var(--font-weight-medium);
text-overflow: ellipsis;
&:not(:only-child) {
margin: 0 1rem;
&.delete,
&.error,
&.confirm,
&.pin,
&.unpin-all {
.modal-dialog {
max-width: 24rem;
}
}
}
.modal-header-condensed {
@extend %modal-header;
&.error {
.modal-content .dialog-buttons {
margin-top: 1rem;
}
}
min-height: 3.5rem;
padding: 0.375rem 0.75rem !important;
&.narrow {
.modal-dialog {
max-width: 20rem;
}
}
.modal-action-button {
width: auto;
min-width: 5rem;
height: 2.25rem;
margin-left: auto;
padding-right: 1.25rem;
padding-left: 1.25rem;
&.slim {
.modal-dialog {
max-width: 26.25rem;
}
font-size: 0.875rem;
line-height: 1.625rem;
.modal-content {
max-height: min(92vh, 36rem);
}
&.danger {
color: var(--color-white);
background-color: var(--color-error);
&.tall .modal-content {
max-height: min(92vh, 50rem);
}
}
&:hover,
&:focus,
&:active {
background-color: var(--color-error-shade);
.modal-container {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
}
&.with-balance-bar {
.modal-container {
flex-direction: column;
}
.modal-dialog {
margin-top: 0;
}
}
.modal-backdrop {
position: fixed;
z-index: -1;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: rgba(0, 0, 0, 0.25);
}
&.transparent-backdrop .modal-backdrop {
background-color: transparent;
}
.modal-dialog {
position: relative;
transform: translate3d(0, -1rem, 0);
display: inline-flex;
flex-direction: column;
width: 100%;
min-width: 17.5rem;
max-width: 35rem;
margin: 2rem auto;
border-radius: var(--border-radius-modal);
background-color: var(--color-background);
box-shadow: 0 0.25rem 0.5rem 0.125rem var(--color-default-shadow);
transition:
transform 0.2s ease,
opacity 0.2s ease;
body.no-page-transitions & {
transform: none !important;
transition: none;
}
@media (max-width: 450px) {
max-width: calc(100vw - 3rem) !important;
}
}
&.open .modal-dialog {
transform: translate3d(0, 0, 0);
}
&.closing .modal-dialog {
transform: translate3d(0, 1rem, 0);
}
.modal-header,
%modal-header {
display: flex;
flex-shrink: 0;
align-items: center;
padding: 1.3125rem 1.375rem 0;
}
.modal-title {
overflow: hidden;
flex: 1 1 auto;
font-size: 1.25rem;
font-weight: var(--font-weight-medium);
text-overflow: ellipsis;
&:not(:only-child) {
margin: 0 1rem;
}
}
.modal-header-condensed {
@extend %modal-header;
min-height: 3.5rem;
padding: 0.375rem 0.75rem !important;
.modal-action-button {
width: auto;
min-width: 5rem;
height: 2.25rem;
margin-left: auto;
padding-right: 1.25rem;
padding-left: 1.25rem;
font-size: 0.875rem;
line-height: 1.625rem;
&.danger {
color: var(--color-white);
background-color: var(--color-error);
&:hover,
&:focus,
&:active {
background-color: var(--color-error-shade);
}
}
}
}
}
.modal-content {
overflow-y: auto;
flex-grow: 1;
.modal-content {
overflow-y: auto;
flex-grow: 1;
width: 100%;
max-height: 92vh;
padding: 1rem 1.5rem 1.1875rem;
width: 100%;
max-height: min(92vh, 50rem);
padding: 1rem 1.5rem 1.1875rem;
b,
strong {
overflow-wrap: anywhere;
b,
strong {
overflow-wrap: anywhere;
}
}
.modal-title,
.modal-content,
.modal-content > p {
unicode-bidi: plaintext;
}
.modal-about {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 5;
text-overflow: ellipsis;
}
.modal-help {
font-size: 0.9375rem;
line-height: 1.3;
color: var(--color-text-secondary);
}
.dialog-buttons {
display: flex;
flex-direction: row-reverse;
flex-wrap: wrap;
gap: 0.5rem 1rem;
justify-content: flex-start;
}
.dialog-buttons-column {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.dialog-buttons-centered {
justify-content: center;
}
.dialog-checkbox {
margin: 1rem -1.125rem;
}
.dialog-checkbox-group {
margin: 0 -1.125rem 1rem;
}
.confirm-dialog-button {
width: auto;
height: auto;
font-weight: var(--font-weight-semibold);
text-align: right;
white-space: pre-wrap;
}
.dialog-button-spacer {
flex-grow: 1;
}
.modal-absolute-close-button {
position: absolute;
z-index: 3;
top: 0.875rem;
left: 0.875rem;
}
.modal-more-button {
position: absolute;
z-index: 3;
top: 0.875rem;
right: 0.875rem;
}
}
.modal-title,
.modal-content,
.modal-content > p {
unicode-bidi: plaintext;
}
.modal-about {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 5;
text-overflow: ellipsis;
}
.modal-help {
font-size: 0.9375rem;
line-height: 1.3;
color: var(--color-text-secondary);
}
.dialog-buttons {
display: flex;
flex-direction: row-reverse;
flex-wrap: wrap;
gap: 0.5rem 1rem;
justify-content: flex-start;
}
.dialog-buttons-column {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.dialog-buttons-centered {
justify-content: center;
}
.dialog-checkbox {
margin: 1rem -1.125rem;
}
.dialog-checkbox-group {
margin: 0 -1.125rem 1rem;
}
.confirm-dialog-button {
width: auto;
height: auto;
font-weight: var(--font-weight-semibold);
text-align: right;
white-space: pre-wrap;
}
.dialog-button-spacer {
flex-grow: 1;
}
.modal-absolute-close-button {
position: absolute;
z-index: 3;
top: 0.875rem;
left: 0.875rem;
}
.modal-more-button {
position: absolute;
z-index: 3;
top: 0.875rem;
right: 0.875rem;
}
}

View File

@ -47,13 +47,13 @@ export type OwnProps = {
isLowStackPriority?: boolean;
dialogContent?: React.ReactNode;
moreMenuItems?: TeactNode;
onClose: () => void;
onCloseAnimationEnd?: () => void;
onEnter?: () => void;
withBalanceBar?: boolean;
currencyInBalanceBar?: 'TON' | 'XTR';
isCondensedHeader?: boolean;
noFreezeOnClose?: boolean;
onClose: NoneToVoidFunction;
onCloseAnimationEnd?: NoneToVoidFunction;
onEnter?: NoneToVoidFunction;
};
const Modal = (props: OwnProps) => {
@ -61,10 +61,10 @@ const Modal = (props: OwnProps) => {
dialogRef,
isOpen,
noBackdropClose,
noFreezeOnClose,
onClose,
onCloseAnimationEnd,
onEnter,
noFreezeOnClose,
} = props;
const {
@ -72,8 +72,8 @@ const Modal = (props: OwnProps) => {
shouldRender,
} = useShowTransition({
isOpen,
onCloseAnimationEnd,
withShouldRender: true,
onCloseAnimationEnd,
});
const shouldFreeze = !noFreezeOnClose && !isOpen;

View File

@ -752,3 +752,20 @@ addActionHandler('loadActiveGiftAuctions', async (global, actions, payload): Pro
};
setGlobal(global);
});
addActionHandler('openGiftPreviewModal', async (global, _actions, payload): Promise<void> => {
const { originGift, tabId = getCurrentTabId() } = payload;
const giftId = originGift.type === 'starGiftUnique' ? originGift.regularGiftId : originGift.id;
const result = await callApi('fetchStarGiftUpgradeAttributes', { giftId });
if (!result) return;
global = getGlobal();
global = updateTabState(global, {
giftPreviewModal: {
originGift,
attributes: result.attributes,
},
}, tabId);
setGlobal(global);
});

View File

@ -422,6 +422,8 @@ addTabStateResetterAction('closeGiftResalePriceComposerModal', 'giftResalePriceC
addTabStateResetterAction('closeGiftUpgradeModal', 'giftUpgradeModal');
addTabStateResetterAction('closeGiftPreviewModal', 'giftPreviewModal');
addActionHandler('closeGiftAuctionModal', (global, _actions, payload): ActionReturnType => {
const { shouldKeepAuction, tabId = getCurrentTabId() } = payload || {};
const tabState = selectTabState(global, tabId);

View File

@ -2740,6 +2740,10 @@ export interface ActionPayloads {
gift: ApiStarGiftUnique;
} & WithTabId;
closeGiftInfoValueModal: WithTabId | undefined;
openGiftPreviewModal: {
originGift: ApiStarGift;
} & WithTabId;
closeGiftPreviewModal: WithTabId | undefined;
loadActiveGiftAuctions: undefined;
openActiveGiftAuctionsModal: WithTabId | undefined;
closeActiveGiftAuctionsModal: WithTabId | undefined;

View File

@ -901,6 +901,11 @@ export type TabState = {
emojiStatus: ApiEmojiStatusCollectible;
};
giftPreviewModal?: {
attributes: ApiStarGiftAttribute[];
originGift: ApiStarGift;
};
giftAuctionModal?: {
auctionGiftId: string;
sampleAttributes?: ApiStarGiftAttribute[];

View File

@ -1904,6 +1904,7 @@ payments.getStarGiftAuctionState#5c9ff4d6 auction:InputStarGiftAuction version:i
payments.getStarGiftAuctionAcquiredGifts#6ba2cbec gift_id:long = payments.StarGiftAuctionAcquiredGifts;
payments.getStarGiftActiveAuctions#a5d0514d hash:long = payments.StarGiftActiveAuctions;
payments.resolveStarGiftOffer#e9ce781c flags:# decline:flags.0?true offer_msg_id:int = Updates;
payments.getStarGiftUpgradeAttributes#6d038b58 gift_id:long = payments.StarGiftUpgradeAttributes;
phone.requestCall#42ff96ed flags:# video:flags.0?true user_id:InputUser random_id:int g_a_hash:bytes protocol:PhoneCallProtocol = phone.PhoneCall;
phone.acceptCall#3bd2b4a0 peer:InputPhoneCall g_b:bytes protocol:PhoneCallProtocol = phone.PhoneCall;
phone.confirmCall#2efe1722 peer:InputPhoneCall g_a:bytes key_fingerprint:long protocol:PhoneCallProtocol = phone.PhoneCall;

View File

@ -359,6 +359,7 @@
"payments.getStarGiftAuctionAcquiredGifts",
"payments.getStarGiftActiveAuctions",
"payments.resolveStarGiftOffer",
"payments.getStarGiftUpgradeAttributes",
"langpack.getLangPack",
"langpack.getStrings",
"langpack.getLanguages",

View File

@ -5,12 +5,12 @@
}
}
@mixin adapt-padding-to-scrollbar($padding) {
padding-inline-end: calc($padding - var(--scrollbar-width));
@mixin adapt-padding-to-scrollbar($padding, $forceSpace: 0px) {
padding-inline-end: calc(max($padding - var(--scrollbar-width), $forceSpace));
}
@mixin adapt-margin-to-scrollbar($margin) {
margin-inline-end: calc($margin - var(--scrollbar-width));
@mixin adapt-margin-to-scrollbar($margin, $forceSpace: 0px) {
margin-inline-end: calc(max($margin - var(--scrollbar-width), $forceSpace));
}
@mixin filter-outline($width: 0.125rem, $color) {

View File

@ -1955,6 +1955,12 @@ export interface LangPair {
'ChatListAuctionOutbid': undefined;
'ChatListAuctionView': undefined;
'BotAuthSuccessTitle': undefined;
'GiftPreviewSelectedTraits': undefined;
'GiftUpgradeViewAll': undefined;
'GiftPreviewToggleCraftableModels': undefined;
'GiftPreviewToggleRegularModels': undefined;
'AriaGiftPreviewPlay': undefined;
'AriaGiftPreviewStop': undefined;
}
export interface LangPairWithVariables<V = LangVariable> {
@ -3892,6 +3898,18 @@ export interface LangPairPluralWithVariables<V = LangVariable> {
'ChatListAuctionTitle': {
'count': V;
};
'GiftPreviewCountModels': {
'count': V;
};
'GiftPreviewCountCraftableModels': {
'count': V;
};
'GiftPreviewCountPatterns': {
'count': V;
};
'GiftPreviewCountBackdrops': {
'count': V;
};
}
export type RegularLangKey = keyof LangPair;
export type RegularLangKeyWithVariables = keyof LangPairWithVariables;