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,
ApiInvoice, ApiLabeledPrice, ApiMyBoost, ApiPaymentCredentials,
ApiPaymentForm, ApiPaymentSavedInfo, ApiPremiumGiftCodeOption, ApiPremiumPromo, ApiPremiumSubscriptionOption,
ApiReceipt, ApiStarsGiftOption,
ApiReceipt,
ApiStarsTransaction,
ApiStarsTransactionPeer,
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 {
extended, stars, amount, currency,
} = option;

View File

@ -159,13 +159,6 @@ export interface ApiPremiumGiftCodeOption {
amount: number;
}
export interface ApiStarsGiftOption {
isExtended?: true;
stars: number;
currency: string;
amount: number;
}
export type ApiBoostsStatus = {
level: 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 {
width: 7.5rem;
height: 7.5rem;

View File

@ -45,7 +45,8 @@ import './Avatar.scss';
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');
cn.media = cn('media');

View File

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

View File

@ -37,6 +37,7 @@ const SIZES: Record<AvatarSize, number> = {
medium: 2.875 * REM,
large: 3.5 * REM,
giant: 5.125 * REM,
huge: 6.125 * 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 { TextPart } from '../../../types';
import { SERVICE_NOTIFICATIONS_USER_ID } from '../../../config';
import {
getChatTitle,
getExpiredMessageDescription,
@ -111,7 +112,9 @@ export function renderActionMessageText(
actionOriginChat ? (
renderChatContent(lang, actionOriginChat, noLinks) || NBSP
) : actionOriginUser ? (
renderUserContent(actionOriginUser, noLinks) || NBSP
actionOriginUser.id === SERVICE_NOTIFICATIONS_USER_ID
? lang('StarsTransactionUnknown')
: renderUserContent(actionOriginUser, noLinks) || NBSP
) : 'User',
'',
);

View File

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

View File

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

View File

@ -30,7 +30,39 @@
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 {
display: flex;
flex-direction: column;
height: 100%;
padding: 0.5rem;
}
@ -67,41 +99,24 @@
z-index: 3;
}
.avatars {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 1rem;
margin: 1rem;
.avatar {
margin: 2rem;
}
.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 {
text-align: center;
font-size: 0.875rem;
color: var(--color-text-secondary);
padding: 0.5rem 1rem;
padding: 1rem;
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 type {
ApiStarsGiftOption, ApiStarTopupOption, ApiUser,
ApiStarTopupOption, ApiUser,
} from '../../../api/types';
import { getSenderTitle } from '../../../global/helpers';
import {
selectTabState, selectUser,
} from '../../../global/selectors';
@ -27,13 +28,16 @@ import Modal from '../../ui/Modal';
import styles from './StarsGiftModal.module.scss';
import StarLogo from '../../../assets/icons/StarLogo.svg';
import StarsBackground from '../../../assets/stars-bg.png';
export type OwnProps = {
isOpen?: boolean;
};
type StateProps = {
isCompleted?: boolean;
starsGiftOptions?: ApiStarsGiftOption[] | undefined;
starsGiftOptions?: ApiStarTopupOption[] | undefined;
forUserId?: string;
user?: ApiUser;
};
@ -88,13 +92,22 @@ const StarsGiftModal: FC<OwnProps & StateProps> = ({
const handleClick = useLastCallback((option: ApiStarTopupOption) => {
setSelectedOption(option);
openInvoice({
type: 'starsgift',
userId: forUserId!,
stars: option.stars,
currency: option.currency,
amount: option.amount,
});
if (user) {
openInvoice({
type: 'starsgift',
userId: forUserId!,
stars: option.stars,
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>) {
@ -105,11 +118,12 @@ const StarsGiftModal: FC<OwnProps & StateProps> = ({
function renderGiftTitle() {
if (isCompleted) {
return renderText(oldLang('Notification.StarsGift.SentYou',
formatCurrencyAsString(selectedOption!.amount, selectedOption!.currency, oldLang.code)), ['simple_markdown']);
return user ? renderText(oldLang('Notification.StarsGift.SentYou',
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() {
@ -154,28 +168,40 @@ const StarsGiftModal: FC<OwnProps & StateProps> = ({
</Button>
<div className={buildClassName(styles.header, isHeaderHidden && styles.hiddenHeader)}>
<h2 className={styles.starHeaderText}>
{oldLang('GiftStarsTitle')}
{user ? oldLang('GiftStarsTitle') : oldLang('Star.List.GetStars')}
</h2>
</div>
<div className={styles.avatars}>
<Avatar
size="large"
peer={user}
/>
<div className={styles.headerInfo}>
{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>
<h2 className={buildClassName(styles.headerText, styles.center)}>
{renderGiftTitle()}
</h2>
{!isCompleted && (
<>
<div className={buildClassName(styles.section, styles.options)}>
{renderStarOptionList()}
</div>
<div className={styles.secondaryInfo}>
{bottomText}
</div>
</>
)}
<p className={styles.description}>
{user ? renderText(
oldLang('ActionGiftStarsSubtitle', getSenderTitle(oldLang, user)), ['simple_markdown'],
) : oldLang('Stars.Purchase.GetStarsInfo')}
</p>
<div className={styles.section}>
{renderStarOptionList()}
<div className={styles.secondaryInfo}>
{bottomText}
</div>
</div>
</div>
</Modal>
);

View File

@ -1,7 +1,6 @@
import type { FC } from '../../../lib/teact/teact';
import React, {
memo, useMemo,
useRef,
useState,
} from '../../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../../global';
@ -10,17 +9,14 @@ import { SERVICE_NOTIFICATIONS_USER_ID } from '../../../config';
import {
filterUsersByName, isDeletedUser, isUserBot,
} from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName';
import { unique } from '../../../util/iteratees';
import sortChatIds from '../../common/helpers/sortChatIds';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
import Icon from '../../common/icons/Icon';
import PeerPicker from '../../common/pickers/PeerPicker';
import Button from '../../ui/Button';
import Modal from '../../ui/Modal';
import PickerModal from '../../common/pickers/PickerModal';
import styles from './StarsGiftingPickerModal.module.scss';
@ -42,8 +38,6 @@ const StarsGiftingPickerModal: FC<OwnProps & StateProps> = ({
archivedListIds,
userIds,
}) => {
// eslint-disable-next-line no-null/no-null
const dialogRef = useRef<HTMLDivElement>(null);
const { closeStarsGiftingModal, openStarsGiftModal } = getActions();
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 (
<Modal
<PickerModal
className={styles.root}
isOpen={isOpen}
onClose={closeStarsGiftingModal}
onEnter={handleSelectedUserIdsChange}
dialogRef={dialogRef}
title={oldLang('GiftStarsTitle')}
hasCloseButton
shouldAdaptToSearch
withFixedHeight
confirmButtonText={oldLang('Continue')}
onEnter={closeStarsGiftingModal}
>
<div className={buildClassName(styles.main, 'custom-scroll')}>
{renderHeaderText()}
<PeerPicker
className={styles.picker}
itemIds={displayedUserIds}
filterValue={searchQuery}
filterPlaceholder={oldLang('Search')}
onFilterChange={setSearchQuery}
isSearchable
withDefaultPadding
withStatus
onSelectedIdChange={handleSelectedUserIdsChange}
/>
</div>
</Modal>
<PeerPicker
className={styles.picker}
itemIds={displayedUserIds}
filterValue={searchQuery}
filterPlaceholder={oldLang('Search')}
onFilterChange={setSearchQuery}
isSearchable
withDefaultPadding
withStatus
onSelectedIdChange={handleSelectedUserIdsChange}
/>
</PickerModal>
);
};

View File

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

View File

@ -273,7 +273,7 @@
}
.action-message-gift-code {
width: 20rem;
width: 12rem;
margin-inline: auto;
}
@ -282,12 +282,25 @@
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 {
margin-top: 1rem;
font-weight: normal;
text-wrap: balance;
}
.action-message-stars-subtitle {
font-weight: normal;
text-wrap: balance;
}
.action-message-suggested-avatar {
max-width: 16rem;
display: flex !important;

View File

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

View File

@ -1,5 +1,12 @@
@use '../../../styles/mixins';
.options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
width: 100%;
}
.option {
--_background-color: var(--color-background-secondary);
display: flex;
@ -60,3 +67,9 @@
margin-inline-start: 0.25rem;
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]);
return (
<>
<div className={styles.options}>
{renderingOptions?.map(({ option, starsCount, isWide }) => {
const length = renderingOptions?.length;
const isOdd = length % 2 === 0;
@ -103,7 +103,7 @@ const StarTopupOptionList: FC<OwnProps> = ({
<Icon className={styles.iconDown} name="down" />
</Button>
)}
</>
</div>
);
};

View File

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

View File

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

View File

@ -2,6 +2,10 @@
z-index: calc(var(--z-media-viewer) - 1);
}
.modal :global(.modal-dialog) {
max-width: 26rem !important;
}
.positive {
color: var(--color-success);
}
@ -19,10 +23,14 @@
position: relative;
}
.starsHeader {
gap: normal;
}
.amount {
display: flex;
gap: 0.25rem;
font-size: 1.25rem;
font-size: 1rem;
font-weight: 500;
line-height: 1.325;
}
@ -44,6 +52,7 @@
.footer {
text-align: center;
margin-block: 0.5rem;
color: var(--color-text-secondary);
}
.starsBackground {
@ -67,3 +76,12 @@
margin-bottom: 2rem;
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 {
ApiPeer,
ApiStarsTransactionPeer,
ApiStarsTransactionPeer, ApiSticker, ApiUser,
} from '../../../../api/types';
import type { TabState } from '../../../../global/types';
import { MediaViewerOrigin } from '../../../../types';
import { getMessageLink } from '../../../../global/helpers';
import { getMessageLink, getUserFullName } from '../../../../global/helpers';
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 { copyTextToClipboard } from '../../../../util/clipboard';
import { formatDateTimeToString } from '../../../../util/dates/dateFormat';
import renderText from '../../../common/helpers/renderText';
import useLang from '../../../../hooks/useLang';
import useLastCallback from '../../../../hooks/useLastCallback';
import useOldLang from '../../../../hooks/useOldLang';
import usePrevious from '../../../../hooks/usePrevious';
import AnimatedIconFromSticker from '../../../common/AnimatedIconFromSticker';
import Icon from '../../../common/icons/Icon';
import StarIcon from '../../../common/icons/StarIcon';
import SafeLink from '../../../common/SafeLink';
@ -36,14 +43,19 @@ export type OwnProps = {
type StateProps = {
peer?: ApiPeer;
user?: ApiUser;
canPlayAnimatedEmojis?: boolean;
starGiftSticker?: ApiSticker;
};
const StarsTransactionModal: FC<OwnProps & StateProps> = ({
modal, peer,
modal, peer, user, canPlayAnimatedEmojis, starGiftSticker,
}) => {
const { showNotification, openMediaViewer, closeStarsTransactionModal } = getActions();
const lang = useOldLang();
const oldLang = useOldLang();
const lang = useLang();
const { transaction } = modal || {};
const isGift = transaction?.isGift;
const handleOpenMedia = useLastCallback(() => {
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(() => {
if (!transaction) {
return undefined;
@ -64,9 +130,9 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
&& buildStarsTransactionCustomPeer(transaction.peer)) || 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
? getMessageLink(peer, undefined, transaction.messageId) : undefined;
@ -77,14 +143,14 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
const areAllPhotos = media?.every((m) => !m.video);
const areAllVideos = media?.every((m) => !m.photo);
const mediaText = areAllPhotos ? lang('Stars.Transfer.Photos', mediaAmount)
: areAllVideos ? lang('Stars.Transfer.Videos', mediaAmount)
: lang('Media', mediaAmount);
const mediaText = areAllPhotos ? oldLang('Stars.Transfer.Photos', mediaAmount)
: areAllVideos ? oldLang('Stars.Transfer.Videos', mediaAmount)
: oldLang('Media', mediaAmount);
const description = transaction.description || (media ? mediaText : undefined);
const header = (
<div className={styles.header}>
<div className={buildClassName(styles.header, styles.starsHeader)}>
{media && (
<PaidMediaThumb
className={buildClassName(styles.mediaPreview, 'transaction-media-preview')}
@ -92,45 +158,57 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
onClick={handleOpenMedia}
/>
)}
<img
className={buildClassName(styles.starsBackground, media && styles.mediaShift)}
src={StarsBackground}
alt=""
draggable={false}
/>
{isGift ? animatedStickerData : (
<img
className={buildClassName(styles.starsBackground, media && styles.mediaShift)}
src={StarsBackground}
alt=""
draggable={false}
/>
)}
{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.amount}>
<span className={buildClassName(styles.amount, transaction.stars < 0 ? styles.negative : styles.positive)}>
{formatStarsTransactionAmount(transaction.stars)}
</span>
<StarIcon type="gold" size="big" />
<StarIcon type="gold" size="middle" />
</p>
{isGift && (
<span className={styles.subtitle}>
{transaction?.isMyGift ? giftOutAboutText : giftEntryAboutText}
</span>
)}
</div>
);
const tableData: TableData = [];
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 ? { chatId: peerId } : toName || '',
]);
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) {
tableData.push([
lang('Stars.Transaction.Id'),
oldLang('Stars.Transaction.Id'),
(
<span
className={styles.tid}
onClick={() => {
copyTextToClipboard(transaction.id!);
showNotification({
message: lang('StarsTransactionIDCopied'),
message: oldLang('StarsTransactionIDCopied'),
});
}}
>
@ -142,17 +220,17 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
}
tableData.push([
lang('Stars.Transaction.Date'),
formatDateTimeToString(transaction.date * 1000, lang.code, true),
oldLang('Stars.Transaction.Date'),
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 footer = (
<span className={styles.footer}>
{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]}
</span>
);
@ -163,7 +241,7 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
footer,
avatarPeer: !transaction.photo ? (peer || customPeer) : undefined,
};
}, [lang, transaction, peer]);
}, [transaction, oldLang, peer, isGift, animatedStickerData, giftOutAboutText, giftEntryAboutText]);
const prevModalData = usePrevious(starModalData);
const renderingModalData = prevModalData || starModalData;
@ -173,12 +251,13 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
isOpen={Boolean(transaction)}
className={styles.modal}
header={renderingModalData?.header}
isGift={isGift}
tableData={renderingModalData?.tableData}
footer={renderingModalData?.footer}
noHeaderImage={Boolean(transaction?.extendedMedia)}
headerAvatarWebPhoto={transaction?.photo}
headerAvatarPeer={renderingModalData?.avatarPeer}
buttonText={lang('OK')}
buttonText={oldLang('OK')}
onClose={closeStarsTransactionModal}
/>
);
@ -188,9 +267,16 @@ export default memo(withGlobal<OwnProps>(
(global, { modal }): StateProps => {
const peerId = modal?.transaction?.peer?.type === 'peer' && modal.transaction.peer.id;
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 {
peer,
user,
canPlayAnimatedEmojis: selectCanPlayAnimatedEmojis(global),
starGiftSticker,
};
},
)(StarsTransactionModal));

View File

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

View File

@ -15,6 +15,12 @@ const MONTH_EMOTICON: Record<number, string> = {
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) {
const { stickers } = global.stickers.favorite;
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];
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,
ApiSession,
ApiSessionData,
ApiSponsoredMessage, ApiStarsGiftOption,
ApiSponsoredMessage,
ApiStarsTransaction,
ApiStarTopupOption,
ApiStealthMode,
@ -725,7 +725,7 @@ export type TabState = {
isCompleted?: boolean;
isOpen?: boolean;
forUserId?: string;
starsGiftOptions?: ApiStarsGiftOption[];
starsGiftOptions?: ApiStarTopupOption[];
};
starsTransactionModal?: {
@ -737,7 +737,7 @@ export type TabState = {
isOpen?: boolean;
forUserIds?: string[];
gifts?: ApiPremiumGiftCodeOption[];
starsGiftOptions?: ApiStarsGiftOption[];
starsGiftOptions?: ApiStarTopupOption[];
};
limitReachedModal?: {