Gifts: Support purchase offer (#6579)

This commit is contained in:
Alexander Zinchuk 2026-01-20 12:00:30 +01:00
parent bb4678dd5a
commit 049db12d92
33 changed files with 1076 additions and 47 deletions

View File

@ -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,

View File

@ -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,
};
}

View File

@ -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;
}

View File

@ -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,
});
}

View File

@ -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;

View File

@ -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[][];

View File

@ -240,6 +240,7 @@ export interface ApiAppConfig {
starsPaidMessageCommissionPermille?: number;
starsPaidMessageAmountMax?: number;
starsUsdWithdrawRateX1000: number;
starsUsdSellRateX1000?: number;
bandwidthPremiumNotifyPeriod?: number;
bandwidthPremiumUploadSpeedup?: number;
bandwidthPremiumDownloadSpeedup?: number;

View File

@ -59,6 +59,8 @@ export interface ApiStarGiftUnique {
resaleTonOnly?: true;
valueCurrency?: string;
valueAmount?: number;
valueUsdAmount?: number;
offerMinStars?: number;
}
export type ApiStarGift = ApiStarGiftRegular | ApiStarGiftUnique;

View File

@ -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";

View File

@ -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';

View File

@ -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;
}

View File

@ -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<ApiMessageAction['type']>([
'unsupported',
]);
const HIDDEN_TEXT_ACTIONS = new Set<ApiMessageAction['type']>(['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<HTMLDivElement>();
@ -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 (
<StarGiftPurchaseOffer
action={action}
message={message}
hasButtons={shouldRenderGiftOfferButtons}
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
onClick={shouldRenderGiftOfferButtons ? handleClick : undefined}
/>
);
case 'channelJoined':
return (
<SimilarChannels
@ -442,7 +538,9 @@ const ActionMessage = ({
default:
return undefined;
}
}, [action, message, observeIntersectionForLoading, sender, observeIntersectionForPlaying]);
}, [
action, message, observeIntersectionForLoading, sender, observeIntersectionForPlaying, shouldRenderGiftOfferButtons,
]);
if ((isInsideTopic && action.type === 'topicCreate') || action.type === 'phoneCall') {
return undefined;
@ -473,20 +571,46 @@ const ActionMessage = ({
{!isTextHidden && (
<>
{isFluidMultiline && (
<div className={buildClassName(styles.inlineWrapper, isClickableText && styles.hoverable)}>
<div className={buildClassName(
styles.inlineWrapper,
isClickableText && styles.hoverable,
isNarrowMessage && styles.narrowWrapper,
)}
>
<span className={styles.fluidBackground} style={fluidBackgroundStyle}>
<ActionMessageText message={message} isInsideTopic={isInsideTopic} />
</span>
</div>
)}
<div className={buildClassName(styles.inlineWrapper, isClickableText && styles.hoverable)}>
<span className={styles.textContent} onClick={handleClick}>
<div className={buildClassName(
styles.inlineWrapper,
isClickableText && styles.hoverable,
isNarrowMessage && styles.narrowWrapper,
)}
>
<span
className={styles.textContent}
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={handleKeyDown}
>
<ActionMessageText message={message} isInsideTopic={isInsideTopic} />
</span>
</div>
</>
)}
{fullContent}
{(fullContent || shouldRenderInlineButtons) && (
<div className={styles.contentWrapper}>
{fullContent}
{shouldRenderInlineButtons && (
<InlineButtons
inlineButtons={giftOfferInlineButtons}
onClick={handleInlineButtonClick}
/>
)}
</div>
)}
{contextMenuAnchor && (
<ContextMenuContainer
isOpen={isContextMenuOpen}
@ -508,6 +632,19 @@ const ActionMessage = ({
isAccountFrozen={isAccountFrozen}
/>
)}
<ConfirmDialog
isOpen={isRejectOfferDialogOpen}
title={lang('GiftOfferRejectTitle')}
textParts={lang(
'GiftOfferRejectText',
{ user: sender ? getPeerTitle(lang, sender) : lang('ActionFallbackSomeone') },
{ withNodes: true, withMarkdown: true },
)}
confirmLabel={lang('GiftOfferReject')}
confirmIsDestructive
confirmHandler={handleRejectOfferConfirm}
onClose={handleRejectOfferClose}
/>
</div>
);
};

View File

@ -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);
}

View File

@ -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 {

View File

@ -58,6 +58,14 @@ const InlineButtons = ({ inlineButtons, onClick }: OwnProps) => {
return <Icon className="left-icon" name="close" />;
}
break;
case 'giftOffer':
if (button.buttonType === 'accept') {
return <Icon className="left-icon" name="check" />;
}
if (button.buttonType === 'reject') {
return <Icon className="left-icon" name="close" />;
}
break;
}
return;

View File

@ -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;
}

