From 1dc29627bd3f3d15ecd43ad1d4f1e1e64b17d5f5 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Sat, 2 Nov 2024 21:11:10 +0400 Subject: [PATCH] Gifts Modal: Implement extended gift options (#5017) Co-authored-by: Alexander Zinchuk Co-authored-by: zubiden <19638254+zubiden@users.noreply.github.com> --- src/api/gramjs/apiBuilders/appConfig.ts | 4 +- src/api/gramjs/apiBuilders/messageContent.ts | 16 +- src/api/gramjs/apiBuilders/messages.ts | 48 +++ src/api/gramjs/apiBuilders/payments.ts | 187 ++++---- src/api/gramjs/apiBuilders/users.ts | 3 +- src/api/gramjs/gramjsBuilders/index.ts | 13 + src/api/gramjs/methods/payments.ts | 128 +++++- src/api/types/messages.ts | 70 ++- src/api/types/misc.ts | 3 +- src/api/types/payments.ts | 73 +++- src/api/types/updates.ts | 14 +- src/api/types/users.ts | 7 + src/assets/localization/fallback.strings | 125 ++++-- src/bundles/extra.ts | 2 - src/bundles/stars.ts | 5 +- src/components/common/BadgeButton.module.scss | 16 + src/components/common/BadgeButton.tsx | 25 ++ .../common/PremiumProgress.module.scss | 11 + src/components/common/PremiumProgress.tsx | 7 +- src/components/common/Sparkles.module.scss | 6 +- src/components/common/Sparkles.tsx | 14 +- .../common/gift/GiftRibbon.module.scss | 18 + src/components/common/gift/GiftRibbon.tsx | 48 +++ .../common/gift/UserGift.module.scss | 62 +++ src/components/common/gift/UserGift.tsx | 89 ++++ .../helpers/renderActionMessageText.tsx | 24 +- src/components/common/pickers/PickerModal.tsx | 4 +- .../common/profile/UserBirthday.tsx | 10 +- .../left/main/hooks/useChatListEntry.tsx | 20 +- src/components/left/settings/SettingsMain.tsx | 6 +- src/components/main/Main.tsx | 12 +- src/components/main/premium/GiveawayModal.tsx | 8 +- .../main/premium/PremiumGiftModal.async.tsx | 18 - .../main/premium/PremiumGiftModal.tsx | 283 ------------ .../PremiumGiftingPickerModal.async.tsx | 18 - .../PremiumGiftingPickerModal.module.scss | 80 ---- .../premium/PremiumGiftingPickerModal.tsx | 117 ----- .../main/premium/StarsGiftingPickerModal.tsx | 7 +- src/components/middle/ActionMessage.tsx | 171 +++++++- src/components/middle/HeaderMenuContainer.tsx | 23 +- src/components/middle/MessageList.scss | 22 +- src/components/middle/MiddleColumn.tsx | 12 +- src/components/middle/message/Invoice.tsx | 6 +- .../reactions/ReactionButton.module.scss | 4 +- .../message/reactions/ReactionButton.tsx | 2 +- src/components/modals/ModalContainer.tsx | 18 +- .../modals/common/TableInfoModal.module.scss | 8 + .../modals/common/TableInfoModal.tsx | 13 +- .../modals/gift/GiftComposer.module.scss | 142 ++++++ src/components/modals/gift/GiftComposer.tsx | 259 +++++++++++ .../modals/gift/GiftItem.module.scss | 65 +++ .../modals/gift/GiftItemPremium.tsx | 102 +++++ src/components/modals/gift/GiftItemStar.tsx | 89 ++++ .../modals/gift/GiftModal.async.tsx | 18 + .../gift/GiftModal.module.scss} | 96 ++++- src/components/modals/gift/GiftModal.tsx | 334 ++++++++++++++ .../gift/StarGiftCategoryList.module.scss | 50 +++ .../modals/gift/StarGiftCategoryList.tsx | 98 +++++ .../modals/gift/info/GiftInfoModal.async.tsx | 18 + .../gift/info/GiftInfoModal.module.scss | 42 ++ .../modals/gift/info/GiftInfoModal.tsx | 274 ++++++++++++ .../recipient/GiftRecipientPicker.async.tsx | 18 + .../recipient/GiftRecipientPicker.module.scss | 15 + .../gift/recipient/GiftRecipientPicker.tsx | 95 ++++ .../modals/paidReaction/PaidReactionModal.tsx | 5 +- src/components/modals/stars/BalanceBlock.tsx | 4 +- .../stars/StarsBalanceModal.module.scss | 5 +- .../modals/stars/StarsBalanceModal.tsx | 43 +- .../modals/stars/StarsPaymentModal.tsx | 77 ++-- .../stars/gift}/StarsGiftModal.async.tsx | 12 +- .../stars/gift}/StarsGiftModal.module.scss | 7 - .../stars/gift}/StarsGiftModal.tsx | 91 ++-- .../modals/stars/helpers/transaction.ts | 23 + .../subscription/StarsSubscriptionModal.tsx | 3 +- .../transaction/StarsTransactionItem.tsx | 9 +- .../StarsTransactionModal.module.scss | 10 +- .../transaction/StarsTransactionModal.tsx | 108 ++--- .../modals/webApp/WebAppModal.module.scss | 2 +- src/components/modals/webApp/WebAppModal.tsx | 8 +- .../modals/webApp/WebAppModalTabContent.tsx | 12 +- src/components/payment/Checkout.module.scss | 4 +- src/components/payment/Checkout.tsx | 42 +- src/components/payment/PasswordConfirm.tsx | 2 +- src/components/payment/PaymentModal.tsx | 132 ++---- src/components/payment/ReceiptModal.tsx | 135 ++---- src/components/right/Profile.scss | 12 +- src/components/right/Profile.tsx | 43 +- .../right/hooks/useProfileViewportIds.ts | 70 ++- src/components/ui/Button.scss | 20 +- src/components/ui/Button.tsx | 38 +- src/global/actions/all.ts | 2 + src/global/actions/api/chats.ts | 24 +- src/global/actions/api/payments.ts | 408 ++++++------------ src/global/actions/api/stars.ts | 264 ++++++++++++ src/global/actions/api/sync.ts | 2 +- src/global/actions/apiUpdaters/payments.ts | 167 ++++--- src/global/actions/ui/payments.ts | 128 ++---- src/global/actions/ui/stars.ts | 258 +++++++++++ src/global/cache.ts | 5 + src/global/helpers/messageSummary.ts | 2 +- src/global/helpers/payments.ts | 22 +- src/global/initialState.ts | 10 + src/global/reducers/messages.ts | 24 ++ src/global/reducers/payments.ts | 81 ++-- src/global/selectors/payments.ts | 15 +- src/global/selectors/symbols.ts | 4 + src/global/types.ts | 133 ++++-- src/hooks/useHorizontalScroll.ts | 2 +- src/styles/_mixins.scss | 2 +- src/styles/_variables.scss | 4 + src/styles/index.scss | 4 + src/styles/themes.json | 3 +- src/types/index.ts | 24 +- src/types/language.d.ts | 114 +++++ src/util/data/readFallbackStrings.ts | 1 + src/util/data/readStrings.ts | 26 +- src/util/deeplink.ts | 2 +- src/util/formatCurrency.tsx | 2 +- src/util/getReadableErrorText.ts | 3 + src/util/localization/index.ts | 8 +- src/util/objects/customPeer.ts | 8 + 121 files changed, 4341 insertions(+), 1923 deletions(-) create mode 100644 src/components/common/BadgeButton.module.scss create mode 100644 src/components/common/BadgeButton.tsx create mode 100644 src/components/common/gift/GiftRibbon.module.scss create mode 100644 src/components/common/gift/GiftRibbon.tsx create mode 100644 src/components/common/gift/UserGift.module.scss create mode 100644 src/components/common/gift/UserGift.tsx delete mode 100644 src/components/main/premium/PremiumGiftModal.async.tsx delete mode 100644 src/components/main/premium/PremiumGiftModal.tsx delete mode 100644 src/components/main/premium/PremiumGiftingPickerModal.async.tsx delete mode 100644 src/components/main/premium/PremiumGiftingPickerModal.module.scss delete mode 100644 src/components/main/premium/PremiumGiftingPickerModal.tsx create mode 100644 src/components/modals/gift/GiftComposer.module.scss create mode 100644 src/components/modals/gift/GiftComposer.tsx create mode 100644 src/components/modals/gift/GiftItem.module.scss create mode 100644 src/components/modals/gift/GiftItemPremium.tsx create mode 100644 src/components/modals/gift/GiftItemStar.tsx create mode 100644 src/components/modals/gift/GiftModal.async.tsx rename src/components/{main/premium/PremiumGiftModal.module.scss => modals/gift/GiftModal.module.scss} (53%) create mode 100644 src/components/modals/gift/GiftModal.tsx create mode 100644 src/components/modals/gift/StarGiftCategoryList.module.scss create mode 100644 src/components/modals/gift/StarGiftCategoryList.tsx create mode 100644 src/components/modals/gift/info/GiftInfoModal.async.tsx create mode 100644 src/components/modals/gift/info/GiftInfoModal.module.scss create mode 100644 src/components/modals/gift/info/GiftInfoModal.tsx create mode 100644 src/components/modals/gift/recipient/GiftRecipientPicker.async.tsx create mode 100644 src/components/modals/gift/recipient/GiftRecipientPicker.module.scss create mode 100644 src/components/modals/gift/recipient/GiftRecipientPicker.tsx rename src/components/{main/premium => modals/stars/gift}/StarsGiftModal.async.tsx (55%) rename src/components/{main/premium => modals/stars/gift}/StarsGiftModal.module.scss (92%) rename src/components/{main/premium => modals/stars/gift}/StarsGiftModal.tsx (72%) create mode 100644 src/components/modals/stars/helpers/transaction.ts create mode 100644 src/global/actions/api/stars.ts create mode 100644 src/global/actions/ui/stars.ts diff --git a/src/api/gramjs/apiBuilders/appConfig.ts b/src/api/gramjs/apiBuilders/appConfig.ts index 26cd5e49b..42374cc7f 100644 --- a/src/api/gramjs/apiBuilders/appConfig.ts +++ b/src/api/gramjs/apiBuilders/appConfig.ts @@ -84,6 +84,7 @@ export interface GramJsAppConfig extends LimitsConfig { upload_premium_speedup_download?: number; upload_premium_speedup_upload?: number; stars_gifts_enabled?: boolean; + stargifts_message_length_max?: number; } function buildEmojiSounds(appConfig: GramJsAppConfig) { @@ -166,6 +167,7 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp channelRestrictAdsLevelMin: appConfig.channel_restrict_sponsored_level_min, paidReactionMaxAmount: appConfig.stars_paid_reaction_amount_max, isChannelRevenueWithdrawalEnabled: appConfig.channel_revenue_withdrawal_enabled, - isStarsGiftsEnabled: appConfig.stars_gifts_enabled, + isStarsGiftEnabled: appConfig.stars_gifts_enabled, + starGiftMaxMessageLength: appConfig.stargifts_message_length_max, }; } diff --git a/src/api/gramjs/apiBuilders/messageContent.ts b/src/api/gramjs/apiBuilders/messageContent.ts index d37ed580e..211cb21cb 100644 --- a/src/api/gramjs/apiBuilders/messageContent.ts +++ b/src/api/gramjs/apiBuilders/messageContent.ts @@ -8,9 +8,9 @@ import type { ApiGame, ApiGiveaway, ApiGiveawayResults, - ApiInvoice, ApiLocation, ApiMediaExtendedPreview, + ApiMediaInvoice, ApiMessageStoryData, ApiPaidMedia, ApiPhoto, @@ -473,12 +473,12 @@ function buildPollFromMedia(media: GramJs.TypeMessageMedia): ApiPoll | undefined return buildPoll(media.poll, media.results); } -function buildInvoiceFromMedia(media: GramJs.TypeMessageMedia): ApiInvoice | undefined { +function buildInvoiceFromMedia(media: GramJs.TypeMessageMedia): ApiMediaInvoice | undefined { if (!(media instanceof GramJs.MessageMediaInvoice)) { return undefined; } - return buildInvoice(media); + return buildMediaInvoice(media); } function buildLocationFromMedia(media: GramJs.TypeMessageMedia): ApiLocation | undefined { @@ -671,9 +671,9 @@ export function buildPoll(poll: GramJs.Poll, pollResults: GramJs.PollResults): A }; } -export function buildInvoice(media: GramJs.MessageMediaInvoice): ApiInvoice { +export function buildMediaInvoice(media: GramJs.MessageMediaInvoice): ApiMediaInvoice { const { - description: text, title, photo, test, totalAmount, currency, receiptMsgId, extendedMedia, + description, title, photo, test, totalAmount, currency, receiptMsgId, extendedMedia, } = media; const preview = extendedMedia instanceof GramJs.MessageExtendedMediaPreview @@ -682,10 +682,10 @@ export function buildInvoice(media: GramJs.MessageMediaInvoice): ApiInvoice { return { mediaType: 'invoice', title, - text, + description, photo: buildApiWebDocument(photo), - receiptMsgId, - amount: Number(totalAmount), + receiptMessageId: receiptMsgId, + amount: totalAmount.toJSNumber(), currency, isTest: test, extendedMedia: preview, diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index f727ec98e..be6b239f4 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -7,11 +7,13 @@ import type { ApiChat, ApiContact, ApiFactCheck, + ApiFormattedText, ApiGroupCall, ApiInputMessageReplyInfo, ApiInputReplyInfo, ApiKeyboardButton, ApiMessage, + ApiMessageActionStarGift, ApiMessageEntity, ApiMessageForwardInfo, ApiNewPoll, @@ -38,6 +40,7 @@ import { DELETED_COMMENTS_CHANNEL_ID, SERVICE_NOTIFICATIONS_USER_ID, SPONSORED_MESSAGE_CACHE_MS, + STARS_CURRENCY_CODE, SUPPORTED_AUDIO_CONTENT_TYPES, SUPPORTED_PHOTO_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES, @@ -59,6 +62,7 @@ import { buildApiPhoto, } from './common'; import { buildMessageContent, buildMessageMediaContent, buildMessageTextContent } from './messageContent'; +import { buildApiStarGift } from './payments'; import { buildApiPeerColor, buildApiPeerId, getApiChatIdFromMtpPeer } from './peers'; import { buildMessageReactions } from './reactions'; @@ -349,6 +353,21 @@ export function buildApiFactCheck(factCheck: GramJs.FactCheck): ApiFactCheck { }; } +function buildApiMessageActionStarGift(action: GramJs.MessageActionStarGift) : ApiMessageActionStarGift { + const { + nameHidden, saved, converted, gift, message, convertStars, + } = action; + + return { + isNameHidden: Boolean(nameHidden), + isSaved: Boolean(saved), + isConverted: Boolean(converted), + gift: buildApiStarGift(gift), + message: message && buildApiFormattedText(message), + starsToConvert: convertStars.toJSNumber(), + }; +} + function buildAction( action: GramJs.TypeMessageAction, senderId: string | undefined, @@ -364,6 +383,7 @@ function buildAction( let call: Partial | undefined; let amount: number | undefined; let stars: number | undefined; + let starGift: ApiMessageActionStarGift | undefined; let currency: string | undefined; let giftCryptoInfo: { currency: string; @@ -382,6 +402,7 @@ function buildAction( let isUnclaimed: boolean | undefined; let pluralValue: number | undefined; let transactionId: string | undefined; + let message: ApiFormattedText | undefined; let targetUserIds = 'users' in action ? action.users && action.users.map((id) => buildApiPeerId(id, 'user')) @@ -528,6 +549,9 @@ function buildAction( } else { translationValues.push('%action_origin%', '%gift_payment_amount%'); } + if (action.message) { + message = buildApiFormattedText(action.message); + } if (targetPeerId) { targetUserIds.push(targetPeerId); } @@ -584,6 +608,10 @@ function buildAction( if (isOutgoing) { translationValues.push('%gift_payment_amount%'); } + if (action.message) { + message = buildApiFormattedText(action.message); + } + currency = action.currency; if (action.cryptoCurrency) { giftCryptoInfo = { @@ -667,6 +695,24 @@ function buildAction( amount = action.amount.toJSNumber(); stars = action.stars.toJSNumber(); transactionId = action.transactionId; + } else if (action instanceof GramJs.MessageActionStarGift) { + type = 'starGift'; + if (isOutgoing) { + text = 'ActionGiftOutbound'; + translationValues.push('%gift_payment_amount%'); + } else { + text = 'ActionGiftInbound'; + translationValues.push('%action_origin%', '%gift_payment_amount%'); + } + + if (targetPeerId) { + targetUserIds.push(targetPeerId); + targetChatId = targetPeerId; + } + + amount = action.gift.stars.toJSNumber(); + currency = STARS_CURRENCY_CODE; + starGift = buildApiMessageActionStarGift(action); } else { text = 'ChatList.UnsupportedMessage'; } @@ -685,6 +731,7 @@ function buildAction( photo, // TODO Only used internally now, will be used for the UI in future amount, stars, + starGift, currency, giftCryptoInfo, isGiveaway, @@ -699,6 +746,7 @@ function buildAction( isUnclaimed, pluralValue, transactionId, + message, }; } diff --git a/src/api/gramjs/apiBuilders/payments.ts b/src/api/gramjs/apiBuilders/payments.ts index 1333c8103..bee6be99c 100644 --- a/src/api/gramjs/apiBuilders/payments.ts +++ b/src/api/gramjs/apiBuilders/payments.ts @@ -1,3 +1,4 @@ +import bigInt from 'big-integer'; import { Api as GramJs } from '../../../lib/gramjs'; import type { ApiPremiumSection } from '../../../global/types'; @@ -18,18 +19,20 @@ import type { ApiPrepaidGiveaway, ApiPrepaidStarsGiveaway, ApiReceipt, + ApiStarGift, ApiStarGiveawayOption, ApiStarsGiveawayWinnerOption, ApiStarsSubscription, ApiStarsTransaction, ApiStarsTransactionPeer, ApiStarTopupOption, + ApiUserStarGift, BoughtPaidMedia, } from '../../types'; import { addWebDocumentToLocalDb } from '../helpers'; import { buildApiStarsSubscriptionPricing } from './chats'; -import { buildApiMessageEntity } from './common'; +import { buildApiFormattedText, buildApiMessageEntity } from './common'; import { omitVirtualClassFields } from './helpers'; import { buildApiDocument, buildApiWebDocument, buildMessageMediaContent } from './messageContent'; import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers'; @@ -64,26 +67,20 @@ export function buildApiReceipt(receipt: GramJs.payments.TypePaymentReceipt): Ap if (receipt instanceof GramJs.payments.PaymentReceiptStars) { const { - botId, currency, date, description: text, title, totalAmount, transactionId, + botId, currency, date, description, title, totalAmount, transactionId, invoice, } = receipt; - if (photo) { - addWebDocumentToLocalDb(photo); - } - return { type: 'stars', currency, - peer: { - type: 'peer', - id: buildApiPeerId(botId, 'user'), - }, date, - text, + botId: buildApiPeerId(botId, 'user'), + description, title, totalAmount: -totalAmount.toJSNumber(), transactionId, - photo: photo && buildApiWebDocument(photo), + photo: buildApiWebDocument(photo), + invoice: buildApiInvoice(invoice), }; } @@ -91,22 +88,19 @@ export function buildApiReceipt(receipt: GramJs.payments.TypePaymentReceipt): Ap invoice, info, shipping, - currency, totalAmount, credentialsTitle, tipAmount, title, - description: text, + description, + botId, + currency, + date, + providerId, } = receipt; const { shippingAddress, phone, name } = (info || {}); - const { prices } = invoice; - const mappedPrices: ApiLabeledPrice[] = prices.map(({ label, amount }) => ({ - label, - amount: amount.toJSNumber(), - })); - let shippingPrices: ApiLabeledPrice[] | undefined; let shippingMethod: string | undefined; @@ -122,32 +116,50 @@ export function buildApiReceipt(receipt: GramJs.payments.TypePaymentReceipt): Ap return { type: 'regular', - currency, - prices: mappedPrices, info: { shippingAddress, phone, name }, totalAmount: totalAmount.toJSNumber(), + currency, + date, credentialsTitle, shippingPrices, shippingMethod, tipAmount: tipAmount ? tipAmount.toJSNumber() : 0, title, - text, + description, + botId: buildApiPeerId(botId, 'user'), + providerId: providerId.toString(), photo: photo && buildApiWebDocument(photo), + invoice: buildApiInvoice(invoice), }; } -export function buildApiPaymentForm(form: GramJs.payments.TypePaymentForm): ApiPaymentForm | undefined { +export function buildApiPaymentForm(form: GramJs.payments.TypePaymentForm): ApiPaymentForm { if (form instanceof GramJs.payments.PaymentFormStarGift) { - return undefined; + const { formId } = form; + return { + type: 'stargift', + formId: String(formId), + invoice: buildApiInvoice(form.invoice), + }; } if (form instanceof GramJs.payments.PaymentFormStars) { - const { botId, formId } = form; + const { + botId, formId, title, description, photo, + } = form; + + if (photo) { + addWebDocumentToLocalDb(photo); + } return { type: 'stars', botId: buildApiPeerId(botId, 'user'), formId: String(formId), + title, + description, + photo: buildApiWebDocument(photo), + invoice: buildApiInvoice(form.invoice), }; } @@ -163,25 +175,14 @@ export function buildApiPaymentForm(form: GramJs.payments.TypePaymentForm): ApiP savedCredentials, url, botId, + description, + title, + photo, } = form; - const { - test: isTest, - nameRequested: isNameRequested, - phoneRequested: isPhoneRequested, - emailRequested: isEmailRequested, - shippingAddressRequested: isShippingAddressRequested, - flexible: isFlexible, - phoneToProvider: shouldSendPhoneToProvider, - emailToProvider: shouldSendEmailToProvider, - currency, - prices, - } = invoice; - - const mappedPrices: ApiLabeledPrice[] = prices.map(({ label, amount }) => ({ - label, - amount: amount.toJSNumber(), - })); + if (photo) { + addWebDocumentToLocalDb(photo); + } const { shippingAddress } = savedInfo || {}; const cleanedInfo: ApiPaymentSavedInfo | undefined = savedInfo ? omitVirtualClassFields(savedInfo) : undefined; if (cleanedInfo && shippingAddress) { @@ -192,6 +193,9 @@ export function buildApiPaymentForm(form: GramJs.payments.TypePaymentForm): ApiP return { type: 'regular', + title, + description, + photo: buildApiWebDocument(photo), url, botId: buildApiPeerId(botId, 'user'), canSaveCredentials, @@ -200,18 +204,7 @@ export function buildApiPaymentForm(form: GramJs.payments.TypePaymentForm): ApiP providerId: String(providerId), nativeProvider, savedInfo: cleanedInfo, - invoiceContainer: { - isTest, - isNameRequested, - isPhoneRequested, - isEmailRequested, - isShippingAddressRequested, - isFlexible, - shouldSendPhoneToProvider, - shouldSendEmailToProvider, - currency, - prices: mappedPrices, - }, + invoice: buildApiInvoice(invoice), nativeParams: { needCardholderName: Boolean(nativeData?.need_cardholder_name), needCountry: Boolean(nativeData?.need_country), @@ -224,32 +217,47 @@ export function buildApiPaymentForm(form: GramJs.payments.TypePaymentForm): ApiP }; } -export function buildApiInvoiceFromForm(form: GramJs.payments.TypePaymentForm): ApiInvoice | undefined { - if (form instanceof GramJs.payments.PaymentFormStarGift) { - return undefined; - } - +export function buildApiInvoice(invoice: GramJs.Invoice): ApiInvoice { const { - invoice, description: text, title, photo, - } = form; - const { - test, currency, prices, recurring, termsUrl, maxTipAmount, suggestedTipAmounts, + test, + currency, + prices, + recurring, + termsUrl, + maxTipAmount, + suggestedTipAmounts, + emailRequested, + emailToProvider, + nameRequested, + phoneRequested, + phoneToProvider, + shippingAddressRequested, + flexible, } = invoice; - const totalAmount = prices.reduce((ac, cur) => ac + cur.amount.toJSNumber(), 0); + const mappedPrices: ApiLabeledPrice[] = prices.map(({ label, amount }) => ({ + label, + amount: amount.toJSNumber(), + })); + + const totalAmount = prices.reduce((acc, cur) => acc.add(cur.amount), bigInt(0)).toJSNumber(); return { - mediaType: 'invoice', - text, - title, - photo: buildApiWebDocument(photo), - amount: totalAmount, + totalAmount, currency, isTest: test, isRecurring: recurring, termsUrl, + prices: mappedPrices, maxTipAmount: maxTipAmount?.toJSNumber(), - ...(suggestedTipAmounts && { suggestedTipAmounts: suggestedTipAmounts.map((tip) => tip.toJSNumber()) }), + suggestedTipAmounts: suggestedTipAmounts?.map((tip) => tip.toJSNumber()), + isEmailRequested: emailRequested, + isEmailSentToProvider: emailToProvider, + isNameRequested: nameRequested, + isPhoneRequested: phoneRequested, + isPhoneSentToProvider: phoneToProvider, + isShippingAddressRequested: shippingAddressRequested, + isFlexible: flexible, }; } @@ -514,7 +522,7 @@ export function buildApiStarsTransactionPeer(peer: GramJs.TypeStarsTransactionPe export function buildApiStarsTransaction(transaction: GramJs.StarsTransaction): ApiStarsTransaction { const { date, id, peer, stars, description, photo, title, refund, extendedMedia, failed, msgId, pending, gift, reaction, - subscriptionPeriod, + subscriptionPeriod, stargift, giveawayPostId, } = transaction; if (photo) { @@ -540,6 +548,8 @@ export function buildApiStarsTransaction(transaction: GramJs.StarsTransaction): extendedMedia: boughtExtendedMedia, subscriptionPeriod, isReaction: reaction, + starGift: stargift && buildApiStarGift(stargift), + giveawayPostId, }; } @@ -572,3 +582,36 @@ export function buildApiStarTopupOption(option: GramJs.TypeStarsTopupOption): Ap isExtended: extended, }; } + +export function buildApiStarGift(startGift: GramJs.StarGift): ApiStarGift { + const { + id, limited, sticker, stars, availabilityRemains, availabilityTotal, convertStars, + } = startGift; + + return { + id: id.toString(), + isLimited: limited, + stickerId: sticker.id.toString(), + stars: stars.toJSNumber(), + availabilityRemains, + availabilityTotal, + starsToConvert: convertStars.toJSNumber(), + }; +} + +export function buildApiUserStarGift(userStarGift: GramJs.UserStarGift): ApiUserStarGift { + const { + gift, date, convertStars, fromId, message, msgId, nameHidden, unsaved, + } = userStarGift; + + return { + gift: buildApiStarGift(gift), + date, + starsToConvert: convertStars?.toJSNumber(), + fromId: fromId && buildApiPeerId(fromId, 'user'), + message: message && buildApiFormattedText(message), + messageId: msgId, + isNameHidden: nameHidden, + isUnsaved: unsaved, + }; +} diff --git a/src/api/gramjs/apiBuilders/users.ts b/src/api/gramjs/apiBuilders/users.ts index 991c58fad..31b104a6c 100644 --- a/src/api/gramjs/apiBuilders/users.ts +++ b/src/api/gramjs/apiBuilders/users.ts @@ -22,7 +22,7 @@ export function buildApiUserFullInfo(mtpUserFull: GramJs.users.UserFull): ApiUse profilePhoto, voiceMessagesForbidden, premiumGifts, fallbackPhoto, personalPhoto, translationsDisabled, storiesPinnedAvailable, contactRequirePremium, businessWorkHours, businessLocation, businessIntro, - birthday, personalChannelId, personalChannelMessage, sponsoredEnabled, + birthday, personalChannelId, personalChannelMessage, sponsoredEnabled, stargiftsCount, }, users, } = mtpUserFull; @@ -50,6 +50,7 @@ export function buildApiUserFullInfo(mtpUserFull: GramJs.users.UserFull): ApiUse personalChannelId: personalChannelId && buildApiPeerId(personalChannelId, 'channel'), personalChannelMessageId: personalChannelMessage, areAdsEnabled: sponsoredEnabled, + starGiftCount: stargiftsCount, }; } diff --git a/src/api/gramjs/gramjsBuilders/index.ts b/src/api/gramjs/gramjsBuilders/index.ts index ebfa144aa..fa86e803f 100644 --- a/src/api/gramjs/gramjsBuilders/index.ts +++ b/src/api/gramjs/gramjsBuilders/index.ts @@ -573,6 +573,7 @@ GramJs.TypeInputStorePaymentPurpose { : undefined, currency: purpose.currency, amount: BigInt(purpose.amount), + message: purpose.message && buildInputTextWithEntities(purpose.message), }); } @@ -633,6 +634,18 @@ export function buildInputInvoice(invoice: ApiRequestInputInvoice) { }); } + case 'stargift': { + const { + user, shouldHideName, giftId, message, + } = invoice; + return new GramJs.InputInvoiceStarGift({ + userId: buildInputEntity(user.id, user.accessHash) as GramJs.InputUser, + hideName: shouldHideName || undefined, + giftId: BigInt(giftId), + message: message && buildInputTextWithEntities(message), + }); + } + case 'stars': { const purpose = buildInputStorePaymentPurpose(invoice.purpose); return new GramJs.InputInvoiceStars({ diff --git a/src/api/gramjs/methods/payments.ts b/src/api/gramjs/methods/payments.ts index 39a849cb4..4c200e259 100644 --- a/src/api/gramjs/methods/payments.ts +++ b/src/api/gramjs/methods/payments.ts @@ -3,7 +3,8 @@ import { Api as GramJs } from '../../../lib/gramjs'; import type { ApiChat, ApiInputStorePaymentPurpose, ApiPeer, ApiRequestInputInvoice, - ApiThemeParameters, + ApiSticker, ApiThemeParameters, + ApiUser, } from '../../types'; import { DEBUG } from '../../../config'; @@ -12,25 +13,26 @@ import { buildApiBoostsStatus, buildApiCheckedGiftCode, buildApiGiveawayInfo, - buildApiInvoiceFromForm, buildApiMyBoost, buildApiPaymentForm, buildApiPremiumGiftCodeOption, buildApiPremiumPromo, buildApiReceipt, + buildApiStarGift, buildApiStarsGiftOptions, buildApiStarsGiveawayOptions, buildApiStarsSubscription, buildApiStarsTransaction, buildApiStarTopupOption, + buildApiUserStarGift, buildShippingOptions, } from '../apiBuilders/payments'; import { buildApiPeerId } from '../apiBuilders/peers'; +import { buildStickerFromDocument } from '../apiBuilders/symbols'; import { buildInputInvoice, buildInputPeer, buildInputStorePaymentPurpose, buildInputThemeParams, buildShippingInfo, } from '../gramjsBuilders'; import { - addWebDocumentToLocalDb, deserializeBytes, serializeBytes, } from '../helpers'; @@ -171,23 +173,35 @@ export async function sendStarPaymentForm({ } export async function getPaymentForm(inputInvoice: ApiRequestInputInvoice, theme?: ApiThemeParameters) { - const result = await invokeRequest(new GramJs.payments.GetPaymentForm({ - invoice: buildInputInvoice(inputInvoice), - themeParams: theme ? buildInputThemeParams(theme) : undefined, - })); + try { + const result = await invokeRequest(new GramJs.payments.GetPaymentForm({ + invoice: buildInputInvoice(inputInvoice), + themeParams: theme ? buildInputThemeParams(theme) : undefined, + }), { + shouldThrow: true, + }); - if (!result || result instanceof GramJs.payments.PaymentFormStarGift) { + if (!result) { + return undefined; + } + + return buildApiPaymentForm(result); + } catch (err) { + if (err instanceof Error) { + // Can be removed if separate error handling is added to payment UI + sendApiUpdate({ + '@type': 'error', + error: { + message: err.message, + hasErrorKey: true, + }, + }); + return { + error: err.message, + }; + } return undefined; } - - if (result.photo) { - addWebDocumentToLocalDb(result.photo); - } - - return { - form: buildApiPaymentForm(result)!, - invoice: buildApiInvoiceFromForm(result)!, - }; } export async function getReceipt(chat: ApiChat, msgId: number) { @@ -408,6 +422,86 @@ export async function fetchStarsGiveawayOptions() { return result.map(buildApiStarsGiveawayOptions); } +export async function fetchStarGifts() { + const result = await invokeRequest(new GramJs.payments.GetStarGifts({})); + + if (!result || result instanceof GramJs.payments.StarGiftsNotModified) { + return undefined; + } + + const gifts = result.gifts.map(buildApiStarGift); + const stickers : Record = {}; + + result.gifts.forEach((gift) => { + if (gift.sticker instanceof GramJs.Document) { + localDb.documents[String(gift.sticker.id)] = gift.sticker; + } + + const sticker = buildStickerFromDocument(gift.sticker); + if (sticker) { + stickers[sticker.id] = sticker; + } + }); + + return { gifts, stickers }; +} + +export async function fetchUserStarGifts({ + user, + offset = '', + limit, +}: { + user: ApiUser; + offset?: string; + limit?: number; +}) { + const result = await invokeRequest(new GramJs.payments.GetUserStarGifts({ + userId: buildInputPeer(user.id, user.accessHash), + offset, + limit, + })); + + if (!result) { + return undefined; + } + + const gifts = result.gifts.map(buildApiUserStarGift); + + return { + gifts, + nextOffset: result.nextOffset, + }; +} + +export function saveStarGift({ + user, + messageId, + shouldUnsave, +}: { + user: ApiUser; + messageId: number; + shouldUnsave?: boolean; +}) { + return invokeRequest(new GramJs.payments.SaveStarGift({ + userId: buildInputPeer(user.id, user.accessHash), + msgId: messageId, + unsave: shouldUnsave || undefined, + })); +} + +export function convertStarGift({ + user, + messageId, +}: { + user: ApiUser; + messageId: number; +}) { + return invokeRequest(new GramJs.payments.ConvertStarGift({ + userId: buildInputPeer(user.id, user.accessHash), + msgId: messageId, + })); +} + export function launchPrepaidGiveaway({ chat, giveawayId, diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 88f5e5f53..c6d430bd0 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -3,12 +3,14 @@ import type { ThreadId } from '../../types'; import type { ApiWebDocument } from './bots'; import type { ApiGroupCall, PhoneCallAction } from './calls'; import type { ApiChat, ApiPeerColor } from './chats'; -import type { ApiChatInviteInfo } from './misc'; import type { ApiInputStorePaymentPurpose, + ApiLabeledPrice, ApiPremiumGiftCodeOption, + ApiStarGift, } from './payments'; import type { ApiMessageStoryData, ApiWebPageStickerData, ApiWebPageStoryData } from './stories'; +import type { ApiUser } from './users'; export interface ApiDimensions { width: number; @@ -233,6 +235,7 @@ export type ApiInputInvoiceGiftCode = { currency: string; amount: number; option: ApiPremiumGiftCodeOption; + message?: ApiFormattedText; }; export type ApiInputInvoiceStars = { @@ -250,6 +253,14 @@ export type ApiInputInvoiceStarsGift = { amount: number; }; +export type ApiInputInvoiceStarGift = { + type: 'stargift'; + shouldHideName?: boolean; + userId: string; + giftId: string; + message?: ApiFormattedText; +}; + export type ApiInputInvoiceStarsGiveaway = { type: 'starsgiveaway'; chatId: string; @@ -268,12 +279,11 @@ export type ApiInputInvoiceStarsGiveaway = { export type ApiInputInvoiceChatInviteSubscription = { type: 'chatInviteSubscription'; hash: string; - inviteInfo: ApiChatInviteInfo; }; export type ApiInputInvoice = ApiInputInvoiceMessage | ApiInputInvoiceSlug | ApiInputInvoiceGiveaway -| ApiInputInvoiceGiftCode | ApiInputInvoiceStarsGift | ApiInputInvoiceStars | ApiInputInvoiceStarsGiveaway -| ApiInputInvoiceChatInviteSubscription; +| ApiInputInvoiceGiftCode | ApiInputInvoiceStars | ApiInputInvoiceStarsGift +| ApiInputInvoiceStarsGiveaway | ApiInputInvoiceStarGift | ApiInputInvoiceChatInviteSubscription; /* Used for Invoice request */ export type ApiRequestInputInvoiceMessage = { @@ -303,6 +313,14 @@ export type ApiRequestInputInvoiceStarsGiveaway = { purpose: ApiInputStorePaymentPurpose; }; +export type ApiRequestInputInvoiceStarGift = { + type: 'stargift'; + shouldHideName?: boolean; + user: ApiUser; + giftId: string; + message?: ApiFormattedText; +}; + export type ApiRequestInputInvoiceChatInviteSubscription = { type: 'chatInviteSubscription'; hash: string; @@ -310,22 +328,36 @@ export type ApiRequestInputInvoiceChatInviteSubscription = { export type ApiRequestInputInvoice = ApiRequestInputInvoiceMessage | ApiRequestInputInvoiceSlug | ApiRequestInputInvoiceGiveaway | ApiRequestInputInvoiceStars | ApiRequestInputInvoiceStarsGiveaway -| ApiRequestInputInvoiceChatInviteSubscription; +| ApiRequestInputInvoiceChatInviteSubscription | ApiRequestInputInvoiceStarGift; export interface ApiInvoice { - mediaType: 'invoice'; - text: string; - title: string; - photo?: ApiWebDocument; - amount: number; + prices: ApiLabeledPrice[]; + totalAmount: number; currency: string; - receiptMsgId?: number; isTest?: boolean; isRecurring?: boolean; termsUrl?: string; - extendedMedia?: ApiMediaExtendedPreview; maxTipAmount?: number; suggestedTipAmounts?: number[]; + isNameRequested?: boolean; + isPhoneRequested?: boolean; + isEmailRequested?: boolean; + isShippingAddressRequested?: boolean; + isFlexible?: boolean; + isPhoneSentToProvider?: boolean; + isEmailSentToProvider?: boolean; +} + +export interface ApiMediaInvoice { + mediaType: 'invoice'; + title: string; + description: string; + photo?: ApiWebDocument; + isTest?: boolean; + receiptMessageId?: number; + currency: string; + amount: number; + extendedMedia?: ApiMediaExtendedPreview; } export interface ApiMediaExtendedPreview { @@ -420,6 +452,15 @@ export type ApiNewPoll = { }; }; +export interface ApiMessageActionStarGift { + isNameHidden: boolean; + isSaved: boolean; + isConverted?: boolean; + gift: ApiStarGift; + message?: ApiFormattedText; + starsToConvert: number; +} + export interface ApiAction { mediaType: 'action'; text: string; @@ -438,6 +479,7 @@ export interface ApiAction { | 'giftPremium' | 'giftCode' | 'prizeStars' + | 'starGift' | 'other'; photo?: ApiPhoto; amount?: number; @@ -448,6 +490,7 @@ export interface ApiAction { currency: string; amount: number; }; + starGift?: ApiMessageActionStarGift; translationValues: string[]; call?: Partial; phoneCall?: PhoneCallAction; @@ -459,6 +502,7 @@ export interface ApiAction { isGiveaway?: boolean; isUnclaimed?: boolean; pluralValue?: number; + message?: ApiFormattedText; } export interface ApiWebPage { @@ -627,7 +671,7 @@ export type MediaContent = { webPage?: ApiWebPage; audio?: ApiAudio; voice?: ApiVoice; - invoice?: ApiInvoice; + invoice?: ApiMediaInvoice; location?: ApiLocation; game?: ApiGame; storyData?: ApiMessageStoryData; diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index 4e93a26e1..98d0082ce 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -235,7 +235,8 @@ export interface ApiAppConfig { channelRestrictAdsLevelMin?: number; paidReactionMaxAmount?: number; isChannelRevenueWithdrawalEnabled?: boolean; - isStarsGiftsEnabled?: boolean; + isStarsGiftEnabled?: boolean; + starGiftMaxMessageLength?: number; } export interface ApiConfig { diff --git a/src/api/types/payments.ts b/src/api/types/payments.ts index 6460466c1..0bffc4782 100644 --- a/src/api/types/payments.ts +++ b/src/api/types/payments.ts @@ -1,9 +1,13 @@ import type { ApiPremiumSection } from '../../global/types'; -import type { ApiInvoiceContainer } from '../../types'; import type { ApiWebDocument } from './bots'; import type { ApiChat } from './chats'; import type { - ApiDocument, ApiMessageEntity, ApiPaymentCredentials, BoughtPaidMedia, + ApiDocument, + ApiFormattedText, + ApiInvoice, + ApiMessageEntity, + ApiPaymentCredentials, + BoughtPaidMedia, } from './messages'; import type { ApiStarsSubscriptionPricing } from './misc'; import type { StatisticsOverviewPercentage } from './statistics'; @@ -34,19 +38,32 @@ export interface ApiPaymentFormRegular { formId: string; providerId: string; nativeProvider?: string; + nativeParams: ApiPaymentFormNativeParams; savedInfo?: ApiPaymentSavedInfo; savedCredentials?: ApiPaymentCredentials[]; - invoiceContainer: ApiInvoiceContainer; - nativeParams: ApiPaymentFormNativeParams; + invoice: ApiInvoice; + title: string; + description: string; + photo?: ApiWebDocument; } export interface ApiPaymentFormStars { type: 'stars'; formId: string; botId: string; + title: string; + description: string; + photo?: ApiWebDocument; + invoice: ApiInvoice; } -export type ApiPaymentForm = ApiPaymentFormRegular | ApiPaymentFormStars; +export interface ApiPaymentFormStarGift { + type: 'stargift'; + formId: string; + invoice: ApiInvoice; +} + +export type ApiPaymentForm = ApiPaymentFormRegular | ApiPaymentFormStars | ApiPaymentFormStarGift; export interface ApiPaymentFormNativeParams { needCardholderName?: boolean; @@ -64,25 +81,25 @@ export interface ApiLabeledPrice { export interface ApiReceiptStars { type: 'stars'; - peer: ApiStarsTransactionPeer; date: number; - title?: string; - text?: string; + botId: string; + title: string; + description: string; + invoice: ApiInvoice; photo?: ApiWebDocument; - media?: BoughtPaidMedia[]; currency: string; totalAmount: number; transactionId: string; - messageId?: number; } export interface ApiReceiptRegular { type: 'regular'; + botId: string; + providerId: string; + description: string; + title: string; + invoice: ApiInvoice; photo?: ApiWebDocument; - text?: string; - title?: string; - currency: string; - prices: ApiLabeledPrice[]; info?: { shippingAddress?: ApiShippingAddress; phone?: string; @@ -90,6 +107,8 @@ export interface ApiReceiptRegular { }; tipAmount: number; totalAmount: number; + currency: string; + date: number; credentialsTitle: string; shippingPrices?: ApiLabeledPrice[]; shippingMethod?: string; @@ -133,6 +152,7 @@ export type ApiInputStorePaymentGiftcode = { boostChannel?: ApiChat; currency: string; amount: number; + message?: ApiFormattedText; }; export type ApiInputStorePaymentStarsTopup = { @@ -168,6 +188,28 @@ export type ApiInputStorePaymentStarsGiveaway = { export type ApiInputStorePaymentPurpose = ApiInputStorePaymentGiveaway | ApiInputStorePaymentGiftcode | ApiInputStorePaymentStarsTopup | ApiInputStorePaymentStarsGift | ApiInputStorePaymentStarsGiveaway; +export type ApiStarGift = { + isLimited?: true; + id: string; + stickerId: string; + stars: number; + availabilityRemains?: number; + availabilityTotal?: number; + starsToConvert: number; +}; + +export interface ApiUserStarGift { + isNameHidden?: boolean; + isUnsaved?: boolean; + fromId?: string; + date: number; + gift: ApiStarGift; + message?: ApiFormattedText; + messageId?: number; + starsToConvert?: number; + isConverted?: boolean; // Local field, used for Action Message +} + export interface ApiPremiumGiftCodeOption { users: number; months: number; @@ -302,7 +344,8 @@ export interface ApiStarsTransaction { stars: number; isRefund?: true; isGift?: true; - isPrizeStars?: true; + starGift?: ApiStarGift; + giveawayPostId?: number; isMyGift?: true; // Used only for outgoing star gift messages isReaction?: true; hasFailed?: true; diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index 85e59bf53..ed80d0551 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -1,4 +1,4 @@ -import type { ApiDraft } from '../../global/types'; +import type { ApiDraft, TabState } from '../../global/types'; import type { GroupCallConnectionData, GroupCallConnectionState, @@ -20,7 +20,6 @@ import type { } from './chats'; import type { ApiFormattedText, - ApiInputInvoice, ApiMediaExtendedPreview, ApiMessage, ApiPhoto, @@ -516,7 +515,14 @@ export type ApiUpdatePaymentVerificationNeeded = { export type ApiUpdatePaymentStateCompleted = { '@type': 'updatePaymentStateCompleted'; - inputInvoice: ApiInputInvoice; + paymentState: TabState['payment']; + tabId: number; +}; + +export type ApiUpdateStarPaymentStateCompleted = { + '@type': 'updateStarPaymentStateCompleted'; + paymentState: TabState['starsPayment']; + tabId: number; }; export type ApiUpdatePrivacy = { @@ -779,7 +785,7 @@ export type ApiUpdate = ( ApiUpdateError | ApiUpdateResetContacts | ApiUpdateStartEmojiInteraction | ApiUpdateFavoriteStickers | ApiUpdateStickerSet | ApiUpdateStickerSets | ApiUpdateStickerSetsOrder | ApiUpdateRecentStickers | ApiUpdateSavedGifs | ApiUpdateNewScheduledMessage | ApiUpdateMoveStickerSetToTop | - ApiUpdateScheduledMessageSendSucceeded | ApiUpdateScheduledMessage | + ApiUpdateScheduledMessageSendSucceeded | ApiUpdateScheduledMessage | ApiUpdateStarPaymentStateCompleted | ApiUpdateDeleteScheduledMessages | ApiUpdateResetMessages | ApiUpdateMessageTranslations | ApiUpdateTwoFaError | ApiUpdatePasswordError | ApiUpdateTwoFaStateWaitCode | ApiUpdateWebViewResultSent | ApiUpdateNotifySettings | ApiUpdateNotifyExceptions | ApiUpdatePeerBlocked | ApiUpdatePrivacy | diff --git a/src/api/types/users.ts b/src/api/types/users.ts index 69e703d7d..b8360099d 100644 --- a/src/api/types/users.ts +++ b/src/api/types/users.ts @@ -3,6 +3,7 @@ import type { ApiBotInfo } from './bots'; import type { ApiBusinessIntro, ApiBusinessLocation, ApiBusinessWorkHours } from './business'; import type { ApiPeerColor } from './chats'; import type { ApiDocument, ApiPhoto } from './messages'; +import type { ApiUserStarGift } from './payments'; export interface ApiUser { id: string; @@ -58,6 +59,7 @@ export interface ApiUserFullInfo { businessLocation?: ApiBusinessLocation; businessWorkHours?: ApiBusinessWorkHours; businessIntro?: ApiBusinessIntro; + starGiftCount?: number; } export type ApiFakeType = 'fake' | 'scam'; @@ -81,6 +83,11 @@ export interface ApiUserCommonChats { isFullyLoaded: boolean; } +export interface ApiUserGifts { + gifts: ApiUserStarGift[]; + nextOffset?: string; +} + export interface ApiUsername { username: string; isActive?: boolean; diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 56e28c813..2ef78d6f3 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -33,12 +33,12 @@ "SetUrlInUse" = "Sorry, this link is already taken."; "UsernameAvailable" = "{username} is available."; "UsernameInUse" = "Sorry, this username is already taken."; -"CreateGroupError" = "Sorry, you can\'t create a group with these users because of their privacy settings."; +"CreateGroupError" = "Sorry, you can't create a group with these users because of their privacy settings."; "PasscodeControllerErrorCurrent" = "invalid passcode"; -"LimitReachedChatInFolders" = "Sorry, you can\'t add more than **{limit}** chats to a folder. You can increase this limit to **{limit2}** by subscribing to **Telegram Premium**."; +"LimitReachedChatInFolders" = "Sorry, you can't add more than **{limit}** chats to a folder. You can increase this limit to **{limit2}** by subscribing to **Telegram Premium**."; "LimitReachedFileSize" = "The document can’t be sent, because it is larger than **{limit}**. You can double this limit to **{limit2}** per document by subscribing to **Telegram Premium**."; "LimitReachedFolders" = "You have reached the limit of **{limit}** folders. You can double the limit to **{limit2}** folders by subscribing to **Telegram Premium**."; -"LimitReachedPinDialogs" = "You can\'t pin more than {limit} chats to the top. Unpin some that are currently pinned – or subscribe to **Telegram Premium** to double the limit to **{limit2}** chats."; +"LimitReachedPinDialogs" = "You can't pin more than {limit} chats to the top. Unpin some that are currently pinned – or subscribe to **Telegram Premium** to double the limit to **{limit2}** chats."; "LimitReachedPublicLinks" = "You have reserved too many public links. Try revoking the link from an older group or channel, or subscribe to **Telegram Premium** to double the limit to **{limit2}** public links."; "LimitReachedCommunities" = "You are a member of **{limit}** groups and channels. Please leave some before joining a new one — or subscribe to **Telegram Premium** to double the limit to **{limit2}** groups and channels."; "LimitReachedChatInFoldersLocked" = "Sorry, you can't add more than **{limit}** chats to a folder. Please create a new one. We are working to let you increase this limit in the future."; @@ -50,7 +50,7 @@ "LimitReachedChatInFoldersPremium" = "Sorry, you can't add more than **{limit}** chats to a folder. Please create a new one."; "LimitReachedFileSizePremium" = "The document can't be sent, because it is larger than **{limit}**."; "LimitReachedFoldersPremium" = "You have reached the limit of **{limit}** folders for this account."; -"LimitReachedPinDialogsPremium" = "Sorry, you can\'t pin more than {limit} chats to the top. Unpin some that are currently pinned."; +"LimitReachedPinDialogsPremium" = "Sorry, you can't pin more than {limit} chats to the top. Unpin some that are currently pinned."; "LimitReachedPublicLinksPremium" = "You have reserved too many public links. Try revoking the link from an older group or channel."; "LimitReachedCommunitiesPremium" = "You are a member of **{limit}** groups and channels. Please leave some before joining a new one."; "PremiumPreviewLimits" = "Doubled Limits"; @@ -116,12 +116,12 @@ "MegaPrivateLinkHelp" = "People can join your group by following this link. You can revoke the link at any time."; "ChannelUsernameCreatePublicLinkHelp" = "If you set a public link, other people will be able to find and join your channel.\n\nYou can use a–z, 0–9 and underscores.\nMinimum length is 5 characters."; "GroupUsernameCreatePublicLinkHelp" = "People can share this link with others and find your group using Telegram search."; -"UserRestrictionsNoSend" = "can\'t send messages"; +"UserRestrictionsNoSend" = "can't send messages"; "UserRestrictionsNoSendMedia" = "no media"; "UserRestrictionsNoSendStickers" = "no stickers & GIFs"; "UserRestrictionsNoEmbedLinks" = "no embed links"; "UserRestrictionsNoSendPolls" = "no polls"; -"UserRestrictionsNoChangeInfo" = "can\'t change Info"; +"UserRestrictionsNoChangeInfo" = "can't change Info"; "UserRestrictionsInviteUsers" = "Add Users"; "UserRestrictionsPinMessages" = "Pin Messages"; "StatsMessageInteractionsTitle" = "INTERACTIONS"; @@ -151,7 +151,7 @@ "ChannelStatsOverviewViewsPerPost" = "Views Per Post"; "ChannelStatsOverviewSharesPerPost" = "Shares Per Post"; "WrongNumber" = "Wrong number?"; -"SentAppCode" = "We\'ve sent the code to the **Telegram** app on your other device."; +"SentAppCode" = "We've sent the code to the **Telegram** app on your other device."; "LoginJustSentSms" = "We've sent you a code via SMS. Please enter it above."; "Code" = "Code"; "LoginHeaderPassword" = "Enter Password"; @@ -228,7 +228,7 @@ "Send" = "Send"; "SponsoredMessageInfo" = "What are sponsored\nmessages?"; "SponsoredMessageInfoDescription1" = "Unlike other apps, Telegram never uses your private data to target ads. Sponsored messages on Telegram are based solely on the topic of the public channels in which they are shown. This means that no user data is mined or analyzed to display ads, and every user viewing a channel on Telegram sees the same sponsored messages."; -"SponsoredMessageInfoDescription2" = "Unlike other apps, Telegram doesn\'t track whether you tapped on a sponsored message and doesn\'t profile you based on your activity. We also prevent external links in sponsored messages to ensure that third parties can’t spy on our users. We believe that everyone has the right to privacy, and technological platforms should respect that."; +"SponsoredMessageInfoDescription2" = "Unlike other apps, Telegram doesn't track whether you tapped on a sponsored message and doesn't profile you based on your activity. We also prevent external links in sponsored messages to ensure that third parties can’t spy on our users. We believe that everyone has the right to privacy, and technological platforms should respect that."; "SponsoredMessageInfoDescription3" = "Telegram offers a free and unlimited service to hundreds of millions of users, which involves significant server and traffic costs. In order to remain independent and stay true to its values, Telegram developed a paid tool to promote messages with user privacy in mind. We welcome responsible advertisers at:"; "SponsoredMessageAlertLearnMoreUrl" = "https://ads.telegram.org"; "SponsoredMessageInfoDescription4" = "Sponsored Messages are currently in test mode. Once they are fully launched and allow Telegram to cover its basic costs, we will start sharing ad revenue with the owners of public channels in which sponsored messages are displayed.\n\nOnline ads should no longer be synonymous with abuse of user privacy. Let us redefine how a tech company should operate – together."; @@ -499,10 +499,10 @@ "SettingsSensitiveTitle" = "Sensitive content"; "SettingsSensitiveDisableFiltering" = "Disable filtering"; "SettingsSensitiveAbout" = "Display sensitive media in public channels on all your Telegram devices."; -"BlockedUsersInfo" = "Blocked users can\'t send you messages or add you to groups. They will not see your profile pictures, online and last seen status."; +"BlockedUsersInfo" = "Blocked users can't send you messages or add you to groups. They will not see your profile pictures, online and last seen status."; "NoBlocked" = "No blocked users yet"; "BlockContact" = "Block"; -"CustomHelp" = "You won\'t see Last Seen or Online statuses for people with whom you don\'t share yours. Approximate times will be shown instead (recently, within a week, within a month)."; +"CustomHelp" = "You won't see Last Seen or Online statuses for people with whom you don't share yours. Approximate times will be shown instead (recently, within a week, within a month)."; "PrivacyExceptions" = "Exceptions"; "AlwaysAllow" = "Always Allow"; "EditAdminAddUsers" = "Add Users"; @@ -516,7 +516,7 @@ "TwoStepVerificationPasswordSetInfo" = "This password will be required when you log in on a new device in addition to the code you get in the SMS."; "TwoStepVerificationPasswordReturnSettings" = "Return to Settings"; "YourEmailCode" = "Your Email Code"; -"EnabledPasswordText" = "You have enabled Two-Step verification.\nYou\'ll need the password you set up here to log in to your Telegram account."; +"EnabledPasswordText" = "You have enabled Two-Step verification.\nYou'll need the password you set up here to log in to your Telegram account."; "ChangePassword" = "Change Password"; "TurnPasswordOff" = "Disable Password"; "SetRecoveryEmail" = "Set Recovery Email"; @@ -814,8 +814,8 @@ "ChannelVisibilityForwardingGroupInfo" = "Members will be able to copy, save and forward content from this group."; "UserRemovedBy" = "Removed by {user}"; "Unblock" = "Unblock"; -"NoBlockedChannel2" = "Users removed from the channel by admins can\'t rejoin via invite links."; -"NoBlockedGroup2" = "Users removed from the group by admins can\'t rejoin via invite links."; +"NoBlockedChannel2" = "Users removed from the channel by admins can't rejoin via invite links."; +"NoBlockedGroup2" = "Users removed from the group by admins can't rejoin via invite links."; "ChannelEditAdminPermissionBanUsers" = "Ban Users"; "DiscussionUnlinkGroup" = "Unlink Group"; "DiscussionUnlinkChannel" = "Unlink Channel"; @@ -982,7 +982,7 @@ "LimitReachedFavoriteStickersSubtitle" = "An older sticker was replaced with this one. You can **increase the limit** to {count} stickers."; "StickerPackErrorNotFound" = "Sorry, this sticker set doesn't seem to exist."; "ContactsPhoneNumberNotRegistred" = "The person with this phone number is not registered on Telegram yet."; -"VoipPeerIncompatible" = "**{user}**\'s app is using an incompatible protocol. They need to update their app before you can call them."; +"VoipPeerIncompatible" = "**{user}**'s app is using an incompatible protocol. They need to update their app before you can call them."; "NoUsernameFound" = "Username not found."; "HiddenName" = "Deleted Account"; "ChannelPersmissionDeniedSendMessagesForever" = "The admins of this group have restricted your ability to send messages."; @@ -1020,7 +1020,7 @@ "VoipMutedTapedForSpeak" = "You asked to speak"; "VoipMutedByAdmin" = "Muted by admin"; "VoipUnmute" = "Unmute"; -"VoipTapToMute" = "You\'re live"; +"VoipTapToMute" = "You're live"; "Weekday1" = "Mon"; "Weekday2" = "Tue"; "Weekday3" = "Wed"; @@ -1280,21 +1280,84 @@ "ChannelEarnAbout" = "Telegram shares 50% of the revenue from ads displayed in your channel as rewards. {link}"; "AriaSearchOlderResult" = "Focus next result"; "AriaSearchNewerResult" = "Focus previous result"; -"CreditsBoxHistoryEntryGiftOutAbout" = "With Stars, {user} will be able to unlock content and services on Telegram. {link}" -"StarsTransactionTOS" = "Review the {link} for Stars." -"StarsTransactionTOSLinkText" = "Terms of Service" -"StarsTransactionTOSLink" = "https://telegram.org/tos/stars" -"GiftStarsOutgoing" = "With Stars, {user} will be able to unlock content and services on Telegram." -"SendPaidReaction" = "Send ⭐️{amount}" -"StarsReactionTerms" = "By sending Stars you agree to the {link}" -"StarsReactionLinkText" = "Terms of Service" -"StarsReactionLink" = "https://telegram.org/tos/stars" +"CreditsBoxHistoryEntryGiftOutAbout" = "With Stars, {user} will be able to unlock content and services on Telegram. {link}"; +"StarsTransactionTOS" = "Review the {link} for Stars."; +"StarsTransactionTOSLinkText" = "Terms of Service"; +"StarsTransactionTOSLink" = "https://telegram.org/tos/stars"; +"GiftStarsOutgoing" = "With Stars, {user} will be able to unlock content and services on Telegram."; +"GiftPremiumHeader" = "Gift Premium"; +"GiftPremiumDescription" = "Give {user} access to exclusive features with Telegram Premium. {link}"; +"GiftPremiumDescriptionLinkCaption" = "See Features >"; +"GiftPremiumDescriptionLink" = "https://telegram.org/faq_premium"; +"StarsGiftHeader" = "Send a Gift"; +"StarGiftDescription" = "Give {user} gifts that can be kept on the profile or converted to Stars."; +"GiftLimited" = "limited"; +"GiftDiscount" = "-{percent}%"; +"GiftSoldCount" = "{count} sold"; +"GiftLeftCount" = "{count} left"; +"GiftSoldOut" = "sold out"; +"GiftSoldOutInfo" = "Sorry, this gift is sold out."; +"GiftMessagePlaceholder" = "Enter Message (Optional)"; +"GiftHideMyName" = "Hide My Name"; +"GiftHideNameDescription" = "Hide my name and message from visitors to {profile}'s profile. {receiver} will still see your name and message."; +"GiftSend" = "Send a Gift for {amount}"; +"GiftInfoSent" = "Sent Gift"; +"GiftInfoReceived" = "Received Gift"; +"GiftInfoTitle" = "Gift"; +"GiftInfoDescription_one" = "You can keep this gift in your Profile or convert it to **{amount}** Star."; +"GiftInfoDescription_other" = "You can keep this gift in your Profile or convert it to **{amount}** Stars."; +"GiftInfoDescriptionOut_one" = "{user} can keep this gift in profile or convert it to **{amount}** Star."; +"GiftInfoDescriptionOut_other" = "{user} can keep this gift in profile or convert it to **{amount}** Stars."; +"GiftInfoDescriptionConverted_one" = "You converted this gift to **{amount}** Star."; +"GiftInfoDescriptionConverted_other" = "You converted this gift to **{amount}** Stars."; +"GiftInfoDescriptionOutConverted_one" = "{user} converted this gift to **{amount}** Star."; +"GiftInfoDescriptionOutConverted_other" = "{user} converted this gift to **{amount}** Stars."; +"GiftInfoFrom" = "From"; +"GiftInfoDate" = "Date"; +"GiftInfoValue" = "Value"; +"GiftInfoMakeVisible" = "Display on my Page"; +"GiftInfoMakeInvisible" = "Hide from my Page"; +"GiftInfoConvert_one" = "Convert to {amount} Star"; +"GiftInfoConvert_other" = "Convert to {amount} Stars"; +"GiftInfoConvertTitle" = "Convert Gift to Stars"; +"GiftInfoConvertDescription" = "Do you want to convert this gift from **{user}** to **{amount}**?\n\nThis action cannot be undone. This will permanently destroy the gift."; +"GiftInfoSaved" = "This gift is visible on your profile. {link}"; +"GiftInfoSavedView" = "View >"; +"GiftInfoHidden" = "This gift is hidden. Only you can see it."; +"StarsAmount" = "⭐️{amount}"; +"StarsAmountText_one" = "{amount} Star"; +"StarsAmountText_other" = "{amount} Stars"; +"AllGiftsCategory" = "All gifts"; +"LimitedGiftsCategory" = "Limited"; +"PremiumGiftDescription" = "Premium"; +"SendPaidReaction" = "Send ⭐️{amount}"; +"StarsReactionTerms" = "By sending Stars you agree to the {link}"; +"StarsReactionLinkText" = "Terms of Service"; +"StarsReactionLink" = "https://telegram.org/tos/stars"; "MiniAppsMoreTabs_one" = "{botName} & {count} Other"; "MiniAppsMoreTabs_other" = "{botName} & {count} Others"; -"PrizeCredits" = "Your prize is {count} Stars." -"StarsSubscribeText_one" = "Do you want to subscribe to **{chat}** for **{amount} Star** per month?" -"StarsSubscribeText_other" = "Do you want to subscribe to **{chat}** for **{amount} Stars** per month?" -"StarsSubscribeInfo" = "By subscribing you agree to the {link}" -"StarsSubscribeInfoLinkText" = "Terms of Service" -"StarsSubscribeInfoLink" = "https://telegram.org/tos/stars" -"StarsPerMonth" = "⭐️{amount}/month" +"PrizeCredits" = "Your prize is {count} Stars."; +"ActionStarGiftTitle" = "{user} sent you a Gift for {count} Stars"; +"ActionStarGiftOutTitle" = "You have sent a gift for {count} Stars"; +"ActionStarGiftOutDescription" = "{user} can display this gift on their page or convert it to {count} Stars."; +"ActionStarGiftDescription" = "Display this gift on your page or convert it to {count} Stars."; +"ActionStarGiftDisplaying" = "You kept this gift on your page."; +"GiftTo" = "Gift to"; +"GiftFrom" = "Gift from"; +"ReceivedGift" = "Received Gift"; +"SentGift" = "Sent Gift"; +"StarGiftInfoDescriptionInbound" = "You can keep this gift in your Profile or convert it to {count} Stars. {link}"; +"StarGiftInfoDescriptionOutgoing" = "{user} can keep this gift in Profile or convert it to {count} Stars. {link}" +"StarGiftInfoLinkCaption" = "More About Stars >"; +"StarGiftDisplayOnMyPage" = "Display on on my page"; +"StarGiftConvertTo" = "Convert to"; +"StarGiftHideFromMyPage" = "Hide from my page"; +"StarGiftSenderPrivacyNote" = "Only you can see the sender's name and message."; +"StarGiftAvailability" = "Availability"; +"StarGiftAvailabilityValue" = "{number} of {total} left"; +"StarsSubscribeText_one" = "Do you want to subscribe to **{chat}** for **{amount} Star** per month?"; +"StarsSubscribeText_other" = "Do you want to subscribe to **{chat}** for **{amount} Stars** per month?"; +"StarsSubscribeInfo" = "By subscribing you agree to the {link}"; +"StarsSubscribeInfoLinkText" = "Terms of Service"; +"StarsSubscribeInfoLink" = "https://telegram.org/tos/stars"; +"StarsPerMonth" = "⭐️{amount}/month"; diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index a2f63782a..f55458cb0 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -17,9 +17,7 @@ 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 PremiumGiftModal } from '../components/main/premium/PremiumGiftModal'; export { default as GiveawayModal } from '../components/main/premium/GiveawayModal'; -export { default as PremiumGiftingPickerModal } from '../components/main/premium/PremiumGiftingPickerModal'; 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'; diff --git a/src/bundles/stars.ts b/src/bundles/stars.ts index af74a2adb..803faa177 100644 --- a/src/bundles/stars.ts +++ b/src/bundles/stars.ts @@ -1,7 +1,10 @@ -export { default as StarsGiftModal } from '../components/main/premium/StarsGiftModal'; +export { default as StarsGiftModal } from '../components/modals/stars/gift/StarsGiftModal'; export { default as StarsGiftingPickerModal } from '../components/main/premium/StarsGiftingPickerModal'; export { default as StarsBalanceModal } from '../components/modals/stars/StarsBalanceModal'; export { default as StarPaymentModal } from '../components/modals/stars/StarsPaymentModal'; export { default as StarsTransactionInfoModal } from '../components/modals/stars/transaction/StarsTransactionModal'; export { default as StarsSubscriptionModal } from '../components/modals/stars/subscription/StarsSubscriptionModal'; export { default as PaidReactionModal } from '../components/modals/paidReaction/PaidReactionModal'; +export { default as GiftModal } from '../components/modals/gift/GiftModal'; +export { default as GiftRecipientPicker } from '../components/modals/gift/recipient/GiftRecipientPicker'; +export { default as GiftInfoModal } from '../components/modals/gift/info/GiftInfoModal'; diff --git a/src/components/common/BadgeButton.module.scss b/src/components/common/BadgeButton.module.scss new file mode 100644 index 000000000..8bd2fbc9b --- /dev/null +++ b/src/components/common/BadgeButton.module.scss @@ -0,0 +1,16 @@ +.root { + font-size: 0.75rem; + line-height: 1; + border-radius: 1em; + padding: 0.25em 0.5em; + background-color: var(--accent-background-active-color); + color: var(--accent-color); + + cursor: var(--custom-cursor, pointer); + filter: brightness(1); + transition: 150ms filter ease-in; + + &:hover { + filter: brightness(1.1); + } +} diff --git a/src/components/common/BadgeButton.tsx b/src/components/common/BadgeButton.tsx new file mode 100644 index 000000000..f308e2c5b --- /dev/null +++ b/src/components/common/BadgeButton.tsx @@ -0,0 +1,25 @@ +import React from '../../lib/teact/teact'; + +import buildClassName from '../../util/buildClassName'; + +import styles from './BadgeButton.module.scss'; + +type OwnProps = { + children: React.ReactNode; + className?: string; + onClick?: NoneToVoidFunction; +}; + +const BadgeButton = ({ + children, + className, + onClick, +}: OwnProps) => { + return ( +
+ {children} +
+ ); +}; + +export default BadgeButton; diff --git a/src/components/common/PremiumProgress.module.scss b/src/components/common/PremiumProgress.module.scss index dd7e0af50..891740d42 100644 --- a/src/components/common/PremiumProgress.module.scss +++ b/src/components/common/PremiumProgress.module.scss @@ -129,3 +129,14 @@ .fullProgress { border-radius: 0.625rem; } + +.primary { + .progress { + background-image: none; + background-color: var(--color-primary); + } + + .floating-badge { + background-color: var(--color-primary); + } +} diff --git a/src/components/common/PremiumProgress.tsx b/src/components/common/PremiumProgress.tsx index 7baf6a0f5..a85eaa7db 100644 --- a/src/components/common/PremiumProgress.tsx +++ b/src/components/common/PremiumProgress.tsx @@ -21,15 +21,17 @@ type OwnProps = { floatingBadgeIcon?: IconName; floatingBadgeText?: string; progress?: number; + isPrimary?: boolean; className?: string; }; -const LimitPreview: FC = ({ +const PremiumProgress: FC = ({ leftText, rightText, floatingBadgeText, floatingBadgeIcon, progress, + isPrimary, className, }) => { const lang = useOldLang(); @@ -78,6 +80,7 @@ const LimitPreview: FC = ({ className={buildClassName( styles.root, hasFloatingBadge && styles.withBadge, + isPrimary && styles.primary, className, )} style={buildStyle( @@ -123,4 +126,4 @@ const LimitPreview: FC = ({ ); }; -export default memo(LimitPreview); +export default memo(PremiumProgress); diff --git a/src/components/common/Sparkles.module.scss b/src/components/common/Sparkles.module.scss index 32ab0d680..21afd4888 100644 --- a/src/components/common/Sparkles.module.scss +++ b/src/components/common/Sparkles.module.scss @@ -1,8 +1,6 @@ .root { position: absolute; - width: 100%; - height: 100%; - z-index: -1; + inset: 0; line-height: 1; pointer-events: none; } @@ -16,7 +14,7 @@ overflow: hidden; } -.reaction { +.button { font-size: 0.5rem; } diff --git a/src/components/common/Sparkles.tsx b/src/components/common/Sparkles.tsx index 10efd7d7b..495b7d0d2 100644 --- a/src/components/common/Sparkles.tsx +++ b/src/components/common/Sparkles.tsx @@ -5,15 +5,15 @@ import buildStyle from '../../util/buildStyle'; import styles from './Sparkles.module.scss'; -type ReactionParameters = { - preset: 'reaction'; +type ButtonParameters = { + preset: 'button'; }; type ProgressParameters = { preset: 'progress'; }; -type PresetParameters = ReactionParameters | ProgressParameters; +type PresetParameters = ButtonParameters | ProgressParameters; type OwnProps = { className?: string; @@ -23,7 +23,7 @@ const SYMBOL = '✦'; const ANIMATION_DURATION = 5; // Values are in percents -const REACTION_POSITIONS = [{ +const BUTTON_POSITIONS = [{ x: 20, y: 0, size: 100, @@ -85,10 +85,10 @@ const Sparkles = ({ className, ...presetSettings }: OwnProps) => { - if (presetSettings.preset === 'reaction') { + if (presetSettings.preset === 'button') { return ( -
- {REACTION_POSITIONS.map((position) => { +
+ {BUTTON_POSITIONS.map((position) => { const shiftX = Math.cos(Math.atan2(-50 + position.y, -50 + position.x)) * 100; const shiftY = Math.sin(Math.atan2(-50 + position.y, -50 + position.x)) * 100; return ( diff --git a/src/components/common/gift/GiftRibbon.module.scss b/src/components/common/gift/GiftRibbon.module.scss new file mode 100644 index 000000000..65cc48d1c --- /dev/null +++ b/src/components/common/gift/GiftRibbon.module.scss @@ -0,0 +1,18 @@ +.root { + position: absolute; + height: 3.5rem; + width: 3.5rem; + top: -0.125rem; + right: -0.125rem; +} + +.text { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) translate(6px, -6px) rotate(45deg); + + font-size: 0.625rem; + color: var(--color-white); + white-space: nowrap; +} diff --git a/src/components/common/gift/GiftRibbon.tsx b/src/components/common/gift/GiftRibbon.tsx new file mode 100644 index 000000000..9d9ed5e24 --- /dev/null +++ b/src/components/common/gift/GiftRibbon.tsx @@ -0,0 +1,48 @@ +import React, { memo } from '../../../lib/teact/teact'; + +import buildClassName from '../../../util/buildClassName'; + +import useUniqueId from '../../../hooks/useUniqueId'; + +import styles from './GiftRibbon.module.scss'; + +const COLORS = { + red: ['#FF5B54', '#ED1C26'], + blue: ['#6ED2FF', '#34A4FC'], +} as const; +type ColorKey = keyof typeof COLORS; + +const COLOR_KEYS = new Set(Object.keys(COLORS) as ColorKey[]); + +type OwnProps = { + color: ColorKey | string; + text: string; + className?: string; +}; + +const GiftRibbon = ({ text, color, className }: OwnProps) => { + const randomId = useUniqueId(); + const validSvgRandomId = `svg-${randomId}`; // ID must start with a letter + + const colorKey = COLOR_KEYS.has(color as ColorKey) ? color as ColorKey : undefined; + + const startColor = colorKey ? COLORS[colorKey][0] : color; + const endColor = colorKey ? COLORS[colorKey][1] : color; + + return ( +
+ + + + + + + + + +
{text}
+
+ ); +}; + +export default memo(GiftRibbon); diff --git a/src/components/common/gift/UserGift.module.scss b/src/components/common/gift/UserGift.module.scss new file mode 100644 index 000000000..d033e32b5 --- /dev/null +++ b/src/components/common/gift/UserGift.module.scss @@ -0,0 +1,62 @@ +.root { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + min-width: 0; + + padding: 0.625rem; + padding-top: 0.875rem; + + border-radius: 0.625rem; + background-color: var(--color-background-secondary); + position: relative; + cursor: var(--custom-cursor, pointer); + + &::before { + content: ""; + position: absolute; + inset: 0; + opacity: 0; + border-radius: 0.625rem; + background-color: var(--color-hover-overlay); + pointer-events: none; + } + + &:hover::before { + opacity: 1; + } +} + +.avatar { + position: absolute; + top: 0.25rem; + left: 0.25rem; +} + +.stars { + display: flex; + align-items: center; + gap: 0.125rem; + + color: #E88011; + font-weight: 500; +} + +.hiddenGift { + display: grid; + place-items: center; + + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 2rem; + height: 2rem; + border-radius: 50%; + + background-color: var(--color-light-shadow); + color: white; + font-size: 1.25rem; + backdrop-filter: blur(0.5rem); +} diff --git a/src/components/common/gift/UserGift.tsx b/src/components/common/gift/UserGift.tsx new file mode 100644 index 000000000..0c591a871 --- /dev/null +++ b/src/components/common/gift/UserGift.tsx @@ -0,0 +1,89 @@ +import React, { memo } from '../../../lib/teact/teact'; +import { getActions, withGlobal } from '../../../global'; + +import type { ApiSticker, ApiUser, ApiUserStarGift } from '../../../api/types'; + +import { STARS_CURRENCY_CODE } from '../../../config'; +import { selectUser } from '../../../global/selectors'; +import { formatCurrency } from '../../../util/formatCurrency'; +import { CUSTOM_PEER_HIDDEN } from '../../../util/objects/customPeer'; +import { formatIntegerCompact } from '../../../util/textFormat'; + +import useLastCallback from '../../../hooks/useLastCallback'; +import useOldLang from '../../../hooks/useOldLang'; + +import AnimatedIconFromSticker from '../AnimatedIconFromSticker'; +import Avatar from '../Avatar'; +import Icon from '../icons/Icon'; +import GiftRibbon from './GiftRibbon'; + +import styles from './UserGift.module.scss'; + +type OwnProps = { + userId: string; + gift: ApiUserStarGift; +}; + +type StateProps = { + fromPeer?: ApiUser; + sticker?: ApiSticker; +}; + +const GIFT_STICKER_SIZE = 90; + +const UserGift = ({ + userId, gift, fromPeer, sticker, +}: OwnProps & StateProps) => { + const { openGiftInfoModal } = getActions(); + + const oldLang = useOldLang(); + + const handleClick = useLastCallback(() => { + openGiftInfoModal({ + userId, + gift, + }); + }); + + const avatarPeer = (gift.isNameHidden || !fromPeer) ? CUSTOM_PEER_HIDDEN : fromPeer; + + if (!sticker) return undefined; + + return ( +
+ + + {gift.isUnsaved && ( +
+ +
+ )} +
+ {formatCurrency(gift.gift.stars, STARS_CURRENCY_CODE)} +
+ {gift.gift.availabilityTotal && ( + + )} +
+ ); +}; + +export default memo(withGlobal( + (global, { gift }): StateProps => { + const sticker = global.stickers.starGifts.stickers[gift.gift.stickerId]; + const fromPeer = gift.fromId ? selectUser(global, gift.fromId) : undefined; + + return { + sticker, + fromPeer, + }; + }, +)(UserGift)); diff --git a/src/components/common/helpers/renderActionMessageText.tsx b/src/components/common/helpers/renderActionMessageText.tsx index eccfaed08..7a3a9093d 100644 --- a/src/components/common/helpers/renderActionMessageText.tsx +++ b/src/components/common/helpers/renderActionMessageText.tsx @@ -36,7 +36,7 @@ const MAX_LENGTH = 32; const NBSP = '\u00A0'; export function renderActionMessageText( - lang: LangFn, + oldLang: LangFn, message: ApiMessage, actionOriginUser?: ApiUser, actionOriginChat?: ApiChat, @@ -49,23 +49,25 @@ export function renderActionMessageText( observeIntersectionForPlaying?: ObserveFn, ) { if (isExpiredMessage(message)) { - return getExpiredMessageDescription(lang, message); + return getExpiredMessageDescription(oldLang, message); } - if (!message.content.action) { + if (!message.content?.action) { return []; } const { text, translationValues, amount, currency, call, score, topicEmojiIconId, giftCryptoInfo, pluralValue, } = message.content.action; - const content: TextPart[] = []; + const noLinks = options.asPlainText || options.isEmbedded; + + const content: TextPart[] = []; const translationKey = text === 'Chat.Service.Group.UpdatedPinnedMessage1' && !targetMessage ? 'Message.PinnedGenericMessage' : text; - let unprocessed = lang( + let unprocessed = oldLang( translationKey, translationValues?.length ? translationValues : undefined, undefined, pluralValue, ); if (translationKey.includes('ScoredInGame')) { // Translation hack for games @@ -116,10 +118,10 @@ export function renderActionMessageText( '%action_origin%', actionOriginUser ? ( actionOriginUser.id === SERVICE_NOTIFICATIONS_USER_ID - ? lang('StarsTransactionUnknown') + ? oldLang('StarsTransactionUnknown') : renderUserContent(actionOriginUser, noLinks) || NBSP ) : actionOriginChat ? ( - renderChatContent(lang, actionOriginChat, noLinks) || NBSP + renderChatContent(oldLang, actionOriginChat, noLinks) || NBSP ) : 'User', '', ); @@ -131,7 +133,7 @@ export function renderActionMessageText( processed = processPlaceholder( unprocessed, '%payment_amount%', - formatCurrencyAsString(amount!, currency!, lang.code), + formatCurrencyAsString(amount!, currency!, oldLang.code), ); unprocessed = processed.pop() as string; content.push(...processed); @@ -166,11 +168,11 @@ export function renderActionMessageText( } if (unprocessed.includes('%gift_payment_amount%')) { - const price = formatCurrencyAsString(amount!, currency!, lang.code); + const price = formatCurrencyAsString(amount!, currency!, oldLang.code); let priceText = price; if (giftCryptoInfo) { - const cryptoPrice = formatCurrencyAsString(giftCryptoInfo.amount, giftCryptoInfo.currency, lang.code); + const cryptoPrice = formatCurrencyAsString(giftCryptoInfo.amount, giftCryptoInfo.currency, oldLang.code); priceText = `${cryptoPrice} (${price})`; } @@ -220,7 +222,7 @@ export function renderActionMessageText( '%message%', targetMessage ? renderMessageContent( - lang, targetMessage, options, observeIntersectionForLoading, observeIntersectionForPlaying, + oldLang, targetMessage, options, observeIntersectionForLoading, observeIntersectionForPlaying, ) : 'a message', ); diff --git a/src/components/common/pickers/PickerModal.tsx b/src/components/common/pickers/PickerModal.tsx index ca20cc653..2857bc402 100644 --- a/src/components/common/pickers/PickerModal.tsx +++ b/src/components/common/pickers/PickerModal.tsx @@ -28,7 +28,7 @@ const PickerModal = ({ ...modalProps }: OwnProps) => { const lang = useOldLang(); - const hasOnClickHandler = Boolean(onConfirm || modalProps.onClose); + const hasButton = Boolean(confirmButtonText || onConfirm); return ( {modalProps.children} - {hasOnClickHandler && ( + {hasButton && (
-
- -
-

- {renderGiftTitle()} -

-

- {renderGiftText()} -

- {!isCompleted && ( - <> -

- {renderText(renderBoostsPluralText(), ['simple_markdown', 'emoji'])} -

- -
- {renderSubscriptionGiftOptions()} -
- - )} - {renderPremiumFeaturesLink()} -
- - {!isCompleted && ( -
- -
- )} -
- ); -}; - -export default memo(withGlobal((global): StateProps => { - const { - gifts, forUserIds, isCompleted, - } = selectTabState(global).giftModal || {}; - - return { - isCompleted, - gifts, - boostPerSentGift: global.appConfig?.boostsPerSentGift, - forUserIds, - }; -})(PremiumGiftModal)); diff --git a/src/components/main/premium/PremiumGiftingPickerModal.async.tsx b/src/components/main/premium/PremiumGiftingPickerModal.async.tsx deleted file mode 100644 index 1db4183a4..000000000 --- a/src/components/main/premium/PremiumGiftingPickerModal.async.tsx +++ /dev/null @@ -1,18 +0,0 @@ -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 = (props) => { - const { isOpen } = props; - const PremiumGiftingPickerModal = useModuleLoader(Bundles.Extra, 'PremiumGiftingPickerModal', !isOpen); - - // eslint-disable-next-line react/jsx-props-no-spreading - return PremiumGiftingPickerModal ? : undefined; -}; - -export default PremiumGiftingPickerModalAsync; diff --git a/src/components/main/premium/PremiumGiftingPickerModal.module.scss b/src/components/main/premium/PremiumGiftingPickerModal.module.scss deleted file mode 100644 index 7b664767e..000000000 --- a/src/components/main/premium/PremiumGiftingPickerModal.module.scss +++ /dev/null @@ -1,80 +0,0 @@ -.root :global(.modal-content) { - padding: 0; -} - -.root :global(.modal-dialog) { - max-width: 55vh; -} - -.root :global(.modal-dialog), .root :global(.modal-content) { - 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; -} diff --git a/src/components/main/premium/PremiumGiftingPickerModal.tsx b/src/components/main/premium/PremiumGiftingPickerModal.tsx deleted file mode 100644 index 113372b2b..000000000 --- a/src/components/main/premium/PremiumGiftingPickerModal.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import type { FC } from '../../../lib/teact/teact'; -import React, { - memo, useMemo, useState, -} from '../../../lib/teact/teact'; -import { getActions, getGlobal, withGlobal } from '../../../global'; - -import { GIVEAWAY_MAX_ADDITIONAL_CHANNELS } from '../../../config'; -import { - filterUsersByName, isUserBot, -} from '../../../global/helpers'; -import { unique } from '../../../util/iteratees'; -import sortChatIds from '../../common/helpers/sortChatIds'; - -import useLastCallback from '../../../hooks/useLastCallback'; -import useOldLang from '../../../hooks/useOldLang'; - -import PeerPicker from '../../common/pickers/PeerPicker'; -import PickerModal from '../../common/pickers/PickerModal'; - -import styles from './PremiumGiftingPickerModal.module.scss'; - -export type OwnProps = { - isOpen?: boolean; -}; - -interface StateProps { - currentUserId?: string; - userSelectionLimit?: number; - userIds?: string[]; -} - -const PremiumGiftingPickerModal: FC = ({ - isOpen, - currentUserId, - userSelectionLimit = GIVEAWAY_MAX_ADDITIONAL_CHANNELS, - userIds, -}) => { - const { closePremiumGiftingModal, openPremiumGiftModal, showNotification } = getActions(); - - const oldLang = useOldLang(); - - const [selectedUserIds, setSelectedUserIds] = useState([]); - const [searchQuery, setSearchQuery] = useState(''); - - const displayedUserIds = useMemo(() => { - const usersById = getGlobal().users.byId; - const filteredContactIds = userIds ? filterUsersByName(userIds, usersById, searchQuery) : []; - - return sortChatIds(unique(filteredContactIds).filter((userId) => { - const user = usersById[userId]; - if (!user) { - return true; - } - - return !isUserBot(user) && userId !== currentUserId; - })); - }, [currentUserId, searchQuery, userIds]); - - const handleSendIdList = useLastCallback(() => { - if (selectedUserIds?.length) { - openPremiumGiftModal({ forUserIds: selectedUserIds }); - closePremiumGiftingModal(); - } - }); - - const handleSelectedUserIdsChange = useLastCallback((newSelectedIds: string[]) => { - if (newSelectedIds.length > userSelectionLimit) { - showNotification({ - message: oldLang('BoostingSelectUpToWarningUsers', userSelectionLimit), - }); - return; - } - setSelectedUserIds(newSelectedIds); - }); - - return ( - - - - ); -}; - -export default memo(withGlobal((global): StateProps => { - const { currentUserId } = global; - - return { - currentUserId, - userIds: global.contactList?.userIds, - userSelectionLimit: global.appConfig?.giveawayAddPeersMax, - }; -})(PremiumGiftingPickerModal)); diff --git a/src/components/main/premium/StarsGiftingPickerModal.tsx b/src/components/main/premium/StarsGiftingPickerModal.tsx index 1f5cb4040..b6650c543 100644 --- a/src/components/main/premium/StarsGiftingPickerModal.tsx +++ b/src/components/main/premium/StarsGiftingPickerModal.tsx @@ -38,7 +38,7 @@ const StarsGiftingPickerModal: FC = ({ archivedListIds, userIds, }) => { - const { closeStarsGiftingModal, openStarsGiftModal } = getActions(); + const { closeStarsGiftingPickerModal, openStarsGiftModal } = getActions(); const oldLang = useOldLang(); @@ -70,6 +70,7 @@ const StarsGiftingPickerModal: FC = ({ const handleSelectedUserIdsChange = useLastCallback((newSelectedId?: string) => { if (newSelectedId?.length) { openStarsGiftModal({ forUserId: newSelectedId }); + closeStarsGiftingPickerModal(); } }); @@ -77,13 +78,13 @@ const StarsGiftingPickerModal: FC = ({ = ({ message, @@ -96,10 +105,12 @@ const ActionMessage: FC = ({ noFocusHighlight, premiumGiftSticker, starGiftSticker, + starsGiftSticker, isInsideTopic, topic, memoFirstUnreadIdRef, canPlayAnimatedEmojis, + patternColor, observeIntersectionForReading, observeIntersectionForLoading, observeIntersectionForPlaying, @@ -110,7 +121,7 @@ const ActionMessage: FC = ({ requestConfetti, checkGiftCode, getReceipt, - openStarsTransactionFromGift, + openGiftInfoModalFromMessage, openPrizeStarsTransactionFromGiveaway, } = getActions(); @@ -141,6 +152,7 @@ const ActionMessage: FC = ({ const isSuggestedAvatar = message.content.action?.type === 'suggestProfilePhoto' && message.content.action!.photo; const isJoinedMessage = isJoinedChannelMessage(message); const isStarsGift = message.content.action?.type === 'giftStars'; + const isStarGift = message.content.action?.type === 'starGift'; const isPrizeStars = message.content.action?.type === 'prizeStars'; useEffect(() => { @@ -207,7 +219,7 @@ const ActionMessage: FC = ({ }; const handleStarGiftClick = () => { - openStarsTransactionFromGift({ + openGiftInfoModalFromMessage({ chatId: message.chatId, messageId: message.id, }); @@ -255,6 +267,7 @@ const ActionMessage: FC = ({ } function renderGift() { + const giftMessage = message.content.action?.message; return ( = ({ {oldLang('ActionGiftPremiumSubtitle', oldLang('Months', message.content.action?.months, 'i'))} + {giftMessage && ( +
+ {renderTextWithEntities({ text: giftMessage.text, entities: giftMessage.entities })} +
+ )} - {oldLang('ActionGiftPremiumView')} + + + {oldLang('ActionGiftPremiumView')} +
); } @@ -282,6 +303,7 @@ const ActionMessage: FC = ({ function renderGiftCode() { const isFromGiveaway = message.content.action?.isGiveaway; const isUnclaimed = message.content.action?.isUnclaimed; + const giftMessage = message.content.action?.message; return ( = ({ ), ['simple_markdown'])} - { - oldLang('BoostingReceivedGiftOpenBtn') - } + {giftMessage && ( +
+ {renderTextWithEntities({ text: giftMessage.text, entities: giftMessage.entities })} +
+ )} + + + {oldLang('BoostingReceivedGiftOpenBtn')}
); @@ -334,7 +361,7 @@ const ActionMessage: FC = ({ > = ({ ['simple_markdown'], )} - { - oldLang('ActionGiftPremiumView') - } + + + {oldLang('ActionGiftPremiumView')} ); } + function renderStarGiftUserCaption() { + const targetUser = targetUsers && targetUsers[0]; + if (!targetUser || !senderUser) return undefined; + + if (message.isOutgoing) { + return ( +
+ {lang('GiftTo')} + + {targetUser.firstName} +
+ ); + } + + return ( +
+ {lang('GiftFrom')} + + {senderUser.firstName} +
+ ); + } + + function renderStarGiftUserDescription() { + const starGift = message.content.action?.starGift; + const targetUser = targetUsers && targetUsers[0]?.firstName; + const starGiftMessage = message.content.action?.starGift?.message; + if (!starGift) return undefined; + + if (starGiftMessage) { + return renderTextWithEntities({ text: starGiftMessage.text, entities: starGiftMessage.entities }); + } + const amount = starGift?.starsToConvert; + + if (message.isOutgoing) { + return lang('ActionStarGiftOutDescription', { + user: targetUser || 'User', + count: amount, + }, { withNodes: true }); + } + + if (starGift.isSaved) { + return lang('ActionStarGiftDisplaying'); + } + + if (starGift.isConverted) { + return message.isOutgoing + ? lang('GiftInfoDescriptionOutConverted', { + amount: formatInteger(amount!), + user: targetUser || 'User', + }, { + pluralValue: amount, + withNodes: true, + withMarkdown: true, + }) + : lang('GiftInfoDescriptionConverted', { + amount: formatInteger(amount!), + }, { + pluralValue: amount, + withNodes: true, + withMarkdown: true, + }); + } + + return lang('ActionStarGiftDescription', { + count: amount, + }, { withNodes: true }); + } + + function renderStarGift() { + const starGift = message.content.action?.starGift; + if (!starGift) return undefined; + + return ( + + + + + {renderStarGiftUserCaption()} +
+ {renderStarGiftUserDescription()} +
+ + {!message.isOutgoing && ( +
+ + {oldLang('ActionGiftPremiumView')} +
+ )} + {starGift.gift.availabilityTotal && ( + + )} +
+ ); + } + function renderPrizeStars() { const isUnclaimed = message.content.action?.isUnclaimed; @@ -427,6 +564,7 @@ const ActionMessage: FC = ({ {isPremiumGift && renderGift()} {isGiftCode && renderGiftCode()} {isStarsGift && renderStarsGift()} + {isStarGift && renderStarGift()} {isPrizeStars && renderPrizeStars()} {isSuggestedAvatar && ( @@ -458,6 +596,11 @@ export default memo(withGlobal( ? selectChatMessage(global, chatId, targetMessageId) : undefined; + const theme = selectTheme(global); + const { + patternColor, + } = global.settings.themes[theme] || {}; + const isFocused = threadId ? selectIsMessageFocused(global, message, threadId) : false; const { direction: focusDirection, @@ -472,8 +615,10 @@ export default memo(withGlobal( const giftDuration = content.action?.months; const premiumGiftSticker = selectGiftStickerForDuration(global, giftDuration); + const starGift = content.action?.type === 'starGift' ? content.action.starGift?.gift : undefined; const starCount = content.action?.stars; - const starGiftSticker = selectGiftStickerForStars(global, starCount); + const starGiftSticker = starGift?.stickerId ? selectStarGiftSticker(global, starGift.stickerId) : undefined; + const starsGiftSticker = selectGiftStickerForStars(global, starCount); const topic = selectTopicFromMessage(global, message); @@ -487,7 +632,9 @@ export default memo(withGlobal( isFocused, premiumGiftSticker, starGiftSticker, + starsGiftSticker, topic, + patternColor, canPlayAnimatedEmojis: selectCanPlayAnimatedEmojis(global), ...(isFocused && { focusDirection, diff --git a/src/components/middle/HeaderMenuContainer.tsx b/src/components/middle/HeaderMenuContainer.tsx index 406bce0b9..29b29f345 100644 --- a/src/components/middle/HeaderMenuContainer.tsx +++ b/src/components/middle/HeaderMenuContainer.tsx @@ -111,7 +111,7 @@ type StateProps = { canAddContact?: boolean; canReportChat?: boolean; canDeleteChat?: boolean; - canGiftPremium?: boolean; + canGift?: boolean; canCreateTopic?: boolean; canEditTopic?: boolean; hasLinkedChat?: boolean; @@ -158,7 +158,7 @@ const HeaderMenuContainer: FC = ({ isMuted, canReportChat, canDeleteChat, - canGiftPremium, + canGift, hasLinkedChat, canAddContact, canCreateTopic, @@ -191,7 +191,7 @@ const HeaderMenuContainer: FC = ({ toggleStatistics, openMonetizationStatistics, openBoostStatistics, - openPremiumGiftModal, + openGiftModal, openThreadWithInfo, openCreateTopicPanel, openEditTopicPanel, @@ -317,8 +317,8 @@ const HeaderMenuContainer: FC = ({ closeMenu(); }); - const handleGiftPremiumClick = useLastCallback(() => { - openPremiumGiftModal({ forUserIds: [chatId] }); + const handleGiftClick = useLastCallback(() => { + openGiftModal({ forUserId: chatId }); closeMenu(); }); @@ -672,12 +672,12 @@ const HeaderMenuContainer: FC = ({ )} {botButtons} - {canGiftPremium && ( + {canGift && ( - {lang('GiftPremium')} + {lang('ProfileSendAGift')} )} {isBot && ( @@ -756,10 +756,7 @@ export default memo(withGlobal( const userFullInfo = isPrivate ? selectUserFullInfo(global, chatId) : undefined; const chatFullInfo = !isPrivate ? selectChatFullInfo(global, chatId) : undefined; const fullInfo = userFullInfo || chatFullInfo; - const canGiftPremium = Boolean( - userFullInfo?.premiumGifts?.length - && !selectIsPremiumPurchaseBlocked(global), - ); + const canGift = !selectIsPremiumPurchaseBlocked(global) && !isChatWithSelf; const topic = selectTopic(global, chatId, threadId); const canCreateTopic = chat.isForum && ( @@ -783,7 +780,7 @@ export default memo(withGlobal( canAddContact, canReportChat, canDeleteChat: getCanDeleteChat(chat), - canGiftPremium, + canGift, hasLinkedChat: Boolean(chatFullInfo?.linkedChatId), botCommands: chatBot ? userFullInfo?.botInfo?.commands : undefined, botPrivacyPolicyUrl: chatBot ? userFullInfo?.botInfo?.privacyPolicyUrl : undefined, diff --git a/src/components/middle/MessageList.scss b/src/components/middle/MessageList.scss index 3b15bae95..ce7d5378e 100644 --- a/src/components/middle/MessageList.scss +++ b/src/components/middle/MessageList.scss @@ -271,6 +271,7 @@ .action-message-gift { display: flex !important; + width: 13.75rem; flex-direction: column; align-items: center; line-height: 1rem !important; @@ -281,15 +282,10 @@ } .action-message-gift-code { - width: 12rem; - margin-inline: auto; - } - - .action-message-stars-gift { - width: 15rem; margin-inline: auto; } + .action-message-user-caption, .action-message-stars-balance { margin-top: 0.5rem; display: flex; @@ -298,15 +294,26 @@ font-weight: 500; } + .action-message-user-caption { + align-items: center; + font-size: 0.875rem; + font-weight: 500; + } + + .action-message-user-avatar { + margin-left: 0.25rem; + } + .action-message-subtitle { margin-top: 1rem; font-weight: normal; text-wrap: balance; } - .action-message-stars-subtitle { + .action-message-gift-subtitle { font-weight: normal; text-wrap: balance; + font-size: 0.75rem; } .action-message-suggested-avatar { @@ -328,6 +335,7 @@ } .action-message-button { + position: relative; display: inline-block; border-radius: var(--border-radius-default); padding: 0.5rem 0.75rem; diff --git a/src/components/middle/MiddleColumn.tsx b/src/components/middle/MiddleColumn.tsx index d976849ec..ab85a272d 100644 --- a/src/components/middle/MiddleColumn.tsx +++ b/src/components/middle/MiddleColumn.tsx @@ -86,8 +86,6 @@ import Composer from '../common/Composer'; import PrivacySettingsNoticeModal from '../common/PrivacySettingsNoticeModal.async'; import SeenByModal from '../common/SeenByModal.async'; import UnpinAllMessagesModal from '../common/UnpinAllMessagesModal.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'; @@ -138,8 +136,6 @@ type StateProps = { isSeenByModalOpen: boolean; isPrivacySettingsNoticeModalOpen: boolean; isReactorListModalOpen: boolean; - isPremiumGiftModalOpen?: boolean; - isStarsGiftModalOpen?: boolean; isChatLanguageModalOpen?: boolean; withInterfaceAnimations?: boolean; shouldSkipHistoryAnimations?: boolean; @@ -199,8 +195,6 @@ function MiddleColumn({ isSeenByModalOpen, isPrivacySettingsNoticeModalOpen, isReactorListModalOpen, - isPremiumGiftModalOpen, - isStarsGiftModalOpen, isChatLanguageModalOpen, withInterfaceAnimations, shouldSkipHistoryAnimations, @@ -721,8 +715,6 @@ function MiddleColumn({ /> ))}
- -
); } @@ -736,7 +728,7 @@ export default memo(withGlobal( const { messageLists, isLeftColumnShown, activeEmojiInteractions, - seenByModal, giftModal, starsGiftModal, reactorModal, audioPlayer, shouldSkipHistoryAnimations, + seenByModal, reactorModal, audioPlayer, shouldSkipHistoryAnimations, chatLanguageModal, privacySettingsNoticeModal, } = selectTabState(global); const currentMessageList = selectCurrentMessageList(global); @@ -755,8 +747,6 @@ export default memo(withGlobal( isSeenByModalOpen: Boolean(seenByModal), isPrivacySettingsNoticeModalOpen: Boolean(privacySettingsNoticeModal), isReactorListModalOpen: Boolean(reactorModal), - isPremiumGiftModalOpen: giftModal?.isOpen, - isStarsGiftModalOpen: starsGiftModal?.isOpen, isChatLanguageModalOpen: Boolean(chatLanguageModal), withInterfaceAnimations: selectCanAnimateInterface(global), currentTransitionKey: Math.max(0, messageLists.length - 1), diff --git a/src/components/middle/message/Invoice.tsx b/src/components/middle/message/Invoice.tsx index a344ebc50..23c8e9a14 100644 --- a/src/components/middle/message/Invoice.tsx +++ b/src/components/middle/message/Invoice.tsx @@ -46,7 +46,7 @@ const Invoice: FC = ({ const { title, - text, + description, amount, currency, isTest, @@ -93,8 +93,8 @@ const Invoice: FC = ({ {title && (

{renderText(title)}

)} - {text && ( -
{renderText(text, ['emoji', 'br'])}
+ {description && ( +
{renderText(description, ['emoji', 'br'])}
)}
{Boolean(photo) && ( diff --git a/src/components/middle/message/reactions/ReactionButton.module.scss b/src/components/middle/message/reactions/ReactionButton.module.scss index 3214dd290..273849506 100644 --- a/src/components/middle/message/reactions/ReactionButton.module.scss +++ b/src/components/middle/message/reactions/ReactionButton.module.scss @@ -19,8 +19,8 @@ } &.paid.chosen { - --reaction-background: #FFBC2E !important; - --reaction-background-hover: #FFBC2ECC !important; + --reaction-background: #FFB727 !important; + --reaction-background-hover: #FFB727CC !important; --reaction-text-color: #FFFFFF !important; } diff --git a/src/components/middle/message/reactions/ReactionButton.tsx b/src/components/middle/message/reactions/ReactionButton.tsx index 75217d519..8f3ff385f 100644 --- a/src/components/middle/message/reactions/ReactionButton.tsx +++ b/src/components/middle/message/reactions/ReactionButton.tsx @@ -184,6 +184,7 @@ const ReactionButton = ({ > {reaction.reaction.type === 'paid' ? ( <> + - {shouldRenderPaidCounter && ( ; type StateProps = { @@ -70,12 +78,16 @@ const MODALS: ModalRegistry = { webApps: WebAppModal, collectibleInfoModal: CollectibleInfoModal, mapModal: MapModal, - isStarPaymentModalOpen: StarsPaymentModal, + starsPayment: StarsPaymentModal, starsBalanceModal: StarsBalanceModal, starsTransactionModal: StarsTransactionInfoModal, chatInviteModal: ChatInviteModal, paidReactionModal: PaidReactionModal, starsSubscriptionModal: StarsSubscriptionModal, + starsGiftModal: StarsGiftModal, + giftModal: PremiumGiftModal, + isGiftRecipientPickerOpen: GiftRecipientPicker, + giftInfoModal: GiftInfoModal, }; const MODAL_KEYS = Object.keys(MODALS) as ModalKey[]; const MODAL_ENTRIES = Object.entries(MODALS) as Entries; diff --git a/src/components/modals/common/TableInfoModal.module.scss b/src/components/modals/common/TableInfoModal.module.scss index 8dfc1fc25..66276f600 100644 --- a/src/components/modals/common/TableInfoModal.module.scss +++ b/src/components/modals/common/TableInfoModal.module.scss @@ -29,6 +29,10 @@ overflow: hidden; } +.noFooter { + margin-top: 1.5rem; +} + .cell { display: flex; align-items: center; @@ -37,6 +41,10 @@ min-height: 2.5rem; } +.fullWidth { + grid-column: 1 / -1; +} + .avatar { align-self: center; } diff --git a/src/components/modals/common/TableInfoModal.tsx b/src/components/modals/common/TableInfoModal.tsx index 167bdcd5f..67e49b75e 100644 --- a/src/components/modals/common/TableInfoModal.tsx +++ b/src/components/modals/common/TableInfoModal.tsx @@ -17,7 +17,7 @@ import styles from './TableInfoModal.module.scss'; type ChatItem = { chatId: string }; -export type TableData = [TeactNode, TeactNode | ChatItem][]; +export type TableData = [TeactNode | undefined, TeactNode | ChatItem][]; type OwnProps = { isOpen?: boolean; @@ -68,8 +68,8 @@ const TableInfoModal = ({
{tableData?.map(([label, value]) => ( <> -
{label}
-
+ {label &&
{label}
} +
{typeof value === 'object' && 'chatId' in value ? ( {footer} {buttonText && ( - + )} ); diff --git a/src/components/modals/gift/GiftComposer.module.scss b/src/components/modals/gift/GiftComposer.module.scss new file mode 100644 index 000000000..758650549 --- /dev/null +++ b/src/components/modals/gift/GiftComposer.module.scss @@ -0,0 +1,142 @@ +.root { + height: 100%; + display: flex; + flex-direction: column; + overflow-y: auto; + padding-top: 3.5rem; +} + +.header { + padding: 0.5rem; + padding-left: 4rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +.spacer { + flex-grow: 1; +} + +.title { + margin-inline: 0.5rem; + font-size: 1.25rem; + font-weight: 500; +} + +.balance-container { + margin-left: auto; + align-items: end; + display: flex; + flex-direction: column; +} + +.balance-caption { + font-size: 1rem; +} + +.star-balance { + margin-right: 0.1875rem; +} + +.balance { + display: flex; + font-size: 1rem; + font-weight: 500; + align-items: center; +} + +.optionsSection { + padding: 1rem; + padding-bottom: 0.5rem; + box-shadow: 0 1px 2px var(--color-default-shadow); +} + +.checkboxTitle { + color: var(--color-text); + font-size: 1rem; + text-transform: initial; + margin: 0; +} + +.actionMessageView { + display: grid; + place-content: center; + height: 22.5rem; + margin-bottom: 0; + + position: relative; + overflow: hidden; + flex: 0 0 auto; + + background-color: var(--theme-background-color); + background-position: center; + background-repeat: no-repeat; + background-size: 100% 100%; + + :global(html.theme-light) & { + background-image: url('../../../assets/chat-bg-br.png'); + } + + &::before { + content: ""; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + background-image: url('../../../assets/chat-bg-pattern-light.png'); + background-position: top right; + background-size: 510px auto; + background-repeat: repeat; + mix-blend-mode: overlay; + + :global(html.theme-dark) & { + background-image: url('../../../assets/chat-bg-pattern-dark.png'); + mix-blend-mode: unset; + } + + @media (max-width: 600px) { + bottom: auto; + height: calc(var(--vh, 1vh) * 100); + } + } +} + +.messageInput, .limited { + margin-bottom: 0.5rem; +} + +.footer { + display: flex; + justify-content: space-between; + padding: 1rem; + padding-top: 0.5rem; + flex-grow: 1; + flex-direction: column; + background-color: var(--color-background-secondary); +} + +.switcher { + margin-bottom: 0 !important; +} + +.description { + color: var(--color-text-secondary); + font-size: 0.875rem; +} + +.main-button { + display: flex; + font-weight: 500; + font-size: 1rem; + height: 3rem; +} + +.star { + --color-fill: var(--color-white); + width: 1rem; + height: 1rem; + margin-right: 0.1875rem; + margin-left: 0.5rem; +} diff --git a/src/components/modals/gift/GiftComposer.tsx b/src/components/modals/gift/GiftComposer.tsx new file mode 100644 index 000000000..54429a8b5 --- /dev/null +++ b/src/components/modals/gift/GiftComposer.tsx @@ -0,0 +1,259 @@ +import type { ChangeEvent } from 'react'; +import React, { + memo, useMemo, useState, +} from '../../../lib/teact/teact'; +import { getActions, withGlobal } from '../../../global'; + +import type { ApiMessage, ApiUser } from '../../../api/types'; +import type { GiftOption } from './GiftModal'; + +import { STARS_CURRENCY_CODE, STARS_ICON_PLACEHOLDER } from '../../../config'; +import { getUserFullName } from '../../../global/helpers'; +import { selectTabState, selectTheme, selectUser } from '../../../global/selectors'; +import buildClassName from '../../../util/buildClassName'; +import { formatCurrency } from '../../../util/formatCurrency'; +import { formatInteger } from '../../../util/textFormat'; + +import useLang from '../../../hooks/useLang'; +import useLastCallback from '../../../hooks/useLastCallback'; + +import Icon from '../../common/icons/Icon'; +import PremiumProgress from '../../common/PremiumProgress'; +import ActionMessage from '../../middle/ActionMessage'; +import Button from '../../ui/Button'; +import ListItem from '../../ui/ListItem'; +import Switcher from '../../ui/Switcher'; +import TextArea from '../../ui/TextArea'; + +import styles from './GiftComposer.module.scss'; + +export type OwnProps = { + gift: GiftOption; + userId: string; +}; + +export type StateProps = { + captionLimit?: number; + patternColor?: string; + user?: ApiUser; + currentUserId?: string; + isPaymentFormLoading?: boolean; +}; + +const LIMIT_DISPLAY_THRESHOLD = 50; + +function GiftComposer({ + gift, + userId, + user, + captionLimit, + patternColor, + currentUserId, + isPaymentFormLoading, +}: OwnProps & StateProps) { + const { sendStarGift, openInvoice } = getActions(); + + const lang = useLang(); + + const [giftMessage, setGiftMessage] = useState(''); + const [shouldHideName, setShouldHideName] = useState(false); + + const isStarGift = 'id' in gift; + + const localMessage = useMemo(() => { + if (!isStarGift) { + return { + id: -1, + chatId: '0', + isOutgoing: true, + senderId: currentUserId, + date: Math.floor(Date.now() / 1000), + content: { + action: { + targetUserIds: [userId], + mediaType: 'action', + text: 'ActionGiftInbound', + type: 'giftPremium', + amount: gift.amount, + currency: gift.currency, + months: gift.months, + message: { + text: giftMessage, + }, + translationValues: ['%action_origin%', '%gift_payment_amount%'], + }, + }, + } satisfies ApiMessage; + } + + return { + id: -1, + chatId: currentUserId!, + isOutgoing: false, + senderId: currentUserId, + date: Math.floor(Date.now() / 1000), + content: { + action: { + targetUserIds: [userId], + mediaType: 'action', + text: 'ActionGiftInbound', + type: 'starGift', + currency: STARS_CURRENCY_CODE, + amount: gift.stars, + starGift: { + message: giftMessage?.length ? { + text: giftMessage, + } : undefined, + isNameHidden: shouldHideName, + starsToConvert: gift.starsToConvert, + isSaved: false, + isConverted: false, + gift, + }, + translationValues: ['%action_origin%', '%gift_payment_amount%'], + }, + }, + } satisfies ApiMessage; + }, [currentUserId, gift, giftMessage, isStarGift, shouldHideName, userId]); + + const handleGiftMessageChange = useLastCallback((e: ChangeEvent) => { + setGiftMessage(e.target.value); + }); + + const handleShouldHideNameChange = useLastCallback(() => { + setShouldHideName(!shouldHideName); + }); + + const handleMainButtonClick = useLastCallback(() => { + if (isStarGift) { + sendStarGift({ + userId, + shouldHideName, + gift, + message: giftMessage ? { text: giftMessage } : undefined, + }); + return; + } + + openInvoice({ + type: 'giftcode', + userIds: [userId], + currency: gift.currency, + amount: gift.amount, + option: gift, + message: giftMessage ? { text: giftMessage } : undefined, + }); + }); + + function renderOptionsSection() { + const symbolsLeft = captionLimit ? captionLimit - giftMessage.length : undefined; + return ( +
+