Introduce Gift Premium (#1967)

This commit is contained in:
Alexander Zinchuk 2022-08-31 15:00:36 +02:00
parent 47c0bc206f
commit 1eb030b2d8
37 changed files with 974 additions and 249 deletions

View File

@ -813,6 +813,7 @@ function buildAction(
let type: ApiAction['type'] = 'other';
let photo: ApiPhoto | undefined;
let score: number | undefined;
let months: number | undefined;
const targetUserIds = 'users' in action
? action.users && action.users.map((id) => buildApiPeerId(id, 'user'))
@ -944,6 +945,19 @@ function buildAction(
} else if (action instanceof GramJs.MessageActionWebViewDataSent) {
text = 'Notification.WebAppSentData';
translationValues.push(action.text);
} else if (action instanceof GramJs.MessageActionGiftPremium) {
text = isOutgoing ? 'ActionGiftOutbound' : 'ActionGiftInbound';
if (isOutgoing) {
translationValues.push('%gift_payment_amount%');
} else {
translationValues.push('%action_origin%', '%gift_payment_amount%');
}
if (targetPeerId) {
targetUserIds.push(targetPeerId);
}
currency = action.currency;
amount = action.amount.toJSNumber();
months = action.months;
} else {
text = 'ChatList.UnsupportedMessage';
}
@ -965,6 +979,7 @@ function buildAction(
call,
phoneCall,
score,
months,
};
}

View File

@ -41,7 +41,7 @@ export {
fetchStickerSets, fetchRecentStickers, fetchFavoriteStickers, fetchFeaturedStickers,
faveSticker, fetchStickers, fetchSavedGifs, saveGif, searchStickers, installStickerSet, uninstallStickerSet,
searchGifs, fetchAnimatedEmojis, fetchStickersForEmoji, fetchEmojiKeywords, fetchAnimatedEmojiEffects,
removeRecentSticker, clearRecentStickers,
removeRecentSticker, clearRecentStickers, fetchPremiumGifts,
} from './symbols';
export {

View File

@ -163,6 +163,21 @@ export async function fetchAnimatedEmojiEffects() {
};
}
export async function fetchPremiumGifts() {
const result = await invokeRequest(new GramJs.messages.GetStickerSet({
stickerset: new GramJs.InputStickerSetPremiumGifts(),
}));
if (!(result instanceof GramJs.messages.StickerSet)) {
return undefined;
}
return {
set: buildStickerSet(result.set),
stickers: processStickerResult(result.documents),
};
}
export async function searchStickers({ query, hash = '0' }: { query: string; hash?: string }) {
const result = await invokeRequest(new GramJs.messages.SearchStickerSets({
q: query,

View File

@ -235,6 +235,7 @@ export interface ApiAction {
call?: Partial<ApiGroupCall>;
phoneCall?: PhoneCallAction;
score?: number;
months?: number;
}
export interface ApiWebPage {

Binary file not shown.

Binary file not shown.

1
src/assets/mir.svg Normal file
View File

@ -0,0 +1 @@
<svg width="100" height="80" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill="#fff" d="M0 0h100v80H0z"/><g clip-path="url(#a)"><path d="M31.558 29.005v.01c-.01 0-3.126-.01-3.957 2.967-.761 2.73-2.908 10.265-2.967 10.473h-.594s-2.196-7.703-2.967-10.482c-.832-2.978-3.957-2.968-3.957-2.968H10V51.65h7.119V38.2h.593l4.155 13.45h4.943l4.155-13.44h.593v13.44h7.119V29.005h-7.119Zm26.006 0s-2.087.188-3.066 2.374l-5.042 11.076h-.594v-13.45h-7.119V51.65h6.724s2.186-.198 3.165-2.374L56.575 38.2h.593v13.45h7.12V29.005h-6.724Zm9.889 10.284v12.362h7.119v-7.218h7.712a7.703 7.703 0 0 0 7.268-5.14h-22.1v-.004Z" fill="#4DB45E"/><path d="M82.287 29.005H66.454c.791 4.313 4.026 7.772 8.207 8.9.97.262 1.972.395 2.977.395h12.204A7.604 7.604 0 0 0 90 36.718a7.711 7.711 0 0 0-7.713-7.713Z" fill="url(#b)"/></g><defs><linearGradient id="b" x1="66.454" y1="33.653" x2="90" y2="33.653" gradientUnits="userSpaceOnUse"><stop offset=".3" stop-color="#00B4E6"/><stop offset="1" stop-color="#088CCB"/></linearGradient><clipPath id="a"><path fill="#fff" transform="translate(10 29)" d="M0 0h80v22.656H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -13,6 +13,7 @@ export { default as BotTrustModal } from '../components/main/BotTrustModal';
export { default as BotAttachModal } from '../components/main/BotAttachModal';
export { default as DeleteFolderDialog } from '../components/main/DeleteFolderDialog';
export { default as PremiumMainModal } from '../components/main/premium/PremiumMainModal';
export { default as GiftPremiumModal } from '../components/main/premium/GiftPremiumModal';
export { default as PremiumLimitReachedModal } from '../components/main/premium/common/PremiumLimitReachedModal';
export { default as AboutAdsModal } from '../components/common/AboutAdsModal';

View File

@ -1,17 +1,20 @@
const VISA = /^4[0-9]{12}(?:[0-9]{1,3})?$/;
const MASTERCARD1 = /^5[1-5][0-9]{11,14}$/;
const MASTERCARD2 = /^2[2-7][0-9]{11,14}$/;
const MIR = /^220[0-4]/;
export enum CardType {
Default,
Visa,
Mastercard,
Mir,
}
const cards: Record<number, string> = {
[CardType.Default]: '',
[CardType.Visa]: 'visa',
[CardType.Mastercard]: 'mastercard',
[CardType.Mir]: 'mir',
};
export function detectCardType(cardNumber: string): number {
@ -19,6 +22,9 @@ export function detectCardType(cardNumber: string): number {
if (VISA.test(cardNumber)) {
return CardType.Visa;
}
if (MIR.test(cardNumber)) {
return CardType.Mir;
}
if (MASTERCARD1.test(cardNumber) || MASTERCARD2.test(cardNumber)) {
return CardType.Mastercard;
}

View File

@ -55,6 +55,15 @@ export function renderActionMessageText(
if (translationKey.includes('ScoredInGame')) { // Translation hack for games
unprocessed = unprocessed.replace('un1', '%action_origin%').replace('un2', '%message%');
}
if (translationKey === 'ActionGiftOutbound') { // Translation hack for Premium Gift
unprocessed = unprocessed.replace('un2', '%gift_payment_amount%').replace(/\*\*/g, '');
}
if (translationKey === 'ActionGiftInbound') { // Translation hack for Premium Gift
unprocessed = unprocessed
.replace('un1', '%action_origin%')
.replace('un2', '%gift_payment_amount%')
.replace(/\*\*/g, '');
}
let processed: TextPart[];
if (unprocessed.includes('%payment_amount%')) {
@ -80,6 +89,16 @@ export function renderActionMessageText(
unprocessed = processed.pop() as string;
content.push(...processed);
if (unprocessed.includes('%gift_payment_amount%')) {
processed = processPlaceholder(
unprocessed,
'%gift_payment_amount%',
formatCurrency(amount!, currency!, lang.code),
);
unprocessed = processed.pop() as string;
content.push(...processed);
}
if (unprocessed.includes('%score%')) {
processed = processPlaceholder(
unprocessed,

View File

@ -169,6 +169,7 @@ const Main: FC<StateProps> = ({
loadCountryList,
loadAvailableReactions,
loadStickerSets,
loadPremiumGifts,
loadAddedStickers,
loadFavoriteStickers,
ensureTimeFormat,
@ -206,10 +207,12 @@ const Main: FC<StateProps> = ({
loadEmojiKeywords({ language: BASE_EMOJI_KEYWORD_LANG });
loadAttachMenuBots();
loadContactList();
loadPremiumGifts();
}
}, [
lastSyncTime, loadAnimatedEmojis, loadEmojiKeywords, loadNotificationExceptions, loadNotificationSettings,
loadTopInlineBots, updateIsOnline, loadAvailableReactions, loadAppConfig, loadAttachMenuBots, loadContactList,
loadPremiumGifts,
]);
// Language-based API calls

View File

@ -0,0 +1,110 @@
.wrapper {
position: relative;
display: block;
padding-inline: 4.5rem 1rem;
margin-bottom: 1.5rem;
border-radius: var(--border-radius-default);
background: var(--color-background);
box-shadow: 0 0 0 0.0625rem var(--color-borders-input);
cursor: pointer;
line-height: 1.5rem;
}
.active {
box-shadow: 0 0 0 0.125rem var(--color-primary);
}
.input {
position: absolute;
z-index: var(--z-below);
opacity: 0;
&:checked ~ .content {
&::before {
border-color: var(--color-primary);
}
&::after {
opacity: 1;
}
}
}
.content {
display: grid;
grid-template-areas: "left_top right" "left_bottom right";
grid-template-columns: 1fr auto;
justify-content: start;
padding: 0.5rem 0;
gap: 0.25rem;
&::before,
&::after {
content: "";
display: block;
position: absolute;
inset-inline-start: 1.0625rem;
top: 50%;
width: 1.25rem;
height: 1.25rem;
transform: translateY(-50%);
}
&::before {
border: 2px solid var(--color-borders-input);
border-radius: 50%;
background-color: var(--color-background);
opacity: 1;
transition: border-color 0.1s ease, opacity 0.1s ease;
}
&::after {
inset-inline-start: 1.375rem;
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
background: var(--color-primary);
opacity: 0;
transition: opacity 0.1s ease;
}
}
.month {
grid-area: left_top;
white-space: nowrap;
}
.perMonth {
grid-area: left_bottom;
align-self: end;
display: flex;
flex-direction: row-reverse;
margin-inline-end: auto;
font-size: 0.875rem;
color: var(--color-text-secondary);
@media (max-width: 450px) {
flex-direction: column-reverse;
}
}
.amount {
grid-area: right;
align-self: center;
justify-self: end;
padding-inline-start: 1.5rem;
color: var(--color-text-secondary);
}
.discount {
color: var(--color-white);
background: var(--color-primary);
border-radius: var(--border-radius-default-small);
padding: 0 0.5rem;
unicode-bidi: plaintext;
margin-inline-end: 0.5rem;
align-self: baseline;
}

View File

@ -0,0 +1,64 @@
import type { ChangeEvent } from 'react';
import React, { memo, useCallback, useMemo } from '../../../lib/teact/teact';
import type { FC } from '../../../lib/teact/teact';
import type { ApiPremiumGiftOption } from '../../../api/types';
import { formatCurrency } from '../../../util/formatCurrency';
import buildClassName from '../../../util/buildClassName';
import useLang from '../../../hooks/useLang';
import styles from './GiftOption.module.scss';
type OwnProps = {
option: ApiPremiumGiftOption;
checked?: boolean;
fullMonthlyAmount?: number;
onChange: (month: number) => void;
};
const GiftOption: FC<OwnProps> = ({
option, checked, fullMonthlyAmount, onChange,
}) => {
const lang = useLang();
const { months, amount, currency } = option;
const perMonth = Math.floor(amount / months);
const discount = useMemo(() => {
return fullMonthlyAmount && fullMonthlyAmount > perMonth
? Math.ceil(100 - perMonth / (fullMonthlyAmount / 100))
: undefined;
}, [fullMonthlyAmount, perMonth]);
const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
onChange(months);
}
}, [months, onChange]);
return (
<label className={buildClassName(styles.wrapper, checked && styles.active)} dir={lang.isRtl ? 'rtl' : undefined}>
<input
className={styles.input}
type="radio"
name="gift_option"
value={months}
checked={checked}
onChange={handleChange}
/>
<div className={styles.content}>
<div className={styles.month}>{lang('Months', months)}</div>
<div className={styles.perMonth}>
{lang('PricePerMonth', formatCurrency(perMonth, currency, lang.code))}
{discount && (
<span className={styles.discount} title={lang('GiftDiscount')}> &minus;{discount}% </span>
)}
</div>
<div className={styles.amount}>{formatCurrency(amount, currency, lang.code)}</div>
</div>
</label>
);
};
export default memo(GiftOption);

View File

@ -0,0 +1,17 @@
import type { FC } from '../../../lib/teact/teact';
import React, { memo } from '../../../lib/teact/teact';
import { Bundles } from '../../../util/moduleLoader';
import type { OwnProps } from './GiftPremiumModal';
import useModuleLoader from '../../../hooks/useModuleLoader';
const GiftPremiumModalAsync: FC<OwnProps> = (props) => {
const { isOpen } = props;
const GiftPremiumModal = useModuleLoader(Bundles.Extra, 'GiftPremiumModal', !isOpen);
// eslint-disable-next-line react/jsx-props-no-spreading
return GiftPremiumModal ? <GiftPremiumModal {...props} /> : undefined;
};
export default memo(GiftPremiumModalAsync);

View File

@ -0,0 +1,44 @@
@media (min-width: 451px) {
.modalDialog :global(.modal-dialog) {
max-width: 32rem !important;
}
}
.closeButton {
position: absolute;
top: 0.5rem;
left: 0.5rem;
}
.avatar {
margin: 0 auto 1.5rem;
}
.headerText {
font-size: 1.5rem;
font-weight: 500;
text-align: center;
}
.description,
.premiumFeatures {
text-align: center;
margin: 0 auto 2rem;
max-width: 25rem;
}
.premiumFeatures {
font-size: 0.9375rem;
color: var(--color-text-secondary);
}
.options {
margin-bottom: 2.5rem;
}
.button {
height: 3rem;
background: linear-gradient(88.39deg, #6C93FF -2.56%, #976FFF 51.27%, #DF69D1 107.39%);
font-size: 1rem;
font-weight: 600;
}

View File

@ -0,0 +1,166 @@
import React, {
memo, useCallback, useEffect, useMemo, useState,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import type { ApiPremiumGiftOption, ApiUser } from '../../../api/types';
import { formatCurrency } from '../../../util/formatCurrency';
import renderText from '../../common/helpers/renderText';
import { getUserFirstOrLastName } from '../../../global/helpers';
import { selectUser } from '../../../global/selectors';
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
import useLang from '../../../hooks/useLang';
import Modal from '../../ui/Modal';
import Button from '../../ui/Button';
import Link from '../../ui/Link';
import Avatar from '../../common/Avatar';
import GiftOption from './GiftOption';
import styles from './GiftPremiumModal.module.scss';
export type OwnProps = {
isOpen?: boolean;
};
type StateProps = {
user?: ApiUser;
gifts?: ApiPremiumGiftOption[];
monthlyCurrency?: string;
monthlyAmount?: number;
};
const GiftPremiumModal: FC<OwnProps & StateProps> = ({
isOpen, user, gifts, monthlyCurrency, monthlyAmount,
}) => {
const { openPremiumModal, closeGiftPremiumModal, openUrl } = getActions();
const lang = useLang();
const renderedUser = useCurrentOrPrev(user, true);
const renderedGifts = useCurrentOrPrev(gifts, true);
const [selectedOption, setSelectedOption] = useState<number | undefined>();
const firstGift = renderedGifts?.[0];
const fullMonthlyAmount = useMemo(() => {
if (!renderedGifts || renderedGifts.length === 0 || !firstGift) {
return undefined;
}
const cheaperGift = renderedGifts.reduce((acc, gift) => {
return gift.amount < firstGift?.amount ? gift : firstGift;
}, firstGift);
return cheaperGift.currency === monthlyCurrency && monthlyAmount
? monthlyAmount
: Math.floor(cheaperGift.amount / cheaperGift.months);
}, [firstGift, renderedGifts, monthlyAmount, monthlyCurrency]);
useEffect(() => {
if (isOpen) {
setSelectedOption(firstGift?.months);
}
}, [firstGift?.months, isOpen]);
const selectedGift = useMemo(() => {
return renderedGifts?.find((gift) => gift.months === selectedOption);
}, [renderedGifts, selectedOption]);
const handleSubmit = useCallback(() => {
if (!selectedGift) {
return;
}
closeGiftPremiumModal();
openUrl({ url: selectedGift.botUrl });
}, [closeGiftPremiumModal, openUrl, selectedGift]);
const handlePremiumClick = useCallback(() => {
openPremiumModal();
}, [openPremiumModal]);
function renderPremiumFeaturesLink() {
const info = lang('GiftPremiumListFeaturesAndTerms');
// Translation hack for rendering component inside string
const parts = info.match(/([^*]*)\*([^*]+)\*(.*)/);
if (!parts || parts.length < 4) {
return undefined;
}
return (
<p className={styles.premiumFeatures}>
{parts[1]}
<Link isPrimary onClick={handlePremiumClick}>{parts[2]}</Link>
{parts[3]}
</p>
);
}
return (
<Modal
onClose={closeGiftPremiumModal}
isOpen={isOpen}
className={styles.modalDialog}
>
<div className="custom-scroll">
<Button
round
size="smaller"
className={styles.closeButton}
color="translucent"
// eslint-disable-next-line react/jsx-no-bind
onClick={() => closeGiftPremiumModal()}
ariaLabel={lang('Close')}
>
<i className="icon-close" />
</Button>
<Avatar user={renderedUser} size="jumbo" className={styles.avatar} />
<h2 className={styles.headerText}>
{lang('GiftTelegramPremiumTitle')}
</h2>
<p className={styles.description}>
{renderText(
lang('GiftTelegramPremiumDescription', getUserFirstOrLastName(renderedUser)),
['emoji', 'simple_markdown'],
)}
</p>
<div className={styles.options}>
{renderedGifts?.map((gift) => (
<GiftOption
key={gift.amount}
option={gift}
fullMonthlyAmount={fullMonthlyAmount}
checked={gift.months === selectedOption}
onChange={setSelectedOption}
/>
))}
</div>
{renderPremiumFeaturesLink()}
</div>
<Button className={styles.button} isShiny disabled={!selectedOption} onClick={handleSubmit}>
{lang(
'GiftSubscriptionFor',
selectedGift && formatCurrency(Number(selectedGift.amount), selectedGift.currency, lang.code),
)}
</Button>
</Modal>
);
};
export default memo(withGlobal<OwnProps>((global): StateProps => {
const { forUserId, monthlyCurrency, monthlyAmount } = global.giftPremiumModal || {};
const user = forUserId ? selectUser(global, forUserId) : undefined;
const gifts = user ? user.fullInfo?.premiumGifts : undefined;
return {
user,
gifts,
monthlyCurrency,
monthlyAmount: monthlyAmount ? Number(monthlyAmount) : undefined,
};
})(GiftPremiumModal));

View File

@ -14,11 +14,11 @@
}
.description {
font-size: 14px;
font-size: 0.875rem;
color: var(--color-text-secondary);
white-space: pre-wrap;
line-height: 20px;
min-height: 40px;
line-height: 1.25rem;
min-height: 2.5rem;
}
.icon {

View File

@ -4,8 +4,8 @@
.button {
font-weight: 600;
font-size: 16px;
height: 48px;
font-size: 1rem;
height: 3rem;
}
.button-premium {
@ -63,17 +63,17 @@
.limits-content {
overflow: auto;
padding: 1rem;
margin-top: 59px;
margin-top: 3.6875rem;
height: calc(var(--vh) * 55 + 41px);
}
.header {
padding-left: 4rem;
font-size: 20px;
font-size: 1.25rem;
font-weight: 500;
padding-top: 0.875rem;
padding-bottom: 0.875rem;
border-bottom: 1px solid var(--color-borders);
border-bottom: 0.0625rem solid var(--color-borders);
position: absolute;
background: var(--color-background);
width: 100%;
@ -101,7 +101,7 @@
}
.title {
font-size: 20px;
font-size: 1.25rem;
font-weight: 500;
text-align: center;
color: var(--color-text);
@ -109,16 +109,16 @@
}
.description {
font-size: 16px;
font-size: 1rem;
font-weight: 400;
line-height: 22px;
line-height: 1.375rem;
text-align: center;
color: var(--color-text-secondary);
padding: 0 5%;
}
.footer {
border-top: 1px solid var(--color-borders);
border-top: 0.0625rem solid var(--color-borders);
position: absolute;
bottom: 0;
left: 0;

View File

@ -26,8 +26,8 @@
.button {
font-weight: 600;
background: var(--premium-gradient);
font-size: 16px;
height: 48px;
font-size: 1rem;
height: 3rem;
}
.main {
@ -46,7 +46,7 @@
}
.header-text {
font-size: 24px;
font-size: 1.5rem;
font-weight: 500;
text-align: center;
}
@ -57,7 +57,7 @@
}
.list {
margin-bottom: 80px;
margin-bottom: 5rem;
width: 100%;
}
@ -69,12 +69,12 @@
z-index: 2;
display: flex;
align-items: center;
border-bottom: 1px solid var(--color-borders);
border-bottom: 0.0625rem solid var(--color-borders);
position: absolute;
width: 100%;
left: 0;
top: 0;
height: 56px;
height: 3.5rem;
padding: 0.5rem;
background: var(--color-background);
transition: 0.25s ease-out transform;
@ -92,7 +92,7 @@
}
.premium-header-text {
font-size: 20px;
font-size: 1.25rem;
font-weight: 500;
margin: 0 0 0 3rem;
}
@ -111,7 +111,7 @@
position: absolute;
width: 100%;
background: var(--color-background);
border-top: 1px solid var(--color-borders);
border-top: 0.0625rem solid var(--color-borders);
left: 0;
bottom: 0;
padding: 1rem;

View File

@ -65,12 +65,16 @@ export type OwnProps = {
};
type StateProps = {
currentUserId?: string;
promo?: ApiPremiumPromo;
isClosing?: boolean;
fromUser?: ApiUser;
toUser?: ApiUser;
initialSection?: string;
isPremium?: boolean;
isSuccess?: boolean;
isGift?: boolean;
monthsAmount?: number;
limitChannels: number;
limitPins: number;
limitLinks: number;
@ -83,6 +87,7 @@ type StateProps = {
const PremiumMainModal: FC<OwnProps & StateProps> = ({
isOpen,
currentUserId,
fromUser,
promo,
initialSection,
@ -96,6 +101,9 @@ const PremiumMainModal: FC<OwnProps & StateProps> = ({
premiumBotUsername,
isClosing,
isSuccess,
isGift,
toUser,
monthsAmount,
premiumPromoOrder,
}) => {
// eslint-disable-next-line no-null/no-null
@ -170,6 +178,42 @@ const PremiumMainModal: FC<OwnProps & StateProps> = ({
if (!promo) return undefined;
function getHeaderText() {
if (isGift) {
return fromUser?.id === currentUserId
? lang('TelegramPremiumUserGiftedPremiumOutboundDialogTitle', [getUserFullName(toUser), monthsAmount])
: lang('TelegramPremiumUserGiftedPremiumDialogTitle', [getUserFullName(fromUser), monthsAmount]);
}
return fromUser
? lang('TelegramPremiumUserDialogTitle', getUserFullName(fromUser))
: lang(isPremium ? 'TelegramPremiumSubscribedTitle' : 'TelegramPremium');
}
function getHeaderDescription() {
if (isGift) {
return fromUser?.id === currentUserId
? lang('TelegramPremiumUserGiftedPremiumOutboundDialogSubtitle', getUserFullName(toUser))
: lang('TelegramPremiumUserGiftedPremiumDialogSubtitle');
}
return fromUser
? lang('TelegramPremiumUserDialogSubtitle')
: lang(isPremium ? 'TelegramPremiumSubscribedSubtitle' : 'TelegramPremiumSubtitle');
}
function renderFooterText() {
if (!promo || (isGift && fromUser?.id === currentUserId)) {
return undefined;
}
return (
<div className={styles.footerText}>
{renderTextWithEntities(promo.statusText, promo.statusEntities)}
</div>
);
}
return (
<Modal
className={styles.root}
@ -195,19 +239,10 @@ const PremiumMainModal: FC<OwnProps & StateProps> = ({
</Button>
<img className={styles.logo} src={PremiumLogo} alt="" />
<h2 className={styles.headerText}>
{renderText(
fromUser
? lang('TelegramPremiumUserDialogTitle', getUserFullName(fromUser))
: lang(isPremium ? 'TelegramPremiumSubscribedTitle' : 'TelegramPremium'),
['simple_markdown', 'emoji'],
)}
{renderText(getHeaderText(), ['simple_markdown', 'emoji'])}
</h2>
<div className={styles.description}>
{renderText(
lang(fromUser ? 'TelegramPremiumUserDialogSubtitle'
: (isPremium ? 'TelegramPremiumSubscribedSubtitle' : 'TelegramPremiumSubtitle')),
['simple_markdown'],
)}
{renderText(getHeaderDescription(), ['simple_markdown', 'emoji'])}
</div>
<div className={buildClassName(styles.header, isHeaderHidden && styles.hiddenHeader)}>
<h2 className={styles.premiumHeaderText}>
@ -240,18 +275,7 @@ const PremiumMainModal: FC<OwnProps & StateProps> = ({
{renderText(lang('AboutPremiumDescription2'), ['simple_markdown'])}
</p>
</div>
<div className={styles.footerText}>
{renderTextWithEntities(
promo.statusText,
promo.statusEntities,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
)}
</div>
{renderFooterText()}
</div>
{!isPremium && (
<div className={styles.footer}>
@ -280,10 +304,14 @@ const PremiumMainModal: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>((global): StateProps => {
return {
currentUserId: global.currentUserId,
promo: global.premiumModal?.promo,
isClosing: global.premiumModal?.isClosing,
isSuccess: global.premiumModal?.isSuccess,
isGift: global.premiumModal?.isGift,
monthsAmount: global.premiumModal?.monthsAmount,
fromUser: global.premiumModal?.fromUserId ? selectUser(global, global.premiumModal.fromUserId) : undefined,
toUser: global.premiumModal?.toUserId ? selectUser(global, global.premiumModal.toUserId) : undefined,
initialSection: global.premiumModal?.initialSection,
isPremium: selectIsCurrentUserPremium(global),
limitChannels: selectPremiumLimit(global, 'channels'),

View File

@ -2,9 +2,11 @@ import type { FC } from '../../lib/teact/teact';
import React, {
memo, useEffect, useMemo, useRef,
} from '../../lib/teact/teact';
import { withGlobal } from '../../global';
import { getActions, withGlobal } from '../../global';
import type { ApiUser, ApiMessage, ApiChat } from '../../api/types';
import type {
ApiUser, ApiMessage, ApiChat, ApiSticker,
} from '../../api/types';
import type { FocusDirection } from '../../types';
import {
@ -16,24 +18,27 @@ import {
import { getMessageHtmlId, isChatChannel } from '../../global/helpers';
import buildClassName from '../../util/buildClassName';
import { renderActionMessageText } from '../common/helpers/renderActionMessageText';
import { preventMessageInputBlur } from './helpers/preventMessageInputBlur';
import useEnsureMessage from '../../hooks/useEnsureMessage';
import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import { useOnIntersect } from '../../hooks/useIntersectionObserver';
import { useIsIntersecting, useOnIntersect } from '../../hooks/useIntersectionObserver';
import useFocusMessage from './message/hooks/useFocusMessage';
import useLang from '../../hooks/useLang';
import ContextMenuContainer from './message/ContextMenuContainer.async';
import useFlag from '../../hooks/useFlag';
import useShowTransition from '../../hooks/useShowTransition';
import { preventMessageInputBlur } from './helpers/preventMessageInputBlur';
import ContextMenuContainer from './message/ContextMenuContainer.async';
import AnimatedIconFromSticker from '../common/AnimatedIconFromSticker';
type OwnProps = {
message: ApiMessage;
observeIntersection?: ObserveFn;
observeIntersectionForAnimation?: ObserveFn;
isEmbedded?: boolean;
appearanceOrder?: number;
isLastInList?: boolean;
memoFirstUnreadIdRef?: { current: number | undefined };
};
type StateProps = {
@ -46,6 +51,7 @@ type StateProps = {
isFocused: boolean;
focusDirection?: FocusDirection;
noFocusHighlight?: boolean;
premiumGiftSticker?: ApiSticker;
};
const APPEARANCE_DELAY = 10;
@ -53,6 +59,7 @@ const APPEARANCE_DELAY = 10;
const ActionMessage: FC<OwnProps & StateProps> = ({
message,
observeIntersection,
observeIntersectionForAnimation,
isEmbedded,
appearanceOrder = 0,
isLastInList,
@ -65,7 +72,13 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
isFocused,
focusDirection,
noFocusHighlight,
premiumGiftSticker,
memoFirstUnreadIdRef,
}) => {
const { openPremiumModal, requestConfetti } = getActions();
const lang = useLang();
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
@ -73,10 +86,10 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
useEnsureMessage(message.chatId, message.replyToMessageId, targetMessage);
useFocusMessage(ref, message.chatId, isFocused, focusDirection, noFocusHighlight);
const lang = useLang();
const noAppearanceAnimation = appearanceOrder <= 0;
const [isShown, markShown] = useFlag(noAppearanceAnimation);
const isGift = Boolean(message.content.action?.text.startsWith('ActionGift'));
useEffect(() => {
if (noAppearanceAnimation) {
return;
@ -84,6 +97,21 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
setTimeout(markShown, appearanceOrder * APPEARANCE_DELAY);
}, [appearanceOrder, markShown, noAppearanceAnimation]);
const isVisible = useIsIntersecting(ref, observeIntersectionForAnimation);
const shouldShowConfettiRef = useRef((() => {
const isUnread = memoFirstUnreadIdRef?.current && message.id >= memoFirstUnreadIdRef.current;
return isGift && !message.isOutgoing && isUnread;
})());
useEffect(() => {
if (isVisible && shouldShowConfettiRef.current) {
shouldShowConfettiRef.current = false;
requestConfetti();
}
}, [isVisible, requestConfetti]);
const { transitionClassNames } = useShowTransition(isShown, undefined, noAppearanceAnimation, false);
const targetUsers = useMemo(() => {
@ -114,13 +142,41 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
handleBeforeContextMenu(e);
};
const handlePremiumGiftClick = () => {
openPremiumModal({
isGift: true,
fromUserId: senderUser?.id,
toUserId: targetUserIds?.[0],
monthsAmount: message.content.action?.months || 0,
});
};
if (isEmbedded) {
return <span className="embedded-action-message">{content}</span>;
}
function renderGift() {
return (
<span className="action-message-gift" tabIndex={0} role="button" onClick={handlePremiumGiftClick}>
<AnimatedIconFromSticker
key={message.id}
sticker={premiumGiftSticker}
play
noLoop
nonInteractive
/>
<strong>{lang('ActionGiftPremiumTitle')}</strong>
<span>{lang('ActionGiftPremiumSubtitle', lang('Months', message.content.action?.months, 'i'))}</span>
<span className="action-message-button">{lang('ActionGiftPremiumView')}</span>
</span>
);
}
const className = buildClassName(
'ActionMessage message-list-item',
isFocused && !noFocusHighlight && 'focused',
isGift && 'premium-gift',
isContextMenuShown && 'has-menu-open',
isLastInList && 'last-in-list',
transitionClassNames,
@ -136,6 +192,7 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
onContextMenu={handleContextMenu}
>
<span>{content}</span>
{isGift && renderGift()}
{contextMenuPosition && (
<ContextMenuContainer
isOpen={isContextMenuOpen}
@ -167,6 +224,7 @@ export default memo(withGlobal<OwnProps>(
const isChat = chat && (isChatChannel(chat) || userId === message.chatId);
const senderUser = !isChat && userId ? selectUser(global, userId) : undefined;
const senderChat = isChat ? chat : undefined;
const premiumGiftSticker = global.premiumGifts?.stickers?.[0];
return {
usersById,
@ -176,6 +234,7 @@ export default memo(withGlobal<OwnProps>(
targetUserIds,
targetMessage,
isFocused,
premiumGiftSticker,
...(isFocused && { focusDirection, noFocusHighlight }),
};
},

View File

@ -11,7 +11,7 @@ import { REPLIES_USER_ID } from '../../config';
import { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
import { disableScrolling, enableScrolling } from '../../util/scrollLock';
import {
selectChat, selectNotifySettings, selectNotifyExceptions, selectUser, selectChatBot,
selectChat, selectNotifySettings, selectNotifyExceptions, selectUser, selectChatBot, selectIsPremiumPurchaseBlocked,
} from '../../global/selectors';
import {
isUserId, getCanDeleteChat, selectIsChatMuted, getCanAddContact, isChatChannel, isChatGroup,
@ -73,6 +73,7 @@ type StateProps = {
canAddContact?: boolean;
canReportChat?: boolean;
canDeleteChat?: boolean;
canGiftPremium?: boolean;
hasLinkedChat?: boolean;
};
@ -98,6 +99,7 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
isMuted,
canReportChat,
canDeleteChat,
canGiftPremium,
hasLinkedChat,
canAddContact,
onSubscribeChannel,
@ -116,6 +118,7 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
openAddContactDialog,
requestCall,
toggleStatistics,
openGiftPremiumModal,
} = getActions();
const [isMenuOpen, setIsMenuOpen] = useState(true);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
@ -181,6 +184,11 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
closeMenu();
}, [chatId, closeMenu, openLinkedChat]);
const handleGiftPremiumClick = useCallback(() => {
openGiftPremiumModal({ forUserId: chatId });
closeMenu();
}, [openGiftPremiumModal, chatId, closeMenu]);
const handleAddContactClick = useCallback(() => {
openAddContactDialog({ userId: chatId });
closeMenu();
@ -358,6 +366,14 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
</MenuItem>
)}
{botButtons}
{canGiftPremium && (
<MenuItem
icon="gift"
onClick={handleGiftPremiumClick}
>
{lang('GiftPremium')}
</MenuItem>
)}
{canLeave && (
<MenuItem
destructive
@ -402,6 +418,11 @@ export default memo(withGlobal<OwnProps>(
const canReportChat = isChatChannel(chat) || isChatGroup(chat) || (user && !user.isSelf);
const chatBot = chatId !== REPLIES_USER_ID ? selectChatBot(global, chatId) : undefined;
const canGiftPremium = Boolean(
global.lastSyncTime
&& user?.fullInfo?.premiumGifts?.length
&& !selectIsPremiumPurchaseBlocked(global),
);
return {
chat,
@ -410,6 +431,7 @@ export default memo(withGlobal<OwnProps>(
canAddContact,
canReportChat,
canDeleteChat: getCanDeleteChat(chat),
canGiftPremium,
hasLinkedChat: Boolean(chat?.fullInfo?.linkedChatId),
botCommands: chatBot?.fullInfo?.botInfo?.commands,
};

View File

@ -259,6 +259,31 @@
}
}
.ActionMessage.premium-gift {
display: flex;
flex-direction: column;
align-items: center;
}
.action-message-gift {
display: flex !important;
flex-direction: column;
align-items: center;
line-height: 1rem !important;
padding-bottom: 0.75rem !important;
margin-top: 0.5rem;
cursor: pointer;
outline: none;
}
.action-message-button {
display: inline-block;
border-radius: var(--border-radius-default);
padding: 0.5rem 0.75rem;
margin-top: 0.5rem;
background-color: var(--pattern-color);
}
.sticky-date {
margin-top: 1rem;
margin-bottom: 1rem;

View File

@ -144,6 +144,8 @@ const MessageListContent: FC<OwnProps> = ({
key={message.id}
message={message}
observeIntersection={observeIntersectionForReading}
observeIntersectionForAnimation={observeIntersectionForAnimatedStickers}
memoFirstUnreadIdRef={memoFirstUnreadIdRef}
appearanceOrder={messageCountToAnimate - ++appearanceIndex}
isLastInList={isLastInList}
/>,

View File

@ -71,6 +71,7 @@ import UnpinAllMessagesModal from '../common/UnpinAllMessagesModal.async';
import SeenByModal from '../common/SeenByModal.async';
import EmojiInteractionAnimation from './EmojiInteractionAnimation.async';
import ReactorListModal from './ReactorListModal.async';
import GiftPremiumModal from '../main/premium/GiftPremiumModal.async';
import './MiddleColumn.scss';
import styles from './MiddleColumn.module.scss';
@ -98,6 +99,7 @@ type StateProps = {
isSelectModeActive?: boolean;
isSeenByModalOpen: boolean;
isReactorListModalOpen: boolean;
isGiftPremiumModalOpen?: boolean;
animationLevel?: number;
shouldSkipHistoryAnimations?: boolean;
currentTransitionKey: number;
@ -140,6 +142,7 @@ const MiddleColumn: FC<StateProps> = ({
isSelectModeActive,
isSeenByModalOpen,
isReactorListModalOpen,
isGiftPremiumModalOpen,
animationLevel,
shouldSkipHistoryAnimations,
currentTransitionKey,
@ -551,6 +554,7 @@ const MiddleColumn: FC<StateProps> = ({
/>
))}
</div>
<GiftPremiumModal isOpen={isGiftPremiumModalOpen} />
</div>
);
};
@ -580,6 +584,7 @@ export default memo(withGlobal(
isSelectModeActive: selectIsInSelectMode(global),
isSeenByModalOpen: Boolean(global.seenByModal),
isReactorListModalOpen: Boolean(global.reactorModal),
isGiftPremiumModalOpen: global.giftPremiumModal?.isOpen,
animationLevel: global.settings.byKey.animationLevel,
currentTransitionKey: Math.max(0, messageLists.length - 1),
activeEmojiInteractions,

View File

@ -14,6 +14,7 @@ import './CardInput.scss';
import mastercardIconPath from '../../assets/mastercard.svg';
import visaIconPath from '../../assets/visa.svg';
import mirIconPath from '../../assets/mir.svg';
const CARD_NUMBER_MAX_LENGTH = 23;
@ -73,6 +74,8 @@ function getCardIcon(cardType: CardType) {
return <img src={mastercardIconPath} alt="" />;
case CardType.Visa:
return <img src={visaIconPath} alt="" />;
case CardType.Mir:
return <img src={mirIconPath} alt="" />;
default:
return undefined;
}

View File

@ -0,0 +1,19 @@
.link {
color: inherit;
&:hover {
color: inherit;
&:global(.GroupCallLink) {
text-decoration: none;
}
}
}
.isPrimary {
color: var(--color-primary);
&:hover {
color: var(--color-primary);
}
}

View File

@ -1,11 +0,0 @@
.Link {
color: inherit;
&:hover {
color: inherit;
&.GroupCallLink {
text-decoration: none;
}
}
}

View File

@ -3,17 +3,18 @@ import React, { useCallback } from '../../lib/teact/teact';
import buildClassName from '../../util/buildClassName';
import './Link.scss';
import styles from './Link.module.scss';
type OwnProps = {
children: React.ReactNode;
className?: string;
isRtl?: boolean;
isPrimary?: boolean;
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
};
const Link: FC<OwnProps> = ({
children, className, isRtl, onClick,
children, isPrimary, className, isRtl, onClick,
}) => {
const handleClick = useCallback((e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
@ -23,7 +24,7 @@ const Link: FC<OwnProps> = ({
return (
<a
href="#"
className={buildClassName('Link', className)}
className={buildClassName(styles.link, className, isPrimary && styles.isPrimary)}
dir={isRtl ? 'rtl' : 'auto'}
onClick={onClick ? handleClick : undefined}
>

View File

@ -642,6 +642,10 @@ addActionHandler('openTelegramLink', (global, actions, payload) => {
chatId,
messageId,
});
} else if (part1.startsWith('$')) {
actions.openInvoice({
slug: part1.substring(1),
});
} else if (part1 === 'invoice') {
actions.openInvoice({
slug: part2,

View File

@ -376,7 +376,9 @@ addActionHandler('closePremiumModal', (global, actions, payload) => {
});
addActionHandler('openPremiumModal', async (global, actions, payload) => {
const { initialSection, fromUserId, isSuccess } = payload || {};
const {
initialSection, fromUserId, isSuccess, isGift, monthsAmount, toUserId,
} = payload || {};
actions.loadPremiumStickers();
@ -393,7 +395,36 @@ addActionHandler('openPremiumModal', async (global, actions, payload) => {
initialSection,
isOpen: true,
fromUserId,
toUserId,
isGift,
monthsAmount,
isSuccess,
},
});
});
addActionHandler('openGiftPremiumModal', async (global, actions, payload) => {
const { forUserId } = payload || {};
const result = await callApi('fetchPremiumPromo');
if (!result) return;
global = getGlobal();
global = addUsers(global, buildCollectionByKey(result.users, 'id'));
setGlobal({
...global,
giftPremiumModal: {
isOpen: true,
forUserId,
monthlyCurrency: result.promo.currency,
monthlyAmount: result.promo.monthlyAmount,
},
});
});
addActionHandler('closeGiftPremiumModal', (global) => {
setGlobal({
...global,
giftPremiumModal: { isOpen: false },
});
});

View File

@ -109,6 +109,20 @@ addActionHandler('loadFeaturedStickers', (global) => {
void loadFeaturedStickers(hash);
});
addActionHandler('loadPremiumGifts', async () => {
const stickerSet = await callApi('fetchPremiumGifts');
if (!stickerSet) {
return;
}
const { set, stickers } = stickerSet;
setGlobal({
...getGlobal(),
premiumGifts: { ...set, stickers },
});
});
addActionHandler('loadStickers', (global, actions, payload) => {
const { stickerSetId, stickerSetShortName } = payload!;
let { stickerSetAccessHash } = payload!;

View File

@ -7,16 +7,16 @@ addActionHandler('apiUpdate', (global, actions, update) => {
switch (update['@type']) {
case 'updatePaymentStateCompleted': {
const { inputInvoice } = global.payment;
if (update.slug && inputInvoice && 'slug' in inputInvoice && inputInvoice.slug !== update.slug) {
return undefined;
}
// On the production host, the payment frame receives a message with the payment event,
// after which the payment form closes. In other cases, the payment form must be closed manually.
if (!IS_PRODUCTION_HOST) {
global = clearPayment(global);
}
if (update.slug && inputInvoice && 'slug' in inputInvoice && inputInvoice.slug !== update.slug) {
return !IS_PRODUCTION_HOST ? global : undefined;
}
return {
...global,
payment: {

View File

@ -314,6 +314,7 @@ export type GlobalState = {
animatedEmojis?: ApiStickerSet;
animatedEmojiEffects?: ApiStickerSet;
premiumGifts?: ApiStickerSet;
emojiKeywords: Partial<Record<LangCode, EmojiKeywords>>;
gifs: {
@ -638,9 +639,19 @@ export type GlobalState = {
promo: ApiPremiumPromo;
initialSection?: string;
fromUserId?: string;
toUserId?: string;
isGift?: boolean;
monthsAmount?: number;
isSuccess?: boolean;
};
giftPremiumModal?: {
isOpen?: boolean;
forUserId?: string;
monthlyCurrency?: string;
monthlyAmount?: string;
};
transcriptions: Record<string, ApiTranscription>;
limitReachedModal?: {
@ -1062,7 +1073,10 @@ export interface ActionPayloads {
openPremiumModal: {
initialSection?: string;
fromUserId?: string;
toUserId?: string;
isSuccess?: boolean;
isGift?: boolean;
monthsAmount?: number;
};
closePremiumModal: never | {
isClosed?: boolean;
@ -1073,10 +1087,17 @@ export interface ActionPayloads {
messageId: number;
};
loadPremiumGifts: never;
loadPremiumStickers: {
hash?: string;
};
openGiftPremiumModal: {
forUserId?: string;
};
closeGiftPremiumModal: never;
// Invoice
openInvoice: ApiInputInvoice;
}

File diff suppressed because it is too large Load Diff

View File

@ -51,6 +51,9 @@
.icon-volume-3:before {
content: "\e991";
}
.icon-gift:before {
content: "\e9ad";
}
.icon-sort:before {
content: "\e9ac";
}

View File

@ -204,14 +204,14 @@ function processTemplate(template: string, value: any) {
const initialValue = translationSlices.shift();
return translationSlices.reduce((result, str, index) => {
return `${result}${String(value[index] || '')}${str}`;
return `${result}${String(value[index] ?? '')}${str}`;
}, initialValue || '');
}
function processTranslation(langString: ApiLangString | undefined, key: string, value?: any, format?: 'i') {
const preferedPluralOption = typeof value === 'number' ? getPluralOption(value) : 'value';
const preferredPluralOption = typeof value === 'number' ? getPluralOption(value) : 'value';
const template = langString ? (
langString[preferedPluralOption] || langString.otherValue || langString.value
langString[preferredPluralOption] || langString.otherValue || langString.value
) : undefined;
if (!template || !template.trim()) {
const parts = key.split('.');