Gifts: Support isPremiumRequired (#6100)

This commit is contained in:
Alexander Zinchuk 2025-08-15 18:25:19 +02:00
parent fd57d088f9
commit 6d39fd28d8
14 changed files with 211 additions and 37 deletions

View File

@ -25,7 +25,7 @@ export function buildApiStarGift(starGift: GramJs.TypeStarGift): ApiStarGift {
if (starGift instanceof GramJs.StarGiftUnique) {
const {
id, num, ownerId, ownerName, title, attributes, availabilityIssued, availabilityTotal, slug, ownerAddress,
giftAddress, resellAmount, releasedBy, resaleTonOnly,
giftAddress, resellAmount, releasedBy, resaleTonOnly, requirePremium,
} = starGift;
return {
@ -43,6 +43,7 @@ export function buildApiStarGift(starGift: GramJs.TypeStarGift): ApiStarGift {
giftAddress,
resellPrice: resellAmount && resellAmount.map((amount) => buildApiCurrencyAmount(amount)).filter(Boolean),
releasedByPeerId: releasedBy && getApiChatIdFromMtpPeer(releasedBy),
requirePremium,
resaleTonOnly,
};
}
@ -50,6 +51,7 @@ 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,
} = starGift;
addDocumentToLocalDb(starGift.sticker);
@ -74,6 +76,10 @@ export function buildApiStarGift(starGift: GramJs.TypeStarGift): ApiStarGift {
resellMinStars: resellMinStars?.toJSNumber(),
releasedByPeerId: releasedBy && getApiChatIdFromMtpPeer(releasedBy),
availabilityResale: availabilityResale?.toJSNumber(),
requirePremium,
limitedPerUser,
perUserTotal,
perUserRemains,
};
}

View File

@ -22,6 +22,10 @@ export interface ApiStarGiftRegular {
resellMinStars?: number;
releasedByPeerId?: string;
title?: string;
requirePremium?: true;
limitedPerUser?: true;
perUserTotal?: number;
perUserRemains?: number;
}
export interface ApiStarGiftUnique {
@ -39,6 +43,7 @@ export interface ApiStarGiftUnique {
giftAddress?: string;
resellPrice?: ApiTypeCurrencyAmount[];
releasedByPeerId?: string;
requirePremium?: true;
resaleTonOnly?: true;
}

View File

@ -2153,6 +2153,10 @@
"TitleAgeCheckFailed" = "Age Check Failed";
"TitleAgeCheckSuccess" = "Age Check Success";
"ButtonAgeVerification" = "Verify My Age";
"GiftRibbonPremium" = "premium";
"NotificationGiftsLimit" = "You've already sent {count} of these gifts, and it's the limit.";
"PremiumGiftHeader" = "Premium Gift";
"DescriptionGiftPremiumRequired" = "Subscribe to **Telegram Premium** to send up to **{count}** of these gifts and unlock access to multiple additional features.";
"PriceInStars" = "Price in Stars";
"PriceInTON" = "Price in TON";
"DescriptionComposerGiftMinimumCurrencyPrice" = "Minimum price is **{amount}**.";
@ -2164,4 +2168,5 @@
"LabelPayInTON" = "Pay in TON";
"PriceChanged" = "Price Changed";
"PayNewPrice" = "Pay New Price";
"PriceChangedText" = "The price has already changed from **{originalAmount}** to **{newAmount}**. Do you want to pay the new price?";
"PriceChangedText" = "The price has already changed from **{originalAmount}** to **{newAmount}**. Do you want to pay the new price?";

View File

@ -15,6 +15,7 @@ const COLORS = {
blue: [['#6ED2FF', '#34A4FC'], ['#344F5A', '#152E42']],
purple: [['#E367D7', '#757BF6'], ['#E367D7', '#757BF6']],
green: [['#52D553', '#4BB121'], ['#52D553', '#4BB121']],
orange: [['#D48F23', '#BE7E15'], ['#D48F23', '#BE7E15']],
} as const;
type ColorKey = keyof typeof COLORS;

View File

@ -50,6 +50,11 @@
margin: 1rem;
}
.sticker-wrapper {
position: relative;
margin: 1rem;
}
.header-text {
margin-inline: 0.5rem;
font-size: 1.5rem;
@ -176,3 +181,16 @@
.subscriptionOption {
margin: 0.8125rem;
}
.starParticlesHeader,
.giftParticlesHeader {
position: relative !important;
}
.starParticlesHeader {
padding-top: 10rem !important;
}
.giftParticlesHeader {
padding-top: 11rem !important;
}

View File

@ -6,6 +6,7 @@ import type {
ApiPremiumPromo,
ApiPremiumSection,
ApiPremiumSubscriptionOption,
ApiStarGift,
ApiSticker,
ApiStickerSet,
ApiUser,
@ -19,6 +20,7 @@ import { selectIsCurrentUserPremium, selectStickerSet, selectTabState, selectUse
import { selectPremiumLimit } from '../../../global/selectors/limits';
import buildClassName from '../../../util/buildClassName';
import { formatCurrency } from '../../../util/formatCurrency';
import { getStickerFromGift } from '../../common/helpers/gifts';
import { REM } from '../../common/helpers/mediaDimensions';
import renderText from '../../common/helpers/renderText';
import { renderTextWithEntities } from '../../common/helpers/renderTextWithEntities';
@ -99,6 +101,7 @@ type StateProps = {
isSuccess?: boolean;
isGift?: boolean;
monthsAmount?: number;
gift?: ApiStarGift;
limitChannels: number;
limitPins: number;
limitLinks: number;
@ -130,6 +133,7 @@ const PremiumMainModal: FC<OwnProps & StateProps> = ({
toUser,
monthsAmount,
premiumPromoOrder,
gift,
}) => {
const dialogRef = useRef<HTMLDivElement>();
const {
@ -273,6 +277,10 @@ const PremiumMainModal: FC<OwnProps & StateProps> = ({
if (!promo || (fromUserStatusEmoji && !fromUserStatusSet)) return undefined;
function getHeaderText() {
if (gift) {
return lang('PremiumGiftHeader');
}
if (isGift) {
return renderText(
fromUser?.id === currentUserId
@ -307,6 +315,11 @@ const PremiumMainModal: FC<OwnProps & StateProps> = ({
}
function getHeaderDescription() {
if (gift) {
const perUserTotal = gift.type !== 'starGiftUnique' ? gift.perUserTotal : 0;
return lang('DescriptionGiftPremiumRequired', { count: perUserTotal });
}
if (isGift) {
return fromUser?.id === currentUserId
? oldLang('TelegramPremiumUserGiftedPremiumOutboundDialogSubtitle', getUserFullName(toUser))
@ -322,6 +335,52 @@ const PremiumMainModal: FC<OwnProps & StateProps> = ({
: oldLang(isPremium ? 'TelegramPremiumSubscribedSubtitle' : 'TelegramPremiumSubtitle');
}
function renderHeader() {
if (gift) {
const giftSticker = getStickerFromGift(gift);
return (
<ParticlesHeader
model="sticker"
sticker={giftSticker}
color="purple"
title={getHeaderText()}
description={renderText(getHeaderDescription(), ['simple_markdown', 'emoji'])}
className={styles.giftParticlesHeader}
/>
);
}
if (!fromUserStatusEmoji) {
return (
<ParticlesHeader
model="swaying-star"
color="purple"
title={getHeaderText()}
description={renderText(getHeaderDescription(), ['simple_markdown', 'emoji'])}
className={styles.starParticlesHeader}
/>
);
}
return (
<>
<CustomEmoji
className={styles.statusEmoji}
onClick={handleOpenStatusSet}
documentId={fromUserStatusEmoji.id}
isBig
size={STATUS_EMOJI_SIZE}
/>
<h2 className={buildClassName(styles.headerText, fromUserStatusSet && styles.stickerSetText)}>
{getHeaderText()}
</h2>
<div className={styles.description}>
{renderText(getHeaderDescription(), ['simple_markdown', 'emoji'])}
</div>
</>
);
}
function renderFooterText() {
if (!promo || (isGift && fromUser?.id === currentUserId)) {
return undefined;
@ -375,30 +434,7 @@ const PremiumMainModal: FC<OwnProps & StateProps> = ({
>
<Icon name="close" />
</Button>
{!fromUserStatusEmoji ? (
<ParticlesHeader
model="swaying-star"
color="purple"
title={getHeaderText()}
description={renderText(getHeaderDescription(), ['simple_markdown', 'emoji'])}
/>
) : (
<>
<CustomEmoji
className={styles.statusEmoji}
onClick={handleOpenStatusSet}
documentId={fromUserStatusEmoji.id}
isBig
size={STATUS_EMOJI_SIZE}
/>
<h2 className={buildClassName(styles.headerText, fromUserStatusSet && styles.stickerSetText)}>
{getHeaderText()}
</h2>
<div className={styles.description}>
{renderText(getHeaderDescription(), ['simple_markdown', 'emoji'])}
</div>
</>
)}
{renderHeader()}
{!isPremium && !isGift && renderSubscriptionOptions()}
<div className={buildClassName(styles.header, isHeaderHidden && styles.hiddenHeader)}>
<h2 className={styles.premiumHeaderText}>
@ -483,6 +519,7 @@ export default memo(withGlobal<OwnProps>((global): StateProps => {
isSuccess: premiumModal?.isSuccess,
isGift: premiumModal?.isGift,
monthsAmount: premiumModal?.monthsAmount,
gift: premiumModal?.gift,
fromUser,
fromUserStatusEmoji,
fromUserStatusSet,

View File

@ -30,3 +30,14 @@
text-align: center;
text-wrap: balance;
}
.stickerWrapper {
position: absolute;
z-index: 1;
top: 2rem;
transition: transform 0.25s ease-out;
&:hover {
transform: scale(1.1);
}
}

View File

@ -1,35 +1,47 @@
import type { TeactNode } from '@teact';
import { memo, useLayoutEffect, useRef } from '@teact';
import type { ApiSticker } from '../../../api/types';
import buildClassName from '../../../util/buildClassName';
import { PARTICLE_BURST_PARAMS, PARTICLE_COLORS, setupParticles } from '../../../util/particles.ts';
import { REM } from '../../common/helpers/mediaDimensions';
import useLastCallback from '../../../hooks/useLastCallback.ts';
import StickerView from '../../common/StickerView';
import SpeedingDiamond from './SpeedingDiamond.tsx';
import SwayingStar from './SwayingStar.tsx';
import styles from './ParticlesHeader.module.scss';
interface OwnProps {
model: 'swaying-star' | 'speeding-diamond';
model: 'swaying-star' | 'speeding-diamond' | 'sticker';
sticker?: ApiSticker;
color: 'purple' | 'gold' | 'blue';
title: TeactNode;
description: TeactNode;
isDisabled?: boolean;
className?: string;
}
const GIFT_STICKER_SIZE = 8 * REM;
const PARTICLE_PARAMS = {
centerShift: [0, -36] as const,
};
function ParticlesHeader({
model,
sticker,
color,
title,
description,
isDisabled,
className,
}: OwnProps) {
const canvasRef = useRef<HTMLCanvasElement>();
const stickerRef = useRef<HTMLDivElement>();
useLayoutEffect(() => {
if (isDisabled) return undefined;
@ -49,7 +61,7 @@ function ParticlesHeader({
});
return (
<div className={styles.root}>
<div className={buildClassName(styles.root, className)}>
<canvas ref={canvasRef} className={styles.particles} />
{model === 'swaying-star' ? (
@ -58,8 +70,23 @@ function ParticlesHeader({
centerShift={PARTICLE_PARAMS.centerShift}
onMouseMove={handleMouseMove}
/>
) : model === 'speeding-diamond' && (
) : model === 'speeding-diamond' ? (
<SpeedingDiamond onMouseMove={handleMouseMove} />
) : model === 'sticker' && sticker && (
<div
ref={stickerRef}
className={styles.stickerWrapper}
style={`width: ${GIFT_STICKER_SIZE}px; height: ${GIFT_STICKER_SIZE}px`}
onMouseMove={handleMouseMove}
>
<StickerView
containerRef={stickerRef}
sticker={sticker}
size={GIFT_STICKER_SIZE}
shouldPreloadPreview
shouldLoop={true}
/>
</div>
)}
<h2 className={styles.title}>

View File

@ -75,6 +75,16 @@
font-size: 0.75rem !important;
}
.premiumRequired {
outline: 0.125rem solid #D18D21;
outline-offset: -0.125rem;
&:focus-visible {
outline: 0.125rem solid #D18D21;
outline-offset: -0.125rem;
}
}
.amount {
margin-top: 0.0625rem; // It just refuses to be centered
}

View File

@ -1,5 +1,5 @@
import { memo, useMemo, useRef, useState } from '../../../lib/teact/teact';
import { getActions } from '../../../global';
import { getActions, withGlobal } from '../../../global';
import type {
ApiStarGift,
@ -7,6 +7,7 @@ import type {
} from '../../../api/types';
import { STARS_CURRENCY_CODE, TON_CURRENCY_CODE } from '../../../config';
import { selectIsCurrentUserPremium } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { formatStarsAsIcon, formatTonAsIcon } from '../../../util/localization/format';
import { getStickerFromGift } from '../../common/helpers/gifts';
@ -30,12 +31,16 @@ export type OwnProps = {
isResale?: boolean;
};
type StateProps = {
isCurrentUserPremium?: boolean;
};
const GIFT_STICKER_SIZE = 90;
function GiftItemStar({
gift, observeIntersection, onClick, isResale,
}: OwnProps) {
const { openGiftInfoModal } = getActions();
gift, observeIntersection, onClick, isResale, isCurrentUserPremium,
}: OwnProps & StateProps) {
const { openGiftInfoModal, openPremiumModal, showNotification } = getActions();
const ref = useRef<HTMLDivElement>();
const stickerRef = useRef<HTMLDivElement>();
@ -69,6 +74,9 @@ function GiftItemStar({
? lang.number(resellMinStars) + '+' : priceInfo?.amount || 0;
const isLimited = !isGiftUnique && Boolean(regularGift?.isLimited);
const isSoldOut = !isGiftUnique && Boolean(regularGift?.isSoldOut);
const isPremiumRequired = Boolean(gift?.requirePremium);
const isUserLimitReached = Boolean(regularGift?.limitedPerUser && !regularGift?.perUserRemains);
const perUserTotal = regularGift?.perUserTotal;
const handleGiftClick = useLastCallback(() => {
if (isSoldOut && !isResale) {
@ -76,6 +84,25 @@ function GiftItemStar({
return;
}
if (isUserLimitReached) {
showNotification({
message: lang('NotificationGiftsLimit', {
count: perUserTotal,
}, {
withMarkdown: true,
withNodes: true,
}),
});
return;
}
if (isPremiumRequired && !isCurrentUserPremium) {
openPremiumModal({
gift,
});
return;
}
onClick(gift, isResale ? 'resell' : 'original');
});
@ -116,6 +143,9 @@ function GiftItemStar({
/>
);
}
if (isPremiumRequired) {
return <GiftRibbon color="orange" text={lang('LimitPremium')} />;
}
if (isResale) {
return <GiftRibbon color="green" text={lang('GiftRibbonResale')} />;
}
@ -126,7 +156,7 @@ function GiftItemStar({
return <GiftRibbon color="blue" text={lang('GiftLimited')} />;
}
return undefined;
}, [isGiftUnique, isResale, gift, isSoldOut, isLimited, lang, giftNumber]);
}, [isGiftUnique, isResale, gift, isSoldOut, isLimited, lang, giftNumber, isPremiumRequired]);
useOnIntersect(ref, observeIntersection, (entry) => {
const visible = entry.isIntersecting;
@ -136,7 +166,12 @@ function GiftItemStar({
return (
<div
ref={ref}
className={buildClassName(styles.container, styles.starGift, 'starGiftItem')}
className={buildClassName(
styles.container,
styles.starGift,
'starGiftItem',
isPremiumRequired && styles.premiumRequired,
)}
tabIndex={0}
role="button"
onClick={handleGiftClick}
@ -178,4 +213,12 @@ function GiftItemStar({
);
}
export default memo(GiftItemStar);
export default memo(
withGlobal<OwnProps>((global): StateProps => {
const isCurrentUserPremium = selectIsCurrentUserPremium(global);
return {
isCurrentUserPremium,
};
})(GiftItemStar),
);

View File

@ -484,7 +484,7 @@ addActionHandler('closePremiumModal', (global, actions, payload): ActionReturnTy
addActionHandler('openPremiumModal', async (global, actions, payload): Promise<void> => {
const {
initialSection, fromUserId, isSuccess, isGift, monthsAmount, toUserId,
initialSection, fromUserId, isSuccess, isGift, monthsAmount, toUserId, gift,
tabId = getCurrentTabId(),
} = payload || {};
@ -505,6 +505,7 @@ addActionHandler('openPremiumModal', async (global, actions, payload): Promise<v
isGift,
monthsAmount,
isSuccess,
gift,
},
}, tabId);
setGlobal(global);

View File

@ -2435,6 +2435,7 @@ export interface ActionPayloads {
isSuccess?: boolean;
isGift?: boolean;
monthsAmount?: number;
gift?: ApiStarGift;
} & WithTabId) | undefined;
closePremiumModal: WithTabId | undefined;

View File

@ -640,6 +640,7 @@ export type TabState = {
isGift?: boolean;
monthsAmount?: number;
isSuccess?: boolean;
gift?: ApiStarGift;
};
giveawayModal?: {

View File

@ -1608,6 +1608,8 @@ export interface LangPair {
'TitleAgeCheckFailed': undefined;
'TitleAgeCheckSuccess': undefined;
'ButtonAgeVerification': undefined;
'GiftRibbonPremium': undefined;
'PremiumGiftHeader': undefined;
'PriceInStars': undefined;
'PriceInTON': undefined;
'OnlyAcceptTON': undefined;
@ -2793,6 +2795,12 @@ export interface LangPairWithVariables<V = LangVariable> {
'ButtonSensitiveAlways': {
'years': V;
};
'NotificationGiftsLimit': {
'count': V;
};
'DescriptionGiftPremiumRequired': {
'count': V;
};
'DescriptionComposerGiftMinimumCurrencyPrice': {
'amount': V;
};