View File

@ -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<HTMLDivElement>();
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 (
<div
className={buildClassName(
actionStyles.contentBox,
styles.root,
hasButtons && styles.hasButtons,
onClick && styles.clickable,
)}
tabIndex={onClick ? 0 : undefined}
role={onClick ? 'button' : undefined}
onMouseEnter={!IS_TOUCH_ENV ? markHover : undefined}
onMouseLeave={!IS_TOUCH_ENV ? unmarkHover : undefined}
onClick={onClick}
>
<div className={styles.giftContainer}>
<div className={styles.uniqueBackgroundWrapper}>
<RadialPatternBackground
className={styles.uniqueBackground}
backgroundColors={backgroundColors}
patternIcon={pattern.sticker}
patternSize={9}
ringsCount={1}
ovalFactor={1}
/>
</div>
<div
ref={stickerRef}
className={styles.stickerWrapper}
style={`width: ${STICKER_SIZE}px; height: ${STICKER_SIZE}px`}
>
{sticker && (
<StickerView
containerRef={stickerRef}
sticker={sticker}
size={STICKER_SIZE}
shouldLoop={isHover}
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
noLoad={!canPlayAnimatedEmojis}
/>
)}
</div>
</div>
<div className={styles.info}>
<p className={styles.title}>
{lang(
isOutgoing ? 'ActionStarGiftOfferOutgoing' : 'ActionStarGiftOfferIncoming',
{
peer: renderPeerLink(peer?.id, peerTitle || fallbackPeerTitle),
cost: priceText,
gift: giftName,
},
{ withNodes: true, withMarkdown: true },
)}
</p>
<p className={styles.subtitle}>
{subtitle}
</p>
</div>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { message }): Complete<StateProps> => {
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));

View File

