Stars Gifting: Fixes for Stars Gifting (#4899)

This commit is contained in:
Alexander Zinchuk 2024-08-29 15:52:54 +02:00
parent 1dfcbf1009
commit e1504323f1
25 changed files with 406 additions and 214 deletions

View File

@ -8,7 +8,7 @@ import type {
ApiGiveawayInfo, ApiGiveawayInfo,
ApiInvoice, ApiLabeledPrice, ApiMyBoost, ApiPaymentCredentials, ApiInvoice, ApiLabeledPrice, ApiMyBoost, ApiPaymentCredentials,
ApiPaymentForm, ApiPaymentSavedInfo, ApiPremiumGiftCodeOption, ApiPremiumPromo, ApiPremiumSubscriptionOption, ApiPaymentForm, ApiPaymentSavedInfo, ApiPremiumGiftCodeOption, ApiPremiumPromo, ApiPremiumSubscriptionOption,
ApiReceipt, ApiStarsGiftOption, ApiReceipt,
ApiStarsTransaction, ApiStarsTransaction,
ApiStarsTransactionPeer, ApiStarsTransactionPeer,
ApiStarTopupOption, ApiStarTopupOption,
@ -386,7 +386,7 @@ export function buildApiPremiumGiftCodeOption(option: GramJs.PremiumGiftCodeOpti
}; };
} }
export function buildApiStarsGiftOptions(option: GramJs.StarsGiftOption): ApiStarsGiftOption { export function buildApiStarsGiftOptions(option: GramJs.StarsGiftOption): ApiStarTopupOption {
const { const {
extended, stars, amount, currency, extended, stars, amount, currency,
} = option; } = option;

View File

@ -159,13 +159,6 @@ export interface ApiPremiumGiftCodeOption {
amount: number; amount: number;
} }
export interface ApiStarsGiftOption {
isExtended?: true;
stars: number;
currency: string;
amount: number;
}
export type ApiBoostsStatus = { export type ApiBoostsStatus = {
level: number; level: number;
currentLevelBoosts: number; currentLevelBoosts: number;

View File

@ -135,6 +135,17 @@
} }
} }
&.size-huge {
width: 6rem;
height: 6rem;
font-size: 3rem;
.emoji {
width: 3rem;
height: 3rem;
}
}
&.size-jumbo { &.size-jumbo {
width: 7.5rem; width: 7.5rem;
height: 7.5rem; height: 7.5rem;

View File

@ -45,7 +45,8 @@ import './Avatar.scss';
const LOOP_COUNT = 3; const LOOP_COUNT = 3;
export type AvatarSize = 'micro' | 'tiny' | 'mini' | 'small' | 'small-mobile' | 'medium' | 'large' | 'giant' | 'jumbo'; export type AvatarSize =
'micro' | 'tiny' | 'mini' | 'small' | 'small-mobile' | 'medium' | 'large' | 'giant' | 'huge' | 'jumbo';
const cn = createClassNameBuilder('Avatar'); const cn = createClassNameBuilder('Avatar');
cn.media = cn('media'); cn.media = cn('media');

View File

@ -36,6 +36,10 @@
--size: 3.375rem; --size: 3.375rem;
} }
.size-huge {
--size: 6.5rem;
}
.size-jumbo { .size-jumbo {
--size: 7.5rem; --size: 7.5rem;
} }

View File

@ -37,6 +37,7 @@ const SIZES: Record<AvatarSize, number> = {
medium: 2.875 * REM, medium: 2.875 * REM,
large: 3.5 * REM, large: 3.5 * REM,
giant: 5.125 * REM, giant: 5.125 * REM,
huge: 6.125 * REM,
jumbo: 7.625 * REM, jumbo: 7.625 * REM,
}; };

View File

@ -7,6 +7,7 @@ import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import type { LangFn } from '../../../hooks/useOldLang'; import type { LangFn } from '../../../hooks/useOldLang';
import type { TextPart } from '../../../types'; import type { TextPart } from '../../../types';
import { SERVICE_NOTIFICATIONS_USER_ID } from '../../../config';
import { import {
getChatTitle, getChatTitle,
getExpiredMessageDescription, getExpiredMessageDescription,
@ -111,7 +112,9 @@ export function renderActionMessageText(
actionOriginChat ? ( actionOriginChat ? (
renderChatContent(lang, actionOriginChat, noLinks) || NBSP renderChatContent(lang, actionOriginChat, noLinks) || NBSP
) : actionOriginUser ? ( ) : actionOriginUser ? (
renderUserContent(actionOriginUser, noLinks) || NBSP actionOriginUser.id === SERVICE_NOTIFICATIONS_USER_ID
? lang('StarsTransactionUnknown')
: renderUserContent(actionOriginUser, noLinks) || NBSP
) : 'User', ) : 'User',
'', '',
); );

View File

@ -28,6 +28,7 @@ const PickerModal = ({
...modalProps ...modalProps
}: OwnProps) => { }: OwnProps) => {
const lang = useOldLang(); const lang = useOldLang();
const hasOnClickHandler = Boolean(onConfirm || modalProps.onClose);
return ( return (
<Modal <Modal
@ -43,17 +44,19 @@ const PickerModal = ({
headerClassName={buildClassName(styles.header, modalProps.headerClassName)} headerClassName={buildClassName(styles.header, modalProps.headerClassName)}
> >
{modalProps.children} {modalProps.children}
<div className={styles.buttonWrapper}> {hasOnClickHandler && (
<Button <div className={styles.buttonWrapper}>
withPremiumGradient={withPremiumGradient} <Button
onClick={onConfirm || modalProps.onClose} withPremiumGradient={withPremiumGradient}
color="primary" onClick={onConfirm || modalProps.onClose}
size="smaller" color="primary"
disabled={isConfirmDisabled} size="smaller"
> disabled={isConfirmDisabled}
{confirmButtonText || lang('Confirm')} >
</Button> {confirmButtonText || lang('Confirm')}
</div> </Button>
</div>
)}
</Modal> </Modal>
); );
}; };

View File

@ -86,6 +86,7 @@ const PremiumGiftingPickerModal: FC<OwnProps & StateProps> = ({
onConfirm={handleSendIdList} onConfirm={handleSendIdList}
onEnter={handleSendIdList} onEnter={handleSendIdList}
withPremiumGradient withPremiumGradient
isConfirmDisabled={!selectedUserIds?.length}
> >
<PeerPicker <PeerPicker
className={styles.picker} className={styles.picker}

View File

@ -30,7 +30,39 @@
flex-direction: column; flex-direction: column;
} }
.headerInfo {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
.logo {
margin: 1rem;
width: 6.25rem;
height: 6.25rem;
min-height: 6.25rem;
}
.logoBackground {
position: absolute;
top: 0.75rem;
left: 50%;
transform: translateX(-50%);
height: 8rem;
}
.description {
text-align: center;
margin-inline: 0.5rem;
margin-bottom: 1rem;
text-wrap: balance;
}
.section { .section {
display: flex;
flex-direction: column;
height: 100%;
padding: 0.5rem; padding: 0.5rem;
} }
@ -67,41 +99,24 @@
z-index: 3; z-index: 3;
} }
.avatars { .avatar {
display: flex; margin: 2rem;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 1rem;
margin: 1rem;
} }
.center { .center {
text-align: center; text-align: center;
} }
.options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
width: 100%;
margin-bottom: 2.5rem;
}
.boostIcon {
color: var(--color-primary);
vertical-align: middle;
line-height: 1.5;
}
.moreOptions {
grid-column: 1/-1;
}
.secondaryInfo { .secondaryInfo {
text-align: center; text-align: center;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
padding: 0.5rem 1rem; padding: 1rem;
margin-top: auto; margin-top: auto;
} }
@media (max-width: 450px) {
.modalDialog :global(.modal-dialog) {
max-width: 100% !important;
}
}

