From 049db12d925cdfdc499d14547dc97901a5f6ebf0 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Tue, 20 Jan 2026 12:00:30 +0100 Subject: [PATCH] Gifts: Support purchase offer (#6579) --- src/api/gramjs/apiBuilders/appConfig.ts | 2 + src/api/gramjs/apiBuilders/gifts.ts | 3 + src/api/gramjs/apiBuilders/messageActions.ts | 35 ++- src/api/gramjs/methods/stars.ts | 15 ++ src/api/types/messageActions.ts | 20 +- src/api/types/messages.ts | 7 + src/api/types/misc.ts | 1 + src/api/types/stars.ts | 2 + src/assets/localization/fallback.strings | 21 ++ src/bundles/stars.ts | 1 + .../middle/message/ActionMessage.module.scss | 14 ++ .../middle/message/ActionMessage.tsx | 151 +++++++++++- .../middle/message/ActionMessageText.tsx | 78 +++++- .../middle/message/InlineButtons.scss | 2 + .../middle/message/InlineButtons.tsx | 8 + .../actions/StarGiftPurchaseOffer.module.scss | 63 +++++ .../message/actions/StarGiftPurchaseOffer.tsx | 180 ++++++++++++++ src/components/modals/ModalContainer.tsx | 3 + .../modals/gift/info/GiftInfoModal.tsx | 9 +- .../gift/offer/GiftOfferAcceptModal.async.tsx | 18 ++ .../offer/GiftOfferAcceptModal.module.scss | 38 +++ .../gift/offer/GiftOfferAcceptModal.tsx | 226 ++++++++++++++++++ .../resale/GiftResalePriceComposerModal.tsx | 54 +++-- src/global/actions/api/stars.ts | 26 ++ src/global/actions/ui/stars.ts | 17 ++ src/global/selectors/payments.ts | 9 + src/global/types/actions.ts | 14 ++ src/global/types/tabState.ts | 7 + src/lib/gramjs/tl/apiTl.ts | 1 + src/lib/gramjs/tl/static/api.json | 1 + src/types/language.d.ts | 65 +++++ src/util/dates/dateFormat.ts | 20 ++ src/util/localization/format.tsx | 12 +- 33 files changed, 1076 insertions(+), 47 deletions(-) create mode 100644 src/components/middle/message/actions/StarGiftPurchaseOffer.module.scss create mode 100644 src/components/middle/message/actions/StarGiftPurchaseOffer.tsx create mode 100644 src/components/modals/gift/offer/GiftOfferAcceptModal.async.tsx create mode 100644 src/components/modals/gift/offer/GiftOfferAcceptModal.module.scss create mode 100644 src/components/modals/gift/offer/GiftOfferAcceptModal.tsx diff --git a/src/api/gramjs/apiBuilders/appConfig.ts b/src/api/gramjs/apiBuilders/appConfig.ts index 3a0947ff9..76c3e1fe9 100644 --- a/src/api/gramjs/apiBuilders/appConfig.ts +++ b/src/api/gramjs/apiBuilders/appConfig.ts @@ -85,6 +85,7 @@ export interface GramJsAppConfig extends LimitsConfig { ton_blockchain_explorer_url?: string; stars_paid_messages_available?: boolean; stars_usd_withdraw_rate_x1000?: number; + stars_usd_sell_rate_x1000?: number; stars_paid_message_commission_permille?: number; stars_paid_message_amount_max?: number; stargifts_pinned_to_top_limit?: number; @@ -203,6 +204,7 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp starsPaidMessageCommissionPermille: appConfig.stars_paid_message_commission_permille, starsPaidMessageAmountMax: appConfig.stars_paid_message_amount_max, starsUsdWithdrawRateX1000: appConfig.stars_usd_withdraw_rate_x1000, + starsUsdSellRateX1000: appConfig.stars_usd_sell_rate_x1000, bandwidthPremiumNotifyPeriod: appConfig.upload_premium_speedup_notify_period, bandwidthPremiumUploadSpeedup: appConfig.upload_premium_speedup_upload, bandwidthPremiumDownloadSpeedup: appConfig.upload_premium_speedup_download, diff --git a/src/api/gramjs/apiBuilders/gifts.ts b/src/api/gramjs/apiBuilders/gifts.ts index 8ae4994de..a5d8c4657 100644 --- a/src/api/gramjs/apiBuilders/gifts.ts +++ b/src/api/gramjs/apiBuilders/gifts.ts @@ -35,6 +35,7 @@ export function buildApiStarGift(starGift: GramJs.TypeStarGift): ApiStarGift { const { id, num, ownerId, ownerName, title, attributes, availabilityIssued, availabilityTotal, slug, ownerAddress, giftAddress, resellAmount, releasedBy, resaleTonOnly, requirePremium, valueCurrency, valueAmount, giftId, + valueUsdAmount, } = starGift; return { @@ -56,7 +57,9 @@ export function buildApiStarGift(starGift: GramJs.TypeStarGift): ApiStarGift { resaleTonOnly, valueCurrency, valueAmount: toJSNumber(valueAmount), + valueUsdAmount: toJSNumber(valueUsdAmount), regularGiftId: giftId.toString(), + offerMinStars: starGift.offerMinStars, }; } diff --git a/src/api/gramjs/apiBuilders/messageActions.ts b/src/api/gramjs/apiBuilders/messageActions.ts index caf6ab360..224da0362 100644 --- a/src/api/gramjs/apiBuilders/messageActions.ts +++ b/src/api/gramjs/apiBuilders/messageActions.ts @@ -426,7 +426,7 @@ export function buildApiMessageAction(action: GramJs.TypeMessageAction): ApiMess if (action instanceof GramJs.MessageActionStarGiftUnique) { const { upgrade, transferred, saved, refunded, gift, canExportAt, transferStars, fromId, peer, savedId, - resaleAmount, prepaidUpgrade, dropOriginalDetailsStars, + resaleAmount, prepaidUpgrade, dropOriginalDetailsStars, fromOffer, } = action; const starGift = buildApiStarGift(gift); @@ -440,6 +440,7 @@ export function buildApiMessageAction(action: GramJs.TypeMessageAction): ApiMess isSaved: saved, isRefunded: refunded, isPrepaidUpgrade: prepaidUpgrade, + isFromOffer: fromOffer, gift: starGift, canExportAt, transferStars: toJSNumber(transferStars), @@ -523,6 +524,38 @@ export function buildApiMessageAction(action: GramJs.TypeMessageAction): ApiMess items: list.map(buildTodoItem), }; } + if (action instanceof GramJs.MessageActionStarGiftPurchaseOffer) { + const { + accepted, declined, gift, price, expiresAt, + } = action; + + const starGift = buildApiStarGift(gift); + if (starGift.type !== 'starGiftUnique') return UNSUPPORTED_ACTION; + + return { + mediaType: 'action', + type: 'starGiftPurchaseOffer', + isAccepted: accepted, + isDeclined: declined, + gift: starGift, + price: buildApiCurrencyAmount(price), + expiresAt, + }; + } + if (action instanceof GramJs.MessageActionStarGiftPurchaseOfferDeclined) { + const { expired, gift, price } = action; + + const starGift = buildApiStarGift(gift); + if (starGift.type !== 'starGiftUnique') return UNSUPPORTED_ACTION; + + return { + mediaType: 'action', + type: 'starGiftPurchaseOfferDeclined', + isExpired: expired, + gift: starGift, + price: buildApiCurrencyAmount(price), + }; + } return UNSUPPORTED_ACTION; } diff --git a/src/api/gramjs/methods/stars.ts b/src/api/gramjs/methods/stars.ts index 1b504f76e..f15cce018 100644 --- a/src/api/gramjs/methods/stars.ts +++ b/src/api/gramjs/methods/stars.ts @@ -590,3 +590,18 @@ export async function fetchStarGiftCollections({ collections: result.collections.map(buildApiStarGiftCollection).filter(Boolean), }; } + +export function resolveStarGiftOffer({ + offerMsgId, + shouldDecline, +}: { + offerMsgId: number; + shouldDecline?: boolean; +}) { + return invokeRequest(new GramJs.payments.ResolveStarGiftOffer({ + offerMsgId, + decline: shouldDecline || undefined, + }), { + shouldReturnTrue: true, + }); +} diff --git a/src/api/types/messageActions.ts b/src/api/types/messageActions.ts index adf0d2ab9..47374e61d 100644 --- a/src/api/types/messageActions.ts +++ b/src/api/types/messageActions.ts @@ -265,6 +265,7 @@ export interface ApiMessageActionStarGiftUnique extends ActionMediaType { isSaved?: true; isRefunded?: true; isPrepaidUpgrade?: true; + isFromOffer?: true; gift: ApiStarGiftUnique; canExportAt?: number; transferStars?: number; @@ -329,6 +330,22 @@ export interface ApiMessageActionTodoAppendTasks extends ActionMediaType { items: ApiTodoItem[]; } +export interface ApiMessageActionStarGiftPurchaseOffer extends ActionMediaType { + type: 'starGiftPurchaseOffer'; + isAccepted?: true; + isDeclined?: true; + gift: ApiStarGiftUnique; + price: ApiTypeCurrencyAmount; + expiresAt: number; +} + +export interface ApiMessageActionStarGiftPurchaseOfferDeclined extends ActionMediaType { + type: 'starGiftPurchaseOfferDeclined'; + isExpired?: true; + gift: ApiStarGiftUnique; + price: ApiTypeCurrencyAmount; +} + export interface ApiMessageActionUnsupported extends ActionMediaType { type: 'unsupported'; } @@ -348,4 +365,5 @@ export type ApiMessageAction = ApiMessageActionUnsupported | ApiMessageActionCha | ApiMessageActionGiftTon | ApiMessageActionPrizeStars | ApiMessageActionStarGift | ApiMessageActionStarGiftUnique | ApiMessageActionPaidMessagesRefunded | ApiMessageActionPaidMessagesPrice | ApiMessageActionSuggestedPostApproval | ApiMessageActionSuggestedPostSuccess | ApiMessageActionSuggestedPostRefund | ApiMessageActionTodoCompletions - | ApiMessageActionTodoAppendTasks; + | ApiMessageActionTodoAppendTasks | ApiMessageActionStarGiftPurchaseOffer + | ApiMessageActionStarGiftPurchaseOfferDeclined; diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index c963533b6..0a62550e8 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -943,6 +943,12 @@ export interface KeyboardButtonOpenThread { text: string; } +export interface KeyboardButtonGiftOffer { + type: 'giftOffer'; + text: string; + buttonType: 'accept' | 'reject'; +} + export type ApiKeyboardButton = ( ApiKeyboardButtonSimple | ApiKeyboardButtonReceipt @@ -957,6 +963,7 @@ export type ApiKeyboardButton = ( | ApiKeyboardButtonCopy | KeyboardButtonSuggestedMessage | KeyboardButtonOpenThread + | KeyboardButtonGiftOffer ); export type ApiKeyboardButtons = ApiKeyboardButton[][]; diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index 0d4828e88..d16259d2e 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -240,6 +240,7 @@ export interface ApiAppConfig { starsPaidMessageCommissionPermille?: number; starsPaidMessageAmountMax?: number; starsUsdWithdrawRateX1000: number; + starsUsdSellRateX1000?: number; bandwidthPremiumNotifyPeriod?: number; bandwidthPremiumUploadSpeedup?: number; bandwidthPremiumDownloadSpeedup?: number; diff --git a/src/api/types/stars.ts b/src/api/types/stars.ts index 74ed6df20..100963664 100644 --- a/src/api/types/stars.ts +++ b/src/api/types/stars.ts @@ -59,6 +59,8 @@ export interface ApiStarGiftUnique { resaleTonOnly?: true; valueCurrency?: string; valueAmount?: number; + valueUsdAmount?: number; + offerMinStars?: number; } export type ApiStarGift = ApiStarGiftRegular | ApiStarGiftUnique; diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index d0eaabfed..e807b833e 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -1526,6 +1526,7 @@ "GiftInfoFrom" = "From"; "GiftInfoDate" = "Date"; "GiftInfoValue" = "Value"; +"GiftInfoValueAmount" = "~ {amount}"; "GiftInfoConvert_one" = "Convert to {amount} Star"; "GiftInfoConvert_other" = "Convert to {amount} Stars"; "GiftInfoConvertTitle" = "Convert Gift to Stars"; @@ -1912,6 +1913,8 @@ "ActionStarGiftTransferredSelf" = "You transferred a unique collectible"; "ActionStarGiftTransferredUnknown" = "Someone transferred you a gift"; "ActionStarGiftTransferredUnknownChannel" = "Someone transferred a gift to {channel}"; +"ActionStarGiftSoldFromOffer" = "You sold {gift} to {user} for {cost}"; +"ActionStarGiftBoughtFromOffer" = "{user} accepted your offer and sold you {gift} for {cost}"; "ActionStarGiftReceivedAnonymous" = "Someone sent you a gift for {cost}"; "ActionStarGiftSentChannel" = "{user} sent a gift to {channel} for {cost}"; "ActionStarGiftSentChannelYou" = "You sent a gift to {channel} for {cost}"; @@ -1942,6 +1945,24 @@ "ActionStarGiftAuctionWon" = "You won the auction with a bid of {cost}"; "ActionStarGiftAuctionFor" = "Gift for {peer}"; "ActionStarGiftAuctionBought" = "You bought this gift at auction for {cost}."; +"ActionStarGiftOfferOutgoing" = "You offered {peer} {cost} for {gift}."; +"ActionStarGiftOfferIncoming" = "{peer} offered you {cost} for {gift}."; +"ActionStarGiftOfferExpires" = "This offer expires in {time}"; +"ActionStarGiftOfferAccepted" = "This offer was accepted."; +"ActionStarGiftOfferRejected" = "This offer was rejected."; +"ActionStarGiftOfferHasExpired" = "This offer has expired"; +"ActionStarGiftOfferDeclinedOutgoing" = "You rejected {peer}'s offer to buy your {gift} for {cost}."; +"ActionStarGiftOfferDeclinedIncoming" = "{peer} rejected your offer to buy {gift} for {cost}."; +"GiftOfferReject" = "Reject"; +"GiftOfferAccept" = "Accept"; +"GiftOfferRejectTitle" = "Reject Offer"; +"GiftOfferRejectText" = "Are you sure you want to reject the offer from **{user}**?"; +"GiftOfferAcceptTitle" = "Sell Gift"; +"GiftOfferAcceptText" = "Do you want to sell **{gift}** to **{user}** for **{price}**?"; +"GiftOfferAcceptReceive" = "You will receive **{amount}** after fees."; +"GiftOfferAcceptButton" = "Sell for {amount}"; +"GiftOfferPriceLow" = "The price you are offered is **{percent}** lower than the average price for {gift}"; +"GiftOfferPriceHigh" = "The price you are offered is **{percent}** higher than the average price for {gift}"; "ActionSuggestedPhotoYou" = "You suggested this photo for {user}'s Telegram profile."; "ActionSuggestedPhoto" = "{user} suggests this photo for your Telegram profile."; "ActionSuggestedPhotoButton" = "View Photo"; diff --git a/src/bundles/stars.ts b/src/bundles/stars.ts index 61da1efb3..4591f4e2e 100644 --- a/src/bundles/stars.ts +++ b/src/bundles/stars.ts @@ -26,5 +26,6 @@ export { default as GiftWithdrawModal } from '../components/modals/gift/withdraw export { default as GiftTransferModal } from '../components/modals/gift/transfer/GiftTransferModal'; export { default as GiftTransferConfirmModal } from '../components/modals/gift/transfer/GiftTransferConfirmModal'; export { default as GiftDescriptionRemoveModal } from '../components/modals/gift/message/GiftDescriptionRemoveModal'; +export { default as GiftOfferAcceptModal } from '../components/modals/gift/offer/GiftOfferAcceptModal'; export { default as ChatRefundModal } from '../components/modals/stars/chatRefund/ChatRefundModal'; export { default as PriceConfirmModal } from '../components/modals/priceConfirm/PriceConfirmModal'; diff --git a/src/components/middle/message/ActionMessage.module.scss b/src/components/middle/message/ActionMessage.module.scss index 117b1cc25..90d1a8cca 100644 --- a/src/components/middle/message/ActionMessage.module.scss +++ b/src/components/middle/message/ActionMessage.module.scss @@ -82,6 +82,16 @@ max-width: 100%; } +.contentWrapper { + display: flex; + flex-direction: column; + align-items: center; + + :global(.InlineButtons) { + width: 100%; + } +} + .contextContainer { grid-area: 1 / 1; } @@ -303,3 +313,7 @@ font-size: 1.5rem; opacity: 0.5; } + +.narrowWrapper { + max-width: 18rem; +} diff --git a/src/components/middle/message/ActionMessage.tsx b/src/components/middle/message/ActionMessage.tsx index 10627316c..fb70ab57a 100644 --- a/src/components/middle/message/ActionMessage.tsx +++ b/src/components/middle/message/ActionMessage.tsx @@ -10,11 +10,18 @@ import type { ThreadId, } from '../../../types'; import type { Signal } from '../../../util/signals'; -import { type ApiMessage, type ApiPeer, MAIN_THREAD_ID } from '../../../api/types'; +import { + type ApiKeyboardButton, + type ApiMessage, + type ApiPeer, + type KeyboardButtonGiftOffer, + MAIN_THREAD_ID, +} from '../../../api/types'; import { MediaViewerOrigin } from '../../../types'; import { MESSAGE_APPEARANCE_DELAY } from '../../../config'; import { getMessageHtmlId } from '../../../global/helpers'; +import { getPeerTitle } from '../../../global/helpers/peers'; import { getMessageReplyInfo } from '../../../global/helpers/replies'; import { selectActionMessageBg, @@ -31,6 +38,7 @@ import { IS_TAURI } from '../../../util/browser/globalEnvironment'; import { IS_ANDROID, IS_FLUID_BACKGROUND_SUPPORTED } from '../../../util/browser/windowEnvironment'; import buildClassName from '../../../util/buildClassName'; import { isLocalMessageId } from '../../../util/keys/messageKey'; +import { getServerTime } from '../../../util/serverTime'; import { isElementInViewport } from '../../../util/visibility/isElementInViewport'; import { preventMessageInputBlur } from '../helpers/preventMessageInputBlur'; @@ -39,23 +47,27 @@ import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; import useEnsureMessage from '../../../hooks/useEnsureMessage'; import useFlag from '../../../hooks/useFlag'; import { type ObserveFn, useOnIntersect } from '../../../hooks/useIntersectionObserver'; +import useLang from '../../../hooks/useLang'; import useLastCallback from '../../../hooks/useLastCallback'; import useShowTransition from '../../../hooks/useShowTransition'; import { type OnIntersectPinnedMessage } from '../hooks/usePinnedMessage'; import useFluidBackgroundFilter from './hooks/useFluidBackgroundFilter'; import useFocusMessageListElement from './hooks/useFocusMessageListElement'; +import ConfirmDialog from '../../ui/ConfirmDialog'; import ActionMessageText from './ActionMessageText'; import ChannelPhoto from './actions/ChannelPhoto'; import Gift from './actions/Gift'; import GiveawayPrize from './actions/GiveawayPrize'; import StarGift from './actions/StarGift'; +import StarGiftPurchaseOffer from './actions/StarGiftPurchaseOffer'; import StarGiftUnique from './actions/StarGiftUnique'; import SuggestedPhoto from './actions/SuggestedPhoto'; import SuggestedPostApproval from './actions/SuggestedPostApproval'; import SuggestedPostBalanceTooLow from './actions/SuggestedPostBalanceTooLow'; import SuggestedPostRejected from './actions/SuggestedPostRejected'; import ContextMenuContainer from './ContextMenuContainer'; +import InlineButtons from './InlineButtons'; import Reactions from './reactions/Reactions'; import SimilarChannels from './SimilarChannels'; @@ -101,7 +113,7 @@ const SINGLE_LINE_ACTIONS = new Set([ 'unsupported', ]); const HIDDEN_TEXT_ACTIONS = new Set(['giftCode', 'prizeStars', - 'suggestProfilePhoto', 'suggestedPostApproval']); + 'suggestProfilePhoto', 'suggestedPostApproval', 'starGiftPurchaseOffer']); const ActionMessage = ({ message, @@ -143,6 +155,8 @@ const ActionMessage = ({ animateUnreadReaction, markMentionsRead, focusMessage, + openGiftOfferAcceptModal, + declineStarGiftOffer, } = getActions(); const ref = useRef(); @@ -155,16 +169,67 @@ const ActionMessage = ({ const isSingleLine = SINGLE_LINE_ACTIONS.has(action.type); const isFluidMultiline = IS_FLUID_BACKGROUND_SUPPORTED && !isSingleLine; const isClickableText = action.type === 'suggestedPostSuccess'; + const isNarrowMessage = action.type === 'starGiftPurchaseOfferDeclined'; const messageReplyInfo = getMessageReplyInfo(message); const { replyToMsgId, replyToPeerId } = messageReplyInfo || {}; const withServiceReactions = Boolean(message.areReactionsPossible && message?.reactions?.results?.length); + const hasGiftOfferExpired = action.type === 'starGiftPurchaseOffer' && action.expiresAt <= getServerTime(); + const shouldRenderGiftOfferButtons = action.type === 'starGiftPurchaseOffer' + && !message.isOutgoing && !action.isAccepted && !action.isDeclined && !hasGiftOfferExpired; + + const shouldRenderInlineButtons = shouldRenderGiftOfferButtons; + const shouldSkipRender = isInsideTopic && action.type === 'topicCreate'; + const lang = useLang(); const { isTouchScreen } = useAppLayout(); + const giftOfferInlineButtons: KeyboardButtonGiftOffer[][] = useMemo(() => [ + [ + { + type: 'giftOffer', + buttonType: 'reject', + text: lang('GiftOfferReject'), + }, + { + type: 'giftOffer', + buttonType: 'accept', + text: lang('GiftOfferAccept'), + }, + ], + ], [lang]); + + const [isRejectOfferDialogOpen, openRejectOfferDialog, closeRejectOfferDialog] = useFlag(false); + + const handleInlineButtonClick = useLastCallback((button: ApiKeyboardButton) => { + if (button.type === 'giftOffer') { + if (button.buttonType === 'accept') { + if (action.type === 'starGiftPurchaseOffer') { + openGiftOfferAcceptModal({ + peerId: chatId, + messageId: id, + gift: action.gift, + price: action.price, + }); + } + } else if (button.buttonType === 'reject') { + openRejectOfferDialog(); + } + } + }); + + const handleRejectOfferConfirm = useLastCallback(() => { + closeRejectOfferDialog(); + declineStarGiftOffer({ messageId: id }); + }); + + const handleRejectOfferClose = useLastCallback(() => { + closeRejectOfferDialog(); + }); + useOnIntersect(ref, !shouldSkipRender ? observeIntersectionForBottom : undefined); useEnsureMessage( @@ -247,6 +312,13 @@ const ActionMessage = ({ const fluidBackgroundStyle = useFluidBackgroundFilter(isFluidMultiline ? actionMessageBg : undefined); + const handleKeyDown = useLastCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleClick(); + } + }); + const handleClick = useLastCallback(() => { switch (action.type) { case 'paymentSent': @@ -309,6 +381,18 @@ const ActionMessage = ({ break; } + case 'starGiftPurchaseOffer': { + if (shouldRenderGiftOfferButtons) { + openGiftOfferAcceptModal({ + peerId: chatId, + messageId: id, + gift: action.gift, + price: action.price, + }); + } + break; + } + case 'channelJoined': { toggleChannelRecommendations({ chatId }); break; @@ -408,6 +492,18 @@ const ActionMessage = ({ /> ); + case 'starGiftPurchaseOffer': + return ( + + ); + case 'channelJoined': return ( {isFluidMultiline && ( -
+
)} -
- +
+
)} - {fullContent} + {(fullContent || shouldRenderInlineButtons) && ( +
+ {fullContent} + {shouldRenderInlineButtons && ( + + )} +
+ )} {contextMenuAnchor && ( )} +
); }; diff --git a/src/components/middle/message/ActionMessageText.tsx b/src/components/middle/message/ActionMessageText.tsx index e0359d973..f24d801d8 100644 --- a/src/components/middle/message/ActionMessageText.tsx +++ b/src/components/middle/message/ActionMessageText.tsx @@ -30,7 +30,7 @@ import { ensureProtocol } from '../../../util/browser/url'; import { formatDateTimeToString, formatScheduledDateTime, formatShortDuration } from '../../../util/dates/dateFormat'; import { formatCurrency } from '../../../util/formatCurrency'; import { convertTonFromNanos } from '../../../util/formatCurrency'; -import { formatStarsAsText, formatTonAsText } from '../../../util/localization/format'; +import { formatCurrencyAmountAsText, formatStarsAsText, formatTonAsText } from '../../../util/localization/format'; import { conjuctionWithNodes } from '../../../util/localization/utils'; import { getServerTime } from '../../../util/serverTime'; import renderText from '../../common/helpers/renderText'; @@ -616,6 +616,7 @@ const ActionMessageText = ({ case 'starGiftUnique': { const { isTransferred, isUpgrade, savedId, peerId, fromId, resaleAmount, gift, transferStars, isPrepaidUpgrade, + isFromOffer, } = action; const isToChannel = Boolean(peerId && savedId); @@ -629,6 +630,28 @@ const ActionMessageText = ({ || (isToChannel ? channelFallbackText : userFallbackText); const toLink = renderPeerLink(toPeer?.id, toTitle, asPreview); + if (isFromOffer && resaleAmount) { + const giftName = lang('GiftUnique', { title: gift.title, number: gift.number }); + const amountText = formatCurrencyAmountAsText(lang, resaleAmount); + + const formattedAmountText = asPreview ? amountText : renderStrong(amountText); + const formattedGiftName = asPreview ? giftName : renderStrong(giftName); + + if (isOutgoing) { + return lang( + 'ActionStarGiftSoldFromOffer', + { user: chatLink, gift: formattedGiftName, cost: formattedAmountText }, + { withNodes: true }, + ); + } + + return lang( + 'ActionStarGiftBoughtFromOffer', + { user: senderLink, gift: formattedGiftName, cost: formattedAmountText }, + { withNodes: true }, + ); + } + if (isPrepaidUpgrade) { if (isOutgoing) { return lang('ActionStarGiftPrepaidUpgradedYou'); @@ -637,9 +660,7 @@ const ActionMessageText = ({ } if (resaleAmount && !transferStars) { - const amountText = resaleAmount.currency === TON_CURRENCY_CODE - ? formatTonAsText(lang, convertTonFromNanos(resaleAmount.amount)) - : formatStarsAsText(lang, resaleAmount.amount); + const amountText = formatCurrencyAmountAsText(lang, resaleAmount); return lang( isOutgoing @@ -1023,6 +1044,55 @@ const ActionMessageText = ({ case 'phoneCall': // Rendered as a regular message, but considered an action for the summary return lang(getCallMessageKey(action, isOutgoing)); + + case 'starGiftPurchaseOffer': { + const { gift, price } = action; + + const peer = isOutgoing ? chat : sender; + const peerTitle = (peer && getPeerTitle(lang, peer)) || userFallbackText; + const peerLink = renderPeerLink(peer?.id, peerTitle, asPreview); + + const giftName = lang('GiftUnique', { title: gift.title, number: gift.number }); + const priceText = formatCurrencyAmountAsText(lang, price); + + const formattedGiftName = asPreview ? giftName : renderStrong(giftName); + const formattedPriceText = asPreview ? priceText : renderStrong(priceText); + + return lang( + isOutgoing ? 'ActionStarGiftOfferOutgoing' : 'ActionStarGiftOfferIncoming', + { + peer: peerLink, + cost: formattedPriceText, + gift: formattedGiftName, + }, + { withNodes: true }, + ); + } + + case 'starGiftPurchaseOfferDeclined': { + const { gift, price } = action; + + const peer = isOutgoing ? chat : sender; + const peerTitle = (peer && getPeerTitle(lang, peer)) || userFallbackText; + const peerLink = renderPeerLink(peer?.id, peerTitle, asPreview); + + const giftName = lang('GiftUnique', { title: gift.title, number: gift.number }); + const priceText = formatCurrencyAmountAsText(lang, price); + + const formattedGiftName = asPreview ? giftName : renderStrong(giftName); + const formattedPriceText = asPreview ? priceText : renderStrong(priceText); + + return lang( + isOutgoing ? 'ActionStarGiftOfferDeclinedOutgoing' : 'ActionStarGiftOfferDeclinedIncoming', + { + peer: peerLink, + gift: formattedGiftName, + cost: formattedPriceText, + }, + { withNodes: true }, + ); + } + default: return lang(UNSUPPORTED_LANG_KEY); } diff --git a/src/components/middle/message/InlineButtons.scss b/src/components/middle/message/InlineButtons.scss index 543a69307..198b1580a 100644 --- a/src/components/middle/message/InlineButtons.scss +++ b/src/components/middle/message/InlineButtons.scss @@ -23,6 +23,7 @@ transition: background-color 150ms, color 150ms, backdrop-filter 150ms, filter 150ms; + &:active, &:hover, &:focus { background: var(--action-message-bg) !important; @@ -61,6 +62,7 @@ .left-icon { margin-right: 0.25rem; + font-size: 1rem !important; } .inline-button-text { diff --git a/src/components/middle/message/InlineButtons.tsx b/src/components/middle/message/InlineButtons.tsx index 9a739a82a..51a35ccb6 100644 --- a/src/components/middle/message/InlineButtons.tsx +++ b/src/components/middle/message/InlineButtons.tsx @@ -58,6 +58,14 @@ const InlineButtons = ({ inlineButtons, onClick }: OwnProps) => { return ; } break; + case 'giftOffer': + if (button.buttonType === 'accept') { + return ; + } + if (button.buttonType === 'reject') { + return ; + } + break; } return; diff --git a/src/components/middle/message/actions/StarGiftPurchaseOffer.module.scss b/src/components/middle/message/actions/StarGiftPurchaseOffer.module.scss new file mode 100644 index 000000000..29d589a5f --- /dev/null +++ b/src/components/middle/message/actions/StarGiftPurchaseOffer.module.scss @@ -0,0 +1,63 @@ +.root { + gap: 0.25rem; + + &.hasButtons { + border-bottom-right-radius: var(--border-radius-messages-small); + border-bottom-left-radius: var(--border-radius-messages-small); + } + + &.clickable { + cursor: var(--custom-cursor, pointer); + transition: background-color 150ms, backdrop-filter 150ms, filter 150ms; + + &:hover, + &:focus { + background: var(--action-message-bg) !important; + backdrop-filter: brightness(115%); + + @supports not (backdrop-filter: brightness(115%)) { + filter: brightness(115%); + } + } + } +} + +.giftContainer { + position: relative; + + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + + width: 4.75rem; + height: 4.75rem; + border-radius: 1rem; +} + +.uniqueBackgroundWrapper, +.uniqueBackground { + position: absolute; + inset: 0; +} + +.stickerWrapper { + position: relative; +} + +.info { + font-size: 0.9375rem; + text-align: center; +} + +.title { + margin-bottom: 0; + font-size: inherit; + font-weight: var(--font-weight-normal); +} + +.subtitle { + margin-block: 0.25rem; + font-size: 0.8125rem; + text-wrap: balance; +} diff --git a/src/components/middle/message/actions/StarGiftPurchaseOffer.tsx b/src/components/middle/message/actions/StarGiftPurchaseOffer.tsx new file mode 100644 index 000000000..544a7d836 --- /dev/null +++ b/src/components/middle/message/actions/StarGiftPurchaseOffer.tsx @@ -0,0 +1,180 @@ +import { memo, useMemo, useRef } from '@teact'; +import { withGlobal } from '../../../../global'; + +import type { ApiMessage, ApiPeer } from '../../../../api/types'; +import type { ApiMessageActionStarGiftPurchaseOffer } from '../../../../api/types/messageActions'; + +import { getPeerTitle } from '../../../../global/helpers/peers'; +import { + selectCanPlayAnimatedEmojis, + selectPeer, + selectSender, + selectUser, +} from '../../../../global/selectors'; +import { IS_TOUCH_ENV } from '../../../../util/browser/windowEnvironment'; +import buildClassName from '../../../../util/buildClassName'; +import { formatShortHoursMinutes } from '../../../../util/dates/dateFormat'; +import { formatCurrencyAmountAsText } from '../../../../util/localization/format'; +import { getServerTime } from '../../../../util/serverTime'; +import { getGiftAttributes, getStickerFromGift } from '../../../common/helpers/gifts'; +import { renderPeerLink } from '../helpers/messageActions'; + +import useIntervalForceUpdate from '../../../../hooks/schedulers/useIntervalForceUpdate'; +import useFlag from '../../../../hooks/useFlag'; +import { type ObserveFn } from '../../../../hooks/useIntersectionObserver'; +import useLang from '../../../../hooks/useLang'; +import useOldLang from '../../../../hooks/useOldLang'; + +import RadialPatternBackground from '../../../common/profile/RadialPatternBackground'; +import StickerView from '../../../common/StickerView'; + +import actionStyles from '../ActionMessage.module.scss'; +import styles from './StarGiftPurchaseOffer.module.scss'; + +type OwnProps = { + message: ApiMessage; + action: ApiMessageActionStarGiftPurchaseOffer; + hasButtons?: boolean; + observeIntersectionForLoading?: ObserveFn; + observeIntersectionForPlaying?: ObserveFn; + onClick?: NoneToVoidFunction; +}; + +type StateProps = { + canPlayAnimatedEmojis: boolean; + sender?: ApiPeer; + recipient?: ApiPeer; +}; + +const STICKER_SIZE = 48; +const ONE_MINUTE = 60 * 1000; + +const StarGiftPurchaseOffer = ({ + action, + message, + hasButtons, + canPlayAnimatedEmojis, + sender, + recipient, + observeIntersectionForLoading, + observeIntersectionForPlaying, + onClick, +}: OwnProps & StateProps) => { + const stickerRef = useRef(); + const lang = useLang(); + const oldLang = useOldLang(); + + const [isHover, markHover, unmarkHover] = useFlag(); + + const { isOutgoing } = message; + + const sticker = getStickerFromGift(action.gift); + const attributes = getGiftAttributes(action.gift); + const pattern = attributes?.pattern; + const backdrop = attributes?.backdrop; + + const isActive = !action.isAccepted && !action.isDeclined; + useIntervalForceUpdate(isActive ? ONE_MINUTE : undefined); + + const serverTime = getServerTime(); + const timeLeft = Math.max(0, action.expiresAt - serverTime); + const formattedTime = formatShortHoursMinutes(oldLang, timeLeft); + const hasExpired = timeLeft <= 0; + + const subtitle = useMemo(() => { + if (action.isAccepted) return lang('ActionStarGiftOfferAccepted'); + if (action.isDeclined) return lang('ActionStarGiftOfferRejected'); + if (hasExpired) return lang('ActionStarGiftOfferHasExpired'); + return lang('ActionStarGiftOfferExpires', { time: formattedTime }); + }, [action.isAccepted, action.isDeclined, formattedTime, lang, hasExpired]); + + if (!sticker || !pattern || !backdrop) { + return undefined; + } + + const backgroundColors = [backdrop.centerColor, backdrop.edgeColor]; + + const peer = isOutgoing ? recipient : sender; + const fallbackPeerTitle = lang('ActionFallbackSomeone'); + const peerTitle = peer && getPeerTitle(lang, peer); + + const giftName = lang('GiftUnique', { title: action.gift.title, number: action.gift.number }); + const priceText = formatCurrencyAmountAsText(lang, action.price); + + return ( +
+
+
+ +
+
+ {sticker && ( + + )} +
+
+
+

+ {lang( + isOutgoing ? 'ActionStarGiftOfferOutgoing' : 'ActionStarGiftOfferIncoming', + { + peer: renderPeerLink(peer?.id, peerTitle || fallbackPeerTitle), + cost: priceText, + gift: giftName, + }, + { withNodes: true, withMarkdown: true }, + )} +

+

+ {subtitle} +

+
+
+ ); +}; + +export default memo(withGlobal( + (global, { message }): Complete => { + const currentUser = selectUser(global, global.currentUserId!); + const canPlayAnimatedEmojis = selectCanPlayAnimatedEmojis(global); + const messageSender = selectSender(global, message); + const messageRecipient = message.isOutgoing ? selectPeer(global, message.chatId) : currentUser; + + return { + canPlayAnimatedEmojis, + sender: messageSender, + recipient: messageRecipient, + }; + }, +)(StarGiftPurchaseOffer)); diff --git a/src/components/modals/ModalContainer.tsx b/src/components/modals/ModalContainer.tsx index 4f49eddfd..470b7704c 100644 --- a/src/components/modals/ModalContainer.tsx +++ b/src/components/modals/ModalContainer.tsx @@ -29,6 +29,7 @@ import PremiumGiftModal from './gift/GiftModal.async'; import GiftInfoModal from './gift/info/GiftInfoModal.async'; import GiftLockedModal from './gift/locked/GiftLockedModal.async'; import GiftDescriptionRemoveModal from './gift/message/GiftDescriptionRemoveModal.async'; +import GiftOfferAcceptModal from './gift/offer/GiftOfferAcceptModal.async'; import GiftRecipientPicker from './gift/recipient/GiftRecipientPicker.async'; import GiftResalePriceComposerModal from './gift/resale/GiftResalePriceComposerModal.async'; import StarGiftPriceDecreaseInfoModal from './gift/StarGiftPriceDecreaseInfoModal.async'; @@ -115,6 +116,7 @@ type ModalKey = keyof Pick - ~ - {' '} - {formatCurrencyAsString( - gift.valueAmount, - gift.valueCurrency, - lang.code, - )} + {lang('GiftInfoValueAmount', { amount: formattedValue })} {lang('GiftInfoValueLinkMore')} diff --git a/src/components/modals/gift/offer/GiftOfferAcceptModal.async.tsx b/src/components/modals/gift/offer/GiftOfferAcceptModal.async.tsx new file mode 100644 index 000000000..a0ef9f1c0 --- /dev/null +++ b/src/components/modals/gift/offer/GiftOfferAcceptModal.async.tsx @@ -0,0 +1,18 @@ +import type { OwnProps } from './GiftOfferAcceptModal'; + +import { Bundles } from '../../../../util/moduleLoader'; + +import useModuleLoader from '../../../../hooks/useModuleLoader'; + +const GiftOfferAcceptModalAsync = (props: OwnProps) => { + const { modal } = props; + const GiftOfferAcceptModal = useModuleLoader( + Bundles.Stars, + 'GiftOfferAcceptModal', + !modal, + ); + + return GiftOfferAcceptModal ? : undefined; +}; + +export default GiftOfferAcceptModalAsync; diff --git a/src/components/modals/gift/offer/GiftOfferAcceptModal.module.scss b/src/components/modals/gift/offer/GiftOfferAcceptModal.module.scss new file mode 100644 index 000000000..484fb8cff --- /dev/null +++ b/src/components/modals/gift/offer/GiftOfferAcceptModal.module.scss @@ -0,0 +1,38 @@ +.description { + margin-bottom: 0.5rem; + text-align: center; + text-wrap: balance; +} + +.receiveText { + margin-bottom: 1rem; + text-align: center; +} + +.table { + margin-bottom: 1rem; +} + +.attributeValue { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.warning { + margin-bottom: 0.5rem; + color: var(--color-error); + text-align: center; +} + +.hint { + margin-bottom: 0.5rem; + color: var(--color-text-secondary); + text-align: center; +} + +.success { + margin-bottom: 0.5rem; + color: var(--color-success); + text-align: center; +} diff --git a/src/components/modals/gift/offer/GiftOfferAcceptModal.tsx b/src/components/modals/gift/offer/GiftOfferAcceptModal.tsx new file mode 100644 index 000000000..6a36215f3 --- /dev/null +++ b/src/components/modals/gift/offer/GiftOfferAcceptModal.tsx @@ -0,0 +1,226 @@ +import { memo, useMemo } from '../../../../lib/teact/teact'; +import { getActions, withGlobal } from '../../../../global'; + +import type { ApiPeer } from '../../../../api/types'; +import type { TabState } from '../../../../global/types'; + +import { TON_CURRENCY_CODE } from '../../../../config'; +import { getPeerTitle } from '../../../../global/helpers/peers'; +import { + selectPeer, + selectStarsGiftResaleCommission, + selectTonGiftResaleCommission, +} from '../../../../global/selectors'; +import { convertTonToUsd, formatCurrencyAsString } from '../../../../util/formatCurrency'; +import { + formatCurrencyAmountAsText, formatStarsAsIcon, formatStarsAsText, formatTonAsIcon, formatTonAsText, +} from '../../../../util/localization/format'; +import { round } from '../../../../util/math'; +import { formatPercent } from '../../../../util/textFormat'; +import { getGiftAttributes } from '../../../common/helpers/gifts'; + +import useCurrentOrPrev from '../../../../hooks/useCurrentOrPrev'; +import useLang from '../../../../hooks/useLang'; +import useLastCallback from '../../../../hooks/useLastCallback'; + +import BadgeButton from '../../../common/BadgeButton'; +import GiftTransferPreview from '../../../common/gift/GiftTransferPreview'; +import ConfirmDialog from '../../../ui/ConfirmDialog'; +import TableInfo, { type TableData } from '../../common/TableInfo'; + +import styles from './GiftOfferAcceptModal.module.scss'; + +const PRICE_WARNING_THRESHOLD_PERCENT = 10; + +export type OwnProps = { + modal: TabState['giftOfferAcceptModal']; +}; + +type StateProps = { + recipientPeer?: ApiPeer; + starsCommission?: number; + tonCommission?: number; + starsUsdRate?: number; + tonUsdRate?: number; +}; + +const GiftOfferAcceptModal = ({ + modal, recipientPeer, starsCommission, tonCommission, starsUsdRate, tonUsdRate, +}: OwnProps & StateProps) => { + const { + closeGiftOfferAcceptModal, acceptStarGiftOffer, + } = getActions(); + const lang = useLang(); + + const isOpen = Boolean(modal); + const renderingModal = useCurrentOrPrev(modal); + const renderingBuyerPeer = useCurrentOrPrev(recipientPeer); + + const handleConfirm = useLastCallback(() => { + if (!renderingModal) return; + + acceptStarGiftOffer({ messageId: renderingModal.messageId }); + closeGiftOfferAcceptModal(); + }); + + const giftAttributes = useMemo(() => { + return renderingModal?.gift && getGiftAttributes(renderingModal.gift); + }, [renderingModal?.gift]); + + const isPriceInTon = renderingModal?.price.currency === TON_CURRENCY_CODE; + const commission = isPriceInTon ? tonCommission : starsCommission; + const priceAmount = renderingModal?.price.amount || 0; + const receiveAmount = commission + ? (round(priceAmount * commission, isPriceInTon ? 2 : 0)) + : priceAmount; + + const tableData: TableData = useMemo(() => { + if (!giftAttributes) return []; + + const { model, backdrop, pattern } = giftAttributes; + const data: TableData = []; + + if (model) { + data.push([ + lang('GiftAttributeModel'), + + {model.name} + {formatPercent(model.rarityPercent)} + , + ]); + } + + if (backdrop) { + data.push([ + lang('GiftAttributeBackdrop'), + + {backdrop.name} + {formatPercent(backdrop.rarityPercent)} + , + ]); + } + + if (pattern) { + data.push([ + lang('GiftAttributeSymbol'), + + {pattern.name} + {formatPercent(pattern.rarityPercent)} + , + ]); + } + + const gift = renderingModal?.gift; + if (gift?.valueAmount && gift.valueCurrency) { + const formattedValue = formatCurrencyAsString(gift.valueAmount, gift.valueCurrency, lang.code); + data.push([ + lang('GiftInfoValue'), + lang('GiftInfoValueAmount', { amount: formattedValue }), + ]); + } + + return data; + }, [giftAttributes, lang, renderingModal?.gift]); + + const priceWarning = useMemo(() => { + if (!renderingModal) return undefined; + + const { gift } = renderingModal; + const { valueUsdAmount } = gift; + if (!valueUsdAmount || valueUsdAmount <= 0 || receiveAmount <= 0) return undefined; + + const avgValueUsd = valueUsdAmount / 100; + + let receiveValueUsd: number; + if (isPriceInTon) { + if (!tonUsdRate) return undefined; + receiveValueUsd = convertTonToUsd(receiveAmount, tonUsdRate, true) / 100; + } else { + if (!starsUsdRate) return undefined; + receiveValueUsd = receiveAmount * starsUsdRate / 100; + } + + const isLower = avgValueUsd >= receiveValueUsd; + const percent = isLower + ? (1 - receiveValueUsd / avgValueUsd) * 100 + : (receiveValueUsd / avgValueUsd - 1) * 100; + + if (percent <= PRICE_WARNING_THRESHOLD_PERCENT) return undefined; + + return { percent, isLow: isLower }; + }, [renderingModal, receiveAmount, isPriceInTon, tonUsdRate, starsUsdRate]); + + if (!renderingModal || !renderingBuyerPeer) return undefined; + + const { gift, price } = renderingModal; + const giftName = lang('GiftUnique', { title: gift.title, number: gift.number }); + const buyerName = getPeerTitle(lang, renderingBuyerPeer); + + const formattedPrice = formatCurrencyAmountAsText(lang, price); + const formattedReceiveAmountAsText = isPriceInTon + ? formatTonAsText(lang, receiveAmount, true) + : formatStarsAsText(lang, receiveAmount); + const formattedReceiveAmountAsIcon = isPriceInTon + ? formatTonAsIcon(lang, receiveAmount, { shouldConvertFromNanos: true }) + : formatStarsAsIcon(lang, receiveAmount, { asFont: true }); + + return ( + + +

+ {lang('GiftOfferAcceptText', { + gift: giftName, + user: buyerName, + price: formattedPrice, + }, { withNodes: true, withMarkdown: true })} +

+

+ {lang('GiftOfferAcceptReceive', { + amount: formattedReceiveAmountAsText, + }, { withNodes: true, withMarkdown: true })} +

+ {Boolean(tableData.length) && ( + + )} + {priceWarning && ( +

+ {lang(priceWarning.isLow ? 'GiftOfferPriceLow' : 'GiftOfferPriceHigh', { + percent: formatPercent(priceWarning.percent, 0), + gift: gift.title, + }, { withNodes: true, withMarkdown: true })} +

+ )} +
+ ); +}; + +export default memo( + withGlobal((global, { modal }): Complete => { + const recipientPeer = modal?.peerId ? selectPeer(global, modal.peerId) : undefined; + const starsCommission = selectStarsGiftResaleCommission(global); + const tonCommission = selectTonGiftResaleCommission(global); + + const starsUsdSellRateX1000 = global.appConfig?.starsUsdSellRateX1000; + const starsUsdRate = starsUsdSellRateX1000 ? starsUsdSellRateX1000 / 1000 : undefined; + const tonUsdRate = global.appConfig?.tonUsdRate; + + return { + recipientPeer, + starsCommission, + tonCommission, + starsUsdRate, + tonUsdRate, + }; + })(GiftOfferAcceptModal), +); diff --git a/src/components/modals/gift/resale/GiftResalePriceComposerModal.tsx b/src/components/modals/gift/resale/GiftResalePriceComposerModal.tsx index b38d9e897..23b77ea72 100644 --- a/src/components/modals/gift/resale/GiftResalePriceComposerModal.tsx +++ b/src/components/modals/gift/resale/GiftResalePriceComposerModal.tsx @@ -5,6 +5,10 @@ import { getActions, withGlobal } from '../../../../global'; import type { TabState } from '../../../../global/types'; +import { + selectStarsGiftResaleCommission, + selectTonGiftResaleCommission, +} from '../../../../global/selectors'; import { convertTonFromNanos, convertTonToNanos } from '../../../../util/formatCurrency'; import { convertTonToUsd, formatCurrencyAsString } from '../../../../util/formatCurrency'; import { formatStarsAsIcon, formatStarsAsText, formatTonAsIcon, @@ -26,20 +30,20 @@ export type OwnProps = { }; export type StateProps = { - starsStargiftResaleCommissionPermille?: number; - starsStargiftResaleAmountMin: number; - starsStargiftResaleAmountMax?: number; + starsCommission?: number; + starsResaleAmountMin: number; + starsResaleAmountMax?: number; starsUsdWithdrawRate?: number; - tonStargiftResaleCommissionPermille?: number; - tonStargiftResaleAmountMin: number; - tonStargiftResaleAmountMax?: number; + tonCommission?: number; + tonResaleAmountMin: number; + tonResaleAmountMax?: number; tonUsdRate?: number; }; const GiftResalePriceComposerModal = ({ - modal, starsStargiftResaleCommissionPermille, - starsStargiftResaleAmountMin, starsStargiftResaleAmountMax, starsUsdWithdrawRate, - tonStargiftResaleCommissionPermille, tonStargiftResaleAmountMin, tonStargiftResaleAmountMax, tonUsdRate, + modal, starsCommission, + starsResaleAmountMin, starsResaleAmountMax, starsUsdWithdrawRate, + tonCommission, tonResaleAmountMin, tonResaleAmountMax, tonUsdRate, }: OwnProps & StateProps) => { const { closeGiftResalePriceComposerModal, @@ -62,7 +66,7 @@ const GiftResalePriceComposerModal = ({ const handleChangePrice = useLastCallback((e) => { const value = e.target.value; const number = parseFloat(value); - const maxAmount = isPriceInTon ? tonStargiftResaleAmountMax : starsStargiftResaleAmountMax; + const maxAmount = isPriceInTon ? tonResaleAmountMax : starsResaleAmountMax; const result = value === '' || Number.isNaN(number) ? undefined : maxAmount ? Math.min(number, maxAmount) : number; setPrice(result); @@ -95,8 +99,8 @@ const GiftResalePriceComposerModal = ({ }, }); }); - const commission = isPriceInTon ? tonStargiftResaleCommissionPermille : starsStargiftResaleCommissionPermille; - const minAmount = isPriceInTon ? tonStargiftResaleAmountMin : starsStargiftResaleAmountMin; + const commission = isPriceInTon ? tonCommission : starsCommission; + const minAmount = isPriceInTon ? tonResaleAmountMin : starsResaleAmountMin; const isPriceCorrect = hasPrice && price >= minAmount; return ( @@ -176,30 +180,28 @@ const GiftResalePriceComposerModal = ({ export default memo(withGlobal( (global): Complete => { - const configPermille = global.appConfig.starsStargiftResaleCommissionPermille; - const starsStargiftResaleCommissionPermille = configPermille ? (configPermille / 1000) : undefined; - const starsStargiftResaleAmountMin = global.appConfig.starsStargiftResaleAmountMin || 0; - const starsStargiftResaleAmountMax = global.appConfig.starsStargiftResaleAmountMax; + const starsCommission = selectStarsGiftResaleCommission(global); + const starsResaleAmountMin = global.appConfig.starsStargiftResaleAmountMin || 0; + const starsResaleAmountMax = global.appConfig.starsStargiftResaleAmountMax; const starsUsdWithdrawRateX1000 = global.appConfig.starsUsdWithdrawRateX1000; const starsUsdWithdrawRate = starsUsdWithdrawRateX1000 ? starsUsdWithdrawRateX1000 / 1000 : 1; - const tonConfigPermille = global.appConfig.tonStargiftResaleCommissionPermille; - const tonStargiftResaleCommissionPermille = tonConfigPermille ? (tonConfigPermille / 1000) : 0; - const tonStargiftResaleAmountMin = convertTonFromNanos(global.appConfig.tonStargiftResaleAmountMin || 0); + const tonCommission = selectTonGiftResaleCommission(global); + const tonResaleAmountMin = convertTonFromNanos(global.appConfig.tonStargiftResaleAmountMin || 0); const maxTonFromConfig = global.appConfig.tonStargiftResaleAmountMax; - const tonStargiftResaleAmountMax = maxTonFromConfig && convertTonFromNanos(maxTonFromConfig); + const tonResaleAmountMax = maxTonFromConfig ? convertTonFromNanos(maxTonFromConfig) : undefined; const tonUsdRate = global.appConfig.tonUsdRate; return { - starsStargiftResaleCommissionPermille, - starsStargiftResaleAmountMin, - starsStargiftResaleAmountMax, + starsCommission, + starsResaleAmountMin, + starsResaleAmountMax, starsUsdWithdrawRate, - tonStargiftResaleCommissionPermille, - tonStargiftResaleAmountMin, - tonStargiftResaleAmountMax, + tonCommission, + tonResaleAmountMin, + tonResaleAmountMax, tonUsdRate, }; }, diff --git a/src/global/actions/api/stars.ts b/src/global/actions/api/stars.ts index 9b36eeddf..66946d473 100644 --- a/src/global/actions/api/stars.ts +++ b/src/global/actions/api/stars.ts @@ -709,3 +709,29 @@ addActionHandler('openGiftAuctionAcquiredModal', async (global, actions, payload setGlobal(global); }); + +addActionHandler('acceptStarGiftOffer', async (global, actions, payload): Promise => { + const { messageId } = payload; + + const result = await callApi('resolveStarGiftOffer', { + offerMsgId: messageId, + }); + + if (!result) { + return; + } + + actions.loadStarStatus(); + if (global.currentUserId) { + actions.reloadPeerSavedGifts({ peerId: global.currentUserId }); + } +}); + +addActionHandler('declineStarGiftOffer', async (global, actions, payload): Promise => { + const { messageId } = payload; + + await callApi('resolveStarGiftOffer', { + offerMsgId: messageId, + shouldDecline: true, + }); +}); diff --git a/src/global/actions/ui/stars.ts b/src/global/actions/ui/stars.ts index 3debefb47..88611a700 100644 --- a/src/global/actions/ui/stars.ts +++ b/src/global/actions/ui/stars.ts @@ -620,6 +620,23 @@ addActionHandler('openGiftDescriptionRemoveModal', (global, actions, payload): A addTabStateResetterAction('closeGiftDescriptionRemoveModal', 'giftDescriptionRemoveModal'); +addActionHandler('openGiftOfferAcceptModal', (global, actions, payload): ActionReturnType => { + const { + peerId, messageId, gift, price, tabId = getCurrentTabId(), + } = payload; + + return updateTabState(global, { + giftOfferAcceptModal: { + peerId, + messageId, + gift, + price, + }, + }, tabId); +}); + +addTabStateResetterAction('closeGiftOfferAcceptModal', 'giftOfferAcceptModal'); + addActionHandler('updateSelectedGiftCollection', (global, actions, payload): ActionReturnType => { const { peerId, collectionId, tabId = getCurrentTabId() } = payload; const tabState = selectTabState(global, tabId); diff --git a/src/global/selectors/payments.ts b/src/global/selectors/payments.ts index ba858b8aa..26e795751 100644 --- a/src/global/selectors/payments.ts +++ b/src/global/selectors/payments.ts @@ -106,3 +106,12 @@ export function selectActiveGiftsCollectionId( ): ProfileCollectionKey { return selectTabState(global, tabId).savedGifts.activeCollectionByPeerId?.[peerId] || 'all'; } + +export function selectStarsGiftResaleCommission(global: T) { + const permille = global.appConfig?.starsStargiftResaleCommissionPermille; + return permille !== undefined ? permille / 1000 : undefined; +} +export function selectTonGiftResaleCommission(global: T) { + const permille = global.appConfig?.tonStargiftResaleCommissionPermille; + return permille !== undefined ? permille / 1000 : undefined; +} diff --git a/src/global/types/actions.ts b/src/global/types/actions.ts index 233c6283e..d5aa7bdcd 100644 --- a/src/global/types/actions.ts +++ b/src/global/types/actions.ts @@ -2768,6 +2768,13 @@ export interface ActionPayloads { details: ApiStarGiftAttributeOriginalDetails; } & WithTabId; closeGiftDescriptionRemoveModal: WithTabId | undefined; + openGiftOfferAcceptModal: { + peerId: string; + messageId: number; + gift: ApiStarGiftUnique; + price: ApiTypeCurrencyAmount; + } & WithTabId; + closeGiftOfferAcceptModal: WithTabId | undefined; updateSelectedGiftCollection: { peerId: string; collectionId: number; @@ -2805,6 +2812,13 @@ export interface ActionPayloads { hash?: string; } & WithTabId; + acceptStarGiftOffer: { + messageId: number; + } & WithTabId; + declineStarGiftOffer: { + messageId: number; + } & WithTabId; + openStarsGiftModal: ({ chatId?: string; forUserId?: string; diff --git a/src/global/types/tabState.ts b/src/global/types/tabState.ts index 818b33e7a..edbffcd81 100644 --- a/src/global/types/tabState.ts +++ b/src/global/types/tabState.ts @@ -871,6 +871,13 @@ export type TabState = { details: ApiStarGiftAttributeOriginalDetails; }; + giftOfferAcceptModal?: { + peerId: string; + messageId: number; + gift: ApiStarGiftUnique; + price: ApiTypeCurrencyAmount; + }; + giftUpgradeModal?: { sampleAttributes: ApiStarGiftAttribute[]; recipientId?: string; diff --git a/src/lib/gramjs/tl/apiTl.ts b/src/lib/gramjs/tl/apiTl.ts index 8e8fadab7..125a4e7b0 100644 --- a/src/lib/gramjs/tl/apiTl.ts +++ b/src/lib/gramjs/tl/apiTl.ts @@ -1883,6 +1883,7 @@ payments.getUniqueStarGiftValueInfo#4365af6b slug:string = payments.UniqueStarGi payments.checkCanSendGift#c0c4edc9 gift_id:long = payments.CheckCanSendGiftResult; payments.getStarGiftAuctionState#5c9ff4d6 auction:InputStarGiftAuction version:int = payments.StarGiftAuctionState; payments.getStarGiftAuctionAcquiredGifts#6ba2cbec gift_id:long = payments.StarGiftAuctionAcquiredGifts; +payments.resolveStarGiftOffer#e9ce781c flags:# decline:flags.0?true offer_msg_id:int = Updates; phone.requestCall#42ff96ed flags:# video:flags.0?true user_id:InputUser random_id:int g_a_hash:bytes protocol:PhoneCallProtocol = phone.PhoneCall; phone.acceptCall#3bd2b4a0 peer:InputPhoneCall g_b:bytes protocol:PhoneCallProtocol = phone.PhoneCall; phone.confirmCall#2efe1722 peer:InputPhoneCall g_a:bytes key_fingerprint:long protocol:PhoneCallProtocol = phone.PhoneCall; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index e56105753..2c82815e2 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -354,6 +354,7 @@ "payments.getStarGiftCollections", "payments.getStarGiftAuctionState", "payments.getStarGiftAuctionAcquiredGifts", + "payments.resolveStarGiftOffer", "langpack.getLangPack", "langpack.getStrings", "langpack.getLanguages", diff --git a/src/types/language.d.ts b/src/types/language.d.ts index 3be5a63d0..1dc69a51c 100644 --- a/src/types/language.d.ts +++ b/src/types/language.d.ts @@ -1525,6 +1525,13 @@ export interface LangPair { 'ActionStarGiftUniqueBackdrop': undefined; 'ActionStarGiftUniqueSymbol': undefined; 'ActionStarGiftSelf': undefined; + 'ActionStarGiftOfferAccepted': undefined; + 'ActionStarGiftOfferRejected': undefined; + 'ActionStarGiftOfferHasExpired': undefined; + 'GiftOfferReject': undefined; + 'GiftOfferAccept': undefined; + 'GiftOfferRejectTitle': undefined; + 'GiftOfferAcceptTitle': undefined; 'ActionSuggestedPhotoButton': undefined; 'ActionSuggestedVideoTitle': undefined; 'ActionSuggestedVideoText': undefined; @@ -2271,6 +2278,9 @@ export interface LangPairWithVariables { 'GiftInfoPeerDescriptionFreeUpgradeOut': { 'peer': V; }; + 'GiftInfoValueAmount': { + 'amount': V; + }; 'GiftInfoPeerConvertDescription': { 'peer': V; 'amount': V; @@ -2655,6 +2665,16 @@ export interface LangPairWithVariables { 'ActionStarGiftTransferredUnknownChannel': { 'channel': V; }; + 'ActionStarGiftSoldFromOffer': { + 'gift': V; + 'user': V; + 'cost': V; + }; + 'ActionStarGiftBoughtFromOffer': { + 'user': V; + 'gift': V; + 'cost': V; + }; 'ActionStarGiftReceivedAnonymous': { 'cost': V; }; @@ -2719,6 +2739,51 @@ export interface LangPairWithVariables { 'ActionStarGiftAuctionBought': { 'cost': V; }; + 'ActionStarGiftOfferOutgoing': { + 'peer': V; + 'cost': V; + 'gift': V; + }; + 'ActionStarGiftOfferIncoming': { + 'peer': V; + 'cost': V; + 'gift': V; + }; + 'ActionStarGiftOfferExpires': { + 'time': V; + }; + 'ActionStarGiftOfferDeclinedOutgoing': { + 'peer': V; + 'gift': V; + 'cost': V; + }; + 'ActionStarGiftOfferDeclinedIncoming': { + 'peer': V; + 'gift': V; + 'cost': V; + }; + 'GiftOfferRejectText': { + 'user': V; + }; + 'GiftOfferAcceptText': { + 'gift': V; + 'user': V; + 'price': V; + }; + 'GiftOfferAcceptReceive': { + 'amount': V; + }; + 'GiftOfferAcceptButton': { + 'amount': V; + }; + 'GiftOfferPriceLow': { + 'percent': V; + 'gift': V; + }; + 'GiftOfferPriceHigh': { + 'percent': V; + 'gift': V; + }; 'ActionSuggestedPhotoYou': { 'user': V; }; diff --git a/src/util/dates/dateFormat.ts b/src/util/dates/dateFormat.ts index ec141ed4a..589951289 100644 --- a/src/util/dates/dateFormat.ts +++ b/src/util/dates/dateFormat.ts @@ -339,6 +339,26 @@ export function formatMediaDuration(duration: number, maxValue?: number) { return string; } +export function formatShortHoursMinutes(lang: OldLangFn, durationInSeconds: number) { + if (durationInSeconds <= 0) { + return lang('MessageTimer.ShortMinutes', 0); + } + + const hours = Math.floor(durationInSeconds / 3600); + const minutes = Math.floor((durationInSeconds % 3600) / 60); + + if (hours > 0) { + const hoursText = lang('MessageTimer.ShortHours', hours); + if (minutes === 0) { + return hoursText; + } + + const minutesText = lang('MessageTimer.ShortMinutes', minutes); + return `${hoursText} ${minutesText}`; + } + return lang('MessageTimer.ShortMinutes', minutes); +} + export function formatVoiceRecordDuration(durationInMs: number) { const parts = []; diff --git a/src/util/localization/format.tsx b/src/util/localization/format.tsx index c38af1b7c..b2bf323a5 100644 --- a/src/util/localization/format.tsx +++ b/src/util/localization/format.tsx @@ -1,6 +1,7 @@ +import type { ApiTypeCurrencyAmount } from '../../api/types'; import type { LangFn } from './types'; -import { STARS_ICON_PLACEHOLDER } from '../../config'; +import { STARS_ICON_PLACEHOLDER, TON_CURRENCY_CODE } from '../../config'; import { convertTonFromNanos } from '../../util/formatCurrency'; import buildClassName from '../buildClassName'; @@ -74,3 +75,12 @@ export function formatStarsAsIcon(lang: LangFn, amount: number | string, options }, }); } + +export function formatCurrencyAmountAsText(lang: LangFn, currencyAmount: ApiTypeCurrencyAmount) { + if (currencyAmount.currency === TON_CURRENCY_CODE) { + return formatTonAsText(lang, currencyAmount.amount, true); + } + + const amount = currencyAmount.amount + currencyAmount.nanos / 1e9; + return formatStarsAsText(lang, amount); +}