Stars: Support bot subscriptions (#5261)

This commit is contained in:
zubiden 2024-12-06 19:44:04 +04:00 committed by Alexander Zinchuk
parent c6d30e8cb8
commit 34b3df1ca6
22 changed files with 226 additions and 41 deletions

View File

@ -233,6 +233,7 @@ export function buildApiInvoice(invoice: GramJs.Invoice): ApiInvoice {
phoneToProvider,
shippingAddressRequested,
flexible,
subscriptionPeriod,
} = invoice;
const mappedPrices: ApiLabeledPrice[] = prices.map(({ label, amount }) => ({
@ -258,6 +259,7 @@ export function buildApiInvoice(invoice: GramJs.Invoice): ApiInvoice {
isPhoneSentToProvider: phoneToProvider,
isShippingAddressRequested: shippingAddressRequested,
isFlexible: flexible,
subscriptionPeriod,
};
}
@ -559,9 +561,14 @@ export function buildApiStarsTransaction(transaction: GramJs.StarsTransaction):
export function buildApiStarsSubscription(subscription: GramJs.StarsSubscription): ApiStarsSubscription {
const {
id, peer, pricing, untilDate, canRefulfill, canceled, chatInviteHash, missingBalance,
id, peer, pricing, untilDate, canRefulfill, canceled, chatInviteHash, missingBalance, botCanceled, photo, title,
invoiceSlug,
} = subscription;
if (photo) {
addWebDocumentToLocalDb(photo);
}
return {
id,
peerId: getApiChatIdFromMtpPeer(peer),
@ -571,6 +578,10 @@ export function buildApiStarsSubscription(subscription: GramJs.StarsSubscription
canRefulfill,
hasMissingBalance: missingBalance,
chatInviteHash,
hasBotCancelled: botCanceled,
title,
photo: photo && buildApiWebDocument(photo),
invoiceSlug,
};
}

View File

@ -342,6 +342,7 @@ export interface ApiInvoice {
currency: string;
isTest?: boolean;
isRecurring?: boolean;
subscriptionPeriod?: number;
termsUrl?: string;
maxTipAmount?: number;
suggestedTipAmounts?: number[];

View File

@ -375,6 +375,10 @@ export interface ApiStarsSubscription {
canRefulfill?: true;
hasMissingBalance?: true;
chatInviteHash?: string;
hasBotCancelled?: true;
title?: string;
photo?: ApiWebDocument;
invoiceSlug?: string;
}
export interface ApiStarTopupOption {

View File

@ -1346,6 +1346,7 @@
"LimitedGiftsCategory" = "Limited";
"PremiumGiftDescription" = "Premium";
"SendPaidReaction" = "Send ⭐️{amount}";
"StarsPay" = "Confirm and Pay {amount}";
"StarsReactionTerms" = "By sending Stars you agree to the {link}";
"StarsReactionLinkText" = "Terms of Service";
"StarsReactionLink" = "https://telegram.org/tos/stars";
@ -1392,3 +1393,6 @@
"BotSuggestedStatus" = "Do you want to set this emoji status suggested by **{bot}**?";
"BotSuggestedStatusTitle" = "Set Emoji Status";
"BotSuggestedStatusUpdated" = "Your emoji status is updated.";
"StarsSubscribeBotText_one" = "Do you want to subscribe to **{name}** in **{bot}** for **{amount}** star per month?"
"StarsSubscribeBotText_other" = "Do you want to subscribe to **{name}** in **{bot}** for **{amount}** stars per month?"
"StarsSubscribeBotButtonMonth" = "Subscribe for {amount} / month";

View File

@ -1,18 +1,20 @@
import React, { memo } from '../../lib/teact/teact';
import type { ApiPeer } from '../../api/types';
import type { ApiPeer, ApiWebDocument } from '../../api/types';
import type { CustomPeer } from '../../types';
import type { IconName } from '../../types/icons';
import buildClassName from '../../util/buildClassName';
import Avatar from './Avatar';
import Avatar, { type AvatarSize } from './Avatar';
import Icon from './icons/Icon';
import styles from './PeerBadge.module.scss';
type OwnProps = {
peer: ApiPeer | CustomPeer;
peer?: ApiPeer | CustomPeer;
avatarWebPhoto?: ApiWebDocument;
avatarSize?: AvatarSize;
text?: string;
badgeText?: string;
badgeIcon?: IconName;
@ -24,7 +26,9 @@ type OwnProps = {
};
const PeerBadge = ({
peer,
peer: avatarPeer,
avatarWebPhoto,
avatarSize,
text,
badgeText,
badgeIcon,
@ -40,7 +44,7 @@ const PeerBadge = ({
onClick={onClick}
>
<div className={styles.top}>
<Avatar size="large" peer={peer} />
<Avatar size={avatarSize} peer={avatarPeer} webPhoto={avatarWebPhoto} />
{badgeText && (
<div className={buildClassName(styles.badge, badgeClassName)}>
{badgeIcon && <Icon name={badgeIcon} className={badgeIconClassName} />}

View File

@ -109,6 +109,10 @@
unicode-bidi: plaintext;
}
.botItem {
margin-bottom: 0.75rem;
}
.hiddenHeader {
transform: translateY(-100%);
}
@ -191,15 +195,9 @@
transform: translate(-50%, -50%);
}
.paymentAmount {
display: flex;
line-height: 1.125;
gap: 0.125rem;
}
.paymentButton {
display: flex;
gap: 0.125rem;
align-items: center;
margin-top: 1rem;
}
@ -225,3 +223,20 @@
margin-top: 0.5rem;
color: var(--color-text-secondary);
}
.amountBadge {
background-image: var(--stars-gradient);
}
.loadMore {
justify-content: flex-start;
gap: 0.75rem;
}
.loadMoreIcon {
display: grid;
place-items: center;
width: 2.75rem;
height: 2.75rem;
font-size: 1.5rem;
}

View File

@ -56,7 +56,7 @@ const StarsBalanceModal = ({
modal, starsBalanceState, canBuyPremium,
}: OwnProps & StateProps) => {
const {
closeStarsBalanceModal, loadStarsTransactions, openStarsGiftingPickerModal, openInvoice,
closeStarsBalanceModal, loadStarsTransactions, loadStarsSubscriptions, openStarsGiftingPickerModal, openInvoice,
} = getActions();
const { balance, history, subscriptions } = starsBalanceState || {};
@ -154,6 +154,10 @@ const StarsBalanceModal = ({
});
});
const handleLoadMoreSubscriptions = useLastCallback(() => {
loadStarsSubscriptions();
});
const openStarsGiftingPickerModalHandler = useLastCallback(() => {
openStarsGiftingPickerModal({});
});
@ -240,6 +244,19 @@ const StarsBalanceModal = ({
subscription={subscription}
/>
))}
{subscriptions?.nextOffset && (
<Button
isText
disabled={subscriptions.isLoading}
size="smaller"
noForcedUpperCase
className={styles.loadMore}
onClick={handleLoadMoreSubscriptions}
>
<Icon name="down" className={styles.loadMoreIcon} />
{oldLang('StarMySubscriptionsExpand')}
</Button>
)}
</div>
</div>
)}

View File

@ -11,6 +11,8 @@ import {
selectChat, selectChatMessage, selectUser,
} from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { formatStarsAsIcon } from '../../../util/localization/format';
import { formatInteger } from '../../../util/textFormat';
import renderText from '../../common/helpers/renderText';
import useFlag from '../../../hooks/useFlag';
@ -21,6 +23,8 @@ import usePrevious from '../../../hooks/usePrevious';
import Avatar from '../../common/Avatar';
import StarIcon from '../../common/icons/StarIcon';
import PeerBadge from '../../common/PeerBadge';
import PickerSelectedItem from '../../common/pickers/PickerSelectedItem';
import SafeLink from '../../common/SafeLink';
import Button from '../../ui/Button';
import Modal from '../../ui/Modal';
@ -58,6 +62,8 @@ const StarPaymentModal = ({
const { form, subscriptionInfo } = renderingModal || {};
const amount = form?.invoice?.totalAmount || subscriptionInfo?.subscriptionPricing?.amount;
const isBotSubscription = Boolean(form?.invoice.subscriptionPeriod);
const canShowPeerItem = !subscriptionInfo?.subscriptionPricing;
const photo = form?.photo;
@ -102,8 +108,21 @@ const StarPaymentModal = ({
});
}
if (isBotSubscription) {
return lang('StarsSubscribeBotText', {
name: form.title,
amount,
bot: botName,
}, {
pluralValue: amount!,
});
}
return oldLang('Stars.Transfer.Info', [form!.title, botName, starsText]);
}, [renderingModal, bot, oldLang, amount, paidMediaMessage, subscriptionInfo, form, paidMediaChat, lang]);
}, [
renderingModal?.inputInvoice, bot, oldLang, amount, paidMediaMessage, subscriptionInfo, isBotSubscription, form,
paidMediaChat, lang,
]);
const disclaimerText = useMemo(() => {
if (subscriptionInfo) {
@ -160,25 +179,31 @@ const StarPaymentModal = ({
<StarIcon type="gold" size="adaptive" className={styles.avatarStar} />
</>
) : (
<>
<Avatar peer={bot} size="giant" />
{photo && <Avatar className={styles.paymentPhoto} webPhoto={photo} size="giant" />}
</>
<PeerBadge
peer={!photo ? bot : undefined}
avatarWebPhoto={photo}
avatarSize="giant"
badgeIcon="star"
badgeText={formatInteger(amount!)}
badgeClassName={styles.amountBadge}
className={styles.paymentPhoto}
/>
)}
<img className={styles.paymentImageBackground} src={StarsBackground} alt="" draggable={false} />
</div>
<h2 className={styles.headerText}>
{inviteCustomPeer ? oldLang('StarsSubscribeTitle') : oldLang('StarsConfirmPurchaseTitle')}
</h2>
{canShowPeerItem && <PickerSelectedItem className={styles.botItem} peerId={form?.botId} />}
<div className={styles.description}>
{renderText(descriptionText, ['simple_markdown', 'emoji'])}
</div>
<Button className={styles.paymentButton} size="smaller" onClick={handlePayment} isLoading={isLoading}>
{oldLang('Stars.Transfer.Pay')}
<div className={styles.paymentAmount}>
{amount}
<StarIcon className={styles.paymentButtonStar} size="small" />
</div>
{lang(isBotSubscription ? 'StarsSubscribeBotButtonMonth' : 'StarsPay', {
amount: formatStarsAsIcon(lang, amount!, true),
}, {
withNodes: true,
})}
</Button>
{disclaimerText && (
<div className={buildClassName(styles.disclaimer, styles.smallerText)}>

View File

@ -5,7 +5,7 @@ import { buildStarsTransactionCustomPeer } from '../../../../global/helpers/paym
export function getTransactionTitle(lang: OldLangFn, transaction: ApiStarsTransaction) {
if (transaction.extendedMedia) return lang('StarMediaPurchase');
if (transaction.subscriptionPeriod) return lang('StarSubscriptionPurchase');
if (transaction.subscriptionPeriod) return transaction.title || lang('StarSubscriptionPurchase');
if (transaction.isReaction) return lang('StarsReactionsSent');
if (transaction.giveawayPostId) return lang('StarsGiveawayPrizeReceived');
if (transaction.isMyGift) return lang('StarsGiftSent');

View File

@ -19,6 +19,13 @@
flex-grow: 1;
}
.subtitle {
display: flex;
align-items: center;
gap: 0.125rem;
font-size: 0.875rem;
}
.status {
display: flex;
flex-direction: column;
@ -37,7 +44,7 @@
font-weight: 500;
}
.title, .description {
.title, .description, .subtitle {
margin-bottom: 0;
}

View File

@ -33,7 +33,7 @@ function selectProvidedPeer(peerId: string) {
const StarsSubscriptionItem = ({ subscription }: OwnProps) => {
const { openStarsSubscriptionModal } = getActions();
const {
peerId, pricing, until, isCancelled,
peerId, pricing, until, isCancelled, title, photo,
} = subscription;
const lang = useOldLang();
@ -57,6 +57,12 @@ const StarsSubscriptionItem = ({ subscription }: OwnProps) => {
</div>
<div className={styles.info}>
<h3 className={styles.title}>{getSenderTitle(lang, peer)}</h3>
{title && (
<p className={styles.subtitle}>
{photo && <Avatar webPhoto={photo} size="micro" />}
{title}
</p>
)}
<p className={styles.description}>
{lang(
hasExpired ? 'StarsSubscriptionExpired'

View File

@ -21,6 +21,13 @@
margin-bottom: 0;
}
.title {
text-align: center;
text-wrap: balance;
font-size: 1.75rem;
line-height: 1.25;
}
.amount {
display: flex;
align-items: center;

View File

@ -8,6 +8,7 @@ import type {
import type { TabState } from '../../../../global/types';
import { STARS_ICON_PLACEHOLDER } from '../../../../config';
import { isApiPeerUser } from '../../../../global/helpers/peers';
import {
selectPeer,
} from '../../../../global/selectors';
@ -46,6 +47,7 @@ const StarsSubscriptionModal: FC<OwnProps & StateProps> = ({
changeStarsSubscription,
checkChatInvite,
loadStarStatus,
openInvoice,
} = getActions();
const oldLang = useOldLang();
const lang = useLang();
@ -69,7 +71,8 @@ const StarsSubscriptionModal: FC<OwnProps & StateProps> = ({
return 'renew';
}
if (!isActive) {
const canRestart = subscription.chatInviteHash || subscription.invoiceSlug;
if (!isActive && canRestart) {
return 'restart';
}
@ -87,7 +90,14 @@ const StarsSubscriptionModal: FC<OwnProps & StateProps> = ({
break;
}
case 'restart': {
checkChatInvite({ hash: subscription.chatInviteHash! });
if (subscription.chatInviteHash) {
checkChatInvite({ hash: subscription.chatInviteHash });
} else if (subscription.invoiceSlug) {
openInvoice({
type: 'slug',
slug: subscription.invoiceSlug,
});
}
loadStarStatus();
break;
}
@ -109,13 +119,15 @@ const StarsSubscriptionModal: FC<OwnProps & StateProps> = ({
}
const {
pricing, until, isCancelled, canRefulfill,
pricing, until, isCancelled, canRefulfill, photo, title, hasBotCancelled,
} = subscription;
const isBotSubscription = isApiPeerUser(peer);
const header = (
<div className={buildClassName(styles.header, styles.starsHeader)}>
<div className={styles.avatarWrapper}>
<Avatar peer={peer} size="jumbo" />
<Avatar peer={!photo ? peer : undefined} webPhoto={photo} size="jumbo" />
<StarIcon className={styles.subscriptionStar} type="gold" size="adaptive" />
</div>
<img
@ -124,7 +136,7 @@ const StarsSubscriptionModal: FC<OwnProps & StateProps> = ({
alt=""
draggable={false}
/>
<h1 className={styles.title}>{oldLang('StarsSubscriptionTitle')}</h1>
<h1 className={styles.title}>{title || oldLang('StarsSubscriptionTitle')}</h1>
<p className={styles.amount}>
{lang('StarsPerMonth', {
amount: pricing.amount,
@ -141,10 +153,17 @@ const StarsSubscriptionModal: FC<OwnProps & StateProps> = ({
const tableData: TableData = [];
tableData.push([
oldLang('StarsSubscriptionChannel'),
oldLang(isBotSubscription ? 'StarsSubscriptionBot' : 'StarsSubscriptionChannel'),
{ chatId: peer.id },
]);
if (title) {
tableData.push([
oldLang('StarsSubscriptionBotProduct'),
title,
]);
}
const hasExpired = until < Date.now() / 1000;
tableData.push([
oldLang(hasExpired ? 'StarsSubscriptionUntilExpired'
@ -162,7 +181,9 @@ const StarsSubscriptionModal: FC<OwnProps & StateProps> = ({
<span className={styles.footer}>
<p className={styles.secondary}>{footerTos}</p>
{isCancelled && (
<p className={styles.danger}>{oldLang('StarsSubscriptionCancelledText')}</p>
<p className={styles.danger}>
{oldLang(hasBotCancelled ? 'StarsSubscriptionBotCancelledText' : 'StarsSubscriptionCancelledText')}
</p>
)}
{canRefulfill && (
<p className={styles.secondary}>

View File

@ -37,6 +37,13 @@
margin-bottom: 0;
}
.title {
text-align: center;
text-wrap: balance;
font-size: 1.75rem;
line-height: 1.25;
}
.tid {
font-family: var(--font-family-monospace);
font-size: 0.875rem;

View File

@ -10,6 +10,7 @@ import {
appendStarsSubscriptions,
appendStarsTransactions,
updateStarsBalance,
updateStarsSubscriptionLoading,
} from '../../reducers';
import {
selectPeer,
@ -179,6 +180,9 @@ addActionHandler('loadStarsSubscriptions', async (global): Promise<void> => {
const offset = subscriptions?.nextOffset;
if (subscriptions && !offset) return; // Already loaded all
global = updateStarsSubscriptionLoading(global, true);
setGlobal(global);
const result = await callApi('fetchStarsSubscriptions', {
offset: offset || '',
});

View File

@ -42,7 +42,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
case 'updateStarPaymentStateCompleted': {
const { paymentState, tabId } = update;
const { inputInvoice, subscriptionInfo } = paymentState;
const { inputInvoice, subscriptionInfo, form } = paymentState;
if (inputInvoice?.type === 'chatInviteSubscription' && subscriptionInfo) {
const amount = subscriptionInfo.subscriptionPricing!.amount;
@ -57,6 +57,19 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
});
}
if (form?.invoice.subscriptionPeriod) {
const amount = form.invoice.totalAmount;
actions.showNotification({
tabId,
title: langProvider.oldTranslate('StarsSubscriptionCompleted'),
message: langProvider.oldTranslate('StarsSubscriptionCompletedText', [
amount,
form.title,
], undefined, amount),
icon: 'star',
});
}
if (inputInvoice?.type === 'giftcode') {
if (!inputInvoice.userIds) {
return;

View File

@ -6,7 +6,7 @@ import type {
} from '../../api/types';
import type { PaymentStep, ShippingOption } from '../../types';
import type {
GlobalState, StarsTransactionType, TabArgs, TabState,
GlobalState, StarsSubscriptions, StarsTransactionType, TabArgs, TabState,
} from '../types';
import { getCurrentTabId } from '../../util/establishMultitabRole';
@ -182,7 +182,7 @@ export function appendStarsSubscriptions<T extends GlobalState>(
const newObject = {
list: (global.stars.subscriptions?.list || []).concat(subscriptions),
nextOffset,
};
} satisfies StarsSubscriptions;
return {
...global,
@ -193,6 +193,26 @@ export function appendStarsSubscriptions<T extends GlobalState>(
};
}
export function updateStarsSubscriptionLoading<T extends GlobalState>(
global: T, isLoading: boolean,
): T {
const subscriptions = global.stars?.subscriptions;
if (!subscriptions) {
return global;
}
return {
...global,
stars: {
...global.stars,
subscriptions: {
...subscriptions,
isLoading,
},
},
};
}
export function openStarsTransactionModal<T extends GlobalState>(
global: T, transaction: ApiStarsTransaction, ...[tabId = getCurrentTabId()]: TabArgs<T>
): T {

View File

@ -190,6 +190,7 @@ export type StarsTransactionHistory = Record<StarsTransactionType, {
export type StarsSubscriptions = {
list: ApiStarsSubscription[];
nextOffset?: string;
isLoading?: boolean;
};
export type ConfettiStyle = 'poppers' | 'top-down';

View File

@ -292,8 +292,10 @@ body:not(.is-ios) {
vertical-align: text-top;
}
.star-amount-icon {
margin-inline-start: 0.5em; // Prevent sticking to the text without using `white-space: pre`
// Increase specificity to override the default icon style
.star-amount-icon.star-amount-icon {
line-height: inherit; // Vertical centring
margin-inline-start: 0.375em; // Prevent sticking to the text without using `white-space: pre`
}
.shared-canvas-container {

View File

@ -1509,6 +1509,9 @@ export interface LangPairWithVariables<V extends unknown = LangVariable> {
'SendPaidReaction': {
'amount': V;
};
'StarsPay': {
'amount': V;
};
'StarsReactionTerms': {
'link': V;
};
@ -1555,6 +1558,9 @@ export interface LangPairWithVariables<V extends unknown = LangVariable> {
'BotSuggestedStatus': {
'bot': V;
};
'StarsSubscribeBotButtonMonth': {
'amount': V;
};
}
export interface LangPairPlural {
@ -1731,6 +1737,11 @@ export interface LangPairPluralWithVariables<V extends unknown = LangVariable> {
'chat': V;
'amount': V;
};
'StarsSubscribeBotText': {
'name': V;
'bot': V;
'amount': V;
};
}
export type RegularLangKey = keyof LangPair;
export type RegularLangKeyWithVariables = keyof LangPairWithVariables;

View File

@ -76,6 +76,7 @@ const READABLE_ERROR_MESSAGES: Record<string, string> = {
PROVIDER_ACCOUNT_TIMEOUT: 'Request to the payment provider has expired',
STARGIFT_CONVERT_TOO_OLD: 'This gift no longer can be converted to Stars',
SUBSCRIPTION_ALREADY_ACTIVE: 'You are already subscribed',
PEERS_LIST_EMPTY: 'No chats are added to the list',

View File

@ -4,17 +4,21 @@ import type { LangFn } from './types';
import { STARS_ICON_PLACEHOLDER } from '../../config';
import Icon from '../../components/common/icons/Icon';
import StarIcon from '../../components/common/icons/StarIcon';
export function formatStarsAsText(lang: LangFn, amount: number) {
return lang('StarsAmountText', { amount }, { pluralValue: amount });
}
export function formatStarsAsIcon(lang: LangFn, amount: number) {
export function formatStarsAsIcon(lang: LangFn, amount: number, asFont?: boolean) {
const icon = asFont
? <Icon name="star" className="star-amount-icon" />
: <StarIcon type="gold" className="star-amount-icon" size="adaptive" />;
return lang('StarsAmount', { amount }, {
withNodes: true,
specialReplacement: {
[STARS_ICON_PLACEHOLDER]: <StarIcon type="gold" className="star-amount-icon" size="adaptive" />,
[STARS_ICON_PLACEHOLDER]: icon,
},
});
}