View File

@ -6,9 +6,10 @@ import React, {
import { getActions, getGlobal, withGlobal } from '../../../global'; import { getActions, getGlobal, withGlobal } from '../../../global';
import type { import type {
ApiStarsGiftOption, ApiStarTopupOption, ApiUser, ApiStarTopupOption, ApiUser,
} from '../../../api/types'; } from '../../../api/types';
import { getSenderTitle } from '../../../global/helpers';
import { import {
selectTabState, selectUser, selectTabState, selectUser,
} from '../../../global/selectors'; } from '../../../global/selectors';
@ -27,13 +28,16 @@ import Modal from '../../ui/Modal';
import styles from './StarsGiftModal.module.scss'; import styles from './StarsGiftModal.module.scss';
import StarLogo from '../../../assets/icons/StarLogo.svg';
import StarsBackground from '../../../assets/stars-bg.png';
export type OwnProps = { export type OwnProps = {
isOpen?: boolean; isOpen?: boolean;
}; };
type StateProps = { type StateProps = {
isCompleted?: boolean; isCompleted?: boolean;
starsGiftOptions?: ApiStarsGiftOption[] | undefined; starsGiftOptions?: ApiStarTopupOption[] | undefined;
forUserId?: string; forUserId?: string;
user?: ApiUser; user?: ApiUser;
}; };
@ -88,13 +92,22 @@ const StarsGiftModal: FC<OwnProps & StateProps> = ({
const handleClick = useLastCallback((option: ApiStarTopupOption) => { const handleClick = useLastCallback((option: ApiStarTopupOption) => {
setSelectedOption(option); setSelectedOption(option);
openInvoice({ if (user) {
type: 'starsgift', openInvoice({
userId: forUserId!, type: 'starsgift',
stars: option.stars, userId: forUserId!,
currency: option.currency, stars: option.stars,
amount: option.amount, currency: option.currency,
}); amount: option.amount,
});
} else {
openInvoice({
type: 'stars',
stars: option.stars,
currency: option.currency,
amount: option.amount,
});
}
}); });
function handleScroll(e: React.UIEvent<HTMLDivElement>) { function handleScroll(e: React.UIEvent<HTMLDivElement>) {
@ -105,11 +118,12 @@ const StarsGiftModal: FC<OwnProps & StateProps> = ({
function renderGiftTitle() { function renderGiftTitle() {
if (isCompleted) { if (isCompleted) {
return renderText(oldLang('Notification.StarsGift.SentYou', return user ? renderText(oldLang('Notification.StarsGift.SentYou',
formatCurrencyAsString(selectedOption!.amount, selectedOption!.currency, oldLang.code)), ['simple_markdown']); formatCurrencyAsString(selectedOption!.amount, selectedOption!.currency, oldLang.code)), ['simple_markdown'])
: renderText(oldLang('StarsAcquiredInfo', selectedOption?.stars), ['simple_markdown']);
} }
return oldLang('GiftStarsTitle'); return user ? oldLang('GiftStarsTitle') : oldLang('Star.List.GetStars');
} }
function renderStarOptionList() { function renderStarOptionList() {
@ -154,28 +168,40 @@ const StarsGiftModal: FC<OwnProps & StateProps> = ({
</Button> </Button>
<div className={buildClassName(styles.header, isHeaderHidden && styles.hiddenHeader)}> <div className={buildClassName(styles.header, isHeaderHidden && styles.hiddenHeader)}>
<h2 className={styles.starHeaderText}> <h2 className={styles.starHeaderText}>
{oldLang('GiftStarsTitle')} {user ? oldLang('GiftStarsTitle') : oldLang('Star.List.GetStars')}
</h2> </h2>
</div> </div>
<div className={styles.avatars}> <div className={styles.headerInfo}>
<Avatar {user ? (
size="large" <>
peer={user} <Avatar
/> size="huge"
peer={user}
className={styles.avatar}
/>
<img className={styles.logoBackground} src={StarsBackground} alt="" draggable={false} />
</>
) : (
<>
<img className={styles.logo} src={StarLogo} alt="" draggable={false} />
<img className={styles.logoBackground} src={StarsBackground} alt="" draggable={false} />
</>
)}
</div> </div>
<h2 className={buildClassName(styles.headerText, styles.center)}> <h2 className={buildClassName(styles.headerText, styles.center)}>
{renderGiftTitle()} {renderGiftTitle()}
</h2> </h2>
{!isCompleted && ( <p className={styles.description}>
<> {user ? renderText(
<div className={buildClassName(styles.section, styles.options)}> oldLang('ActionGiftStarsSubtitle', getSenderTitle(oldLang, user)), ['simple_markdown'],
{renderStarOptionList()} ) : oldLang('Stars.Purchase.GetStarsInfo')}
</div> </p>
<div className={styles.secondaryInfo}> <div className={styles.section}>
{bottomText} {renderStarOptionList()}
</div> <div className={styles.secondaryInfo}>
</> {bottomText}
)} </div>
</div>
</div> </div>
</Modal> </Modal>
); );

View File

@ -1,7 +1,6 @@
import type { FC } from '../../../lib/teact/teact'; import type { FC } from '../../../lib/teact/teact';
import React, { import React, {
memo, useMemo, memo, useMemo,
useRef,
useState, useState,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../../global'; import { getActions, getGlobal, withGlobal } from '../../../global';
@ -10,17 +9,14 @@ import { SERVICE_NOTIFICATIONS_USER_ID } from '../../../config';
import { import {
filterUsersByName, isDeletedUser, isUserBot, filterUsersByName, isDeletedUser, isUserBot,
} from '../../../global/helpers'; } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName';
import { unique } from '../../../util/iteratees'; import { unique } from '../../../util/iteratees';
import sortChatIds from '../../common/helpers/sortChatIds'; import sortChatIds from '../../common/helpers/sortChatIds';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang'; import useOldLang from '../../../hooks/useOldLang';
import Icon from '../../common/icons/Icon';
import PeerPicker from '../../common/pickers/PeerPicker'; import PeerPicker from '../../common/pickers/PeerPicker';
import Button from '../../ui/Button'; import PickerModal from '../../common/pickers/PickerModal';
import Modal from '../../ui/Modal';
import styles from './StarsGiftingPickerModal.module.scss'; import styles from './StarsGiftingPickerModal.module.scss';
@ -42,8 +38,6 @@ const StarsGiftingPickerModal: FC<OwnProps & StateProps> = ({
archivedListIds, archivedListIds,
userIds, userIds,
}) => { }) => {
// eslint-disable-next-line no-null/no-null
const dialogRef = useRef<HTMLDivElement>(null);
const { closeStarsGiftingModal, openStarsGiftModal } = getActions(); const { closeStarsGiftingModal, openStarsGiftModal } = getActions();
const oldLang = useOldLang(); const oldLang = useOldLang();
@ -79,48 +73,30 @@ const StarsGiftingPickerModal: FC<OwnProps & StateProps> = ({
} }
}); });
function renderHeaderText() {
return (
<div className={styles.filter} dir={oldLang.isRtl ? 'rtl' : undefined}>
<Button
round
size="smaller"
color="translucent"
// eslint-disable-next-line react/jsx-no-bind
onClick={() => closeStarsGiftingModal()}
ariaLabel={oldLang('Close')}
>
<Icon name="close" />
</Button>
<h3 className={buildClassName(styles.title, 'ml-2')}>{oldLang('GiftStarsTitle')}
</h3>
</div>
);
}
return ( return (
<Modal <PickerModal
className={styles.root} className={styles.root}
isOpen={isOpen} isOpen={isOpen}
onClose={closeStarsGiftingModal} onClose={closeStarsGiftingModal}
onEnter={handleSelectedUserIdsChange} title={oldLang('GiftStarsTitle')}
dialogRef={dialogRef} hasCloseButton
shouldAdaptToSearch
withFixedHeight
confirmButtonText={oldLang('Continue')}
onEnter={closeStarsGiftingModal}
> >
<div className={buildClassName(styles.main, 'custom-scroll')}> <PeerPicker
{renderHeaderText()} className={styles.picker}
<PeerPicker itemIds={displayedUserIds}
className={styles.picker} filterValue={searchQuery}
itemIds={displayedUserIds} filterPlaceholder={oldLang('Search')}
filterValue={searchQuery} onFilterChange={setSearchQuery}
filterPlaceholder={oldLang('Search')} isSearchable
onFilterChange={setSearchQuery} withDefaultPadding
isSearchable withStatus
withDefaultPadding onSelectedIdChange={handleSelectedUserIdsChange}
withStatus />
onSelectedIdChange={handleSelectedUserIdsChange} </PickerModal>
/>
</div>
</Modal>
); );
}; };

View File

@ -21,12 +21,14 @@ import {
selectChat, selectChat,
selectChatMessage, selectChatMessage,
selectGiftStickerForDuration, selectGiftStickerForDuration,
selectGiftStickerForStars,
selectIsMessageFocused, selectIsMessageFocused,
selectTabState, selectTabState,
selectTopicFromMessage, selectTopicFromMessage,
selectUser, selectUser,
} from '../../global/selectors'; } from '../../global/selectors';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import { formatInteger } from '../../util/textFormat';
import { renderActionMessageText } from '../common/helpers/renderActionMessageText'; import { renderActionMessageText } from '../common/helpers/renderActionMessageText';
import renderText from '../common/helpers/renderText'; import renderText from '../common/helpers/renderText';
import { preventMessageInputBlur } from './helpers/preventMessageInputBlur'; import { preventMessageInputBlur } from './helpers/preventMessageInputBlur';
@ -72,6 +74,7 @@ type StateProps = {
focusDirection?: FocusDirection; focusDirection?: FocusDirection;
noFocusHighlight?: boolean; noFocusHighlight?: boolean;
premiumGiftSticker?: ApiSticker; premiumGiftSticker?: ApiSticker;
starGiftSticker?: ApiSticker;
canPlayAnimatedEmojis?: boolean; canPlayAnimatedEmojis?: boolean;
}; };
@ -93,6 +96,7 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
focusDirection, focusDirection,
noFocusHighlight, noFocusHighlight,
premiumGiftSticker, premiumGiftSticker,
starGiftSticker,
isInsideTopic, isInsideTopic,
topic, topic,
memoFirstUnreadIdRef, memoFirstUnreadIdRef,
@ -315,15 +319,16 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
> >
<AnimatedIconFromSticker <AnimatedIconFromSticker
key={message.id} key={message.id}
sticker={premiumGiftSticker} sticker={starGiftSticker}
play={canPlayAnimatedEmojis} play={canPlayAnimatedEmojis}
noLoop noLoop
nonInteractive nonInteractive
/> />
<strong> <div className="action-message-stars-balance">
{oldLang('Stars', message.content.action!.stars)} {formatInteger(message.content.action!.stars!)}
</strong> <strong>{oldLang('Stars')}</strong>
<span className="action-message-subtitle"> </div>
<span className="action-message-stars-subtitle">
{renderText( {renderText(
oldLang(!message.isOutgoing oldLang(!message.isOutgoing
? 'ActionGiftStarsSubtitleYou' : 'ActionGiftStarsSubtitle', getChatTitle(oldLang, targetChat!)), ? 'ActionGiftStarsSubtitleYou' : 'ActionGiftStarsSubtitle', getChatTitle(oldLang, targetChat!)),
@ -406,6 +411,10 @@ export default memo(withGlobal<OwnProps>(
const giftDuration = content.action?.months; const giftDuration = content.action?.months;
const premiumGiftSticker = selectGiftStickerForDuration(global, giftDuration); const premiumGiftSticker = selectGiftStickerForDuration(global, giftDuration);
const starCount = content.action?.stars;
const starGiftSticker = selectGiftStickerForStars(global, starCount);
const topic = selectTopicFromMessage(global, message); const topic = selectTopicFromMessage(global, message);
return { return {
@ -417,6 +426,7 @@ export default memo(withGlobal<OwnProps>(
targetMessage, targetMessage,
isFocused, isFocused,
premiumGiftSticker, premiumGiftSticker,
starGiftSticker,
topic, topic,
canPlayAnimatedEmojis: selectCanPlayAnimatedEmojis(global), canPlayAnimatedEmojis: selectCanPlayAnimatedEmojis(global),
...(isFocused && { ...(isFocused && {

View File

@ -273,7 +273,7 @@
} }
.action-message-gift-code { .action-message-gift-code {
width: 20rem; width: 12rem;
margin-inline: auto; margin-inline: auto;
} }
@ -282,12 +282,25 @@
margin-inline: auto; margin-inline: auto;
} }
.action-message-stars-balance {
margin-top: 0.5rem;
display: flex;
gap: 0.25rem;
line-height: 1.5;
font-weight: 500;
}
.action-message-subtitle { .action-message-subtitle {
margin-top: 1rem; margin-top: 1rem;
font-weight: normal; font-weight: normal;
text-wrap: balance; text-wrap: balance;
} }
.action-message-stars-subtitle {
font-weight: normal;
text-wrap: balance;
}
.action-message-suggested-avatar { .action-message-suggested-avatar {
max-width: 16rem; max-width: 16rem;
display: flex !important; display: flex !important;

View File

@ -30,6 +30,7 @@ type OwnProps = {
headerAvatarPeer?: ApiPeer | CustomPeer; headerAvatarPeer?: ApiPeer | CustomPeer;
headerAvatarWebPhoto?: ApiWebDocument; headerAvatarWebPhoto?: ApiWebDocument;
noHeaderImage?: boolean; noHeaderImage?: boolean;
isGift?: boolean;
header?: TeactNode; header?: TeactNode;
footer?: TeactNode; footer?: TeactNode;
buttonText?: string; buttonText?: string;
@ -47,6 +48,7 @@ const TableInfoModal = ({
headerAvatarPeer, headerAvatarPeer,
headerAvatarWebPhoto, headerAvatarWebPhoto,
noHeaderImage, noHeaderImage,
isGift,
header, header,
footer, footer,
buttonText, buttonText,
@ -73,7 +75,7 @@ const TableInfoModal = ({
contentClassName={styles.content} contentClassName={styles.content}
onClose={onClose} onClose={onClose}
> >
{!noHeaderImage && ( {!isGift && !noHeaderImage && (
withAvatar ? ( withAvatar ? (
<Avatar peer={headerAvatarPeer} webPhoto={headerAvatarWebPhoto} size="jumbo" className={styles.avatar} /> <Avatar peer={headerAvatarPeer} webPhoto={headerAvatarWebPhoto} size="jumbo" className={styles.avatar} />
) : ( ) : (

View File

@ -1,5 +1,12 @@
@use '../../../styles/mixins'; @use '../../../styles/mixins';
.options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
width: 100%;
}
.option { .option {
--_background-color: var(--color-background-secondary); --_background-color: var(--color-background-secondary);
display: flex; display: flex;
@ -60,3 +67,9 @@
margin-inline-start: 0.25rem; margin-inline-start: 0.25rem;
font-size: 1.5rem; font-size: 1.5rem;
} }
@media (max-width: 450px) {
.optionTop {
font-size: 1rem;
}
}

View File

@ -73,7 +73,7 @@ const StarTopupOptionList: FC<OwnProps> = ({
}, [areOptionsExtended, options, starsNeeded]); }, [areOptionsExtended, options, starsNeeded]);
return ( return (
<> <div className={styles.options}>
{renderingOptions?.map(({ option, starsCount, isWide }) => { {renderingOptions?.map(({ option, starsCount, isWide }) => {
const length = renderingOptions?.length; const length = renderingOptions?.length;
const isOdd = length % 2 === 0; const isOdd = length % 2 === 0;
@ -103,7 +103,7 @@ const StarTopupOptionList: FC<OwnProps> = ({
<Icon className={styles.iconDown} name="down" /> <Icon className={styles.iconDown} name="down" />
</Button> </Button>
)} )}
</> </div>
); );
}; };

View File

@ -136,23 +136,10 @@
z-index: 3; z-index: 3;
} }
.options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
width: 100%;
}
.optionFullWidth {
grid-column: 1 / -1;
}
.starButton { .starButton {
grid-column: 1/-1; grid-column: 1/-1;
display: flex;
justify-content: center;
align-items: center;
gap: 1rem; gap: 1rem;
margin-top: 0.5rem;
} }
.paymentContent { .paymentContent {

View File

@ -3,7 +3,7 @@ import React, {
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
import type { ApiStarTopupOption, ApiUser } from '../../../api/types'; import type { ApiUser } from '../../../api/types';
import type { GlobalState, TabState } from '../../../global/types'; import type { GlobalState, TabState } from '../../../global/types';
import { getUserFullName } from '../../../global/helpers'; import { getUserFullName } from '../../../global/helpers';
@ -24,7 +24,6 @@ import Modal from '../../ui/Modal';
import TabList, { type TabWithProperties } from '../../ui/TabList'; import TabList, { type TabWithProperties } from '../../ui/TabList';
import Transition from '../../ui/Transition'; import Transition from '../../ui/Transition';
import BalanceBlock from './BalanceBlock'; import BalanceBlock from './BalanceBlock';
import StarTopupOptionList from './StarTopupOptionList';
import TransactionItem from './transaction/StarsTransactionItem'; import TransactionItem from './transaction/StarsTransactionItem';
import styles from './StarsBalanceModal.module.scss'; import styles from './StarsBalanceModal.module.scss';
@ -53,10 +52,10 @@ const StarsBalanceModal = ({
modal, starsBalanceState, originPaymentBot, canBuyPremium, modal, starsBalanceState, originPaymentBot, canBuyPremium,
}: OwnProps & StateProps) => { }: OwnProps & StateProps) => {
const { const {
closeStarsBalanceModal, loadStarsTransactions, openInvoice, openStarsGiftingModal, closeStarsBalanceModal, loadStarsTransactions, openStarsGiftingModal, openStarsGiftModal,
} = getActions(); } = getActions();
const { balance, history, topupOptions } = starsBalanceState || {}; const { balance, history } = starsBalanceState || {};
const oldLang = useOldLang(); const oldLang = useOldLang();
const lang = useLang(); const lang = useLang();
@ -96,36 +95,20 @@ const StarsBalanceModal = ({
setHeaderHidden(scrollTop <= 150); setHeaderHidden(scrollTop <= 150);
} }
const handleClick = useLastCallback((option: ApiStarTopupOption) => {
openInvoice({
type: 'stars',
stars: option.stars,
currency: option.currency,
amount: option.amount,
});
});
function renderStarOptionList() {
return (
<StarTopupOptionList
isActive={isOpen}
options={topupOptions}
starsNeeded={starsNeeded}
onClick={handleClick}
/>
);
}
const handleLoadMore = useLastCallback(() => { const handleLoadMore = useLastCallback(() => {
loadStarsTransactions({ loadStarsTransactions({
type: TRANSACTION_TYPES[selectedTabIndex], type: TRANSACTION_TYPES[selectedTabIndex],
}); });
}); });
const openPremiumGiftingModalHandler = useLastCallback(() => { const openStarsGiftingModalHandler = useLastCallback(() => {
openStarsGiftingModal({}); openStarsGiftingModal({});
}); });
const openStarsInfoModalHandler = useLastCallback(() => {
openStarsGiftModal({});
});
return ( return (
<Modal className={styles.root} isOpen={isOpen} onClose={closeStarsBalanceModal}> <Modal className={styles.root} isOpen={isOpen} onClose={closeStarsBalanceModal}>
<div className={buildClassName(styles.main, 'custom-scroll')} onScroll={handleScroll}> <div className={buildClassName(styles.main, 'custom-scroll')} onScroll={handleScroll}>
@ -158,19 +141,24 @@ const StarsBalanceModal = ({
['simple_markdown', 'emoji'], ['simple_markdown', 'emoji'],
)} )}
</div> </div>
<div className={styles.options}> {canBuyPremium && (
{renderStarOptionList()} <Button
{canBuyPremium && ( className={styles.starButton}
<Button onClick={openStarsInfoModalHandler}
className={buildClassName(styles.starButton, 'settings-main-menu-star')} >
// eslint-disable-next-line react/jsx-no-bind {oldLang('Star.List.BuyMoreStars')}
onClick={openPremiumGiftingModalHandler} </Button>
> )}
<StarIcon className="icon" type="gold" size="big" /> {canBuyPremium && (
{oldLang('TelegramStarsGift')} <Button
</Button> className={buildClassName(styles.starButton, 'settings-main-menu-star')}
)} color="translucent"
</div> onClick={openStarsGiftingModalHandler}
>
<StarIcon className="icon" type="gold" size="big" />
{oldLang('TelegramStarsGift')}
</Button>
)}
</div> </div>
<div className={styles.secondaryInfo}> <div className={styles.secondaryInfo}>
{tosText} {tosText}

View File

@ -2,6 +2,10 @@
z-index: calc(var(--z-media-viewer) - 1); z-index: calc(var(--z-media-viewer) - 1);
} }
.modal :global(.modal-dialog) {
max-width: 26rem !important;
}
.positive { .positive {
color: var(--color-success); color: var(--color-success);
} }
@ -19,10 +23,14 @@
position: relative; position: relative;
} }
.starsHeader {
gap: normal;
}
.amount { .amount {
display: flex; display: flex;
gap: 0.25rem; gap: 0.25rem;
font-size: 1.25rem; font-size: 1rem;
font-weight: 500; font-weight: 500;
line-height: 1.325; line-height: 1.325;
} }
@ -44,6 +52,7 @@
.footer { .footer {
text-align: center; text-align: center;
margin-block: 0.5rem; margin-block: 0.5rem;
color: var(--color-text-secondary);
} }
.starsBackground { .starsBackground {
@ -67,3 +76,12 @@
margin-bottom: 2rem; margin-bottom: 2rem;
cursor: var(--custom-cursor, pointer); cursor: var(--custom-cursor, pointer);
} }
.starTitle {
font-size: 1.5rem;
}
.subtitle {
text-align: center;
margin-top: 1rem;
}

View File

@ -4,22 +4,29 @@ import { getActions, withGlobal } from '../../../../global';
import type { import type {
ApiPeer, ApiPeer,
ApiStarsTransactionPeer, ApiStarsTransactionPeer, ApiSticker, ApiUser,
} from '../../../../api/types'; } from '../../../../api/types';
import type { TabState } from '../../../../global/types'; import type { TabState } from '../../../../global/types';
import { MediaViewerOrigin } from '../../../../types'; import { MediaViewerOrigin } from '../../../../types';
import { getMessageLink } from '../../../../global/helpers'; import { getMessageLink, getUserFullName } from '../../../../global/helpers';
import { buildStarsTransactionCustomPeer, formatStarsTransactionAmount } from '../../../../global/helpers/payments'; import { buildStarsTransactionCustomPeer, formatStarsTransactionAmount } from '../../../../global/helpers/payments';
import { selectPeer } from '../../../../global/selectors'; import {
selectCanPlayAnimatedEmojis,
selectGiftStickerForStars,
selectPeer, selectUser,
} from '../../../../global/selectors';
import buildClassName from '../../../../util/buildClassName'; import buildClassName from '../../../../util/buildClassName';
import { copyTextToClipboard } from '../../../../util/clipboard'; import { copyTextToClipboard } from '../../../../util/clipboard';
import { formatDateTimeToString } from '../../../../util/dates/dateFormat'; import { formatDateTimeToString } from '../../../../util/dates/dateFormat';
import renderText from '../../../common/helpers/renderText';
import useLang from '../../../../hooks/useLang';
import useLastCallback from '../../../../hooks/useLastCallback'; import useLastCallback from '../../../../hooks/useLastCallback';
import useOldLang from '../../../../hooks/useOldLang'; import useOldLang from '../../../../hooks/useOldLang';
import usePrevious from '../../../../hooks/usePrevious'; import usePrevious from '../../../../hooks/usePrevious';
import AnimatedIconFromSticker from '../../../common/AnimatedIconFromSticker';
import Icon from '../../../common/icons/Icon'; import Icon from '../../../common/icons/Icon';
import StarIcon from '../../../common/icons/StarIcon'; import StarIcon from '../../../common/icons/StarIcon';
import SafeLink from '../../../common/SafeLink'; import SafeLink from '../../../common/SafeLink';
@ -36,14 +43,19 @@ export type OwnProps = {
type StateProps = { type StateProps = {
peer?: ApiPeer; peer?: ApiPeer;
user?: ApiUser;
canPlayAnimatedEmojis?: boolean;
starGiftSticker?: ApiSticker;
}; };
const StarsTransactionModal: FC<OwnProps & StateProps> = ({ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
modal, peer, modal, peer, user, canPlayAnimatedEmojis, starGiftSticker,
}) => { }) => {
const { showNotification, openMediaViewer, closeStarsTransactionModal } = getActions(); const { showNotification, openMediaViewer, closeStarsTransactionModal } = getActions();
const lang = useOldLang(); const oldLang = useOldLang();
const lang = useLang();
const { transaction } = modal || {}; const { transaction } = modal || {};
const isGift = transaction?.isGift;
const handleOpenMedia = useLastCallback(() => { const handleOpenMedia = useLastCallback(() => {
const media = transaction?.extendedMedia; const media = transaction?.extendedMedia;
@ -55,6 +67,60 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
}); });
}); });
const animatedStickerData = useMemo(() => {
if (!transaction) {
return undefined;
}
return (
<AnimatedIconFromSticker
key={transaction.id}
sticker={starGiftSticker}
play={canPlayAnimatedEmojis}
noLoop
nonInteractive
/>
);
}, [canPlayAnimatedEmojis, starGiftSticker, transaction]);
const giftEntryAboutText = useMemo(() => {
const subtitleText = oldLang('lng_credits_box_history_entry_gift_in_about');
const subtitleTextParts = subtitleText.split('{link}');
return (
<>
{subtitleTextParts[0]}
<SafeLink
url={oldLang('lng_credits_box_history_entry_gift_about_url')}
text={oldLang('GiftStarsSubtitleLinkName')}
>
{renderText(oldLang('GiftStarsSubtitleLinkName'), ['simple_markdown'])}
</SafeLink>
{subtitleTextParts[1]}
</>
);
}, [oldLang]);
const giftOutAboutText = useMemo(() => {
return lang(
'CreditsBoxHistoryEntryGiftOutAbout',
{
user: <strong>{user ? getUserFullName(user) : ''}</strong>,
link: (
<SafeLink
url={oldLang('lng_credits_box_history_entry_gift_about_url')}
text={oldLang('GiftStarsSubtitleLinkName')}
>
{renderText(oldLang('GiftStarsSubtitleLinkName'), ['simple_markdown'])}
</SafeLink>
),
},
{
withNodes: true,
},
);
}, [lang, user, oldLang]);
const starModalData = useMemo(() => { const starModalData = useMemo(() => {
if (!transaction) { if (!transaction) {
return undefined; return undefined;
@ -64,9 +130,9 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
&& buildStarsTransactionCustomPeer(transaction.peer)) || undefined; && buildStarsTransactionCustomPeer(transaction.peer)) || undefined;
const peerId = transaction.peer?.type === 'peer' ? transaction.peer.id : undefined; const peerId = transaction.peer?.type === 'peer' ? transaction.peer.id : undefined;
const toName = transaction.peer && lang(getStarsPeerTitleKey(transaction.peer)); const toName = transaction.peer && oldLang(getStarsPeerTitleKey(transaction.peer));
const title = transaction.title || (customPeer ? lang(customPeer.titleKey) : undefined); const title = transaction.title || (customPeer ? oldLang(customPeer.titleKey) : undefined);
const messageLink = peer && transaction.messageId const messageLink = peer && transaction.messageId
? getMessageLink(peer, undefined, transaction.messageId) : undefined; ? getMessageLink(peer, undefined, transaction.messageId) : undefined;
@ -77,14 +143,14 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
const areAllPhotos = media?.every((m) => !m.video); const areAllPhotos = media?.every((m) => !m.video);
const areAllVideos = media?.every((m) => !m.photo); const areAllVideos = media?.every((m) => !m.photo);
const mediaText = areAllPhotos ? lang('Stars.Transfer.Photos', mediaAmount) const mediaText = areAllPhotos ? oldLang('Stars.Transfer.Photos', mediaAmount)
: areAllVideos ? lang('Stars.Transfer.Videos', mediaAmount) : areAllVideos ? oldLang('Stars.Transfer.Videos', mediaAmount)
: lang('Media', mediaAmount); : oldLang('Media', mediaAmount);
const description = transaction.description || (media ? mediaText : undefined); const description = transaction.description || (media ? mediaText : undefined);
const header = ( const header = (
<div className={styles.header}> <div className={buildClassName(styles.header, styles.starsHeader)}>
{media && ( {media && (
<PaidMediaThumb <PaidMediaThumb
className={buildClassName(styles.mediaPreview, 'transaction-media-preview')} className={buildClassName(styles.mediaPreview, 'transaction-media-preview')}
@ -92,45 +158,57 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
onClick={handleOpenMedia} onClick={handleOpenMedia}
/> />
)} )}
<img {isGift ? animatedStickerData : (
className={buildClassName(styles.starsBackground, media && styles.mediaShift)} <img
src={StarsBackground} className={buildClassName(styles.starsBackground, media && styles.mediaShift)}
alt="" src={StarsBackground}
draggable={false} alt=""
/> draggable={false}
/>
)}
{title && <h1 className={styles.title}>{title}</h1>} {title && <h1 className={styles.title}>{title}</h1>}
{isGift && (
<h1 className={buildClassName(styles.title, styles.starTitle)}>
{transaction?.isMyGift ? oldLang('StarsGiftSent') : oldLang('StarsGiftReceived')}
</h1>
)}
<p className={styles.description}>{description}</p> <p className={styles.description}>{description}</p>
<p className={styles.amount}> <p className={styles.amount}>
<span className={buildClassName(styles.amount, transaction.stars < 0 ? styles.negative : styles.positive)}> <span className={buildClassName(styles.amount, transaction.stars < 0 ? styles.negative : styles.positive)}>
{formatStarsTransactionAmount(transaction.stars)} {formatStarsTransactionAmount(transaction.stars)}
</span> </span>
<StarIcon type="gold" size="big" /> <StarIcon type="gold" size="middle" />
</p> </p>
{isGift && (
<span className={styles.subtitle}>
{transaction?.isMyGift ? giftOutAboutText : giftEntryAboutText}
</span>
)}
</div> </div>
); );
const tableData: TableData = []; const tableData: TableData = [];
tableData.push([ tableData.push([
lang(transaction.stars < 0 || transaction.isMyGift ? 'Stars.Transaction.To' oldLang(transaction.stars < 0 || transaction.isMyGift ? 'Stars.Transaction.To'
: peerId ? 'Star.Transaction.From' : 'Stars.Transaction.Via'), : peerId ? 'Star.Transaction.From' : 'Stars.Transaction.Via'),
peerId ? { chatId: peerId } : toName || '', peerId ? { chatId: peerId } : toName || '',
]); ]);
if (messageLink) { if (messageLink) {
tableData.push([lang('Stars.Transaction.Media'), <SafeLink url={messageLink} text={messageLink} />]); tableData.push([oldLang('Stars.Transaction.Media'), <SafeLink url={messageLink} text={messageLink} />]);
} }
if (transaction.id) { if (transaction.id) {
tableData.push([ tableData.push([
lang('Stars.Transaction.Id'), oldLang('Stars.Transaction.Id'),
( (
<span <span
className={styles.tid} className={styles.tid}
onClick={() => { onClick={() => {
copyTextToClipboard(transaction.id!); copyTextToClipboard(transaction.id!);
showNotification({ showNotification({
message: lang('StarsTransactionIDCopied'), message: oldLang('StarsTransactionIDCopied'),
}); });
}} }}
> >
@ -142,17 +220,17 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
} }
tableData.push([ tableData.push([
lang('Stars.Transaction.Date'), oldLang('Stars.Transaction.Date'),
formatDateTimeToString(transaction.date * 1000, lang.code, true), formatDateTimeToString(transaction.date * 1000, oldLang.code, true),
]); ]);
const footerText = lang('lng_credits_box_out_about'); const footerText = oldLang('lng_credits_box_out_about');
const footerTextParts = footerText.split('{link}'); const footerTextParts = footerText.split('{link}');
const footer = ( const footer = (
<span className={styles.footer}> <span className={styles.footer}>
{footerTextParts[0]} {footerTextParts[0]}
<SafeLink url={lang('StarsTOSLink')} text={lang('lng_credits_summary_options_about_link')} /> <SafeLink url={oldLang('StarsTOSLink')} text={oldLang('lng_credits_summary_options_about_link')} />
{footerTextParts[1]} {footerTextParts[1]}
</span> </span>
); );
@ -163,7 +241,7 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
footer, footer,
avatarPeer: !transaction.photo ? (peer || customPeer) : undefined, avatarPeer: !transaction.photo ? (peer || customPeer) : undefined,
}; };
}, [lang, transaction, peer]); }, [transaction, oldLang, peer, isGift, animatedStickerData, giftOutAboutText, giftEntryAboutText]);
const prevModalData = usePrevious(starModalData); const prevModalData = usePrevious(starModalData);
const renderingModalData = prevModalData || starModalData; const renderingModalData = prevModalData || starModalData;
@ -173,12 +251,13 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
isOpen={Boolean(transaction)} isOpen={Boolean(transaction)}
className={styles.modal} className={styles.modal}
header={renderingModalData?.header} header={renderingModalData?.header}
isGift={isGift}
tableData={renderingModalData?.tableData} tableData={renderingModalData?.tableData}
footer={renderingModalData?.footer} footer={renderingModalData?.footer}
noHeaderImage={Boolean(transaction?.extendedMedia)} noHeaderImage={Boolean(transaction?.extendedMedia)}
headerAvatarWebPhoto={transaction?.photo} headerAvatarWebPhoto={transaction?.photo}
headerAvatarPeer={renderingModalData?.avatarPeer} headerAvatarPeer={renderingModalData?.avatarPeer}
buttonText={lang('OK')} buttonText={oldLang('OK')}
onClose={closeStarsTransactionModal} onClose={closeStarsTransactionModal}
/> />
); );
@ -188,9 +267,16 @@ export default memo(withGlobal<OwnProps>(
(global, { modal }): StateProps => { (global, { modal }): StateProps => {
const peerId = modal?.transaction?.peer?.type === 'peer' && modal.transaction.peer.id; const peerId = modal?.transaction?.peer?.type === 'peer' && modal.transaction.peer.id;
const peer = peerId ? selectPeer(global, peerId) : undefined; const peer = peerId ? selectPeer(global, peerId) : undefined;
const user = peerId ? selectUser(global, peerId) : undefined;
const starCount = modal?.transaction.stars;
const starGiftSticker = selectGiftStickerForStars(global, starCount);
return { return {
peer, peer,
user,
canPlayAnimatedEmojis: selectCanPlayAnimatedEmojis(global),
starGiftSticker,
}; };
}, },
)(StarsTransactionModal)); )(StarsTransactionModal));

View File

@ -583,7 +583,8 @@ addActionHandler('closePremiumGiftModal', (global, actions, payload): ActionRetu
addActionHandler('openStarsGiftModal', async (global, actions, payload): Promise<void> => { addActionHandler('openStarsGiftModal', async (global, actions, payload): Promise<void> => {
const { const {
forUserId, tabId = getCurrentTabId(), forUserId,
tabId = getCurrentTabId(),
} = payload || {}; } = payload || {};
const starsGiftOptions = await callApi('getStarsGiftOptions', {}); const starsGiftOptions = await callApi('getStarsGiftOptions', {});

View File

@ -64,6 +64,23 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
} }
} }
if (inputInvoice?.type === 'stars') {
if (!inputInvoice.stars) {
return;
}
const starsModalState = selectTabState(global, tabId).starsGiftModal;
if (starsModalState && starsModalState.isOpen) {
global = updateTabState(global, {
starsGiftModal: {
...starsModalState,
isCompleted: true,
},
}, tabId);
global = closeInvoice(global, tabId);
}
}
setGlobal(global); setGlobal(global);
}); });

View File

@ -15,6 +15,12 @@ const MONTH_EMOTICON: Record<number, string> = {
24: `${5}\u{FE0F}\u20E3`, 24: `${5}\u{FE0F}\u20E3`,
}; };
const STAR_EMOTICON: Record<number, string> = {
1000: `${2}\u{FE0F}\u20E3`,
2500: `${3}\u{FE0F}\u20E3`,
5000: `${4}\u{FE0F}\u20E3`,
};
export function selectIsStickerFavorite<T extends GlobalState>(global: T, sticker: ApiSticker) { export function selectIsStickerFavorite<T extends GlobalState>(global: T, sticker: ApiSticker) {
const { stickers } = global.stickers.favorite; const { stickers } = global.stickers.favorite;
return stickers && stickers.some(({ id }) => id === sticker.id); return stickers && stickers.some(({ id }) => id === sticker.id);
@ -156,3 +162,20 @@ export function selectGiftStickerForDuration<T extends GlobalState>(global: T, d
const emoji = MONTH_EMOTICON[duration]; const emoji = MONTH_EMOTICON[duration];
return stickers.find((sticker) => sticker.emoji === emoji) || stickers[0]; return stickers.find((sticker) => sticker.emoji === emoji) || stickers[0];
} }
export function selectGiftStickerForStars<T extends GlobalState>(global: T, starCount?: number) {
const stickers = global.premiumGifts?.stickers;
if (!stickers || !starCount) return undefined;
let emoji;
if (starCount <= 1000) {
emoji = STAR_EMOTICON[1000];
} else if (starCount < 2500) {
emoji = STAR_EMOTICON[2500];
} else {
emoji = STAR_EMOTICON[5000];
}
return stickers.find((sticker) => sticker.emoji === emoji) || stickers[0];
}

View File

@ -63,7 +63,7 @@ import type {
ApiSendMessageAction, ApiSendMessageAction,
ApiSession, ApiSession,
ApiSessionData, ApiSessionData,
ApiSponsoredMessage, ApiStarsGiftOption, ApiSponsoredMessage,
ApiStarsTransaction, ApiStarsTransaction,
ApiStarTopupOption, ApiStarTopupOption,
ApiStealthMode, ApiStealthMode,
@ -725,7 +725,7 @@ export type TabState = {
isCompleted?: boolean; isCompleted?: boolean;
isOpen?: boolean; isOpen?: boolean;
forUserId?: string; forUserId?: string;
starsGiftOptions?: ApiStarsGiftOption[]; starsGiftOptions?: ApiStarTopupOption[];
}; };
starsTransactionModal?: { starsTransactionModal?: {
@ -737,7 +737,7 @@ export type TabState = {
isOpen?: boolean; isOpen?: boolean;
forUserIds?: string[]; forUserIds?: string[];
gifts?: ApiPremiumGiftCodeOption[]; gifts?: ApiPremiumGiftCodeOption[];
starsGiftOptions?: ApiStarsGiftOption[]; starsGiftOptions?: ApiStarTopupOption[];
}; };
limitReachedModal?: { limitReachedModal?: {