Stars Gifting: Implement Stars Gifting for users (#4847)

This commit is contained in:
Alexander Zinchuk 2024-08-29 15:52:21 +02:00
parent 7d1eaa55da
commit 6f292c9032
49 changed files with 1668 additions and 387 deletions

View File

@ -352,6 +352,7 @@ function buildAction(
let phoneCall: PhoneCallAction | undefined;
let call: Partial<ApiGroupCall> | undefined;
let amount: number | undefined;
let stars: number | undefined;
let currency: string | undefined;
let giftCryptoInfo: {
currency: string;
@ -370,11 +371,11 @@ function buildAction(
let isUnclaimed: boolean | undefined;
let pluralValue: number | undefined;
const targetUserIds = 'users' in action
let targetUserIds = 'users' in action
? action.users && action.users.map((id) => buildApiPeerId(id, 'user'))
: ('userId' in action && [buildApiPeerId(action.userId, 'user')]) || [];
let targetChatId: string | undefined;
let targetChatId;
if (action instanceof GramJs.MessageActionChatCreate) {
text = 'Notification.CreatedChatWithTitle';
translationValues.push('%action_origin%', action.title);
@ -611,6 +612,27 @@ function buildAction(
text = 'ActionRefunded';
amount = Number(action.totalAmount);
currency = action.currency;
} else if (action instanceof GramJs.MessageActionRequestedPeer) {
text = 'ActionRequestedPeer';
if (action.peers) {
targetUserIds = action.peers?.map((peer) => getApiChatIdFromMtpPeer(peer));
}
if (targetPeerId) {
translationValues.unshift('%action_origin%');
}
} else if (action instanceof GramJs.MessageActionGiftStars) {
text = isOutgoing ? 'ActionGiftOutbound' : 'BoostingReceivedGiftNoName';
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();
stars = action.stars.toJSNumber();
} else {
text = 'ChatList.UnsupportedMessage';
}
@ -628,6 +650,7 @@ function buildAction(
targetChatId,
photo, // TODO Only used internally now, will be used for the UI in future
amount,
stars,
currency,
giftCryptoInfo,
isGiveaway,

View File

@ -8,7 +8,7 @@ import type {
ApiGiveawayInfo,
ApiInvoice, ApiLabeledPrice, ApiMyBoost, ApiPaymentCredentials,
ApiPaymentForm, ApiPaymentSavedInfo, ApiPremiumGiftCodeOption, ApiPremiumPromo, ApiPremiumSubscriptionOption,
ApiReceipt,
ApiReceipt, ApiStarsGiftOption,
ApiStarsTransaction,
ApiStarsTransactionPeer,
ApiStarTopupOption,
@ -383,6 +383,19 @@ export function buildApiPremiumGiftCodeOption(option: GramJs.PremiumGiftCodeOpti
};
}
export function buildApiStarsGiftOptions(option: GramJs.StarsGiftOption): ApiStarsGiftOption {
const {
extended, stars, amount, currency,
} = option;
return {
isExtended: extended,
stars: stars.toJSNumber(),
amount: amount.toJSNumber(),
currency,
};
}
export function buildApiStarsTransactionPeer(peer: GramJs.TypeStarsTransactionPeer): ApiStarsTransactionPeer {
if (peer instanceof GramJs.StarsTransactionPeerAppStore) {
return { type: 'appStore' };

View File

@ -546,6 +546,15 @@ GramJs.TypeInputStorePaymentPurpose {
});
}
if (purpose.type === 'starsgift') {
return new GramJs.InputStorePaymentStarsGift({
userId: buildInputEntity(purpose.user.id, purpose.user.accessHash) as GramJs.InputUser,
stars: BigInt(purpose.stars),
currency: purpose.currency,
amount: BigInt(purpose.amount),
});
}
if (purpose.type === 'giftcode') {
return new GramJs.InputStorePaymentPremiumGiftCode({
users: purpose.users.map((user) => buildInputEntity(user.id, user.accessHash) as GramJs.InputUser),

View File

@ -101,7 +101,7 @@ export {
validateRequestedInfo, sendPaymentForm, getPaymentForm, getReceipt, fetchPremiumPromo, fetchTemporaryPaymentPassword,
applyBoost, fetchBoostList, fetchBoostStatus, fetchGiveawayInfo, fetchMyBoosts, applyGiftCode, checkGiftCode,
getPremiumGiftCodeOptions, launchPrepaidGiveaway, fetchStarsStatus, fetchStarsTopupOptions, fetchStarsTransactions,
sendStarPaymentForm,
sendStarPaymentForm, getStarsGiftOptions,
} from './payments';
export * from './fragment';

View File

@ -19,7 +19,7 @@ import {
buildApiPaymentForm,
buildApiPremiumGiftCodeOption,
buildApiPremiumPromo,
buildApiReceipt,
buildApiReceipt, buildApiStarsGiftOptions,
buildApiStarsTransaction,
buildApiStarTopupOption,
buildShippingOptions,
@ -403,6 +403,22 @@ export async function getPremiumGiftCodeOptions({
return result.map(buildApiPremiumGiftCodeOption);
}
export async function getStarsGiftOptions({
chat,
}: {
chat?: ApiChat;
}) {
const result = await invokeRequest(new GramJs.payments.GetStarsGiftOptions({
userId: chat && buildInputPeer(chat.id, chat.accessHash),
}));
if (!result) {
return undefined;
}
return result.map(buildApiStarsGiftOptions);
}
export function launchPrepaidGiveaway({
chat,
giveawayId,

View File

@ -240,8 +240,16 @@ export type ApiInputInvoiceStars = {
amount: number;
};
export type ApiInputInvoiceStarsGift = {
type: 'starsgift';
userId: string;
stars: number;
currency: string;
amount: number;
};
export type ApiInputInvoice = ApiInputInvoiceMessage | ApiInputInvoiceSlug | ApiInputInvoiceGiveaway
| ApiInputInvoiceGiftCode | ApiInputInvoiceStars;
| ApiInputInvoiceGiftCode | ApiInputInvoiceStarsGift | ApiInputInvoiceStars;
/* Used for Invoice request */
export type ApiRequestInputInvoiceMessage = {
@ -392,6 +400,8 @@ export interface ApiAction {
| 'other';
photo?: ApiPhoto;
amount?: number;
stars?: number;
transactionId?: string;
currency?: string;
giftCryptoInfo?: {
currency: string;

View File

@ -142,8 +142,16 @@ export type ApiInputStorePaymentStarsTopup = {
amount: number;
};
export type ApiInputStorePaymentStarsGift = {
type: 'starsgift';
user: ApiUser;
stars: number;
currency: string;
amount: number;
};
export type ApiInputStorePaymentPurpose = ApiInputStorePaymentGiveaway | ApiInputStorePaymentGiftcode |
ApiInputStorePaymentStarsTopup;
ApiInputStorePaymentStarsTopup | ApiInputStorePaymentStarsGift;
export interface ApiPremiumGiftCodeOption {
users: number;
@ -152,6 +160,13 @@ 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

@ -1273,3 +1273,6 @@
"ReplyInPrivateMessage" = "Reply In Private Message";
"AriaSearchOlderResult" = "Focus next result";
"AriaSearchNewerResult" = "Focus previous result";
"CreditsBoxHistoryEntryGiftOutAbout" = "With Stars, {user} will be able to unlock content and services on Telegram. {link}"
"CreditsBoxOutAbout" = "Review the {link} for Stars."
"GiftStarsOutgoing" = "With Stars, {user} will be able to unlock content and services on Telegram."

View File

@ -16,13 +16,16 @@ export { default as BotTrustModal } from '../components/main/BotTrustModal';
export { default as AttachBotInstallModal } from '../components/modals/attachBotInstall/AttachBotInstallModal';
export { default as DeleteFolderDialog } from '../components/main/DeleteFolderDialog';
export { default as PremiumMainModal } from '../components/main/premium/PremiumMainModal';
export { default as GiftPremiumModal } from '../components/main/premium/GiftPremiumModal';
export { default as PremiumGiftModal } from '../components/main/premium/PremiumGiftModal';
export { default as StarsGiftModal } from '../components/main/premium/StarsGiftModal';
export { default as GiveawayModal } from '../components/main/premium/GiveawayModal';
export { default as PremiumGiftingModal } from '../components/main/premium/PremiumGiftingModal';
export { default as PremiumGiftingPickerModal } from '../components/main/premium/PremiumGiftingPickerModal';
export { default as StarsGiftingPickerModal } from '../components/main/premium/StarsGiftingPickerModal';
export { default as PremiumLimitReachedModal } from '../components/main/premium/common/PremiumLimitReachedModal';
export { default as StatusPickerMenu } from '../components/left/main/StatusPickerMenu';
export { default as BoostModal } from '../components/modals/boost/BoostModal';
export { default as GiftCodeModal } from '../components/modals/giftcode/GiftCodeModal';
export { default as StarGiftInfoModal } from '../components/modals/stars/StarGiftInfoModal';
export { default as ChatlistModal } from '../components/modals/chatlist/ChatlistModal';
export { default as StarsBalanceModal } from '../components/modals/stars/StarsBalanceModal';
export { default as StarPaymentModal } from '../components/modals/stars/StarsPaymentModal';

View File

View File

@ -84,8 +84,41 @@ export function renderActionMessageText(
.replace('un1', '%action_origin%')
.replace('%1$s', '%gift_payment_amount%');
}
if (translationKey === 'ActionRequestedPeer') {
unprocessed = unprocessed
.replace('un1', '%star_target_user%')
.replace('un2', '%action_origin%')
.replace(/\*\*/g, '');
}
let processed: TextPart[];
if (unprocessed.includes('%star_target_user%')) {
processed = processPlaceholder(
unprocessed,
'%star_target_user%',
targetUsers
? targetUsers.map((user) => renderUserContent(user, noLinks)).filter(Boolean)
: 'User',
);
unprocessed = processed.pop() as string;
content.push(...processed);
}
processed = processPlaceholder(
unprocessed,
'%action_origin%',
actionOriginChat ? (
renderChatContent(lang, actionOriginChat, noLinks) || NBSP
) : actionOriginUser ? (
renderUserContent(actionOriginUser, noLinks) || NBSP
) : 'User',
'',
);
unprocessed = processed.pop() as string;
content.push(...processed);
if (unprocessed.includes('%payment_amount%')) {
processed = processPlaceholder(
unprocessed,
@ -96,20 +129,6 @@ export function renderActionMessageText(
content.push(...processed);
}
processed = processPlaceholder(
unprocessed,
'%action_origin%',
actionOriginUser ? (
renderUserContent(actionOriginUser, noLinks) || NBSP
) : actionOriginChat ? (
renderChatContent(lang, actionOriginChat, noLinks) || NBSP
) : 'User',
'',
);
unprocessed = processed.pop() as string;
content.push(...processed);
if (unprocessed.includes('%action_topic%')) {
const topicEmoji = topic?.iconEmojiId
? <CustomEmoji documentId={topic.iconEmojiId} />

View File

@ -14,6 +14,7 @@ type OwnProps = {
isConfirmDisabled?: boolean;
shouldAdaptToSearch?: boolean;
withFixedHeight?: boolean;
withPremiumGradient?: boolean;
onConfirm?: NoneToVoidFunction;
} & ModalProps;
@ -23,6 +24,7 @@ const PickerModal = ({
shouldAdaptToSearch,
withFixedHeight,
onConfirm,
withPremiumGradient,
...modalProps
}: OwnProps) => {
const lang = useOldLang();
@ -43,6 +45,7 @@ const PickerModal = ({
{modalProps.children}
<div className={styles.buttonWrapper}>
<Button
withPremiumGradient={withPremiumGradient}
onClick={onConfirm || modalProps.onClose}
color="primary"
size="smaller"

View File

@ -53,7 +53,7 @@ const UserBirthday = ({
animatedEmojiEffects,
isInSettings,
}: OwnProps & StateProps) => {
const { openGiftPremiumModal, requestConfetti } = getActions();
const { openPremiumGiftModal, requestConfetti } = getActions();
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
const animationPlayedRef = useRef(false);
@ -144,15 +144,15 @@ const UserBirthday = ({
const canGiftPremium = isToday && !user.isPremium && !user.isSelf && !isPremiumPurchaseBlocked;
const handleOpenGiftModal = useLastCallback(() => {
openGiftPremiumModal({ forUserIds: [user.id] });
const handleOpenPremiumGiftModal = useLastCallback(() => {
openPremiumGiftModal({ forUserIds: [user.id] });
});
const handleClick = useLastCallback(() => {
if (!isToday) return;
if (canGiftPremium && animationPlayedRef.current) {
handleOpenGiftModal();
handleOpenPremiumGiftModal();
return;
}
@ -173,7 +173,7 @@ const UserBirthday = ({
ripple={!isStatic}
onClick={handleClick}
isStatic={isStatic}
onSecondaryIconClick={handleOpenGiftModal}
onSecondaryIconClick={handleOpenPremiumGiftModal}
>
<div className="title">{renderText(lang(valueKey, [formattedDate, age], undefined, age))}</div>
<span className="subtitle">{lang(isToday ? 'ProfileBirthdayToday' : 'ProfileBirthday')}</span>

View File

@ -59,7 +59,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
const [isSupportDialogOpen, openSupportDialog, closeSupportDialog] = useFlag(false);
const lang = useOldLang();
const oldLang = useOldLang();
useEffect(() => {
if (currentUserId) {
@ -99,7 +99,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onScreenSelect(SettingsScreens.General)}
>
{lang('Telegram.GeneralSettingsViewController')}
{oldLang('Telegram.GeneralSettingsViewController')}
</ListItem>
<ListItem
icon="animations"
@ -107,7 +107,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onScreenSelect(SettingsScreens.Performance)}
>
{lang('Animations and Performance')}
{oldLang('Animations and Performance')}
</ListItem>
<ListItem
icon="unmute"
@ -115,7 +115,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onScreenSelect(SettingsScreens.Notifications)}
>
{lang('Notifications')}
{oldLang('Notifications')}
</ListItem>
<ListItem
icon="data"
@ -123,7 +123,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onScreenSelect(SettingsScreens.DataStorage)}
>
{lang('DataSettings')}
{oldLang('DataSettings')}
</ListItem>
<ListItem
icon="lock"
@ -131,7 +131,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onScreenSelect(SettingsScreens.Privacy)}
>
{lang('PrivacySettings')}
{oldLang('PrivacySettings')}
</ListItem>
<ListItem
icon="folder"
@ -139,7 +139,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onScreenSelect(SettingsScreens.Folders)}
>
{lang('Filters')}
{oldLang('Filters')}
</ListItem>
<ListItem
icon="active-sessions"
@ -147,7 +147,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onScreenSelect(SettingsScreens.ActiveSessions)}
>
{lang('SessionsTitle')}
{oldLang('SessionsTitle')}
{sessionCount > 0 && (<span className="settings-item__current-value">{sessionCount}</span>)}
</ListItem>
<ListItem
@ -156,8 +156,8 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onScreenSelect(SettingsScreens.Language)}
>
{lang('Language')}
<span className="settings-item__current-value">{lang.langName}</span>
{oldLang('Language')}
<span className="settings-item__current-value">{oldLang.langName}</span>
</ListItem>
<ListItem
icon="stickers"
@ -165,7 +165,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onScreenSelect(SettingsScreens.Stickers)}
>
{lang('StickersName')}
{oldLang('StickersName')}
</ListItem>
</div>
<div className="settings-main-menu">
@ -176,7 +176,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
// eslint-disable-next-line react/jsx-no-bind
onClick={() => openPremiumModal()}
>
{lang('TelegramPremium')}
{oldLang('TelegramPremium')}
</ListItem>
)}
{shouldDisplayStars && (
@ -186,7 +186,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
// eslint-disable-next-line react/jsx-no-bind
onClick={() => openStarsBalanceModal({})}
>
{lang('MenuTelegramStars')}
{oldLang('MenuTelegramStars')}
{Boolean(starsBalance) && (
<span className="settings-item__current-value">{formatInteger(starsBalance)}</span>
)}
@ -199,7 +199,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
// eslint-disable-next-line react/jsx-no-bind
onClick={() => openPremiumGiftingModal()}
>
{lang('GiftPremiumGifting')}
{oldLang('GiftPremiumGifting')}
</ListItem>
)}
</div>
@ -209,7 +209,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
narrow
onClick={openSupportDialog}
>
{lang('AskAQuestion')}
{oldLang('AskAQuestion')}
</ListItem>
<ListItem
icon="help"
@ -217,7 +217,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
// eslint-disable-next-line react/jsx-no-bind
onClick={() => openUrl({ url: FAQ_URL })}
>
{lang('TelegramFaq')}
{oldLang('TelegramFaq')}
</ListItem>
<ListItem
icon="privacy-policy"
@ -225,14 +225,14 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
// eslint-disable-next-line react/jsx-no-bind
onClick={() => openUrl({ url: PRIVACY_URL })}
>
{lang('PrivacyPolicy')}
{oldLang('PrivacyPolicy')}
</ListItem>
</div>
<ConfirmDialog
isOpen={isSupportDialogOpen}
confirmLabel={lang('lng_settings_ask_ok')}
title={lang('AskAQuestion')}
text={lang('lng_settings_ask_sure')}
confirmLabel={oldLang('lng_settings_ask_ok')}
title={oldLang('AskAQuestion')}
text={oldLang('lng_settings_ask_sure')}
confirmHandler={handleOpenSupport}
onClose={closeSupportDialog}
/>

View File

@ -74,6 +74,7 @@ import ReactionPicker from '../middle/message/reactions/ReactionPicker.async';
import MessageListHistoryHandler from '../middle/MessageListHistoryHandler';
import MiddleColumn from '../middle/MiddleColumn';
import ModalContainer from '../modals/ModalContainer';
import StarGiftInfoModal from '../modals/stars/StarGiftInfoModal';
import PaymentModal from '../payment/PaymentModal.async';
import ReceiptModal from '../payment/ReceiptModal.async';
import RightColumn from '../right/RightColumn';
@ -92,8 +93,9 @@ import NewContactModal from './NewContactModal.async';
import Notifications from './Notifications.async';
import PremiumLimitReachedModal from './premium/common/PremiumLimitReachedModal.async';
import GiveawayModal from './premium/GiveawayModal.async';
import PremiumGiftingModal from './premium/PremiumGiftingModal.async';
import PremiumGiftingPickerModal from './premium/PremiumGiftingPickerModal.async';
import PremiumMainModal from './premium/PremiumMainModal.async';
import StarsGiftingPickerModal from './premium/StarsGiftingPickerModal.async';
import SafeLinkModal from './SafeLinkModal.async';
import './Main.scss';
@ -145,7 +147,9 @@ type StateProps = {
isAppendModalOpen?: boolean;
isGiveawayModalOpen?: boolean;
isDeleteMessageModalOpen?: boolean;
isPremiumGiftingModalOpen?: boolean;
isPremiumGiftingPickerModal?: boolean;
isStarsGiftingPickerModal?: boolean;
isStarGiftInfoModal?: boolean;
isCurrentUserPremium?: boolean;
noRightColumnAnimation?: boolean;
withInterfaceAnimations?: boolean;
@ -195,7 +199,9 @@ const Main: FC<OwnProps & StateProps> = ({
isPremiumModalOpen,
isGiveawayModalOpen,
isDeleteMessageModalOpen,
isPremiumGiftingModalOpen,
isPremiumGiftingPickerModal,
isStarsGiftingPickerModal,
isStarGiftInfoModal,
isPaymentModalOpen,
isReceiptModalOpen,
isReactionPickerOpen,
@ -574,7 +580,9 @@ const Main: FC<OwnProps & StateProps> = ({
<MessageListHistoryHandler />
{isPremiumModalOpen && <PremiumMainModal isOpen={isPremiumModalOpen} />}
{isGiveawayModalOpen && <GiveawayModal isOpen={isGiveawayModalOpen} />}
{isPremiumGiftingModalOpen && <PremiumGiftingModal isOpen={isPremiumGiftingModalOpen} />}
{isPremiumGiftingPickerModal && <PremiumGiftingPickerModal isOpen={isPremiumGiftingPickerModal} />}
{isStarsGiftingPickerModal && <StarsGiftingPickerModal isOpen={isStarsGiftingPickerModal} />}
{isStarGiftInfoModal && <StarGiftInfoModal isOpen={isStarGiftInfoModal} />}
<PremiumLimitReachedModal limit={limitReached} />
<PaymentModal isOpen={isPaymentModalOpen} onClose={closePaymentModal} />
<ReceiptModal isOpen={isReceiptModalOpen} onClose={clearReceipt} />
@ -616,6 +624,8 @@ export default memo(withGlobal<OwnProps>(
giveawayModal,
deleteMessageModal,
giftingModal,
starsGiftingModal,
starGiftInfoModal,
isMasterTab,
payment,
limitReachedModal,
@ -671,7 +681,9 @@ export default memo(withGlobal<OwnProps>(
isPremiumModalOpen: premiumModal?.isOpen,
isGiveawayModalOpen: giveawayModal?.isOpen,
isDeleteMessageModalOpen: Boolean(deleteMessageModal),
isPremiumGiftingModalOpen: giftingModal?.isOpen,
isPremiumGiftingPickerModal: giftingModal?.isOpen,
isStarsGiftingPickerModal: starsGiftingModal?.isOpen,
isStarGiftInfoModal: starGiftInfoModal?.isOpen,
limitReached: limitReachedModal?.limit,
isPaymentModalOpen: payment.isPaymentModalOpen,
isReceiptModalOpen: Boolean(payment.receipt),

View File

@ -1,46 +0,0 @@
@media (min-width: 451px) {
.modalDialog :global(.modal-dialog) {
max-width: 32rem !important;
}
}
.closeButton {
position: absolute;
top: 0.5rem;
left: 0.5rem;
}
.avatars {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 1rem;
margin: 1rem;
}
.center {
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;
}
.boostIcon {
color: var(--color-primary);
vertical-align: middle;
line-height: 1.5;
}

View File

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

View File

@ -0,0 +1,123 @@
@use '../../../styles/mixins';
@media (min-width: 451px) {
.modalDialog :global(.modal-dialog) {
max-width: 32rem !important;
}
}
.root {
z-index: calc(var(--z-media-viewer) - 1);
}
.root :global(.modal-content) {
padding: 0;
}
.root :global(.modal-dialog) {
height: min(calc(55vh + 41px + 193px), 90vh);
max-width: 26.25rem;
}
.root :global(.modal-dialog),
.root :global(.modal-content),
.transition {
overflow: hidden;
}
.content {
display: flex;
flex-direction: column;
}
.main {
overflow-y: scroll;
}
.giftSection {
padding: 1.5rem;
}
.section {
padding: 0.5rem;
}
.header {
z-index: 2;
display: flex;
align-items: center;
border-bottom: 0.0625rem solid var(--color-borders);
position: absolute;
width: 100%;
left: 0;
top: 0;
height: 3.5rem;
padding: 0.5rem;
background: var(--color-background);
transition: 0.25s ease-out transform;
}
.starHeaderText {
font-size: 1.25rem;
font-weight: 500;
margin: 0 0 0 3rem;
unicode-bidi: plaintext;
}
.hiddenHeader {
transform: translateY(-100%);
}
.closeButton {
position: absolute;
top: 0.5rem;
left: 0.5rem;
z-index: 3;
}
.avatars {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 1rem;
margin: 1rem;
}
.center {
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);
}
.boostIcon {
color: var(--color-primary);
vertical-align: middle;
line-height: 1.5;
}
.optionBottom {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.secondaryInfo {
text-align: center;
font-size: 0.875rem;
color: var(--color-text-secondary);
padding: 0.5rem 1rem;
}
.footer {
margin: 0 1.5rem;
}

View File

@ -28,7 +28,7 @@ import Link from '../../ui/Link';
import Modal from '../../ui/Modal';
import PremiumSubscriptionOption from './PremiumSubscriptionOption';
import styles from './GiftPremiumModal.module.scss';
import styles from './PremiumGiftModal.module.scss';
export type OwnProps = {
isOpen?: boolean;
@ -41,7 +41,7 @@ type StateProps = {
boostPerSentGift?: number;
};
const GiftPremiumModal: FC<OwnProps & StateProps> = ({
const PremiumGiftModal: FC<OwnProps & StateProps> = ({
isOpen,
isCompleted,
gifts,
@ -52,10 +52,11 @@ const GiftPremiumModal: FC<OwnProps & StateProps> = ({
const dialogRef = useRef<HTMLDivElement>(null);
const {
openPremiumModal, closeGiftPremiumModal, openInvoice, requestConfetti,
openPremiumModal, closePremiumGiftModal, openInvoice, requestConfetti,
} = getActions();
const lang = useOldLang();
const oldLang = useOldLang();
const [selectedMonthOption, setSelectedMonthOption] = useState<number | undefined>();
const selectedUserQuantity = forUserIds && forUserIds.length * boostPerSentGift;
@ -140,23 +141,23 @@ const GiftPremiumModal: FC<OwnProps & StateProps> = ({
function renderGiftTitle() {
if (isCompleted) {
return renderText(lang('TelegramPremiumUserGiftedPremiumOutboundDialogTitle',
return renderText(oldLang('TelegramPremiumUserGiftedPremiumOutboundDialogTitle',
[userNameList, selectedGift?.months]), ['simple_markdown']);
}
return lang('GiftTelegramPremiumTitle');
return oldLang('GiftTelegramPremiumTitle');
}
function renderGiftText() {
if (isCompleted) {
return renderText(lang('TelegramPremiumUserGiftedPremiumOutboundDialogSubtitle', userNameList),
return renderText(oldLang('TelegramPremiumUserGiftedPremiumOutboundDialogSubtitle', userNameList),
['simple_markdown']);
}
return renderText(lang('GiftPremiumUsersGiveAccessManyZero', userNameList), ['simple_markdown']);
return renderText(oldLang('GiftPremiumUsersGiveAccessManyZero', userNameList), ['simple_markdown']);
}
function renderPremiumFeaturesLink() {
const info = lang('GiftPremiumListFeaturesAndTerms');
const info = oldLang('GiftPremiumListFeaturesAndTerms');
// Translation hack for rendering component inside string
const parts = info.match(/([^*]*)\*([^*]+)\*(.*)/);
@ -174,7 +175,8 @@ const GiftPremiumModal: FC<OwnProps & StateProps> = ({
}
function renderBoostsPluralText() {
const giftParts = renderText(lang('GiftPremiumWillReceiveBoostsPlural', selectedUserQuantity), ['simple_markdown']);
const giftParts = renderText(oldLang('GiftPremiumWillReceiveBoostsPlural',
selectedUserQuantity), ['simple_markdown']);
return giftParts.map((part) => {
if (typeof part === 'string') {
return part.split(/(⚡)/g).map((subpart) => {
@ -210,19 +212,20 @@ const GiftPremiumModal: FC<OwnProps & StateProps> = ({
return (
<Modal
dialogRef={dialogRef}
onClose={closeGiftPremiumModal}
onClose={closePremiumGiftModal}
isOpen={isOpen}
className={styles.modalDialog}
contentClassName={styles.content}
className={buildClassName(styles.modalDialog, styles.root)}
>
<div className="custom-scroll">
<div className={buildClassName(styles.main, 'custom-scroll')}>
<Button
round
size="smaller"
className={styles.closeButton}
color="translucent"
// eslint-disable-next-line react/jsx-no-bind
onClick={() => closeGiftPremiumModal()}
ariaLabel={lang('Close')}
onClick={() => closePremiumGiftModal()}
ariaLabel={oldLang('Close')}
>
<i className="icon icon-close" />
</Button>
@ -244,7 +247,7 @@ const GiftPremiumModal: FC<OwnProps & StateProps> = ({
{renderText(renderBoostsPluralText(), ['simple_markdown', 'emoji'])}
</p>
<div className={styles.options}>
<div className={styles.giftSection}>
{renderSubscriptionGiftOptions()}
</div>
</>
@ -253,12 +256,14 @@ const GiftPremiumModal: FC<OwnProps & StateProps> = ({
</div>
{!isCompleted && (
<Button withPremiumGradient className={styles.button} isShiny disabled={!selectedGift} onClick={handleSubmit}>
{lang(
'GiftSubscriptionFor', selectedGift
&& formatCurrency(selectedGift!.amount, selectedGift.currency, lang.code),
)}
</Button>
<div className={styles.footer}>
<Button withPremiumGradient isShiny disabled={!selectedGift} onClick={handleSubmit}>
{oldLang(
'GiftSubscriptionFor', selectedGift
&& formatCurrency(selectedGift!.amount, selectedGift.currency, oldLang.code),
)}
</Button>
</div>
)}
</Modal>
);
@ -267,7 +272,7 @@ const GiftPremiumModal: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>((global): StateProps => {
const {
gifts, forUserIds, isCompleted,
} = selectTabState(global).giftPremiumModal || {};
} = selectTabState(global).giftModal || {};
return {
isCompleted,
@ -275,4 +280,4 @@ export default memo(withGlobal<OwnProps>((global): StateProps => {
boostPerSentGift: global.appConfig?.boostsPerSentGift,
forUserIds,
};
})(GiftPremiumModal));
})(PremiumGiftModal));

View File

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

View File

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

View File

@ -8,19 +8,16 @@ import { GIVEAWAY_MAX_ADDITIONAL_CHANNELS } from '../../../config';
import {
filterUsersByName, 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 './PremiumGiftingModal.module.scss';
import styles from './PremiumGiftingPickerModal.module.scss';
export type OwnProps = {
isOpen?: boolean;
@ -32,15 +29,15 @@ interface StateProps {
userIds?: string[];
}
const PremiumGiftingModal: FC<OwnProps & StateProps> = ({
const PremiumGiftingPickerModal: FC<OwnProps & StateProps> = ({
isOpen,
currentUserId,
userSelectionLimit = GIVEAWAY_MAX_ADDITIONAL_CHANNELS,
userIds,
}) => {
const { closePremiumGiftingModal, openGiftPremiumModal, showNotification } = getActions();
const { closePremiumGiftingModal, openPremiumGiftModal, showNotification } = getActions();
const lang = useOldLang();
const oldLang = useOldLang();
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState<string>('');
@ -61,8 +58,7 @@ const PremiumGiftingModal: FC<OwnProps & StateProps> = ({
const handleSendIdList = useLastCallback(() => {
if (selectedUserIds?.length) {
openGiftPremiumModal({ forUserIds: selectedUserIds });
openPremiumGiftModal({ forUserIds: selectedUserIds });
closePremiumGiftingModal();
}
});
@ -70,65 +66,42 @@ const PremiumGiftingModal: FC<OwnProps & StateProps> = ({
const handleSelectedUserIdsChange = useLastCallback((newSelectedIds: string[]) => {
if (newSelectedIds.length > userSelectionLimit) {
showNotification({
message: lang('BoostingSelectUpToWarningUsers', userSelectionLimit),
message: oldLang('BoostingSelectUpToWarningUsers', userSelectionLimit),
});
return;
}
setSelectedUserIds(newSelectedIds);
});
function renderSearchField() {
return (
<div className={styles.filter} dir={lang.isRtl ? 'rtl' : undefined}>
<Button
round
size="smaller"
color="translucent"
// eslint-disable-next-line react/jsx-no-bind
onClick={() => closePremiumGiftingModal()}
ariaLabel={lang('Close')}
>
<Icon name="close" />
</Button>
<h3 className={styles.title}>{lang('GiftTelegramPremiumTitle')}
</h3>
</div>
);
}
return (
<Modal
<PickerModal
className={styles.root}
isOpen={isOpen}
onClose={closePremiumGiftingModal}
title={oldLang('GiftTelegramPremiumTitle')}
hasCloseButton
shouldAdaptToSearch
withFixedHeight
confirmButtonText={oldLang('Continue')}
onConfirm={handleSendIdList}
onEnter={handleSendIdList}
withPremiumGradient
>
<div className={styles.main}>
{renderSearchField()}
<div className={buildClassName(styles.main, 'custom-scroll')}>
<PeerPicker
className={styles.picker}
itemIds={displayedUserIds}
selectedIds={selectedUserIds}
filterValue={searchQuery}
filterPlaceholder={lang('Search')}
searchInputId="users-picker-search"
onSelectedIdsChange={handleSelectedUserIdsChange}
onFilterChange={setSearchQuery}
isSearchable
withDefaultPadding
withStatus
allowMultiple
itemInputType="checkbox"
/>
</div>
<div className={styles.buttons}>
<Button withPremiumGradient size="smaller" onClick={handleSendIdList} disabled={!selectedUserIds?.length}>
{lang('Continue')}
</Button>
</div>
</div>
</Modal>
<PeerPicker
className={styles.picker}
itemIds={displayedUserIds}
selectedIds={selectedUserIds}
filterValue={searchQuery}
filterPlaceholder={oldLang('Search')}
onSelectedIdsChange={handleSelectedUserIdsChange}
onFilterChange={setSearchQuery}
isSearchable
withDefaultPadding
withStatus
allowMultiple
itemInputType="checkbox"
/>
</PickerModal>
);
};
@ -140,4 +113,4 @@ export default memo(withGlobal<OwnProps>((global): StateProps => {
userIds: global.contactList?.userIds,
userSelectionLimit: global.appConfig?.giveawayAddPeersMax,
};
})(PremiumGiftingModal));
})(PremiumGiftingPickerModal));

View File

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

View File

@ -0,0 +1,107 @@
@media (min-width: 451px) {
.modalDialog :global(.modal-dialog) {
max-width: 32rem !important;
}
}
.root {
z-index: calc(var(--z-media-viewer) - 1);
}
.root :global(.modal-content) {
padding: 0;
}
.root :global(.modal-dialog) {
height: min(calc(55vh + 41px + 193px), 90vh);
max-width: 26.25rem;
}
.root :global(.modal-dialog),
.root :global(.modal-content),
.transition {
overflow: hidden;
}
.main {
height: 100%;
overflow-y: scroll;
display: flex;
flex-direction: column;
}
.section {
padding: 0.5rem;
}
.header {
z-index: 2;
display: flex;
align-items: center;
border-bottom: 0.0625rem solid var(--color-borders);
position: absolute;
width: 100%;
left: 0;
top: 0;
height: 3.5rem;
padding: 0.5rem;
background: var(--color-background);
transition: 0.25s ease-out transform;
}
.starHeaderText {
font-size: 1.25rem;
font-weight: 500;
margin: 0 0 0 3.5rem;
unicode-bidi: plaintext;
}
.hiddenHeader {
transform: translateY(-100%);
}
.closeButton {
position: absolute;
top: 0.5rem;
left: 0.5rem;
z-index: 3;
}
.avatars {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 1rem;
margin: 1rem;
}
.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;
margin-top: auto;
}

View File

@ -0,0 +1,197 @@
import type { FC } from '../../../lib/teact/teact';
import React, {
memo, useEffect, useMemo, useRef,
useState,
} from '../../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../../global';
import type {
ApiStarsGiftOption, ApiStarTopupOption, ApiUser,
} from '../../../api/types';
import {
selectTabState, selectUser,
} from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { formatCurrencyAsString } from '../../../util/formatCurrency';
import renderText from '../../common/helpers/renderText';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
import Avatar from '../../common/Avatar';
import SafeLink from '../../common/SafeLink';
import StarTopupOptionList from '../../modals/stars/StarTopupOptionList';
import Button from '../../ui/Button';
import Modal from '../../ui/Modal';
import styles from './StarsGiftModal.module.scss';
export type OwnProps = {
isOpen?: boolean;
};
type StateProps = {
isCompleted?: boolean;
starsGiftOptions?: ApiStarsGiftOption[] | undefined;
forUserId?: string;
user?: ApiUser;
};
const StarsGiftModal: FC<OwnProps & StateProps> = ({
isOpen,
isCompleted,
starsGiftOptions,
forUserId,
user,
}) => {
// eslint-disable-next-line no-null/no-null
const dialogRef = useRef<HTMLDivElement>(null);
const {
closeStarsGiftModal, openInvoice, requestConfetti,
} = getActions();
const oldLang = useOldLang();
const [selectedOption, setSelectedOption] = useState<ApiStarTopupOption | undefined>();
const [isHeaderHidden, setHeaderHidden] = useState(true);
useEffect(() => {
if (!isOpen) {
setHeaderHidden(true);
}
}, [isOpen]);
const showConfetti = useLastCallback(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (isOpen) {
const {
top, left, width, height,
} = dialog.querySelector('.modal-content')!.getBoundingClientRect();
requestConfetti({
top,
left,
width,
height,
withStars: true,
});
}
});
useEffect(() => {
if (isCompleted) {
showConfetti();
}
}, [isCompleted, showConfetti]);
const handleClick = useLastCallback((option: ApiStarTopupOption) => {
setSelectedOption(option);
openInvoice({
type: 'starsgift',
userId: forUserId!,
stars: option.stars,
currency: option.currency,
amount: option.amount,
});
});
function handleScroll(e: React.UIEvent<HTMLDivElement>) {
const { scrollTop } = e.currentTarget;
setHeaderHidden(scrollTop <= 150);
}
function renderGiftTitle() {
if (isCompleted) {
return renderText(oldLang('Notification.StarsGift.SentYou',
formatCurrencyAsString(selectedOption!.amount, selectedOption!.currency, oldLang.code)), ['simple_markdown']);
}
return oldLang('GiftStarsTitle');
}
function renderStarOptionList() {
return (
<StarTopupOptionList
options={starsGiftOptions}
onClick={handleClick}
/>
);
}
const bottomText = useMemo(() => {
if (!isOpen) return undefined;
const text = oldLang('lng_credits_summary_options_about');
const parts = text.split('{link}');
return [
parts[0],
<SafeLink url={oldLang('StarsTOSLink')} text={oldLang('lng_credits_summary_options_about_link')} />,
parts[1],
];
}, [isOpen, oldLang]);
return (
<Modal
dialogRef={dialogRef}
onClose={closeStarsGiftModal}
isOpen={isOpen}
className={buildClassName(styles.modalDialog, styles.root)}
>
<div className={buildClassName(styles.main, 'custom-scroll')} onScroll={handleScroll}>
<Button
round
size="smaller"
className={styles.closeButton}
color="translucent"
// eslint-disable-next-line react/jsx-no-bind
onClick={() => closeStarsGiftModal()}
ariaLabel={oldLang('Close')}
>
<i className="icon icon-close" />
</Button>
<div className={buildClassName(styles.header, isHeaderHidden && styles.hiddenHeader)}>
<h2 className={styles.starHeaderText}>
{oldLang('GiftStarsTitle')}
</h2>
</div>
<div className={styles.avatars}>
<Avatar
size="large"
peer={user}
/>
</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>
</>
)}
</div>
</Modal>
);
};
export default memo(withGlobal<OwnProps>((global): StateProps => {
const {
starsGiftOptions, forUserId, isCompleted,
} = selectTabState(global).starsGiftModal || {};
const user = forUserId ? selectUser(getGlobal(), forUserId) : undefined;
return {
isCompleted,
starsGiftOptions,
forUserId,
user,
};
})(StarsGiftModal));

View File

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

View File

@ -0,0 +1,86 @@
.root {
z-index: calc(var(--z-media-viewer) - 1);
}
.root :global(.modal-content) {
padding: 0;
}
.root :global(.modal-dialog) {
max-width: 55vh;
}
.root :global(.modal-dialog),
.root :global(.modal-content),
.transition {
overflow: hidden;
}
.main {
height: 90vh;
}
.filter {
padding: 0.375rem 1rem 0.25rem 0.75rem;
margin-bottom: 0.625rem;
background-color: var(--color-background);
box-shadow: inset 0 -0.0625rem 0 0 var(--color-background-secondary-accent);
border-bottom: 0.625rem solid var(--color-background-secondary);
display: flex;
flex-flow: row wrap;
align-items: center;
flex-shrink: 0;
overflow-y: auto;
max-height: 20rem;
}
.title {
margin: 0;
}
.buttons {
width: 100%;
background: var(--color-background);
position: absolute;
bottom: 0;
z-index: 1;
padding: 0.75rem;
}
.picker {
height: 75vh;
}
.avatars {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 1rem;
margin: 1rem;
}
.center {
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;
font-weight: 600;
}

View File

@ -0,0 +1,141 @@
import type { FC } from '../../../lib/teact/teact';
import React, {
memo, useMemo,
useRef,
useState,
} from '../../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../../global';
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 styles from './StarsGiftingPickerModal.module.scss';
export type OwnProps = {
isOpen?: boolean;
};
interface StateProps {
currentUserId?: string;
userIds?: string[];
activeListIds?: string[];
archivedListIds?: string[];
}
const StarsGiftingPickerModal: FC<OwnProps & StateProps> = ({
isOpen,
currentUserId,
activeListIds,
archivedListIds,
userIds,
}) => {
// eslint-disable-next-line no-null/no-null
const dialogRef = useRef<HTMLDivElement>(null);
const { closeStarsGiftingModal, openStarsGiftModal } = getActions();
const oldLang = useOldLang();
const [searchQuery, setSearchQuery] = useState<string>('');
const displayedUserIds = useMemo(() => {
const usersById = getGlobal().users.byId;
const combinedIds = [
...(userIds || []),
...(activeListIds || []),
...(archivedListIds || []),
];
const filteredContactIds = filterUsersByName(combinedIds, usersById, searchQuery);
return sortChatIds(unique(filteredContactIds).filter((id) => {
const user = usersById[id];
if (!user) {
return false;
}
return !user.isSupport
&& !isUserBot(user) && !isDeletedUser(user)
&& id !== currentUserId && id !== SERVICE_NOTIFICATIONS_USER_ID;
}));
}, [currentUserId, searchQuery, userIds, activeListIds, archivedListIds]);
const handleSelectedUserIdsChange = useLastCallback((newSelectedId?: string) => {
if (newSelectedId?.length) {
openStarsGiftModal({ forUserId: newSelectedId });
}
});
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
className={styles.root}
isOpen={isOpen}
onClose={closeStarsGiftingModal}
onEnter={handleSelectedUserIdsChange}
dialogRef={dialogRef}
>
<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>
);
};
export default memo(withGlobal<OwnProps>((global): StateProps => {
const {
chats: {
listIds,
},
currentUserId,
} = global;
return {
userIds: global.contactList?.userIds,
activeListIds: listIds.active,
archivedListIds: listIds.archived,
currentUserId,
};
})(StarsGiftingPickerModal));

View File

@ -13,7 +13,7 @@ import type { FocusDirection, ThreadId } from '../../types';
import type { PinnedIntersectionChangedCallback } from './hooks/usePinnedMessage';
import {
getChatTitle, getMessageHtmlId, isJoinedChannelMessage,
getChatTitle, getMessageHtmlId, getSenderTitle, isJoinedChannelMessage,
} from '../../global/helpers';
import { getMessageReplyInfo } from '../../global/helpers/replies';
import {
@ -35,6 +35,7 @@ import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
import useEnsureMessage from '../../hooks/useEnsureMessage';
import useFlag from '../../hooks/useFlag';
import { useIsIntersecting, useOnIntersect } from '../../hooks/useIntersectionObserver';
import useLang from '../../hooks/useLang';
import useOldLang from '../../hooks/useOldLang';
import useShowTransition from '../../hooks/useShowTransition';
import useFocusMessage from './message/hooks/useFocusMessage';
@ -103,10 +104,11 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
onPinnedIntersectionChange,
}) => {
const {
openPremiumModal, requestConfetti, checkGiftCode, getReceipt,
openPremiumModal, requestConfetti, checkGiftCode, getReceipt, openStarGiftInfoModal,
} = getActions();
const lang = useOldLang();
const oldLang = useOldLang();
const lang = useLang();
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
@ -133,6 +135,7 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
const isGiftCode = Boolean(message.content.action?.text.startsWith('BoostingReceivedGift'));
const isSuggestedAvatar = message.content.action?.type === 'suggestProfilePhoto' && message.content.action!.photo;
const isJoinedMessage = isJoinedChannelMessage(message);
const hasStars = Boolean(message.content.action?.stars);
useEffect(() => {
if (noAppearanceAnimation) {
@ -168,7 +171,7 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
const renderContent = useCallback(() => {
return renderActionMessageText(
lang,
oldLang,
message,
senderUser,
senderChat,
@ -181,7 +184,7 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
observeIntersectionForPlaying,
);
}, [
isEmbedded, lang, message, observeIntersectionForLoading, observeIntersectionForPlaying,
isEmbedded, message, observeIntersectionForLoading, observeIntersectionForPlaying, oldLang,
senderChat, senderUser, targetChatId, targetMessage, targetUsers, topic,
]);
@ -197,6 +200,14 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
handleBeforeContextMenu(e);
};
const handleStarGiftClick = () => {
openStarGiftInfoModal({
toUserId: targetUserIds?.[0],
stars: message.content.action!.stars,
date: message.date,
});
};
const handlePremiumGiftClick = () => {
openPremiumModal({
isGift: true,
@ -233,7 +244,12 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
function renderGift() {
return (
<span className="action-message-gift" tabIndex={0} role="button" onClick={handlePremiumGiftClick}>
<span
className="action-message-gift action-message-stars-gift"
tabIndex={0}
role="button"
onClick={hasStars ? handleStarGiftClick : handlePremiumGiftClick}
>
<AnimatedIconFromSticker
key={message.id}
sticker={premiumGiftSticker}
@ -241,10 +257,14 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
noLoop
nonInteractive
/>
<strong>{lang('ActionGiftPremiumTitle')}</strong>
<span>{lang('ActionGiftPremiumSubtitle', lang('Months', message.content.action?.months, 'i'))}</span>
<strong>{hasStars ? oldLang('Stars', message.content.action?.stars)
: oldLang('ActionGiftPremiumTitle')}
</strong>
<span>{hasStars ? oldLang('ActionGiftStarsSubtitleYou')
: oldLang('ActionGiftPremiumSubtitle', oldLang('Months', message.content.action?.months, 'i'))}
</span>
<span className="action-message-button">{lang('ActionGiftPremiumView')}</span>
<span className="action-message-button">{oldLang('ActionGiftPremiumView')}</span>
</span>
);
}
@ -257,7 +277,7 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
className="action-message-gift action-message-gift-code"
tabIndex={0}
role="button"
onClick={handleGiftCodeClick}
onClick={hasStars ? handleStarGiftClick : handleGiftCodeClick}
>
<AnimatedIconFromSticker
key={message.id}
@ -266,21 +286,36 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
noLoop
nonInteractive
/>
<strong>{lang(isUnclaimed ? 'BoostingUnclaimedPrize' : 'BoostingCongratulations')}</strong>
<strong>{hasStars ? oldLang('Stars', message.content.action?.stars)
: oldLang(isUnclaimed ? 'BoostingUnclaimedPrize' : 'BoostingCongratulations')}
</strong>
<span className="action-message-subtitle">
{targetChat && renderText(lang(isFromGiveaway ? 'BoostingReceivedGiftFrom' : isUnclaimed
{hasStars ? lang('GiftStarsOutgoing', {
user: (
<b>
{senderUser && renderText(getSenderTitle(oldLang, senderUser) || '', ['simple_markdown'])}
</b>
),
}, {
withNodes: true,
}) : targetChat && renderText(oldLang(isFromGiveaway ? 'BoostingReceivedGiftFrom' : isUnclaimed
? 'BoostingReceivedPrizeFrom' : 'BoostingYouHaveUnclaimedPrize',
getChatTitle(lang, targetChat)),
getChatTitle(oldLang, targetChat)),
['simple_markdown'])}
</span>
<span className="action-message-subtitle">
{renderText(lang(
'BoostingUnclaimedPrizeDuration',
lang('Months', message.content.action?.months, 'i'),
), ['simple_markdown'])}
</span>
{!hasStars && (
<span className="action-message-subtitle">
{renderText(oldLang(
'BoostingUnclaimedPrizeDuration',
oldLang('Months', message.content.action?.months, 'i'),
), ['simple_markdown'])}
</span>
)}
<span className="action-message-button">{lang('BoostingReceivedGiftOpenBtn')}</span>
<span className="action-message-button">{
oldLang(hasStars ? 'ActionGiftPremiumView' : 'BoostingReceivedGiftOpenBtn')
}
</span>
</span>
);
}

View File

@ -187,7 +187,7 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
requestMasterAndRequestCall,
toggleStatistics,
openBoostStatistics,
openGiftPremiumModal,
openPremiumGiftModal,
openThreadWithInfo,
openCreateTopicPanel,
openEditTopicPanel,
@ -313,7 +313,7 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
});
const handleGiftPremiumClick = useLastCallback(() => {
openGiftPremiumModal({ forUserIds: [chatId] });
openPremiumGiftModal({ forUserIds: [chatId] });
closeMenu();
});

View File

@ -277,6 +277,11 @@
margin-inline: auto;
}
.action-message-stars-gift {
width: 15rem;
margin-inline: auto;
}
.action-message-subtitle {
margin-top: 1rem;
font-weight: normal;

View File

@ -82,7 +82,8 @@ import Composer from '../common/Composer';
import PrivacySettingsNoticeModal from '../common/PrivacySettingsNoticeModal.async';
import SeenByModal from '../common/SeenByModal.async';
import UnpinAllMessagesModal from '../common/UnpinAllMessagesModal.async';
import GiftPremiumModal from '../main/premium/GiftPremiumModal.async';
import PremiumGiftModal from '../main/premium/PremiumGiftModal.async';
import StarsGiftModal from '../main/premium/StarsGiftModal.async';
import Button from '../ui/Button';
import Transition from '../ui/Transition';
import ChatLanguageModal from './ChatLanguageModal.async';
@ -133,7 +134,8 @@ type StateProps = {
isSeenByModalOpen: boolean;
isPrivacySettingsNoticeModalOpen: boolean;
isReactorListModalOpen: boolean;
isGiftPremiumModalOpen?: boolean;
isPremiumGiftModalOpen?: boolean;
isStarsGiftModalOpen?: boolean;
isChatLanguageModalOpen?: boolean;
withInterfaceAnimations?: boolean;
shouldSkipHistoryAnimations?: boolean;
@ -193,7 +195,8 @@ function MiddleColumn({
isSeenByModalOpen,
isPrivacySettingsNoticeModalOpen,
isReactorListModalOpen,
isGiftPremiumModalOpen,
isPremiumGiftModalOpen,
isStarsGiftModalOpen,
isChatLanguageModalOpen,
withInterfaceAnimations,
shouldSkipHistoryAnimations,
@ -715,7 +718,8 @@ function MiddleColumn({
/>
))}
</div>
<GiftPremiumModal isOpen={isGiftPremiumModalOpen} />
<PremiumGiftModal isOpen={isPremiumGiftModalOpen} />
<StarsGiftModal isOpen={isStarsGiftModalOpen} />
</div>
);
}
@ -729,7 +733,7 @@ export default memo(withGlobal<OwnProps>(
const {
messageLists, isLeftColumnShown, activeEmojiInteractions,
seenByModal, giftPremiumModal, reactorModal, audioPlayer, shouldSkipHistoryAnimations,
seenByModal, giftModal, starsGiftModal, reactorModal, audioPlayer, shouldSkipHistoryAnimations,
chatLanguageModal, privacySettingsNoticeModal,
} = selectTabState(global);
const currentMessageList = selectCurrentMessageList(global);
@ -748,7 +752,8 @@ export default memo(withGlobal<OwnProps>(
isSeenByModalOpen: Boolean(seenByModal),
isPrivacySettingsNoticeModalOpen: Boolean(privacySettingsNoticeModal),
isReactorListModalOpen: Boolean(reactorModal),
isGiftPremiumModalOpen: giftPremiumModal?.isOpen,
isPremiumGiftModalOpen: giftModal?.isOpen,
isStarsGiftModalOpen: starsGiftModal?.isOpen,
isChatLanguageModalOpen: Boolean(chatLanguageModal),
withInterfaceAnimations: selectCanAnimateInterface(global),
currentTransitionKey: Math.max(0, messageLists.length - 1),

View File

@ -1,3 +1,5 @@
@use '../../../styles/mixins';
.content {
display: flex;
flex-direction: column;
@ -19,10 +21,30 @@
padding: 0.25rem 0.5rem;
}
.section {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem;
position: relative;
@include mixins.adapt-padding-to-scrollbar(0.5rem);
}
.logo {
margin: 1rem;
width: 6.25rem;
height: 6.25rem;
align-self: center;
min-height: 6.25rem;
}
.logoBackground {
position: absolute;
top: 0.75rem;
left: 50%;
transform: translateX(-50%);
height: 8rem;
}
.avatar {

View File

@ -15,6 +15,8 @@ import Modal from '../../ui/Modal';
import styles from './TableInfoModal.module.scss';
import StarsBackground from '../../../assets/stars-bg.png';
type ChatItem = { chatId: string };
export type TableData = [TeactNode, TeactNode | ChatItem][];
@ -24,6 +26,7 @@ type OwnProps = {
title?: string;
tableData?: TableData;
headerImageUrl?: string;
logoBackground?: string;
headerAvatarPeer?: ApiPeer | CustomPeer;
headerAvatarWebPhoto?: ApiWebDocument;
noHeaderImage?: boolean;
@ -40,6 +43,7 @@ const TableInfoModal = ({
title,
tableData,
headerImageUrl,
logoBackground,
headerAvatarPeer,
headerAvatarWebPhoto,
noHeaderImage,
@ -73,7 +77,11 @@ const TableInfoModal = ({
withAvatar ? (
<Avatar peer={headerAvatarPeer} webPhoto={headerAvatarWebPhoto} size="jumbo" className={styles.avatar} />
) : (
<img className={styles.logo} src={headerImageUrl} alt="" draggable={false} />
<div className={styles.section}>
<img className={styles.logo} src={headerImageUrl} alt="" draggable={false} />
{Boolean(logoBackground)
&& <img className={styles.logoBackground} src={StarsBackground} alt="" draggable={false} />}
</div>
)
)}
{header}
@ -98,7 +106,7 @@ const TableInfoModal = ({
</table>
{footer}
{buttonText && (
<Button onClick={onButtonClick || onClose}>{buttonText}</Button>
<Button size="smaller" onClick={onButtonClick || onClose}>{buttonText}</Button>
)}
</Modal>
);

View File

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

View File

@ -0,0 +1,32 @@
@use '../../../styles/mixins';
.centered {
text-align: center !important;
}
.section {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem;
position: relative;
@include mixins.adapt-padding-to-scrollbar(0.5rem);
}
.info {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 0.5rem;
}
.starTitle {
margin: 0;
}
.footer {
margin: 1rem 0;
color: var(--color-text-secondary);
}

View File

@ -0,0 +1,150 @@
import React, { memo, useMemo } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiPeer } from '../../../api/types';
import { getSenderTitle } from '../../../global/helpers';
import { selectTabState, selectUser } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
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 StarIcon from '../../common/icons/StarIcon';
import SafeLink from '../../common/SafeLink';
import TableInfoModal, { type TableData } from '../common/TableInfoModal';
import styles from './StarGiftInfoModal.module.scss';
import StarLogo from '../../../assets/icons/StarLogo.svg';
import StarsBackground from '../../../assets/stars-bg.png';
export type OwnProps = {
isOpen?: boolean;
};
export type StateProps = {
stars?: number;
user?: ApiPeer;
date?: number;
};
const StarGiftInfoModal = ({
isOpen,
stars,
user,
date,
}: OwnProps & StateProps) => {
const {
closeStarGiftInfoModal,
} = getActions();
const oldLang = useOldLang();
const lang = useLang();
const infoText = useMemo(() => {
const linkText = oldLang('GiftStarsSubtitleLinkName');
return lang('CreditsBoxHistoryEntryGiftOutAbout',
{
user: (
<b>
{user && renderText(getSenderTitle(oldLang, user) || '', ['simple_markdown'])}
</b>
),
link: (
<SafeLink
url={oldLang('lng_paid_about_link_url')}
text={linkText}
/>
),
},
{
withNodes: true,
});
}, [lang, oldLang, user]);
const footerText = useMemo(() => {
const linkText = oldLang('lng_payments_terms_link');
return lang('CreditsBoxOutAbout', {
link: (
<SafeLink
url={oldLang('StarsTOSLink')}
text={linkText}
/>
),
}, {
withNodes: true,
});
}, [lang, oldLang]);
const handleButtonClick = useLastCallback(() => {
closeStarGiftInfoModal();
});
const modalData = useMemo(() => {
if (!isOpen) return undefined;
const header = (
<>
<h2 className={buildClassName(styles.starTitle, styles.centered)}>{oldLang('StarsGiftSent')}</h2>
<div className={styles.info}>
<p className={buildClassName(styles.starTitle, styles.centered)}>{stars}</p>
<StarIcon type="gold" size="middle" />
</div>
<p className={styles.centered}>{infoText}</p>
</>
);
const tableData = [
[oldLang('Recipient'), user ? { chatId: user.id } : oldLang('BoostingNoRecipient')],
[oldLang('BoostingDate'), formatDateTimeToString(date! * 1000, lang.code, true)],
] satisfies TableData;
const footer = (
<span className={buildClassName(styles.footer, styles.centered)}>
{footerText}
</span>
);
return {
header,
tableData,
footer,
};
}, [isOpen, oldLang, stars, infoText, user, date, lang.code, footerText]);
if (!modalData) return undefined;
return (
<TableInfoModal
isOpen={isOpen}
headerImageUrl={StarLogo}
logoBackground={StarsBackground}
tableData={modalData.tableData}
header={modalData.header}
footer={modalData.footer}
buttonText={lang('Close')}
onButtonClick={handleButtonClick}
onClose={closeStarGiftInfoModal}
/>
);
};
export default memo(withGlobal<OwnProps>(
(global): StateProps => {
const {
starGiftInfoModal,
} = selectTabState(global);
const toUserId = starGiftInfoModal?.toUserId;
const user = toUserId ? selectUser(global, toUserId) : undefined;
return {
stars: starGiftInfoModal?.stars,
user,
date: starGiftInfoModal?.date,
};
},
)(StarGiftInfoModal));

View File

@ -0,0 +1,62 @@
@use '../../../styles/mixins';
.option {
--_background-color: var(--color-background-secondary);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.125rem;
padding: 1rem;
border-radius: 0.625rem;
background-color: var(--_background-color);
transition: background-color 0.25s ease-out;
cursor: var(--custom-cursor, pointer);
&:hover {
--_background-color: var(--color-background-secondary-accent);
}
}
.wideOption {
grid-column: 1 / -1;
}
.optionTop {
display: flex;
align-items: center;
gap: 0.25rem;
font-weight: 500;
font-size: 1.5rem;
line-height: 1;
}
.stackedStars {
display: grid;
grid-auto-columns: 0.4375rem;
grid-auto-flow: column;
justify-items: end;
}
.stackedStar {
@include mixins.filter-outline(0.0625rem, var(--_background-color));
transition: filter 0.25s ease-out;
}
.optionBottom {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.moreOptions {
grid-column: 1/-1;
}
.iconDown {
margin-inline-start: 0.25rem;
font-size: 1.5rem;
}

View File

@ -0,0 +1,110 @@
import type { FC } from '../../../lib/teact/teact';
import React, { memo, useEffect, useMemo } from '../../../lib/teact/teact';
import type { ApiStarTopupOption } from '../../../api/types';
import buildClassName from '../../../util/buildClassName';
import { formatCurrency } from '../../../util/formatCurrency';
import { formatInteger } from '../../../util/textFormat';
import useFlag from '../../../hooks/useFlag';
import useOldLang from '../../../hooks/useOldLang';
import Icon from '../../common/icons/Icon';
import StarIcon from '../../common/icons/StarIcon';
import Button from '../../ui/Button';
import styles from './StarTopupOptionList.module.scss';
const MAX_STARS_COUNT = 6;
type OwnProps = {
isActive?: boolean;
options?: ApiStarTopupOption[];
starsNeeded?: number;
onClick: (option: ApiStarTopupOption) => void;
};
const StarTopupOptionList: FC<OwnProps> = ({
isActive,
options,
starsNeeded,
onClick,
}) => {
const lang = useOldLang();
const [areOptionsExtended, markOptionsExtended, unmarkOptionsExtended] = useFlag();
useEffect(() => {
if (!isActive) {
unmarkOptionsExtended();
}
}, [isActive]);
const [renderingOptions, canExtend] = useMemo(() => {
if (!options) {
return [undefined, false];
}
const maxOption = options.reduce((max, option) => (
max.stars > option.stars ? max : option
));
const forceShowAll = starsNeeded && maxOption.stars < starsNeeded;
const result: { option: ApiStarTopupOption; starsCount: number; isWide: boolean }[] = [];
let currentStackedStarsCount = 0;
let canExtendOptions = false;
options.forEach((option, index) => {
if (!option.isExtended) currentStackedStarsCount++;
if (starsNeeded && !forceShowAll && option.stars < starsNeeded) return;
if (!areOptionsExtended && option.isExtended) {
canExtendOptions = true;
return;
}
result.push({
option,
starsCount: Math.min(currentStackedStarsCount, MAX_STARS_COUNT),
isWide: index === options.length - 1,
});
});
return [result, canExtendOptions];
}, [areOptionsExtended, options, starsNeeded]);
return (
<>
{renderingOptions?.map(({ option, starsCount, isWide }) => {
const length = renderingOptions?.length;
const isOdd = length % 2 === 0;
return (
<div
className={buildClassName(styles.option, (!isOdd && isWide) && styles.wideOption)}
key={option.stars}
onClick={() => onClick?.(option)}
>
<div className={styles.optionTop}>
+{formatInteger(option.stars)}
<div className={styles.stackedStars} dir={lang.isRtl ? 'ltr' : 'rtl'}>
{Array.from({ length: starsCount }).map(() => (
<StarIcon className={styles.stackedStar} type="gold" size="big" />
))}
</div>
</div>
<div className={styles.optionBottom}>
{formatCurrency(option.amount, option.currency, lang.code)}
</div>
</div>
);
})}
{!areOptionsExtended && canExtend && (
<Button className={styles.moreOptions} isText noForcedUpperCase onClick={markOptionsExtended}>
{lang('Stars.Purchase.ShowMore')}
<Icon className={styles.iconDown} name="down" />
</Button>
)}
</>
);
};
export default memo(StarTopupOptionList);

View File

@ -24,6 +24,11 @@
overflow-y: scroll;
}
.container {
display: flex;
flex-direction: column-reverse;
}
.section {
display: flex;
flex-direction: column;
@ -138,63 +143,16 @@
width: 100%;
}
.option {
--_background-color: var(--color-background-secondary);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.125rem;
padding: 1rem;
border-radius: 0.625rem;
background-color: var(--_background-color);
transition: background-color 0.25s ease-out;
cursor: var(--custom-cursor, pointer);
&:hover {
--_background-color: var(--color-background-secondary-accent);
}
.optionFullWidth {
grid-column: 1 / -1;
}
.optionTop {
display: flex;
align-items: center;
gap: 0.25rem;
font-weight: 500;
font-size: 1.5rem;
line-height: 1;
white-space: nowrap;
}
.stackedStars {
display: grid;
grid-auto-columns: 0.4375rem;
grid-auto-flow: column;
justify-items: center;
margin-left: 0.375rem;
}
.stackedStar {
@include mixins.filter-outline(0.0625rem, var(--_background-color));
transition: filter 0.25s ease-out;
}
.optionBottom {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.moreOptions {
.starButton {
grid-column: 1/-1;
}
.iconDown {
margin-inline-start: 0.25rem;
font-size: 1.5rem;
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
}
.paymentContent {

View File

@ -7,13 +7,11 @@ import type { ApiStarTopupOption, ApiUser } from '../../../api/types';
import type { GlobalState, TabState } from '../../../global/types';
import { getUserFullName } from '../../../global/helpers';
import { selectUser } from '../../../global/selectors';
import { selectIsPremiumPurchaseBlocked, selectUser } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { formatCurrency } from '../../../util/formatCurrency';
import { formatInteger } from '../../../util/textFormat';
import renderText from '../../common/helpers/renderText';
import useFlag from '../../../hooks/useFlag';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
@ -27,6 +25,7 @@ import TabList, { type TabWithProperties } from '../../ui/TabList';
import Transition from '../../ui/Transition';
import BalanceBlock from './BalanceBlock';
import TransactionItem from './StarsTransactionItem';
import StarTopupOptionList from './StarTopupOptionList';
import styles from './StarsBalanceModal.module.scss';
@ -39,7 +38,6 @@ const TRANSACTION_TABS: TabWithProperties[] = [
{ title: 'StarsTransactionsIncoming' },
{ title: 'StarsTransactionsOutgoing' },
];
const MAX_STARS_COUNT = 6;
export type OwnProps = {
modal: TabState['starsBalanceModal'];
@ -48,19 +46,22 @@ export type OwnProps = {
type StateProps = {
starsBalanceState?: GlobalState['stars'];
originPaymentBot?: ApiUser;
canBuyPremium?: boolean;
};
const StarsBalanceModal = ({
modal, starsBalanceState, originPaymentBot,
modal, starsBalanceState, originPaymentBot, canBuyPremium,
}: OwnProps & StateProps) => {
const { closeStarsBalanceModal, loadStarsTransactions, openInvoice } = getActions();
const {
closeStarsBalanceModal, loadStarsTransactions, openInvoice, openStarsGiftingModal,
} = getActions();
const { balance, history, topupOptions } = starsBalanceState || {};
const lang = useOldLang();
const oldLang = useOldLang();
const lang = useLang();
const [isHeaderHidden, setHeaderHidden] = useState(true);
const [areOptionsExtended, markOptionsExtended, unmarkOptionsExtended] = useFlag();
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const isOpen = Boolean(modal && starsBalanceState);
@ -73,52 +74,21 @@ const StarsBalanceModal = ({
useEffect(() => {
if (!isOpen) {
setHeaderHidden(true);
unmarkOptionsExtended();
setSelectedTabIndex(0);
}
}, [isOpen]);
const [renderingOptions, canExtend] = useMemo(() => {
if (!topupOptions) {
return [undefined, false];
}
const maxOption = topupOptions.reduce((max, option) => (
max.stars > option.stars ? max : option
));
const forceShowAll = starsNeeded && maxOption.stars < starsNeeded;
const result: { option: ApiStarTopupOption; starsCount: number }[] = [];
let currentStackedStarsCount = 0;
let canExtendOptions = false;
topupOptions.forEach((option) => {
if (!option.isExtended) currentStackedStarsCount++;
if (starsNeeded && !forceShowAll && option.stars < starsNeeded) return;
if (!areOptionsExtended && option.isExtended) {
canExtendOptions = true;
return;
}
result.push({
option,
starsCount: Math.min(currentStackedStarsCount, MAX_STARS_COUNT),
});
});
return [result, canExtendOptions];
}, [areOptionsExtended, topupOptions, starsNeeded]);
const tosText = useMemo(() => {
if (!isOpen) return undefined;
const text = lang('lng_credits_summary_options_about');
const text = oldLang('lng_credits_summary_options_about');
const parts = text.split('{link}');
return [
parts[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')} />,
parts[1],
];
}, [isOpen, lang]);
}, [isOpen, oldLang]);
function handleScroll(e: React.UIEvent<HTMLDivElement>) {
const { scrollTop } = e.currentTarget;
@ -135,12 +105,27 @@ const StarsBalanceModal = ({
});
});
function renderStarOptionList() {
return (
<StarTopupOptionList
isActive={isOpen}
options={topupOptions}
starsNeeded={starsNeeded}
onClick={handleClick}
/>
);
}
const handleLoadMore = useLastCallback(() => {
loadStarsTransactions({
type: TRANSACTION_TYPES[selectedTabIndex],
});
});
const openPremiumGiftingModalHandler = useLastCallback(() => {
openStarsGiftingModal({});
});
return (
<Modal className={styles.root} isOpen={isOpen} onClose={closeStarsBalanceModal}>
<div className={buildClassName(styles.main, 'custom-scroll')} onScroll={handleScroll}>
@ -158,29 +143,31 @@ const StarsBalanceModal = ({
<BalanceBlock balance={balance || 0} className={styles.modalBalance} />
<div className={buildClassName(styles.header, isHeaderHidden && styles.hiddenHeader)}>
<h2 className={styles.starHeaderText}>
{lang('TelegramStars')}
{oldLang('TelegramStars')}
</h2>
</div>
<div className={styles.section}>
<img className={styles.logo} src={StarLogo} alt="" draggable={false} />
<img className={styles.logoBackground} src={StarsBackground} alt="" draggable={false} />
<h2 className={styles.headerText}>
{starsNeeded ? lang('StarsNeededTitle', starsNeeded) : lang('TelegramStars')}
{starsNeeded ? oldLang('StarsNeededTitle', starsNeeded) : oldLang('TelegramStars')}
</h2>
<div className={styles.description}>
{renderText(
starsNeeded ? lang('StarsNeededText', originBotName) : lang('TelegramStarsInfo'),
starsNeeded ? oldLang('StarsNeededText', originBotName) : oldLang('TelegramStarsInfo'),
['simple_markdown', 'emoji'],
)}
</div>
<div className={styles.options}>
{renderingOptions?.map(({ option, starsCount }) => (
<StarTopupOption option={option} starsCount={starsCount} onClick={handleClick} />
))}
{!areOptionsExtended && canExtend && (
<Button className={styles.moreOptions} isText noForcedUpperCase onClick={markOptionsExtended}>
{lang('Stars.Purchase.ShowMore')}
<Icon className={styles.iconDown} name="down" />
{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>
@ -189,13 +176,7 @@ const StarsBalanceModal = ({
{tosText}
</div>
{shouldShowTransactions && (
<>
<TabList
big
activeTab={selectedTabIndex}
tabs={TRANSACTION_TABS}
onSwitchTab={setSelectedTabIndex}
/>
<div className={styles.container}>
<div className={styles.section}>
<Transition
name={lang.isRtl ? 'slideOptimizedRtl' : 'slideOptimized'}
@ -219,38 +200,19 @@ const StarsBalanceModal = ({
</InfiniteScroll>
</Transition>
</div>
</>
<TabList
big
activeTab={selectedTabIndex}
tabs={TRANSACTION_TABS}
onSwitchTab={setSelectedTabIndex}
/>
</div>
)}
</div>
</Modal>
);
};
function StarTopupOption({
option, starsCount, onClick,
}: {
option: ApiStarTopupOption; starsCount: number; onClick?: (option: ApiStarTopupOption) => void;
}) {
const lang = useOldLang();
return (
<div className={styles.option} key={option.stars} onClick={() => onClick?.(option)}>
<div className={styles.optionTop}>
+{formatInteger(option.stars)}
{/* Switch directionality for correct order. Can't use flex because https://issues.chromium.org/issues/40249030 */}
<div className={styles.stackedStars} dir={lang.isRtl ? 'ltr' : 'rtl'}>
{Array.from({ length: starsCount }).map(() => (
<StarIcon className={styles.stackedStar} type="gold" size="big" />
))}
</div>
</div>
<div className={styles.optionBottom}>
{formatCurrency(option.amount, option.currency, lang.code)}
</div>
</div>
);
}
export default memo(withGlobal<OwnProps>(
(global, { modal }): StateProps => {
const botId = modal?.originPayment?.botId;
@ -259,6 +221,7 @@ export default memo(withGlobal<OwnProps>(
return {
starsBalanceState: global.stars,
originPaymentBot: bot,
canBuyPremium: !selectIsPremiumPurchaseBlocked(global),
};
},
)(StarsBalanceModal));

View File

@ -51,7 +51,7 @@ export const MEDIA_PROGRESSIVE_CACHE_DISABLED = false;
export const MEDIA_PROGRESSIVE_CACHE_NAME = 'tt-media-progressive';
export const MEDIA_CACHE_MAX_BYTES = 512 * 1024; // 512 KB
export const CUSTOM_BG_CACHE_NAME = 'tt-custom-bg';
export const LANG_CACHE_NAME = 'tt-lang-packs-v38';
export const LANG_CACHE_NAME = 'tt-lang-packs-v39';
export const ASSET_CACHE_NAME = 'tt-assets';
export const AUTODOWNLOAD_FILESIZE_MB_LIMITS = [1, 5, 10, 50, 100, 500];
export const DATA_BROADCAST_CHANNEL_NAME = 'tt-global';

View File

@ -511,7 +511,63 @@ addActionHandler('closePremiumGiftingModal', (global, actions, payload): ActionR
}, tabId);
});
addActionHandler('openGiftPremiumModal', async (global, actions, payload): Promise<void> => {
addActionHandler('openStarsGiftingModal', (global, actions, payload): ActionReturnType => {
const {
tabId = getCurrentTabId(),
} = payload || {};
global = getGlobal();
global = updateTabState(global, {
starsGiftingModal: {
isOpen: true,
},
}, tabId);
setGlobal(global);
});
addActionHandler('closeStarsGiftingModal', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
starsGiftingModal: undefined,
}, tabId);
});
addActionHandler('openStarGiftInfoModal', (global, actions, payload): ActionReturnType => {
const {
toUserId,
stars,
date,
tabId = getCurrentTabId(),
} = payload || {};
if (!stars || !toUserId || !date) {
return;
}
global = getGlobal();
global = updateTabState(global, {
starGiftInfoModal: {
toUserId,
stars,
date,
isOpen: true,
},
}, tabId);
setGlobal(global);
});
addActionHandler('closeStarGiftInfoModal', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
starGiftInfoModal: undefined,
}, tabId);
});
addActionHandler('openPremiumGiftModal', async (global, actions, payload): Promise<void> => {
const {
forUserIds, tabId = getCurrentTabId(),
} = payload || {};
@ -524,7 +580,7 @@ addActionHandler('openGiftPremiumModal', async (global, actions, payload): Promi
const gifts = await callApi('getPremiumGiftCodeOptions', {});
global = updateTabState(global, {
giftPremiumModal: {
giftModal: {
isOpen: true,
forUserIds,
gifts,
@ -533,10 +589,35 @@ addActionHandler('openGiftPremiumModal', async (global, actions, payload): Promi
setGlobal(global);
});
addActionHandler('closeGiftPremiumModal', (global, actions, payload): ActionReturnType => {
addActionHandler('closePremiumGiftModal', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
global = updateTabState(global, {
giftPremiumModal: { isOpen: false },
giftModal: { isOpen: false },
}, tabId);
setGlobal(global);
});
addActionHandler('openStarsGiftModal', async (global, actions, payload): Promise<void> => {
const {
forUserId, tabId = getCurrentTabId(),
} = payload || {};
const starsGiftOptions = await callApi('getStarsGiftOptions', {});
global = updateTabState(global, {
starsGiftModal: {
isOpen: true,
forUserId,
starsGiftOptions,
},
}, tabId);
setGlobal(global);
});
addActionHandler('closeStarsGiftModal', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
global = updateTabState(global, {
starsGiftModal: { isOpen: false },
}, tabId);
setGlobal(global);
});

View File

@ -32,12 +32,12 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
if (!inputInvoice.userIds) {
return;
}
const giftModalState = selectTabState(global, tabId).giftPremiumModal;
const giftModalState = selectTabState(global, tabId).giftModal;
if (giftModalState && giftModalState.isOpen
&& areDeepEqual(inputInvoice.userIds, giftModalState.forUserIds)) {
global = updateTabState(global, {
giftPremiumModal: {
giftModal: {
...giftModalState,
isCompleted: true,
},
@ -46,6 +46,24 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
}
}
if (inputInvoice?.type === 'starsgift') {
if (!inputInvoice.userId) {
return;
}
const starsModalState = selectTabState(global, tabId).starsGiftModal;
if (starsModalState && starsModalState.isOpen
&& areDeepEqual(inputInvoice.userId, starsModalState.forUserId)) {
global = updateTabState(global, {
starsGiftModal: {
...starsModalState,
isCompleted: true,
},
}, tabId);
global = closeInvoice(global, tabId);
}
}
setGlobal(global);
});

View File

@ -12,6 +12,26 @@ export function getRequestInputInvoice<T extends GlobalState>(
): ApiRequestInputInvoice | undefined {
if (inputInvoice.type === 'slug') return inputInvoice;
if (inputInvoice.type === 'starsgift') {
const {
userId, stars, amount, currency,
} = inputInvoice;
const user = selectUser(global, userId!);
if (!user) return undefined;
return {
type: 'stars',
purpose: {
type: 'starsgift',
user,
stars,
amount,
currency,
},
};
}
if (inputInvoice.type === 'stars') {
const {
stars, amount, currency,

View File

@ -61,7 +61,7 @@ import type {
ApiSendMessageAction,
ApiSession,
ApiSessionData,
ApiSponsoredMessage,
ApiSponsoredMessage, ApiStarsGiftOption,
ApiStarsTransaction,
ApiStarTopupOption,
ApiStealthMode,
@ -709,11 +709,30 @@ export type TabState = {
isOpen?: boolean;
};
giftPremiumModal?: {
starsGiftingModal?: {
isOpen?: boolean;
};
starGiftInfoModal?: {
isOpen?: boolean;
toUserId: string;
date: number;
stars: number;
};
starsGiftModal?: {
isCompleted?: boolean;
isOpen?: boolean;
forUserId?: string;
starsGiftOptions?: ApiStarsGiftOption[];
};
giftModal?: {
isCompleted?: boolean;
isOpen?: boolean;
forUserIds?: string[];
gifts?: ApiPremiumGiftCodeOption[];
starsGiftOptions?: ApiStarsGiftOption[];
};
limitReachedModal?: {
@ -3162,6 +3181,9 @@ export interface ActionPayloads {
openPremiumGiftingModal: WithTabId | undefined;
closePremiumGiftingModal: WithTabId | undefined;
openStarsGiftingModal: WithTabId | undefined;
closeStarsGiftingModal: WithTabId | undefined;
openDeleteMessageModal: ({
message?: ApiMessage;
isSchedule?: boolean;
@ -3179,12 +3201,26 @@ export interface ActionPayloads {
loadDefaultTopicIcons: undefined;
loadPremiumStickers: undefined;
openGiftPremiumModal: ({
openPremiumGiftModal: ({
chatId?: string;
forMultipleUsers?: boolean;
forUserIds?: string[];
isStarsGifting?: boolean;
} & WithTabId) | undefined;
closeGiftPremiumModal: WithTabId | undefined;
closePremiumGiftModal: WithTabId | undefined;
openStarsGiftModal: ({
chatId?: string;
forUserId?: string;
} & WithTabId) | undefined;
closeStarsGiftModal: WithTabId | undefined;
openStarGiftInfoModal: ({
toUserId?: string;
stars?: number;
date?: number;
} & WithTabId) | undefined;
closeStarGiftInfoModal: WithTabId | undefined;
setEmojiStatus: {
emojiStatus: ApiSticker;

View File

@ -1514,7 +1514,16 @@ export interface LangPair {
'ReplyInPrivateMessage': undefined;
'AriaSearchOlderResult': undefined;
'AriaSearchNewerResult': undefined;
'CreditsBoxHistoryEntryGiftOutAbout': {
'user': string | number;
'link': string | number;
};
'CreditsBoxOutAbout': {
'link': string | number;
};
'GiftStarsOutgoing': {
'user': string | number;
};
}
export type LangKey = keyof LangPair;

View File

@ -91,6 +91,7 @@ async function loadFallbackPack() {
if (!language) {
updateLanguage(fallbackData.language);
} else {
translationFn = createTranslationFn();
scheduleCallbacks();
}
}