Gift Auction Modal: Add upgrade preview in header (#6573)

This commit is contained in:
Alexander Zinchuk 2026-01-13 01:14:20 +01:00
parent 9308631302
commit bcb276d568
29 changed files with 611 additions and 122 deletions

View File

@ -63,7 +63,8 @@ export function buildApiStarGift(starGift: GramJs.TypeStarGift): ApiStarGift {
const {
id, limited, stars, availabilityRemains, availabilityTotal, convertStars, firstSaleDate, lastSaleDate, soldOut,
birthday, upgradeStars, resellMinStars, title, availabilityResale, releasedBy,
requirePremium, limitedPerUser, perUserTotal, perUserRemains, lockedUntilDate, auction, giftsPerRound, background,
requirePremium, limitedPerUser, perUserTotal, perUserRemains, lockedUntilDate, auction, auctionSlug, giftsPerRound,
background,
} = starGift;
addDocumentToLocalDb(starGift.sticker);
@ -94,6 +95,7 @@ export function buildApiStarGift(starGift: GramJs.TypeStarGift): ApiStarGift {
perUserRemains,
lockedUntilDate,
isAuction: auction,
auctionSlug,
giftsPerRound,
background: background ? {
centerColor: int2hex(background.centerColor),

View File

@ -119,8 +119,11 @@ export type ApiReceipt = ApiReceiptRegular | ApiReceiptStars;
export type ApiPremiumSection = typeof PREMIUM_FEATURE_SECTIONS[number];
// Video sections can include additional values like 'gifts' that are not premium features
export type ApiPromoVideoSection = ApiPremiumSection | 'gifts';
export interface ApiPremiumPromo {
videoSections: ApiPremiumSection[];
videoSections: ApiPromoVideoSection[];
videos: ApiDocument[];
statusText: string;
statusEntities: ApiMessageEntity[];

View File

@ -28,6 +28,7 @@ export interface ApiStarGiftRegular {
perUserRemains?: number;
lockedUntilDate?: number;
isAuction?: true;
auctionSlug?: string;
giftsPerRound?: number;
background?: ApiStarGiftBackground;
}

View File

@ -2342,6 +2342,8 @@
"ContextMenuHintTouch" = "To edit or reply, close this menu. Then long tap on a message.";
"GiftValueForSaleOnFragment" = "for sale on Fragment";
"GiftValueForSaleOnTelegram" = "for sale on Telegram";
"GiftAuctionForSaleOnFragment" = "{count} for sale on Fragment >";
"GiftAuctionForSaleOnTelegram" = "{count} for sale on Telegram >";
"EmbeddedMessageNoCaption" = "Caption removed";
"ConfirmBuyGiftForTonDescription" = "The seller only accepts TON as payment.";
"TitleGiftLocked" = "Gift Locked";
@ -2478,6 +2480,16 @@
"GiftAuctionAveragePrice" = "Average Price";
"GiftAuctionTapToBidMore" = "click to bid more";
"GiftAuctionWonNotification" = "You won {gift} at the auction!";
"GiftAuctionLearnMoreAboutGifts" = "Learn more about Telegram Gifts >";
"GiftAuctionLearnMoreMenuItem" = "Learn More";
"StarGiftInfoTitle" = "Telegram Gifts";
"StarGiftInfoSubtitle" = "Gifts are collectible items you can trade or showcase on your profile.";
"StarGiftInfoUniqueTitle" = "Unique";
"StarGiftInfoUniqueSubtitle" = "Upgrade your gifts to get a unique number, model, backdrop and symbol.";
"StarGiftInfoTradableTitle" = "Tradable";
"StarGiftInfoTradableSubtitle" = "Sell your gift on Telegram or on third-party NFT marketplaces.";
"StarGiftInfoWearableTitle" = "Wearable";
"StarGiftInfoWearableSubtitle" = "Display gifts on your profile and set them as your cover or status.";
"StarGift" = "Star Gift";
"SettingsItemPrivacyPasskeys" = "Passkeys";
"SettingsItemPrivacyOn" = "Enabled";

View File

@ -20,6 +20,7 @@ export { default as GiftAuctionInfoModal } from '../components/modals/gift/aucti
export { default as GiftAuctionAcquiredModal } from '../components/modals/gift/auction/GiftAuctionAcquiredModal';
export { default as GiftAuctionChangeRecipientModal } from '../components/modals/gift/auction/GiftAuctionChangeRecipientModal';
export { default as StarGiftPriceDecreaseInfoModal } from '../components/modals/gift/StarGiftPriceDecreaseInfoModal';
export { default as AboutStarGiftModal } from '../components/modals/gift/AboutStarGiftModal';
export { default as GiftStatusInfoModal } from '../components/modals/gift/status/GiftStatusInfoModal';
export { default as GiftWithdrawModal } from '../components/modals/gift/withdraw/GiftWithdrawModal';
export { default as GiftTransferModal } from '../components/modals/gift/transfer/GiftTransferModal';

View File

@ -7,6 +7,10 @@ import type {
ApiStarGiftAttributePattern,
ApiSticker,
} from '../../../api/types';
import { ApiMediaFormat } from '../../../api/types';
import { getStickerMediaHash } from '../../../global/helpers';
import { fetch } from '../../../util/mediaLoader';
export type GiftAttributes = {
model?: ApiStarGiftAttributeModel;
@ -101,3 +105,17 @@ export function getRandomGiftPreviewAttributes(
backdrop: randomBackdrop,
};
}
export function preloadGiftAttributeStickers(attributes: ApiStarGiftAttribute[]) {
const patternStickers = attributes
.filter((attr): attr is ApiStarGiftAttributePattern => attr.type === 'pattern')
.map((attr) => attr.sticker);
const modelStickers = attributes
.filter((attr): attr is ApiStarGiftAttributeModel => attr.type === 'model')
.map((attr) => attr.sticker);
const mediaHashes = [...patternStickers, ...modelStickers].map((sticker) => getStickerMediaHash(sticker, 'full'));
mediaHashes.forEach((hash) => {
fetch(hash, ApiMediaFormat.BlobUrl);
});
}

View File

@ -291,14 +291,14 @@ const PremiumFeatureModal: FC<OwnProps> = ({
const i = promo.videoSections.indexOf(section);
if (i === -1) return undefined;
const shouldUseNewLang = promo.videoSections[i] === 'todo';
const shouldUseNewLang = section === 'todo';
return (
<div className={styles.slide}>
<div className={styles.frame}>
<PremiumFeaturePreviewVideo
isActive={currentSlideIndex === index}
videoId={promo.videos[i].id!}
videoThumbnail={promo.videos[i].thumbnail!}
videoId={promo.videos[i].id}
videoThumbnail={promo.videos[i].thumbnail}
isDown={PREMIUM_BOTTOM_VIDEOS.includes(section)}
index={index}
isReverseAnimation={index === reverseAnimationSlideIndex}
@ -307,20 +307,20 @@ const PremiumFeatureModal: FC<OwnProps> = ({
<h1 className={styles.title}>
{shouldUseNewLang
? lang(
PREMIUM_FEATURE_TITLES[promo.videoSections[i]] as keyof LangPair,
PREMIUM_FEATURE_TITLES['todo'] as keyof LangPair,
undefined,
{ withNodes: true, renderTextFilters: ['br'] },
)
: oldLang(PREMIUM_FEATURE_TITLES[promo.videoSections[i]])}
: oldLang(PREMIUM_FEATURE_TITLES[section])}
</h1>
<div className={styles.description}>
{renderText(shouldUseNewLang
? lang(
PREMIUM_FEATURE_DESCRIPTIONS[promo.videoSections[i]] as keyof LangPair,
PREMIUM_FEATURE_DESCRIPTIONS['todo'] as keyof LangPair,
undefined,
{ withNodes: true, renderTextFilters: ['br'] },
)
: oldLang(PREMIUM_FEATURE_DESCRIPTIONS[promo.videoSections[i]]), ['br'],
: oldLang(PREMIUM_FEATURE_DESCRIPTIONS[section]), ['br'],
)}
</div>
</div>

View File

@ -35,7 +35,7 @@
}
.down {
--y-static: 3%;
--y-static: 2%;
--y-dynamic: 10%;
}
@ -67,3 +67,16 @@
bottom: initial;
border-radius: 10% 10% 0 0;
}
.placeholder {
--placeholder-background: #555555;
position: absolute;
z-index: 0;
width: 97%;
height: 97%;
border-radius: 10%;
background-color: var(--placeholder-background);
}

View File

@ -16,47 +16,55 @@ import styles from './PremiumFeaturePreviewVideo.module.scss';
import DeviceFrame from '../../../../assets/premium/DeviceFrame.svg';
type OwnProps = {
videoId: string;
isReverseAnimation: boolean;
isDown: boolean;
videoThumbnail: ApiThumbnail;
index: number;
isActive: boolean;
videoId?: string;
videoThumbnail?: ApiThumbnail;
isActive?: boolean;
isReverseAnimation?: boolean;
isDown?: boolean;
index?: number;
className?: string;
wrapperClassName?: string;
};
const PremiumFeaturePreviewVideo: FC<OwnProps> = ({
videoId,
videoThumbnail,
isActive,
isReverseAnimation,
isDown,
videoThumbnail,
index,
isActive,
className,
wrapperClassName,
}) => {
const mediaData = useMedia(`document${videoId}`);
const thumbnailRef = useCanvasBlur(videoThumbnail.dataUri);
const mediaData = useMedia(videoId ? `document${videoId}` : undefined);
const thumbnailRef = useCanvasBlur(videoThumbnail?.dataUri);
const transitionClassNames = useMediaTransitionDeprecated(mediaData);
return (
<div className={styles.root}>
<div className={buildClassName(styles.root, className)}>
<div
className={buildClassName(
styles.wrapper,
isReverseAnimation && styles.reverse,
isDown && styles.down,
wrapperClassName,
)}
id={`premium_feature_preview_video_${index}`}
id={index !== undefined ? `premium_feature_preview_video_${index}` : undefined}
>
<img src={DeviceFrame} alt="" className={styles.frame} draggable={false} />
<canvas ref={thumbnailRef} className={styles.video} />
<OptimizedVideo
canPlay={isActive}
className={buildClassName(styles.video, transitionClassNames)}
src={mediaData}
disablePictureInPicture
playsInline
muted
loop
/>
{!videoId && <div className={styles.placeholder} />}
{videoThumbnail && <canvas ref={thumbnailRef} className={styles.video} />}
{videoId && (
<OptimizedVideo
canPlay={Boolean(isActive)}
className={buildClassName(styles.video, transitionClassNames)}
src={mediaData}
disablePictureInPicture
playsInline
muted
loop
/>
)}
</div>
</div>
);

View File

@ -19,6 +19,7 @@ import CollectibleInfoModal from './collectible/CollectibleInfoModal.async';
import DeleteAccountModal from './deleteAccount/DeleteAccountModal.async';
import EmojiStatusAccessModal from './emojiStatusAccess/EmojiStatusAccessModal.async';
import FrozenAccountModal from './frozenAccount/FrozenAccountModal.async';
import AboutStarGiftModal from './gift/AboutStarGiftModal.async';
import GiftAuctionAcquiredModal from './gift/auction/GiftAuctionAcquiredModal.async';
import GiftAuctionBidModal from './gift/auction/GiftAuctionBidModal.async';
import GiftAuctionChangeRecipientModal from './gift/auction/GiftAuctionChangeRecipientModal.async';
@ -105,6 +106,7 @@ type ModalKey = keyof Pick<TabState,
'giftAuctionChangeRecipientModal' |
'giftAuctionAcquiredModal' |
'starGiftPriceDecreaseInfoModal' |
'aboutStarGiftModal' |
'monetizationVerificationModal' |
'giftWithdrawModal' |
'preparedMessageModal' |
@ -177,6 +179,7 @@ const MODALS: ModalRegistry = {
giftAuctionChangeRecipientModal: GiftAuctionChangeRecipientModal,
giftAuctionAcquiredModal: GiftAuctionAcquiredModal,
starGiftPriceDecreaseInfoModal: StarGiftPriceDecreaseInfoModal,
aboutStarGiftModal: AboutStarGiftModal,
monetizationVerificationModal: VerificationMonetizationModal,
giftWithdrawModal: GiftWithdrawModal,
giftStatusInfoModal: GiftStatusInfoModal,

View File

@ -1,6 +1,7 @@
import { memo, type TeactNode } from '../../../lib/teact/teact';
import type { IconName } from '../../../types/icons';
import type { OwnProps as ButtonProps } from '../../ui/Button';
import buildClassName from '../../../util/buildClassName';
@ -25,6 +26,7 @@ type OwnProps = {
footer?: TeactNode;
buttonText?: TeactNode;
hasBackdrop?: boolean;
absoluteCloseButtonColor?: ButtonProps['color'];
withSeparator?: boolean;
onClose: NoneToVoidFunction;
onButtonClick?: NoneToVoidFunction;
@ -40,6 +42,7 @@ const TableAboutModal = ({
footer,
buttonText,
hasBackdrop,
absoluteCloseButtonColor,
withSeparator,
contentClassName,
onClose,
@ -51,7 +54,7 @@ const TableAboutModal = ({
className={buildClassName(styles.root, className)}
contentClassName={buildClassName(styles.content, contentClassName)}
hasAbsoluteCloseButton
absoluteCloseButtonColor={hasBackdrop ? 'translucent-white' : undefined}
absoluteCloseButtonColor={absoluteCloseButtonColor || (hasBackdrop ? 'translucent-white' : undefined)}
onClose={onClose}
>
{headerIconName && (

View File

@ -30,6 +30,8 @@ type OwnProps = {
contentClassName?: string;
tableClassName?: string;
hasBackdrop?: boolean;
closeButtonColor?: 'translucent' | 'translucent-white';
moreMenuItems?: TeactNode;
onClose: NoneToVoidFunction;
onButtonClick?: NoneToVoidFunction;
withBalanceBar?: boolean;
@ -50,6 +52,8 @@ const TableInfoModal = ({
contentClassName,
tableClassName,
hasBackdrop,
closeButtonColor,
moreMenuItems,
onClose,
onButtonClick,
withBalanceBar,
@ -68,12 +72,13 @@ const TableInfoModal = ({
isOpen={isOpen}
hasCloseButton={Boolean(title)}
hasAbsoluteCloseButton={!title}
absoluteCloseButtonColor={hasBackdrop ? 'translucent-white' : undefined}
absoluteCloseButtonColor={closeButtonColor || (hasBackdrop ? 'translucent-white' : undefined)}
isSlim
header={modalHeader}
title={title}
className={className}
contentClassName={buildClassName(styles.content, contentClassName)}
moreMenuItems={moreMenuItems}
onClose={onClose}
withBalanceBar={withBalanceBar}
currencyInBalanceBar={currencyInBalanceBar}

View File

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

View File

@ -0,0 +1,53 @@
.header {
display: flex;
flex-direction: column;
align-items: center;
width: calc(100% + 2rem);
margin: -1rem -1rem 0.25rem;
}
.content {
gap: 0.125rem;
}
.videoPreviewWrapper {
position: relative;
overflow: hidden;
display: flex;
justify-content: center;
aspect-ratio: 10 / 8;
width: 100%;
background: var(--premium-gradient);
}
.sparkles {
color: white;
}
.title {
padding-top: 0.5rem;
font-size: 1.5rem;
font-weight: var(--font-weight-medium);
text-align: center;
}
.subtitle {
padding-top: 0.25rem;
text-align: center;
text-wrap: balance;
}
.footer {
display: flex;
flex-direction: column;
align-self: stretch;
margin-top: 0.5rem;
}
.understoodIcon {
font-size: 1.1875rem;
}

View File

@ -0,0 +1,93 @@
import { memo, useMemo } from '../../../lib/teact/teact';
import { getActions } from '../../../global';
import type { TabState } from '../../../global/types';
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import Sparkles from '../../common/Sparkles';
import PremiumFeaturePreviewVideo from '../../main/premium/previews/PremiumFeaturePreviewVideo';
import Button from '../../ui/Button';
import TableAboutModal, { type TableAboutData } from '../common/TableAboutModal';
import styles from './AboutStarGiftModal.module.scss';
export type OwnProps = {
modal: TabState['aboutStarGiftModal'];
};
const AboutStarGiftModal = ({
modal,
}: OwnProps) => {
const { closeAboutStarGiftModal } = getActions();
const lang = useLang();
const isOpen = Boolean(modal?.isOpen);
const renderingModal = useCurrentOrPrev(modal);
const handleClose = useLastCallback(() => {
closeAboutStarGiftModal();
});
const header = useMemo(() => {
return (
<div className={styles.header}>
<div className={styles.videoPreviewWrapper}>
<Sparkles preset="progress" className={styles.sparkles} />
<PremiumFeaturePreviewVideo
videoId={renderingModal?.videoId}
videoThumbnail={renderingModal?.videoThumbnail}
isActive={isOpen}
isDown
/>
</div>
<div className={styles.title}>
{lang('StarGiftInfoTitle')}
</div>
<div className={styles.subtitle}>
{lang('StarGiftInfoSubtitle')}
</div>
</div>
);
}, [lang, renderingModal, isOpen]);
const footer = useMemo(() => {
if (!isOpen) return undefined;
return (
<div className={styles.footer}>
<Button
iconName="understood"
iconClassName={styles.understoodIcon}
onClick={handleClose}
>
{lang('ButtonUnderstood')}
</Button>
</div>
);
}, [lang, isOpen, handleClose]);
const listItemData = useMemo(() => {
return [
['diamond', lang('StarGiftInfoUniqueTitle'), lang('StarGiftInfoUniqueSubtitle')],
['auction', lang('StarGiftInfoTradableTitle'), lang('StarGiftInfoTradableSubtitle')],
['crown-wear-outline', lang('StarGiftInfoWearableTitle'), lang('StarGiftInfoWearableSubtitle')],
] satisfies TableAboutData;
}, [lang]);
return (
<TableAboutModal
isOpen={isOpen}
contentClassName={styles.content}
header={header}
listItemData={listItemData}
footer={footer}
hasBackdrop={Boolean(renderingModal?.videoId)}
absoluteCloseButtonColor="translucent-white"
onClose={handleClose}
/>
);
};
export default memo(AboutStarGiftModal);

View File

@ -25,6 +25,10 @@
}
}
&.withBadge {
--_height: 17rem;
}
:global {
canvas {
transform-origin: center 45%;
@ -33,17 +37,23 @@
}
}
.subtitleBadge, .badge {
padding: 0.25rem 0.5rem;
border-radius: 1rem;
line-height: 0.875rem;
background-color: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(50px);
}
.subtitleBadge {
margin-top: 0.25rem;
padding: 0.75rem;
padding-block: 0.25rem;
border-radius: 1rem;
line-height: 1rem;
background-color: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(50px);
transition: color 150ms ease-in, background-color 0.15s !important;
&:hover {
@ -97,6 +107,10 @@
color: white;
}
.badge + .title {
margin-top: 0;
}
.subtitle {
font-size: 1rem;
line-height: 1.375rem;
@ -106,7 +120,16 @@
transition: color 150ms ease-in;
}
.title, .subtitle {
.title, .subtitle, .badge {
z-index: 1;
margin-bottom: 0;
}
.badge {
margin-top: auto;
padding: 0.25rem 0.75rem;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
color: white;
}

View File

@ -35,6 +35,7 @@ type OwnProps = {
backdropAttribute: ApiStarGiftAttributeBackdrop;
patternAttribute: ApiStarGiftAttributePattern;
title?: string;
badge?: TeactNode;
subtitle?: TeactNode;
subtitlePeer?: ApiPeer;
className?: string;
@ -50,6 +51,7 @@ const UniqueGiftHeader = ({
backdropAttribute,
patternAttribute,
title,
badge,
subtitle,
subtitlePeer,
className,
@ -89,6 +91,7 @@ const UniqueGiftHeader = ({
<div className={buildClassName(styles.root,
'interactive-gift',
showManageButtons && styles.withManageButtons,
badge && styles.withBadge,
className)}
>
<Transition
@ -108,6 +111,9 @@ const UniqueGiftHeader = ({
onMouseLeave={!IS_TOUCH_ENV ? unmarkGiftHover : undefined}
/>
</Transition>
{Boolean(badge) && (
<div className={styles.badge}>{badge}</div>
)}
{title && <h1 className={styles.title}>{title}</h1>}
{Boolean(subtitle) && (
<div

View File

@ -80,3 +80,9 @@
.starIcon {
line-height: 1rem !important;
}
.learnMoreLink {
font-size: 0.875rem;
color: white !important;
opacity: 0.75;
}

View File

@ -1,16 +1,23 @@
import { memo, useMemo } from '../../../../lib/teact/teact';
import {
memo, useEffect, useMemo, useRef, useState,
} from '../../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../../global';
import type { ApiStarGiftAuctionState } from '../../../../api/types';
import type { TabState } from '../../../../global/types';
import { TME_LINK_PREFIX } from '../../../../config';
import { selectTabState } from '../../../../global/selectors';
import { copyTextToClipboard } from '../../../../util/clipboard';
import { formatCountdown, formatDateTimeToString } from '../../../../util/dates/dateFormat';
import { HOUR } from '../../../../util/dates/units';
import { formatStarsAsIcon } from '../../../../util/localization/format';
import { getServerTime } from '../../../../util/serverTime';
import { getStickerFromGift } from '../../../common/helpers/gifts';
import {
getRandomGiftPreviewAttributes, getStickerFromGift, type GiftPreviewAttributes,
preloadGiftAttributeStickers } from '../../../common/helpers/gifts';
import useInterval from '../../../../hooks/schedulers/useInterval';
import useCurrentOrPrev from '../../../../hooks/useCurrentOrPrev';
import useLang from '../../../../hooks/useLang';
import useLastCallback from '../../../../hooks/useLastCallback';
@ -18,13 +25,16 @@ import useLastCallback from '../../../../hooks/useLastCallback';
import AnimatedIconFromSticker from '../../../common/AnimatedIconFromSticker';
import Button from '../../../ui/Button';
import Link from '../../../ui/Link';
import MenuItem from '../../../ui/MenuItem';
import TextTimer from '../../../ui/TextTimer';
import TableInfoModal, { type TableData } from '../../common/TableInfoModal';
import GiftItemStar from '../GiftItemStar';
import UniqueGiftHeader from '../UniqueGiftHeader';
import styles from './GiftAuctionModal.module.scss';
const TEXT_TIMER_THRESHOLD = 48 * HOUR;
const PREVIEW_UPDATE_INTERVAL = 3000;
export type OwnProps = {
modal: TabState['giftAuctionModal'];
@ -40,9 +50,15 @@ const GiftAuctionModal = ({ modal, auctionState }: OwnProps & StateProps) => {
setGiftModalSelectedGift,
openGiftAuctionInfoModal,
openGiftAuctionAcquiredModal,
openAboutStarGiftModal,
showNotification,
openChatWithDraft,
openUrl,
openGiftInMarket,
} = getActions();
const isOpen = Boolean(modal?.isOpen);
const renderingModal = useCurrentOrPrev(modal);
const renderingAuctionState = useCurrentOrPrev(auctionState);
const gift = renderingAuctionState?.gift;
@ -50,14 +66,43 @@ const GiftAuctionModal = ({ modal, auctionState }: OwnProps & StateProps) => {
const userState = renderingAuctionState?.userState;
const isFinished = state?.type === 'finished';
const [previewAttributes, setPreviewAttributes] = useState<GiftPreviewAttributes | undefined>();
const shouldUseUniqueHeader = Boolean(gift && state && previewAttributes);
const uniqueHeaderRef = useRef<HTMLDivElement>();
const lang = useLang();
const updatePreviewAttributes = useLastCallback(() => {
if (!renderingModal?.sampleAttributes) return;
setPreviewAttributes(getRandomGiftPreviewAttributes(renderingModal.sampleAttributes, previewAttributes));
});
useInterval(updatePreviewAttributes, isOpen ? PREVIEW_UPDATE_INTERVAL : undefined, true);
useEffect(() => {
if (isOpen && renderingModal?.sampleAttributes) {
updatePreviewAttributes();
} else {
setPreviewAttributes(undefined);
}
}, [isOpen, renderingModal?.sampleAttributes]);
useEffect(() => {
const attributes = renderingModal?.sampleAttributes;
if (!attributes) return;
preloadGiftAttributeStickers(attributes);
}, [renderingModal?.sampleAttributes]);
const handleClose = useLastCallback(() => closeGiftAuctionModal());
const handleLearnMoreClick = useLastCallback(() => {
openGiftAuctionInfoModal({});
});
const handleLearnMoreAboutGiftsClick = useLastCallback(() => {
openAboutStarGiftModal({});
});
const handleItemsBoughtClick = useLastCallback(() => {
if (!gift) return;
const giftSticker = getStickerFromGift(gift);
@ -70,8 +115,65 @@ const GiftAuctionModal = ({ modal, auctionState }: OwnProps & StateProps) => {
setGiftModalSelectedGift({ gift });
});
const header = useMemo(() => {
if (!gift || !state) {
const auctionLink = useMemo(() => {
if (!gift?.auctionSlug) return undefined;
return `${TME_LINK_PREFIX}auction/${gift.auctionSlug}`;
}, [gift]);
const handleCopyLink = useLastCallback(() => {
if (!auctionLink) return;
copyTextToClipboard(auctionLink);
showNotification({
message: lang('LinkCopied'),
});
});
const handleShareLink = useLastCallback(() => {
if (!auctionLink) return;
openChatWithDraft({ text: { text: auctionLink } });
});
const handleOpenFragment = useLastCallback(() => {
if (state?.type === 'finished' && state.fragmentListedUrl) {
openUrl({ url: state.fragmentListedUrl });
}
});
const handleOpenTelegramMarket = useLastCallback(() => {
if (!gift) return;
handleClose();
openGiftInMarket({ gift });
});
const uniqueHeader = useMemo(() => {
if (!shouldUseUniqueHeader) {
return undefined;
}
const giftTitle = gift!.title || lang('StarGift');
const badge = isFinished ? lang('GiftAuctionEnded') : lang('GiftAuctionInfoTitle');
const subtitle = (
<Link className={styles.learnMoreLink} isPrimary onClick={handleLearnMoreAboutGiftsClick}>
{lang('GiftAuctionLearnMoreAboutGifts')}
</Link>
);
return (
<div ref={uniqueHeaderRef}>
<UniqueGiftHeader
modelAttribute={previewAttributes!.model}
backdropAttribute={previewAttributes!.backdrop}
patternAttribute={previewAttributes!.pattern}
title={giftTitle}
badge={badge}
subtitle={subtitle}
/>
</div>
);
}, [shouldUseUniqueHeader, gift, isFinished, lang, previewAttributes, handleLearnMoreAboutGiftsClick]);
const regularHeader = useMemo(() => {
if (!gift || !state || shouldUseUniqueHeader) {
return undefined;
}
@ -97,7 +199,9 @@ const GiftAuctionModal = ({ modal, auctionState }: OwnProps & StateProps) => {
)}
</div>
);
}, [gift, state, isFinished, lang, handleLearnMoreClick]);
}, [gift, state, shouldUseUniqueHeader, isFinished, lang, handleLearnMoreClick]);
const header = uniqueHeader || regularHeader;
const modalData = useMemo(() => {
if (!gift || !state || !userState) {
@ -148,6 +252,10 @@ const GiftAuctionModal = ({ modal, auctionState }: OwnProps & StateProps) => {
const auctionTimeLeft = state.endDate - getServerTime();
const shouldUseTextTimer = auctionTimeLeft > 0 && auctionTimeLeft < TEXT_TIMER_THRESHOLD;
const canBuyOnFragment = state.type === 'finished'
&& Boolean(state.fragmentListedUrl && state.fragmentListedCount);
const canBuyOnTelegram = state.type === 'finished' && Boolean(state.listedCount);
const footer = (
<div className={styles.footer}>
{acquiredCount > 0 && (
@ -165,6 +273,40 @@ const GiftAuctionModal = ({ modal, auctionState }: OwnProps & StateProps) => {
}, { pluralValue: acquiredCount, withNodes: true })}
</Link>
)}
{canBuyOnFragment && (
<Link className={styles.itemsBoughtLink} isPrimary onClick={handleOpenFragment}>
{lang('GiftAuctionForSaleOnFragment', {
count: giftSticker ? (
<>
{lang.number(state.fragmentListedCount!)}
<AnimatedIconFromSticker
className={styles.itemsBoughtSticker}
sticker={giftSticker}
size={20}
play={false}
/>
</>
) : lang.number(state.fragmentListedCount!),
}, { withNodes: true })}
</Link>
)}
{canBuyOnTelegram && (
<Link className={styles.itemsBoughtLink} isPrimary onClick={handleOpenTelegramMarket}>
{lang('GiftAuctionForSaleOnTelegram', {
count: giftSticker ? (
<>
{lang.number(state.listedCount!)}
<AnimatedIconFromSticker
className={styles.itemsBoughtSticker}
sticker={giftSticker}
size={20}
play={false}
/>
</>
) : lang.number(state.listedCount!),
}, { withNodes: true })}
</Link>
)}
<Button
noForcedUpperCase
className={styles.footerButton}
@ -194,7 +336,26 @@ const GiftAuctionModal = ({ modal, auctionState }: OwnProps & StateProps) => {
tableData,
footer,
};
}, [gift, state, userState, isFinished, lang, handleJoinClick, handleItemsBoughtClick, handleClose]);
}, [gift, state, userState, isFinished, lang, handleJoinClick, handleItemsBoughtClick, handleClose,
handleOpenFragment, handleOpenTelegramMarket]);
const moreMenuItems = useMemo(() => {
if (!shouldUseUniqueHeader) return undefined;
return (
<>
<MenuItem icon="info" onClick={handleLearnMoreClick}>
{lang('GiftAuctionLearnMoreMenuItem')}
</MenuItem>
<MenuItem icon="link-badge" onClick={handleCopyLink}>
{lang('CopyLink')}
</MenuItem>
<MenuItem icon="forward" onClick={handleShareLink}>
{lang('Share')}
</MenuItem>
</>
);
}, [shouldUseUniqueHeader, lang, handleLearnMoreClick, handleCopyLink, handleShareLink]);
return (
<TableInfoModal
@ -204,6 +365,8 @@ const GiftAuctionModal = ({ modal, auctionState }: OwnProps & StateProps) => {
tableData={modalData?.tableData}
className={styles.modal}
contentClassName={styles.modalContent}
closeButtonColor={shouldUseUniqueHeader ? 'translucent-white' : undefined}
moreMenuItems={moreMenuItems}
onClose={handleClose}
/>
);

View File

@ -27,7 +27,6 @@ import { renderGiftOriginalInfo } from '../../../common/helpers/giftOriginalInfo
import { getGiftAttributes, getStickerFromGift } from '../../../common/helpers/gifts';
import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities';
import useContextMenuHandlers from '../../../../hooks/useContextMenuHandlers';
import useCurrentOrPrev from '../../../../hooks/useCurrentOrPrev';
import useFlag from '../../../../hooks/useFlag';
import useLang from '../../../../hooks/useLang';
@ -45,7 +44,6 @@ import Button from '../../../ui/Button';
import Checkbox from '../../../ui/Checkbox';
import ConfirmDialog from '../../../ui/ConfirmDialog';
import Link from '../../../ui/Link';
import Menu from '../../../ui/Menu';
import TableInfoModal, { type TableData } from '../../common/TableInfoModal';
import UniqueGiftHeader from '../UniqueGiftHeader';
@ -108,16 +106,7 @@ const GiftInfoModal = ({
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState<boolean>(false);
const [shouldPayInTon, setShouldPayInTon] = useState<boolean>(false);
const moreButtonRef = useRef<HTMLButtonElement>();
const menuRef = useRef<HTMLDivElement>();
const uniqueGiftHeaderRef = useRef<HTMLDivElement>();
const {
isContextMenuOpen,
contextMenuAnchor,
handleContextMenu,
handleContextMenuClose,
handleContextMenuHide,
} = useContextMenuHandlers(moreButtonRef);
const handleSymbolClick = useLastCallback(() => {
if (!gift || !giftAttributes?.pattern) return;
@ -540,19 +529,6 @@ const GiftInfoModal = ({
onClick={handleClose}
/>
<Button
ref={moreButtonRef}
className={styles.moreMenuButton}
round
color="translucent-white"
size="tiny"
iconName="more"
aria-haspopup="menu"
aria-label={lang('AriaMoreButton')}
onContextMenu={handleContextMenu}
onClick={handleContextMenu}
/>
{Boolean(resellPrice?.amount) && (
<div className={styles.giftResalePriceContainer}>
{resellPrice.currency === TON_CURRENCY_CODE
@ -881,38 +857,16 @@ const GiftInfoModal = ({
isGiftUnique, saleDateInfo,
canBuyGift, giftOwnerTitle, resellPrice, giftSubtitle,
releasedByPeer, handleSymbolClick, handleBackdropClick, handleModelClick,
handleContextMenu,
]);
const getRootElement = useLastCallback(() => uniqueGiftHeaderRef.current);
const getTriggerElement = useLastCallback(() => moreButtonRef.current);
const getMenuElement = useLastCallback(() => menuRef.current);
const getLayout = useLastCallback(() => ({ withPortal: true }));
const uniqueGiftContextMenu = contextMenuAnchor && typeGift && (
<Menu
ref={menuRef}
isOpen={isContextMenuOpen}
anchor={contextMenuAnchor}
className="gift-context-menu with-menu-transitions"
autoClose
withPortal
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
positionX="right"
getTriggerElement={getTriggerElement}
getRootElement={getRootElement}
getMenuElement={getMenuElement}
getLayout={getLayout}
>
<GiftMenuItems
peerId={renderingModal!.peerId!}
gift={typeGift}
canManage={canManage}
collectibleEmojiStatuses={collectibleEmojiStatuses}
currentUserEmojiStatus={currentUserEmojiStatus}
/>
</Menu>
const moreMenuItems = typeGift && (
<GiftMenuItems
peerId={renderingModal!.peerId!}
gift={typeGift}
canManage={canManage}
collectibleEmojiStatuses={collectibleEmojiStatuses}
currentUserEmojiStatus={currentUserEmojiStatus}
/>
);
return (
@ -926,12 +880,13 @@ const GiftInfoModal = ({
footer={modalData?.footer}
className={styles.modal}
contentClassName={styles.modalContent}
closeButtonColor={isGiftUnique ? 'translucent-white' : undefined}
moreMenuItems={moreMenuItems}
onClose={handleClose}
withBalanceBar={Boolean(canBuyGift)}
currencyInBalanceBar={confirmPrice?.currency}
isLowStackPriority
/>
{uniqueGiftContextMenu}
{uniqueGift && currentUser && Boolean(confirmPrice) && (
<ConfirmDialog
isOpen={isConfirmModalOpen}

View File

@ -3,18 +3,14 @@ import {
} from '../../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../../global';
import type {
ApiPeer,
ApiStarGiftAttributeModel,
} from '../../../../api/types';
import type { ApiPeer } from '../../../../api/types';
import type { TabState } from '../../../../global/types';
import { ApiMediaFormat } from '../../../../api/types';
import { getStickerMediaHash } from '../../../../global/helpers';
import { getPeerTitle } from '../../../../global/helpers/peers';
import { selectPeer } from '../../../../global/selectors';
import { fetch } from '../../../../util/mediaLoader';
import { getRandomGiftPreviewAttributes, type GiftPreviewAttributes } from '../../../common/helpers/gifts';
import {
getRandomGiftPreviewAttributes, type GiftPreviewAttributes,
preloadGiftAttributeStickers } from '../../../common/helpers/gifts';
import useInterval from '../../../../hooks/schedulers/useInterval';
import useCurrentOrPrev from '../../../../hooks/useCurrentOrPrev';
@ -125,19 +121,10 @@ const GiftUpgradeModal = ({ modal, recipient }: OwnProps & StateProps) => {
}
}, [isOpen, renderingModal?.sampleAttributes]);
// Preload stickers and patterns
useEffect(() => {
const attributes = renderingModal?.sampleAttributes;
if (!attributes) return;
const patternStickers = attributes.filter((attr): attr is ApiStarGiftAttributeModel => attr.type === 'pattern')
.map((attr) => attr.sticker);
const modelStickers = attributes.filter((attr): attr is ApiStarGiftAttributeModel => attr.type === 'model')
.map((attr) => attr.sticker);
const mediaHashes = [...patternStickers, ...modelStickers].map((sticker) => getStickerMediaHash(sticker, 'full'));
mediaHashes.forEach((hash) => {
fetch(hash, ApiMediaFormat.BlobUrl);
});
preloadGiftAttributeStickers(attributes);
}, [renderingModal?.sampleAttributes]);
const formattedPriceElement = useMemo(() => (upgradeStars ? (

View File

@ -248,4 +248,11 @@
top: 0.875rem;
left: 0.875rem;
}
.modal-more-button {
position: absolute;
z-index: 3;
top: 0.875rem;
right: 0.875rem;
}
}

View File

@ -1,6 +1,6 @@
import type { ElementRef, FC, TeactNode } from '../../lib/teact/teact';
import type React from '../../lib/teact/teact';
import { beginHeavyAnimation, useEffect } from '../../lib/teact/teact';
import { beginHeavyAnimation, useEffect, useRef } from '../../lib/teact/teact';
import type { TextPart } from '../../types';
@ -9,6 +9,7 @@ import captureKeyboardListeners from '../../util/captureKeyboardListeners';
import { disableDirectTextInput, enableDirectTextInput } from '../../util/directInputManager';
import trapFocus from '../../util/trapFocus';
import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
import useHistoryBack from '../../hooks/useHistoryBack';
import useLastCallback from '../../hooks/useLastCallback';
import useLayoutEffectWithPrevDeps from '../../hooks/useLayoutEffectWithPrevDeps';
@ -16,6 +17,7 @@ import useOldLang from '../../hooks/useOldLang';
import useShowTransition from '../../hooks/useShowTransition';
import Button, { type OwnProps as ButtonProps } from './Button';
import Menu from './Menu';
import ModalStarBalanceBar from './ModalStarBalanceBar';
import Portal from './Portal';
@ -44,6 +46,7 @@ export type OwnProps = {
isLowStackPriority?: boolean;
dialogContent?: React.ReactNode;
ignoreFreeze?: boolean;
moreMenuItems?: TeactNode;
onClose: () => void;
onCloseAnimationEnd?: () => void;
onEnter?: () => void;
@ -72,6 +75,7 @@ const Modal: FC<OwnProps> = ({
isLowStackPriority,
dialogContent,
dialogClassName,
moreMenuItems,
onClose,
onCloseAnimationEnd,
onEnter,
@ -88,6 +92,25 @@ const Modal: FC<OwnProps> = ({
withShouldRender: true,
});
const localDialogRef = useRef<HTMLDivElement>();
const moreButtonRef = useRef<HTMLButtonElement>();
const menuRef = useRef<HTMLDivElement>();
const {
isContextMenuOpen,
contextMenuAnchor,
handleContextMenu,
handleContextMenuClose,
handleContextMenuHide,
} = useContextMenuHandlers(moreButtonRef);
const actualDialogRef = dialogRef || localDialogRef;
const getRootElement = useLastCallback(() => actualDialogRef.current);
const getTriggerElement = useLastCallback(() => moreButtonRef.current);
const getMenuElement = useLastCallback(() => menuRef.current);
const getLayout = useLastCallback(() => ({ withPortal: true }));
const withCloseButton = hasCloseButton || hasAbsoluteCloseButton;
useEffect(() => {
@ -193,8 +216,39 @@ const Modal: FC<OwnProps> = ({
)}
<div className="modal-container">
<div className="modal-backdrop" onClick={!noBackdropClose ? onClose : undefined} />
<div className={modalDialogClassName} ref={dialogRef} style={dialogStyle}>
<div className={modalDialogClassName} ref={actualDialogRef} style={dialogStyle}>
{renderHeader()}
{Boolean(moreMenuItems) && (
<>
<Button
ref={moreButtonRef}
className="modal-more-button"
round
color={absoluteCloseButtonColor}
size="tiny"
iconName="more"
ariaLabel={lang('AriaMoreButton')}
onClick={handleContextMenu}
onContextMenu={handleContextMenu}
/>
<Menu
ref={menuRef}
isOpen={isContextMenuOpen}
anchor={contextMenuAnchor}
autoClose
withPortal
positionX="right"
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
getRootElement={getRootElement}
getTriggerElement={getTriggerElement}
getMenuElement={getMenuElement}
getLayout={getLayout}
>
{moreMenuItems}
</Menu>
</>
)}
{dialogContent}
<div className={buildClassName('modal-content custom-scroll', contentClassName)} style={style}>
{children}

View File

@ -583,11 +583,17 @@ addActionHandler('shiftGiftUpgradeNextPrice', async (global, _actions, payload):
addActionHandler('openGiftAuctionModal', async (global, _actions, payload): Promise<void> => {
const { gift, tabId = getCurrentTabId() } = payload;
await getPromiseActions().loadActiveGiftAuction({ giftId: gift.id, tabId });
const [, preview] = await Promise.all([
getPromiseActions().loadActiveGiftAuction({ giftId: gift.id, tabId }),
callApi('fetchStarGiftUpgradePreview', { giftId: gift.id }),
]);
global = getGlobal();
global = updateTabState(global, {
giftAuctionModal: { isOpen: true },
giftAuctionModal: {
isOpen: true,
sampleAttributes: preview?.sampleAttributes,
},
}, tabId);
setGlobal(global);
});

View File

@ -42,6 +42,7 @@ addActionHandler('processOpenChatOrThread', (global, actions, payload): ActionRe
actions.closeStarsBalanceModal({ tabId });
actions.closeStarsTransactionModal({ tabId });
actions.closeGiftInfoModal({ tabId });
actions.closeGiftAuctionModal({ tabId });
if (!currentMessageList || (
currentMessageList.chatId !== chatId

View File

@ -441,6 +441,32 @@ addActionHandler('openGiftAuctionInfoModal', (global, _actions, payload): Action
addTabStateResetterAction('closeGiftAuctionInfoModal', 'giftAuctionInfoModal');
addActionHandler('openAboutStarGiftModal', async (global, _actions, payload): Promise<void> => {
const { tabId = getCurrentTabId() } = payload || {};
const result = await callApi('fetchPremiumPromo');
let videoId: string | undefined;
let videoThumbnail;
if (result?.promo) {
const giftsIndex = result.promo.videoSections.indexOf('gifts');
if (giftsIndex !== -1 && giftsIndex < result.promo.videos.length) {
const video = result.promo.videos[giftsIndex];
videoId = video.id;
videoThumbnail = video.thumbnail;
}
}
global = getGlobal();
global = updateTabState(global, {
aboutStarGiftModal: { isOpen: true, videoId, videoThumbnail },
}, tabId);
setGlobal(global);
});
addTabStateResetterAction('closeAboutStarGiftModal', 'aboutStarGiftModal');
addActionHandler('openGiftAuctionChangeRecipientModal', (global, _actions, payload): ActionReturnType => {
const {
oldPeerId, newPeerId, message, shouldHideName, tabId = getCurrentTabId(),

View File

@ -2709,6 +2709,8 @@ export interface ActionPayloads {
closeGiftAuctionBidModal: WithTabId | undefined;
openGiftAuctionInfoModal: WithTabId | undefined;
closeGiftAuctionInfoModal: WithTabId | undefined;
openAboutStarGiftModal: WithTabId | undefined;
closeAboutStarGiftModal: WithTabId | undefined;
openGiftAuctionChangeRecipientModal: {
oldPeerId: string;
newPeerId: string;

View File

@ -53,6 +53,7 @@ import type {
ApiStarsTransaction,
ApiStarTopupOption,
ApiSticker,
ApiThumbnail,
ApiTypeCurrencyAmount,
ApiTypePrepaidGiveaway,
ApiTypeStoryView,
@ -895,6 +896,7 @@ export type TabState = {
giftAuctionModal?: {
isOpen?: boolean;
sampleAttributes?: ApiStarGiftAttribute[];
};
giftAuctionBidModal?: {
@ -908,6 +910,12 @@ export type TabState = {
isOpen?: boolean;
};
aboutStarGiftModal?: {
isOpen?: boolean;
videoId?: string;
videoThumbnail?: ApiThumbnail;
};
giftAuctionChangeRecipientModal?: {
isOpen?: boolean;
oldPeerId?: string;

View File

@ -1830,6 +1830,16 @@ export interface LangPair {
'GiftAuctionChangeRecipientTitle': undefined;
'GiftAuctionAveragePrice': undefined;
'GiftAuctionTapToBidMore': undefined;
'GiftAuctionLearnMoreAboutGifts': undefined;
'GiftAuctionLearnMoreMenuItem': undefined;
'StarGiftInfoTitle': undefined;
'StarGiftInfoSubtitle': undefined;
'StarGiftInfoUniqueTitle': undefined;
'StarGiftInfoUniqueSubtitle': undefined;
'StarGiftInfoTradableTitle': undefined;
'StarGiftInfoTradableSubtitle': undefined;
'StarGiftInfoWearableTitle': undefined;
'StarGiftInfoWearableSubtitle': undefined;
'StarGift': undefined;
'SettingsItemPrivacyPasskeys': undefined;
'SettingsItemPrivacyOn': undefined;
@ -3145,6 +3155,12 @@ export interface LangPairWithVariables<V = LangVariable> {
'RatingLevel': {
'level': V;
};
'GiftAuctionForSaleOnFragment': {
'count': V;
};
'GiftAuctionForSaleOnTelegram': {
'count': V;
};
'GiftLockedMessage': {
'relativeDate': V;
};