Support TON balance (#6076)

Co-authored-by: zubiden <19638254+zubiden@users.noreply.github.com>
This commit is contained in:
Alexander Zinchuk 2025-07-29 14:33:40 +02:00
parent 58d2906425
commit a0e8eff8e3
57 changed files with 1112 additions and 323 deletions

View File

@ -12,6 +12,10 @@ import {
TODO_ITEM_LENGTH_LIMIT,
TODO_ITEMS_LIMIT,
TODO_TITLE_LENGTH_LIMIT,
TON_SUGGESTED_POST_AMOUNT_MAX,
TON_SUGGESTED_POST_AMOUNT_MIN,
TON_TOPUP_URL_DEFAULT,
TON_USD_RATE_DEFAULT,
} from '../../../config';
import localDb from '../localDb';
import { buildJson } from './misc';
@ -108,6 +112,10 @@ export interface GramJsAppConfig extends LimitsConfig {
stars_suggested_post_future_max?: number;
stars_suggested_post_future_min?: number;
ton_suggested_post_commission_permille?: number;
ton_suggested_post_amount_max?: number;
ton_suggested_post_amount_min?: number;
ton_usd_rate?: number;
ton_topup_url?: string;
poll_answers_max?: number;
todo_items_max?: number;
todo_title_length_max?: number;
@ -219,6 +227,10 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp
starsSuggestedPostFutureMax: appConfig.stars_suggested_post_future_max,
starsSuggestedPostFutureMin: appConfig.stars_suggested_post_future_min,
tonSuggestedPostCommissionPermille: appConfig.ton_suggested_post_commission_permille,
tonSuggestedPostAmountMax: appConfig.ton_suggested_post_amount_max ?? TON_SUGGESTED_POST_AMOUNT_MAX,
tonSuggestedPostAmountMin: appConfig.ton_suggested_post_amount_min ?? TON_SUGGESTED_POST_AMOUNT_MIN,
tonUsdRate: appConfig.ton_usd_rate ?? TON_USD_RATE_DEFAULT,
tonTopupUrl: appConfig.ton_topup_url ?? TON_TOPUP_URL_DEFAULT,
pollMaxAnswers: appConfig.poll_answers_max,
todoItemsMax: appConfig.todo_items_max ?? TODO_ITEMS_LIMIT,
todoTitleLengthMax: appConfig.todo_title_length_max ?? TODO_TITLE_LENGTH_LIMIT,

View File

@ -7,7 +7,7 @@ import { buildApiBotApp } from './bots';
import { buildApiFormattedText, buildApiPhoto } from './common';
import { buildApiStarGift } from './gifts';
import { buildTodoItem } from './messageContent';
import { buildApiStarsAmount } from './payments';
import { buildApiCurrencyAmount } from './payments';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers';
const UNSUPPORTED_ACTION: ApiMessageAction = {
@ -359,6 +359,20 @@ export function buildApiMessageAction(action: GramJs.TypeMessageAction): ApiMess
transactionId,
};
}
if (action instanceof GramJs.MessageActionGiftTon) {
const {
currency, amount, cryptoCurrency, cryptoAmount, transactionId,
} = action;
return {
mediaType: 'action',
type: 'giftTon',
currency,
amount: amount.toJSNumber(),
cryptoCurrency,
cryptoAmount: cryptoAmount.toJSNumber(),
transactionId,
};
}
if (action instanceof GramJs.MessageActionPrizeStars) {
const {
unclaimed, stars, transactionId, boostPeer, giveawayMsgId,
@ -459,7 +473,7 @@ export function buildApiMessageAction(action: GramJs.TypeMessageAction): ApiMess
isBalanceTooLow: Boolean(balanceTooLow),
rejectComment,
scheduleDate,
amount: price ? buildApiStarsAmount(price) : undefined,
amount: price ? buildApiCurrencyAmount(price) : undefined,
};
}
if (action instanceof GramJs.MessageActionSuggestedPostSuccess) {
@ -467,7 +481,7 @@ export function buildApiMessageAction(action: GramJs.TypeMessageAction): ApiMess
return {
mediaType: 'action',
type: 'suggestedPostSuccess',
amount: buildApiStarsAmount(price),
amount: buildApiCurrencyAmount(price),
};
}
if (action instanceof GramJs.MessageActionSuggestedPostRefund) {

View File

@ -47,7 +47,7 @@ import { omitUndefined, pick } from '../../../util/iteratees';
import { getServerTime, getServerTimeOffset } from '../../../util/serverTime';
import { interpolateArray } from '../../../util/waveform';
import {
buildApiStarsAmount,
buildApiCurrencyAmount,
} from '../apiBuilders/payments';
import { buildPeer } from '../gramjsBuilders';
import {
@ -302,7 +302,7 @@ export function buildMessageDraft(draft: GramJs.TypeDraftMessage): ApiDraft | un
const suggestedPostInfo = suggestedPost instanceof GramJs.SuggestedPost ? {
isAccepted: suggestedPost.accepted,
isRejected: suggestedPost.rejected,
price: suggestedPost.price ? buildApiStarsAmount(suggestedPost.price) : undefined,
price: suggestedPost.price ? buildApiCurrencyAmount(suggestedPost.price) : undefined,
scheduleDate: suggestedPost.scheduleDate,
} satisfies ApiInputSuggestedPostInfo : undefined;
@ -319,7 +319,7 @@ function buildApiSuggestedPost(suggestedPost: GramJs.SuggestedPost): ApiSuggeste
return {
isAccepted: suggestedPost.accepted,
isRejected: suggestedPost.rejected,
price: suggestedPost.price ? buildApiStarsAmount(suggestedPost.price) : undefined,
price: suggestedPost.price ? buildApiCurrencyAmount(suggestedPost.price) : undefined,
scheduleDate: suggestedPost.scheduleDate,
};
}

View File

@ -20,15 +20,16 @@ import type {
ApiPrepaidStarsGiveaway,
ApiReceipt,
ApiStarGiveawayOption,
ApiStarsAmount,
ApiStarsGiveawayWinnerOption,
ApiStarsSubscription,
ApiStarsTransaction,
ApiStarsTransactionPeer,
ApiStarTopupOption,
ApiTypeCurrencyAmount,
BoughtPaidMedia,
} from '../../types';
import { STARS_CURRENCY_CODE, TON_CURRENCY_CODE } from '../../../config';
import { addWebDocumentToLocalDb } from '../helpers/localDb';
import { buildApiStarsSubscriptionPricing } from './chats';
import { buildApiMessageEntity } from './common';
@ -461,28 +462,25 @@ export function buildApiStarsGiftOptions(option: GramJs.StarsGiftOption): ApiSta
};
}
export function buildApiStarsAmount(amount: GramJs.TypeStarsAmount): ApiStarsAmount | undefined {
export function buildApiCurrencyAmount(amount: GramJs.TypeStarsAmount): ApiTypeCurrencyAmount | undefined {
if (amount instanceof GramJs.StarsAmount) {
return {
currency: STARS_CURRENCY_CODE,
amount: amount.amount.toJSNumber(),
nanos: amount.nanos,
};
}
if (amount instanceof GramJs.StarsTonAmount) {
return undefined;
return {
currency: TON_CURRENCY_CODE,
amount: amount.amount.toJSNumber(),
};
}
return undefined;
}
export function buildInputStarsAmount(amount: ApiStarsAmount): GramJs.TypeStarsAmount {
return new GramJs.StarsAmount({
amount: bigInt(amount.amount),
nanos: amount.nanos,
});
}
export function buildApiStarsGiveawayWinnersOption(
option: GramJs.StarsGiveawayWinnersOption,
): ApiStarsGiveawayWinnerOption {
@ -563,7 +561,7 @@ export function buildApiStarsTransaction(transaction: GramJs.StarsTransaction):
const starRefCommision = starrefCommissionPermille ? starrefCommissionPermille / 10 : undefined;
const starsAmount = buildApiStarsAmount(amount);
const starsAmount = buildApiCurrencyAmount(amount);
if (!starsAmount) {
return undefined;
}
@ -572,7 +570,7 @@ export function buildApiStarsTransaction(transaction: GramJs.StarsTransaction):
id,
date,
peer: buildApiStarsTransactionPeer(peer),
stars: starsAmount,
amount: starsAmount,
title,
description,
photo: photo && buildApiWebDocument(photo),

View File

@ -33,15 +33,15 @@ import type {
ApiStory,
ApiStorySkipped,
ApiThemeParameters,
ApiTypeCurrencyAmount,
ApiVideo,
} from '../../types';
import {
ApiMessageEntityTypes,
} from '../../types';
import { CHANNEL_ID_BASE, DEFAULT_STATUS_ICON_ID } from '../../../config';
import { CHANNEL_ID_BASE, DEFAULT_STATUS_ICON_ID, STARS_CURRENCY_CODE } from '../../../config';
import { pick } from '../../../util/iteratees';
import { buildInputStarsAmount } from '../apiBuilders/payments';
import { deserializeBytes } from '../helpers/misc';
import localDb from '../localDb';
@ -890,12 +890,22 @@ export function buildInputReplyTo(replyInfo: ApiInputReplyInfo) {
return undefined;
}
export function buildInputSuggestedPost(suggestedPostInfo: ApiInputSuggestedPostInfo): GramJs.SuggestedPost {
const isPaid = Boolean(suggestedPostInfo.price)
&& Boolean((suggestedPostInfo.price.amount || suggestedPostInfo.price.nanos));
export function buildInputStarsAmount(amount: ApiTypeCurrencyAmount): GramJs.TypeStarsAmount {
if (amount.currency === STARS_CURRENCY_CODE) {
return new GramJs.StarsAmount({
amount: BigInt(amount.amount),
nanos: amount.nanos,
});
}
return new GramJs.StarsTonAmount({
amount: BigInt(amount.amount),
});
}
export function buildInputSuggestedPost(suggestedPostInfo: ApiInputSuggestedPostInfo): GramJs.SuggestedPost {
return new GramJs.SuggestedPost({
price: isPaid ? buildInputStarsAmount(suggestedPostInfo.price!) : undefined,
price: suggestedPostInfo.price && buildInputStarsAmount(suggestedPostInfo.price),
scheduleDate: suggestedPostInfo.scheduleDate,
});
}

View File

@ -14,7 +14,7 @@ import { buildApiChatFromPreview } from '../apiBuilders/chats';
import { buildApiResaleGifts, buildApiSavedStarGift, buildApiStarGift,
buildApiStarGiftAttribute, buildInputResaleGiftsAttributes } from '../apiBuilders/gifts';
import {
buildApiStarsAmount,
buildApiCurrencyAmount,
buildApiStarsGiftOptions,
buildApiStarsGiveawayOptions,
buildApiStarsSubscription,
@ -182,18 +182,22 @@ export async function getStarsGiftOptions({
return result.map(buildApiStarsGiftOptions);
}
export async function fetchStarsStatus() {
export async function fetchStarsStatus({
isTon,
}: {
isTon?: boolean;
} = {}) {
const result = await invokeRequest(new GramJs.payments.GetStarsStatus({
peer: new GramJs.InputPeerSelf(),
ton: isTon || undefined,
}));
if (!result) {
return undefined;
}
const balance = buildApiStarsAmount(result.balance);
const balance = buildApiCurrencyAmount(result.balance);
if (!balance) {
// For now, skip if balance is in TON
return undefined;
}
@ -212,29 +216,31 @@ export async function fetchStarsTransactions({
limit = DEFAULT_PRIMITIVES.INT,
isInbound,
isOutbound,
isTon,
}: {
peer?: ApiPeer;
offset?: string;
limit?: number;
isInbound?: true;
isOutbound?: true;
isInbound?: boolean;
isOutbound?: boolean;
isTon?: boolean;
}) {
const inputPeer = peer ? buildInputPeer(peer.id, peer.accessHash) : new GramJs.InputPeerSelf();
const result = await invokeRequest(new GramJs.payments.GetStarsTransactions({
peer: inputPeer,
offset,
limit,
inbound: isInbound,
outbound: isOutbound,
inbound: isInbound || undefined,
outbound: isOutbound || undefined,
ton: isTon || undefined,
}));
if (!result) {
return undefined;
}
const balance = buildApiStarsAmount(result.balance);
const balance = buildApiCurrencyAmount(result.balance);
if (!balance) {
// For now, skip if balance is in TON
return undefined;
}
@ -246,14 +252,16 @@ export async function fetchStarsTransactions({
}
export async function fetchStarsTransactionById({
id, peer,
id, peer, ton,
}: {
id: string;
peer?: ApiPeer;
ton?: true;
}) {
const inputPeer = peer ? buildInputPeer(peer.id, peer.accessHash) : new GramJs.InputPeerSelf();
const result = await invokeRequest(new GramJs.payments.GetStarsTransactionsByID({
peer: inputPeer,
ton,
id: [new GramJs.InputStarsTransaction({
id,
})],
@ -285,9 +293,8 @@ export async function fetchStarsSubscriptions({
return undefined;
}
const balance = buildApiStarsAmount(result.balance);
const balance = buildApiCurrencyAmount(result.balance);
if (!balance) {
// For now, skip if balance is in TON
return undefined;
}

View File

@ -272,6 +272,22 @@ export async function fetchPremiumGifts() {
};
}
export async function fetchTonGifts() {
const result = await invokeRequest(new GramJs.messages.GetStickerSet({
stickerset: new GramJs.InputStickerSetTonGifts(),
hash: DEFAULT_PRIMITIVES.INT,
}));
if (!(result instanceof GramJs.messages.StickerSet)) {
return undefined;
}
return {
set: buildStickerSet(result.set),
stickers: processStickerResult(result.documents),
};
}
export async function fetchDefaultTopicIcons() {
const result = await invokeRequest(new GramJs.messages.GetStickerSet({
stickerset: new GramJs.InputStickerSetEmojiDefaultTopicIcons(),

View File

@ -50,7 +50,7 @@ import {
buildLangStrings,
buildPrivacyKey,
} from '../apiBuilders/misc';
import { buildApiStarsAmount } from '../apiBuilders/payments';
import { buildApiCurrencyAmount } from '../apiBuilders/payments';
import { buildApiEmojiStatus, buildApiPeerId, getApiChatIdFromMtpPeer } from '../apiBuilders/peers';
import {
buildApiPaidReactionPrivacy,
@ -1042,7 +1042,7 @@ export function updater(update: Update) {
isEnabled: update.enabled ? true : undefined,
});
} else if (update instanceof GramJs.UpdateStarsBalance) {
const balance = buildApiStarsAmount(update.balance);
const balance = buildApiCurrencyAmount(update.balance);
if (!balance) {
// Skip TON balance updates for now
return;

View File

@ -1,7 +1,7 @@
import type { ApiGroupCall, ApiPhoneCallDiscardReason } from './calls';
import type { ApiBotApp, ApiFormattedText, ApiPhoto } from './messages';
import type { ApiTodoItem } from './messages';
import type { ApiStarGiftRegular, ApiStarGiftUnique, ApiStarsAmount } from './stars';
import type { ApiStarGiftRegular, ApiStarGiftUnique, ApiTypeCurrencyAmount } from './stars';
interface ActionMediaType {
mediaType: 'action';
@ -216,6 +216,15 @@ export interface ApiMessageActionGiftStars extends ActionMediaType {
transactionId?: string;
}
export interface ApiMessageActionGiftTon extends ActionMediaType {
type: 'giftTon';
currency: string;
amount: number;
cryptoCurrency: string;
cryptoAmount: number;
transactionId?: string;
}
export interface ApiMessageActionPrizeStars extends ActionMediaType {
type: 'prizeStars';
isUnclaimed?: true;
@ -288,12 +297,12 @@ export interface ApiMessageActionSuggestedPostApproval extends ActionMediaType {
isBalanceTooLow?: boolean;
rejectComment?: string;
scheduleDate?: number;
amount?: ApiStarsAmount;
amount?: ApiTypeCurrencyAmount;
}
export interface ApiMessageActionSuggestedPostSuccess extends ActionMediaType {
type: 'suggestedPostSuccess';
amount?: ApiStarsAmount;
amount?: ApiTypeCurrencyAmount;
}
export interface ApiMessageActionSuggestedPostRefund extends ActionMediaType {
@ -328,7 +337,7 @@ export type ApiMessageAction = ApiMessageActionUnsupported | ApiMessageActionCha
| ApiMessageActionTopicCreate | ApiMessageActionTopicEdit | ApiMessageActionSuggestProfilePhoto
| ApiMessageActionChannelJoined | ApiMessageActionGiftCode | ApiMessageActionGiveawayLaunch
| ApiMessageActionGiveawayResults | ApiMessageActionPaymentRefunded | ApiMessageActionGiftStars
| ApiMessageActionPrizeStars | ApiMessageActionStarGift | ApiMessageActionStarGiftUnique
| ApiMessageActionGiftTon | ApiMessageActionPrizeStars | ApiMessageActionStarGift | ApiMessageActionStarGiftUnique
| ApiMessageActionPaidMessagesRefunded | ApiMessageActionPaidMessagesPrice | ApiMessageActionSuggestedPostApproval
| ApiMessageActionSuggestedPostSuccess | ApiMessageActionSuggestedPostRefund | ApiMessageActionTodoCompletions
| ApiMessageActionTodoAppendTasks;

View File

@ -9,7 +9,7 @@ import type { ApiMessageAction } from './messageActions';
import type {
ApiLabeledPrice,
} from './payments';
import type { ApiStarGiftUnique, ApiStarsAmount } from './stars';
import type { ApiStarGiftUnique, ApiTypeCurrencyAmount } from './stars';
import type {
ApiMessageStoryData, ApiStory, ApiWebPageStickerData, ApiWebPageStoryData,
} from './stories';
@ -415,7 +415,7 @@ export interface ApiInputMessageReplyInfo {
export interface ApiSuggestedPost {
isAccepted?: true;
isRejected?: true;
price?: ApiStarsAmount;
price?: ApiTypeCurrencyAmount;
scheduleDate?: number;
}
@ -426,7 +426,7 @@ export interface ApiInputStoryReplyInfo {
}
export interface ApiInputSuggestedPostInfo {
price?: ApiStarsAmount;
price?: ApiTypeCurrencyAmount;
scheduleDate?: number;
isAccepted?: true;
isRejected?: true;

View File

@ -259,6 +259,10 @@ export interface ApiAppConfig {
starsSuggestedPostFutureMax?: number;
starsSuggestedPostFutureMin?: number;
tonSuggestedPostCommissionPermille?: number;
tonSuggestedPostAmountMax?: number;
tonSuggestedPostAmountMin?: number;
tonUsdRate?: number;
tonTopupUrl?: string;
pollMaxAnswers?: number;
todoItemsMax?: number;
todoTitleLengthMax?: number;

View File

@ -1,3 +1,4 @@
import type { STARS_CURRENCY_CODE, TON_CURRENCY_CODE } from '../../config';
import type { ApiWebDocument } from './bots';
import type { ApiChat } from './chats';
import type { ApiFormattedText, ApiSticker, BoughtPaidMedia } from './messages';
@ -153,11 +154,19 @@ export type ApiRequestInputSavedStarGiftChat = {
};
export type ApiRequestInputSavedStarGift = ApiRequestInputSavedStarGiftUser | ApiRequestInputSavedStarGiftChat;
export type ApiTypeCurrencyAmount = ApiStarsAmount | ApiTonAmount;
export interface ApiStarsAmount {
currency: typeof STARS_CURRENCY_CODE;
amount: number;
nanos: number;
}
export interface ApiTonAmount {
currency: typeof TON_CURRENCY_CODE;
amount: number;
}
export interface ApiStarsTransactionPeerUnsupported {
type: 'unsupported';
}
@ -205,7 +214,7 @@ export interface ApiStarsTransaction {
id?: string;
peer: ApiStarsTransactionPeer;
messageId?: number;
stars: ApiStarsAmount;
amount: ApiTypeCurrencyAmount;
isRefund?: true;
isGift?: true;
starGift?: ApiStarGift;

View File

@ -43,7 +43,7 @@ import type {
ApiSessionData,
} from './misc';
import type { ApiPrivacyKey, LangPackStringValue, PrivacyVisibility } from './settings';
import type { ApiStarsAmount } from './stars';
import type { ApiTypeCurrencyAmount } from './stars';
import type { ApiStealthMode, ApiStory, ApiStorySkipped } from './stories';
import type {
ApiEmojiStatusType, ApiUser, ApiUserFullInfo, ApiUserStatus,
@ -794,7 +794,7 @@ export type ApiUpdatePremiumFloodWait = {
export type ApiUpdateStarsBalance = {
'@type': 'updateStarsBalance';
balance: ApiStarsAmount;
balance: ApiTypeCurrencyAmount;
};
export type ApiUpdateDeleteProfilePhoto = {

View File

@ -657,6 +657,7 @@
"MenuStickers" = "Stickers and Emoji";
"MenuAnimations" = "Animations and Performance";
"MenuStars" = "My Stars";
"MenuTon" = "My TON";
"MenuSendGift" = "Send a Gift";
"MenuTelegramFaq" = "Telegram FAQ";
"MenuPrivacyPolicy" = "Privacy Policy";
@ -1880,6 +1881,10 @@
"ActionGiftStarsTitle_one" = "{amount} Star";
"ActionGiftStarsTitle_other" = "{amount} Stars";
"ActionGiftStarsText" = "Use Stars to unlock content and services on Telegram.";
"TonAmount" = "💎{amount}";
"TonAmountText_one" = "{amount} TON";
"TonAmountText_other" = "{amount} TON";
"ActionGiftCostCrypto" = "{cryptoPrice} ({price})";
"ActionBoostApplyYou_one" = "You boosted the group";
"ActionBoostApplyYou_other" = "You boosted the group {count} times";
"ActionBoostApply_one" = "{from} boosted the group";
@ -2040,11 +2045,14 @@
"TitleTime" = "Time";
"TitleSuggestMessage" = "Suggest a Message";
"TitleSuggestedChanges" = "Suggest Changes";
"EnterPriceInStars" = "Enter Price In Stars";
"SuggestMessagePriceDescription" = "Choose how many {currency} to pay to publish this post.";
"SuggestMessageNoPrice" = "Free";
"EnterPriceInStars" = "Enter Price in Stars";
"EnterPriceInTon" = "Enter Price in TON";
"SuggestMessagePriceDescriptionStars" = "Choose how many Stars to pay to publish this post.";
"SuggestMessagePriceDescriptionTon" = "Choose how many TON to pay to publish this post.";
"SuggestMessageDateTimeHint" = "Select the date and time you want the post to be published.";
"SuggestMessageTimeDescription" = "{hint} The post will remain available for at least {duration} from this date.";
"TitleAnytime" = "Anytime";
"SuggestMessageAnytime" = "Anytime";
"ButtonOfferAmount" = "Offer {amount}";
"ButtonOfferFree" = "Offer Free";
"ButtonUpdateTerms" = "Update Terms";
@ -2070,6 +2078,7 @@
"SuggestedPostRefundedByUser" = "{channel} will not receive {amount} because {user} requested a refund.";
"SuggestedPostRefundedByChannel" = "{amount} was returned to {peer} because {channel} deleted the message.";
"CurrencyStars" = "Stars";
"CurrencyTon" = "TON";
"DeclineReasonPlaceholder" = "Add a reason (optional)";
"DeclinePostDialogQuestion" = "Do you want to decline this post from **{sender}**?";
"SuggestedPostRejected" = "**{peer}** rejected this message.";
@ -2126,4 +2135,9 @@
"ToDoListErrorChooseTitle" = "Please enter a title.";
"ToDoListErrorChooseTasks" = "Please enter at least one task.";
"GiftInfoCollectibleBy" = "Collectible #{number} by **{owner}**";
"PremiumPreviewTodo" = "Checklists";
"PremiumPreviewTodo" = "Checklists";
"MenuTon" = "My TON";
"DescriptionAboutTon" = "Offer TON to submit post suggestions to channels on Telegram.";
"ButtonTopUpViaFragment" = "Top-Up Via Fragment";
"TonModalHint" = "You can top-up your TON using Fragment.";
"TonGiftReceived" = "TON Top-Up";

BIN
src/assets/tgs/Diamond.tgs Normal file

Binary file not shown.

View File

@ -156,11 +156,19 @@
font-weight: var(--font-weight-semibold) !important;
line-height: 1;
animation: hide-icon 0.4s forwards ease-out;
&.visible {
animation: grow-icon 0.4s ease-out;
}
&.hiding {
animation: hide-icon 0.4s forwards ease-out;
}
&.hidden {
pointer-events: none;
opacity: 0;
}
.icon {
font-size: 0.875rem;
}

View File

@ -55,6 +55,7 @@ import {
SCHEDULED_WHEN_ONLINE,
SEND_MESSAGE_ACTION_INTERVAL,
SERVICE_NOTIFICATIONS_USER_ID,
STARS_CURRENCY_CODE,
} from '../../config';
import { requestMeasure, requestNextMutation } from '../../lib/fasterdom/fasterdom';
import {
@ -142,6 +143,7 @@ import useGetSelectionRange from '../../hooks/useGetSelectionRange';
import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback';
import useOldLang from '../../hooks/useOldLang';
import usePrevious from '../../hooks/usePrevious';
import usePreviousDeprecated from '../../hooks/usePreviousDeprecated';
import useSchedule from '../../hooks/useSchedule';
import useSendMessageAction from '../../hooks/useSendMessageAction';
@ -1576,7 +1578,7 @@ const Composer: FC<OwnProps & StateProps> = ({
});
const handleSuggestPostClick = useLastCallback(() => {
updateDraftSuggestedPostInfo({
price: { amount: 0, nanos: 0 },
price: { currency: STARS_CURRENCY_CODE, amount: 0, nanos: 0 },
});
});
@ -1874,6 +1876,7 @@ const Composer: FC<OwnProps & StateProps> = ({
const effectEmoji = areEffectsSupported && effect?.emoticon;
const shouldRenderPaidBadge = Boolean(paidMessagesStars && mainButtonState === MainButtonState.Send);
const prevShouldRenderPaidBadge = usePrevious(shouldRenderPaidBadge);
return (
<div className={fullClassName}>
@ -2358,7 +2361,12 @@ const Composer: FC<OwnProps & StateProps> = ({
{isInMessageList && <Icon name="schedule" />}
{isInMessageList && <Icon name="check" />}
<Button
className={buildClassName('paidStarsBadge', shouldRenderPaidBadge && 'visible')}
className={buildClassName(
'paidStarsBadge',
shouldRenderPaidBadge && 'visible',
prevShouldRenderPaidBadge && !shouldRenderPaidBadge && 'hiding',
!prevShouldRenderPaidBadge && !shouldRenderPaidBadge && 'hidden',
)}
nonInteractive
size="tiny"
color="stars"

View File

@ -68,6 +68,11 @@
text-indent: 0rem;
}
.suggested-price-ton-icon {
margin-left: 0rem;
text-indent: 0rem;
}
&--background-icons {
margin: -0.1875rem -0.375rem -0.1875rem -0.1875rem;
}
@ -183,6 +188,11 @@
content: none;
display: none;
}
.suggested-post-price-wrapper {
display: flex;
align-items: center;
}
}
.multiline {

View File

@ -11,7 +11,7 @@ import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import type { ChatTranslatedMessages } from '../../../types';
import type { IconName } from '../../../types/icons';
import { CONTENT_NOT_SUPPORTED } from '../../../config';
import { CONTENT_NOT_SUPPORTED, TON_CURRENCY_CODE } from '../../../config';
import {
getMessageIsSpoiler,
getMessageMediaHash,
@ -26,7 +26,7 @@ import buildClassName from '../../../util/buildClassName';
import { formatScheduledDateTime } from '../../../util/dates/dateFormat';
import { isUserId } from '../../../util/entities/ids';
import freezeWhenClosed from '../../../util/hoc/freezeWhenClosed';
import { formatStarsAsIcon } from '../../../util/localization/format';
import { formatStarsAsIcon, formatTonAsIcon } from '../../../util/localization/format';
import { getPictogramDimensions } from '../helpers/mediaDimensions';
import renderText from '../helpers/renderText';
import { renderTextWithEntities } from '../helpers/renderTextWithEntities';
@ -150,23 +150,34 @@ const EmbeddedMessage: FC<OwnProps> = ({
return lang('ComposerEmbeddedMessageSuggestedPostDescription');
}
const priceText = suggestedPostInfo.price
? formatStarsAsIcon(lang, suggestedPostInfo.price.amount, {
className: 'suggested-price-star-icon',
})
? (suggestedPostInfo.price.currency === TON_CURRENCY_CODE
? formatTonAsIcon(lang, suggestedPostInfo.price.amount, {
className: 'suggested-price-ton-icon',
shouldConvertFromNanos: true,
})
: formatStarsAsIcon(lang, suggestedPostInfo.price.amount, {
className: 'suggested-price-star-icon',
}))
: '';
const scheduleText = suggestedPostInfo.scheduleDate
? formatScheduledDateTime(suggestedPostInfo.scheduleDate, lang, oldLang)
: '';
if (priceText && !scheduleText) {
return lang('TitleSuggestedPostAmountForAnyTime',
{ amount: priceText },
{
withNodes: true,
withMarkdown: true,
});
return (
<span className="suggested-post-price-wrapper">
{
lang('TitleSuggestedPostAmountForAnyTime',
{ amount: priceText },
{
withNodes: true,
withMarkdown: true,
})
}
</span>
);
}
return (
<span>
<span className="suggested-post-price-wrapper">
{priceText}
{scheduleText ? `${scheduleText}` : ''}
</span>

View File

@ -8,6 +8,7 @@ import VoiceAllowTalk from '../../../assets/tgs/calls/VoiceAllowTalk.tgs';
import VoiceMini from '../../../assets/tgs/calls/VoiceMini.tgs';
import VoiceMuted from '../../../assets/tgs/calls/VoiceMuted.tgs';
import VoiceOutlined from '../../../assets/tgs/calls/VoiceOutlined.tgs';
import Diamond from '../../../assets/tgs/Diamond.tgs';
import Flame from '../../../assets/tgs/general/Flame.tgs';
import Fragment from '../../../assets/tgs/general/Fragment.tgs';
import Mention from '../../../assets/tgs/general/Mention.tgs';
@ -68,4 +69,5 @@ export const LOCAL_TGS_URLS = {
Report,
SearchingDuck,
BannedDuck,
Diamond,
};

View File

@ -2,21 +2,23 @@ import type { FC } from '../../../lib/teact/teact';
import { memo, useEffect } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiStarsAmount } from '../../../api/types';
import type { ApiStarsAmount, ApiTonAmount } from '../../../api/types';
import { SettingsScreens } from '../../../types';
import { FAQ_URL, PRIVACY_URL } from '../../../config';
import { FAQ_URL, PRIVACY_URL, TON_CURRENCY_CODE } from '../../../config';
import { formatStarsAmount } from '../../../global/helpers/payments';
import {
selectIsGiveawayGiftsPurchaseAvailable,
selectIsPremiumPurchaseBlocked,
} from '../../../global/selectors';
import { convertCurrencyFromBaseUnit } from '../../../util/formatCurrency';
import useFlag from '../../../hooks/useFlag';
import useHistoryBack from '../../../hooks/useHistoryBack';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import Icon from '../../common/icons/Icon';
import StarIcon from '../../common/icons/StarIcon';
import ChatExtra from '../../common/profile/ChatExtra';
import ProfileInfo from '../../common/ProfileInfo';
@ -34,6 +36,7 @@ type StateProps = {
canBuyPremium?: boolean;
isGiveawayAvailable?: boolean;
starsBalance?: ApiStarsAmount;
tonBalance?: ApiTonAmount;
};
const SettingsMain: FC<OwnProps & StateProps> = ({
@ -43,6 +46,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
canBuyPremium,
isGiveawayAvailable,
starsBalance,
tonBalance,
onReset,
}) => {
const {
@ -192,6 +196,18 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
</span>
)}
</ListItem>
<ListItem
leftElement={<Icon className="icon ListItem-main-icon" name="toncoin" />}
narrow
onClick={() => openStarsBalanceModal({ currency: TON_CURRENCY_CODE })}
>
{lang('MenuTon')}
{Boolean(tonBalance) && (
<span className="settings-item__current-value">
{convertCurrencyFromBaseUnit(tonBalance.amount, tonBalance.currency)}
</span>
)}
</ListItem>
{isGiveawayAvailable && (
<ListItem
icon="gift"
@ -245,6 +261,7 @@ export default memo(withGlobal<OwnProps>(
const { currentUserId } = global;
const isGiveawayAvailable = selectIsGiveawayGiftsPurchaseAvailable(global);
const starsBalance = global.stars?.balance;
const tonBalance = global.ton?.balance;
return {
sessionCount: global.activeSessions.orderedHashes.length,
@ -252,6 +269,7 @@ export default memo(withGlobal<OwnProps>(
canBuyPremium: !selectIsPremiumPurchaseBlocked(global),
isGiveawayAvailable,
starsBalance,
tonBalance,
};
},
)(SettingsMain));

View File

@ -214,6 +214,7 @@ const Main = ({
loadAvailableReactions,
loadStickerSets,
loadPremiumGifts,
loadTonGifts,
loadStarGifts,
loadDefaultTopicIcons,
loadAddedStickers,
@ -347,6 +348,7 @@ const Main = ({
loadUserCollectibleStatuses();
loadGenericEmojiEffects();
loadPremiumGifts();
loadTonGifts();
loadStarGifts();
loadAvailableEffects();
loadBirthdayNumbersStickers();

View File

@ -23,8 +23,9 @@ import { getPeerTitle } from '../../global/helpers/peers';
import { selectChatMessage, selectSender } from '../../global/selectors';
import buildClassName from '../../util/buildClassName';
import { formatHumanDate, formatScheduledDateTime } from '../../util/dates/dateFormat';
import { convertTonFromNanos } from '../../util/formatCurrency';
import { compact } from '../../util/iteratees';
import { formatStarsAsText } from '../../util/localization/format';
import { formatStarsAsText, formatTonAsText } from '../../util/localization/format';
import { isAlbum } from './helpers/groupMessages';
import { preventMessageInputBlur } from './helpers/preventMessageInputBlur';
import { renderPeerLink } from './message/helpers/messageActions';
@ -202,8 +203,14 @@ const MessageListContent: FC<OwnProps> = ({
: lang('ActionSuggestedPostIncoming', { user: userLink }, { withNodes: true, withMarkdown: true });
const tableData: TableEntry[] = compact([
price && [lang('TitlePrice'), formatStarsAsText(lang, price.amount)],
Boolean(scheduleDate) && [lang('TitleTime'), formatScheduledDateTime(scheduleDate, lang, oldLang)],
[lang('TitlePrice'), price ? (price.currency === 'TON'
? formatTonAsText(lang, convertTonFromNanos(price.amount))
: formatStarsAsText(lang, price.amount)) : lang('SuggestMessageNoPrice')],
[lang('TitleTime'),
scheduleDate
? formatScheduledDateTime(scheduleDate, lang, oldLang)
: lang('SuggestMessageAnytime'),
],
]);
return (

View File

@ -48,6 +48,7 @@ import {
selectCurrentMessageList,
selectCurrentMiddleSearch,
selectDraft,
selectEditingId,
selectIsChatBotNotStarted,
selectIsCurrentUserFrozen,
selectIsInSelectMode,
@ -788,6 +789,8 @@ export default memo(withGlobal<OwnProps>(
const chatFullInfo = chatId ? selectChatFullInfo(global, chatId) : undefined;
const userFullInfo = chatId ? selectUserFullInfo(global, chatId) : undefined;
const editingId = selectEditingId(global, chatId, threadId);
const threadInfo = selectThreadInfo(global, chatId, threadId);
const isMessageThread = Boolean(!threadInfo?.isCommentsInfo && threadInfo?.fromChannelId);
const topic = selectTopic(global, chatId, threadId);
@ -814,7 +817,7 @@ export default memo(withGlobal<OwnProps>(
? threadId === MAIN_THREAD_ID && !draftReplyInfo && (selectTopic(global, chatId, GENERAL_TOPIC_ID)?.isClosed)
: false;
const isMonoforumAdmin = selectIsMonoforumAdmin(global, chatId);
const shouldBlockSendInMonoforum = Boolean(chat?.isMonoforum && !draftReplyInfo && isMonoforumAdmin);
const shouldBlockSendInMonoforum = Boolean(chat?.isMonoforum && !draftReplyInfo && isMonoforumAdmin && !editingId);
const topics = selectTopics(global, chatId);
const isSavedDialog = getIsSavedDialog(chatId, threadId, global.currentUserId);

View File

@ -294,6 +294,7 @@ const ActionMessage = ({
break;
}
case 'giftTon':
case 'giftStars': {
openStarsTransactionFromGift({
chatId: message.chatId,
@ -377,6 +378,7 @@ const ActionMessage = ({
);
case 'giftPremium':
case 'giftTon':
case 'giftStars':
return (
<Gift

View File

@ -6,7 +6,9 @@ import type { ApiChat, ApiMessage, ApiPeer } from '../../../api/types';
import {
GENERAL_TOPIC_ID,
SERVICE_NOTIFICATIONS_USER_ID,
STARS_CURRENCY_CODE,
TME_LINK_PREFIX,
TON_CURRENCY_CODE,
} from '../../../config';
import {
getMainUsername,
@ -27,7 +29,8 @@ import {
import { ensureProtocol } from '../../../util/browser/url';
import { formatDateTimeToString, formatScheduledDateTime, formatShortDuration } from '../../../util/dates/dateFormat';
import { formatCurrency } from '../../../util/formatCurrency';
import { formatStarsAsText } from '../../../util/localization/format';
import { convertTonFromNanos } from '../../../util/formatCurrency';
import { formatStarsAsText, formatTonAsText } from '../../../util/localization/format';
import { conjuctionWithNodes } from '../../../util/localization/utils';
import { getServerTime } from '../../../util/serverTime';
import renderText from '../../common/helpers/renderText';
@ -441,15 +444,17 @@ const ActionMessageText = ({
}
case 'giftStars':
case 'giftPremium': {
case 'giftPremium':
case 'giftTon': {
const {
amount, currency, cryptoAmount, cryptoCurrency,
amount, currency, cryptoAmount, cryptoCurrency, type,
} = action;
const price = formatCurrency(lang, amount, currency, { asFontIcon: true });
const cryptoPrice = cryptoAmount ? formatCurrency(lang, cryptoAmount, cryptoCurrency!) : undefined;
const cryptoPrice = cryptoAmount && type !== 'giftTon'
? formatCurrency(lang, cryptoAmount, cryptoCurrency!) : undefined;
const cost = cryptoPrice ? lang('ActionCostCrypto', { price, cryptoPrice }, { withNodes: true }) : price;
const cost = cryptoPrice ? lang('ActionGiftCostCrypto', { price, cryptoPrice }, { withNodes: true }) : price;
if (isServiceNotificationsChat) {
return lang('ActionGiftTextCostAnonymous', { cost }, { withNodes: true });
@ -755,13 +760,21 @@ const ActionMessageText = ({
}
case 'suggestedPostSuccess': {
const { amount: stars } = action;
const { amount: price } = action;
const currency = price?.currency || STARS_CURRENCY_CODE;
const amount = price?.amount || 0;
const channel = chat?.isMonoforum ? selectMonoforumChannel(global, chatId) : chat;
const channelTitle = channel && getPeerTitle(lang, channel);
const channelLink = renderPeerLink(channel?.id, channelTitle || channelFallbackText, asPreview);
const formattedAmount = currency === TON_CURRENCY_CODE
? formatTonAsText(lang, convertTonFromNanos(amount))
: formatStarsAsText(lang, amount);
return lang('ActionSuggestedPostSuccess', {
channel: channelLink,
amount: formatStarsAsText(lang, stars?.amount || 0),
amount: formattedAmount,
}, { withNodes: true });
}
case 'suggestedPostRefund': {
@ -775,22 +788,28 @@ const ActionMessageText = ({
const postSenderTitle = postSender && getPeerTitle(lang, postSender);
const postSenderLink = renderPeerLink(postSender?.id, postSenderTitle || userFallbackText, asPreview);
const starsAmount = replyMessage?.suggestedPostInfo?.price?.amount || 0;
const price = replyMessage?.suggestedPostInfo?.price;
const currency = price?.currency || STARS_CURRENCY_CODE;
const amount = price?.amount || 0;
const channel = chat?.isMonoforum ? selectMonoforumChannel(global, chatId) : chat;
const channelTitle = channel && getPeerTitle(lang, channel);
const channelLink = renderPeerLink(channel?.id, channelTitle || channelFallbackText, asPreview);
const formattedAmount = currency === TON_CURRENCY_CODE
? formatTonAsText(lang, convertTonFromNanos(amount))
: formatStarsAsText(lang, amount);
if (payerInitiated) {
return lang('SuggestedPostRefundedByUser', {
amount: formatStarsAsText(lang, starsAmount),
amount: formattedAmount,
user: postSenderLink,
channel: channelLink,
}, { withNodes: true, withMarkdown: true });
}
return lang('SuggestedPostRefundedByChannel', {
amount: formatStarsAsText(lang, starsAmount),
amount: formattedAmount,
peer: postSenderLink,
channel: channelLink,
}, { withNodes: true, withMarkdown: true });
@ -819,9 +838,13 @@ const ActionMessageText = ({
const replyMessageSender = replyMessage ? selectSender(global, replyMessage) : sender;
const replyPeerTitle = replyMessageSender && getPeerTitle(lang, replyMessageSender);
const userLink = renderPeerLink(replyMessageSender?.id, replyPeerTitle || userFallbackText, asPreview);
const currency = replyMessage?.suggestedPostInfo?.price?.currency || STARS_CURRENCY_CODE;
const currencyName = currency === TON_CURRENCY_CODE ? lang('CurrencyTon') : lang('CurrencyStars');
return lang('SuggestedPostBalanceTooLow', {
peer: userLink,
currency: lang('CurrencyStars'),
currency: currencyName,
}, { withNodes: true, withMarkdown: true });
}

View File

@ -2,13 +2,18 @@ import { memo, useRef } from '../../../../lib/teact/teact';
import { withGlobal } from '../../../../global';
import type { ApiSticker } from '../../../../api/types';
import type { ApiMessageActionGiftPremium, ApiMessageActionGiftStars } from '../../../../api/types/messageActions';
import type {
ApiMessageActionGiftPremium,
ApiMessageActionGiftStars,
ApiMessageActionGiftTon } from '../../../../api/types/messageActions';
import {
selectCanPlayAnimatedEmojis,
selectGiftStickerForDuration,
selectGiftStickerForStars,
selectGiftStickerForTon,
} from '../../../../global/selectors';
import { formatCurrency } from '../../../../util/formatCurrency';
import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities';
import { type ObserveFn } from '../../../../hooks/useIntersectionObserver';
@ -20,7 +25,7 @@ import StickerView from '../../../common/StickerView';
import styles from '../ActionMessage.module.scss';
type OwnProps = {
action: ApiMessageActionGiftPremium | ApiMessageActionGiftStars;
action: ApiMessageActionGiftPremium | ApiMessageActionGiftStars | ApiMessageActionGiftTon;
observeIntersectionForLoading?: ObserveFn;
observeIntersectionForPlaying?: ObserveFn;
onClick?: NoneToVoidFunction;
@ -45,6 +50,15 @@ const GiftAction = ({
const lang = useLang();
const message = action.type === 'giftPremium' ? action.message : undefined;
const renderTonTitle = () => {
const { cryptoAmount, cryptoCurrency } = action;
const price = cryptoAmount
? formatCurrency(lang, cryptoAmount, cryptoCurrency!, { asFontIcon: true })
: undefined;
return price;
};
return (
<div className={styles.contentBox} tabIndex={0} role="button" onClick={onClick}>
<div
@ -67,13 +81,16 @@ const GiftAction = ({
<h3 className={styles.title}>
{action.type === 'giftPremium' ? (
lang('ActionGiftPremiumTitle', { months: action.months }, { pluralValue: action.months })
) : (
) : action.type === 'giftStars' ? (
lang('ActionGiftStarsTitle', { amount: action.stars }, { pluralValue: action.stars })
)}
) : renderTonTitle()}
</h3>
<div>
{message && renderTextWithEntities(message)}
{!message && (lang(action.type === 'giftPremium' ? 'ActionGiftPremiumText' : 'ActionGiftStarsText'))}
{!message
&& (lang(action.type === 'giftTon' ? 'DescriptionAboutTon'
: action.type === 'giftPremium'
? 'ActionGiftPremiumText' : 'ActionGiftStarsText'))}
</div>
</div>
<div className={styles.actionButton}>
@ -88,7 +105,9 @@ export default memo(withGlobal<OwnProps>(
(global, { action }): StateProps => {
const sticker = action.type === 'giftPremium'
? selectGiftStickerForDuration(global, action.months)
: selectGiftStickerForStars(global, action.stars);
: action.type === 'giftStars'
? selectGiftStickerForStars(global, action.stars)
: selectGiftStickerForTon(global, action.cryptoAmount);
const canPlayAnimatedEmojis = selectCanPlayAnimatedEmojis(global);
return {

View File

@ -4,7 +4,7 @@ import { withGlobal } from '../../../../global';
import type { ApiMessage, ApiPeer } from '../../../../api/types';
import type { ApiMessageActionSuggestedPostApproval } from '../../../../api/types/messageActions';
import { STARS_SUGGESTED_POST_AGE_MIN } from '../../../../config';
import { STARS_SUGGESTED_POST_AGE_MIN, TON_CURRENCY_CODE } from '../../../../config';
import { getPeerFullTitle } from '../../../../global/helpers/peers';
import { getMessageReplyInfo } from '../../../../global/helpers/replies';
import { selectIsMonoforumAdmin, selectMonoforumChannel,
@ -12,7 +12,8 @@ import { selectIsMonoforumAdmin, selectMonoforumChannel,
selectSender } from '../../../../global/selectors';
import buildClassName from '../../../../util/buildClassName';
import { formatScheduledDateTime, formatShortDuration } from '../../../../util/dates/dateFormat';
import { formatStarsAsText } from '../../../../util/localization/format';
import { convertTonFromNanos } from '../../../../util/formatCurrency';
import { formatStarsAsText, formatTonAsText } from '../../../../util/localization/format';
import { getServerTime } from '../../../../util/serverTime';
import renderText from '../../../common/helpers/renderText';
import { renderPeerLink, translateWithYou } from '../helpers/messageActions';
@ -58,11 +59,18 @@ const SuggestedPostApproval = ({
const publishDate = scheduleDate
? formatScheduledDateTime(scheduleDate, lang, oldLang)
: lang('TitleAnytime');
: lang('SuggestMessageAnytime');
const isPostPublished = scheduleDate ? scheduleDate <= getServerTime() : false;
const starsText = amount?.amount ? formatStarsAsText(lang, amount.amount) : undefined;
const currency = amount?.currency;
const amountValue = amount?.amount || 0;
const formattedAmount = amountValue > 0
? (currency === TON_CURRENCY_CODE
? formatTonAsText(lang, convertTonFromNanos(amountValue))
: formatStarsAsText(lang, amountValue))
: undefined;
const duration = formatShortDuration(lang, ageMinSeconds, true);
@ -85,31 +93,35 @@ const SuggestedPostApproval = ({
)}
</div>
{starsText && (
{formattedAmount && (
<div className={styles.suggestedPostApprovalSection}>
{translateWithYou(lang,
'SuggestedPostCharged',
!isAdmin,
{
user: originalSenderLink,
amount: starsText,
amount: formattedAmount,
},
{ withMarkdown: true },
)}
</div>
)}
{isPostPublished && starsText && (
{isPostPublished && formattedAmount && (
<>
<div className={styles.suggestedPostApprovalSection}>
{translateWithYou(lang, 'SuggestedPostReceiveAmount', !isAdmin, {
peer: renderChatLink(), duration, currency: lang('CurrencyStars'),
peer: renderChatLink(),
duration,
currency: currency === TON_CURRENCY_CODE ? lang('CurrencyTon') : lang('CurrencyStars'),
}, { withMarkdown: true })}
</div>
<div className={styles.suggestedPostApprovalSection}>
{translateWithYou(lang, 'SuggestedPostRefund', !isAdmin, {
peer: renderChatLink(), duration, currency: lang('CurrencyStars'),
peer: renderChatLink(),
duration,
currency: currency === TON_CURRENCY_CODE ? lang('CurrencyTon') : lang('CurrencyStars'),
}, { withMarkdown: true })}
</div>
</>

View File

@ -4,6 +4,7 @@ import { getActions, withGlobal } from '../../../../global';
import type { ApiMessage, ApiPeer } from '../../../../api/types';
import type { ApiMessageActionSuggestedPostApproval } from '../../../../api/types/messageActions';
import { STARS_CURRENCY_CODE, TON_CURRENCY_CODE } from '../../../../config';
import { getPeerFullTitle } from '../../../../global/helpers/peers';
import { selectChatMessage, selectSender } from '../../../../global/selectors';
import buildClassName from '../../../../util/buildClassName';
@ -25,6 +26,7 @@ type OwnProps = {
type StateProps = {
sender?: ApiPeer;
replyMessageSender?: ApiPeer;
replyMessage?: ApiMessage;
};
const SuggestedPostBalanceTooLow = ({
@ -32,6 +34,7 @@ const SuggestedPostBalanceTooLow = ({
message,
sender,
replyMessageSender,
replyMessage,
}: OwnProps & StateProps) => {
const { openStarsBalanceModal } = getActions();
const lang = useLang();
@ -46,6 +49,10 @@ const SuggestedPostBalanceTooLow = ({
const peerTitle = targetPeer && getPeerFullTitle(lang, targetPeer);
const peerLink = renderPeerLink(targetPeer?.id, peerTitle || lang('ActionFallbackUser'));
const currency = replyMessage?.suggestedPostInfo?.price?.currency || STARS_CURRENCY_CODE;
const currencyName = currency === TON_CURRENCY_CODE ? lang('CurrencyTon') : lang('CurrencyStars');
const buyButtonText = currency === TON_CURRENCY_CODE ? lang('ButtonTopUpViaFragment') : lang('ButtonBuyStars');
return (
<div
className={buildClassName(styles.contentBox, styles.suggestedPostBalanceTooLowBox)}
@ -54,14 +61,14 @@ const SuggestedPostBalanceTooLow = ({
<div className={styles.suggestedPostBalanceTooLowTitle}>
{lang('SuggestedPostBalanceTooLow', {
peer: peerLink,
currency: lang('CurrencyStars'),
currency: currencyName,
}, { withNodes: true, withMarkdown: true })}
</div>
{!message.isOutgoing && (
<div className={styles.actionButton} onClick={handleGetMoreStars}>
<Sparkles preset="button" />
{lang('ButtonBuyStars')}
{buyButtonText}
</div>
)}
</div>
@ -81,6 +88,7 @@ export default memo(withGlobal<OwnProps>(
return {
sender,
replyMessageSender,
replyMessage,
};
},
)(SuggestedPostBalanceTooLow));

View File

@ -124,7 +124,7 @@ const GiftModal: FC<OwnProps & StateProps> = ({
const allGifts = renderingModal?.gifts;
const filteredGifts = useMemo(() => {
return allGifts?.sort((prevGift, gift) => prevGift.months - gift.months)
.filter((gift) => gift.users === 1 && gift.currency !== 'XTR');
.filter((gift) => gift.users === 1 && gift.currency !== STARS_CURRENCY_CODE);
}, [allGifts]);
const giftsByStars = useMemo(() => {

View File

@ -5,7 +5,7 @@ import { getActions } from '../../../global';
import type {
ApiPeer,
ApiStarGiftAttributeBackdrop, ApiStarGiftAttributeModel, ApiStarGiftAttributePattern,
ApiStarsAmount } from '../../../api/types';
ApiTypeCurrencyAmount } from '../../../api/types';
import {
formatStarsTransactionAmount,
@ -31,7 +31,7 @@ type OwnProps = {
subtitle?: TeactNode;
subtitlePeer?: ApiPeer;
className?: string;
resellPrice?: ApiStarsAmount;
resellPrice?: ApiTypeCurrencyAmount;
};
const STICKER_SIZE = 120;

View File

@ -1,10 +1,12 @@
import { memo } from '../../../lib/teact/teact';
import { getActions } from '../../../global';
import type { ApiStarsAmount } from '../../../api/types';
import type { ApiTypeCurrencyAmount } from '../../../api/types';
import { STARS_CURRENCY_CODE, TON_CURRENCY_CODE } from '../../../config';
import { formatStarsAmount } from '../../../global/helpers/payments';
import buildClassName from '../../../util/buildClassName';
import { convertCurrencyFromBaseUnit } from '../../../util/formatCurrency';
import useLang from '../../../hooks/useLang';
@ -15,7 +17,7 @@ import StarIcon from '../../common/icons/StarIcon';
import styles from './StarsBalanceModal.module.scss';
type OwnProps = {
balance?: ApiStarsAmount;
balance?: ApiTypeCurrencyAmount;
withAddButton?: boolean;
className?: string;
};
@ -27,25 +29,42 @@ const BalanceBlock = ({ balance, className, withAddButton }: OwnProps) => {
openStarsBalanceModal,
} = getActions();
const renderStarsAmount = () => {
return (
<>
<StarIcon type="gold" size="middle" />
{balance !== undefined && balance.currency === STARS_CURRENCY_CODE
? formatStarsAmount(lang, balance) : '…'}
{withAddButton && (
<BadgeButton
className={styles.addStarsButton}
onClick={() => openStarsBalanceModal({})}
>
<Icon
className={styles.addStarsIcon}
name="add"
/>
</BadgeButton>
)}
</>
);
};
const renderTonAmount = () => {
return (
<>
<Icon name="toncoin" />
{balance !== undefined ? convertCurrencyFromBaseUnit(balance.amount, balance.currency) : '…'}
</>
);
};
return (
<div className={buildClassName(styles.balanceBlock, className)}>
<div className={styles.balanceInfo}>
<span className={styles.smallerText}>{lang('StarsBalance')}</span>
<div className={styles.balanceBottom}>
<StarIcon type="gold" size="middle" />
{balance !== undefined ? formatStarsAmount(lang, balance) : '…'}
{withAddButton && (
<BadgeButton
className={styles.addStarsButton}
onClick={() => openStarsBalanceModal({})}
>
<Icon
className={styles.addStarsIcon}
name="add"
/>
</BadgeButton>
)}
{balance?.currency === TON_CURRENCY_CODE ? renderTonAmount() : renderStarsAmount()}
</div>
</div>
</div>

View File

@ -56,6 +56,7 @@
color: var(--color-primary);
}
.hint,
.tos {
padding: 0.5rem 1rem;
padding-top: 0;
@ -73,6 +74,37 @@
margin: 1rem;
}
.topUpButton,
.tonBalanceContainer {
margin-bottom: 0.5rem;
}
.tonBalance {
unicode-bidi: plaintext;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.75rem;
font-weight: var(--font-weight-medium);
}
.tonIconBalance {
color: var(--color-primary);
}
.tonInUsd {
font-size: 1rem;
color: var(--color-text-secondary);
}
.tonIconLogo {
padding-top: 1.5rem;
padding-bottom: 1rem;
font-size: 5rem;
color: var(--color-primary);
}
.logoBackground {
position: absolute;
top: 0.75rem;
@ -196,6 +228,7 @@
right: 1.25rem;
}
.topUpButton,
.starButton {
grid-column: 1/-1;
gap: 0.5rem;

View File

@ -8,11 +8,19 @@ import type { ApiStarTopupOption } from '../../../api/types';
import type { GlobalState, TabState } from '../../../global/types';
import type { RegularLangKey } from '../../../types/language';
import { PAID_MESSAGES_PURPOSE } from '../../../config';
import {
PAID_MESSAGES_PURPOSE,
STARS_CURRENCY_CODE,
TON_CURRENCY_CODE,
TON_TOPUP_URL_DEFAULT,
TON_USD_RATE_DEFAULT,
} from '../../../config';
import { getChatTitle, getUserFullName } from '../../../global/helpers';
import { getPeerTitle } from '../../../global/helpers/peers';
import { selectChat, selectIsPremiumPurchaseBlocked, selectUser } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { convertCurrencyFromBaseUnit, convertTonToUsd, formatCurrencyAsString } from '../../../util/formatCurrency';
import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets';
import renderText from '../../common/helpers/renderText';
import useFlag from '../../../hooks/useFlag';
@ -20,6 +28,7 @@ import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
import AnimatedIconWithPreview from '../../common/AnimatedIconWithPreview';
import Icon from '../../common/icons/Icon';
import SafeLink from '../../common/SafeLink';
import Button from '../../ui/Button';
@ -52,18 +61,25 @@ export type OwnProps = {
type StateProps = {
starsBalanceState?: GlobalState['stars'];
tonBalanceState?: GlobalState['ton'];
canBuyPremium?: boolean;
shouldForceHeight?: boolean;
tonUsdRate?: number;
tonTopupUrl: string;
};
const StarsBalanceModal = ({
modal, starsBalanceState, canBuyPremium, shouldForceHeight,
modal, starsBalanceState, tonBalanceState, canBuyPremium, shouldForceHeight, tonUsdRate, tonTopupUrl,
}: OwnProps & StateProps) => {
const {
closeStarsBalanceModal, loadStarsTransactions, loadStarsSubscriptions, openStarsGiftingPickerModal, openInvoice,
openUrl,
} = getActions();
const { balance, history, subscriptions } = starsBalanceState || {};
const currency = modal?.currency || STARS_CURRENCY_CODE;
const currentState = currency === TON_CURRENCY_CODE ? tonBalanceState : starsBalanceState;
const { balance, history } = currentState || {};
const { subscriptions } = starsBalanceState || {};
const oldLang = useOldLang();
const lang = useLang();
@ -72,7 +88,7 @@ const StarsBalanceModal = ({
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const [areBuyOptionsShown, showBuyOptions, hideBuyOptions] = useFlag();
const isOpen = Boolean(modal && starsBalanceState);
const isOpen = Boolean(modal && (starsBalanceState || tonBalanceState));
const {
originStarsPayment, originReaction, originGift, topup,
@ -159,6 +175,90 @@ const StarsBalanceModal = ({
];
}, [isOpen, oldLang]);
const renderStarsSection = () => {
return (
<>
<img className={styles.logo} src={StarLogo} alt="" draggable={false} />
<img className={styles.logoBackground} src={StarsBackground} alt="" draggable={false} />
<h2 className={styles.headerText}>
{starsNeeded ? oldLang('StarsNeededTitle', ongoingTransactionAmount) : oldLang('TelegramStars')}
</h2>
<div className={styles.description}>
{renderText(
starsNeededText || oldLang('TelegramStarsInfo'),
['simple_markdown', 'emoji'],
)}
</div>
{canBuyPremium && !areBuyOptionsShown && (
<Button
className={styles.starButton}
onClick={showBuyOptions}
>
{oldLang('Star.List.BuyMoreStars')}
</Button>
)}
{canBuyPremium && !areBuyOptionsShown && shouldSuggestGifting && (
<Button
isText
noForcedUpperCase
className={styles.starButton}
onClick={openStarsGiftingPickerModalHandler}
>
{oldLang('TelegramStarsGift')}
</Button>
)}
{areBuyOptionsShown && starsBalanceState?.topupOptions && (
<StarTopupOptionList
starsNeeded={starsNeeded}
options={starsBalanceState.topupOptions}
onClick={handleBuyStars}
/>
)}
</>
);
};
const renderTonSection = () => {
const tonAmount = convertCurrencyFromBaseUnit(balance?.amount || 0, TON_CURRENCY_CODE);
return (
<>
<AnimatedIconWithPreview
size={160}
tgsUrl={LOCAL_TGS_URLS.Diamond}
nonInteractive
noLoop={false}
/>
<h2 className={styles.headerText}>
{lang('CurrencyTon')}
</h2>
<div className={styles.description}>
{lang('DescriptionAboutTon')}
</div>
<div className={styles.tonBalanceContainer}>
<div className={styles.tonBalance}>
<Icon name="toncoin" className={styles.tonIconBalance} />
{tonAmount}
</div>
{Boolean(tonUsdRate) && (
<span className={styles.tonInUsd}>
{`${formatCurrencyAsString(
convertTonToUsd(balance?.amount || 0, tonUsdRate),
'USD',
lang.code,
)}`}
</span>
)}
</div>
<Button
className={styles.topUpButton}
onClick={handleTonTopUp}
>
{lang('ButtonTopUpViaFragment')}
</Button>
</>
);
};
function handleScroll(e: React.UIEvent<HTMLDivElement>) {
const { scrollTop } = e.currentTarget;
@ -168,6 +268,7 @@ const StarsBalanceModal = ({
const handleLoadMoreTransactions = useLastCallback(() => {
loadStarsTransactions({
type: TRANSACTION_TYPES[selectedTabIndex],
isTon: currency === TON_CURRENCY_CODE,
});
});
@ -188,6 +289,10 @@ const StarsBalanceModal = ({
});
});
const handleTonTopUp = useLastCallback(() => {
openUrl({ url: tonTopupUrl });
});
return (
<Modal
className={buildClassName(styles.root, !shouldForceHeight && !areBuyOptionsShown && styles.minimal)}
@ -206,55 +311,25 @@ const StarsBalanceModal = ({
>
<Icon name="close" />
</Button>
<BalanceBlock balance={balance} className={styles.modalBalance} />
{currency !== TON_CURRENCY_CODE && <BalanceBlock balance={balance} className={styles.modalBalance} />}
<div className={buildClassName(styles.header, isHeaderHidden && styles.hiddenHeader)}>
<h2 className={styles.starHeaderText}>
{oldLang('TelegramStars')}
</h2>
</div>
<div className={styles.section}>
<img className={styles.logo} src={StarLogo} alt="" draggable={false} />
<img className={styles.logoBackground} src={StarsBackground} alt="" draggable={false} />
<h2 className={styles.headerText}>
{starsNeeded ? oldLang('StarsNeededTitle', ongoingTransactionAmount) : oldLang('TelegramStars')}
</h2>
<div className={styles.description}>
{renderText(
starsNeededText || oldLang('TelegramStarsInfo'),
['simple_markdown', 'emoji'],
)}
</div>
{canBuyPremium && !areBuyOptionsShown && (
<Button
className={styles.starButton}
onClick={showBuyOptions}
>
{oldLang('Star.List.BuyMoreStars')}
</Button>
)}
{canBuyPremium && !areBuyOptionsShown && shouldSuggestGifting && (
<Button
isText
noForcedUpperCase
className={styles.starButton}
onClick={openStarsGiftingPickerModalHandler}
>
{oldLang('TelegramStarsGift')}
</Button>
)}
{areBuyOptionsShown && starsBalanceState?.topupOptions && (
<StarTopupOptionList
starsNeeded={starsNeeded}
options={starsBalanceState.topupOptions}
onClick={handleBuyStars}
/>
)}
{currency === TON_CURRENCY_CODE ? renderTonSection() : renderStarsSection()}
</div>
{areBuyOptionsShown && (
<div className={styles.tos}>
{tosText}
</div>
)}
{currency === TON_CURRENCY_CODE && (
<div className={styles.hint}>
{lang('TonModalHint')}
</div>
)}
{shouldShowItems && Boolean(subscriptions?.list.length) && (
<div className={styles.section}>
<h3 className={styles.sectionTitle}>{oldLang('StarMySubscriptions')}</h3>
@ -325,13 +400,18 @@ const StarsBalanceModal = ({
};
export default memo(withGlobal<OwnProps>(
(global): StateProps => {
const shouldForceHeight = Boolean(global.stars?.history?.all?.transactions.length);
(global, { modal }): StateProps => {
const shouldForceHeight = modal?.currency === TON_CURRENCY_CODE
? Boolean(global.ton?.history?.all?.transactions.length)
: Boolean(global.stars?.history?.all?.transactions.length);
return {
shouldForceHeight,
starsBalanceState: global.stars,
tonBalanceState: global.ton,
canBuyPremium: !selectIsPremiumPurchaseBlocked(global),
tonUsdRate: global.appConfig?.tonUsdRate || TON_USD_RATE_DEFAULT,
tonTopupUrl: global.appConfig?.tonTopupUrl || TON_TOPUP_URL_DEFAULT,
};
},
)(StarsBalanceModal));

View File

@ -1,6 +1,7 @@
import type { ApiStarsAmount, ApiStarsTransaction } from '../../../../api/types';
import type { ApiStarsAmount, ApiStarsTransaction, ApiTypeCurrencyAmount } from '../../../../api/types';
import type { OldLangFn } from '../../../../hooks/useOldLang';
import { STARS_CURRENCY_CODE, TON_CURRENCY_CODE } from '../../../../config';
import { buildStarsTransactionCustomPeer } from '../../../../global/helpers/payments';
import {
type LangFn,
@ -20,7 +21,7 @@ export function getTransactionTitle(oldLang: OldLangFn, lang: LangFn, transactio
}
if (transaction.isGiftResale) {
return isNegativeStarsAmount(transaction.stars)
return isNegativeAmount(transaction.amount)
? lang('StarGiftSaleTransaction')
: lang('StarGiftPurchaseTransaction');
}
@ -34,9 +35,14 @@ export function getTransactionTitle(oldLang: OldLangFn, lang: LangFn, transactio
if (transaction.isReaction) return oldLang('StarsReactionsSent');
if (transaction.giveawayPostId) return oldLang('StarsGiveawayPrizeReceived');
if (transaction.isMyGift) return oldLang('StarsGiftSent');
if (transaction.isGift) return oldLang('StarsGiftReceived');
if (transaction.isGift) {
if (transaction.amount.currency === TON_CURRENCY_CODE) {
return lang('TonGiftReceived');
}
return oldLang('StarsGiftReceived');
}
if (transaction.starGift) {
return isNegativeStarsAmount(transaction.stars) ? oldLang('Gift2TransactionSent') : oldLang('Gift2ConvertedTitle');
return isNegativeAmount(transaction.amount) ? oldLang('Gift2TransactionSent') : oldLang('Gift2ConvertedTitle');
}
const customPeer = (transaction.peer && transaction.peer.type !== 'peer'
@ -51,3 +57,10 @@ export function isNegativeStarsAmount(starsAmount: ApiStarsAmount) {
if (starsAmount.amount) return starsAmount.amount < 0;
return starsAmount.nanos < 0;
}
export function isNegativeAmount(currencyAmount: ApiTypeCurrencyAmount) {
if (currencyAmount.currency === STARS_CURRENCY_CODE) {
return isNegativeStarsAmount(currencyAmount);
}
return currencyAmount.amount < 0;
}

View File

@ -8,6 +8,7 @@ import type {
import type { GlobalState } from '../../../../global/types';
import type { CustomPeer } from '../../../../types';
import { STARS_CURRENCY_CODE, TON_CURRENCY_CODE } from '../../../../config';
import { buildStarsTransactionCustomPeer, formatStarsTransactionAmount } from '../../../../global/helpers/payments';
import { getPeerTitle } from '../../../../global/helpers/peers';
import { selectPeer } from '../../../../global/selectors';
@ -16,7 +17,7 @@ import { formatDateTimeToString } from '../../../../util/dates/dateFormat';
import { CUSTOM_PEER_PREMIUM } from '../../../../util/objects/customPeer';
import { getGiftAttributes, getStickerFromGift } from '../../../common/helpers/gifts';
import renderText from '../../../common/helpers/renderText';
import { getTransactionTitle, isNegativeStarsAmount } from '../helpers/transaction';
import { getTransactionTitle, isNegativeAmount } from '../helpers/transaction';
import useSelector from '../../../../hooks/data/useSelector';
import useLang from '../../../../hooks/useLang';
@ -48,7 +49,7 @@ const StarsTransactionItem = ({ transaction, className }: OwnProps) => {
const { openStarsTransactionModal } = getActions();
const {
date,
stars,
amount,
photo,
peer: transactionPeer,
extendedMedia,
@ -73,7 +74,10 @@ const StarsTransactionItem = ({ transaction, className }: OwnProps) => {
description = peer && getPeerTitle(oldLang, peer);
avatarPeer = peer || CUSTOM_PEER_PREMIUM;
} else {
const customPeer = buildStarsTransactionCustomPeer(transaction.peer);
const customPeer = buildStarsTransactionCustomPeer(
transaction.peer,
transaction.amount.currency === TON_CURRENCY_CODE,
);
title = customPeer.title || oldLang(customPeer.titleKey!);
description = oldLang(customPeer.subtitleKey!);
avatarPeer = customPeer;
@ -178,11 +182,11 @@ const StarsTransactionItem = ({ transaction, className }: OwnProps) => {
</div>
<div className={styles.stars}>
<span
className={buildClassName(styles.amount, isNegativeStarsAmount(stars) ? styles.negative : styles.positive)}
className={buildClassName(styles.amount, isNegativeAmount(amount) ? styles.negative : styles.positive)}
>
{formatStarsTransactionAmount(lang, stars)}
{formatStarsTransactionAmount(lang, amount)}
</span>
<StarIcon className={styles.star} type="gold" size="adaptive" />
{amount.currency === STARS_CURRENCY_CODE && <StarIcon className={styles.star} type="gold" size="adaptive" />}
</div>
</div>
);

View File

@ -9,6 +9,7 @@ import type {
import type { TabState } from '../../../../global/types';
import { MediaViewerOrigin } from '../../../../types';
import { STARS_CURRENCY_CODE } from '../../../../config';
import { getMessageLink } from '../../../../global/helpers';
import {
buildStarsTransactionCustomPeer,
@ -17,6 +18,7 @@ import {
import {
selectCanPlayAnimatedEmojis,
selectGiftStickerForStars,
selectGiftStickerForTon,
selectPeer,
} from '../../../../global/selectors';
import buildClassName from '../../../../util/buildClassName';
@ -25,7 +27,7 @@ import { formatDateTimeToString } from '../../../../util/dates/dateFormat';
import { formatStarsAsIcon } from '../../../../util/localization/format';
import { formatPercent } from '../../../../util/textFormat';
import { getGiftAttributes, getStickerFromGift } from '../../../common/helpers/gifts';
import { getTransactionTitle, isNegativeStarsAmount } from '../helpers/transaction';
import { getTransactionTitle, isNegativeAmount } from '../helpers/transaction';
import useLang from '../../../../hooks/useLang';
import useLastCallback from '../../../../hooks/useLastCallback';
@ -85,7 +87,7 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
}
const {
giveawayPostId, photo, stars, isGiftUpgrade, starGift, isGiftResale,
giveawayPostId, photo, amount, isGiftUpgrade, starGift, isGiftResale,
starRefCommision,
} = transaction;
@ -132,7 +134,7 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
modelAttribute={giftAttributes!.model!}
title={gift.title}
subtitle={lang('GiftInfoCollectible', { number: gift.number })}
resellPrice={transaction.stars}
resellPrice={transaction.amount}
/>
</div>
);
@ -169,11 +171,11 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
<p className={styles.description}>{description}</p>
<p className={styles.amount}>
<span
className={buildClassName(styles.amount, isNegativeStarsAmount(stars) ? styles.negative : styles.positive)}
className={buildClassName(styles.amount, isNegativeAmount(amount) ? styles.negative : styles.positive)}
>
{formatStarsTransactionAmount(lang, stars)}
{formatStarsTransactionAmount(lang, amount)}
</span>
<StarIcon type="gold" size="middle" />
{amount.currency === STARS_CURRENCY_CODE && <StarIcon type="gold" size="middle" />}
{transaction.isRefund && (
<p className={styles.refunded}>{lang('Refunded')}</p>
)}
@ -212,7 +214,7 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
if (isGiftResale) {
tableData.push([
oldLang('StarGiftReason'),
isNegativeStarsAmount(transaction.stars)
isNegativeAmount(transaction.amount)
? lang('StarGiftSaleTransaction')
: lang('StarGiftPurchaseTransaction'),
]);
@ -221,7 +223,7 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
let peerLabel;
if (isGiftUpgrade) {
peerLabel = oldLang('Stars.Transaction.GiftFrom');
} else if (isNegativeStarsAmount(stars) || transaction.isMyGift) {
} else if (isNegativeAmount(amount) || transaction.isMyGift) {
peerLabel = oldLang('Stars.Transaction.To');
} else if (transaction.starRefCommision && !transaction.paidMessages && !isGiftResale) {
peerLabel = oldLang('StarsTransaction.StarRefReason.Miniapp');
@ -240,7 +242,7 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
tableData.push([
lang('PaidMessageTransactionTotal'),
formatStarsAsIcon(lang,
transaction.stars.amount / ((100 - transaction.starRefCommision) / 100),
transaction.amount.amount / ((100 - transaction.starRefCommision) / 100),
{ asFont: false, className: styles.starIcon, containerClassName: styles.totalStars }),
]);
}
@ -249,9 +251,9 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
tableData.push([oldLang('Stars.Transaction.Reaction.Post'), <SafeLink url={messageLink} text={messageLink} />]);
}
if (giveawayMessageLink) {
if (giveawayMessageLink && transaction.amount.currency === STARS_CURRENCY_CODE) {
tableData.push([oldLang('BoostReason'), <SafeLink url={giveawayMessageLink} text={oldLang('Giveaway')} />]);
tableData.push([oldLang('Gift'), oldLang('Stars', transaction.stars, 'i')]);
tableData.push([oldLang('Gift'), oldLang('Stars', transaction.amount, 'i')]);
}
if (transaction.id) {
@ -322,8 +324,10 @@ export default memo(withGlobal<OwnProps>(
const peer = peerId ? selectPeer(global, peerId) : undefined;
const paidMessageCommission = global.appConfig?.starsPaidMessageCommissionPermille;
const starCount = modal?.transaction.stars;
const starsGiftSticker = modal?.transaction.isGift && selectGiftStickerForStars(global, starCount?.amount);
const currencyAmount = modal?.transaction.amount;
const starsGiftSticker = modal?.transaction.isGift
&& currencyAmount?.currency === STARS_CURRENCY_CODE ? selectGiftStickerForStars(global, currencyAmount?.amount)
: selectGiftStickerForTon(global, currencyAmount?.amount);
return {
peer,

View File

@ -53,3 +53,14 @@
.offerButton {
font-weight: var(--font-weight-medium);
}
.currencySelector {
display: flex;
gap: 0.5rem;
justify-content: center;
margin-bottom: 1.25rem;
}
.currencyIcon {
margin-inline-end: 0.25rem;
}

View File

@ -4,22 +4,31 @@ import {
useState } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiDraft, ApiStarsAmount } from '../../../api/types';
import type { ApiDraft, ApiStarsAmount, ApiTypeCurrencyAmount } from '../../../api/types';
import type { ApiPeer } from '../../../api/types';
import type { TabState } from '../../../global/types';
import { MAIN_THREAD_ID } from '../../../api/types';
import {
STARS_CURRENCY_CODE,
STARS_SUGGESTED_POST_AGE_MIN,
STARS_SUGGESTED_POST_AMOUNT_MAX,
STARS_SUGGESTED_POST_AMOUNT_MIN,
STARS_SUGGESTED_POST_FUTURE_MAX,
STARS_SUGGESTED_POST_FUTURE_MIN } from '../../../config';
import { selectPeer } from '../../../global/selectors';
STARS_SUGGESTED_POST_FUTURE_MIN,
TON_CURRENCY_CODE,
TON_SUGGESTED_POST_AMOUNT_MAX,
TON_SUGGESTED_POST_AMOUNT_MIN } from '../../../config';
import { selectIsMonoforumAdmin, selectPeer } from '../../../global/selectors';
import { selectDraft } from '../../../global/selectors/messages';
import buildClassName from '../../../util/buildClassName';
import { formatScheduledDateTime, formatShortDuration } from '../../../util/dates/dateFormat';
import { formatStarsAsIcon, formatStarsAsText } from '../../../util/localization/format';
import { convertTonFromNanos, convertTonToNanos } from '../../../util/formatCurrency';
import {
formatStarsAsIcon,
formatStarsAsText,
formatTonAsIcon,
formatTonAsText } from '../../../util/localization/format';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
@ -41,25 +50,33 @@ import useFlag from '../../../hooks/useFlag';
type StateProps = {
starBalance?: ApiStarsAmount;
tonBalance?: number;
peer?: ApiPeer;
currentDraft?: ApiDraft;
maxAmount: number;
minAmount: number;
maxStarsAmount: number;
minStarsAmount: number;
tonMaxAmount: number;
tonMinAmount: number;
ageMinSeconds: number;
futureMin: number;
futureMax: number;
isMonoforumAdmin?: boolean;
};
const SuggestMessageModal = ({
modal,
starBalance,
tonBalance,
peer,
currentDraft,
maxAmount,
minAmount,
maxStarsAmount,
minStarsAmount,
tonMaxAmount,
tonMinAmount,
ageMinSeconds,
futureMin,
futureMax,
isMonoforumAdmin,
}: OwnProps & StateProps) => {
const { closeSuggestMessageModal, updateDraftSuggestedPostInfo, openStarsBalanceModal } = getActions();
const [isCalendarOpened, openCalendar, closeCalendar] = useFlag();
@ -68,9 +85,12 @@ const SuggestMessageModal = ({
const currentReplyInfo = currentDraft?.replyInfo;
const isInSuggestChangesMode = Boolean(currentReplyInfo);
const [starsAmount, setStarsAmount] = useState<number | undefined>(
const [currencyAmount, setCurrencyAmount] = useState<number | undefined>(
currentSuggestedPostInfo?.price?.amount || undefined,
);
const [selectedCurrency, setSelectedCurrency] = useState<ApiTypeCurrencyAmount['currency']>(
currentSuggestedPostInfo?.price?.currency || STARS_CURRENCY_CODE,
);
const [scheduleDate, setScheduleDate] = useState<number | undefined>(
currentSuggestedPostInfo?.scheduleDate
? currentSuggestedPostInfo.scheduleDate * 1000
@ -78,7 +98,10 @@ const SuggestMessageModal = ({
);
useEffect(() => {
setStarsAmount(currentSuggestedPostInfo?.price?.amount || undefined);
const price = currentSuggestedPostInfo?.price;
const amount = price?.currency === TON_CURRENCY_CODE ? convertTonFromNanos(price.amount) : price?.amount;
setCurrencyAmount(amount);
setSelectedCurrency(currentSuggestedPostInfo?.price?.currency || STARS_CURRENCY_CODE);
setScheduleDate(currentSuggestedPostInfo?.scheduleDate
? currentSuggestedPostInfo.scheduleDate * 1000
: undefined);
@ -87,6 +110,7 @@ const SuggestMessageModal = ({
const lang = useLang();
const oldLang = useOldLang();
const isCurrencyStars = selectedCurrency === STARS_CURRENCY_CODE;
const now = Math.floor(Date.now() / 1000);
const minAt = (now + futureMin) * 1000;
const maxAt = (now + futureMax) * 1000;
@ -97,9 +121,9 @@ const SuggestMessageModal = ({
const number = parseFloat(value);
const result = value === '' || Number.isNaN(number) ? undefined
: Math.min(Math.max(number, 0), maxAmount);
: Math.min(Math.max(number, 0), currentMaxAmount);
setStarsAmount(result);
setCurrencyAmount(result);
});
const handleExpireDateChange = useLastCallback((date: Date) => {
@ -112,28 +136,44 @@ const SuggestMessageModal = ({
closeCalendar();
});
const isDisabled = Boolean(starsAmount) && starsAmount < minAmount;
const currentMinAmount = isCurrencyStars ? minStarsAmount : convertTonFromNanos(tonMinAmount);
const currentMaxAmount = isCurrencyStars ? maxStarsAmount : convertTonFromNanos(tonMaxAmount);
const isDisabled = Boolean(currencyAmount) && currencyAmount < currentMinAmount;
const handleOffer = useLastCallback(() => {
const neededAmount = starsAmount || 0;
const neededAmount = currencyAmount
? (isCurrencyStars ? currencyAmount : convertTonToNanos(currencyAmount))
: 0;
if (isDisabled) {
return;
}
const currentBalance = starBalance?.amount || 0;
if (!isMonoforumAdmin) {
if (isCurrencyStars) {
const currentBalance = starBalance?.amount || 0;
if (neededAmount > currentBalance) {
openStarsBalanceModal({
topup: {
balanceNeeded: neededAmount,
},
});
return;
if (neededAmount > currentBalance) {
openStarsBalanceModal({
topup: {
balanceNeeded: neededAmount,
},
});
return;
}
} else {
const currentTonBalance = tonBalance || 0;
if (neededAmount > currentTonBalance) {
openStarsBalanceModal({
currency: TON_CURRENCY_CODE,
});
return;
}
}
}
updateDraftSuggestedPostInfo({
price: { amount: neededAmount, nanos: 0 },
price: { currency: selectedCurrency, amount: neededAmount, nanos: 0 },
scheduleDate: scheduleDate ? scheduleDate / 1000 : undefined,
});
@ -153,23 +193,51 @@ const SuggestMessageModal = ({
>
<div className={styles.form}>
<div className={styles.section}>
<div className={styles.currencySelector}>
<Button
className={styles.currencyButton}
color={isCurrencyStars ? 'primary' : 'translucent'}
pill
fluid
size="tiny"
noFastClick
onClick={() => setSelectedCurrency(STARS_CURRENCY_CODE)}
>
<Icon name="star" className={styles.currencyIcon} />
{lang('CurrencyStars')}
</Button>
<Button
className={styles.currencyButton}
fluid
color={!isCurrencyStars ? 'primary' : 'translucent'}
pill
size="tiny"
noFastClick
onClick={() => setSelectedCurrency(TON_CURRENCY_CODE)}
>
<Icon name="toncoin" className={styles.currencyIcon} />
{lang('CurrencyTon')}
</Button>
</div>
<InputText
label={lang('InputPlaceholderPrice')}
className={buildClassName(styles.input)}
value={starsAmount?.toString()}
value={currencyAmount?.toString()}
onChange={handleAmountChange}
inputMode="numeric"
tabIndex={0}
teactExperimentControlled
teactExperimentControlled={isCurrencyStars}
/>
<div className={styles.description}>
{starsAmount !== undefined && starsAmount > 0 && starsAmount < minAmount
{currencyAmount !== undefined && currencyAmount > 0 && currencyAmount < currentMinAmount
? lang('DescriptionSuggestedPostMinimumOffer', {
amount: formatStarsAsText(lang, minAmount) },
amount: isCurrencyStars
? formatStarsAsText(lang, currentMinAmount)
: formatTonAsText(lang, currentMinAmount) },
{ withNodes: true, withMarkdown: true })
: lang('SuggestMessagePriceDescription', {
currency: lang('CurrencyStars'),
})}
: isCurrencyStars
? lang('SuggestMessagePriceDescriptionStars')
: lang('SuggestMessagePriceDescriptionTon')}
</div>
</div>
@ -178,7 +246,9 @@ const SuggestMessageModal = ({
<input
type="text"
className={buildClassName('form-control', isCalendarOpened && 'focus')}
value={scheduleDate ? formatScheduledDateTime(scheduleDate / 1000, lang, oldLang) : lang('TitleAnytime')}
value={scheduleDate
? formatScheduledDateTime(scheduleDate / 1000, lang, oldLang)
: lang('SuggestMessageAnytime')}
autoComplete="off"
onClick={openCalendar}
onFocus={openCalendar}
@ -205,7 +275,7 @@ const SuggestMessageModal = ({
onSubmit={handleExpireDateChange}
selectedAt={scheduleDate || defaultSelectedTime}
submitButtonLabel={lang('Save')}
secondButtonLabel={lang('TitleAnytime')}
secondButtonLabel={lang('SuggestMessageAnytime')}
onSecondButtonClick={handleAnytimeClick}
description={lang('SuggestMessageDateTimeHint')}
/>
@ -217,8 +287,10 @@ const SuggestMessageModal = ({
disabled={isDisabled}
>
{isInSuggestChangesMode ? lang('ButtonUpdateTerms')
: starsAmount ? lang('ButtonOfferAmount', {
amount: formatStarsAsIcon(lang, starsAmount, { asFont: true }),
: currencyAmount ? lang('ButtonOfferAmount', {
amount: isCurrencyStars
? formatStarsAsIcon(lang, currencyAmount, { asFont: true })
: formatTonAsIcon(lang, currencyAmount, { asFont: true }),
}, {
withNodes: true,
withMarkdown: true,
@ -236,21 +308,30 @@ export default memo(withGlobal<OwnProps>(
const currentDraft = modal ? selectDraft(global, modal.chatId, MAIN_THREAD_ID) : undefined;
const { appConfig } = global;
const maxAmount = appConfig?.starsSuggestedPostAmountMax || STARS_SUGGESTED_POST_AMOUNT_MAX;
const minAmount = appConfig?.starsSuggestedPostAmountMin || STARS_SUGGESTED_POST_AMOUNT_MIN;
const maxStarsAmount = appConfig?.starsSuggestedPostAmountMax || STARS_SUGGESTED_POST_AMOUNT_MAX;
const minStarsAmount = appConfig?.starsSuggestedPostAmountMin || STARS_SUGGESTED_POST_AMOUNT_MIN;
const ageMinSeconds = appConfig?.starsSuggestedPostAgeMin || STARS_SUGGESTED_POST_AGE_MIN;
const futureMin = appConfig?.starsSuggestedPostFutureMin || STARS_SUGGESTED_POST_FUTURE_MIN;
const futureMax = appConfig?.starsSuggestedPostFutureMax || STARS_SUGGESTED_POST_FUTURE_MAX;
const tonMaxAmount = appConfig?.tonSuggestedPostAmountMax || TON_SUGGESTED_POST_AMOUNT_MAX;
const tonMinAmount = appConfig?.tonSuggestedPostAmountMin || TON_SUGGESTED_POST_AMOUNT_MIN;
const isMonoforumAdmin = modal ? selectIsMonoforumAdmin(global, modal.chatId) : false;
return {
peer,
starBalance,
tonBalance: global.ton?.balance?.amount,
currentDraft,
maxAmount,
minAmount,
maxStarsAmount,
minStarsAmount,
tonMaxAmount,
tonMinAmount,
ageMinSeconds,
futureMin,
futureMax,
isMonoforumAdmin,
};
},
)(SuggestMessageModal));

View File

@ -4,15 +4,18 @@ import { getActions, withGlobal } from '../../../global';
import type { ApiMessage, ApiPeer } from '../../../api/types';
import type { TabState } from '../../../global/types';
import { STARS_SUGGESTED_POST_AGE_MIN,
import { STARS_CURRENCY_CODE, STARS_SUGGESTED_POST_AGE_MIN,
STARS_SUGGESTED_POST_COMMISSION_PERMILLE,
STARS_SUGGESTED_POST_FUTURE_MAX,
STARS_SUGGESTED_POST_FUTURE_MIN,
TON_CURRENCY_CODE,
TON_SUGGESTED_POST_COMMISSION_PERMILLE,
} from '../../../config';
import { getPeerFullTitle } from '../../../global/helpers/peers';
import { selectChatMessage, selectIsMonoforumAdmin, selectSender } from '../../../global/selectors';
import { formatScheduledDateTime, formatShortDuration } from '../../../util/dates/dateFormat';
import { formatStarsAsText } from '../../../util/localization/format';
import { convertTonFromNanos } from '../../../util/formatCurrency';
import { formatStarsAsText, formatTonAsText } from '../../../util/localization/format';
import renderText from '../../common/helpers/renderText';
import useFlag from '../../../hooks/useFlag';
@ -31,6 +34,7 @@ export type OwnProps = {
type StateProps = {
commissionPermille: number;
tonCommissionPermille: number;
minAge: number;
futureMin: number;
futureMax: number;
@ -46,6 +50,7 @@ const SuggestedPostApprovalModal = ({
sender,
isAdmin,
commissionPermille,
tonCommissionPermille,
minAge,
futureMin,
futureMax,
@ -114,7 +119,10 @@ const SuggestedPostApprovalModal = ({
const senderName = sender ? getPeerFullTitle(oldLang, sender) : '';
const renderContent = () => {
const amount = message?.suggestedPostInfo?.price?.amount;
const price = message?.suggestedPostInfo?.price;
const amount = price?.amount;
const currency = price?.currency || STARS_CURRENCY_CODE;
const question = lang(
'SuggestedPostConfirmMessage',
{ peer: senderName },
@ -125,10 +133,13 @@ const SuggestedPostApprovalModal = ({
return questionText;
}
const commission = (commissionPermille / 10);
const currentCommissionPermille = currency === TON_CURRENCY_CODE ? tonCommissionPermille : commissionPermille;
const commission = (currentCommissionPermille / 10);
const amountWithCommission = amount / 100 * commission;
const starsText = formatStarsAsText(lang, amountWithCommission);
const formattedAmount = currency === TON_CURRENCY_CODE
? formatTonAsText(lang, convertTonFromNanos(amountWithCommission))
: formatStarsAsText(lang, amountWithCommission);
const ageMinSeconds = minAge;
const duration = formatShortDuration(lang, ageMinSeconds, true);
@ -146,7 +157,7 @@ const SuggestedPostApprovalModal = ({
</div>
<div className={styles.details}>
{renderText(lang(key, {
amount: starsText,
amount: formattedAmount,
commission,
duration,
time,
@ -165,7 +176,7 @@ const SuggestedPostApprovalModal = ({
</div>
<div className={styles.details}>
{renderText(lang(key, {
amount: starsText,
amount: formattedAmount,
commission,
duration,
}, { withNodes: true, withMarkdown: true }))}
@ -215,6 +226,8 @@ export default memo(withGlobal<OwnProps>(
const { appConfig } = global;
const commissionPermille = appConfig?.starsSuggestedPostCommissionPermille
|| STARS_SUGGESTED_POST_COMMISSION_PERMILLE;
const tonCommissionPermille = appConfig?.tonSuggestedPostCommissionPermille
|| TON_SUGGESTED_POST_COMMISSION_PERMILLE;
const minAge = appConfig?.starsSuggestedPostAgeMin || STARS_SUGGESTED_POST_AGE_MIN;
const futureMin = (appConfig?.starsSuggestedPostFutureMin || STARS_SUGGESTED_POST_FUTURE_MIN) * 2;
const futureMax = appConfig?.starsSuggestedPostFutureMax || STARS_SUGGESTED_POST_FUTURE_MAX;
@ -228,6 +241,7 @@ export default memo(withGlobal<OwnProps>(
sender,
isAdmin,
commissionPermille,
tonCommissionPermille,
scheduleDate,
};
},

View File

@ -117,7 +117,12 @@ export const STARS_SUGGESTED_POST_COMMISSION_PERMILLE = 850;
export const STARS_SUGGESTED_POST_AGE_MIN = 86400; // 24 hours in seconds
export const STARS_SUGGESTED_POST_FUTURE_MAX = 2678400; // 31 days in seconds
export const STARS_SUGGESTED_POST_FUTURE_MIN = 300; // 5 minutes in seconds
export const TON_CURRENCY_CODE = 'TON';
export const TON_SUGGESTED_POST_COMMISSION_PERMILLE = 850;
export const TON_USD_RATE_DEFAULT = 3;
export const TON_TOPUP_URL_DEFAULT = 'https://fragment.com/ads/topup';
export const TON_SUGGESTED_POST_AMOUNT_MIN = 10000000; // 0.01 TON in nanos
export const TON_SUGGESTED_POST_AMOUNT_MAX = 10000000000000; // 10 000 TON in nanos
export const STORY_VIEWS_MIN_SEARCH = 15;
export const STORY_MIN_REACTIONS_SORT = 10;

View File

@ -33,10 +33,12 @@ import {
MESSAGE_LIST_SLICE,
RE_TELEGRAM_LINK,
SERVICE_NOTIFICATIONS_USER_ID,
STARS_CURRENCY_CODE,
STARS_SUGGESTED_POST_FUTURE_MIN,
SUPPORTED_AUDIO_CONTENT_TYPES,
SUPPORTED_PHOTO_CONTENT_TYPES,
SUPPORTED_VIDEO_CONTENT_TYPES,
TON_CURRENCY_CODE,
} from '../../../config';
import { ensureProtocol, isMixedScriptUrl } from '../../../util/browser/url';
import { IS_IOS } from '../../../util/browser/windowEnvironment';
@ -361,18 +363,31 @@ addActionHandler('sendMessage', async (global, actions, payload): Promise<void>
const messagePriceInStars = await getPeerStarsForMessage(global, chatId!);
const suggestedPostPrice = draftSuggestedPostInfo?.price?.amount || 0;
if (suggestedPostPrice && !draftReplyInfo) {
const currentBalance = global.stars?.balance?.amount || 0;
const suggestedPostPrice = draftSuggestedPostInfo?.price;
const suggestedPostCurrency = suggestedPostPrice?.currency || STARS_CURRENCY_CODE;
const suggestedPostAmount = suggestedPostPrice?.amount || 0;
if (suggestedPostAmount && !draftReplyInfo) {
if (suggestedPostCurrency === STARS_CURRENCY_CODE) {
const currentBalance = global.stars?.balance?.amount || 0;
if (suggestedPostPrice > currentBalance) {
actions.openStarsBalanceModal({
topup: {
balanceNeeded: suggestedPostPrice,
},
tabId,
});
return;
if (suggestedPostAmount > currentBalance) {
actions.openStarsBalanceModal({
topup: {
balanceNeeded: suggestedPostAmount,
},
tabId,
});
return;
}
} else if (suggestedPostCurrency === TON_CURRENCY_CODE) {
const currentTonBalance = global.ton?.balance?.amount || 0;
if (suggestedPostAmount > currentTonBalance) {
actions.openStarsBalanceModal({
currency: TON_CURRENCY_CODE,
tabId,
});
return;
}
}
}
@ -2203,16 +2218,28 @@ addActionHandler('approveSuggestedPost', async (global, actions, payload): Promi
if (!isAdmin && message?.suggestedPostInfo?.price?.amount) {
const neededAmount = message.suggestedPostInfo.price.amount;
const currentBalance = global.stars?.balance?.amount || 0;
const isCurrencyStars = message.suggestedPostInfo.price.currency === STARS_CURRENCY_CODE;
if (neededAmount > currentBalance) {
actions.openStarsBalanceModal({
topup: {
balanceNeeded: neededAmount,
},
tabId,
});
return;
if (isCurrencyStars) {
const currentBalance = global.stars?.balance?.amount || 0;
if (neededAmount > currentBalance) {
actions.openStarsBalanceModal({
topup: {
balanceNeeded: neededAmount,
},
tabId,
});
return;
}
} else {
const currentTonBalance = global.ton?.balance?.amount || 0;
if (neededAmount > currentTonBalance) {
actions.openStarsBalanceModal({
currency: TON_CURRENCY_CODE,
tabId,
});
return;
}
}
}

View File

@ -2,7 +2,12 @@ import type { ApiSavedStarGift, ApiStarGiftUnique } from '../../../api/types';
import type { StarGiftCategory } from '../../../types';
import type { ActionReturnType } from '../../types';
import { DEFAULT_RESALE_GIFTS_FILTER_OPTIONS, RESALE_GIFTS_LIMIT } from '../../../config';
import {
DEFAULT_RESALE_GIFTS_FILTER_OPTIONS,
RESALE_GIFTS_LIMIT,
STARS_CURRENCY_CODE,
TON_CURRENCY_CODE,
} from '../../../config';
import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { buildCollectionByKey } from '../../../util/iteratees';
import { callApi } from '../../../api/gramjs';
@ -26,57 +31,82 @@ import {
} from '../../selectors';
addActionHandler('loadStarStatus', async (global): Promise<void> => {
const currentStatus = global.stars;
const needsTopupOptions = !currentStatus?.topupOptions;
const currentStarsStatus = global.stars;
const needsTopupOptions = !currentStarsStatus?.topupOptions;
const [status, topupOptions] = await Promise.all([
const [starsStatus, tonStatus, topupOptions] = await Promise.all([
callApi('fetchStarsStatus'),
callApi('fetchStarsStatus', { isTon: true }),
needsTopupOptions ? callApi('fetchStarsTopupOptions') : undefined,
]);
if (!status || (needsTopupOptions && !topupOptions)) {
if (!(starsStatus || tonStatus) || (needsTopupOptions && !topupOptions)) {
return;
}
global = getGlobal();
global = {
...global,
stars: {
...currentStatus,
balance: status.balance,
topupOptions: topupOptions || currentStatus!.topupOptions,
history: {
all: undefined,
inbound: undefined,
outbound: undefined,
if (starsStatus && starsStatus.balance.currency === STARS_CURRENCY_CODE) {
global = {
...global,
stars: {
...currentStarsStatus,
balance: starsStatus.balance,
topupOptions: topupOptions || currentStarsStatus!.topupOptions,
history: {
all: undefined,
inbound: undefined,
outbound: undefined,
},
subscriptions: undefined,
},
subscriptions: undefined,
},
};
};
if (status.history) {
global = appendStarsTransactions(global, 'all', status.history, status.nextHistoryOffset);
if (starsStatus.history) {
global = appendStarsTransactions(global, 'all', starsStatus.history, starsStatus.nextHistoryOffset);
}
if (starsStatus.subscriptions) {
global = appendStarsSubscriptions(global, starsStatus.subscriptions, starsStatus.nextSubscriptionOffset);
}
}
if (status.subscriptions) {
global = appendStarsSubscriptions(global, status.subscriptions, status.nextSubscriptionOffset);
if (tonStatus?.balance.currency === TON_CURRENCY_CODE) {
global = {
...global,
ton: {
...tonStatus,
balance: tonStatus.balance,
history: {
all: undefined,
inbound: undefined,
outbound: undefined,
},
},
};
global = updateStarsBalance(global, tonStatus.balance);
if (tonStatus.history) {
global = appendStarsTransactions(global, 'all', tonStatus.history, tonStatus.nextHistoryOffset, true);
}
}
setGlobal(global);
});
addActionHandler('loadStarsTransactions', async (global, actions, payload): Promise<void> => {
const { type } = payload;
const { type, isTon } = payload;
const history = global.stars?.history[type];
const history = isTon ? global.ton?.history[type] : global.stars?.history[type];
const offset = history?.nextOffset;
if (history && !offset) return; // Already loaded all
const result = await callApi('fetchStarsTransactions', {
isInbound: type === 'inbound' || undefined,
isOutbound: type === 'outbound' || undefined,
isInbound: type === 'inbound',
isOutbound: type === 'outbound',
offset: offset || '',
isTon,
});
if (!result) {
@ -87,7 +117,7 @@ addActionHandler('loadStarsTransactions', async (global, actions, payload): Prom
global = updateStarsBalance(global, result.balance);
if (result.history) {
global = appendStarsTransactions(global, type, result.history, result.nextOffset);
global = appendStarsTransactions(global, type, result.history, result.nextOffset, isTon);
}
setGlobal(global);
});
@ -315,7 +345,7 @@ addActionHandler('loadStarsSubscriptions', async (global): Promise<void> => {
offset: offset || '',
});
if (!result) {
if (!result || result.balance.currency !== STARS_CURRENCY_CODE) {
return;
}

View File

@ -215,6 +215,22 @@ addActionHandler('loadPremiumGifts', async (global): Promise<void> => {
setGlobal(global);
});
addActionHandler('loadTonGifts', async (global): Promise<void> => {
const stickerSet = await callApi('fetchTonGifts');
if (!stickerSet) {
return;
}
const { set, stickers } = stickerSet;
global = getGlobal();
global = {
...global,
tonGifts: { ...set, stickers },
};
setGlobal(global);
});
addActionHandler('loadDefaultTopicIcons', async (global): Promise<void> => {
const stickerSet = await callApi('fetchDefaultTopicIcons');
if (!stickerSet) {

View File

@ -1,6 +1,7 @@
import type { ApiInputSavedStarGift, ApiSavedStarGift } from '../../../api/types';
import type { ActionReturnType } from '../../types';
import { STARS_CURRENCY_CODE } from '../../../config';
import { getCurrentTabId } from '../../../util/establishMultitabRole';
import * as langProvider from '../../../util/oldLangProvider';
import { addTabStateResetterAction } from '../../helpers/meta';
@ -110,6 +111,7 @@ addActionHandler('openStarsBalanceModal', (global, actions, payload): ActionRetu
originGift,
topup,
shouldIgnoreBalance,
currency = STARS_CURRENCY_CODE,
tabId = getCurrentTabId(),
} = payload || {};
@ -140,6 +142,7 @@ addActionHandler('openStarsBalanceModal', (global, actions, payload): ActionRetu
originReaction,
originGift,
topup,
currency,
},
}, tabId);
});

View File

@ -8,12 +8,15 @@ import type {
ApiStarsTransaction,
ApiStarsTransactionPeer,
ApiStarsTransactionPeerPeer,
ApiTypeCurrencyAmount,
} from '../../api/types';
import type { CustomPeer } from '../../types';
import type { LangFn } from '../../util/localization';
import type { GlobalState } from '../types';
import { STARS_CURRENCY_CODE, TON_CURRENCY_CODE } from '../../config';
import arePropsShallowEqual from '../../util/arePropsShallowEqual';
import { convertCurrencyFromBaseUnit } from '../../util/formatCurrency';
import { selectChat, selectPeer, selectUser } from '../selectors';
export function getRequestInputInvoice<T extends GlobalState>(
@ -257,6 +260,7 @@ export function getRequestInputSavedStarGift<T extends GlobalState>(
export function buildStarsTransactionCustomPeer(
peer: Exclude<ApiStarsTransactionPeer, ApiStarsTransactionPeerPeer>,
isForTon?: boolean,
): CustomPeer {
if (peer.type === 'appStore') {
return {
@ -279,8 +283,17 @@ export function buildStarsTransactionCustomPeer(
}
if (peer.type === 'fragment') {
if (isForTon) {
return {
avatarIcon: 'fragment',
isCustomPeer: true,
titleKey: 'Stars.Gift.Received.Title',
subtitleKey: 'Stars.Intro.Transaction.Gift.UnknownUser',
customPeerAvatarColor: '#000000',
};
}
return {
avatarIcon: 'star',
avatarIcon: 'fragment',
isCustomPeer: true,
titleKey: 'Stars.Intro.Transaction.FragmentTopUp.Title',
subtitleKey: 'Stars.Intro.Transaction.FragmentTopUp.Subtitle',
@ -328,13 +341,29 @@ export function buildStarsTransactionCustomPeer(
};
}
export function formatStarsTransactionAmount(lang: LangFn, starsAmount: ApiStarsAmount) {
const amount = starsAmount.amount + starsAmount.nanos / 1e9;
if (amount < 0) {
return `- ${lang.number(Math.abs(amount))}`;
export function formatStarsTransactionAmount(lang: LangFn, currencyAmount: ApiTypeCurrencyAmount) {
if (currencyAmount.currency === STARS_CURRENCY_CODE) {
const amount = currencyAmount.amount + currencyAmount.nanos / 1e9;
if (amount < 0) {
return `- ${lang.number(Math.abs(amount))}`;
}
return `+ ${lang.number(amount)}`;
}
return `+ ${lang.number(amount)}`;
if (currencyAmount.currency === TON_CURRENCY_CODE) {
const amount = convertCurrencyFromBaseUnit(currencyAmount.amount, currencyAmount.currency);
const absAmount = Math.abs(amount);
const tonText = lang('TonAmountText', { amount: absAmount }, { pluralValue: absAmount });
if (amount < 0) {
return `- ${tonText}`;
}
return `+ ${tonText}`;
}
return undefined;
}
export function formatStarsAmount(lang: LangFn, starsAmount: ApiStarsAmount) {
@ -344,24 +373,45 @@ export function formatStarsAmount(lang: LangFn, starsAmount: ApiStarsAmount) {
export function getStarsTransactionFromGift(message: ApiMessage): ApiStarsTransaction | undefined {
const { action } = message.content;
if (action?.type !== 'giftStars') return undefined;
if (action?.type === 'giftStars') {
const { transactionId, stars } = action;
const { transactionId, stars } = action;
return {
id: transactionId,
amount: {
currency: STARS_CURRENCY_CODE,
amount: stars,
nanos: 0,
},
peer: {
type: 'peer',
id: message.isOutgoing ? message.chatId : (message.senderId || message.chatId),
},
date: message.date,
isGift: true,
isMyGift: message.isOutgoing || undefined,
};
}
return {
id: transactionId,
stars: {
amount: stars,
nanos: 0,
},
peer: {
type: 'peer',
id: message.isOutgoing ? message.chatId : (message.senderId || message.chatId),
},
date: message.date,
isGift: true,
isMyGift: message.isOutgoing || undefined,
};
if (action?.type === 'giftTon') {
const { transactionId, cryptoAmount } = action;
return {
id: transactionId,
amount: {
currency: TON_CURRENCY_CODE,
amount: cryptoAmount,
},
peer: {
type: 'fragment',
},
date: message.date,
isGift: true,
isMyGift: message.isOutgoing || undefined,
};
}
return undefined;
}
export function getPrizeStarsTransactionFromGiveaway(message: ApiMessage): ApiStarsTransaction | undefined {
@ -373,7 +423,8 @@ export function getPrizeStarsTransactionFromGiveaway(message: ApiMessage): ApiSt
return {
id: transactionId,
stars: {
amount: {
currency: STARS_CURRENCY_CODE,
amount: stars,
nanos: 0,
},

View File

@ -1,9 +1,9 @@
import type {
ApiReceiptRegular,
ApiReceiptStars,
ApiStarsAmount,
ApiStarsSubscription,
ApiStarsTransaction,
ApiTypeCurrencyAmount,
} from '../../api/types';
import type {
PaymentStep,
@ -15,6 +15,7 @@ import type {
GlobalState, TabArgs, TabState,
} from '../types';
import { STARS_CURRENCY_CODE, TON_CURRENCY_CODE } from '../../config';
import { getCurrentTabId } from '../../util/establishMultitabRole';
import { selectStarsPayment, selectTabState } from '../selectors';
import { updateTabState } from './tabs';
@ -137,15 +138,29 @@ export function closeInvoice<T extends GlobalState>(
}
export function updateStarsBalance<T extends GlobalState>(
global: T, balance: ApiStarsAmount,
global: T, balance: ApiTypeCurrencyAmount,
): T {
return {
...global,
stars: {
...global.stars,
balance,
},
};
if (balance.currency === STARS_CURRENCY_CODE) {
return {
...global,
stars: {
...global.stars,
balance,
},
};
}
if (balance.currency === TON_CURRENCY_CODE) {
return {
...global,
ton: {
...global.ton,
balance,
},
};
}
return global;
}
export function appendStarsTransactions<T extends GlobalState>(
@ -153,7 +168,31 @@ export function appendStarsTransactions<T extends GlobalState>(
type: StarsTransactionType,
transactions: ApiStarsTransaction[],
nextOffset?: string,
isTon?: boolean,
): T {
if (isTon) {
const history = global.ton?.history;
if (!history) {
return global;
}
const newTypeObject = {
transactions: (history[type]?.transactions || []).concat(transactions),
nextOffset,
};
return {
...global,
ton: {
...global.ton,
history: {
...history,
[type]: newTypeObject,
},
},
};
}
const history = global.stars?.history;
if (!history) {
return global;
@ -238,7 +277,8 @@ export function openStarsTransactionFromReceipt<T extends GlobalState>(
type: 'peer',
id: receipt.botId,
},
stars: {
amount: {
currency: STARS_CURRENCY_CODE,
amount: receipt.totalAmount,
nanos: 0,
},

View File

@ -1,8 +1,9 @@
import type { ApiSticker, ApiStickerSet, ApiStickerSetInfo } from '../../api/types';
import type { GlobalState, TabArgs } from '../types';
import { RESTRICTED_EMOJI_SET_ID } from '../../config';
import { RESTRICTED_EMOJI_SET_ID, TON_CURRENCY_CODE } from '../../config';
import { getCurrentTabId } from '../../util/establishMultitabRole';
import { convertCurrencyFromBaseUnit } from '../../util/formatCurrency';
import { selectTabState } from './tabs';
import { selectIsCurrentUserPremium } from './users';
@ -21,6 +22,12 @@ const STAR_EMOTICON: Record<number, string> = {
5000: `${4}\u{FE0F}\u20E3`,
};
const TON_EMOTICON: Record<number, string> = {
1: `${1}\u{FE0F}\u20E3`,
10: `${2}\u{FE0F}\u20E3`,
50: `${3}\u{FE0F}\u20E3`,
};
export function selectIsStickerFavorite<T extends GlobalState>(global: T, sticker: ApiSticker) {
const { stickers } = global.stickers.favorite;
return stickers && stickers.some(({ id }) => id === sticker.id);
@ -194,3 +201,20 @@ export function selectGiftStickerForStars<T extends GlobalState>(global: T, star
return stickers.find((sticker) => sticker.emoji === emoji) || stickers[0];
}
export function selectGiftStickerForTon<T extends GlobalState>(global: T, amount?: number) {
const stickers = global.tonGifts?.stickers;
if (!stickers || !amount) return undefined;
const convertedAmount = convertCurrencyFromBaseUnit(amount, TON_CURRENCY_CODE);
let emoji;
if (convertedAmount < 10) {
emoji = TON_EMOTICON[1];
} else if (convertedAmount < 50) {
emoji = TON_EMOTICON[10];
} else {
emoji = TON_EMOTICON[50];
}
return stickers.find((sticker) => sticker.emoji === emoji) || stickers[0];
}

View File

@ -49,6 +49,7 @@ import type {
ApiStickerSetInfo,
ApiThemeParameters,
ApiTodoItem,
ApiTypeCurrencyAmount,
ApiTypePrepaidGiveaway,
ApiUpdate,
ApiUser,
@ -1278,6 +1279,7 @@ export interface ActionPayloads {
loadStarStatus: undefined;
loadStarsTransactions: {
type: StarsTransactionType;
isTon?: boolean;
};
loadStarsSubscriptions: undefined;
changeStarsSubscription: {
@ -1302,6 +1304,7 @@ export interface ActionPayloads {
purpose?: string;
};
shouldIgnoreBalance?: boolean;
currency?: ApiTypeCurrencyAmount['currency'];
} & WithTabId;
closeStarsBalanceModal: WithTabId | undefined;
@ -2483,6 +2486,7 @@ export interface ActionPayloads {
};
loadPremiumGifts: undefined;
loadTonGifts: undefined;
loadStarGifts: undefined;
updateResaleGiftsFilter: {
filter: ResaleGiftsFilterOptions;

View File

@ -37,6 +37,7 @@ import type {
ApiSticker,
ApiStickerSet,
ApiTimezone,
ApiTonAmount,
ApiTranscription,
ApiUpdateAuthorizationStateType,
ApiUpdateConnectionStateType,
@ -367,6 +368,7 @@ export type GlobalState = {
defaultTopicIconsId?: string;
defaultStatusIconsId?: string;
premiumGifts?: ApiStickerSet;
tonGifts?: ApiStickerSet;
emojiKeywords: Record<string, EmojiKeywords | undefined>;
collectibleEmojiStatuses?: {
@ -452,6 +454,10 @@ export type GlobalState = {
history: StarsTransactionHistory;
subscriptions?: StarsSubscriptions;
};
ton?: {
balance: ApiTonAmount;
history: StarsTransactionHistory;
};
};
export type RequiredGlobalState = GlobalState & { _: never };

View File

@ -45,6 +45,7 @@ import type {
ApiStarsTransaction,
ApiStarTopupOption,
ApiSticker,
ApiTypeCurrencyAmount,
ApiTypePrepaidGiveaway,
ApiTypeStoryView,
ApiUser,
@ -795,6 +796,7 @@ export type TabState = {
balanceNeeded: number;
purpose?: string;
};
currency?: ApiTypeCurrencyAmount['currency'];
};
giftInfoModal?: {

View File

@ -318,10 +318,12 @@ body:not(.is-ios) {
}
// Increase specificity to override the default icon style
.ton-amount-icon.ton-amount-icon,
.star-amount-icon.star-amount-icon {
margin-inline-start: 0.375em; // Prevent sticking to the text without using `white-space: pre`
margin-inline-end: 0.2em; // Prevent sticking to the text without using `white-space: pre`
line-height: inherit; // Vertical centring
vertical-align: text-bottom; // As regular text
}
.shared-canvas-container {

View File

@ -576,6 +576,7 @@ export interface LangPair {
'MenuStickers': undefined;
'MenuAnimations': undefined;
'MenuStars': undefined;
'MenuTon': undefined;
'MenuSendGift': undefined;
'MenuTelegramFaq': undefined;
'MenuPrivacyPolicy': undefined;
@ -1549,9 +1550,13 @@ export interface LangPair {
'TitleTime': undefined;
'TitleSuggestMessage': undefined;
'TitleSuggestedChanges': undefined;
'SuggestMessageNoPrice': undefined;
'EnterPriceInStars': undefined;
'EnterPriceInTon': undefined;
'SuggestMessagePriceDescriptionStars': undefined;
'SuggestMessagePriceDescriptionTon': undefined;
'SuggestMessageDateTimeHint': undefined;
'TitleAnytime': undefined;
'SuggestMessageAnytime': undefined;
'ButtonOfferFree': undefined;
'ButtonUpdateTerms': undefined;
'InputPlaceholderPrice': undefined;
@ -1563,6 +1568,7 @@ export interface LangPair {
'SuggestedPostRejectedNotification': undefined;
'SuggestedPostAgreementReached': undefined;
'CurrencyStars': undefined;
'CurrencyTon': undefined;
'DeclineReasonPlaceholder': undefined;
'SuggestedPostRejectedYou': undefined;
'SuggestedPostRejectedWithReasonYou': undefined;
@ -1590,6 +1596,10 @@ export interface LangPair {
'ToDoListErrorChooseTitle': undefined;
'ToDoListErrorChooseTasks': undefined;
'PremiumPreviewTodo': undefined;
'DescriptionAboutTon': undefined;
'ButtonTopUpViaFragment': undefined;
'TonModalHint': undefined;
'TonGiftReceived': undefined;
}
export interface LangPairWithVariables<V = LangVariable> {
@ -2425,6 +2435,13 @@ export interface LangPairWithVariables<V = LangVariable> {
'from': V;
'amount': V;
};
'TonAmount': {
'amount': V;
};
'ActionGiftCostCrypto': {
'cryptoPrice': V;
'price': V;
};
'ActionPaymentRefunded': {
'peer': V;
'amount': V;
@ -2584,9 +2601,6 @@ export interface LangPairWithVariables<V = LangVariable> {
'user': V;
'changes': V;
};
'SuggestMessagePriceDescription': {
'currency': V;
};
'SuggestMessageTimeDescription': {
'hint': V;
'duration': V;
@ -3035,6 +3049,9 @@ export interface LangPairPluralWithVariables<V = LangVariable> {
'ActionGiftStarsTitle': {
'amount': V;
};
'TonAmountText': {
'amount': V;
};
'ActionBoostApplyYou': {
'count': V;
};

View File

@ -2,8 +2,24 @@ import { type TeactNode } from '../lib/teact/teact';
import type { LangFn } from './localization';
import { STARS_CURRENCY_CODE } from '../config';
import { formatStarsAsIcon } from './localization/format';
import { STARS_CURRENCY_CODE, TON_CURRENCY_CODE } from '../config';
import { formatStarsAsIcon, formatTonAsIcon } from './localization/format';
export function convertCurrencyFromBaseUnit(amount: number, currency: string) {
return amount / 10 ** getCurrencyExp(currency);
}
export function convertCurrencyToBaseUnit(amount: number, currency: string) {
return amount * 10 ** getCurrencyExp(currency);
}
export function convertTonFromNanos(nanos: number): number {
return convertCurrencyFromBaseUnit(nanos, TON_CURRENCY_CODE);
}
export function convertTonToNanos(ton: number): number {
return convertCurrencyToBaseUnit(ton, TON_CURRENCY_CODE);
}
export function formatCurrency(
lang: LangFn,
@ -15,15 +31,24 @@ export function formatCurrency(
asFontIcon?: boolean;
},
): TeactNode {
const price = totalPrice / 10 ** getCurrencyExp(currency);
const price = convertCurrencyFromBaseUnit(totalPrice, currency);
if (currency === STARS_CURRENCY_CODE) {
return formatStarsAsIcon(lang, price, { asFont: options?.asFontIcon, className: options?.iconClassName });
}
if (currency === TON_CURRENCY_CODE) {
return formatTonAsIcon(lang, price, { asFont: options?.asFontIcon, className: options?.iconClassName });
}
return formatCurrencyAsString(totalPrice, currency, lang.code, options);
}
export function convertTonToUsd(amount: number, usdRate: number): number {
const tonInRegularUnits = convertTonFromNanos(amount);
return tonInRegularUnits * usdRate * 100;
}
export function formatCurrencyAsString(
totalPrice: number,
currency: string,
@ -32,7 +57,7 @@ export function formatCurrencyAsString(
shouldOmitFractions?: boolean;
},
) {
const price = totalPrice / 10 ** getCurrencyExp(currency);
const price = convertCurrencyFromBaseUnit(totalPrice, currency);
if ((options?.shouldOmitFractions || currency === STARS_CURRENCY_CODE) && Number.isInteger(price)) {
return new Intl.NumberFormat(locale, {
@ -43,6 +68,15 @@ export function formatCurrencyAsString(
}).format(price);
}
if (currency === TON_CURRENCY_CODE) {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 10,
}).format(price);
}
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
@ -50,7 +84,7 @@ export function formatCurrencyAsString(
}
function getCurrencyExp(currency: string) {
if (currency === 'TON') {
if (currency === TON_CURRENCY_CODE) {
return 9;
}
if (currency === 'CLF') {

View File

@ -1,6 +1,7 @@
import type { LangFn } from './types';
import { STARS_ICON_PLACEHOLDER } from '../../config';
import { convertCurrencyFromBaseUnit } from '../../util/formatCurrency';
import buildClassName from '../buildClassName';
import Icon from '../../components/common/icons/Icon';
@ -10,6 +11,37 @@ export function formatStarsAsText(lang: LangFn, amount: number) {
return lang('StarsAmountText', { amount }, { pluralValue: amount });
}
export function formatTonAsText(lang: LangFn, amount: number) {
return lang('TonAmountText', { amount: lang.preciseNumber(amount) }, { pluralValue: amount });
}
export function formatTonAsIcon(lang: LangFn, amount: number | string, options?: {
asFont?: boolean; className?: string; containerClassName?: string; shouldConvertFromNanos?: boolean; }) {
const { className, containerClassName, shouldConvertFromNanos } = options || {};
const formattedAmount = shouldConvertFromNanos ? convertCurrencyFromBaseUnit(Number(amount), 'TON') : amount;
const icon = <Icon name="toncoin" className={buildClassName('ton-amount-icon', className)} />;
if (containerClassName) {
return (
<span className={containerClassName}>
{lang('TonAmount', { amount: formattedAmount }, {
withNodes: true,
specialReplacement: {
'💎': icon,
},
})}
</span>
);
}
return lang('TonAmount', { amount: formattedAmount }, {
withNodes: true,
specialReplacement: {
'💎': icon,
},
});
}
export function formatStarsAsIcon(lang: LangFn, amount: number | string, options?: {
asFont?: boolean; className?: string; containerClassName?: string; }) {
const { asFont, className, containerClassName } = options || {};

View File

@ -169,6 +169,10 @@ function createFormatters() {
conjunction: createListFormat(langCode, 'conjunction'),
disjunction: createListFormat(langCode, 'disjunction'),
number: new Intl.NumberFormat(langCode),
preciseNumber: new Intl.NumberFormat(langCode, {
minimumFractionDigits: 0,
maximumFractionDigits: 10,
}),
};
} catch (e) {
// eslint-disable-next-line no-console
@ -179,6 +183,10 @@ function createFormatters() {
conjunction: createListFormat(FORMATTERS_FALLBACK_LANG, 'conjunction'),
disjunction: createListFormat(FORMATTERS_FALLBACK_LANG, 'disjunction'),
number: new Intl.NumberFormat(FORMATTERS_FALLBACK_LANG),
preciseNumber: new Intl.NumberFormat(FORMATTERS_FALLBACK_LANG, {
minimumFractionDigits: 0,
maximumFractionDigits: 10,
}),
};
}
}
@ -325,6 +333,7 @@ function createTranslationFn(): LangFn {
fn.conjunction = (list: string[]) => formatters?.conjunction.format(list) || list.join(', ');
fn.disjunction = (list: string[]) => formatters?.disjunction.format(list) || list.join(', ');
fn.number = (value: number) => formatters?.number.format(value) || String(value);
fn.preciseNumber = (value: number) => formatters?.preciseNumber.format(value) || String(value);
fn.internalFormatters = formatters!;
fn.languageInfo = language!;
return fn;

View File

@ -155,6 +155,7 @@ export type LangFn = {
conjunction: (list: string[]) => string;
disjunction: (list: string[]) => string;
number: (value: number) => string;
preciseNumber: (value: number) => string;
internalFormatters: LangFormatters;
isRtl?: boolean;
rawCode: string;
@ -171,6 +172,7 @@ export type LangFormatters = {
conjunction: ListFormat;
disjunction: ListFormat;
number: Intl.NumberFormat;
preciseNumber: Intl.NumberFormat;
};
/* GUARDS */