@ -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<TabState,
'giftTransferModal' |
'giftTransferConfirmModal' |
'giftDescriptionRemoveModal' |
'giftOfferAcceptModal' |
'chatRefundModal' |
'priceConfirmModal' |
'isFrozenAccountModalOpen' |
@ -188,6 +190,7 @@ const MODALS: ModalRegistry = {
giftTransferModal: GiftTransferModal,
giftTransferConfirmModal: GiftTransferConfirmModal,
giftDescriptionRemoveModal: GiftDescriptionRemoveModal,
giftOfferAcceptModal: GiftOfferAcceptModal,
chatRefundModal: ChatRefundModal,
priceConfirmModal: PriceConfirmModal,
isFrozenAccountModalOpen: FrozenAccountModal,

View File

@ -743,16 +743,11 @@ const GiftInfoModal = ({
]);
if (gift.valueAmount && gift.valueCurrency) {
const formattedValue = formatCurrencyAsString(gift.valueAmount, gift.valueCurrency, lang.code);
tableData.push([
lang('GiftInfoValue'),
<span className={styles.uniqueValue}>
~
{' '}
{formatCurrencyAsString(
gift.valueAmount,
gift.valueCurrency,
lang.code,
)}
{lang('GiftInfoValueAmount', { amount: formattedValue })}
<BadgeButton onClick={handleOpenValueModal}>
{lang('GiftInfoValueLinkMore')}
</BadgeButton>

View File

@ -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 ? <GiftOfferAcceptModal {...props} /> : undefined;
};
export default GiftOfferAcceptModalAsync;

View File

@ -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;
}

View File

@ -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'),
<span className={styles.attributeValue}>
<span>{model.name}</span>
<BadgeButton>{formatPercent(model.rarityPercent)}</BadgeButton>
</span>,
]);
}
if (backdrop) {
data.push([
lang('GiftAttributeBackdrop'),
<span className={styles.attributeValue}>
<span>{backdrop.name}</span>
<BadgeButton>{formatPercent(backdrop.rarityPercent)}</BadgeButton>
</span>,
]);
}
if (pattern) {
data.push([
lang('GiftAttributeSymbol'),
<span className={styles.attributeValue}>
<span>{pattern.name}</span>
<BadgeButton>{formatPercent(pattern.rarityPercent)}</BadgeButton>
</span>,
]);
}
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 (
<ConfirmDialog
isOpen={isOpen}
title={lang('GiftOfferAcceptTitle')}
onClose={closeGiftOfferAcceptModal}
confirmLabel={lang('GiftOfferAcceptButton', {
amount: formattedReceiveAmountAsIcon,
}, { withNodes: true })}
confirmHandler={handleConfirm}
>
<GiftTransferPreview
peer={renderingBuyerPeer}
gift={gift}
/>
<p className={styles.description}>
{lang('GiftOfferAcceptText', {
gift: giftName,
user: buyerName,
price: formattedPrice,
}, { withNodes: true, withMarkdown: true })}
</p>
<p className={styles.receiveText}>
{lang('GiftOfferAcceptReceive', {
amount: formattedReceiveAmountAsText,
}, { withNodes: true, withMarkdown: true })}
</p>
{Boolean(tableData.length) && (
<TableInfo tableData={tableData} className={styles.table} />
)}
{priceWarning && (
<p className={priceWarning.isLow ? styles.warning : styles.success}>
{lang(priceWarning.isLow ? 'GiftOfferPriceLow' : 'GiftOfferPriceHigh', {
percent: formatPercent(priceWarning.percent, 0),
gift: gift.title,
}, { withNodes: true, withMarkdown: true })}
</p>
)}
</ConfirmDialog>
);
};
export default memo(
withGlobal<OwnProps>((global, { modal }): Complete<StateProps> => {
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),
);

View File

@ -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<OwnProps>(
(global): Complete<StateProps> => {
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,
};
},

View File

@ -709,3 +709,29 @@ addActionHandler('openGiftAuctionAcquiredModal', async (global, actions, payload
setGlobal(global);
});
addActionHandler('acceptStarGiftOffer', async (global, actions, payload): Promise<void> => {
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<void> => {
const { messageId } = payload;
await callApi('resolveStarGiftOffer', {
offerMsgId: messageId,
shouldDecline: true,
});
});

View File

@ -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);

View File

@ -106,3 +106,12 @@ export function selectActiveGiftsCollectionId<T extends GlobalState>(
): ProfileCollectionKey {
return selectTabState(global, tabId).savedGifts.activeCollectionByPeerId?.[peerId] || 'all';
}
export function selectStarsGiftResaleCommission<T extends GlobalState>(global: T) {
const permille = global.appConfig?.starsStargiftResaleCommissionPermille;
return permille !== undefined ? permille / 1000 : undefined;
}
export function selectTonGiftResaleCommission<T extends GlobalState>(global: T) {
const permille = global.appConfig?.tonStargiftResaleCommissionPermille;
return permille !== undefined ? permille / 1000 : undefined;
}

View File

@ -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;

View File

@ -871,6 +871,13 @@ export type TabState = {
details: ApiStarGiftAttributeOriginalDetails;
};
giftOfferAcceptModal?: {
peerId: string;
messageId: number;
gift: ApiStarGiftUnique;
price: ApiTypeCurrencyAmount;
};
giftUpgradeModal?: {
sampleAttributes: ApiStarGiftAttribute[];
recipientId?: string;

View File

@ -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;

View File

@ -354,6 +354,7 @@
"payments.getStarGiftCollections",
"payments.getStarGiftAuctionState",
"payments.getStarGiftAuctionAcquiredGifts",
"payments.resolveStarGiftOffer",
"langpack.getLangPack",
"langpack.getStrings",
"langpack.getLanguages",

View File

@ -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<V = LangVariable> {
'GiftInfoPeerDescriptionFreeUpgradeOut': {
'peer': V;
};
'GiftInfoValueAmount': {
'amount': V;
};
'GiftInfoPeerConvertDescription': {
'peer': V;
'amount': V;
@ -2655,6 +2665,16 @@ export interface LangPairWithVariables<V = LangVariable> {
'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<V = LangVariable> {
'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;
};

View File

@ -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 = [];

View File

@ -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);
}