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_ITEM_LENGTH_LIMIT,
TODO_ITEMS_LIMIT, TODO_ITEMS_LIMIT,
TODO_TITLE_LENGTH_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'; } from '../../../config';
import localDb from '../localDb'; import localDb from '../localDb';
import { buildJson } from './misc'; import { buildJson } from './misc';
@ -108,6 +112,10 @@ export interface GramJsAppConfig extends LimitsConfig {
stars_suggested_post_future_max?: number; stars_suggested_post_future_max?: number;
stars_suggested_post_future_min?: number; stars_suggested_post_future_min?: number;
ton_suggested_post_commission_permille?: 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; poll_answers_max?: number;
todo_items_max?: number; todo_items_max?: number;
todo_title_length_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, starsSuggestedPostFutureMax: appConfig.stars_suggested_post_future_max,
starsSuggestedPostFutureMin: appConfig.stars_suggested_post_future_min, starsSuggestedPostFutureMin: appConfig.stars_suggested_post_future_min,
tonSuggestedPostCommissionPermille: appConfig.ton_suggested_post_commission_permille, 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, pollMaxAnswers: appConfig.poll_answers_max,
todoItemsMax: appConfig.todo_items_max ?? TODO_ITEMS_LIMIT, todoItemsMax: appConfig.todo_items_max ?? TODO_ITEMS_LIMIT,
todoTitleLengthMax: appConfig.todo_title_length_max ?? TODO_TITLE_LENGTH_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 { buildApiFormattedText, buildApiPhoto } from './common';
import { buildApiStarGift } from './gifts'; import { buildApiStarGift } from './gifts';
import { buildTodoItem } from './messageContent'; import { buildTodoItem } from './messageContent';
import { buildApiStarsAmount } from './payments'; import { buildApiCurrencyAmount } from './payments';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers'; import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers';
const UNSUPPORTED_ACTION: ApiMessageAction = { const UNSUPPORTED_ACTION: ApiMessageAction = {
@ -359,6 +359,20 @@ export function buildApiMessageAction(action: GramJs.TypeMessageAction): ApiMess
transactionId, 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) { if (action instanceof GramJs.MessageActionPrizeStars) {
const { const {
unclaimed, stars, transactionId, boostPeer, giveawayMsgId, unclaimed, stars, transactionId, boostPeer, giveawayMsgId,
@ -459,7 +473,7 @@ export function buildApiMessageAction(action: GramJs.TypeMessageAction): ApiMess
isBalanceTooLow: Boolean(balanceTooLow), isBalanceTooLow: Boolean(balanceTooLow),
rejectComment, rejectComment,
scheduleDate, scheduleDate,
amount: price ? buildApiStarsAmount(price) : undefined, amount: price ? buildApiCurrencyAmount(price) : undefined,
}; };
} }
if (action instanceof GramJs.MessageActionSuggestedPostSuccess) { if (action instanceof GramJs.MessageActionSuggestedPostSuccess) {
@ -467,7 +481,7 @@ export function buildApiMessageAction(action: GramJs.TypeMessageAction): ApiMess
return { return {
mediaType: 'action', mediaType: 'action',
type: 'suggestedPostSuccess', type: 'suggestedPostSuccess',
amount: buildApiStarsAmount(price), amount: buildApiCurrencyAmount(price),
}; };
} }
if (action instanceof GramJs.MessageActionSuggestedPostRefund) { if (action instanceof GramJs.MessageActionSuggestedPostRefund) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -259,6 +259,10 @@ export interface ApiAppConfig {
starsSuggestedPostFutureMax?: number; starsSuggestedPostFutureMax?: number;
starsSuggestedPostFutureMin?: number; starsSuggestedPostFutureMin?: number;
tonSuggestedPostCommissionPermille?: number; tonSuggestedPostCommissionPermille?: number;
tonSuggestedPostAmountMax?: number;
tonSuggestedPostAmountMin?: number;
tonUsdRate?: number;
tonTopupUrl?: string;
pollMaxAnswers?: number; pollMaxAnswers?: number;
todoItemsMax?: number; todoItemsMax?: number;
todoTitleLengthMax?: 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 { ApiWebDocument } from './bots';
import type { ApiChat } from './chats'; import type { ApiChat } from './chats';
import type { ApiFormattedText, ApiSticker, BoughtPaidMedia } from './messages'; import type { ApiFormattedText, ApiSticker, BoughtPaidMedia } from './messages';
@ -153,11 +154,19 @@ export type ApiRequestInputSavedStarGiftChat = {
}; };
export type ApiRequestInputSavedStarGift = ApiRequestInputSavedStarGiftUser | ApiRequestInputSavedStarGiftChat; export type ApiRequestInputSavedStarGift = ApiRequestInputSavedStarGiftUser | ApiRequestInputSavedStarGiftChat;
export type ApiTypeCurrencyAmount = ApiStarsAmount | ApiTonAmount;
export interface ApiStarsAmount { export interface ApiStarsAmount {
currency: typeof STARS_CURRENCY_CODE;
amount: number; amount: number;
nanos: number; nanos: number;
} }
export interface ApiTonAmount {
currency: typeof TON_CURRENCY_CODE;
amount: number;
}
export interface ApiStarsTransactionPeerUnsupported { export interface ApiStarsTransactionPeerUnsupported {
type: 'unsupported'; type: 'unsupported';
} }
@ -205,7 +214,7 @@ export interface ApiStarsTransaction {
id?: string; id?: string;
peer: ApiStarsTransactionPeer; peer: ApiStarsTransactionPeer;
messageId?: number; messageId?: number;
stars: ApiStarsAmount; amount: ApiTypeCurrencyAmount;
isRefund?: true; isRefund?: true;
isGift?: true; isGift?: true;
starGift?: ApiStarGift; starGift?: ApiStarGift;

View File

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

View File

@ -657,6 +657,7 @@
"MenuStickers" = "Stickers and Emoji"; "MenuStickers" = "Stickers and Emoji";
"MenuAnimations" = "Animations and Performance"; "MenuAnimations" = "Animations and Performance";
"MenuStars" = "My Stars"; "MenuStars" = "My Stars";
"MenuTon" = "My TON";
"MenuSendGift" = "Send a Gift"; "MenuSendGift" = "Send a Gift";
"MenuTelegramFaq" = "Telegram FAQ"; "MenuTelegramFaq" = "Telegram FAQ";
"MenuPrivacyPolicy" = "Privacy Policy"; "MenuPrivacyPolicy" = "Privacy Policy";
@ -1880,6 +1881,10 @@
"ActionGiftStarsTitle_one" = "{amount} Star"; "ActionGiftStarsTitle_one" = "{amount} Star";
"ActionGiftStarsTitle_other" = "{amount} Stars"; "ActionGiftStarsTitle_other" = "{amount} Stars";
"ActionGiftStarsText" = "Use Stars to unlock content and services on Telegram."; "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_one" = "You boosted the group";
"ActionBoostApplyYou_other" = "You boosted the group {count} times"; "ActionBoostApplyYou_other" = "You boosted the group {count} times";
"ActionBoostApply_one" = "{from} boosted the group"; "ActionBoostApply_one" = "{from} boosted the group";
@ -2040,11 +2045,14 @@
"TitleTime" = "Time"; "TitleTime" = "Time";
"TitleSuggestMessage" = "Suggest a Message"; "TitleSuggestMessage" = "Suggest a Message";
"TitleSuggestedChanges" = "Suggest Changes"; "TitleSuggestedChanges" = "Suggest Changes";
"EnterPriceInStars" = "Enter Price In Stars"; "SuggestMessageNoPrice" = "Free";
"SuggestMessagePriceDescription" = "Choose how many {currency} to pay to publish this post."; "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."; "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."; "SuggestMessageTimeDescription" = "{hint} The post will remain available for at least {duration} from this date.";
"TitleAnytime" = "Anytime"; "SuggestMessageAnytime" = "Anytime";
"ButtonOfferAmount" = "Offer {amount}"; "ButtonOfferAmount" = "Offer {amount}";
"ButtonOfferFree" = "Offer Free"; "ButtonOfferFree" = "Offer Free";
"ButtonUpdateTerms" = "Update Terms"; "ButtonUpdateTerms" = "Update Terms";
@ -2070,6 +2078,7 @@
"SuggestedPostRefundedByUser" = "{channel} will not receive {amount} because {user} requested a refund."; "SuggestedPostRefundedByUser" = "{channel} will not receive {amount} because {user} requested a refund.";
"SuggestedPostRefundedByChannel" = "{amount} was returned to {peer} because {channel} deleted the message."; "SuggestedPostRefundedByChannel" = "{amount} was returned to {peer} because {channel} deleted the message.";
"CurrencyStars" = "Stars"; "CurrencyStars" = "Stars";
"CurrencyTon" = "TON";
"DeclineReasonPlaceholder" = "Add a reason (optional)"; "DeclineReasonPlaceholder" = "Add a reason (optional)";
"DeclinePostDialogQuestion" = "Do you want to decline this post from **{sender}**?"; "DeclinePostDialogQuestion" = "Do you want to decline this post from **{sender}**?";
"SuggestedPostRejected" = "**{peer}** rejected this message."; "SuggestedPostRejected" = "**{peer}** rejected this message.";
@ -2127,3 +2136,8 @@
"ToDoListErrorChooseTasks" = "Please enter at least one task."; "ToDoListErrorChooseTasks" = "Please enter at least one task.";
"GiftInfoCollectibleBy" = "Collectible #{number} by **{owner}**"; "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; font-weight: var(--font-weight-semibold) !important;
line-height: 1; line-height: 1;
animation: hide-icon 0.4s forwards ease-out;
&.visible { &.visible {
animation: grow-icon 0.4s ease-out; animation: grow-icon 0.4s ease-out;
} }
&.hiding {
animation: hide-icon 0.4s forwards ease-out;
}
&.hidden {
pointer-events: none;
opacity: 0;
}
.icon { .icon {
font-size: 0.875rem; font-size: 0.875rem;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -124,7 +124,7 @@ const GiftModal: FC<OwnProps & StateProps> = ({
const allGifts = renderingModal?.gifts; const allGifts = renderingModal?.gifts;
const filteredGifts = useMemo(() => { const filteredGifts = useMemo(() => {
return allGifts?.sort((prevGift, gift) => prevGift.months - gift.months) 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]); }, [allGifts]);
const giftsByStars = useMemo(() => { const giftsByStars = useMemo(() => {

View File

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

View File

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

View File

@ -56,6 +56,7 @@
color: var(--color-primary); color: var(--color-primary);
} }
.hint,
.tos { .tos {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
padding-top: 0; padding-top: 0;
@ -73,6 +74,37 @@
margin: 1rem; 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 { .logoBackground {
position: absolute; position: absolute;
top: 0.75rem; top: 0.75rem;
@ -196,6 +228,7 @@
right: 1.25rem; right: 1.25rem;
} }
.topUpButton,
.starButton { .starButton {
grid-column: 1/-1; grid-column: 1/-1;
gap: 0.5rem; gap: 0.5rem;

View File

@ -8,11 +8,19 @@ import type { ApiStarTopupOption } from '../../../api/types';
import type { GlobalState, TabState } from '../../../global/types'; import type { GlobalState, TabState } from '../../../global/types';
import type { RegularLangKey } from '../../../types/language'; 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 { getChatTitle, getUserFullName } from '../../../global/helpers';
import { getPeerTitle } from '../../../global/helpers/peers'; import { getPeerTitle } from '../../../global/helpers/peers';
import { selectChat, selectIsPremiumPurchaseBlocked, selectUser } from '../../../global/selectors'; import { selectChat, selectIsPremiumPurchaseBlocked, selectUser } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName'; 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 renderText from '../../common/helpers/renderText';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
@ -20,6 +28,7 @@ import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang'; import useOldLang from '../../../hooks/useOldLang';
import AnimatedIconWithPreview from '../../common/AnimatedIconWithPreview';
import Icon from '../../common/icons/Icon'; import Icon from '../../common/icons/Icon';
import SafeLink from '../../common/SafeLink'; import SafeLink from '../../common/SafeLink';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
@ -52,18 +61,25 @@ export type OwnProps = {
type StateProps = { type StateProps = {
starsBalanceState?: GlobalState['stars']; starsBalanceState?: GlobalState['stars'];
tonBalanceState?: GlobalState['ton'];
canBuyPremium?: boolean; canBuyPremium?: boolean;
shouldForceHeight?: boolean; shouldForceHeight?: boolean;
tonUsdRate?: number;
tonTopupUrl: string;
}; };
const StarsBalanceModal = ({ const StarsBalanceModal = ({
modal, starsBalanceState, canBuyPremium, shouldForceHeight, modal, starsBalanceState, tonBalanceState, canBuyPremium, shouldForceHeight, tonUsdRate, tonTopupUrl,
}: OwnProps & StateProps) => { }: OwnProps & StateProps) => {
const { const {
closeStarsBalanceModal, loadStarsTransactions, loadStarsSubscriptions, openStarsGiftingPickerModal, openInvoice, closeStarsBalanceModal, loadStarsTransactions, loadStarsSubscriptions, openStarsGiftingPickerModal, openInvoice,
openUrl,
} = getActions(); } = 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 oldLang = useOldLang();
const lang = useLang(); const lang = useLang();
@ -72,7 +88,7 @@ const StarsBalanceModal = ({
const [selectedTabIndex, setSelectedTabIndex] = useState(0); const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const [areBuyOptionsShown, showBuyOptions, hideBuyOptions] = useFlag(); const [areBuyOptionsShown, showBuyOptions, hideBuyOptions] = useFlag();
const isOpen = Boolean(modal && starsBalanceState); const isOpen = Boolean(modal && (starsBalanceState || tonBalanceState));
const { const {
originStarsPayment, originReaction, originGift, topup, originStarsPayment, originReaction, originGift, topup,
@ -159,6 +175,90 @@ const StarsBalanceModal = ({
]; ];
}, [isOpen, oldLang]); }, [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>) { function handleScroll(e: React.UIEvent<HTMLDivElement>) {
const { scrollTop } = e.currentTarget; const { scrollTop } = e.currentTarget;
@ -168,6 +268,7 @@ const StarsBalanceModal = ({
const handleLoadMoreTransactions = useLastCallback(() => { const handleLoadMoreTransactions = useLastCallback(() => {
loadStarsTransactions({ loadStarsTransactions({
type: TRANSACTION_TYPES[selectedTabIndex], type: TRANSACTION_TYPES[selectedTabIndex],
isTon: currency === TON_CURRENCY_CODE,
}); });
}); });
@ -188,6 +289,10 @@ const StarsBalanceModal = ({
}); });
}); });
const handleTonTopUp = useLastCallback(() => {
openUrl({ url: tonTopupUrl });
});
return ( return (
<Modal <Modal
className={buildClassName(styles.root, !shouldForceHeight && !areBuyOptionsShown && styles.minimal)} className={buildClassName(styles.root, !shouldForceHeight && !areBuyOptionsShown && styles.minimal)}
@ -206,55 +311,25 @@ const StarsBalanceModal = ({
> >
<Icon name="close" /> <Icon name="close" />
</Button> </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)}> <div className={buildClassName(styles.header, isHeaderHidden && styles.hiddenHeader)}>
<h2 className={styles.starHeaderText}> <h2 className={styles.starHeaderText}>
{oldLang('TelegramStars')} {oldLang('TelegramStars')}
</h2> </h2>
</div> </div>
<div className={styles.section}> <div className={styles.section}>
<img className={styles.logo} src={StarLogo} alt="" draggable={false} /> {currency === TON_CURRENCY_CODE ? renderTonSection() : renderStarsSection()}
<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}
/>
)}
</div> </div>
{areBuyOptionsShown && ( {areBuyOptionsShown && (
<div className={styles.tos}> <div className={styles.tos}>
{tosText} {tosText}
</div> </div>
)} )}
{currency === TON_CURRENCY_CODE && (
<div className={styles.hint}>
{lang('TonModalHint')}
</div>
)}
{shouldShowItems && Boolean(subscriptions?.list.length) && ( {shouldShowItems && Boolean(subscriptions?.list.length) && (
<div className={styles.section}> <div className={styles.section}>
<h3 className={styles.sectionTitle}>{oldLang('StarMySubscriptions')}</h3> <h3 className={styles.sectionTitle}>{oldLang('StarMySubscriptions')}</h3>
@ -325,13 +400,18 @@ const StarsBalanceModal = ({
}; };
export default memo(withGlobal<OwnProps>( export default memo(withGlobal<OwnProps>(
(global): StateProps => { (global, { modal }): StateProps => {
const shouldForceHeight = Boolean(global.stars?.history?.all?.transactions.length); const shouldForceHeight = modal?.currency === TON_CURRENCY_CODE
? Boolean(global.ton?.history?.all?.transactions.length)
: Boolean(global.stars?.history?.all?.transactions.length);
return { return {
shouldForceHeight, shouldForceHeight,
starsBalanceState: global.stars, starsBalanceState: global.stars,
tonBalanceState: global.ton,
canBuyPremium: !selectIsPremiumPurchaseBlocked(global), canBuyPremium: !selectIsPremiumPurchaseBlocked(global),
tonUsdRate: global.appConfig?.tonUsdRate || TON_USD_RATE_DEFAULT,
tonTopupUrl: global.appConfig?.tonTopupUrl || TON_TOPUP_URL_DEFAULT,
}; };
}, },
)(StarsBalanceModal)); )(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 type { OldLangFn } from '../../../../hooks/useOldLang';
import { STARS_CURRENCY_CODE, TON_CURRENCY_CODE } from '../../../../config';
import { buildStarsTransactionCustomPeer } from '../../../../global/helpers/payments'; import { buildStarsTransactionCustomPeer } from '../../../../global/helpers/payments';
import { import {
type LangFn, type LangFn,
@ -20,7 +21,7 @@ export function getTransactionTitle(oldLang: OldLangFn, lang: LangFn, transactio
} }
if (transaction.isGiftResale) { if (transaction.isGiftResale) {
return isNegativeStarsAmount(transaction.stars) return isNegativeAmount(transaction.amount)
? lang('StarGiftSaleTransaction') ? lang('StarGiftSaleTransaction')
: lang('StarGiftPurchaseTransaction'); : lang('StarGiftPurchaseTransaction');
} }
@ -34,9 +35,14 @@ export function getTransactionTitle(oldLang: OldLangFn, lang: LangFn, transactio
if (transaction.isReaction) return oldLang('StarsReactionsSent'); if (transaction.isReaction) return oldLang('StarsReactionsSent');
if (transaction.giveawayPostId) return oldLang('StarsGiveawayPrizeReceived'); if (transaction.giveawayPostId) return oldLang('StarsGiveawayPrizeReceived');
if (transaction.isMyGift) return oldLang('StarsGiftSent'); 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) { 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' const customPeer = (transaction.peer && transaction.peer.type !== 'peer'
@ -51,3 +57,10 @@ export function isNegativeStarsAmount(starsAmount: ApiStarsAmount) {
if (starsAmount.amount) return starsAmount.amount < 0; if (starsAmount.amount) return starsAmount.amount < 0;
return starsAmount.nanos < 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 { GlobalState } from '../../../../global/types';
import type { CustomPeer } from '../../../../types'; import type { CustomPeer } from '../../../../types';
import { STARS_CURRENCY_CODE, TON_CURRENCY_CODE } from '../../../../config';
import { buildStarsTransactionCustomPeer, formatStarsTransactionAmount } from '../../../../global/helpers/payments'; import { buildStarsTransactionCustomPeer, formatStarsTransactionAmount } from '../../../../global/helpers/payments';
import { getPeerTitle } from '../../../../global/helpers/peers'; import { getPeerTitle } from '../../../../global/helpers/peers';
import { selectPeer } from '../../../../global/selectors'; import { selectPeer } from '../../../../global/selectors';
@ -16,7 +17,7 @@ import { formatDateTimeToString } from '../../../../util/dates/dateFormat';
import { CUSTOM_PEER_PREMIUM } from '../../../../util/objects/customPeer'; import { CUSTOM_PEER_PREMIUM } from '../../../../util/objects/customPeer';
import { getGiftAttributes, getStickerFromGift } from '../../../common/helpers/gifts'; import { getGiftAttributes, getStickerFromGift } from '../../../common/helpers/gifts';
import renderText from '../../../common/helpers/renderText'; 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 useSelector from '../../../../hooks/data/useSelector';
import useLang from '../../../../hooks/useLang'; import useLang from '../../../../hooks/useLang';
@ -48,7 +49,7 @@ const StarsTransactionItem = ({ transaction, className }: OwnProps) => {
const { openStarsTransactionModal } = getActions(); const { openStarsTransactionModal } = getActions();
const { const {
date, date,
stars, amount,
photo, photo,
peer: transactionPeer, peer: transactionPeer,
extendedMedia, extendedMedia,
@ -73,7 +74,10 @@ const StarsTransactionItem = ({ transaction, className }: OwnProps) => {
description = peer && getPeerTitle(oldLang, peer); description = peer && getPeerTitle(oldLang, peer);
avatarPeer = peer || CUSTOM_PEER_PREMIUM; avatarPeer = peer || CUSTOM_PEER_PREMIUM;
} else { } else {
const customPeer = buildStarsTransactionCustomPeer(transaction.peer); const customPeer = buildStarsTransactionCustomPeer(
transaction.peer,
transaction.amount.currency === TON_CURRENCY_CODE,
);
title = customPeer.title || oldLang(customPeer.titleKey!); title = customPeer.title || oldLang(customPeer.titleKey!);
description = oldLang(customPeer.subtitleKey!); description = oldLang(customPeer.subtitleKey!);
avatarPeer = customPeer; avatarPeer = customPeer;
@ -178,11 +182,11 @@ const StarsTransactionItem = ({ transaction, className }: OwnProps) => {
</div> </div>
<div className={styles.stars}> <div className={styles.stars}>
<span <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> </span>
<StarIcon className={styles.star} type="gold" size="adaptive" /> {amount.currency === STARS_CURRENCY_CODE && <StarIcon className={styles.star} type="gold" size="adaptive" />}
</div> </div>
</div> </div>
); );

View File

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

View File

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

View File

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

View File

@ -33,10 +33,12 @@ import {
MESSAGE_LIST_SLICE, MESSAGE_LIST_SLICE,
RE_TELEGRAM_LINK, RE_TELEGRAM_LINK,
SERVICE_NOTIFICATIONS_USER_ID, SERVICE_NOTIFICATIONS_USER_ID,
STARS_CURRENCY_CODE,
STARS_SUGGESTED_POST_FUTURE_MIN, STARS_SUGGESTED_POST_FUTURE_MIN,
SUPPORTED_AUDIO_CONTENT_TYPES, SUPPORTED_AUDIO_CONTENT_TYPES,
SUPPORTED_PHOTO_CONTENT_TYPES, SUPPORTED_PHOTO_CONTENT_TYPES,
SUPPORTED_VIDEO_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES,
TON_CURRENCY_CODE,
} from '../../../config'; } from '../../../config';
import { ensureProtocol, isMixedScriptUrl } from '../../../util/browser/url'; import { ensureProtocol, isMixedScriptUrl } from '../../../util/browser/url';
import { IS_IOS } from '../../../util/browser/windowEnvironment'; 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 messagePriceInStars = await getPeerStarsForMessage(global, chatId!);
const suggestedPostPrice = draftSuggestedPostInfo?.price?.amount || 0; const suggestedPostPrice = draftSuggestedPostInfo?.price;
if (suggestedPostPrice && !draftReplyInfo) { const suggestedPostCurrency = suggestedPostPrice?.currency || STARS_CURRENCY_CODE;
const currentBalance = global.stars?.balance?.amount || 0; const suggestedPostAmount = suggestedPostPrice?.amount || 0;
if (suggestedPostAmount && !draftReplyInfo) {
if (suggestedPostCurrency === STARS_CURRENCY_CODE) {
const currentBalance = global.stars?.balance?.amount || 0;
if (suggestedPostPrice > currentBalance) { if (suggestedPostAmount > currentBalance) {
actions.openStarsBalanceModal({ actions.openStarsBalanceModal({
topup: { topup: {
balanceNeeded: suggestedPostPrice, balanceNeeded: suggestedPostAmount,
}, },
tabId, tabId,
}); });
return; 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) { if (!isAdmin && message?.suggestedPostInfo?.price?.amount) {
const neededAmount = 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) { if (isCurrencyStars) {
actions.openStarsBalanceModal({ const currentBalance = global.stars?.balance?.amount || 0;
topup: { if (neededAmount > currentBalance) {
balanceNeeded: neededAmount, actions.openStarsBalanceModal({
}, topup: {
tabId, balanceNeeded: neededAmount,
}); },
return; 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 { StarGiftCategory } from '../../../types';
import type { ActionReturnType } 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 { getCurrentTabId } from '../../../util/establishMultitabRole';
import { buildCollectionByKey } from '../../../util/iteratees'; import { buildCollectionByKey } from '../../../util/iteratees';
import { callApi } from '../../../api/gramjs'; import { callApi } from '../../../api/gramjs';
@ -26,57 +31,82 @@ import {
} from '../../selectors'; } from '../../selectors';
addActionHandler('loadStarStatus', async (global): Promise<void> => { addActionHandler('loadStarStatus', async (global): Promise<void> => {
const currentStatus = global.stars; const currentStarsStatus = global.stars;
const needsTopupOptions = !currentStatus?.topupOptions; const needsTopupOptions = !currentStarsStatus?.topupOptions;
const [status, topupOptions] = await Promise.all([ const [starsStatus, tonStatus, topupOptions] = await Promise.all([
callApi('fetchStarsStatus'), callApi('fetchStarsStatus'),
callApi('fetchStarsStatus', { isTon: true }),
needsTopupOptions ? callApi('fetchStarsTopupOptions') : undefined, needsTopupOptions ? callApi('fetchStarsTopupOptions') : undefined,
]); ]);
if (!status || (needsTopupOptions && !topupOptions)) { if (!(starsStatus || tonStatus) || (needsTopupOptions && !topupOptions)) {
return; return;
} }
global = getGlobal(); global = getGlobal();
global = { if (starsStatus && starsStatus.balance.currency === STARS_CURRENCY_CODE) {
...global, global = {
stars: { ...global,
...currentStatus, stars: {
balance: status.balance, ...currentStarsStatus,
topupOptions: topupOptions || currentStatus!.topupOptions, balance: starsStatus.balance,
history: { topupOptions: topupOptions || currentStarsStatus!.topupOptions,
all: undefined, history: {
inbound: undefined, all: undefined,
outbound: undefined, inbound: undefined,
outbound: undefined,
},
subscriptions: undefined,
}, },
subscriptions: undefined, };
},
};
if (status.history) { if (starsStatus.history) {
global = appendStarsTransactions(global, 'all', status.history, status.nextHistoryOffset); global = appendStarsTransactions(global, 'all', starsStatus.history, starsStatus.nextHistoryOffset);
}
if (starsStatus.subscriptions) {
global = appendStarsSubscriptions(global, starsStatus.subscriptions, starsStatus.nextSubscriptionOffset);
}
} }
if (status.subscriptions) { if (tonStatus?.balance.currency === TON_CURRENCY_CODE) {
global = appendStarsSubscriptions(global, status.subscriptions, status.nextSubscriptionOffset); 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); setGlobal(global);
}); });
addActionHandler('loadStarsTransactions', async (global, actions, payload): Promise<void> => { 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; const offset = history?.nextOffset;
if (history && !offset) return; // Already loaded all if (history && !offset) return; // Already loaded all
const result = await callApi('fetchStarsTransactions', { const result = await callApi('fetchStarsTransactions', {
isInbound: type === 'inbound' || undefined, isInbound: type === 'inbound',
isOutbound: type === 'outbound' || undefined, isOutbound: type === 'outbound',
offset: offset || '', offset: offset || '',
isTon,
}); });
if (!result) { if (!result) {
@ -87,7 +117,7 @@ addActionHandler('loadStarsTransactions', async (global, actions, payload): Prom
global = updateStarsBalance(global, result.balance); global = updateStarsBalance(global, result.balance);
if (result.history) { if (result.history) {
global = appendStarsTransactions(global, type, result.history, result.nextOffset); global = appendStarsTransactions(global, type, result.history, result.nextOffset, isTon);
} }
setGlobal(global); setGlobal(global);
}); });
@ -315,7 +345,7 @@ addActionHandler('loadStarsSubscriptions', async (global): Promise<void> => {
offset: offset || '', offset: offset || '',
}); });
if (!result) { if (!result || result.balance.currency !== STARS_CURRENCY_CODE) {
return; return;
} }

View File

@ -215,6 +215,22 @@ addActionHandler('loadPremiumGifts', async (global): Promise<void> => {
setGlobal(global); 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> => { addActionHandler('loadDefaultTopicIcons', async (global): Promise<void> => {
const stickerSet = await callApi('fetchDefaultTopicIcons'); const stickerSet = await callApi('fetchDefaultTopicIcons');
if (!stickerSet) { if (!stickerSet) {

View File

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

View File

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

View File

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

View File

@ -1,8 +1,9 @@
import type { ApiSticker, ApiStickerSet, ApiStickerSetInfo } from '../../api/types'; import type { ApiSticker, ApiStickerSet, ApiStickerSetInfo } from '../../api/types';
import type { GlobalState, TabArgs } from '../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 { getCurrentTabId } from '../../util/establishMultitabRole';
import { convertCurrencyFromBaseUnit } from '../../util/formatCurrency';
import { selectTabState } from './tabs'; import { selectTabState } from './tabs';
import { selectIsCurrentUserPremium } from './users'; import { selectIsCurrentUserPremium } from './users';
@ -21,6 +22,12 @@ const STAR_EMOTICON: Record<number, string> = {
5000: `${4}\u{FE0F}\u20E3`, 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) { export function selectIsStickerFavorite<T extends GlobalState>(global: T, sticker: ApiSticker) {
const { stickers } = global.stickers.favorite; const { stickers } = global.stickers.favorite;
return stickers && stickers.some(({ id }) => id === sticker.id); 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]; 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, ApiStickerSetInfo,
ApiThemeParameters, ApiThemeParameters,
ApiTodoItem, ApiTodoItem,
ApiTypeCurrencyAmount,
ApiTypePrepaidGiveaway, ApiTypePrepaidGiveaway,
ApiUpdate, ApiUpdate,
ApiUser, ApiUser,
@ -1278,6 +1279,7 @@ export interface ActionPayloads {
loadStarStatus: undefined; loadStarStatus: undefined;
loadStarsTransactions: { loadStarsTransactions: {
type: StarsTransactionType; type: StarsTransactionType;
isTon?: boolean;
}; };
loadStarsSubscriptions: undefined; loadStarsSubscriptions: undefined;
changeStarsSubscription: { changeStarsSubscription: {
@ -1302,6 +1304,7 @@ export interface ActionPayloads {
purpose?: string; purpose?: string;
}; };
shouldIgnoreBalance?: boolean; shouldIgnoreBalance?: boolean;
currency?: ApiTypeCurrencyAmount['currency'];
} & WithTabId; } & WithTabId;
closeStarsBalanceModal: WithTabId | undefined; closeStarsBalanceModal: WithTabId | undefined;
@ -2483,6 +2486,7 @@ export interface ActionPayloads {
}; };
loadPremiumGifts: undefined; loadPremiumGifts: undefined;
loadTonGifts: undefined;
loadStarGifts: undefined; loadStarGifts: undefined;
updateResaleGiftsFilter: { updateResaleGiftsFilter: {
filter: ResaleGiftsFilterOptions; filter: ResaleGiftsFilterOptions;

View File

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

View File

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

View File

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

View File

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

View File

@ -2,8 +2,24 @@ import { type TeactNode } from '../lib/teact/teact';
import type { LangFn } from './localization'; import type { LangFn } from './localization';
import { STARS_CURRENCY_CODE } from '../config'; import { STARS_CURRENCY_CODE, TON_CURRENCY_CODE } from '../config';
import { formatStarsAsIcon } from './localization/format'; 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( export function formatCurrency(
lang: LangFn, lang: LangFn,
@ -15,15 +31,24 @@ export function formatCurrency(
asFontIcon?: boolean; asFontIcon?: boolean;
}, },
): TeactNode { ): TeactNode {
const price = totalPrice / 10 ** getCurrencyExp(currency); const price = convertCurrencyFromBaseUnit(totalPrice, currency);
if (currency === STARS_CURRENCY_CODE) { if (currency === STARS_CURRENCY_CODE) {
return formatStarsAsIcon(lang, price, { asFont: options?.asFontIcon, className: options?.iconClassName }); 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); 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( export function formatCurrencyAsString(
totalPrice: number, totalPrice: number,
currency: string, currency: string,
@ -32,7 +57,7 @@ export function formatCurrencyAsString(
shouldOmitFractions?: boolean; shouldOmitFractions?: boolean;
}, },
) { ) {
const price = totalPrice / 10 ** getCurrencyExp(currency); const price = convertCurrencyFromBaseUnit(totalPrice, currency);
if ((options?.shouldOmitFractions || currency === STARS_CURRENCY_CODE) && Number.isInteger(price)) { if ((options?.shouldOmitFractions || currency === STARS_CURRENCY_CODE) && Number.isInteger(price)) {
return new Intl.NumberFormat(locale, { return new Intl.NumberFormat(locale, {
@ -43,6 +68,15 @@ export function formatCurrencyAsString(
}).format(price); }).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, { return new Intl.NumberFormat(locale, {
style: 'currency', style: 'currency',
currency, currency,
@ -50,7 +84,7 @@ export function formatCurrencyAsString(
} }
function getCurrencyExp(currency: string) { function getCurrencyExp(currency: string) {
if (currency === 'TON') { if (currency === TON_CURRENCY_CODE) {
return 9; return 9;
} }
if (currency === 'CLF') { if (currency === 'CLF') {

View File

@ -1,6 +1,7 @@
import type { LangFn } from './types'; import type { LangFn } from './types';
import { STARS_ICON_PLACEHOLDER } from '../../config'; import { STARS_ICON_PLACEHOLDER } from '../../config';
import { convertCurrencyFromBaseUnit } from '../../util/formatCurrency';
import buildClassName from '../buildClassName'; import buildClassName from '../buildClassName';
import Icon from '../../components/common/icons/Icon'; import Icon from '../../components/common/icons/Icon';
@ -10,6 +11,37 @@ export function formatStarsAsText(lang: LangFn, amount: number) {
return lang('StarsAmountText', { amount }, { pluralValue: amount }); 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?: { export function formatStarsAsIcon(lang: LangFn, amount: number | string, options?: {
asFont?: boolean; className?: string; containerClassName?: string; }) { asFont?: boolean; className?: string; containerClassName?: string; }) {
const { asFont, className, containerClassName } = options || {}; const { asFont, className, containerClassName } = options || {};

View File

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

View File

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