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 type: ApiAction['type'] = 'other';
let photo: ApiPhoto | undefined; let photo: ApiPhoto | undefined;
let score: number | undefined; let score: number | undefined;
let months: number | undefined;
const targetUserIds = 'users' in action const targetUserIds = 'users' in action
? action.users && action.users.map((id) => buildApiPeerId(id, 'user')) ? action.users && action.users.map((id) => buildApiPeerId(id, 'user'))
@ -944,6 +945,19 @@ function buildAction(
} else if (action instanceof GramJs.MessageActionWebViewDataSent) { } else if (action instanceof GramJs.MessageActionWebViewDataSent) {
text = 'Notification.WebAppSentData'; text = 'Notification.WebAppSentData';
translationValues.push(action.text); 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 { } else {
text = 'ChatList.UnsupportedMessage'; text = 'ChatList.UnsupportedMessage';
} }
@ -965,6 +979,7 @@ function buildAction(
call, call,
phoneCall, phoneCall,
score, score,
months,
}; };
} }

View File

@ -41,7 +41,7 @@ export {
fetchStickerSets, fetchRecentStickers, fetchFavoriteStickers, fetchFeaturedStickers, fetchStickerSets, fetchRecentStickers, fetchFavoriteStickers, fetchFeaturedStickers,
faveSticker, fetchStickers, fetchSavedGifs, saveGif, searchStickers, installStickerSet, uninstallStickerSet, faveSticker, fetchStickers, fetchSavedGifs, saveGif, searchStickers, installStickerSet, uninstallStickerSet,
searchGifs, fetchAnimatedEmojis, fetchStickersForEmoji, fetchEmojiKeywords, fetchAnimatedEmojiEffects, searchGifs, fetchAnimatedEmojis, fetchStickersForEmoji, fetchEmojiKeywords, fetchAnimatedEmojiEffects,
removeRecentSticker, clearRecentStickers, removeRecentSticker, clearRecentStickers, fetchPremiumGifts,
} from './symbols'; } from './symbols';
export { 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 }) { export async function searchStickers({ query, hash = '0' }: { query: string; hash?: string }) {
const result = await invokeRequest(new GramJs.messages.SearchStickerSets({ const result = await invokeRequest(new GramJs.messages.SearchStickerSets({
q: query, q: query,

View File

@ -235,6 +235,7 @@ export interface ApiAction {
call?: Partial<ApiGroupCall>; call?: Partial<ApiGroupCall>;
phoneCall?: PhoneCallAction; phoneCall?: PhoneCallAction;
score?: number; score?: number;
months?: number;
} }
export interface ApiWebPage { 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 BotAttachModal } from '../components/main/BotAttachModal';
export { default as DeleteFolderDialog } from '../components/main/DeleteFolderDialog'; export { default as DeleteFolderDialog } from '../components/main/DeleteFolderDialog';
export { default as PremiumMainModal } from '../components/main/premium/PremiumMainModal'; 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 PremiumLimitReachedModal } from '../components/main/premium/common/PremiumLimitReachedModal';
export { default as AboutAdsModal } from '../components/common/AboutAdsModal'; 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 VISA = /^4[0-9]{12}(?:[0-9]{1,3})?$/;
const MASTERCARD1 = /^5[1-5][0-9]{11,14}$/; const MASTERCARD1 = /^5[1-5][0-9]{11,14}$/;
const MASTERCARD2 = /^2[2-7][0-9]{11,14}$/; const MASTERCARD2 = /^2[2-7][0-9]{11,14}$/;
const MIR = /^220[0-4]/;
export enum CardType { export enum CardType {
Default, Default,
Visa, Visa,
Mastercard, Mastercard,
Mir,
} }
const cards: Record<number, string> = { const cards: Record<number, string> = {
[CardType.Default]: '', [CardType.Default]: '',
[CardType.Visa]: 'visa', [CardType.Visa]: 'visa',
[CardType.Mastercard]: 'mastercard', [CardType.Mastercard]: 'mastercard',
[CardType.Mir]: 'mir',
}; };
export function detectCardType(cardNumber: string): number { export function detectCardType(cardNumber: string): number {
@ -19,6 +22,9 @@ export function detectCardType(cardNumber: string): number {
if (VISA.test(cardNumber)) { if (VISA.test(cardNumber)) {
return CardType.Visa; return CardType.Visa;
} }
if (MIR.test(cardNumber)) {
return CardType.Mir;
}
if (MASTERCARD1.test(cardNumber) || MASTERCARD2.test(cardNumber)) { if (MASTERCARD1.test(cardNumber) || MASTERCARD2.test(cardNumber)) {
return CardType.Mastercard; return CardType.Mastercard;
} }

View File

@ -55,6 +55,15 @@ export function renderActionMessageText(
if (translationKey.includes('ScoredInGame')) { // Translation hack for games if (translationKey.includes('ScoredInGame')) { // Translation hack for games
unprocessed = unprocessed.replace('un1', '%action_origin%').replace('un2', '%message%'); 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[]; let processed: TextPart[];
if (unprocessed.includes('%payment_amount%')) { if (unprocessed.includes('%payment_amount%')) {
@ -80,6 +89,16 @@ export function renderActionMessageText(
unprocessed = processed.pop() as string; unprocessed = processed.pop() as string;
content.push(...processed); 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%')) { if (unprocessed.includes('%score%')) {
processed = processPlaceholder( processed = processPlaceholder(
unprocessed, unprocessed,

View File

@ -169,6 +169,7 @@ const Main: FC<StateProps> = ({
loadCountryList, loadCountryList,
loadAvailableReactions, loadAvailableReactions,
loadStickerSets, loadStickerSets,
loadPremiumGifts,
loadAddedStickers, loadAddedStickers,
loadFavoriteStickers, loadFavoriteStickers,
ensureTimeFormat, ensureTimeFormat,
@ -206,10 +207,12 @@ const Main: FC<StateProps> = ({
loadEmojiKeywords({ language: BASE_EMOJI_KEYWORD_LANG }); loadEmojiKeywords({ language: BASE_EMOJI_KEYWORD_LANG });
loadAttachMenuBots(); loadAttachMenuBots();
loadContactList(); loadContactList();
loadPremiumGifts();
} }
}, [ }, [
lastSyncTime, loadAnimatedEmojis, loadEmojiKeywords, loadNotificationExceptions, loadNotificationSettings, lastSyncTime, loadAnimatedEmojis, loadEmojiKeywords, loadNotificationExceptions, loadNotificationSettings,
loadTopInlineBots, updateIsOnline, loadAvailableReactions, loadAppConfig, loadAttachMenuBots, loadContactList, loadTopInlineBots, updateIsOnline, loadAvailableReactions, loadAppConfig, loadAttachMenuBots, loadContactList,
loadPremiumGifts,
]); ]);
// Language-based API calls // 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 { .description {
font-size: 14px; font-size: 0.875rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
white-space: pre-wrap; white-space: pre-wrap;
line-height: 20px; line-height: 1.25rem;
min-height: 40px; min-height: 2.5rem;
} }
.icon { .icon {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,6 +14,7 @@ import './CardInput.scss';
import mastercardIconPath from '../../assets/mastercard.svg'; import mastercardIconPath from '../../assets/mastercard.svg';
import visaIconPath from '../../assets/visa.svg'; import visaIconPath from '../../assets/visa.svg';
import mirIconPath from '../../assets/mir.svg';
const CARD_NUMBER_MAX_LENGTH = 23; const CARD_NUMBER_MAX_LENGTH = 23;
@ -73,6 +74,8 @@ function getCardIcon(cardType: CardType) {
return <img src={mastercardIconPath} alt="" />; return <img src={mastercardIconPath} alt="" />;
case CardType.Visa: case CardType.Visa:
return <img src={visaIconPath} alt="" />; return <img src={visaIconPath} alt="" />;
case CardType.Mir:
return <img src={mirIconPath} alt="" />;
default: default:
return undefined; 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 buildClassName from '../../util/buildClassName';
import './Link.scss'; import styles from './Link.module.scss';
type OwnProps = { type OwnProps = {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
isRtl?: boolean; isRtl?: boolean;
isPrimary?: boolean;
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void; onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
}; };
const Link: FC<OwnProps> = ({ const Link: FC<OwnProps> = ({
children, className, isRtl, onClick, children, isPrimary, className, isRtl, onClick,
}) => { }) => {
const handleClick = useCallback((e: React.MouseEvent<HTMLAnchorElement>) => { const handleClick = useCallback((e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault(); e.preventDefault();
@ -23,7 +24,7 @@ const Link: FC<OwnProps> = ({
return ( return (
<a <a
href="#" href="#"
className={buildClassName('Link', className)} className={buildClassName(styles.link, className, isPrimary && styles.isPrimary)}
dir={isRtl ? 'rtl' : 'auto'} dir={isRtl ? 'rtl' : 'auto'}
onClick={onClick ? handleClick : undefined} onClick={onClick ? handleClick : undefined}
> >

View File

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

View File

@ -376,7 +376,9 @@ addActionHandler('closePremiumModal', (global, actions, payload) => {
}); });
addActionHandler('openPremiumModal', async (global, actions, payload) => { addActionHandler('openPremiumModal', async (global, actions, payload) => {
const { initialSection, fromUserId, isSuccess } = payload || {}; const {
initialSection, fromUserId, isSuccess, isGift, monthsAmount, toUserId,
} = payload || {};
actions.loadPremiumStickers(); actions.loadPremiumStickers();
@ -393,7 +395,36 @@ addActionHandler('openPremiumModal', async (global, actions, payload) => {
initialSection, initialSection,
isOpen: true, isOpen: true,
fromUserId, fromUserId,
toUserId,
isGift,
monthsAmount,
isSuccess, 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); 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) => { addActionHandler('loadStickers', (global, actions, payload) => {
const { stickerSetId, stickerSetShortName } = payload!; const { stickerSetId, stickerSetShortName } = payload!;
let { stickerSetAccessHash } = payload!; let { stickerSetAccessHash } = payload!;

View File

@ -7,16 +7,16 @@ addActionHandler('apiUpdate', (global, actions, update) => {
switch (update['@type']) { switch (update['@type']) {
case 'updatePaymentStateCompleted': { case 'updatePaymentStateCompleted': {
const { inputInvoice } = global.payment; 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, // 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. // after which the payment form closes. In other cases, the payment form must be closed manually.
if (!IS_PRODUCTION_HOST) { if (!IS_PRODUCTION_HOST) {
global = clearPayment(global); global = clearPayment(global);
} }
if (update.slug && inputInvoice && 'slug' in inputInvoice && inputInvoice.slug !== update.slug) {
return !IS_PRODUCTION_HOST ? global : undefined;
}
return { return {
...global, ...global,
payment: { payment: {

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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