Support paid messages (#5712)

This commit is contained in:
Alexander Zinchuk 2025-04-04 13:03:55 +02:00
parent dc61b12f9b
commit 5bb202857d
87 changed files with 2337 additions and 586 deletions

View File

@ -87,6 +87,10 @@ export interface GramJsAppConfig extends LimitsConfig {
stargifts_convert_period_max?: number; stargifts_convert_period_max?: number;
starref_start_param_prefixes?: string[]; starref_start_param_prefixes?: string[];
ton_blockchain_explorer_url?: string; ton_blockchain_explorer_url?: string;
stars_paid_messages_available?: boolean;
stars_usd_withdraw_rate_x1000?: number;
stars_paid_message_commission_permille?: number;
stars_paid_message_amount_max?: number;
stargifts_pinned_to_top_limit?: number; stargifts_pinned_to_top_limit?: number;
} }
@ -164,6 +168,10 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp
maxPinnedStoriesCount: appConfig.stories_pinned_to_top_count_max, maxPinnedStoriesCount: appConfig.stories_pinned_to_top_count_max,
groupTranscribeLevelMin: appConfig.group_transcribe_level_min, groupTranscribeLevelMin: appConfig.group_transcribe_level_min,
canLimitNewMessagesWithoutPremium: appConfig.new_noncontact_peers_require_premium_without_ownpremium, canLimitNewMessagesWithoutPremium: appConfig.new_noncontact_peers_require_premium_without_ownpremium,
starsPaidMessagesAvailable: appConfig.stars_paid_messages_available,
starsPaidMessageCommissionPermille: appConfig.stars_paid_message_commission_permille,
starsPaidMessageAmountMax: appConfig.stars_paid_message_amount_max,
starsUsdWithdrawRateX1000: appConfig.stars_usd_withdraw_rate_x1000,
bandwidthPremiumNotifyPeriod: appConfig.upload_premium_speedup_notify_period, bandwidthPremiumNotifyPeriod: appConfig.upload_premium_speedup_notify_period,
bandwidthPremiumUploadSpeedup: appConfig.upload_premium_speedup_upload, bandwidthPremiumUploadSpeedup: appConfig.upload_premium_speedup_upload,
bandwidthPremiumDownloadSpeedup: appConfig.upload_premium_speedup_download, bandwidthPremiumDownloadSpeedup: appConfig.upload_premium_speedup_download,

View File

@ -75,6 +75,7 @@ function buildApiChatFieldsFromPeerEntity(
const boostLevel = ('level' in peerEntity) ? peerEntity.level : undefined; const boostLevel = ('level' in peerEntity) ? peerEntity.level : undefined;
const areProfilesShown = Boolean('signatureProfiles' in peerEntity && peerEntity.signatureProfiles); const areProfilesShown = Boolean('signatureProfiles' in peerEntity && peerEntity.signatureProfiles);
const subscriptionUntil = 'subscriptionUntilDate' in peerEntity ? peerEntity.subscriptionUntilDate : undefined; const subscriptionUntil = 'subscriptionUntilDate' in peerEntity ? peerEntity.subscriptionUntilDate : undefined;
const paidMessagesStars = 'sendPaidMessagesStars' in peerEntity ? peerEntity.sendPaidMessagesStars : undefined;
return { return {
isMin, isMin,
@ -110,6 +111,7 @@ function buildApiChatFieldsFromPeerEntity(
boostLevel, boostLevel,
botVerificationIconId, botVerificationIconId,
subscriptionUntil, subscriptionUntil,
paidMessagesStars: paidMessagesStars?.toJSNumber(),
}; };
} }

View File

@ -267,6 +267,7 @@ export function buildApiMessageWithChatId(
isInvertedMedia, isInvertedMedia,
isVideoProcessingPending, isVideoProcessingPending,
reportDeliveryUntilDate: mtpMessage.reportDeliveryUntilDate, reportDeliveryUntilDate: mtpMessage.reportDeliveryUntilDate,
paidMessageStars: mtpMessage.paidMessageStars?.toJSNumber(),
}; };
} }
@ -392,6 +393,8 @@ export function buildLocalMessage(
story?: ApiStory | ApiStorySkipped, story?: ApiStory | ApiStorySkipped,
isInvertedMedia?: true, isInvertedMedia?: true,
effectId?: string, effectId?: string,
isPending?: true,
messagePriceInStars?: number,
) { ) {
const localId = getNextLocalMessageId(lastMessageId); const localId = getNextLocalMessageId(lastMessageId);
const media = attachment && buildUploadingMedia(attachment); const media = attachment && buildUploadingMedia(attachment);
@ -427,11 +430,13 @@ export function buildLocalMessage(
isForwardingAllowed: true, isForwardingAllowed: true,
isInvertedMedia, isInvertedMedia,
effectId, effectId,
...(isPending && { sendingState: 'messageSendingStatePending' }),
...(messagePriceInStars && { paidMessageStars: messagePriceInStars }),
} satisfies ApiMessage; } satisfies ApiMessage;
const emojiOnlyCount = getEmojiOnlyCountForMessage(message.content, message.groupedId); const emojiOnlyCount = getEmojiOnlyCountForMessage(message.content, message.groupedId);
const finalMessage = { const finalMessage : ApiMessage = {
...message, ...message,
...(emojiOnlyCount && { emojiOnlyCount }), ...(emojiOnlyCount && { emojiOnlyCount }),
}; };

View File

@ -101,6 +101,8 @@ export function buildPrivacyKey(key: GramJs.TypePrivacyKey): ApiPrivacyKey | und
return 'birthday'; return 'birthday';
case 'PrivacyKeyStarGiftsAutoSave': case 'PrivacyKeyStarGiftsAutoSave':
return 'gifts'; return 'gifts';
case 'PrivacyKeyNoPaidMessages':
return 'noPaidMessages';
} }
return undefined; return undefined;

View File

@ -535,7 +535,7 @@ export function buildApiStarsTransactionPeer(peer: GramJs.TypeStarsTransactionPe
export function buildApiStarsTransaction(transaction: GramJs.StarsTransaction): ApiStarsTransaction { export function buildApiStarsTransaction(transaction: GramJs.StarsTransaction): ApiStarsTransaction {
const { const {
date, id, peer, stars, description, photo, title, refund, extendedMedia, failed, msgId, pending, gift, reaction, date, id, peer, stars, description, photo, title, refund, extendedMedia, failed, msgId, pending, gift, reaction,
subscriptionPeriod, stargift, giveawayPostId, starrefCommissionPermille, stargiftUpgrade, subscriptionPeriod, stargift, giveawayPostId, starrefCommissionPermille, stargiftUpgrade, paidMessages,
} = transaction; } = transaction;
if (photo) { if (photo) {
@ -567,6 +567,7 @@ export function buildApiStarsTransaction(transaction: GramJs.StarsTransaction):
giveawayPostId, giveawayPostId,
starRefCommision, starRefCommision,
isGiftUpgrade: stargiftUpgrade, isGiftUpgrade: stargiftUpgrade,
paidMessages,
}; };
} }

View File

@ -25,7 +25,7 @@ export function buildApiUserFullInfo(mtpUserFull: GramJs.users.UserFull): ApiUse
fallbackPhoto, personalPhoto, translationsDisabled, storiesPinnedAvailable, fallbackPhoto, personalPhoto, translationsDisabled, storiesPinnedAvailable,
contactRequirePremium, businessWorkHours, businessLocation, businessIntro, contactRequirePremium, businessWorkHours, businessLocation, businessIntro,
birthday, personalChannelId, personalChannelMessage, sponsoredEnabled, stargiftsCount, botVerification, birthday, personalChannelId, personalChannelMessage, sponsoredEnabled, stargiftsCount, botVerification,
botCanManageEmojiStatus, settings, botCanManageEmojiStatus, settings, sendPaidMessagesStars,
}, },
users, users,
} = mtpUserFull; } = mtpUserFull;
@ -56,6 +56,7 @@ export function buildApiUserFullInfo(mtpUserFull: GramJs.users.UserFull): ApiUse
starGiftCount: stargiftsCount, starGiftCount: stargiftsCount,
isBotCanManageEmojiStatus: botCanManageEmojiStatus, isBotCanManageEmojiStatus: botCanManageEmojiStatus,
hasScheduledMessages: hasScheduled, hasScheduledMessages: hasScheduled,
paidMessagesStars: sendPaidMessagesStars?.toJSNumber(),
settings: buildApiPeerSettings(settings), settings: buildApiPeerSettings(settings),
}; };
} }
@ -69,6 +70,7 @@ export function buildApiPeerSettings({
phoneCountry, phoneCountry,
nameChangeDate, nameChangeDate,
photoChangeDate, photoChangeDate,
chargePaidMessageStars,
}: GramJs.PeerSettings): ApiPeerSettings { }: GramJs.PeerSettings): ApiPeerSettings {
return { return {
isAutoArchived: Boolean(autoarchived), isAutoArchived: Boolean(autoarchived),
@ -79,6 +81,7 @@ export function buildApiPeerSettings({
phoneCountry, phoneCountry,
nameChangeDate, nameChangeDate,
photoChangeDate, photoChangeDate,
chargedPaidMessageStars: chargePaidMessageStars?.toJSNumber(),
}; };
} }
@ -90,6 +93,7 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined {
const { const {
id, firstName, lastName, fake, scam, support, closeFriend, storiesUnavailable, storiesMaxId, id, firstName, lastName, fake, scam, support, closeFriend, storiesUnavailable, storiesMaxId,
bot, botActiveUsers, botVerificationIcon, botInlinePlaceholder, botAttachMenu, botCanEdit, bot, botActiveUsers, botVerificationIcon, botInlinePlaceholder, botAttachMenu, botCanEdit,
sendPaidMessagesStars,
} = mtpUser; } = mtpUser;
const hasVideoAvatar = mtpUser.photo instanceof GramJs.UserProfilePhoto ? Boolean(mtpUser.photo.hasVideo) : undefined; const hasVideoAvatar = mtpUser.photo instanceof GramJs.UserProfilePhoto ? Boolean(mtpUser.photo.hasVideo) : undefined;
const avatarPhotoId = mtpUser.photo && buildAvatarPhotoId(mtpUser.photo); const avatarPhotoId = mtpUser.photo && buildAvatarPhotoId(mtpUser.photo);
@ -128,6 +132,7 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined {
botActiveUsers, botActiveUsers,
botVerificationIconId: botVerificationIcon?.toString(), botVerificationIconId: botVerificationIcon?.toString(),
color: mtpUser.color && buildApiPeerColor(mtpUser.color), color: mtpUser.color && buildApiPeerColor(mtpUser.color),
paidMessagesStars: sendPaidMessagesStars?.toJSNumber(),
}; };
} }

View File

@ -488,6 +488,9 @@ export function buildInputPrivacyKey(privacyKey: ApiPrivacyKey) {
case 'gifts': case 'gifts':
return new GramJs.InputPrivacyKeyStarGiftsAutoSave(); return new GramJs.InputPrivacyKeyStarGiftsAutoSave();
case 'noPaidMessages':
return new GramJs.InputPrivacyKeyNoPaidMessages();
} }
return undefined; return undefined;

View File

@ -143,7 +143,7 @@ export async function fetchInlineBotResults({
} }
export async function sendInlineBotResult({ export async function sendInlineBotResult({
chat, replyInfo, resultId, queryId, sendAs, isSilent, scheduleDate, chat, replyInfo, resultId, queryId, sendAs, isSilent, scheduleDate, allowPaidStars,
}: { }: {
chat: ApiChat; chat: ApiChat;
replyInfo?: ApiInputMessageReplyInfo; replyInfo?: ApiInputMessageReplyInfo;
@ -152,6 +152,7 @@ export async function sendInlineBotResult({
sendAs?: ApiPeer; sendAs?: ApiPeer;
isSilent?: boolean; isSilent?: boolean;
scheduleDate?: number; scheduleDate?: number;
allowPaidStars?: number;
}) { }) {
const randomId = generateRandomBigInt(); const randomId = generateRandomBigInt();
@ -165,6 +166,7 @@ export async function sendInlineBotResult({
replyTo: replyInfo && buildInputReplyTo(replyInfo), replyTo: replyInfo && buildInputReplyTo(replyInfo),
...(isSilent && { silent: true }), ...(isSilent && { silent: true }),
...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }), ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }),
...(allowPaidStars && { allowPaidStars: BigInt(allowPaidStars) }),
})); }));
} }

View File

@ -2,11 +2,14 @@ import BigInt from 'big-integer';
import { Api as GramJs } from '../../../lib/gramjs'; import { Api as GramJs } from '../../../lib/gramjs';
import { RPCError } from '../../../lib/gramjs/errors'; import { RPCError } from '../../../lib/gramjs/errors';
import type { ThreadId, WebPageMediaSize } from '../../../types'; import type {
ForwardMessagesParams,
SendMessageParams,
ThreadId,
} from '../../../types';
import type { import type {
ApiAttachment, ApiAttachment,
ApiChat, ApiChat,
ApiContact,
ApiError, ApiError,
ApiFormattedText, ApiFormattedText,
ApiGlobalMessageSearchType, ApiGlobalMessageSearchType,
@ -15,18 +18,13 @@ import type {
ApiMessageEntity, ApiMessageEntity,
ApiMessageSearchContext, ApiMessageSearchContext,
ApiMessageSearchType, ApiMessageSearchType,
ApiNewPoll,
ApiOnProgress, ApiOnProgress,
ApiPeer, ApiPeer,
ApiPoll, ApiPoll,
ApiReaction, ApiReaction,
ApiSendMessageAction, ApiSendMessageAction,
ApiSticker,
ApiStory,
ApiStorySkipped,
ApiUser, ApiUser,
ApiUserStatus, ApiUserStatus,
ApiVideo,
MediaContent, MediaContent,
} from '../../types'; } from '../../types';
import { import {
@ -255,56 +253,16 @@ export async function fetchMessage({ chat, messageId }: { chat: ApiChat; message
let mediaQueue = Promise.resolve(); let mediaQueue = Promise.resolve();
export function sendMessage( export function sendMessageLocal(
{ params: SendMessageParams,
chat,
lastMessageId,
text,
entities,
replyInfo,
attachment,
sticker,
story,
gif,
poll,
contact,
isSilent,
scheduledAt,
groupedId,
noWebPage,
sendAs,
shouldUpdateStickerSetOrder,
wasDrafted,
isInvertedMedia,
effectId,
webPageMediaSize,
webPageUrl,
}: {
chat: ApiChat;
lastMessageId?: number;
text?: string;
entities?: ApiMessageEntity[];
replyInfo?: ApiInputReplyInfo;
attachment?: ApiAttachment;
sticker?: ApiSticker;
story?: ApiStory | ApiStorySkipped;
gif?: ApiVideo;
poll?: ApiNewPoll;
contact?: ApiContact;
isSilent?: boolean;
scheduledAt?: number;
groupedId?: string;
noWebPage?: boolean;
sendAs?: ApiPeer;
shouldUpdateStickerSetOrder?: boolean;
wasDrafted?: boolean;
isInvertedMedia?: true;
effectId?: string;
webPageMediaSize?: WebPageMediaSize;
webPageUrl?: string;
},
onProgress?: ApiOnProgress,
) { ) {
const {
chat, lastMessageId, text, entities, replyInfo, attachment, sticker, story, gif, poll, contact,
scheduledAt, groupedId, sendAs, wasDrafted, isInvertedMedia, effectId, isPending, messagePriceInStars,
} = params;
if (!chat) return undefined;
const { const {
message: localMessage, message: localMessage,
poll: localPoll, poll: localPoll,
@ -325,6 +283,8 @@ export function sendMessage(
story, story,
isInvertedMedia, isInvertedMedia,
effectId, effectId,
isPending,
messagePriceInStars,
); );
sendApiUpdate({ sendApiUpdate({
@ -336,6 +296,22 @@ export function sendMessage(
wasDrafted, wasDrafted,
}); });
return localMessage;
}
export function sendApiMessage(
params: SendMessageParams,
localMessage: ApiMessage,
onProgress?: ApiOnProgress,
) {
const {
chat, text, entities, replyInfo, attachment, sticker, story, gif, poll, contact,
isSilent, scheduledAt, groupedId, noWebPage, sendAs, shouldUpdateStickerSetOrder,
isInvertedMedia, effectId, webPageMediaSize, webPageUrl, messagePriceInStars,
} = params;
if (!chat) return undefined;
// This is expected to arrive after `updateMessageSendSucceeded` which replaces the local ID, // This is expected to arrive after `updateMessageSendSucceeded` which replaces the local ID,
// so in most cases this will be simply ignored // so in most cases this will be simply ignored
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
@ -361,6 +337,7 @@ export function sendMessage(
groupedId, groupedId,
isSilent, isSilent,
scheduledAt, scheduledAt,
messagePriceInStars,
}, randomId, localMessage, onProgress); }, randomId, localMessage, onProgress);
} }
@ -420,6 +397,7 @@ export function sendMessage(
...(shouldUpdateStickerSetOrder && { updateStickersetsOrder: shouldUpdateStickerSetOrder }), ...(shouldUpdateStickerSetOrder && { updateStickersetsOrder: shouldUpdateStickerSetOrder }),
...(isInvertedMedia && { invertMedia: isInvertedMedia }), ...(isInvertedMedia && { invertMedia: isInvertedMedia }),
...(effectId && { effect: BigInt(effectId) }), ...(effectId && { effect: BigInt(effectId) }),
...(messagePriceInStars && { allowPaidStars: BigInt(messagePriceInStars) }),
}), { }), {
shouldThrow: true, shouldThrow: true,
shouldIgnoreUpdates: true, shouldIgnoreUpdates: true,
@ -443,6 +421,14 @@ export function sendMessage(
return messagePromise; return messagePromise;
} }
export function sendMessage(
params: SendMessageParams,
onProgress?: ApiOnProgress,
) {
const localMessage = params.localMessage || sendMessageLocal(params);
return localMessage ? sendApiMessage(params, localMessage, onProgress) : undefined;
}
const groupedUploads: Record<string, { const groupedUploads: Record<string, {
counter: number; counter: number;
singleMediaByIndex: Record<number, GramJs.InputSingleMedia>; singleMediaByIndex: Record<number, GramJs.InputSingleMedia>;
@ -460,6 +446,7 @@ function sendGroupedMedia(
isSilent, isSilent,
scheduledAt, scheduledAt,
sendAs, sendAs,
messagePriceInStars,
}: { }: {
chat: ApiChat; chat: ApiChat;
text?: string; text?: string;
@ -470,6 +457,7 @@ function sendGroupedMedia(
isSilent?: boolean; isSilent?: boolean;
scheduledAt?: number; scheduledAt?: number;
sendAs?: ApiPeer; sendAs?: ApiPeer;
messagePriceInStars?: number;
}, },
randomId: GramJs.long, randomId: GramJs.long,
localMessage: ApiMessage, localMessage: ApiMessage,
@ -536,6 +524,7 @@ function sendGroupedMedia(
const { singleMediaByIndex, localMessages } = groupedUploads[groupedId]; const { singleMediaByIndex, localMessages } = groupedUploads[groupedId];
delete groupedUploads[groupedId]; delete groupedUploads[groupedId];
const count = Object.values(singleMediaByIndex).length;
const update = await invokeRequest(new GramJs.messages.SendMultiMedia({ const update = await invokeRequest(new GramJs.messages.SendMultiMedia({
clearDraft: true, clearDraft: true,
@ -545,6 +534,7 @@ function sendGroupedMedia(
...(isSilent && { silent: isSilent }), ...(isSilent && { silent: isSilent }),
...(scheduledAt && { scheduleDate: scheduledAt }), ...(scheduledAt && { scheduleDate: scheduledAt }),
...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }), ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }),
...(messagePriceInStars && { allowPaidStars: BigInt(messagePriceInStars * count) }),
}), { }), {
shouldIgnoreUpdates: true, shouldIgnoreUpdates: true,
}); });
@ -1534,40 +1524,17 @@ export async function fetchExtendedMedia({
})); }));
} }
export async function forwardMessages({ export function forwardMessagesLocal(params: ForwardMessagesParams) {
fromChat, const {
toChat, toChat, toThreadId, messages,
toThreadId, scheduledAt, sendAs, noAuthors, noCaptions,
messages, isCurrentUserPremium, wasDrafted, lastMessageId,
isSilent, } = params;
scheduledAt,
sendAs,
withMyScore,
noAuthors,
noCaptions,
isCurrentUserPremium,
wasDrafted,
lastMessageId,
}: {
fromChat: ApiChat;
toChat: ApiChat;
toThreadId?: ThreadId;
messages: ApiMessage[];
isSilent?: boolean;
scheduledAt?: number;
sendAs?: ApiPeer;
withMyScore?: boolean;
noAuthors?: boolean;
noCaptions?: boolean;
isCurrentUserPremium?: boolean;
wasDrafted?: boolean;
lastMessageId?: number;
}) {
const messageIds = messages.map(({ id }) => id);
const randomIds = messages.map(generateRandomBigInt);
const localMessages: Record<string, ApiMessage> = {};
messages.forEach((message, index) => { const messageIds = messages.map(({ id }) => id);
const localMessages: ApiMessage[] = [];
messages.forEach((message) => {
const localMessage = buildLocalForwardedMessage({ const localMessage = buildLocalForwardedMessage({
toChat, toChat,
toThreadId: Number(toThreadId), toThreadId: Number(toThreadId),
@ -1579,7 +1546,7 @@ export async function forwardMessages({
lastMessageId, lastMessageId,
sendAs, sendAs,
}); });
localMessages[randomIds[index].toString()] = localMessage; localMessages.push(localMessage);
sendApiUpdate({ sendApiUpdate({
'@type': localMessage.isScheduled ? 'newScheduledMessage' : 'newMessage', '@type': localMessage.isScheduled ? 'newScheduledMessage' : 'newMessage',
@ -1589,7 +1556,25 @@ export async function forwardMessages({
wasDrafted, wasDrafted,
}); });
}); });
return { messageIds, localMessages };
}
export async function forwardApiMessages(params: ForwardMessagesParams) {
const {
fromChat, toChat, toThreadId, isSilent,
scheduledAt, sendAs, withMyScore, noAuthors, noCaptions,
forwardedLocalMessagesSlice, messagePriceInStars,
} = params;
if (!forwardedLocalMessagesSlice) return;
const {
messageIds, localMessages,
} = forwardedLocalMessagesSlice;
const priceInStars = messagePriceInStars ? messagePriceInStars * messageIds.length : undefined;
const randomIds = messageIds.map(generateRandomBigInt);
try { try {
const update = await invokeRequest(new GramJs.messages.ForwardMessages({ const update = await invokeRequest(new GramJs.messages.ForwardMessages({
fromPeer: buildInputPeer(fromChat.id, fromChat.accessHash), fromPeer: buildInputPeer(fromChat.id, fromChat.accessHash),
@ -1603,11 +1588,16 @@ export async function forwardMessages({
...(toThreadId && { topMsgId: Number(toThreadId) }), ...(toThreadId && { topMsgId: Number(toThreadId) }),
...(scheduledAt && { scheduleDate: scheduledAt }), ...(scheduledAt && { scheduleDate: scheduledAt }),
...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }), ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }),
...(priceInStars && { allowPaidStars: BigInt(priceInStars) }),
}), { }), {
shouldThrow: true, shouldThrow: true,
shouldIgnoreUpdates: true, shouldIgnoreUpdates: true,
}); });
if (update) handleMultipleLocalMessagesUpdate(localMessages, update); const messagesForUpdate: Record<string, ApiMessage> = {};
localMessages.forEach((message, index) => {
messagesForUpdate[randomIds[index].toString()] = message;
});
if (update) handleMultipleLocalMessagesUpdate(messagesForUpdate, update);
} catch (error: any) { } catch (error: any) {
Object.values(localMessages).forEach((localMessage) => { Object.values(localMessages).forEach((localMessage) => {
sendApiUpdate({ sendApiUpdate({
@ -1620,6 +1610,18 @@ export async function forwardMessages({
} }
} }
export async function forwardMessages(params: ForwardMessagesParams) {
if (params.forwardedLocalMessagesSlice) {
await forwardApiMessages(params);
} else {
const newParams = {
...params,
forwardedLocalMessagesSlice: forwardMessagesLocal(params),
};
await forwardApiMessages(newParams);
}
}
export async function findFirstMessageIdAfterDate({ export async function findFirstMessageIdAfterDate({
chat, chat,
timestamp, timestamp,

View File

@ -656,6 +656,7 @@ export async function fetchGlobalPrivacySettings() {
shouldArchiveAndMuteNewNonContact: Boolean(result.archiveAndMuteNewNoncontactPeers), shouldArchiveAndMuteNewNonContact: Boolean(result.archiveAndMuteNewNoncontactPeers),
shouldHideReadMarks: Boolean(result.hideReadMarks), shouldHideReadMarks: Boolean(result.hideReadMarks),
shouldNewNonContactPeersRequirePremium: Boolean(result.newNoncontactPeersRequirePremium), shouldNewNonContactPeersRequirePremium: Boolean(result.newNoncontactPeersRequirePremium),
nonContactPeersPaidStars: Number(result.noncontactPeersPaidStars),
}; };
} }
@ -663,16 +664,19 @@ export async function updateGlobalPrivacySettings({
shouldArchiveAndMuteNewNonContact, shouldArchiveAndMuteNewNonContact,
shouldHideReadMarks, shouldHideReadMarks,
shouldNewNonContactPeersRequirePremium, shouldNewNonContactPeersRequirePremium,
nonContactPeersPaidStars,
}: { }: {
shouldArchiveAndMuteNewNonContact?: boolean; shouldArchiveAndMuteNewNonContact?: boolean;
shouldHideReadMarks?: boolean; shouldHideReadMarks?: boolean;
shouldNewNonContactPeersRequirePremium?: boolean; shouldNewNonContactPeersRequirePremium?: boolean;
nonContactPeersPaidStars?: number | null;
}) { }) {
const result = await invokeRequest(new GramJs.account.SetGlobalPrivacySettings({ const result = await invokeRequest(new GramJs.account.SetGlobalPrivacySettings({
settings: new GramJs.GlobalPrivacySettings({ settings: new GramJs.GlobalPrivacySettings({
...(shouldArchiveAndMuteNewNonContact && { archiveAndMuteNewNoncontactPeers: true }), ...(shouldArchiveAndMuteNewNonContact && { archiveAndMuteNewNoncontactPeers: true }),
...(shouldHideReadMarks && { hideReadMarks: true }), ...(shouldHideReadMarks && { hideReadMarks: true }),
...(shouldNewNonContactPeersRequirePremium && { newNoncontactPeersRequirePremium: true }), ...(shouldNewNonContactPeersRequirePremium && { newNoncontactPeersRequirePremium: true }),
noncontactPeersPaidStars: BigInt(nonContactPeersPaidStars || 0),
}), }),
})); }));
@ -684,6 +688,7 @@ export async function updateGlobalPrivacySettings({
shouldArchiveAndMuteNewNonContact: Boolean(result.archiveAndMuteNewNoncontactPeers), shouldArchiveAndMuteNewNonContact: Boolean(result.archiveAndMuteNewNoncontactPeers),
shouldHideReadMarks: Boolean(result.hideReadMarks), shouldHideReadMarks: Boolean(result.hideReadMarks),
shouldNewNonContactPeersRequirePremium: Boolean(result.newNoncontactPeersRequirePremium), shouldNewNonContactPeersRequirePremium: Boolean(result.newNoncontactPeersRequirePremium),
nonContactPeersPaidStars: Number(result.noncontactPeersPaidStars),
}; };
} }

View File

@ -103,6 +103,22 @@ export async function fetchCommonChats(user: ApiUser, maxId?: string) {
return { chatIds, count }; return { chatIds, count };
} }
export async function fetchPaidMessagesStarsAmount(user: ApiUser) {
const result = await invokeRequest(new GramJs.users.GetRequirementsToContact({
id: [buildInputEntity(user.id, user.accessHash) as GramJs.InputUser],
}));
if (!result) {
return undefined;
}
if (result[0] instanceof GramJs.RequirementToContactPaidMessages) {
return result[0].starsAmount?.toJSNumber();
}
return undefined;
}
export async function fetchNearestCountry() { export async function fetchNearestCountry() {
const dcInfo = await invokeRequest(new GramJs.help.GetNearestDc()); const dcInfo = await invokeRequest(new GramJs.help.GetNearestDc());
@ -231,6 +247,17 @@ export async function deleteContact({
}); });
} }
export async function addNoPaidMessagesException({ user, shouldRefundCharged }: {
user: ApiUser;
shouldRefundCharged?: boolean;
}) {
const result = await invokeRequest(new GramJs.account.AddNoPaidMessagesException({
refundCharged: shouldRefundCharged ? true : undefined,
userId: buildInputEntity(user.id, user.accessHash) as GramJs.InputUser,
}));
return result;
}
export async function fetchProfilePhotos({ export async function fetchProfilePhotos({
peer, peer,
offset = 0, offset = 0,

View File

@ -88,6 +88,8 @@ export interface ApiChat {
// Locally determined field // Locally determined field
detectedLanguage?: string; detectedLanguage?: string;
paidMessagesStars?: number;
} }
export interface ApiTypingStatus { export interface ApiTypingStatus {
@ -232,6 +234,7 @@ export interface ApiPeerSettings {
canReportSpam?: boolean; canReportSpam?: boolean;
canAddContact?: boolean; canAddContact?: boolean;
canBlockContact?: boolean; canBlockContact?: boolean;
chargedPaidMessageStars?: number;
registrationMonth?: string; registrationMonth?: string;
phoneCountry?: string; phoneCountry?: string;
nameChangeDate?: number; nameChangeDate?: number;

View File

@ -585,6 +585,7 @@ export interface ApiMessage {
isVideoProcessingPending?: true; isVideoProcessingPending?: true;
areReactionsPossible?: true; areReactionsPossible?: true;
reportDeliveryUntilDate?: number; reportDeliveryUntilDate?: number;
paidMessageStars?: number;
} }
export interface ApiReactions { export interface ApiReactions {

View File

@ -109,6 +109,7 @@ export interface ApiSessionData {
export type ApiNotification = { export type ApiNotification = {
localId: string; localId: string;
containerSelector?: string; containerSelector?: string;
type?: 'paidMessage' | undefined;
title?: string | RegularLangFnParameters; title?: string | RegularLangFnParameters;
message: TeactNode | RegularLangFnParameters; message: TeactNode | RegularLangFnParameters;
cacheBreaker?: string; cacheBreaker?: string;
@ -120,6 +121,7 @@ export type ApiNotification = {
shouldShowTimer?: boolean; shouldShowTimer?: boolean;
icon?: IconName; icon?: IconName;
customEmojiIconId?: string; customEmojiIconId?: string;
shouldUseCustomIcon?: boolean;
dismissAction?: CallbackAction; dismissAction?: CallbackAction;
}; };
@ -224,6 +226,10 @@ export interface ApiAppConfig {
maxPinnedStoriesCount?: number; maxPinnedStoriesCount?: number;
groupTranscribeLevelMin?: number; groupTranscribeLevelMin?: number;
canLimitNewMessagesWithoutPremium?: boolean; canLimitNewMessagesWithoutPremium?: boolean;
starsPaidMessagesAvailable?: boolean;
starsPaidMessageCommissionPermille?: number;
starsPaidMessageAmountMax?: number;
starsUsdWithdrawRateX1000?: number;
bandwidthPremiumNotifyPeriod?: number; bandwidthPremiumNotifyPeriod?: number;
bandwidthPremiumUploadSpeedup?: number; bandwidthPremiumUploadSpeedup?: number;
bandwidthPremiumDownloadSpeedup?: number; bandwidthPremiumDownloadSpeedup?: number;

View File

@ -2,7 +2,7 @@ import type { ApiChat } from './chats';
import type { ApiUser } from './users'; import type { ApiUser } from './users';
export type ApiPrivacyKey = 'phoneNumber' | 'addByPhone' | 'lastSeen' | 'profilePhoto' | 'voiceMessages' | export type ApiPrivacyKey = 'phoneNumber' | 'addByPhone' | 'lastSeen' | 'profilePhoto' | 'voiceMessages' |
'forwards' | 'chatInvite' | 'phoneCall' | 'phoneP2P' | 'bio' | 'birthday' | 'gifts'; 'forwards' | 'chatInvite' | 'phoneCall' | 'phoneP2P' | 'bio' | 'birthday' | 'gifts' | 'noPaidMessages';
export type PrivacyVisibility = 'everybody' | 'contacts' | 'closeFriends' | 'nonContacts' | 'nobody'; export type PrivacyVisibility = 'everybody' | 'contacts' | 'closeFriends' | 'nonContacts' | 'nobody';
export type BotsPrivacyType = 'allow' | 'disallow' | 'none'; export type BotsPrivacyType = 'allow' | 'disallow' | 'none';

View File

@ -180,6 +180,7 @@ export interface ApiStarsTransaction {
subscriptionPeriod?: number; subscriptionPeriod?: number;
starRefCommision?: number; starRefCommision?: number;
isGiftUpgrade?: true; isGiftUpgrade?: true;
paidMessages?: number;
} }
export interface ApiStarsSubscription { export interface ApiStarsSubscription {

View File

@ -38,6 +38,7 @@ export interface ApiUser {
hasMainMiniApp?: boolean; hasMainMiniApp?: boolean;
botActiveUsers?: number; botActiveUsers?: number;
botVerificationIconId?: string; botVerificationIconId?: string;
paidMessagesStars?: number;
} }
export interface ApiUserFullInfo { export interface ApiUserFullInfo {
@ -65,6 +66,7 @@ export interface ApiUserFullInfo {
isBotAccessEmojiGranted?: boolean; isBotAccessEmojiGranted?: boolean;
hasScheduledMessages?: boolean; hasScheduledMessages?: boolean;
botVerification?: ApiBotVerification; botVerification?: ApiBotVerification;
paidMessagesStars?: number;
settings?: ApiPeerSettings; settings?: ApiPeerSettings;
} }

View File

@ -1853,3 +1853,41 @@
"GiftPremiumDescriptionYourBalance" = "Your balance is **{stars}**. {link}"; "GiftPremiumDescriptionYourBalance" = "Your balance is **{stars}**. {link}";
"StarsGiftCompleted"= "Gift sent!"; "StarsGiftCompleted"= "Gift sent!";
"GiftSent"= "Gift sent!"; "GiftSent"= "Gift sent!";
"PrivacyDescriptionMessagesContactsAndPremium" = "You can restrict messages from users who are not in your contacts and don't have Premium.";
"PrivacyChargeForMessages" = "Charge for Messages";
"PrivacyDescriptionChargeForMessages" = "Charge a fee for messages from people outside your contacts or those you haven't messaged first.";
"RemoveFeeTitle" = "Remove Fee";
"ExceptionTitlePrivacyChargeForMessages" = "Remove fee";
"ExceptionDescriptionPrivacyChargeForMessages" = "Add users or entire groups who won't be charged for sending messages to you.";
"SectionTitleStarsForForMessages" = "Set your price per message";
"SectionDescriptionStarsForForMessages" = "You will receive {percent}% of the selected fee (~{amount}) for each incoming message.";
"SubtitlePrivacyAddUsers" = "Add Users";
"SubtitlePrivacyUsersCount" = "{count} users";
"PrivacyPaidMessagesValue" = "Paid";
"FirstMessageInPaidMessagesChat" = "**{user}** charges {amount} for each message.";
"ButtonBuyStars" = "Buy Stars";
"ComposerPlaceholderPaidMessage" = "Message for {amount}";
"ComposerPlaceholderPaidReply" = "Reply for {amount}";
"TitleConfirmPayment" = "Confirm Payment";
"ConfirmationModalPaymentForOneMessage" = "{user} charges **{amount} Stars** per incoming message. Would you like to pay **{amount} Stars** to send one message?";
"ConfirmationModalPaymentForMessages" = "{user} charges **{price} Stars** per incoming message. Would you like to pay **{amount} Stars** to send **{count} messages?**";
"ButtonPayForMessage" = "Pay for {count} message";
"ToastTitleMessageSent" = "Message sent!";
"ToastTitleMessagesSent" = "{count} Messages sent!";
"ToastMessageSent" = "You paid {amount} stars.";
"ButtonUndo" = "Undo";
"ActionPaidOneMessageOutgoing" = "You paid {amount} Stars to send a message";
"ActionPaidOneMessageIncoming" = "You received {amount} Stars from {user}";
"PaneMessagePaidMessageCharge" = "{peer} must pay {amount} for each message to you.";
"ConfirmRemoveMessageFee" = "Yes";
"ConfirmDialogMessageRemoveFee" = "Are you sure you want to allow **{peer}** to message you for free?";
"ConfirmDialogRemoveFeeRefundStars" = "Refund already paid **{amount} Stars**";
"DescriptionGiftPaidMessage" = "{user} charges **{amount}** Stars for each message. That price has been added to the cost of the gift.";
"StoryTooltipGifSent" = "Gif Sent!";
"StoryTooltipStickerSent" = "Sticker Sent!";
"StoryTooltipReactionSent" = "Reaction Sent!";
"StarsNeededTextSendPaidMessages" = "Buy **Stars** to send messages.";
"PaidMessageTransaction_one" = "Fee for {count} Message";
"PaidMessageTransaction_other" = "Fee for {count} Messages";
"PaidMessageTransactionDescription" = "You receive **{percent}** of the price that you charge for each incoming message.";
"PaidMessageTransactionTotal" = "Total";

View File

@ -72,6 +72,7 @@
} }
> .Button { > .Button {
overflow: visible;
flex-shrink: 0; flex-shrink: 0;
margin-left: 0.5rem; margin-left: 0.5rem;
width: var(--base-height); width: var(--base-height);
@ -96,6 +97,15 @@
position: absolute; position: absolute;
} }
.paidStarsBadgeText {
display: inline-flex;
align-items: center;
.star-amount-icon {
margin-inline-start: 0;
}
}
@media (hover: hover) { @media (hover: hover) {
&:not(:active):not(:focus):not(:hover) { &:not(:active):not(:focus):not(:hover) {
.icon-send, .icon-send,
@ -152,6 +162,33 @@
} }
} }
.paidStarsBadgeIcon {
margin-inline-start: 0;
margin-inline-end: 0.0625rem;
}
.paidStarsBadge {
animation: hide-icon 0.4s forwards ease-out;
&.visible {
animation: grow-icon 0.4s ease-out;
}
.icon {
font-size: 0.875rem;
}
position: absolute;
top: -1rem;
height: auto;
padding-inline: 0.375rem;
padding-block: 0.25rem;
font-size: 0.8125rem;
margin-top: 0.625rem;
line-height: 1;
font-weight: var(--font-weight-semibold) !important;
}
&.send, &.sendOneTime { &.send, &.sendOneTime {
.icon-send { .icon-send {
animation: grow-icon 0.4s ease-out; animation: grow-icon 0.4s ease-out;
@ -652,8 +689,13 @@
} }
} }
.placeholder-star-icon {
line-height: 1;
}
.forced-placeholder, .forced-placeholder,
.placeholder-text { .placeholder-text {
display: inline-flex;
align-items: center;
position: absolute; position: absolute;
color: var(--color-placeholders); color: var(--color-placeholders);
pointer-events: none; pointer-events: none;

View File

@ -1,4 +1,4 @@
import type { FC } from '../../lib/teact/teact'; import type { FC, TeactNode } from '../../lib/teact/teact';
import React, { import React, {
memo, useEffect, useMemo, useRef, useSignal, useState, memo, useEffect, useMemo, useRef, useSignal, useState,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
@ -57,6 +57,7 @@ import { requestMeasure, requestNextMutation } from '../../lib/fasterdom/fasterd
import { import {
canEditMedia, canEditMedia,
getAllowedAttachmentOptions, getAllowedAttachmentOptions,
getPeerTitle,
getReactionKey, getReactionKey,
getStoryKey, getStoryKey,
isChatAdmin, isChatAdmin,
@ -90,6 +91,7 @@ import {
selectNotifyDefaults, selectNotifyDefaults,
selectNotifyException, selectNotifyException,
selectNoWebPage, selectNoWebPage,
selectPeerPaidMessagesStars,
selectPeerStory, selectPeerStory,
selectPerformanceSettingsValue, selectPerformanceSettingsValue,
selectRequestedDraft, selectRequestedDraft,
@ -108,6 +110,7 @@ import { tryParseDeepLink } from '../../util/deepLinkParser';
import deleteLastCharacterOutsideSelection from '../../util/deleteLastCharacterOutsideSelection'; import deleteLastCharacterOutsideSelection from '../../util/deleteLastCharacterOutsideSelection';
import { processMessageInputForCustomEmoji } from '../../util/emoji/customEmojiManager'; import { processMessageInputForCustomEmoji } from '../../util/emoji/customEmojiManager';
import focusEditableElement from '../../util/focusEditableElement'; import focusEditableElement from '../../util/focusEditableElement';
import { formatStarsAsIcon } from '../../util/localization/format';
import { MEMO_EMPTY_ARRAY } from '../../util/memo'; import { MEMO_EMPTY_ARRAY } from '../../util/memo';
import parseHtmlAsFormattedText from '../../util/parseHtmlAsFormattedText'; import parseHtmlAsFormattedText from '../../util/parseHtmlAsFormattedText';
import { insertHtmlInSelection } from '../../util/selection'; import { insertHtmlInSelection } from '../../util/selection';
@ -147,6 +150,7 @@ import useEditing from '../middle/composer/hooks/useEditing';
import useEmojiTooltip from '../middle/composer/hooks/useEmojiTooltip'; import useEmojiTooltip from '../middle/composer/hooks/useEmojiTooltip';
import useInlineBotTooltip from '../middle/composer/hooks/useInlineBotTooltip'; import useInlineBotTooltip from '../middle/composer/hooks/useInlineBotTooltip';
import useMentionTooltip from '../middle/composer/hooks/useMentionTooltip'; import useMentionTooltip from '../middle/composer/hooks/useMentionTooltip';
import usePaidMessageConfirmation from '../middle/composer/hooks/usePaidMessageConfirmation';
import useStickerTooltip from '../middle/composer/hooks/useStickerTooltip'; import useStickerTooltip from '../middle/composer/hooks/useStickerTooltip';
import useVoiceRecording from '../middle/composer/hooks/useVoiceRecording'; import useVoiceRecording from '../middle/composer/hooks/useVoiceRecording';
@ -175,8 +179,10 @@ import Button from '../ui/Button';
import ResponsiveHoverButton from '../ui/ResponsiveHoverButton'; import ResponsiveHoverButton from '../ui/ResponsiveHoverButton';
import Spinner from '../ui/Spinner'; import Spinner from '../ui/Spinner';
import Transition from '../ui/Transition'; import Transition from '../ui/Transition';
import AnimatedCounter from './AnimatedCounter';
import Avatar from './Avatar'; import Avatar from './Avatar';
import Icon from './icons/Icon'; import Icon from './icons/Icon';
import PaymentMessageConfirmDialog from './PaymentMessageConfirmDialog';
import ReactionAnimatedEmoji from './reactions/ReactionAnimatedEmoji'; import ReactionAnimatedEmoji from './reactions/ReactionAnimatedEmoji';
import './Composer.scss'; import './Composer.scss';
@ -196,7 +202,7 @@ type OwnProps = {
editableInputCssSelector: string; editableInputCssSelector: string;
editableInputId: string; editableInputId: string;
className?: string; className?: string;
inputPlaceholder?: string; inputPlaceholder?: TeactNode | string;
onDropHide?: NoneToVoidFunction; onDropHide?: NoneToVoidFunction;
onForward?: NoneToVoidFunction; onForward?: NoneToVoidFunction;
onFocus?: NoneToVoidFunction; onFocus?: NoneToVoidFunction;
@ -220,6 +226,7 @@ type StateProps =
isSelectModeActive?: boolean; isSelectModeActive?: boolean;
isReactionPickerOpen?: boolean; isReactionPickerOpen?: boolean;
isForwarding?: boolean; isForwarding?: boolean;
forwardedMessagesCount?: number;
pollModal: TabState['pollModal']; pollModal: TabState['pollModal'];
botKeyboardMessageId?: number; botKeyboardMessageId?: number;
botKeyboardPlaceholder?: string; botKeyboardPlaceholder?: string;
@ -271,13 +278,16 @@ type StateProps =
webPagePreview?: ApiWebPage; webPagePreview?: ApiWebPage;
noWebPage?: boolean; noWebPage?: boolean;
isContactRequirePremium?: boolean; isContactRequirePremium?: boolean;
paidMessagesStars?: number;
effect?: ApiAvailableEffect; effect?: ApiAvailableEffect;
effectReactions?: ApiReaction[]; effectReactions?: ApiReaction[];
areEffectsSupported?: boolean; areEffectsSupported?: boolean;
canPlayEffect?: boolean; canPlayEffect?: boolean;
shouldPlayEffect?: boolean; shouldPlayEffect?: boolean;
maxMessageLength: number; maxMessageLength: number;
shouldPaidMessageAutoApprove?: boolean;
isSilentPosting?: boolean; isSilentPosting?: boolean;
isPaymentMessageConfirmDialogOpen: boolean;
}; };
enum MainButtonState { enum MainButtonState {
@ -331,6 +341,7 @@ const Composer: FC<OwnProps & StateProps> = ({
isSelectModeActive, isSelectModeActive,
isReactionPickerOpen, isReactionPickerOpen,
isForwarding, isForwarding,
forwardedMessagesCount,
pollModal, pollModal,
botKeyboardMessageId, botKeyboardMessageId,
botKeyboardPlaceholder, botKeyboardPlaceholder,
@ -382,6 +393,7 @@ const Composer: FC<OwnProps & StateProps> = ({
webPagePreview, webPagePreview,
noWebPage, noWebPage,
isContactRequirePremium, isContactRequirePremium,
paidMessagesStars,
effect, effect,
effectReactions, effectReactions,
areEffectsSupported, areEffectsSupported,
@ -393,12 +405,12 @@ const Composer: FC<OwnProps & StateProps> = ({
onFocus, onFocus,
onBlur, onBlur,
onForward, onForward,
isPaymentMessageConfirmDialogOpen,
}) => { }) => {
const { const {
sendMessage, sendMessage,
clearDraft, clearDraft,
showDialog, showDialog,
forwardMessages,
openPollModal, openPollModal,
closePollModal, closePollModal,
loadScheduledHistory, loadScheduledHistory,
@ -427,6 +439,8 @@ const Composer: FC<OwnProps & StateProps> = ({
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const inputRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const counterRef = useRef<HTMLSpanElement>(null);
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const storyReactionRef = useRef<HTMLButtonElement>(null); const storyReactionRef = useRef<HTMLButtonElement>(null);
@ -513,6 +527,22 @@ const Composer: FC<OwnProps & StateProps> = ({
const isNeedPremium = isContactRequirePremium && isInStoryViewer; const isNeedPremium = isContactRequirePremium && isInStoryViewer;
const isSendTextBlocked = isNeedPremium || !canSendPlainText; const isSendTextBlocked = isNeedPremium || !canSendPlainText;
const messagesCount = useDerivedState(() => {
if (hasAttachments) return attachments.length;
const messagesInInput = (getHtml() || hasAttachments) ? 1 : 0;
if (!isForwarding || !forwardedMessagesCount) return messagesInInput || 1;
return forwardedMessagesCount + messagesInInput;
}, [getHtml, hasAttachments, attachments, isForwarding, forwardedMessagesCount]);
const starsForAllMessages = paidMessagesStars ? messagesCount * paidMessagesStars : 0;
const {
closeConfirmDialog: closeConfirmModalPayForMessage,
dialogHandler: paymentMessageConfirmDialogHandler,
shouldAutoApprove: shouldPaidMessageAutoApprove,
setAutoApprove: setShouldPaidMessageAutoApprove,
handleWithConfirmation: handleActionWithPaymentConfirmation,
} = usePaidMessageConfirmation(starsForAllMessages);
const hasWebPagePreview = !hasAttachments && canAttachEmbedLinks && !noWebPage && Boolean(webPagePreview); const hasWebPagePreview = !hasAttachments && canAttachEmbedLinks && !noWebPage && Boolean(webPagePreview);
const isComposerBlocked = isSendTextBlocked && !editingMessage; const isComposerBlocked = isSendTextBlocked && !editingMessage;
@ -933,6 +963,21 @@ const Composer: FC<OwnProps & StateProps> = ({
return true; return true;
}); });
const canSendAttachments = (attachmentsToSend: ApiAttachment[]): boolean => {
if (!currentMessageList && !storyId) {
return false;
}
const { text } = parseHtmlAsFormattedText(getHtml());
if (!text && !attachmentsToSend.length) {
return false;
}
if (!validateTextLength(text, true)) return false;
if (!checkSlowMode()) return false;
return true;
};
const sendAttachments = useLastCallback(({ const sendAttachments = useLastCallback(({
attachments: attachmentsToSend, attachments: attachmentsToSend,
sendCompressed = attachmentSettings.shouldCompress, sendCompressed = attachmentSettings.shouldCompress,
@ -954,11 +999,6 @@ const Composer: FC<OwnProps & StateProps> = ({
isSilent = isSilent || isSilentPosting; isSilent = isSilent || isSilentPosting;
const { text, entities } = parseHtmlAsFormattedText(getHtml()); const { text, entities } = parseHtmlAsFormattedText(getHtml());
if (!text && !attachmentsToSend.length) {
return;
}
if (!validateTextLength(text, true)) return;
if (!checkSlowMode()) return;
isInvertedMedia = text && sendCompressed && sendGrouped ? isInvertedMedia : undefined; isInvertedMedia = text && sendCompressed && sendGrouped ? isInvertedMedia : undefined;
@ -998,12 +1038,24 @@ const Composer: FC<OwnProps & StateProps> = ({
sendGrouped: boolean, sendGrouped: boolean,
isInvertedMedia?: true, isInvertedMedia?: true,
) => { ) => {
sendAttachments({ if (canSendAttachments(attachments)) {
attachments, if (editingMessage) {
sendCompressed, sendAttachments({
sendGrouped, attachments,
isInvertedMedia, sendCompressed,
}); sendGrouped,
isInvertedMedia,
});
return;
}
handleActionWithPaymentConfirmation(sendAttachments, {
attachments,
sendCompressed,
sendGrouped,
isInvertedMedia,
});
}
}); });
const handleSendAttachments = useLastCallback(( const handleSendAttachments = useLastCallback((
@ -1013,16 +1065,81 @@ const Composer: FC<OwnProps & StateProps> = ({
scheduledAt?: number, scheduledAt?: number,
isInvertedMedia?: true, isInvertedMedia?: true,
) => { ) => {
sendAttachments({ if (canSendAttachments(attachments)) {
attachments, sendAttachments({
sendCompressed, attachments,
sendGrouped, sendCompressed,
isSilent, sendGrouped,
scheduledAt, isSilent,
isInvertedMedia, scheduledAt,
}); isInvertedMedia,
});
}
}); });
const handleSendCore = useLastCallback(
(currentAttachments: ApiAttachment[], isSilent = false, scheduledAt?: number) => {
const { text, entities } = parseHtmlAsFormattedText(getHtml());
if (currentAttachments.length) {
if (canSendAttachments(currentAttachments)) {
sendAttachments({
attachments: currentAttachments,
scheduledAt,
isSilent,
});
}
return;
}
if (!text && !isForwarding) {
return;
}
if (!validateTextLength(text)) return;
const messageInput = document.querySelector<HTMLDivElement>(editableInputCssSelector);
const effectId = effect?.id;
if (text || isForwarding) {
if (!checkSlowMode()) return;
const isInvertedMedia = hasWebPagePreview ? attachmentSettings.isInvertedMedia : undefined;
if (areEffectsSupported) saveEffectInDraft({ chatId, threadId, effectId: undefined });
sendMessage({
messageList: currentMessageList,
text,
entities,
scheduledAt,
isSilent,
shouldUpdateStickerSetOrder,
isInvertedMedia,
effectId,
webPageMediaSize: attachmentSettings.webPageMediaSize,
webPageUrl: hasWebPagePreview ? webPagePreview!.url : undefined,
isForwarding,
});
}
lastMessageSendTimeSeconds.current = getServerTime();
clearDraft({
chatId, threadId, isLocalOnly: true, shouldKeepReply: isForwarding,
});
if (IS_IOS && messageInput && messageInput === document.activeElement) {
applyIosAutoCapitalizationFix(messageInput);
}
// Wait until message animation starts
requestMeasure(() => {
resetComposer();
});
},
);
const handleSend = useLastCallback(async (isSilent = false, scheduledAt?: number) => { const handleSend = useLastCallback(async (isSilent = false, scheduledAt?: number) => {
if (!currentMessageList && !storyId) { if (!currentMessageList && !storyId) {
return; return;
@ -1045,68 +1162,11 @@ const Composer: FC<OwnProps & StateProps> = ({
} }
} }
const { text, entities } = parseHtmlAsFormattedText(getHtml()); handleSendCore(currentAttachments, isSilent, scheduledAt);
});
if (currentAttachments.length) { const handleSendWithConfirmation = useLastCallback((isSilent = false, scheduledAt?: number) => {
sendAttachments({ handleActionWithPaymentConfirmation(handleSend, isSilent, scheduledAt);
attachments: currentAttachments,
scheduledAt,
isSilent,
});
return;
}
if (!text && !isForwarding) {
return;
}
if (!validateTextLength(text)) return;
const messageInput = document.querySelector<HTMLDivElement>(editableInputCssSelector);
const effectId = effect?.id;
if (text) {
if (!checkSlowMode()) return;
const isInvertedMedia = hasWebPagePreview ? attachmentSettings.isInvertedMedia : undefined;
if (areEffectsSupported) saveEffectInDraft({ chatId, threadId, effectId: undefined });
sendMessage({
messageList: currentMessageList,
text,
entities,
scheduledAt,
isSilent,
shouldUpdateStickerSetOrder,
isInvertedMedia,
effectId,
webPageMediaSize: attachmentSettings.webPageMediaSize,
webPageUrl: hasWebPagePreview ? webPagePreview!.url : undefined,
});
}
if (isForwarding) {
forwardMessages({
scheduledAt,
isSilent,
});
}
lastMessageSendTimeSeconds.current = getServerTime();
clearDraft({
chatId, threadId, isLocalOnly: true, shouldKeepReply: isForwarding,
});
if (IS_IOS && messageInput && messageInput === document.activeElement) {
applyIosAutoCapitalizationFix(messageInput);
}
// Wait until message animation starts
requestMeasure(() => {
resetComposer();
});
}); });
const handleClickBotMenu = useLastCallback(() => { const handleClickBotMenu = useLastCallback(() => {
@ -1215,13 +1275,13 @@ const Composer: FC<OwnProps & StateProps> = ({
forceShowSymbolMenu(); forceShowSymbolMenu();
requestCalendar((scheduledAt) => { requestCalendar((scheduledAt) => {
cancelForceShowSymbolMenu(); cancelForceShowSymbolMenu();
handleMessageSchedule({ gif, isSilent }, scheduledAt, currentMessageList!); handleActionWithPaymentConfirmation(handleMessageSchedule, { gif, isSilent }, scheduledAt, currentMessageList!);
requestMeasure(() => { requestMeasure(() => {
resetComposer(true); resetComposer(true);
}); });
}); });
} else { } else {
sendMessage({ messageList: currentMessageList, gif, isSilent }); handleActionWithPaymentConfirmation(sendMessage, { messageList: currentMessageList, gif, isSilent });
requestMeasure(() => { requestMeasure(() => {
resetComposer(true); resetComposer(true);
}); });
@ -1250,18 +1310,23 @@ const Composer: FC<OwnProps & StateProps> = ({
forceShowSymbolMenu(); forceShowSymbolMenu();
requestCalendar((scheduledAt) => { requestCalendar((scheduledAt) => {
cancelForceShowSymbolMenu(); cancelForceShowSymbolMenu();
handleMessageSchedule({ sticker, isSilent }, scheduledAt, currentMessageList!); handleActionWithPaymentConfirmation(
handleMessageSchedule, { sticker, isSilent }, scheduledAt, currentMessageList!,
);
requestMeasure(() => { requestMeasure(() => {
resetComposer(shouldPreserveInput); resetComposer(shouldPreserveInput);
}); });
}); });
} else { } else {
sendMessage({ handleActionWithPaymentConfirmation(
messageList: currentMessageList, sendMessage,
sticker, {
isSilent, messageList: currentMessageList,
shouldUpdateStickerSetOrder: shouldUpdateStickerSetOrder && canUpdateStickerSetsOrder, sticker,
}); isSilent,
shouldUpdateStickerSetOrder: shouldUpdateStickerSetOrder && canUpdateStickerSetsOrder,
},
);
clearDraft({ chatId, threadId, isLocalOnly: true }); clearDraft({ chatId, threadId, isLocalOnly: true });
requestMeasure(() => { requestMeasure(() => {
@ -1281,20 +1346,24 @@ const Composer: FC<OwnProps & StateProps> = ({
if (isInScheduledList || isScheduleRequested) { if (isInScheduledList || isScheduleRequested) {
requestCalendar((scheduledAt) => { requestCalendar((scheduledAt) => {
handleMessageSchedule({ handleActionWithPaymentConfirmation(handleMessageSchedule,
id: inlineResult.id, {
queryId: inlineResult.queryId, id: inlineResult.id,
isSilent, queryId: inlineResult.queryId,
}, scheduledAt, currentMessageList!); isSilent,
},
scheduledAt,
currentMessageList!);
}); });
} else { } else {
sendInlineBotResult({ handleActionWithPaymentConfirmation(sendInlineBotResult,
id: inlineResult.id, {
queryId: inlineResult.queryId, id: inlineResult.id,
threadId, queryId: inlineResult.queryId,
chatId, threadId,
isSilent, chatId,
}); isSilent,
});
} }
const messageInput = document.querySelector<HTMLDivElement>(editableInputCssSelector); const messageInput = document.querySelector<HTMLDivElement>(editableInputCssSelector);
@ -1331,6 +1400,10 @@ const Composer: FC<OwnProps & StateProps> = ({
} }
}); });
const handlePollSendWithPaymentConfirmation = useLastCallback((poll: ApiNewPoll) => {
handleActionWithPaymentConfirmation(handlePollSend, poll);
});
const sendSilent = useLastCallback((additionalArgs?: ScheduledMessageArgs) => { const sendSilent = useLastCallback((additionalArgs?: ScheduledMessageArgs) => {
if (isInScheduledList) { if (isInScheduledList) {
requestCalendar((scheduledAt) => { requestCalendar((scheduledAt) => {
@ -1458,6 +1531,13 @@ const Composer: FC<OwnProps & StateProps> = ({
if (!isComposerBlocked) { if (!isComposerBlocked) {
if (botKeyboardPlaceholder) return botKeyboardPlaceholder; if (botKeyboardPlaceholder) return botKeyboardPlaceholder;
if (inputPlaceholder) return inputPlaceholder; if (inputPlaceholder) return inputPlaceholder;
if (paidMessagesStars) {
return lang('ComposerPlaceholderPaidMessage', {
amount: formatStarsAsIcon(lang, paidMessagesStars, { asFont: true, className: 'placeholder-star-icon' }),
}, {
withNodes: true,
});
}
if (chat?.isForum && chat?.isForumAsMessages && threadId === MAIN_THREAD_ID) { if (chat?.isForum && chat?.isForumAsMessages && threadId === MAIN_THREAD_ID) {
return replyToTopic return replyToTopic
? lang('ComposerPlaceholderTopic', { topic: replyToTopic.title }) ? lang('ComposerPlaceholderTopic', { topic: replyToTopic.title })
@ -1474,7 +1554,7 @@ const Composer: FC<OwnProps & StateProps> = ({
return lang('ComposerPlaceholderNoText'); return lang('ComposerPlaceholderNoText');
}, [ }, [
activeVoiceRecording, botKeyboardPlaceholder, chat, inputPlaceholder, isChannel, isComposerBlocked, activeVoiceRecording, botKeyboardPlaceholder, chat, inputPlaceholder, isChannel, isComposerBlocked,
isInStoryViewer, isSilentPosting, lang, replyToTopic, threadId, windowWidth, isInStoryViewer, isSilentPosting, lang, replyToTopic, threadId, windowWidth, paidMessagesStars,
]); ]);
useEffect(() => { useEffect(() => {
@ -1498,7 +1578,7 @@ const Composer: FC<OwnProps & StateProps> = ({
onForward?.(); onForward?.();
break; break;
case MainButtonState.Send: case MainButtonState.Send:
void handleSend(); handleSendWithConfirmation();
break; break;
case MainButtonState.Record: { case MainButtonState.Record: {
if (areVoiceMessagesNotAllowed) { if (areVoiceMessagesNotAllowed) {
@ -1586,7 +1666,7 @@ const Composer: FC<OwnProps & StateProps> = ({
entities = customEmojiMessage.entities; entities = customEmojiMessage.entities;
} }
sendMessage({ text, entities, isReaction: true }); handleActionWithPaymentConfirmation(sendMessage, { text, entities, isReaction: true });
closeReactionPicker(); closeReactionPicker();
}); });
@ -1622,24 +1702,29 @@ const Composer: FC<OwnProps & StateProps> = ({
}); });
const handleSendSilent = useLastCallback(() => { const handleSendSilent = useLastCallback(() => {
sendSilent(); handleActionWithPaymentConfirmation(sendSilent);
}); });
const handleSendWhenOnline = useLastCallback(() => { const handleSendWhenOnline = useLastCallback(() => {
handleMessageSchedule({}, SCHEDULED_WHEN_ONLINE, currentMessageList!, effect?.id); handleActionWithPaymentConfirmation(
handleMessageSchedule, {}, SCHEDULED_WHEN_ONLINE, currentMessageList!, effect?.id,
);
}); });
const handleSendScheduledAttachments = useLastCallback( const handleSendScheduledAttachments = useLastCallback(
(sendCompressed: boolean, sendGrouped: boolean, isInvertedMedia?: true) => { (sendCompressed: boolean, sendGrouped: boolean, isInvertedMedia?: true) => {
requestCalendar((scheduledAt) => { requestCalendar((scheduledAt) => {
handleMessageSchedule({ sendCompressed, sendGrouped, isInvertedMedia }, scheduledAt, currentMessageList!); handleActionWithPaymentConfirmation(handleMessageSchedule,
{ sendCompressed, sendGrouped, isInvertedMedia },
scheduledAt,
currentMessageList!);
}); });
}, },
); );
const handleSendSilentAttachments = useLastCallback( const handleSendSilentAttachments = useLastCallback(
(sendCompressed: boolean, sendGrouped: boolean, isInvertedMedia?: true) => { (sendCompressed: boolean, sendGrouped: boolean, isInvertedMedia?: true) => {
sendSilent({ sendCompressed, sendGrouped, isInvertedMedia }); handleActionWithPaymentConfirmation(sendSilent, { sendCompressed, sendGrouped, isInvertedMedia });
}, },
); );
@ -1654,15 +1739,17 @@ const Composer: FC<OwnProps & StateProps> = ({
case MainButtonState.Schedule: case MainButtonState.Schedule:
return handleSendScheduled; return handleSendScheduled;
default: default:
return handleSend; return handleSendWithConfirmation;
} }
}, [mainButtonState, handleEditComplete]); }, [mainButtonState, handleEditComplete, handleSendWithConfirmation]);
const withBotCommands = isChatWithBot && botMenuButton?.type === 'commands' && !editingMessage const withBotCommands = isChatWithBot && botMenuButton?.type === 'commands' && !editingMessage
&& botCommands !== false && !activeVoiceRecording; && botCommands !== false && !activeVoiceRecording;
const effectEmoji = areEffectsSupported && effect?.emoticon; const effectEmoji = areEffectsSupported && effect?.emoticon;
const shouldRenderPaidBadge = Boolean(paidMessagesStars && mainButtonState === MainButtonState.Send);
return ( return (
<div className={fullClassName}> <div className={fullClassName}>
{isInMessageList && canAttachMedia && isReady && ( {isInMessageList && canAttachMedia && isReady && (
@ -1702,7 +1789,8 @@ const Composer: FC<OwnProps & StateProps> = ({
shouldForceAsFile={shouldForceAsFile} shouldForceAsFile={shouldForceAsFile}
isForCurrentMessageList={isForCurrentMessageList} isForCurrentMessageList={isForCurrentMessageList}
isForMessage={isInMessageList} isForMessage={isInMessageList}
shouldSchedule={isInScheduledList} shouldSchedule={!paidMessagesStars && isInScheduledList}
canSchedule={!paidMessagesStars}
forceDarkTheme={isInStoryViewer} forceDarkTheme={isInStoryViewer}
onCaptionUpdate={onCaptionUpdate} onCaptionUpdate={onCaptionUpdate}
onSendSilent={handleSendSilentAttachments} onSendSilent={handleSendSilentAttachments}
@ -1717,13 +1805,14 @@ const Composer: FC<OwnProps & StateProps> = ({
editingMessage={editingMessage} editingMessage={editingMessage}
onSendWhenOnline={handleSendWhenOnline} onSendWhenOnline={handleSendWhenOnline}
canScheduleUntilOnline={canScheduleUntilOnline && !isViewOnceEnabled} canScheduleUntilOnline={canScheduleUntilOnline && !isViewOnceEnabled}
paidMessagesStars={paidMessagesStars}
/> />
<PollModal <PollModal
isOpen={pollModal.isOpen} isOpen={pollModal.isOpen}
isQuiz={pollModal.isQuiz} isQuiz={pollModal.isQuiz}
shouldBeAnonymous={isChannel} shouldBeAnonymous={isChannel}
onClear={closePollModal} onClear={closePollModal}
onSend={handlePollSend} onSend={handlePollSendWithPaymentConfirmation}
/> />
<SendAsMenu <SendAsMenu
isOpen={isSendAsMenuOpen} isOpen={isSendAsMenuOpen}
@ -2109,6 +2198,22 @@ const Composer: FC<OwnProps & StateProps> = ({
{onForward && <Icon name="forward" />} {onForward && <Icon name="forward" />}
{isInMessageList && <Icon name="schedule" />} {isInMessageList && <Icon name="schedule" />}
{isInMessageList && <Icon name="check" />} {isInMessageList && <Icon name="check" />}
<Button
className={buildClassName('paidStarsBadge', shouldRenderPaidBadge && 'visible')}
nonInteractive
size="tiny"
color="stars"
pill
fluid
>
<div className="paidStarsBadgeText">
<Icon name="star" className={buildClassName('star-amount-icon', className)} />
<AnimatedCounter
ref={counterRef}
text={lang.number(starsForAllMessages)}
/>
</div>
</Button>
</Button> </Button>
{effectEmoji && ( {effectEmoji && (
<span className="effect-icon" onClick={handleRemoveEffect}> <span className="effect-icon" onClick={handleRemoveEffect}>
@ -2125,7 +2230,7 @@ const Composer: FC<OwnProps & StateProps> = ({
{canShowCustomSendMenu && ( {canShowCustomSendMenu && (
<CustomSendMenu <CustomSendMenu
isOpen={isCustomSendMenuOpen} isOpen={isCustomSendMenuOpen}
canSchedule={isInMessageList && !isViewOnceEnabled} canSchedule={!paidMessagesStars && isInMessageList && !isViewOnceEnabled}
canScheduleUntilOnline={canScheduleUntilOnline && !isViewOnceEnabled} canScheduleUntilOnline={canScheduleUntilOnline && !isViewOnceEnabled}
onSendSilent={!isChatWithSelf ? handleSendSilent : undefined} onSendSilent={!isChatWithSelf ? handleSendSilent : undefined}
onSendSchedule={!isInScheduledList ? handleSendScheduled : undefined} onSendSchedule={!isInScheduledList ? handleSendScheduled : undefined}
@ -2147,6 +2252,16 @@ const Composer: FC<OwnProps & StateProps> = ({
/> />
)} )}
{calendar} {calendar}
<PaymentMessageConfirmDialog
isOpen={isPaymentMessageConfirmDialogOpen}
onClose={closeConfirmModalPayForMessage}
userName={chat ? getPeerTitle(lang, chat) : undefined}
messagePriceInStars={paidMessagesStars || 0}
messagesCount={messagesCount}
shouldAutoApprove={shouldPaidMessageAutoApprove}
setAutoApprove={setShouldPaidMessageAutoApprove}
confirmHandler={paymentMessageConfirmDialogHandler}
/>
</div> </div>
); );
}; };
@ -2161,12 +2276,18 @@ export default memo(withGlobal<OwnProps>(
const isChatWithSelf = selectIsChatWithSelf(global, chatId); const isChatWithSelf = selectIsChatWithSelf(global, chatId);
const isChatWithUser = isUserId(chatId); const isChatWithUser = isUserId(chatId);
const userFullInfo = isChatWithUser ? selectUserFullInfo(global, chatId) : undefined; const userFullInfo = isChatWithUser ? selectUserFullInfo(global, chatId) : undefined;
const paidMessagesStars = selectPeerPaidMessagesStars(global, chatId);
const chatFullInfo = !isChatWithUser ? selectChatFullInfo(global, chatId) : undefined; const chatFullInfo = !isChatWithUser ? selectChatFullInfo(global, chatId) : undefined;
const messageWithActualBotKeyboard = (isChatWithBot || !isChatWithUser) const messageWithActualBotKeyboard = (isChatWithBot || !isChatWithUser)
&& selectNewestMessageWithBotKeyboardButtons(global, chatId, threadId); && selectNewestMessageWithBotKeyboardButtons(global, chatId, threadId);
const { const {
language, shouldSuggestStickers, shouldSuggestCustomEmoji, shouldUpdateStickerSetOrder, language, shouldSuggestStickers, shouldSuggestCustomEmoji, shouldUpdateStickerSetOrder,
shouldPaidMessageAutoApprove,
} = global.settings.byKey; } = global.settings.byKey;
const {
forwardMessages: { messageIds: forwardMessageIds },
} = selectTabState(global);
const baseEmojiKeywords = global.emojiKeywords[BASE_EMOJI_KEYWORD_LANG]; const baseEmojiKeywords = global.emojiKeywords[BASE_EMOJI_KEYWORD_LANG];
const emojiKeywords = language !== BASE_EMOJI_KEYWORD_LANG ? global.emojiKeywords[language] : undefined; const emojiKeywords = language !== BASE_EMOJI_KEYWORD_LANG ? global.emojiKeywords[language] : undefined;
const botKeyboardMessageId = messageWithActualBotKeyboard ? messageWithActualBotKeyboard.id : undefined; const botKeyboardMessageId = messageWithActualBotKeyboard ? messageWithActualBotKeyboard.id : undefined;
@ -2230,6 +2351,7 @@ export default memo(withGlobal<OwnProps>(
const effectReactions = global.reactions.effectReactions; const effectReactions = global.reactions.effectReactions;
const maxMessageLength = global.config?.maxMessageLength || DEFAULT_MAX_MESSAGE_LENGTH; const maxMessageLength = global.config?.maxMessageLength || DEFAULT_MAX_MESSAGE_LENGTH;
const isForwarding = chatId === tabState.forwardMessages.toChatId;
return { return {
availableReactions: global.reactions.availableReactions, availableReactions: global.reactions.availableReactions,
@ -2252,7 +2374,8 @@ export default memo(withGlobal<OwnProps>(
isInScheduledList, isInScheduledList,
botKeyboardMessageId, botKeyboardMessageId,
botKeyboardPlaceholder: keyboardMessage?.keyboardPlaceholder, botKeyboardPlaceholder: keyboardMessage?.keyboardPlaceholder,
isForwarding: chatId === tabState.forwardMessages.toChatId, isForwarding,
forwardedMessagesCount: isForwarding ? forwardMessageIds!.length : undefined,
pollModal: tabState.pollModal, pollModal: tabState.pollModal,
stickersForEmoji: global.stickers.forEmoji.stickers, stickersForEmoji: global.stickers.forEmoji.stickers,
customEmojiForEmoji: global.customEmojis.forEmoji.stickers, customEmojiForEmoji: global.customEmojis.forEmoji.stickers,
@ -2307,7 +2430,10 @@ export default memo(withGlobal<OwnProps>(
canPlayEffect, canPlayEffect,
shouldPlayEffect, shouldPlayEffect,
maxMessageLength, maxMessageLength,
paidMessagesStars,
shouldPaidMessageAutoApprove,
isSilentPosting, isSilentPosting,
isPaymentMessageConfirmDialogOpen: tabState.isPaymentMessageConfirmDialogOpen,
}; };
}, },
)(Composer)); )(Composer));

View File

@ -0,0 +1,5 @@
.checkBox {
margin-top: 0.375rem;
margin-inline: -1.125rem;
padding-inline-start: 3.5rem;
}

View File

@ -0,0 +1,73 @@
import type { FC, StateHookSetter } from '../../lib/teact/teact';
import React, { memo } from '../../lib/teact/teact';
import useLang from '../../hooks/useLang';
import Checkbox from '../ui/Checkbox';
import ConfirmDialog from '../ui/ConfirmDialog';
import styles from './PaymentMessageConfirmDialog.module.scss';
type OwnProps = {
isOpen: boolean;
onClose: NoneToVoidFunction;
userName?: string;
messagePriceInStars: number;
messagesCount: number;
shouldAutoApprove: boolean;
setAutoApprove: StateHookSetter<boolean>;
confirmHandler: NoneToVoidFunction;
};
const PaymentMessageConfirmDialog: FC<OwnProps> = ({
isOpen,
onClose,
userName,
messagePriceInStars,
messagesCount,
shouldAutoApprove: shouldPaidMessageAutoApprove,
setAutoApprove: setShouldPaidMessageAutoApprove,
confirmHandler,
}) => {
const lang = useLang();
const confirmPaymentMessage = messagesCount === 1 ? lang('ConfirmationModalPaymentForOneMessage', {
user: userName,
amount: messagePriceInStars,
}, {
withMarkdown: true,
withNodes: true,
}) : lang('ConfirmationModalPaymentForMessages', {
user: userName,
price: messagePriceInStars,
amount: messagePriceInStars * messagesCount,
count: messagesCount,
}, {
withMarkdown: true,
withNodes: true,
});
const confirmLabel = lang('ButtonPayForMessage', { count: messagesCount }, {
withNodes: true,
});
return (
<ConfirmDialog
title={lang('TitleConfirmPayment')}
confirmLabel={confirmLabel}
isOpen={isOpen}
onClose={onClose}
confirmHandler={confirmHandler}
>
{confirmPaymentMessage}
<Checkbox
className={styles.checkBox}
label={lang('DoNotAskAgain')}
checked={shouldPaidMessageAutoApprove}
onCheck={setShouldPaidMessageAutoApprove}
/>
</ConfirmDialog>
);
};
export default memo(PaymentMessageConfirmDialog);

View File

@ -30,6 +30,7 @@ export type OwnProps = {
onSelectRecipient: (peerId: string, threadId?: ThreadId) => void; onSelectRecipient: (peerId: string, threadId?: ThreadId) => void;
onClose: NoneToVoidFunction; onClose: NoneToVoidFunction;
onCloseAnimationEnd?: NoneToVoidFunction; onCloseAnimationEnd?: NoneToVoidFunction;
isLowStackPriority?: boolean;
}; };
type StateProps = { type StateProps = {
@ -54,6 +55,7 @@ const RecipientPicker: FC<OwnProps & StateProps> = ({
onSelectRecipient, onSelectRecipient,
onClose, onClose,
onCloseAnimationEnd, onCloseAnimationEnd,
isLowStackPriority,
}) => { }) => {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const ids = useMemo(() => { const ids = useMemo(() => {
@ -112,6 +114,7 @@ const RecipientPicker: FC<OwnProps & StateProps> = ({
onSelectChatOrUser={onSelectRecipient} onSelectChatOrUser={onSelectRecipient}
onClose={onClose} onClose={onClose}
onCloseAnimationEnd={onCloseAnimationEnd} onCloseAnimationEnd={onCloseAnimationEnd}
isLowStackPriority={isLowStackPriority}
/> />
); );
}; };

View File

@ -16,6 +16,7 @@ import {
selectCurrentMessageList, selectCurrentMessageList,
selectIsChatWithSelf, selectIsChatWithSelf,
selectIsCurrentUserPremium, selectIsCurrentUserPremium,
selectPeerPaidMessagesStars,
selectShouldSchedule, selectShouldSchedule,
selectStickerSet, selectStickerSet,
selectThreadInfo, selectThreadInfo,
@ -276,12 +277,13 @@ export default memo(withGlobal<OwnProps>(
: stickerSetShortName ? { shortName: stickerSetShortName } : undefined; : stickerSetShortName ? { shortName: stickerSetShortName } : undefined;
const stickerSet = stickerSetInfo ? selectStickerSet(global, stickerSetInfo) : undefined; const stickerSet = stickerSetInfo ? selectStickerSet(global, stickerSetInfo) : undefined;
const paidMessagesStars = chatId ? selectPeerPaidMessagesStars(global, chatId) : undefined;
return { return {
canScheduleUntilOnline: Boolean(chatId) && selectCanScheduleUntilOnline(global, chatId), canScheduleUntilOnline: Boolean(chatId) && selectCanScheduleUntilOnline(global, chatId),
canSendStickers, canSendStickers,
isSavedMessages, isSavedMessages,
shouldSchedule: selectShouldSchedule(global), shouldSchedule: !paidMessagesStars && selectShouldSchedule(global),
stickerSet, stickerSet,
isCurrentUserPremium: selectIsCurrentUserPremium(global), isCurrentUserPremium: selectIsCurrentUserPremium(global),
shouldUpdateStickerSetOrder: global.settings.byKey.shouldUpdateStickerSetOrder, shouldUpdateStickerSetOrder: global.settings.byKey.shouldUpdateStickerSetOrder,

View File

@ -51,6 +51,7 @@ export type OwnProps = {
onSelectChatOrUser: (chatOrUserId: string, threadId?: ThreadId) => void; onSelectChatOrUser: (chatOrUserId: string, threadId?: ThreadId) => void;
onClose: NoneToVoidFunction; onClose: NoneToVoidFunction;
onCloseAnimationEnd?: NoneToVoidFunction; onCloseAnimationEnd?: NoneToVoidFunction;
isLowStackPriority?: boolean;
}; };
const CHAT_LIST_SLIDE = 0; const CHAT_LIST_SLIDE = 0;
@ -71,6 +72,7 @@ const ChatOrUserPicker: FC<OwnProps> = ({
onSelectChatOrUser, onSelectChatOrUser,
onClose, onClose,
onCloseAnimationEnd, onCloseAnimationEnd,
isLowStackPriority,
}) => { }) => {
const { loadTopics } = getActions(); const { loadTopics } = getActions();
@ -323,6 +325,7 @@ const ChatOrUserPicker: FC<OwnProps> = ({
className={buildClassName('ChatOrUserPicker', className)} className={buildClassName('ChatOrUserPicker', className)}
onClose={onClose} onClose={onClose}
onCloseAnimationEnd={onCloseAnimationEnd} onCloseAnimationEnd={onCloseAnimationEnd}
isLowStackPriority={isLowStackPriority}
> >
<Transition activeKey={activeKey} name="slideFade" slideClassName="ChatOrUserPicker_slide"> <Transition activeKey={activeKey} name="slideFade" slideClassName="ChatOrUserPicker_slide">
{() => { {() => {

View File

@ -339,6 +339,11 @@ function LeftColumn({
case SettingsScreens.DoNotTranslate: case SettingsScreens.DoNotTranslate:
setSettingsScreen(SettingsScreens.Language); setSettingsScreen(SettingsScreens.Language);
return; return;
case SettingsScreens.PrivacyNoPaidMessages:
setSettingsScreen(SettingsScreens.PrivacyMessages);
return;
default: default:
break; break;
} }

View File

@ -1,4 +1,4 @@
.contacts_and_premium_option-title { .root {
cursor: pointer; cursor: pointer;
} }

View File

@ -17,7 +17,7 @@ function PrivacyLockedOption({ label }: OwnProps) {
return ( return (
<div <div
className={styles.contactsAndPremiumOptionTitle} className={styles.root}
onClick={() => showNotification({ message: lang('OptionPremiumRequiredMessage') })} onClick={() => showNotification({ message: lang('OptionPremiumRequiredMessage') })}
> >
<span>{label}</span> <span>{label}</span>

View File

@ -1,88 +1,245 @@
import React, { memo, useMemo } from '../../../lib/teact/teact'; import React, {
memo, useCallback, useMemo, useState,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
import { selectIsCurrentUserPremium, selectNewNoncontactPeersRequirePremium } from '../../../global/selectors'; import { SettingsScreens } from '../../../types';
import {
DEFAULT_CHARGE_FOR_MESSAGES,
DEFAULT_MAXIMUM_CHARGE_FOR_MESSAGES,
MINIMUM_CHARGE_FOR_MESSAGES,
} from '../../../config';
import {
selectIsCurrentUserPremium,
selectNewNoncontactPeersRequirePremium,
selectNonContactPeersPaidStars,
} from '../../../global/selectors';
import { formatCurrencyAsString } from '../../../util/formatCurrency';
import { formatStarsAsText } from '../../../util/localization/format';
import useDebouncedCallback from '../../../hooks/useDebouncedCallback';
import useHistoryBack from '../../../hooks/useHistoryBack'; import useHistoryBack from '../../../hooks/useHistoryBack';
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 ListItem from '../../ui/ListItem';
import RadioGroup from '../../ui/RadioGroup'; import RadioGroup from '../../ui/RadioGroup';
import RangeSlider from '../../ui/RangeSlider';
import PremiumStatusItem from './PremiumStatusItem'; import PremiumStatusItem from './PremiumStatusItem';
import PrivacyLockedOption from './PrivacyLockedOption'; import PrivacyLockedOption from './PrivacyLockedOption';
type OwnProps = { type OwnProps = {
isActive?: boolean; isActive?: boolean;
onReset: VoidFunction; onReset: VoidFunction;
onScreenSelect: (screen: SettingsScreens) => void;
}; };
type StateProps = { type StateProps = {
shouldNewNonContactPeersRequirePremium?: boolean; shouldNewNonContactPeersRequirePremium?: boolean;
shouldChargeForMessages?: boolean;
canLimitNewMessagesWithoutPremium?: boolean; canLimitNewMessagesWithoutPremium?: boolean;
canChargeForMessages?: boolean;
isCurrentUserPremium?: boolean; isCurrentUserPremium?: boolean;
starsUsdWithdrawRate: number;
starsPaidMessageCommissionPermille: number;
starsPaidMessageAmountMax?: number;
nonContactPeersPaidStars: number;
noPaidReactionsForUsersCount: number;
}; };
function PrivacyMessages({ function PrivacyMessages({
isActive, isActive,
canLimitNewMessagesWithoutPremium, canLimitNewMessagesWithoutPremium,
canChargeForMessages,
shouldNewNonContactPeersRequirePremium, shouldNewNonContactPeersRequirePremium,
shouldChargeForMessages,
nonContactPeersPaidStars,
isCurrentUserPremium, isCurrentUserPremium,
starsPaidMessageCommissionPermille,
starsPaidMessageAmountMax,
starsUsdWithdrawRate,
noPaidReactionsForUsersCount,
onReset, onReset,
onScreenSelect,
}: OwnProps & StateProps) { }: OwnProps & StateProps) {
const { updateGlobalPrivacySettings } = getActions(); const { updateGlobalPrivacySettings } = getActions();
const lang = useOldLang(); const oldLang = useOldLang();
const lang = useLang();
const canChange = isCurrentUserPremium || canLimitNewMessagesWithoutPremium; const canChangeForContactsAndPremium = isCurrentUserPremium || canLimitNewMessagesWithoutPremium;
const canChangeChargeForMessages = isCurrentUserPremium && canChargeForMessages;
const [chargeForMessages, setChargeForMessages] = useState<number>(nonContactPeersPaidStars);
const options = useMemo(() => { const options = useMemo(() => {
return [ return [
{ value: 'everybody', label: lang('P2PEverybody') }, { value: 'everybody', label: oldLang('P2PEverybody') },
{ {
value: 'contacts_and_premium', value: 'contacts_and_premium',
label: canChange ? ( label: canChangeForContactsAndPremium ? (
lang('PrivacyMessagesContactsAndPremium') oldLang('PrivacyMessagesContactsAndPremium')
) : ( ) : (
<PrivacyLockedOption label={lang('PrivacyMessagesContactsAndPremium')} /> <PrivacyLockedOption label={oldLang('PrivacyMessagesContactsAndPremium')} />
), ),
hidden: !canChange, hidden: !canChangeForContactsAndPremium,
},
{
value: 'charge_for_messages',
label: canChangeChargeForMessages ? (
lang('PrivacyChargeForMessages')
) : (
<PrivacyLockedOption label={lang('PrivacyChargeForMessages')} />
),
hidden: !canChangeChargeForMessages,
}, },
]; ];
}, [lang, canChange]); }, [oldLang, lang, canChangeForContactsAndPremium, canChangeChargeForMessages]);
const handleChange = useLastCallback((privacy: string) => { const handleChange = useLastCallback((privacy: string) => {
updateGlobalPrivacySettings({ shouldNewNonContactPeersRequirePremium: privacy === 'contacts_and_premium' }); updateGlobalPrivacySettings({
shouldNewNonContactPeersRequirePremium: privacy === 'contacts_and_premium',
// eslint-disable-next-line no-null/no-null
nonContactPeersPaidStars: privacy === 'charge_for_messages' ? chargeForMessages : null,
});
}); });
const updateGlobalPrivacySettingsWithDebounced = useDebouncedCallback((value: number) => {
updateGlobalPrivacySettings({
nonContactPeersPaidStars: value,
});
}, [updateGlobalPrivacySettings], 300, true);
const handleChargeForMessagesChange = useCallback((value: number) => {
setChargeForMessages(value);
updateGlobalPrivacySettingsWithDebounced(value);
}, [setChargeForMessages, updateGlobalPrivacySettingsWithDebounced]);
const renderValueForStarsRange = useCallback((value: number) => {
return formatStarsAsText(lang, value);
}, [lang]);
function renderSectionStarsAmountForPaidMessages() {
return (
<div className="settings-item">
<h4 className="settings-item-header" dir={oldLang.isRtl ? 'rtl' : undefined}>
{lang('SectionTitleStarsForForMessages')}
</h4>
<RangeSlider
isCenteredLayout
min={MINIMUM_CHARGE_FOR_MESSAGES}
max={starsPaidMessageAmountMax}
value={chargeForMessages}
onChange={handleChargeForMessagesChange}
renderValue={renderValueForStarsRange}
/>
<p className="settings-item-description-larger" dir={oldLang.isRtl ? 'rtl' : undefined}>
{lang('SectionDescriptionStarsForForMessages', {
percent: starsPaidMessageCommissionPermille * 100,
amount: formatCurrencyAsString(
chargeForMessages * starsUsdWithdrawRate * starsPaidMessageCommissionPermille,
'USD',
lang.code,
),
}, {
withNodes: true,
})}
</p>
</div>
);
}
function renderSectionNoPaidMessagesForUsers() {
const itemSubtitle = !noPaidReactionsForUsersCount ? lang('SubtitlePrivacyAddUsers')
: oldLang('Users', noPaidReactionsForUsersCount, 'i');
return (
<div className="settings-item">
<h4 className="settings-item-header" dir={oldLang.isRtl ? 'rtl' : undefined}>
{lang('RemoveFeeTitle')}
</h4>
<ListItem
narrow
icon="delete-user"
// eslint-disable-next-line react/jsx-no-bind
onClick={() => {
onScreenSelect(SettingsScreens.PrivacyNoPaidMessages);
}}
>
<div className="multiline-item full-size">
<span className="title">{lang('ExceptionTitlePrivacyChargeForMessages')}</span>
<span className="subtitle">{
itemSubtitle
}
</span>
</div>
</ListItem>
</div>
);
}
useHistoryBack({ useHistoryBack({
isActive, isActive,
onBack: onReset, onBack: onReset,
}); });
const selectedValue = useMemo(() => {
if (shouldChargeForMessages) return 'charge_for_messages';
if (shouldNewNonContactPeersRequirePremium) return 'contacts_and_premium';
return 'everybody';
}, [shouldChargeForMessages, shouldNewNonContactPeersRequirePremium]);
const privacyDescription = useMemo(() => {
if (shouldChargeForMessages) return lang('PrivacyDescriptionChargeForMessages');
return lang('PrivacyDescriptionMessagesContactsAndPremium');
}, [shouldChargeForMessages, lang]);
return ( return (
<> <>
<div className="settings-item"> <div className="settings-item">
<h4 className="settings-item-header" dir={lang.isRtl ? 'rtl' : undefined}> <h4 className="settings-item-header" dir={oldLang.isRtl ? 'rtl' : undefined}>
{lang('PrivacyMessagesTitle')} {oldLang('PrivacyMessagesTitle')}
</h4> </h4>
<RadioGroup <RadioGroup
name="privacy-messages" name="privacy-messages"
options={options} options={options}
onChange={handleChange} onChange={handleChange}
selected={shouldNewNonContactPeersRequirePremium ? 'contacts_and_premium' : 'everybody'} selected={selectedValue}
/> />
<p className="settings-item-description-larger" dir={lang.isRtl ? 'rtl' : undefined}> <p className="settings-item-description-larger" dir={oldLang.isRtl ? 'rtl' : undefined}>
{lang('Privacy.Messages.SectionFooter')} {privacyDescription}
</p> </p>
</div> </div>
{!canChange && <PremiumStatusItem premiumSection="message_privacy" />} {canChangeChargeForMessages
&& selectedValue === 'charge_for_messages' && renderSectionStarsAmountForPaidMessages()}
{canChangeChargeForMessages && selectedValue === 'charge_for_messages' && renderSectionNoPaidMessagesForUsers()}
{!isCurrentUserPremium && <PremiumStatusItem premiumSection="message_privacy" />}
</> </>
); );
} }
export default memo(withGlobal<OwnProps>((global): StateProps => { export default memo(withGlobal<OwnProps>((global): StateProps => {
const nonContactPeersPaidStars = selectNonContactPeersPaidStars(global);
const starsUsdWithdrawRateX1000 = global.appConfig?.starsUsdWithdrawRateX1000;
const starsUsdWithdrawRate = starsUsdWithdrawRateX1000 ? starsUsdWithdrawRateX1000 / 1000 : 1;
const configStarsPaidMessageCommissionPermille = global.appConfig?.starsPaidMessageCommissionPermille;
const starsPaidMessageCommissionPermille = configStarsPaidMessageCommissionPermille
? configStarsPaidMessageCommissionPermille / 1000 : 100;
const noPaidReactionsForUsersCount = global.settings.privacy.noPaidMessages?.allowUserIds.length || 0;
return { return {
shouldNewNonContactPeersRequirePremium: selectNewNoncontactPeersRequirePremium(global), shouldNewNonContactPeersRequirePremium: selectNewNoncontactPeersRequirePremium(global),
shouldChargeForMessages: Boolean(nonContactPeersPaidStars),
nonContactPeersPaidStars: nonContactPeersPaidStars || DEFAULT_CHARGE_FOR_MESSAGES,
isCurrentUserPremium: selectIsCurrentUserPremium(global), isCurrentUserPremium: selectIsCurrentUserPremium(global),
canLimitNewMessagesWithoutPremium: global.appConfig?.canLimitNewMessagesWithoutPremium, canLimitNewMessagesWithoutPremium: global.appConfig?.canLimitNewMessagesWithoutPremium,
canChargeForMessages: global.appConfig?.starsPaidMessagesAvailable,
starsPaidMessageAmountMax: global.appConfig?.starsPaidMessageAmountMax || DEFAULT_MAXIMUM_CHARGE_FOR_MESSAGES,
starsPaidMessageCommissionPermille,
starsUsdWithdrawRate,
noPaidReactionsForUsersCount,
}; };
})(PrivacyMessages)); })(PrivacyMessages));

View File

@ -141,6 +141,10 @@ const PRIVACY_GROUP_CHATS_SCREENS = [
SettingsScreens.PrivacyGroupChatsDeniedContacts, SettingsScreens.PrivacyGroupChatsDeniedContacts,
]; ];
const PRIVACY_MESSAGES_SCREENS = [
SettingsScreens.PrivacyNoPaidMessages,
];
export type OwnProps = { export type OwnProps = {
isActive: boolean; isActive: boolean;
currentScreen: SettingsScreens; currentScreen: SettingsScreens;
@ -224,6 +228,7 @@ const Settings: FC<OwnProps> = ({
[SettingsScreens.PrivacyForwarding]: PRIVACY_FORWARDING_SCREENS.includes(activeScreen), [SettingsScreens.PrivacyForwarding]: PRIVACY_FORWARDING_SCREENS.includes(activeScreen),
[SettingsScreens.PrivacyVoiceMessages]: PRIVACY_VOICE_MESSAGES_SCREENS.includes(activeScreen), [SettingsScreens.PrivacyVoiceMessages]: PRIVACY_VOICE_MESSAGES_SCREENS.includes(activeScreen),
[SettingsScreens.PrivacyGroupChats]: PRIVACY_GROUP_CHATS_SCREENS.includes(activeScreen), [SettingsScreens.PrivacyGroupChats]: PRIVACY_GROUP_CHATS_SCREENS.includes(activeScreen),
[SettingsScreens.PrivacyMessages]: PRIVACY_MESSAGES_SCREENS.includes(activeScreen),
}; };
const isTwoFaScreen = TWO_FA_SCREENS.includes(activeScreen); const isTwoFaScreen = TWO_FA_SCREENS.includes(activeScreen);
@ -370,13 +375,14 @@ const Settings: FC<OwnProps> = ({
case SettingsScreens.PrivacyForwardingAllowedContacts: case SettingsScreens.PrivacyForwardingAllowedContacts:
case SettingsScreens.PrivacyVoiceMessagesAllowedContacts: case SettingsScreens.PrivacyVoiceMessagesAllowedContacts:
case SettingsScreens.PrivacyGroupChatsAllowedContacts: case SettingsScreens.PrivacyGroupChatsAllowedContacts:
case SettingsScreens.PrivacyNoPaidMessages:
return ( return (
<SettingsPrivacyVisibilityExceptionList <SettingsPrivacyVisibilityExceptionList
isAllowList isAllowList
usersOnly={currentScreen === SettingsScreens.PrivacyNoPaidMessages}
withPremiumCategory={currentScreen === SettingsScreens.PrivacyGroupChatsAllowedContacts} withPremiumCategory={currentScreen === SettingsScreens.PrivacyGroupChatsAllowedContacts}
withMiniAppsCategory={currentScreen === SettingsScreens.PrivacyGiftsAllowedContacts} withMiniAppsCategory={currentScreen === SettingsScreens.PrivacyGiftsAllowedContacts}
screen={currentScreen} screen={currentScreen}
onScreenSelect={onScreenSelect}
isActive={isScreenActive || privacyAllowScreens[currentScreen]} isActive={isScreenActive || privacyAllowScreens[currentScreen]}
onReset={handleReset} onReset={handleReset}
/> />
@ -396,7 +402,6 @@ const Settings: FC<OwnProps> = ({
return ( return (
<SettingsPrivacyVisibilityExceptionList <SettingsPrivacyVisibilityExceptionList
screen={currentScreen} screen={currentScreen}
onScreenSelect={onScreenSelect}
isActive={isScreenActive} isActive={isScreenActive}
onReset={handleReset} onReset={handleReset}
/> />
@ -407,6 +412,7 @@ const Settings: FC<OwnProps> = ({
<PrivacyMessages <PrivacyMessages
isActive={isScreenActive} isActive={isScreenActive}
onReset={handleReset} onReset={handleReset}
onScreenSelect={onScreenSelect}
/> />
); );

View File

@ -163,6 +163,9 @@ const SettingsHeader: FC<OwnProps> = ({
case SettingsScreens.PrivacyPhoneP2PDeniedContacts: case SettingsScreens.PrivacyPhoneP2PDeniedContacts:
return <h3>{oldLang('NeverAllow')}</h3>; return <h3>{oldLang('NeverAllow')}</h3>;
case SettingsScreens.PrivacyNoPaidMessages:
return <h3>{lang('RemoveFeeTitle')}</h3>;
case SettingsScreens.Performance: case SettingsScreens.Performance:
return <h3>{lang('MenuAnimations')}</h3>; return <h3>{lang('MenuAnimations')}</h3>;

View File

@ -34,6 +34,7 @@ type StateProps = {
canDisplayAutoarchiveSetting: boolean; canDisplayAutoarchiveSetting: boolean;
shouldArchiveAndMuteNewNonContact?: boolean; shouldArchiveAndMuteNewNonContact?: boolean;
shouldNewNonContactPeersRequirePremium?: boolean; shouldNewNonContactPeersRequirePremium?: boolean;
shouldChargeForMessages: boolean;
canDisplayChatInTitle?: boolean; canDisplayChatInTitle?: boolean;
privacy: GlobalState['settings']['privacy']; privacy: GlobalState['settings']['privacy'];
}; };
@ -50,6 +51,7 @@ const SettingsPrivacy: FC<OwnProps & StateProps> = ({
canDisplayAutoarchiveSetting, canDisplayAutoarchiveSetting,
shouldArchiveAndMuteNewNonContact, shouldArchiveAndMuteNewNonContact,
shouldNewNonContactPeersRequirePremium, shouldNewNonContactPeersRequirePremium,
shouldChargeForMessages,
canDisplayChatInTitle, canDisplayChatInTitle,
canSetPasscode, canSetPasscode,
privacy, privacy,
@ -332,9 +334,10 @@ const SettingsPrivacy: FC<OwnProps & StateProps> = ({
<div className="multiline-item"> <div className="multiline-item">
<span className="title">{oldLang('PrivacyMessagesTitle')}</span> <span className="title">{oldLang('PrivacyMessagesTitle')}</span>
<span className="subtitle" dir="auto"> <span className="subtitle" dir="auto">
{shouldNewNonContactPeersRequirePremium {shouldChargeForMessages ? lang('PrivacyPaidMessagesValue')
? oldLang('PrivacyMessagesContactsAndPremium') : shouldNewNonContactPeersRequirePremium
: oldLang('P2PEverybody')} ? oldLang('PrivacyMessagesContactsAndPremium')
: oldLang('P2PEverybody')}
</span> </span>
</div> </div>
</ListItem> </ListItem>
@ -402,7 +405,7 @@ export default memo(withGlobal<OwnProps>(
settings: { settings: {
byKey: { byKey: {
hasPassword, isSensitiveEnabled, canChangeSensitive, shouldArchiveAndMuteNewNonContact, hasPassword, isSensitiveEnabled, canChangeSensitive, shouldArchiveAndMuteNewNonContact,
canDisplayChatInTitle, shouldNewNonContactPeersRequirePremium, canDisplayChatInTitle, shouldNewNonContactPeersRequirePremium, nonContactPeersPaidStars,
}, },
privacy, privacy,
}, },
@ -413,6 +416,8 @@ export default memo(withGlobal<OwnProps>(
appConfig, appConfig,
} = global; } = global;
const shouldChargeForMessages = Boolean(nonContactPeersPaidStars);
return { return {
isCurrentUserPremium: selectIsCurrentUserPremium(global), isCurrentUserPremium: selectIsCurrentUserPremium(global),
hasPassword, hasPassword,
@ -424,6 +429,7 @@ export default memo(withGlobal<OwnProps>(
shouldArchiveAndMuteNewNonContact, shouldArchiveAndMuteNewNonContact,
canChangeSensitive, canChangeSensitive,
shouldNewNonContactPeersRequirePremium, shouldNewNonContactPeersRequirePremium,
shouldChargeForMessages,
privacy, privacy,
canDisplayChatInTitle, canDisplayChatInTitle,
canSetPasscode: selectCanSetPasscode(global), canSetPasscode: selectCanSetPasscode(global),

View File

@ -31,9 +31,9 @@ export type OwnProps = {
isAllowList?: boolean; isAllowList?: boolean;
withPremiumCategory?: boolean; withPremiumCategory?: boolean;
withMiniAppsCategory?: boolean; withMiniAppsCategory?: boolean;
usersOnly?: boolean;
screen: SettingsScreens; screen: SettingsScreens;
isActive?: boolean; isActive?: boolean;
onScreenSelect: (screen: SettingsScreens) => void;
onReset: () => void; onReset: () => void;
}; };
@ -52,7 +52,7 @@ const SettingsPrivacyVisibilityExceptionList: FC<OwnProps & StateProps> = ({
isActive, isActive,
currentUserId, currentUserId,
settings, settings,
onScreenSelect, usersOnly = false,
onReset, onReset,
}) => { }) => {
const { setPrivacySettings } = getActions(); const { setPrivacySettings } = getActions();
@ -120,7 +120,10 @@ const SettingsPrivacyVisibilityExceptionList: FC<OwnProps & StateProps> = ({
const user = usersById[chatId]; const user = usersById[chatId];
const isDeleted = user && isDeletedUser(user); const isDeleted = user && isDeletedUser(user);
const isChannel = chat && isChatChannel(chat); const isChannel = chat && isChatChannel(chat);
return chatId !== currentUserId && chatId !== SERVICE_NOTIFICATIONS_USER_ID && !isChannel && !isDeleted; return (!usersOnly || user)
&& chatId !== currentUserId
&& chatId !== SERVICE_NOTIFICATIONS_USER_ID
&& !isChannel && !isDeleted;
}); });
const filteredChats = filterPeersByQuery({ ids: chatIds, query: searchQuery }); const filteredChats = filterPeersByQuery({ ids: chatIds, query: searchQuery });
@ -132,7 +135,7 @@ const SettingsPrivacyVisibilityExceptionList: FC<OwnProps & StateProps> = ({
...selectedContactIds, ...selectedContactIds,
...chatIds, ...chatIds,
]); ]);
}, [folderAllOrderedIds, folderArchivedOrderedIds, selectedContactIds, searchQuery, currentUserId]); }, [folderAllOrderedIds, folderArchivedOrderedIds, selectedContactIds, searchQuery, currentUserId, usersOnly]);
const handleSelectedCategoriesChange = useCallback((value: CustomPeerType[]) => { const handleSelectedCategoriesChange = useCallback((value: CustomPeerType[]) => {
setNewSelectedCategoryTypes(value); setNewSelectedCategoryTypes(value);
@ -154,13 +157,13 @@ const SettingsPrivacyVisibilityExceptionList: FC<OwnProps & StateProps> = ({
: (newSelectedCategoryTypes.includes(customPeerBots.type) ? 'allow' : 'disallow'), : (newSelectedCategoryTypes.includes(customPeerBots.type) ? 'allow' : 'disallow'),
}); });
onScreenSelect(SettingsScreens.Privacy); onReset();
}, [ }, [
isAllowList, isAllowList,
withMiniAppsCategory, withMiniAppsCategory,
newSelectedCategoryTypes, newSelectedCategoryTypes,
newSelectedContactIds, newSelectedContactIds,
onScreenSelect, onReset,
screen, screen,
customPeerBots, customPeerBots,
]); ]);
@ -244,6 +247,8 @@ function getCurrentPrivacySettings(global: GlobalState, screen: SettingsScreens)
case SettingsScreens.PrivacyGroupChatsDeniedContacts: case SettingsScreens.PrivacyGroupChatsDeniedContacts:
case SettingsScreens.PrivacyGroupChatsAllowedContacts: case SettingsScreens.PrivacyGroupChatsAllowedContacts:
return privacy.chatInvite; return privacy.chatInvite;
case SettingsScreens.PrivacyNoPaidMessages:
return privacy.noPaidMessages;
} }
return undefined; return undefined;

View File

@ -49,6 +49,8 @@ export function getPrivacyKey(screen: SettingsScreens): ApiPrivacyKey | undefine
return 'phoneP2P'; return 'phoneP2P';
case SettingsScreens.PrivacyAddByPhone: case SettingsScreens.PrivacyAddByPhone:
return 'addByPhone'; return 'addByPhone';
case SettingsScreens.PrivacyNoPaidMessages:
return 'noPaidMessages';
} }
return undefined; return undefined;

View File

@ -9,7 +9,6 @@ import type { MessageList } from '../../types';
import { selectCurrentMessageList, selectTabState } from '../../global/selectors'; import { selectCurrentMessageList, selectTabState } from '../../global/selectors';
import getReadableErrorText from '../../util/getReadableErrorText'; import getReadableErrorText from '../../util/getReadableErrorText';
import { pick } from '../../util/iteratees';
import renderText from '../common/helpers/renderText'; import renderText from '../common/helpers/renderText';
import useFlag from '../../hooks/useFlag'; import useFlag from '../../hooks/useFlag';
@ -49,7 +48,7 @@ const Dialogs: FC<StateProps> = ({ dialogs, currentMessageList }) => {
} }
sendMessage({ sendMessage({
contact: pick(contactRequest, ['firstName', 'lastName', 'phoneNumber']), contact: contactRequest,
messageList: currentMessageList, messageList: currentMessageList,
}); });
closeModal(); closeModal();

View File

@ -21,7 +21,7 @@ const Notifications: FC<StateProps> = ({ notifications }) => {
return ( return (
<div id="Notifications"> <div id="Notifications">
{notifications.map((notification) => ( {notifications.map((notification) => (
<Notification notification={notification} /> <Notification key={notification.localId} notification={notification} />
))} ))}
</div> </div>
); );

View File

@ -80,7 +80,7 @@ import ContactGreeting from './ContactGreeting';
import MessageListAccountInfo from './MessageListAccountInfo'; import MessageListAccountInfo from './MessageListAccountInfo';
import MessageListContent from './MessageListContent'; import MessageListContent from './MessageListContent';
import NoMessages from './NoMessages'; import NoMessages from './NoMessages';
import PremiumRequiredMessage from './PremiumRequiredMessage'; import RequirementToContactMessage from './RequirementToContactMessage';
import './MessageList.scss'; import './MessageList.scss';
@ -97,6 +97,7 @@ type OwnProps = {
withDefaultBg: boolean; withDefaultBg: boolean;
onIntersectPinnedMessage: OnIntersectPinnedMessage; onIntersectPinnedMessage: OnIntersectPinnedMessage;
isContactRequirePremium?: boolean; isContactRequirePremium?: boolean;
paidMessagesStars?: number;
}; };
type StateProps = { type StateProps = {
@ -187,6 +188,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
isServiceNotificationsChat, isServiceNotificationsChat,
currentUserId, currentUserId,
isContactRequirePremium, isContactRequirePremium,
paidMessagesStars,
areAdsEnabled, areAdsEnabled,
channelJoinInfo, channelJoinInfo,
isChatProtected, isChatProtected,
@ -689,8 +691,10 @@ const MessageList: FC<OwnProps & StateProps> = ({
{restrictionReason ? restrictionReason.text : `This is a private ${isChannelChat ? 'channel' : 'chat'}`} {restrictionReason ? restrictionReason.text : `This is a private ${isChannelChat ? 'channel' : 'chat'}`}
</span> </span>
</div> </div>
) : paidMessagesStars && isPrivate && !hasMessages && !shouldRenderGreeting ? (
<RequirementToContactMessage paidMessagesStars={paidMessagesStars} userId={chatId} />
) : isContactRequirePremium && !hasMessages ? ( ) : isContactRequirePremium && !hasMessages ? (
<PremiumRequiredMessage userId={chatId} /> <RequirementToContactMessage userId={chatId} />
) : (isBot || isNonContact) && !hasMessages ? ( ) : (isBot || isNonContact) && !hasMessages ? (
<MessageListAccountInfo chatId={chatId} /> <MessageListAccountInfo chatId={chatId} />
) : shouldRenderGreeting ? ( ) : shouldRenderGreeting ? (

View File

@ -1,9 +1,10 @@
import type { RefObject } from 'react'; import type { RefObject } from 'react';
import type { FC } from '../../lib/teact/teact'; import type { FC } from '../../lib/teact/teact';
import React, { getIsHeavyAnimating, memo } from '../../lib/teact/teact'; import React, { getIsHeavyAnimating, memo } from '../../lib/teact/teact';
import { getActions } from '../../global'; import { getActions, getGlobal } from '../../global';
import type { MessageListType, ThreadId } from '../../types'; import type { ApiMessage } from '../../api/types';
import type { IAlbum, MessageListType, ThreadId } from '../../types';
import type { Signal } from '../../util/signals'; import type { Signal } from '../../util/signals';
import type { MessageDateGroup } from './helpers/groupMessages'; import type { MessageDateGroup } from './helpers/groupMessages';
import type { OnIntersectPinnedMessage } from './hooks/usePinnedMessage'; import type { OnIntersectPinnedMessage } from './hooks/usePinnedMessage';
@ -13,10 +14,12 @@ import { SCHEDULED_WHEN_ONLINE } from '../../config';
import { import {
getMessageHtmlId, getMessageHtmlId,
getMessageOriginalId, getMessageOriginalId,
getPeerTitle,
isActionMessage, isActionMessage,
isOwnMessage, isOwnMessage,
isServiceNotificationMessage, isServiceNotificationMessage,
} from '../../global/helpers'; } from '../../global/helpers';
import { selectSender } from '../../global/selectors';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import { formatHumanDate } from '../../util/dates/dateFormat'; import { formatHumanDate } from '../../util/dates/dateFormat';
import { compact } from '../../util/iteratees'; import { compact } from '../../util/iteratees';
@ -24,6 +27,7 @@ import { isAlbum } from './helpers/groupMessages';
import { preventMessageInputBlur } from './helpers/preventMessageInputBlur'; import { preventMessageInputBlur } from './helpers/preventMessageInputBlur';
import useDerivedSignal from '../../hooks/useDerivedSignal'; import useDerivedSignal from '../../hooks/useDerivedSignal';
import useLang from '../../hooks/useLang';
import useOldLang from '../../hooks/useOldLang'; import useOldLang from '../../hooks/useOldLang';
import usePreviousDeprecated from '../../hooks/usePreviousDeprecated'; import usePreviousDeprecated from '../../hooks/usePreviousDeprecated';
import useMessageObservers from './hooks/useMessageObservers'; import useMessageObservers from './hooks/useMessageObservers';
@ -131,12 +135,42 @@ const MessageListContent: FC<OwnProps> = ({
); );
const oldLang = useOldLang(); const oldLang = useOldLang();
const lang = useLang();
const unreadDivider = ( const unreadDivider = (
<div className={buildClassName(UNREAD_DIVIDER_CLASS, 'local-action-message')} key="unread-messages"> <div className={buildClassName(UNREAD_DIVIDER_CLASS, 'local-action-message')} key="unread-messages">
<span>{oldLang('UnreadMessages')}</span> <span>{oldLang('UnreadMessages')}</span>
</div> </div>
); );
const renderPaidMessageAction = (message: ApiMessage, album?: IAlbum) => {
if (message.paidMessageStars) {
const messagesLength = album?.messages?.length || 1;
const amount = message.paidMessageStars * messagesLength;
return (
<div
className={buildClassName('local-action-message')}
key={`paid-messages-action-${message.id}`}
>
<span>{
message.isOutgoing
? lang('ActionPaidOneMessageOutgoing', {
amount,
})
: (() => {
const sender = selectSender(getGlobal(), message);
const userTitle = sender ? getPeerTitle(lang, sender) : '';
return lang('ActionPaidOneMessageIncoming', {
user: userTitle,
amount,
});
})()
}
</span>
</div>
);
}
return undefined;
};
const messageCountToAnimate = noAppearanceAnimation ? 0 : messageGroups.reduce((acc, messageGroup) => { const messageCountToAnimate = noAppearanceAnimation ? 0 : messageGroups.reduce((acc, messageGroup) => {
return acc + messageGroup.senderGroups.flat().length; return acc + messageGroup.senderGroups.flat().length;
}, 0); }, 0);
@ -228,6 +262,7 @@ const MessageListContent: FC<OwnProps> = ({
return compact([ return compact([
message.id === memoUnreadDividerBeforeIdRef.current && unreadDivider, message.id === memoUnreadDividerBeforeIdRef.current && unreadDivider,
message.paidMessageStars && !withUsers && renderPaidMessageAction(message, album),
<Message <Message
key={key} key={key}
message={message} message={message}

View File

@ -65,7 +65,9 @@
> .Button { > .Button {
opacity: 1; opacity: 1;
transform: scale(1); transform: scale(1);
/* stylelint-disable plugin/no-low-performance-animation-properties */
transition: transition:
border-radius 0.15s,
opacity var(--select-transition), opacity var(--select-transition),
transform var(--select-transition), transform var(--select-transition),
background-color 0.15s, background-color 0.15s,
@ -162,7 +164,6 @@
z-index: var(--z-middle-footer); z-index: var(--z-middle-footer);
transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0);
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */
transition: top 200ms, transform var(--layer-transition); transition: top 200ms, transform var(--layer-transition);
body.no-page-transitions & { body.no-page-transitions & {
@ -170,7 +171,6 @@
} }
body.no-right-column-animations & { body.no-right-column-animations & {
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */
transition: top 200ms !important; transition: top 200ms !important;
} }

View File

@ -50,6 +50,7 @@ import {
selectIsInSelectMode, selectIsInSelectMode,
selectIsRightColumnShown, selectIsRightColumnShown,
selectIsUserBlocked, selectIsUserBlocked,
selectPeerPaidMessagesStars,
selectPinnedIds, selectPinnedIds,
selectTabState, selectTabState,
selectTheme, selectTheme,
@ -153,6 +154,7 @@ type StateProps = {
canShowOpenChatButton?: boolean; canShowOpenChatButton?: boolean;
isContactRequirePremium?: boolean; isContactRequirePremium?: boolean;
topics?: Record<number, ApiTopic>; topics?: Record<number, ApiTopic>;
paidMessagesStars?: number;
}; };
function isImage(item: DataTransferItem) { function isImage(item: DataTransferItem) {
@ -213,6 +215,7 @@ function MiddleColumn({
canShowOpenChatButton, canShowOpenChatButton,
isContactRequirePremium, isContactRequirePremium,
topics, topics,
paidMessagesStars,
}: OwnProps & StateProps) { }: OwnProps & StateProps) {
const { const {
openChat, openChat,
@ -551,6 +554,7 @@ function MiddleColumn({
onNotchToggle={setIsNotchShown} onNotchToggle={setIsNotchShown}
isReady={isReady} isReady={isReady}
isContactRequirePremium={isContactRequirePremium} isContactRequirePremium={isContactRequirePremium}
paidMessagesStars={paidMessagesStars}
withBottomShift={withMessageListBottomShift} withBottomShift={withMessageListBottomShift}
withDefaultBg={Boolean(!customBackground && !backgroundColor)} withDefaultBg={Boolean(!customBackground && !backgroundColor)}
onIntersectPinnedMessage={renderingHandleIntersectPinnedMessage!} onIntersectPinnedMessage={renderingHandleIntersectPinnedMessage!}
@ -800,7 +804,10 @@ export default memo(withGlobal<OwnProps>(
) )
); );
const isContactRequirePremium = selectUserFullInfo(global, chatId)?.isContactRequirePremium; const userFull = selectUserFullInfo(global, chatId);
const isContactRequirePremium = userFull?.isContactRequirePremium;
const paidMessagesStars = selectPeerPaidMessagesStars(global, chatId);
return { return {
...state, ...state,
@ -838,6 +845,7 @@ export default memo(withGlobal<OwnProps>(
canShowOpenChatButton, canShowOpenChatButton,
isContactRequirePremium, isContactRequirePremium,
topics, topics,
paidMessagesStars,
}; };
}, },
)(MiddleColumn)); )(MiddleColumn));

View File

@ -26,6 +26,7 @@ import BotAdPane from './panes/BotAdPane';
import BotVerificationPane from './panes/BotVerificationPane'; import BotVerificationPane from './panes/BotVerificationPane';
import ChatReportPane from './panes/ChatReportPane'; import ChatReportPane from './panes/ChatReportPane';
import HeaderPinnedMessage from './panes/HeaderPinnedMessage'; import HeaderPinnedMessage from './panes/HeaderPinnedMessage';
import PaidMessageChargePane from './panes/PaidMessageChargePane';
import styles from './MiddleHeaderPanes.module.scss'; import styles from './MiddleHeaderPanes.module.scss';
@ -70,6 +71,7 @@ const MiddleHeaderPanes = ({
const [getChatReportState, setChatReportState] = useSignal<PaneState>(FALLBACK_PANE_STATE); const [getChatReportState, setChatReportState] = useSignal<PaneState>(FALLBACK_PANE_STATE);
const [getBotAdState, setBotAdState] = useSignal<PaneState>(FALLBACK_PANE_STATE); const [getBotAdState, setBotAdState] = useSignal<PaneState>(FALLBACK_PANE_STATE);
const [getBotVerificationState, setBotVerificationState] = useSignal<PaneState>(FALLBACK_PANE_STATE); const [getBotVerificationState, setBotVerificationState] = useSignal<PaneState>(FALLBACK_PANE_STATE);
const [getPaidMessageChargeState, setPaidMessageChargeState] = useSignal<PaneState>(FALLBACK_PANE_STATE);
const isPinnedMessagesFullWidth = isAudioPlayerRendered || !isDesktop; const isPinnedMessagesFullWidth = isAudioPlayerRendered || !isDesktop;
@ -94,10 +96,11 @@ const MiddleHeaderPanes = ({
const groupCallState = getGroupCallState(); const groupCallState = getGroupCallState();
const chatReportState = getChatReportState(); const chatReportState = getChatReportState();
const botAdState = getBotAdState(); const botAdState = getBotAdState();
const paidMessageState = getPaidMessageChargeState();
// Keep in sync with the order of the panes in the DOM // Keep in sync with the order of the panes in the DOM
const stateArray = [audioPlayerState, groupCallState, const stateArray = [audioPlayerState, groupCallState,
chatReportState, botVerificationState, pinnedState, botAdState]; chatReportState, botVerificationState, pinnedState, botAdState, paidMessageState];
const isFirstRender = isFirstRenderRef.current; const isFirstRender = isFirstRenderRef.current;
const totalHeight = stateArray.reduce((acc, state) => acc + state.height, 0); const totalHeight = stateArray.reduce((acc, state) => acc + state.height, 0);
@ -111,7 +114,7 @@ const MiddleHeaderPanes = ({
'--middle-header-panes-height': `${totalHeight}px`, '--middle-header-panes-height': `${totalHeight}px`,
}); });
}, [getAudioPlayerState, getGroupCallState, getPinnedState, }, [getAudioPlayerState, getGroupCallState, getPinnedState,
getChatReportState, getBotAdState, getBotVerificationState]); getChatReportState, getBotAdState, getBotVerificationState, getPaidMessageChargeState]);
if (!shouldRender) return undefined; if (!shouldRender) return undefined;
@ -140,6 +143,10 @@ const MiddleHeaderPanes = ({
peerId={chatId} peerId={chatId}
onPaneStateChange={setBotVerificationState} onPaneStateChange={setBotVerificationState}
/> />
<PaidMessageChargePane
peerId={chatId}
onPaneStateChange={setPaidMessageChargeState}
/>
<HeaderPinnedMessage <HeaderPinnedMessage
chatId={chatId} chatId={chatId}
threadId={threadId} threadId={threadId}

View File

@ -8,8 +8,8 @@
.button { .button {
background: var(--pattern-color); background: var(--pattern-color);
width: 10rem; width: auto;
margin-top: 0.5rem; margin-top: 1.0625rem;
text-transform: none; text-transform: none;
color: var(--color-white); color: var(--color-white);
height: 2.25rem; height: 2.25rem;
@ -28,8 +28,8 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
background: var(--pattern-color); background: var(--pattern-color);
max-width: 15rem; max-width: 13.5rem;
padding: 0.75rem 0; padding: 1.0625rem 0;
border-radius: 1.5rem; border-radius: 1.5rem;
&[dir="rtl"] { &[dir="rtl"] {
@ -60,7 +60,23 @@
} }
.description { .description {
white-space: pre;
text-align: center; text-align: center;
display: inline-flex;
align-items: center;
flex-wrap: wrap;
justify-content: center;
padding: 0 1rem; padding: 0 1rem;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.starIconContainer {
font-weight: var(--font-weight-medium);
display: inline-flex;
align-items: center;
}
.starIcon {
margin-inline-start: 0 !important;
margin-inline-end: 0.0625rem !important;
}

View File

@ -3,20 +3,25 @@ import { getActions, withGlobal } from '../../global';
import { getUserFirstOrLastName } from '../../global/helpers'; import { getUserFirstOrLastName } from '../../global/helpers';
import { selectTheme, selectUser } from '../../global/selectors'; import { selectTheme, selectUser } from '../../global/selectors';
import { formatStarsAsIcon } from '../../util/localization/format';
import { LOCAL_TGS_URLS } from '../common/helpers/animatedAssets'; import { LOCAL_TGS_URLS } from '../common/helpers/animatedAssets';
import renderText from '../common/helpers/renderText'; import renderText from '../common/helpers/renderText';
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 AnimatedIconWithPreview from '../common/AnimatedIconWithPreview';
import Icon from '../common/icons/Icon'; import Icon from '../common/icons/Icon';
import Sparkles from '../common/Sparkles';
import Button from '../ui/Button'; import Button from '../ui/Button';
import styles from './PremiumRequiredMessage.module.scss'; import styles from './RequirementToContactMessage.module.scss';
type OwnProps = { type OwnProps = {
// eslint-disable-next-line react/no-unused-prop-types
userId: string; userId: string;
paidMessagesStars?: number;
}; };
type StateProps = { type StateProps = {
@ -24,12 +29,15 @@ type StateProps = {
userName?: string; userName?: string;
}; };
function PremiumRequiredMessage({ patternColor, userName }: StateProps) { function RequirementToContactMessage({ patternColor, userName, paidMessagesStars }: OwnProps & StateProps) {
const lang = useOldLang(); const oldLang = useOldLang();
const { openPremiumModal } = getActions(); const lang = useLang();
const { openPremiumModal, openStarsBalanceModal } = getActions();
const handleOpenPremiumModal = useLastCallback(() => openPremiumModal()); const handleOpenPremiumModal = useLastCallback(() => openPremiumModal());
const handleGetMoreStars = useLastCallback(() => { openStarsBalanceModal({}); });
return ( return (
<div className={styles.root}> <div className={styles.root}>
<div className={styles.inner}> <div className={styles.inner}>
@ -43,15 +51,41 @@ function PremiumRequiredMessage({ patternColor, userName }: StateProps) {
<Icon name="comments-sticker" className={styles.commentsIcon} /> <Icon name="comments-sticker" className={styles.commentsIcon} />
</div> </div>
<span className={styles.description}> <span className={styles.description}>
{renderText(lang('MessageLockedPremium', userName), ['simple_markdown'])} {
paidMessagesStars
? lang('FirstMessageInPaidMessagesChat', {
user: userName,
amount: formatStarsAsIcon(lang,
paidMessagesStars,
{
asFont: true,
className: styles.starIcon,
containerClassName: styles.starIconContainer,
}),
}, {
withNodes: true,
withMarkdown: true,
})
: renderText(oldLang('MessageLockedPremium', userName), ['simple_markdown'])
}
</span> </span>
<Button <Button
color="translucent-black" color="translucent-black"
size="tiny" size="default"
onClick={handleOpenPremiumModal} pill
onClick={paidMessagesStars ? handleGetMoreStars : handleOpenPremiumModal}
className={styles.button} className={styles.button}
> >
{lang('MessagePremiumUnlock')} {
paidMessagesStars
? (
<>
{lang('ButtonBuyStars')}
<Sparkles preset="button" />
</>
)
: oldLang('MessagePremiumUnlock')
}
</Button> </Button>
</div> </div>
</div> </div>
@ -68,5 +102,5 @@ export default memo(
patternColor, patternColor,
userName: getUserFirstOrLastName(user), userName: getUserFirstOrLastName(user),
}; };
})(PremiumRequiredMessage), })(RequirementToContactMessage),
); );

View File

@ -86,6 +86,11 @@
} }
} }
.sendButtonStar {
margin-inline-start: 0 !important;
margin-inline-end: 0.125rem !important;
}
.attachments { .attachments {
max-height: 26rem; max-height: 26rem;
min-height: 5rem; min-height: 5rem;

View File

@ -25,6 +25,7 @@ import { selectCurrentLimit } from '../../../global/selectors/limits';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import captureEscKeyListener from '../../../util/captureEscKeyListener'; import captureEscKeyListener from '../../../util/captureEscKeyListener';
import { validateFiles } from '../../../util/files'; import { validateFiles } from '../../../util/files';
import { formatStarsAsIcon } from '../../../util/localization/format';
import { removeAllSelections } from '../../../util/selection'; import { removeAllSelections } from '../../../util/selection';
import { openSystemFilesDialog } from '../../../util/systemFilesDialog'; import { openSystemFilesDialog } from '../../../util/systemFilesDialog';
import getFilesFromDataTransferItems from './helpers/getFilesFromDataTransferItems'; import getFilesFromDataTransferItems from './helpers/getFilesFromDataTransferItems';
@ -36,6 +37,7 @@ import useDerivedState from '../../../hooks/useDerivedState';
import useEffectOnce from '../../../hooks/useEffectOnce'; import useEffectOnce from '../../../hooks/useEffectOnce';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
import useGetSelectionRange from '../../../hooks/useGetSelectionRange'; import useGetSelectionRange from '../../../hooks/useGetSelectionRange';
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 usePreviousDeprecated from '../../../hooks/usePreviousDeprecated'; import usePreviousDeprecated from '../../../hooks/usePreviousDeprecated';
@ -87,7 +89,9 @@ export type OwnProps = {
onRemoveSymbol: VoidFunction; onRemoveSymbol: VoidFunction;
onEmojiSelect: (emoji: string) => void; onEmojiSelect: (emoji: string) => void;
canScheduleUntilOnline?: boolean; canScheduleUntilOnline?: boolean;
canSchedule?: boolean;
onSendWhenOnline?: NoneToVoidFunction; onSendWhenOnline?: NoneToVoidFunction;
paidMessagesStars?: number;
}; };
type StateProps = { type StateProps = {
@ -144,7 +148,9 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
onRemoveSymbol, onRemoveSymbol,
onEmojiSelect, onEmojiSelect,
canScheduleUntilOnline, canScheduleUntilOnline,
canSchedule,
onSendWhenOnline, onSendWhenOnline,
paidMessagesStars,
}) => { }) => {
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@ -152,7 +158,8 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
const svgRef = useRef<SVGSVGElement>(null); const svgRef = useRef<SVGSVGElement>(null);
const { addRecentCustomEmoji, addRecentEmoji, updateAttachmentSettings } = getActions(); const { addRecentCustomEmoji, addRecentEmoji, updateAttachmentSettings } = getActions();
const lang = useOldLang(); const oldLang = useOldLang();
const lang = useLang();
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const mainButtonRef = useRef<HTMLButtonElement | null>(null); const mainButtonRef = useRef<HTMLButtonElement | null>(null);
@ -426,7 +433,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
requestMutation(() => { requestMutation(() => {
input.style.setProperty('--margin-for-scrollbar', `${width}px`); input.style.setProperty('--margin-for-scrollbar', `${width}px`);
}); });
}, [lang, isOpen]); }, [oldLang, isOpen]);
const MoreMenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => { const MoreMenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => {
return ({ onTrigger, isOpen: isMenuOpen }) => ( return ({ onTrigger, isOpen: isMenuOpen }) => (
@ -481,14 +488,15 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
})(); })();
let title = ''; let title = '';
const attachmentsLength = renderingAttachments.length;
if (areAllPhotos) { if (areAllPhotos) {
title = lang(isEditing ? 'EditMessageReplacePhoto' : 'PreviewSender.SendPhoto', renderingAttachments.length, 'i'); title = oldLang(isEditing ? 'EditMessageReplacePhoto' : 'PreviewSender.SendPhoto', attachmentsLength, 'i');
} else if (areAllVideos) { } else if (areAllVideos) {
title = lang(isEditing ? 'EditMessageReplaceVideo' : 'PreviewSender.SendVideo', renderingAttachments.length, 'i'); title = oldLang(isEditing ? 'EditMessageReplaceVideo' : 'PreviewSender.SendVideo', attachmentsLength, 'i');
} else if (areAllAudios) { } else if (areAllAudios) {
title = lang(isEditing ? 'EditMessageReplaceAudio' : 'PreviewSender.SendAudio', renderingAttachments.length, 'i'); title = oldLang(isEditing ? 'EditMessageReplaceAudio' : 'PreviewSender.SendAudio', attachmentsLength, 'i');
} else { } else {
title = lang(isEditing ? 'EditMessageReplaceFile' : 'PreviewSender.SendFile', renderingAttachments.length, 'i'); title = oldLang(isEditing ? 'EditMessageReplaceFile' : 'PreviewSender.SendFile', attachmentsLength, 'i');
} }
function renderHeader() { function renderHeader() {
@ -497,7 +505,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
} }
return ( return (
<div className="modal-header-condensed" dir={lang.isRtl ? 'rtl' : undefined}> <div className="modal-header-condensed" dir={oldLang.isRtl ? 'rtl' : undefined}>
<Button round color="translucent" size="smaller" ariaLabel="Cancel attachments" onClick={onClear}> <Button round color="translucent" size="smaller" ariaLabel="Cancel attachments" onClick={onClear}>
<Icon name="close" /> <Icon name="close" />
</Button> </Button>
@ -510,7 +518,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
positionX="right" positionX="right"
> >
{Boolean(!editingMessage) && ( {Boolean(!editingMessage) && (
<MenuItem icon="add" onClick={handleDocumentSelect}>{lang('Add')}</MenuItem> <MenuItem icon="add" onClick={handleDocumentSelect}>{oldLang('Add')}</MenuItem>
)} )}
{hasMedia && ( {hasMedia && (
<> <>
@ -518,12 +526,12 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
canInvertMedia && (!isInvertedMedia ? ( canInvertMedia && (!isInvertedMedia ? (
// eslint-disable-next-line react/jsx-no-bind // eslint-disable-next-line react/jsx-no-bind
<MenuItem icon="move-caption-up" onClick={() => setIsInvertedMedia(true)}> <MenuItem icon="move-caption-up" onClick={() => setIsInvertedMedia(true)}>
{lang('PreviewSender.MoveTextUp')} {oldLang('PreviewSender.MoveTextUp')}
</MenuItem> </MenuItem>
) : ( ) : (
// eslint-disable-next-line react/jsx-no-bind // eslint-disable-next-line react/jsx-no-bind
<MenuItem icon="move-caption-down" onClick={() => setIsInvertedMedia(undefined)}> <MenuItem icon="move-caption-down" onClick={() => setIsInvertedMedia(undefined)}>
{lang(('PreviewSender.MoveTextDown'))} {oldLang(('PreviewSender.MoveTextDown'))}
</MenuItem> </MenuItem>
)) ))
} }
@ -531,7 +539,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
!shouldForceAsFile && !shouldForceCompression && (isSendingCompressed ? ( !shouldForceAsFile && !shouldForceCompression && (isSendingCompressed ? (
// eslint-disable-next-line react/jsx-no-bind // eslint-disable-next-line react/jsx-no-bind
<MenuItem icon="document" onClick={() => setShouldSendCompressed(false)}> <MenuItem icon="document" onClick={() => setShouldSendCompressed(false)}>
{lang(isMultiple ? 'Attachment.SendAsFiles' : 'Attachment.SendAsFile')} {oldLang(isMultiple ? 'Attachment.SendAsFiles' : 'Attachment.SendAsFile')}
</MenuItem> </MenuItem>
) : ( ) : (
// eslint-disable-next-line react/jsx-no-bind // eslint-disable-next-line react/jsx-no-bind
@ -543,11 +551,11 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
{isSendingCompressed && hasAnySpoilerable && Boolean(!editingMessage) && ( {isSendingCompressed && hasAnySpoilerable && Boolean(!editingMessage) && (
hasSpoiler ? ( hasSpoiler ? (
<MenuItem icon="spoiler-disable" onClick={handleDisableSpoilers}> <MenuItem icon="spoiler-disable" onClick={handleDisableSpoilers}>
{lang('Attachment.DisableSpoiler')} {oldLang('Attachment.DisableSpoiler')}
</MenuItem> </MenuItem>
) : ( ) : (
<MenuItem icon="spoiler" onClick={handleEnableSpoilers}> <MenuItem icon="spoiler" onClick={handleEnableSpoilers}>
{lang('Attachment.EnableSpoiler')} {oldLang('Attachment.EnableSpoiler')}
</MenuItem> </MenuItem>
) )
)} )}
@ -576,6 +584,12 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
} }
const isBottomDividerShown = !areAttachmentsScrolledToBottom || !isCaptionNotScrolled; const isBottomDividerShown = !areAttachmentsScrolledToBottom || !isCaptionNotScrolled;
const buttonSendCaption = paidMessagesStars ? formatStarsAsIcon(lang,
attachmentsLength * paidMessagesStars,
{
className: styles.sendButtonStar,
asFont: true,
}) : oldLang('Send');
return ( return (
<Modal <Modal
@ -591,6 +605,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
forceDarkTheme && 'component-theme-dark', forceDarkTheme && 'component-theme-dark',
)} )}
noBackdropClose noBackdropClose
isLowStackPriority
> >
<div <div
className={styles.dropTarget} className={styles.dropTarget}
@ -599,7 +614,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onClick={unmarkHovered} onClick={unmarkHovered}
data-attach-description={lang('Preview.Dragging.AddItems', 10)} data-attach-description={oldLang('Preview.Dragging.AddItems', 10)}
data-dropzone data-dropzone
> >
<svg className={styles.dropOutlineContainer}> <svg className={styles.dropOutlineContainer}>
@ -684,7 +699,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
isActive={isOpen} isActive={isOpen}
getHtml={getHtml} getHtml={getHtml}
editableInputId={EDITABLE_INPUT_MODAL_ID} editableInputId={EDITABLE_INPUT_MODAL_ID}
placeholder={lang('AddCaption')} placeholder={oldLang('AddCaption')}
onUpdate={onCaptionUpdate} onUpdate={onCaptionUpdate}
onSend={handleSendClick} onSend={handleSendClick}
onScroll={handleCaptionScroll} onScroll={handleCaptionScroll}
@ -700,12 +715,13 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
onClick={handleSendClick} onClick={handleSendClick}
onContextMenu={canShowCustomSendMenu ? handleContextMenu : undefined} onContextMenu={canShowCustomSendMenu ? handleContextMenu : undefined}
> >
{shouldSchedule && !editingMessage ? lang('Next') : editingMessage ? lang('Save') : lang('Send')} {shouldSchedule && !editingMessage ? oldLang('Next')
: editingMessage ? oldLang('Save') : buttonSendCaption}
</Button> </Button>
{canShowCustomSendMenu && ( {canShowCustomSendMenu && (
<CustomSendMenu <CustomSendMenu
isOpen={isCustomSendMenuOpen} isOpen={isCustomSendMenuOpen}
canSchedule={isForMessage} canSchedule={canSchedule && isForMessage}
onSendSilent={!isChatWithSelf ? handleSendSilent : undefined} onSendSilent={!isChatWithSelf ? handleSendSilent : undefined}
onSendSchedule={handleScheduleClick} onSendSchedule={handleScheduleClick}
onClose={handleContextMenuClose} onClose={handleContextMenuClose}

View File

@ -1,5 +1,5 @@
import type { ChangeEvent, RefObject } from 'react'; import type { ChangeEvent, RefObject } from 'react';
import type { FC } from '../../../lib/teact/teact'; import type { FC, TeactNode } from '../../../lib/teact/teact';
import React, { import React, {
getIsHeavyAnimating, getIsHeavyAnimating,
memo, useEffect, useLayoutEffect, memo, useEffect, useLayoutEffect,
@ -58,7 +58,7 @@ type OwnProps = {
isReady: boolean; isReady: boolean;
isActive: boolean; isActive: boolean;
getHtml: Signal<string>; getHtml: Signal<string>;
placeholder: string; placeholder: TeactNode | string;
timedPlaceholderLangKey?: string; timedPlaceholderLangKey?: string;
timedPlaceholderDate?: number; timedPlaceholderDate?: number;
forcedPlaceholder?: string; forcedPlaceholder?: string;
@ -168,7 +168,7 @@ const MessageInput: FC<OwnProps & StateProps> = ({
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const absoluteContainerRef = useRef<HTMLDivElement>(null); const absoluteContainerRef = useRef<HTMLDivElement>(null);
const lang = useOldLang(); const oldLang = useOldLang();
const isContextMenuOpenRef = useRef(false); const isContextMenuOpenRef = useRef(false);
const [isTextFormatterOpen, openTextFormatter, closeTextFormatter] = useFlag(); const [isTextFormatterOpen, openTextFormatter, closeTextFormatter] = useFlag();
const [textFormatterAnchorPosition, setTextFormatterAnchorPosition] = useState<IAnchorPosition>(); const [textFormatterAnchorPosition, setTextFormatterAnchorPosition] = useState<IAnchorPosition>();
@ -561,9 +561,10 @@ const MessageInput: FC<OwnProps & StateProps> = ({
); );
const inputScrollerContentClass = buildClassName('input-scroller-content', isNeedPremium && 'is-need-premium'); const inputScrollerContentClass = buildClassName('input-scroller-content', isNeedPremium && 'is-need-premium');
const placeholderAriaLabel = typeof placeholder === 'string' ? placeholder : undefined;
return ( return (
<div id={id} onClick={shouldSuppressFocus ? onSuppressedFocus : undefined} dir={lang.isRtl ? 'rtl' : undefined}> <div id={id} onClick={shouldSuppressFocus ? onSuppressedFocus : undefined} dir={oldLang.isRtl ? 'rtl' : undefined}>
<div <div
className={buildClassName('custom-scroll', SCROLLER_CLASS, isNeedPremium && 'is-need-premium')} className={buildClassName('custom-scroll', SCROLLER_CLASS, isNeedPremium && 'is-need-premium')}
onScroll={onScroll} onScroll={onScroll}
@ -584,7 +585,7 @@ const MessageInput: FC<OwnProps & StateProps> = ({
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onContextMenu={IS_ANDROID ? handleAndroidContextMenu : undefined} onContextMenu={IS_ANDROID ? handleAndroidContextMenu : undefined}
onTouchCancel={IS_ANDROID ? processSelectionWithTimeout : undefined} onTouchCancel={IS_ANDROID ? processSelectionWithTimeout : undefined}
aria-label={placeholder} aria-label={placeholderAriaLabel}
onFocus={!isNeedPremium ? onFocus : undefined} onFocus={!isNeedPremium ? onFocus : undefined}
onBlur={!isNeedPremium ? onBlur : undefined} onBlur={!isNeedPremium ? onBlur : undefined}
/> />
@ -604,7 +605,7 @@ const MessageInput: FC<OwnProps & StateProps> = ({
) : placeholder} ) : placeholder}
{isStoryInput && isNeedPremium && ( {isStoryInput && isNeedPremium && (
<Button className="unlock-button" size="tiny" color="adaptive" onClick={handleOpenPremiumModal}> <Button className="unlock-button" size="tiny" color="adaptive" onClick={handleOpenPremiumModal}>
{lang('StoryRepliesLockedButton')} {oldLang('StoryRepliesLockedButton')}
</Button> </Button>
)} )}
</span> </span>

View File

@ -0,0 +1,59 @@
import { useRef, useState } from '../../../../lib/teact/teact';
import { getActions, getGlobal } from '../../../../global';
import { PAID_MESSAGES_PURPOSE } from '../../../../config';
import useLastCallback from '../../../../hooks/useLastCallback';
export default function usePaidMessageConfirmation(
starsForAllMessages: number,
) {
const {
shouldPaidMessageAutoApprove,
} = getGlobal().settings.byKey;
const [shouldAutoApprove,
setAutoApprove] = useState(Boolean(shouldPaidMessageAutoApprove));
const confirmPaymentHandlerRef = useRef<NoneToVoidFunction | undefined>(undefined);
const closeConfirmDialog = useLastCallback(() => {
getActions().closePaymentMessageConfirmDialogOpen();
});
const handleWithConfirmation = <T extends (...args: any[]) => void>(
handler: T,
...args: Parameters<T>
) => {
if (starsForAllMessages) {
const balance = getGlobal().stars?.balance.amount;
if (balance && starsForAllMessages > balance) {
getActions().openStarsBalanceModal({
topup:
{ balanceNeeded: starsForAllMessages, purpose: PAID_MESSAGES_PURPOSE },
});
return;
}
}
if (!shouldPaidMessageAutoApprove && starsForAllMessages) {
confirmPaymentHandlerRef.current = () => handler(...args);
getActions().openPaymentMessageConfirmDialogOpen();
} else {
handler(...args);
}
};
const dialogHandler = useLastCallback(() => {
confirmPaymentHandlerRef.current?.();
getActions().closePaymentMessageConfirmDialogOpen();
if (shouldAutoApprove) getActions().setPaidMessageAutoApprove();
});
return {
closeConfirmDialog,
handleWithConfirmation,
dialogHandler,
shouldAutoApprove,
setAutoApprove,
};
}

View File

@ -90,6 +90,7 @@ export function groupMessages(
} else if ( } else if (
nextMessage.id === firstUnreadId nextMessage.id === firstUnreadId
|| message.senderId !== nextMessage.senderId || message.senderId !== nextMessage.senderId
|| message.paidMessageStars
|| message.isOutgoing !== nextMessage.isOutgoing || message.isOutgoing !== nextMessage.isOutgoing
|| message.postAuthorTitle !== nextMessage.postAuthorTitle || message.postAuthorTitle !== nextMessage.postAuthorTitle
|| (isActionMessage(message) && message.content.action?.type !== 'phoneCall') || (isActionMessage(message) && message.content.action?.type !== 'phoneCall')

View File

@ -301,6 +301,8 @@ type StateProps = {
poll?: ApiPoll; poll?: ApiPoll;
maxTimestamp?: number; maxTimestamp?: number;
lastPlaybackTimestamp?: number; lastPlaybackTimestamp?: number;
paidMessageStars?: number;
isChatWithUser?: boolean;
}; };
type MetaPosition = type MetaPosition =
@ -421,6 +423,8 @@ const Message: FC<OwnProps & StateProps> = ({
maxTimestamp, maxTimestamp,
lastPlaybackTimestamp, lastPlaybackTimestamp,
onIntersectPinnedMessage, onIntersectPinnedMessage,
paidMessageStars,
isChatWithUser,
}) => { }) => {
const { const {
toggleMessageSelection, toggleMessageSelection,
@ -787,6 +791,10 @@ const Message: FC<OwnProps & StateProps> = ({
const withAppendix = contentClassName.includes('has-appendix'); const withAppendix = contentClassName.includes('has-appendix');
const emojiSize = getCustomEmojiSize(message.emojiOnlyCount); const emojiSize = getCustomEmojiSize(message.emojiOnlyCount);
const paidMessageStarsInMeta = !isChatWithUser
? (isAlbum && paidMessageStars ? album.messages.length * paidMessageStars : paidMessageStars)
: undefined;
let metaPosition!: MetaPosition; let metaPosition!: MetaPosition;
if (phoneCall) { if (phoneCall) {
metaPosition = 'none'; metaPosition = 'none';
@ -1019,6 +1027,7 @@ const Message: FC<OwnProps & StateProps> = ({
onEffectClick={handleEffectClick} onEffectClick={handleEffectClick}
onTranslationClick={handleTranslationClick} onTranslationClick={handleTranslationClick}
onOpenThread={handleOpenThread} onOpenThread={handleOpenThread}
paidMessageStars={paidMessageStarsInMeta}
/> />
); );
@ -1119,7 +1128,7 @@ const Message: FC<OwnProps & StateProps> = ({
{hasAnimatedEmoji && animatedCustomEmoji && ( {hasAnimatedEmoji && animatedCustomEmoji && (
<AnimatedCustomEmoji <AnimatedCustomEmoji
customEmojiId={animatedCustomEmoji} customEmojiId={animatedCustomEmoji}
withEffects={withAnimatedEffects && isUserId(chatId) && !effect} withEffects={withAnimatedEffects && isChatWithUser && !effect}
isOwn={isOwn} isOwn={isOwn}
observeIntersection={observeIntersectionForLoading} observeIntersection={observeIntersectionForLoading}
forceLoadPreview={isLocal} forceLoadPreview={isLocal}
@ -1131,7 +1140,7 @@ const Message: FC<OwnProps & StateProps> = ({
{hasAnimatedEmoji && animatedEmoji && ( {hasAnimatedEmoji && animatedEmoji && (
<AnimatedEmoji <AnimatedEmoji
emoji={animatedEmoji} emoji={animatedEmoji}
withEffects={withAnimatedEffects && isUserId(chatId) && !effect} withEffects={withAnimatedEffects && isChatWithUser && !effect}
isOwn={isOwn} isOwn={isOwn}
observeIntersection={observeIntersectionForLoading} observeIntersection={observeIntersectionForLoading}
forceLoadPreview={isLocal} forceLoadPreview={isLocal}
@ -1719,15 +1728,18 @@ export default memo(withGlobal<OwnProps>(
} = ownProps; } = ownProps;
const { const {
id, chatId, viaBotId, isOutgoing, forwardInfo, transcriptionId, isPinned, viaBusinessBotId, effectId, id, chatId, viaBotId, isOutgoing, forwardInfo, transcriptionId, isPinned, viaBusinessBotId, effectId,
paidMessageStars,
} = message; } = message;
const isChatWithUser = isUserId(chatId);
const chat = selectChat(global, chatId); const chat = selectChat(global, chatId);
const isChatWithSelf = selectIsChatWithSelf(global, chatId); const isChatWithSelf = selectIsChatWithSelf(global, chatId);
const isSystemBotChat = isSystemBot(chatId); const isSystemBotChat = isSystemBot(chatId);
const isAnonymousForwards = isAnonymousForwardsChat(chatId); const isAnonymousForwards = isAnonymousForwardsChat(chatId);
const isChannel = chat && isChatChannel(chat); const isChannel = chat && isChatChannel(chat);
const isGroup = chat && isChatGroup(chat); const isGroup = chat && isChatGroup(chat);
const chatFullInfo = !isUserId(chatId) ? selectChatFullInfo(global, chatId) : undefined; const chatFullInfo = !isChatWithUser ? selectChatFullInfo(global, chatId) : undefined;
const webPageStoryData = message.content.webPage?.story; const webPageStoryData = message.content.webPage?.story;
const webPageStory = webPageStoryData const webPageStory = webPageStoryData
? selectPeerStory(global, webPageStoryData.peerId, webPageStoryData.id) ? selectPeerStory(global, webPageStoryData.peerId, webPageStoryData.id)
@ -1931,6 +1943,8 @@ export default memo(withGlobal<OwnProps>(
poll, poll,
maxTimestamp, maxTimestamp,
lastPlaybackTimestamp, lastPlaybackTimestamp,
paidMessageStars,
isChatWithUser,
}; };
}, },
)(Message)); )(Message));

View File

@ -14,6 +14,7 @@
cursor: var(--custom-cursor, pointer); cursor: var(--custom-cursor, pointer);
user-select: none; user-select: none;
.message-price,
.message-time, .message-time,
.message-imported, .message-imported,
.message-signature, .message-signature,
@ -26,6 +27,22 @@
white-space: nowrap; white-space: nowrap;
} }
.message-price-stars-container {
display: inline-flex;
align-items: center;
}
.message-price-star-icon {
margin-inline-start: 0 !important;
margin-inline-end: 0.0625rem !important;
}
.message-price {
display: inline-flex;
align-items: center;
margin-inline-end: 0.25rem;
}
.message-replies-wrapper { .message-replies-wrapper {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -8,6 +8,7 @@ import type {
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { formatDateTimeToString, formatPastTimeShort, formatTime } from '../../../util/dates/dateFormat'; import { formatDateTimeToString, formatPastTimeShort, formatTime } from '../../../util/dates/dateFormat';
import { formatStarsAsIcon } from '../../../util/localization/format';
import { formatIntegerCompact } from '../../../util/textFormat'; import { formatIntegerCompact } from '../../../util/textFormat';
import renderText from '../../common/helpers/renderText'; import renderText from '../../common/helpers/renderText';
@ -38,6 +39,7 @@ type OwnProps = {
onEffectClick: (e: React.MouseEvent<HTMLDivElement>) => void; onEffectClick: (e: React.MouseEvent<HTMLDivElement>) => void;
renderQuickReactionButton?: () => TeactNode | undefined; renderQuickReactionButton?: () => TeactNode | undefined;
onOpenThread: NoneToVoidFunction; onOpenThread: NoneToVoidFunction;
paidMessageStars?: number;
}; };
const MessageMeta: FC<OwnProps> = ({ const MessageMeta: FC<OwnProps> = ({
@ -56,6 +58,7 @@ const MessageMeta: FC<OwnProps> = ({
onTranslationClick, onTranslationClick,
onEffectClick, onEffectClick,
onOpenThread, onOpenThread,
paidMessageStars,
}) => { }) => {
const { showNotification } = getActions(); const { showNotification } = getActions();
@ -176,6 +179,16 @@ const MessageMeta: FC<OwnProps> = ({
{signature && ( {signature && (
<span className="message-signature">{renderText(signature)}</span> <span className="message-signature">{renderText(signature)}</span>
)} )}
{paidMessageStars && (
<span className="message-price">{
formatStarsAsIcon(lang, paidMessageStars, {
asFont: true,
className: 'message-price-star-icon',
containerClassName: 'message-price-stars-container',
})
}
</span>
)}
<span className="message-time" title={dateTitle} onMouseEnter={markActivated}> <span className="message-time" title={dateTitle} onMouseEnter={markActivated}>
{message.forwardInfo?.isImported && ( {message.forwardInfo?.isImported && (
<> <>

View File

@ -0,0 +1,41 @@
@use "../../../styles/mixins";
.root {
@include mixins.header-pane;
display: flex;
flex-direction: column;
height: auto;
justify-content: center;
align-items: center;
padding-inline: 1rem;
color: var(--color-text-secondary);
font-size: 0.875rem;
}
.message {
justify-content: center;
display: flex;
align-items: center;
margin-bottom: 0.375rem;
font-size: 1rem;
}
.messageStars {
font-weight: var(--font-weight-medium);
padding-inline: 0.25rem;
display: inline-flex;
align-items: center;
}
.messageStarIcon {
margin-inline-start: 0 !important;
margin-inline-end: 0.125rem !important;
}
.checkBox {
margin-top: 0.375rem;
margin-inline: -1.125rem;
padding-inline-start: 3.5rem;
}

View File

@ -0,0 +1,144 @@
import type { FC } from '../../../lib/teact/teact';
import React, { memo } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type {
ApiChat,
} from '../../../api/types';
import {
getPeerTitle,
} from '../../../global/helpers';
import {
selectChat,
selectUserFullInfo,
} from '../../../global/selectors';
import { formatStarsAsIcon } from '../../../util/localization/format';
import useFlag from '../../../hooks/useFlag';
import useLang from '../../../hooks/useLang';
// import useTimeout from '../../../hooks/schedulers/useTimeout';
import useLastCallback from '../../../hooks/useLastCallback';
import useHeaderPane, { type PaneState } from '../hooks/useHeaderPane';
import Button from '../../ui/Button';
import Checkbox from '../../ui/Checkbox';
import ConfirmDialog from '../../ui/ConfirmDialog';
// import CustomEmoji from '../../common/CustomEmoji';
import styles from './PaidMessageChargePane.module.scss';
type OwnProps = {
peerId: string;
onPaneStateChange?: (state: PaneState) => void;
};
type StateProps = {
chargedPaidMessageStars?: number;
chat?: ApiChat;
};
const PaidMessageChargePane: FC<OwnProps & StateProps> = ({
chargedPaidMessageStars,
chat,
onPaneStateChange,
peerId,
}) => {
const isOpen = Boolean(chargedPaidMessageStars);
const lang = useLang();
const [isRemoveFeeDialogOpen, openRemoveFeeDialog, closeRemoveFeeDialog] = useFlag();
const [shouldRefoundStars, setShouldRefoundStars] = useFlag(false);
const {
addNoPaidMessagesException,
} = getActions();
const { ref, shouldRender } = useHeaderPane({
isOpen,
onStateChange: onPaneStateChange,
});
const handleRemoveFee = useLastCallback(() => {
openRemoveFeeDialog();
});
const handleConfirmRemoveFee = useLastCallback(() => {
addNoPaidMessagesException({ userId: peerId, shouldRefundCharged: shouldRefoundStars });
});
if (!shouldRender || !chargedPaidMessageStars) return undefined;
const peerName = chat ? getPeerTitle(lang, chat) : undefined;
const message = lang('PaneMessagePaidMessageCharge', {
peer: peerName,
amount: formatStarsAsIcon(lang,
chargedPaidMessageStars,
{ asFont: true, className: styles.messageStarIcon, containerClassName: styles.messageStars }),
}, {
withMarkdown: true,
withNodes: true,
});
const dialogMessage = lang('ConfirmDialogMessageRemoveFee', {
peer: peerName,
}, {
withMarkdown: true,
withNodes: true,
});
const checkBoxTitle = lang('ConfirmDialogRemoveFeeRefundStars', {
amount: chargedPaidMessageStars,
}, {
withMarkdown: true,
withNodes: true,
});
return (
<div ref={ref} className={styles.root}>
<div className={styles.message}>
{message}
</div>
<Button
isText
noForcedUpperCase
pill
fluid
size="tiny"
className={styles.button}
onClick={handleRemoveFee}
>
{lang('RemoveFeeTitle')}
</Button>
<ConfirmDialog
isOpen={isRemoveFeeDialogOpen}
onClose={closeRemoveFeeDialog}
title={lang('RemoveFeeTitle')}
confirmLabel={lang('ConfirmRemoveMessageFee')}
confirmHandler={handleConfirmRemoveFee}
>
{dialogMessage}
<Checkbox
className={styles.checkBox}
label={checkBoxTitle}
checked={shouldRefoundStars}
onCheck={setShouldRefoundStars}
/>
</ConfirmDialog>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { peerId }): StateProps => {
const chat = selectChat(global, peerId);
const peerFullInfo = selectUserFullInfo(global, peerId);
const chargedPaidMessageStars = peerFullInfo?.settings?.chargedPaidMessageStars;
return {
chargedPaidMessageStars,
chat,
};
},
)(PaidMessageChargePane));

View File

@ -10,9 +10,14 @@ import {
type ApiMessage, type ApiPeer, type ApiStarsAmount, MAIN_THREAD_ID, type ApiMessage, type ApiPeer, type ApiStarsAmount, MAIN_THREAD_ID,
} from '../../../api/types'; } from '../../../api/types';
import { getPeerTitle } from '../../../global/helpers'; import {
getPeerTitle,
} from '../../../global/helpers';
import { isApiPeerUser } from '../../../global/helpers/peers'; import { isApiPeerUser } from '../../../global/helpers/peers';
import { selectPeer, selectTabState, selectTheme } from '../../../global/selectors'; import {
selectPeer, selectPeerPaidMessagesStars,
selectTabState, selectTheme,
} from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import buildStyle from '../../../util/buildStyle'; import buildStyle from '../../../util/buildStyle';
import { formatCurrency } from '../../../util/formatCurrency'; import { formatCurrency } from '../../../util/formatCurrency';
@ -49,6 +54,7 @@ export type StateProps = {
currentUserId?: string; currentUserId?: string;
isPaymentFormLoading?: boolean; isPaymentFormLoading?: boolean;
starBalance?: ApiStarsAmount; starBalance?: ApiStarsAmount;
paidMessagesStars?: number;
}; };
const LIMIT_DISPLAY_THRESHOLD = 50; const LIMIT_DISPLAY_THRESHOLD = 50;
@ -67,6 +73,7 @@ function GiftComposer({
currentUserId, currentUserId,
isPaymentFormLoading, isPaymentFormLoading,
starBalance, starBalance,
paidMessagesStars,
}: OwnProps & StateProps) { }: OwnProps & StateProps) {
const { const {
sendStarGift, sendPremiumGiftByStars, openInvoice, openGiftUpgradeModal, openStarsBalanceModal, sendStarGift, sendPremiumGiftByStars, openInvoice, openGiftUpgradeModal, openStarsBalanceModal,
@ -202,14 +209,19 @@ function GiftComposer({
const title = getPeerTitle(lang, peer!)!; const title = getPeerTitle(lang, peer!)!;
return ( return (
<div className={styles.optionsSection}> <div className={styles.optionsSection}>
<TextArea
className={styles.messageInput} {!paidMessagesStars && (
onChange={handleGiftMessageChange} <TextArea
value={giftMessage} className={styles.messageInput}
label={lang('GiftMessagePlaceholder')} onChange={handleGiftMessageChange}
maxLength={captionLimit} value={giftMessage}
maxLengthIndicator={symbolsLeft && symbolsLeft < LIMIT_DISPLAY_THRESHOLD ? symbolsLeft.toString() : undefined} label={lang('GiftMessagePlaceholder')}
/> maxLength={captionLimit}
maxLengthIndicator={
symbolsLeft && symbolsLeft < LIMIT_DISPLAY_THRESHOLD ? symbolsLeft.toString() : undefined
}
/>
)}
{canUseStarsPayment && ( {canUseStarsPayment && (
<ListItem className={styles.switcher} narrow ripple onClick={toggleShouldPayByStars}> <ListItem className={styles.switcher} narrow ripple onClick={toggleShouldPayByStars}>
@ -377,6 +389,7 @@ export default memo(withGlobal<OwnProps>(
backgroundColor, backgroundColor,
} = global.settings.themes[theme] || {}; } = global.settings.themes[theme] || {};
const peer = selectPeer(global, peerId); const peer = selectPeer(global, peerId);
const paidMessagesStars = selectPeerPaidMessagesStars(global, peerId);
const tabState = selectTabState(global); const tabState = selectTabState(global);
@ -391,6 +404,7 @@ export default memo(withGlobal<OwnProps>(
captionLimit: global.appConfig?.starGiftMaxMessageLength, captionLimit: global.appConfig?.starGiftMaxMessageLength,
currentUserId: global.currentUserId, currentUserId: global.currentUserId,
isPaymentFormLoading: tabState.isPaymentFormLoading, isPaymentFormLoading: tabState.isPaymentFormLoading,
paidMessagesStars,
}; };
}, },
)(GiftComposer)); )(GiftComposer));

View File

@ -55,7 +55,7 @@
} }
.giftTitle { .giftTitle {
font-weight: 500; font-weight: var(--font-weight-medium);
font-size: 1.5rem; font-size: 1.5rem;
text-align: center; text-align: center;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;

View File

@ -2,45 +2,78 @@ import React, {
type FC, type FC,
memo, useEffect, memo, useEffect,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { getActions, getGlobal } from '../../../global'; import {
getActions, getGlobal, withGlobal,
} from '../../../global';
import type { TabState } from '../../../global/types'; import type { TabState } from '../../../global/types';
import type { ThreadId } from '../../../types'; import type { ThreadId } from '../../../types';
import { MAIN_THREAD_ID } from '../../../api/types'; import { MAIN_THREAD_ID } from '../../../api/types';
import { getPeerTitle } from '../../../global/helpers'; import {
import { selectPeer } from '../../../global/selectors'; getPeerTitle,
} from '../../../global/helpers';
import {
selectPeer, selectTabState,
} from '../../../global/selectors';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang'; import useOldLang from '../../../hooks/useOldLang';
import usePaidMessageConfirmation from '../../middle/composer/hooks/usePaidMessageConfirmation';
import PaymentMessageConfirmDialog from '../../common/PaymentMessageConfirmDialog';
import RecipientPicker from '../../common/RecipientPicker'; import RecipientPicker from '../../common/RecipientPicker';
export type OwnProps = { export type OwnProps = {
modal: TabState['sharePreparedMessageModal']; modal: TabState['sharePreparedMessageModal'];
}; };
const SharePreparedMessageModal: FC<OwnProps> = ({ type StateProps = {
modal, isPaymentMessageConfirmDialogOpen: boolean;
};
export type SendParams = {
peerName?: string;
starsForSendMessage: number;
};
const SharePreparedMessageModal: FC<OwnProps & StateProps> = ({
modal, isPaymentMessageConfirmDialogOpen,
}) => { }) => {
const { const {
closeSharePreparedMessageModal, closeSharePreparedMessageModal,
sendInlineBotResult, sendInlineBotResult,
sendWebAppEvent, sendWebAppEvent,
showNotification, showNotification,
updateSharePreparedMessageModalSendArgs,
} = getActions(); } = getActions();
const lang = useOldLang(); const lang = useOldLang();
const isOpen = Boolean(modal); const isOpen = Boolean(modal);
const [isShown, markIsShown, unmarkIsShown] = useFlag(); const [isShown, markIsShown, unmarkIsShown] = useFlag();
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
markIsShown(); markIsShown();
} }
}, [isOpen, markIsShown]); }, [isOpen, markIsShown]);
const { message, filter, webAppKey } = modal || {}; const {
message, filter, webAppKey, pendingSendArgs,
} = modal || {};
const {
starsForSendMessage,
} = pendingSendArgs || {};
const {
closeConfirmDialog: closeConfirmModalPayForMessage,
dialogHandler: paymentMessageConfirmDialogHandler,
shouldAutoApprove: shouldPaidMessageAutoApprove,
setAutoApprove: setShouldPaidMessageAutoApprove,
handleWithConfirmation: handleActionWithPaymentConfirmation,
} = usePaidMessageConfirmation(starsForSendMessage || 0);
const handleClose = useLastCallback(() => { const handleClose = useLastCallback(() => {
closeSharePreparedMessageModal(); closeSharePreparedMessageModal();
@ -55,7 +88,7 @@ const SharePreparedMessageModal: FC<OwnProps> = ({
} }
}); });
const handleSelectRecipient = useLastCallback((id: string, threadId?: ThreadId) => { const handleSend = useLastCallback((id: string, threadId?: ThreadId) => {
if (message && webAppKey) { if (message && webAppKey) {
const global = getGlobal(); const global = getGlobal();
const peer = selectPeer(global, id); const peer = selectPeer(global, id);
@ -65,33 +98,82 @@ const SharePreparedMessageModal: FC<OwnProps> = ({
id: message.result.id, id: message.result.id,
queryId: message.result.queryId, queryId: message.result.queryId,
}); });
if (!starsForSendMessage) {
showNotification({
message: lang('BotSharedToOne', getPeerTitle(lang, peer!)),
});
}
sendWebAppEvent({ sendWebAppEvent({
webAppKey, webAppKey,
event: { event: {
eventType: 'prepared_message_sent', eventType: 'prepared_message_sent',
}, },
}); });
showNotification({
message: lang('BotSharedToOne', getPeerTitle(lang, peer!)),
});
closeSharePreparedMessageModal(); closeSharePreparedMessageModal();
updateSharePreparedMessageModalSendArgs({ args: undefined });
} }
}); });
const handleSelectRecipient = useLastCallback((id: string, threadId?: ThreadId) => {
updateSharePreparedMessageModalSendArgs({ args: { peerId: id, threadId } });
});
const handleSendWithPaymentConformation = useLastCallback(() => {
if (pendingSendArgs) {
handleActionWithPaymentConfirmation(handleSend, pendingSendArgs.peerId, pendingSendArgs.threadId);
}
});
const handleClosePaymentMessageConfirmDialog = useLastCallback(() => {
closeConfirmModalPayForMessage();
updateSharePreparedMessageModalSendArgs({ args: undefined });
});
useEffect(() => {
if (pendingSendArgs) {
handleSendWithPaymentConformation();
}
}, [pendingSendArgs]);
const global = getGlobal();
const peer = pendingSendArgs ? selectPeer(global, pendingSendArgs.peerId) : undefined;
const peerName = peer ? getPeerTitle(lang, peer) : undefined;
if (!isOpen && !isShown) { if (!isOpen && !isShown) {
return undefined; return undefined;
} }
return ( return (
<RecipientPicker <>
isOpen={isOpen} <RecipientPicker
searchPlaceholder={lang('Search')} isOpen={isOpen}
filter={filter} searchPlaceholder={lang('Search')}
onSelectRecipient={handleSelectRecipient} filter={filter}
onClose={handleClose} onSelectRecipient={handleSelectRecipient}
onCloseAnimationEnd={unmarkIsShown} onClose={handleClose}
/> onCloseAnimationEnd={unmarkIsShown}
isLowStackPriority
/>
<PaymentMessageConfirmDialog
isOpen={isPaymentMessageConfirmDialogOpen}
onClose={handleClosePaymentMessageConfirmDialog}
userName={peerName}
messagePriceInStars={starsForSendMessage || 0}
messagesCount={1}
shouldAutoApprove={shouldPaidMessageAutoApprove}
setAutoApprove={setShouldPaidMessageAutoApprove}
confirmHandler={paymentMessageConfirmDialogHandler}
/>
</>
); );
}; };
export default memo(SharePreparedMessageModal); export default memo(withGlobal(
(global): StateProps => {
const tabState = selectTabState(global);
const { isPaymentMessageConfirmDialogOpen } = tabState;
return {
isPaymentMessageConfirmDialogOpen,
};
},
)(SharePreparedMessageModal));

View File

@ -7,6 +7,7 @@ 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 { getChatTitle, getPeerTitle, getUserFullName } from '../../../global/helpers'; import { getChatTitle, getPeerTitle, getUserFullName } from '../../../global/helpers';
import { selectChat, selectIsPremiumPurchaseBlocked, selectUser } from '../../../global/selectors'; import { selectChat, selectIsPremiumPurchaseBlocked, selectUser } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
@ -108,6 +109,13 @@ const StarsBalanceModal = ({
return oldLang('StarsNeededTextLink'); return oldLang('StarsNeededTextLink');
} }
if (topup?.purpose === PAID_MESSAGES_PURPOSE) {
return lang('StarsNeededTextSendPaidMessages', undefined, {
withMarkdown: true,
withNodes: true,
});
}
return undefined; return undefined;
}, [originReaction, originStarsPayment, originGift, topup?.purpose, lang, oldLang]); }, [originReaction, originStarsPayment, originGift, topup?.purpose, lang, oldLang]);

View File

@ -2,27 +2,40 @@ import type { ApiStarsAmount, ApiStarsTransaction } from '../../../../api/types'
import type { OldLangFn } from '../../../../hooks/useOldLang'; import type { OldLangFn } from '../../../../hooks/useOldLang';
import { buildStarsTransactionCustomPeer } from '../../../../global/helpers/payments'; import { buildStarsTransactionCustomPeer } from '../../../../global/helpers/payments';
import {
type LangFn,
} from '../../../../util/localization';
import { formatPercent } from '../../../../util/textFormat'; import { formatPercent } from '../../../../util/textFormat';
export function getTransactionTitle(lang: OldLangFn, transaction: ApiStarsTransaction) { export function getTransactionTitle(oldLang: OldLangFn, lang: LangFn, transaction: ApiStarsTransaction) {
if (transaction.starRefCommision) { if (transaction.paidMessages) {
return lang('StarTransactionCommission', formatPercent(transaction.starRefCommision)); return lang(
'PaidMessageTransaction',
{ count: transaction.paidMessages },
{
withNodes: true,
pluralValue: transaction.paidMessages,
},
);
} }
if (transaction.isGiftUpgrade) return lang('Gift2TransactionUpgraded'); if (transaction.starRefCommision) {
if (transaction.extendedMedia) return lang('StarMediaPurchase'); return oldLang('StarTransactionCommission', formatPercent(transaction.starRefCommision));
if (transaction.subscriptionPeriod) return transaction.title || lang('StarSubscriptionPurchase'); }
if (transaction.isReaction) return lang('StarsReactionsSent'); if (transaction.isGiftUpgrade) return oldLang('Gift2TransactionUpgraded');
if (transaction.giveawayPostId) return lang('StarsGiveawayPrizeReceived'); if (transaction.extendedMedia) return oldLang('StarMediaPurchase');
if (transaction.isMyGift) return lang('StarsGiftSent'); if (transaction.subscriptionPeriod) return transaction.title || oldLang('StarSubscriptionPurchase');
if (transaction.isGift) return lang('StarsGiftReceived'); if (transaction.isReaction) return oldLang('StarsReactionsSent');
if (transaction.giveawayPostId) return oldLang('StarsGiveawayPrizeReceived');
if (transaction.isMyGift) return oldLang('StarsGiftSent');
if (transaction.isGift) return oldLang('StarsGiftReceived');
if (transaction.starGift) { if (transaction.starGift) {
return isNegativeStarsAmount(transaction.stars) ? lang('Gift2TransactionSent') : lang('Gift2ConvertedTitle'); return isNegativeStarsAmount(transaction.stars) ? oldLang('Gift2TransactionSent') : oldLang('Gift2ConvertedTitle');
} }
const customPeer = (transaction.peer && transaction.peer.type !== 'peer' const customPeer = (transaction.peer && transaction.peer.type !== 'peer'
&& buildStarsTransactionCustomPeer(transaction.peer)) || undefined; && buildStarsTransactionCustomPeer(transaction.peer)) || undefined;
if (customPeer) return customPeer.title || lang(customPeer.titleKey!); if (customPeer) return customPeer.title || oldLang(customPeer.titleKey!);
return transaction.title; return transaction.title;
} }

View File

@ -64,7 +64,7 @@ const StarsTransactionItem = ({ transaction, className }: OwnProps) => {
const giftSticker = starGift && getStickerFromGift(starGift); const giftSticker = starGift && getStickerFromGift(starGift);
const data = useMemo(() => { const data = useMemo(() => {
let title = getTransactionTitle(oldLang, transaction); let title = getTransactionTitle(oldLang, lang, transaction);
let description; let description;
let status: string | undefined; let status: string | undefined;
let avatarPeer: ApiPeer | CustomPeer | undefined; let avatarPeer: ApiPeer | CustomPeer | undefined;
@ -105,7 +105,7 @@ const StarsTransactionItem = ({ transaction, className }: OwnProps) => {
avatarPeer, avatarPeer,
status, status,
}; };
}, [oldLang, peer, transaction]); }, [oldLang, lang, peer, transaction]);
const previewContent = useMemo(() => { const previewContent = useMemo(() => {
if (isUniqueGift) { if (isUniqueGift) {

View File

@ -64,6 +64,16 @@
text-align: center; text-align: center;
} }
.totalStars {
display: inline-flex;
align-items: center;
}
.starIcon {
line-height: 1 !important;
margin-inline-start: 0 !important;
}
.footer { .footer {
text-align: center; text-align: center;
margin-block: 0.5rem; margin-block: 0.5rem;

View File

@ -10,7 +10,10 @@ import type { TabState } from '../../../../global/types';
import { MediaViewerOrigin } from '../../../../types'; import { MediaViewerOrigin } from '../../../../types';
import { getMessageLink } from '../../../../global/helpers'; import { getMessageLink } from '../../../../global/helpers';
import { buildStarsTransactionCustomPeer, formatStarsTransactionAmount } from '../../../../global/helpers/payments'; import {
buildStarsTransactionCustomPeer,
formatStarsTransactionAmount,
} from '../../../../global/helpers/payments';
import { import {
selectCanPlayAnimatedEmojis, selectCanPlayAnimatedEmojis,
selectGiftStickerForStars, selectGiftStickerForStars,
@ -19,6 +22,8 @@ import {
import buildClassName from '../../../../util/buildClassName'; import buildClassName from '../../../../util/buildClassName';
import { copyTextToClipboard } from '../../../../util/clipboard'; import { copyTextToClipboard } from '../../../../util/clipboard';
import { formatDateTimeToString } from '../../../../util/dates/dateFormat'; import { formatDateTimeToString } from '../../../../util/dates/dateFormat';
import { formatStarsAsIcon } from '../../../../util/localization/format';
import { formatPercent } from '../../../../util/textFormat';
import { getGiftAttributes, getStickerFromGift } from '../../../common/helpers/gifts'; import { getGiftAttributes, getStickerFromGift } from '../../../common/helpers/gifts';
import { getTransactionTitle, isNegativeStarsAmount } from '../helpers/transaction'; import { getTransactionTitle, isNegativeStarsAmount } from '../helpers/transaction';
@ -48,10 +53,11 @@ type StateProps = {
peer?: ApiPeer; peer?: ApiPeer;
canPlayAnimatedEmojis?: boolean; canPlayAnimatedEmojis?: boolean;
topSticker?: ApiSticker; topSticker?: ApiSticker;
paidMessageCommission?: number;
}; };
const StarsTransactionModal: FC<OwnProps & StateProps> = ({ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
modal, peer, canPlayAnimatedEmojis, topSticker, modal, peer, canPlayAnimatedEmojis, topSticker, paidMessageCommission,
}) => { }) => {
const { showNotification, openMediaViewer, closeStarsTransactionModal } = getActions(); const { showNotification, openMediaViewer, closeStarsTransactionModal } = getActions();
@ -90,7 +96,7 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
const peerId = transaction.peer?.type === 'peer' ? transaction.peer.id : undefined; const peerId = transaction.peer?.type === 'peer' ? transaction.peer.id : undefined;
const toName = transaction.peer && oldLang(getStarsPeerTitleKey(transaction.peer)); const toName = transaction.peer && oldLang(getStarsPeerTitleKey(transaction.peer));
const title = getTransactionTitle(oldLang, transaction); const title = getTransactionTitle(oldLang, lang, transaction);
const messageLink = peer && transaction.messageId && !isGiftUpgrade const messageLink = peer && transaction.messageId && !isGiftUpgrade
? getMessageLink(peer, undefined, transaction.messageId) : undefined; ? getMessageLink(peer, undefined, transaction.messageId) : undefined;
@ -163,12 +169,25 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
</span> </span>
<StarIcon type="gold" size="middle" /> <StarIcon type="gold" size="middle" />
</p> </p>
{transaction.paidMessages && transaction.starRefCommision && paidMessageCommission
&& (
<p className={styles.description}>
{lang(
'PaidMessageTransactionDescription',
{ percent: formatPercent(paidMessageCommission / 10) },
{
withNodes: true,
withMarkdown: true,
},
)}
</p>
)}
</div> </div>
); );
const tableData: TableData = []; const tableData: TableData = [];
if (transaction.starRefCommision) { if (transaction && !transaction.paidMessages) {
tableData.push([ tableData.push([
oldLang('StarsTransaction.StarRefReason.Title'), oldLang('StarsTransaction.StarRefReason.Title'),
oldLang('StarsTransaction.StarRefReason.Program'), oldLang('StarsTransaction.StarRefReason.Program'),
@ -187,7 +206,7 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
peerLabel = oldLang('Stars.Transaction.GiftFrom'); peerLabel = oldLang('Stars.Transaction.GiftFrom');
} else if (isNegativeStarsAmount(stars) || transaction.isMyGift) { } else if (isNegativeStarsAmount(stars) || transaction.isMyGift) {
peerLabel = oldLang('Stars.Transaction.To'); peerLabel = oldLang('Stars.Transaction.To');
} else if (transaction.starRefCommision) { } else if (transaction.starRefCommision && !transaction.paidMessages) {
peerLabel = oldLang('StarsTransaction.StarRefReason.Miniapp'); peerLabel = oldLang('StarsTransaction.StarRefReason.Miniapp');
} else if (peerId) { } else if (peerId) {
peerLabel = oldLang('Star.Transaction.From'); peerLabel = oldLang('Star.Transaction.From');
@ -200,6 +219,15 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
peerId ? { chatId: peerId } : toName || '', peerId ? { chatId: peerId } : toName || '',
]); ]);
if (transaction.starRefCommision && transaction.paidMessages) {
tableData.push([
lang('PaidMessageTransactionTotal'),
formatStarsAsIcon(lang,
transaction.stars.amount / ((100 - transaction.starRefCommision) / 100),
{ asFont: false, className: styles.starIcon, containerClassName: styles.totalStars }),
]);
}
if (messageLink) { if (messageLink) {
tableData.push([oldLang('Stars.Transaction.Reaction.Post'), <SafeLink url={messageLink} text={messageLink} />]); tableData.push([oldLang('Stars.Transaction.Reaction.Post'), <SafeLink url={messageLink} text={messageLink} />]);
} }
@ -252,7 +280,7 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
tableData, tableData,
footer, footer,
}; };
}, [transaction, oldLang, lang, peer, canPlayAnimatedEmojis, topSticker]); }, [transaction, oldLang, lang, peer, canPlayAnimatedEmojis, topSticker, paidMessageCommission]);
const prevModalData = usePrevious(starModalData); const prevModalData = usePrevious(starModalData);
const renderingModalData = prevModalData || starModalData; const renderingModalData = prevModalData || starModalData;
@ -275,6 +303,7 @@ export default memo(withGlobal<OwnProps>(
(global, { modal }): StateProps => { (global, { modal }): StateProps => {
const peerId = modal?.transaction?.peer?.type === 'peer' && modal.transaction.peer.id; const peerId = modal?.transaction?.peer?.type === 'peer' && modal.transaction.peer.id;
const peer = peerId ? selectPeer(global, peerId) : undefined; const peer = peerId ? selectPeer(global, peerId) : undefined;
const paidMessageCommission = global.appConfig?.starsPaidMessageCommissionPermille;
const starCount = modal?.transaction.stars; const starCount = modal?.transaction.stars;
const starsGiftSticker = modal?.transaction.isGift && selectGiftStickerForStars(global, starCount?.amount); const starsGiftSticker = modal?.transaction.isGift && selectGiftStickerForStars(global, starCount?.amount);
@ -283,6 +312,7 @@ export default memo(withGlobal<OwnProps>(
peer, peer,
canPlayAnimatedEmojis: selectCanPlayAnimatedEmojis(global), canPlayAnimatedEmojis: selectCanPlayAnimatedEmojis(global),
topSticker: starsGiftSticker, topSticker: starsGiftSticker,
paidMessageCommission,
}; };
}, },
)(StarsTransactionModal)); )(StarsTransactionModal));

View File

@ -20,6 +20,7 @@ import {
selectChat, selectChat,
selectIsCurrentUserPremium, selectIsCurrentUserPremium,
selectPeer, selectPeer,
selectPeerPaidMessagesStars,
selectPeerStory, selectPeerStory,
selectPerformanceSettingsValue, selectPerformanceSettingsValue,
selectTabState, selectTabState,
@ -30,6 +31,7 @@ import buildClassName from '../../util/buildClassName';
import captureKeyboardListeners from '../../util/captureKeyboardListeners'; import captureKeyboardListeners from '../../util/captureKeyboardListeners';
import { formatMediaDuration, formatRelativePastTime } from '../../util/dates/dateFormat'; import { formatMediaDuration, formatRelativePastTime } from '../../util/dates/dateFormat';
import download from '../../util/download'; import download from '../../util/download';
import { formatStarsAsIcon } from '../../util/localization/format';
import { round } from '../../util/math'; import { round } from '../../util/math';
import { getServerTime } from '../../util/serverTime'; import { getServerTime } from '../../util/serverTime';
import { IS_SAFARI } from '../../util/windowEnvironment'; import { IS_SAFARI } from '../../util/windowEnvironment';
@ -43,6 +45,7 @@ import useCanvasBlur from '../../hooks/useCanvasBlur';
import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; import useCurrentOrPrev from '../../hooks/useCurrentOrPrev';
import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps'; import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps';
import useFlag from '../../hooks/useFlag'; import useFlag from '../../hooks/useFlag';
import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback'; import useLastCallback from '../../hooks/useLastCallback';
import useLongPress from '../../hooks/useLongPress'; import useLongPress from '../../hooks/useLongPress';
import useMediaTransitionDeprecated from '../../hooks/useMediaTransitionDeprecated'; import useMediaTransitionDeprecated from '../../hooks/useMediaTransitionDeprecated';
@ -99,6 +102,7 @@ interface StateProps {
isCurrentUserPremium?: boolean; isCurrentUserPremium?: boolean;
stealthMode: ApiStealthMode; stealthMode: ApiStealthMode;
withHeaderAnimation?: boolean; withHeaderAnimation?: boolean;
paidMessagesStars?: number;
} }
const VIDEO_MIN_READY_STATE = IS_SAFARI ? 4 : 3; const VIDEO_MIN_READY_STATE = IS_SAFARI ? 4 : 3;
@ -131,6 +135,7 @@ function Story({
onDelete, onDelete,
onClose, onClose,
onReport, onReport,
paidMessagesStars,
}: OwnProps & StateProps) { }: OwnProps & StateProps) {
const { const {
viewStory, viewStory,
@ -151,7 +156,8 @@ function Story({
} = getActions(); } = getActions();
const serverTime = getServerTime(); const serverTime = getServerTime();
const lang = useOldLang(); const oldLang = useOldLang();
const lang = useLang();
const { isMobile } = useAppLayout(); const { isMobile } = useAppLayout();
const [isComposerHasFocus, markComposerHasFocus, unmarkComposerHasFocus] = useFlag(false); const [isComposerHasFocus, markComposerHasFocus, unmarkComposerHasFocus] = useFlag(false);
const [isStoryPlaybackRequested, playStory, pauseStory] = useFlag(false); const [isStoryPlaybackRequested, playStory, pauseStory] = useFlag(false);
@ -199,7 +205,7 @@ function Story({
isOut && (story!.date + viewersExpirePeriod) < getServerTime(), isOut && (story!.date + viewersExpirePeriod) < getServerTime(),
); );
const forwardSenderTitle = forwardSender ? getPeerTitle(lang, forwardSender) const forwardSenderTitle = forwardSender ? getPeerTitle(oldLang, forwardSender)
: (isLoadedStory && story.forwardInfo?.fromName); : (isLoadedStory && story.forwardInfo?.fromName);
const canCopyLink = Boolean( const canCopyLink = Boolean(
@ -492,16 +498,16 @@ function Story({
: story.isForContacts ? 'contacts' : (story.isForCloseFriends ? 'closeFriends' : 'nobody'); : story.isForContacts ? 'contacts' : (story.isForCloseFriends ? 'closeFriends' : 'nobody');
let message; let message;
const myName = getPeerTitle(lang, peer); const myName = getPeerTitle(oldLang, peer);
switch (visibility) { switch (visibility) {
case 'nobody': case 'nobody':
message = lang('StorySelectedContactsHint', myName); message = oldLang('StorySelectedContactsHint', myName);
break; break;
case 'contacts': case 'contacts':
message = lang('StoryContactsHint', myName); message = oldLang('StoryContactsHint', myName);
break; break;
case 'closeFriends': case 'closeFriends':
message = lang('StoryCloseFriendsHint', myName); message = oldLang('StoryCloseFriendsHint', myName);
break; break;
default: default:
return; return;
@ -512,7 +518,7 @@ function Story({
const handleVolumeMuted = useLastCallback(() => { const handleVolumeMuted = useLastCallback(() => {
if (noSound) { if (noSound) {
showNotification({ showNotification({
message: lang('Story.TooltipVideoHasNoSound'), message: oldLang('Story.TooltipVideoHasNoSound'),
}); });
return; return;
} }
@ -525,8 +531,8 @@ function Story({
if (stealthMode.activeUntil && getServerTime() < stealthMode.activeUntil) { if (stealthMode.activeUntil && getServerTime() < stealthMode.activeUntil) {
const diff = stealthMode.activeUntil - getServerTime(); const diff = stealthMode.activeUntil - getServerTime();
showNotification({ showNotification({
title: lang('StealthModeOn'), title: oldLang('StealthModeOn'),
message: lang('Story.ToastStealthModeActiveText', formatMediaDuration(diff)), message: oldLang('Story.ToastStealthModeActiveText', formatMediaDuration(diff)),
duration: STEALTH_MODE_NOTIFICATION_DURATION, duration: STEALTH_MODE_NOTIFICATION_DURATION,
}); });
return; return;
@ -544,9 +550,9 @@ function Story({
if (!isDeletedStory) return; if (!isDeletedStory) return;
showNotification({ showNotification({
message: lang('StoryNotFound'), message: oldLang('StoryNotFound'),
}); });
}, [lang, isDeletedStory]); }, [oldLang, isDeletedStory]);
const MenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => { const MenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => {
return ({ onTrigger, isOpen }) => { return ({ onTrigger, isOpen }) => {
@ -558,13 +564,13 @@ function Story({
color="translucent-white" color="translucent-white"
onClick={onTrigger} onClick={onTrigger}
className={buildClassName(styles.button, isOpen && 'active')} className={buildClassName(styles.button, isOpen && 'active')}
ariaLabel={lang('AccDescrOpenMenu2')} ariaLabel={oldLang('AccDescrOpenMenu2')}
> >
<Icon name="more" /> <Icon name="more" />
</Button> </Button>
); );
}; };
}, [isMobile, lang]); }, [isMobile, oldLang]);
function renderStoriesTabs() { function renderStoriesTabs() {
return ( return (
@ -643,7 +649,7 @@ function Story({
/> />
<div className={styles.senderMeta}> <div className={styles.senderMeta}>
<span onClick={handleOpenChat} className={styles.senderName}> <span onClick={handleOpenChat} className={styles.senderName}>
{renderText(getPeerTitle(lang, peer) || '')} {renderText(getPeerTitle(oldLang, peer) || '')}
</span> </span>
<div className={styles.storyMetaRow}> <div className={styles.storyMetaRow}>
{forwardSenderTitle && ( {forwardSenderTitle && (
@ -668,15 +674,15 @@ function Story({
> >
<Avatar peer={fromPeer} size="micro" /> <Avatar peer={fromPeer} size="micro" />
<span className={styles.headerTitle}> <span className={styles.headerTitle}>
{renderText(getPeerTitle(lang, fromPeer) || '')} {renderText(getPeerTitle(oldLang, fromPeer) || '')}
</span> </span>
</span> </span>
)} )}
{story && 'date' in story && ( {story && 'date' in story && (
<span className={styles.storyMeta}>{formatRelativePastTime(lang, serverTime, story.date)}</span> <span className={styles.storyMeta}>{formatRelativePastTime(oldLang, serverTime, story.date)}</span>
)} )}
{isLoadedStory && story.isEdited && ( {isLoadedStory && story.isEdited && (
<span className={styles.storyMeta}>{lang('Story.HeaderEdited')}</span> <span className={styles.storyMeta}>{oldLang('Story.HeaderEdited')}</span>
)} )}
</div> </div>
</div> </div>
@ -702,7 +708,7 @@ function Story({
color="translucent-white" color="translucent-white"
disabled={!hasFullData} disabled={!hasFullData}
onClick={handleVolumeMuted} onClick={handleVolumeMuted}
ariaLabel={lang('Volume')} ariaLabel={oldLang('Volume')}
> >
<Icon name={(isMuted || noSound) ? 'speaker-muted-story' : 'speaker-story'} /> <Icon name={(isMuted || noSound) ? 'speaker-muted-story' : 'speaker-story'} />
</Button> </Button>
@ -714,36 +720,43 @@ function Story({
onOpen={handleDropdownMenuOpen} onOpen={handleDropdownMenuOpen}
onClose={handleDropdownMenuClose} onClose={handleDropdownMenuClose}
> >
{canCopyLink && <MenuItem icon="copy" onClick={handleCopyStoryLink}>{lang('CopyLink')}</MenuItem>} {canCopyLink && <MenuItem icon="copy" onClick={handleCopyStoryLink}>{oldLang('CopyLink')}</MenuItem>}
{canPinToProfile && ( {canPinToProfile && (
<MenuItem icon="save-story" onClick={handlePinClick}> <MenuItem icon="save-story" onClick={handlePinClick}>
{lang(isUserStory ? 'StorySave' : 'SaveToPosts')} {oldLang(isUserStory ? 'StorySave' : 'SaveToPosts')}
</MenuItem> </MenuItem>
)} )}
{canUnpinFromProfile && ( {canUnpinFromProfile && (
<MenuItem icon="delete" onClick={handleUnpinClick}> <MenuItem icon="delete" onClick={handleUnpinClick}>
{lang(isUserStory ? 'ArchiveStory' : 'RemoveFromPosts')} {oldLang(isUserStory ? 'ArchiveStory' : 'RemoveFromPosts')}
</MenuItem> </MenuItem>
)} )}
{canDownload && ( {canDownload && (
<MenuItem icon="download" disabled={!downloadMediaData} onClick={handleDownload}> <MenuItem icon="download" disabled={!downloadMediaData} onClick={handleDownload}>
{lang('lng_media_download')} {oldLang('lng_media_download')}
</MenuItem> </MenuItem>
)} )}
{!isOut && isUserStory && ( {!isOut && isUserStory && (
<MenuItem icon="eye-crossed-outline" onClick={handleOpenStealthModal}> <MenuItem icon="eye-crossed-outline" onClick={handleOpenStealthModal}>
{lang('StealthMode')} {oldLang('StealthMode')}
</MenuItem>
)}
{!isOut && <MenuItem icon="flag" onClick={handleReportStoryClick}>{oldLang('lng_report_story')}</MenuItem>}
{isOut && (
<MenuItem
icon="delete"
destructive
onClick={handleDeleteStoryClick}
>{oldLang('Delete')}
</MenuItem> </MenuItem>
)} )}
{!isOut && <MenuItem icon="flag" onClick={handleReportStoryClick}>{lang('lng_report_story')}</MenuItem>}
{isOut && <MenuItem icon="delete" destructive onClick={handleDeleteStoryClick}>{lang('Delete')}</MenuItem>}
</DropdownMenu> </DropdownMenu>
<Button <Button
className={buildClassName(styles.button, styles.closeButton)} className={buildClassName(styles.button, styles.closeButton)}
round round
size="tiny" size="tiny"
color="translucent-white" color="translucent-white"
ariaLabel={lang('Close')} ariaLabel={oldLang('Close')}
onClick={onClose} onClick={onClose}
> >
<Icon name="close" /> <Icon name="close" />
@ -753,6 +766,14 @@ function Story({
); );
} }
const inputPlaceholder = paidMessagesStars
? lang('ComposerPlaceholderPaidReply', {
amount: formatStarsAsIcon(lang, paidMessagesStars, { asFont: true, className: 'placeholder-star-icon' }),
}, {
withNodes: true,
})
: oldLang(isChatStory ? 'ReplyToGroupStory' : 'ReplyPrivately');
return ( return (
<div <div
className={buildClassName(styles.slideInner, 'component-theme-dark')} className={buildClassName(styles.slideInner, 'component-theme-dark')}
@ -821,13 +842,13 @@ function Story({
type="button" type="button"
className={buildClassName(styles.navigate, styles.prev)} className={buildClassName(styles.navigate, styles.prev)}
onClick={handleOpenPrevStory} onClick={handleOpenPrevStory}
aria-label={lang('Previous')} aria-label={oldLang('Previous')}
/> />
<button <button
type="button" type="button"
className={buildClassName(styles.navigate, styles.next)} className={buildClassName(styles.navigate, styles.next)}
onClick={handleOpenNextStory} onClick={handleOpenNextStory}
aria-label={lang('Next')} aria-label={oldLang('Next')}
/> />
</> </>
)} )}
@ -847,7 +868,7 @@ function Story({
withStory withStory
storyViewerMode="disabled" storyViewerMode="disabled"
/> />
<div className={styles.name}>{renderText(getPeerTitle(lang, peer) || '')}</div> <div className={styles.name}>{renderText(getPeerTitle(oldLang, peer) || '')}</div>
</div> </div>
</div> </div>
)} )}
@ -862,7 +883,7 @@ function Story({
role="button" role="button"
className={buildClassName(styles.captionBackdrop, captionBackdropTransitionClassNames)} className={buildClassName(styles.captionBackdrop, captionBackdropTransitionClassNames)}
onClick={() => foldCaption()} onClick={() => foldCaption()}
aria-label={lang('Close')} aria-label={oldLang('Close')}
/> />
)} )}
{hasText && <div className={buildClassName(styles.captionGradient, captionAppearanceAnimationClassNames)} />} {hasText && <div className={buildClassName(styles.captionGradient, captionAppearanceAnimationClassNames)} />}
@ -889,7 +910,7 @@ function Story({
editableInputId={EDITABLE_STORY_INPUT_ID} editableInputId={EDITABLE_STORY_INPUT_ID}
inputId="story-input-text" inputId="story-input-text"
className={buildClassName(styles.composer, composerAppearanceAnimationClassNames)} className={buildClassName(styles.composer, composerAppearanceAnimationClassNames)}
inputPlaceholder={lang(isChatStory ? 'ReplyToGroupStory' : 'ReplyPrivately')} inputPlaceholder={inputPlaceholder}
onForward={canShare ? handleForwardClick : undefined} onForward={canShare ? handleForwardClick : undefined}
onFocus={markComposerHasFocus} onFocus={markComposerHasFocus}
onBlur={unmarkComposerHasFocus} onBlur={unmarkComposerHasFocus}
@ -923,12 +944,14 @@ export default memo(withGlobal<OwnProps>((global, {
mapModal, mapModal,
reportModal, reportModal,
giftInfoModal, giftInfoModal,
isPaymentMessageConfirmDialogOpen,
} = tabState; } = tabState;
const { isOpen: isPremiumModalOpen } = premiumModal || {}; const { isOpen: isPremiumModalOpen } = premiumModal || {};
const story = selectPeerStory(global, peerId, storyId); const story = selectPeerStory(global, peerId, storyId);
const isLoadedStory = story && 'content' in story; const isLoadedStory = story && 'content' in story;
const shouldForcePause = Boolean( const shouldForcePause = Boolean(
viewModal || forwardedStoryId || tabState.reactionPicker?.storyId || reportModal || isPrivacyModalOpen isPaymentMessageConfirmDialogOpen
|| viewModal || forwardedStoryId || tabState.reactionPicker?.storyId || reportModal || isPrivacyModalOpen
|| isPremiumModalOpen || isDeleteModalOpen || safeLinkModalUrl || isStealthModalOpen || mapModal || giftInfoModal, || isPremiumModalOpen || isDeleteModalOpen || safeLinkModalUrl || isStealthModalOpen || mapModal || giftInfoModal,
); );
@ -940,6 +963,7 @@ export default memo(withGlobal<OwnProps>((global, {
const withHeaderAnimation = selectPerformanceSettingsValue(global, 'mediaViewerAnimations'); const withHeaderAnimation = selectPerformanceSettingsValue(global, 'mediaViewerAnimations');
const fromPeer = isLoadedStory && story.fromId ? selectPeer(global, story.fromId) : undefined; const fromPeer = isLoadedStory && story.fromId ? selectPeer(global, story.fromId) : undefined;
const paidMessagesStars = selectPeerPaidMessagesStars(global, peerId);
return { return {
peer: (user || chat)!, peer: (user || chat)!,
@ -956,5 +980,6 @@ export default memo(withGlobal<OwnProps>((global, {
arePeerSettingsLoaded: Boolean(userFullInfo?.settings), arePeerSettingsLoaded: Boolean(userFullInfo?.settings),
stealthMode: global.stories.stealthMode, stealthMode: global.stories.stealthMode,
withHeaderAnimation, withHeaderAnimation,
paidMessagesStars,
}; };
})(Story)); })(Story));

View File

@ -60,7 +60,7 @@
} }
.notification-button { .notification-button {
color: var(--color-primary); color: var(--color-toast-action);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
text-transform: none; text-transform: none;
margin-inline-start: 0.125rem; margin-inline-start: 0.125rem;

View File

@ -22,6 +22,7 @@ import useShowTransitionDeprecated from '../../hooks/useShowTransitionDeprecated
import CustomEmoji from '../common/CustomEmoji'; import CustomEmoji from '../common/CustomEmoji';
import Icon from '../common/icons/Icon'; import Icon from '../common/icons/Icon';
import StarIcon from '../common/icons/StarIcon';
import Button from './Button'; import Button from './Button';
import Portal from './Portal'; import Portal from './Portal';
import RoundTimer from './RoundTimer'; import RoundTimer from './RoundTimer';
@ -54,6 +55,7 @@ const Notification: FC<OwnProps> = ({
dismissAction, dismissAction,
duration = DEFAULT_DURATION, duration = DEFAULT_DURATION,
icon, icon,
shouldUseCustomIcon,
customEmojiIconId, customEmojiIconId,
shouldShowTimer, shouldShowTimer,
title, title,
@ -79,6 +81,23 @@ const Notification: FC<OwnProps> = ({
} }
}); });
const handleActionClick = useLastCallback(() => {
if (action) {
if (Array.isArray(action)) {
// @ts-ignore
action.forEach((cb) => actions[cb.action](cb.payload));
} else {
// @ts-ignore
actions[action.action](action.payload);
}
}
if (disableClickDismiss) {
setIsOpen(false);
setTimeout(handleDismiss, ANIMATION_DURATION + ANIMATION_END_DELAY);
}
closeAndDismiss();
});
const handleClick = useLastCallback(() => { const handleClick = useLastCallback(() => {
if (action) { if (action) {
if (Array.isArray(action)) { if (Array.isArray(action)) {
@ -151,6 +170,28 @@ const Notification: FC<OwnProps> = ({
return actionText; return actionText;
}, [lang, actionText]); }, [lang, actionText]);
const renderedIcon = useMemo(() => {
if (customEmojiIconId) {
return (
<CustomEmoji
className="notification-emoji-icon"
forceAlways
size={CUSTOM_EMOJI_SIZE}
documentId={customEmojiIconId}
/>
);
}
if (shouldUseCustomIcon) {
if (icon === 'star') {
return (
<StarIcon type="gold" className={buildClassName('notification-icon')} size="adaptive" />
);
}
}
return <Icon name={icon || 'info-filled'} className="notification-icon" />;
}, [customEmojiIconId, icon, shouldUseCustomIcon]);
return ( return (
<Portal className="Notification-container" containerSelector={containerSelector}> <Portal className="Notification-container" containerSelector={containerSelector}>
<div <div
@ -159,26 +200,17 @@ const Notification: FC<OwnProps> = ({
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
> >
{customEmojiIconId ? ( {renderedIcon}
<CustomEmoji
className="notification-emoji-icon"
forceAlways
size={CUSTOM_EMOJI_SIZE}
documentId={customEmojiIconId}
/>
) : (
<Icon name={icon || 'info-filled'} className="notification-icon" />
)}
<div className="content"> <div className="content">
{renderedTitle && ( {renderedTitle && (
<div className="notification-title">{renderedTitle}</div> <div className="notification-title">{renderedTitle}</div>
)} )}
{renderedMessage} {renderedMessage}
</div> </div>
{action && renderedActionText && ( {renderedActionText && (
<Button <Button
color="translucent-white" color="translucent-white"
onClick={handleClick} onClick={handleActionClick}
className="notification-button" className="notification-button"
> >
{renderedActionText} {renderedActionText}

View File

@ -1,6 +1,6 @@
.root { .root {
position: relative; position: relative;
color: var(--color-primary); color: var(--color-toast-action);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
} }
@ -12,7 +12,7 @@
} }
.circle { .circle {
stroke: var(--color-primary); stroke: var(--color-toast-action);
fill: transparent; fill: transparent;
stroke-width: 2; stroke-width: 2;
stroke-linecap: round; stroke-linecap: round;

View File

@ -19,6 +19,7 @@ export const IS_TEST = process.env.APP_ENV === 'test';
export const IS_PERF = process.env.APP_ENV === 'perf'; export const IS_PERF = process.env.APP_ENV === 'perf';
export const IS_BETA = process.env.APP_ENV === 'staging'; export const IS_BETA = process.env.APP_ENV === 'staging';
export const IS_PACKAGED_ELECTRON = process.env.IS_PACKAGED_ELECTRON; export const IS_PACKAGED_ELECTRON = process.env.IS_PACKAGED_ELECTRON;
export const PAID_MESSAGES_PURPOSE = 'paid_messages';
export const DEBUG = process.env.APP_ENV !== 'production'; export const DEBUG = process.env.APP_ENV !== 'production';
export const DEBUG_MORE = false; export const DEBUG_MORE = false;
@ -121,6 +122,10 @@ export const TOP_CHAT_MESSAGES_PRELOAD_LIMIT = 20;
export const SPONSORED_MESSAGE_CACHE_MS = 300000; // 5 min export const SPONSORED_MESSAGE_CACHE_MS = 300000; // 5 min
export const DEFAULT_CHARGE_FOR_MESSAGES = 250;
export const MINIMUM_CHARGE_FOR_MESSAGES = 1;
export const DEFAULT_MAXIMUM_CHARGE_FOR_MESSAGES = 10000;
export const DEFAULT_VOLUME = 1; export const DEFAULT_VOLUME = 1;
export const DEFAULT_PLAYBACK_RATE = 1; export const DEFAULT_PLAYBACK_RATE = 1;
export const PLAYBACK_RATE_FOR_AUDIO_MIN_DURATION = 20 * 60; // 20 min export const PLAYBACK_RATE_FOR_AUDIO_MIN_DURATION = 20 * 60; // 20 min
@ -177,6 +182,7 @@ export const TMP_CHAT_ID = '0';
export const ANIMATION_END_DELAY = 100; export const ANIMATION_END_DELAY = 100;
export const ANIMATION_WAVE_MIN_INTERVAL = 200; export const ANIMATION_WAVE_MIN_INTERVAL = 200;
export const MESSAGE_APPEARANCE_DELAY = 10; export const MESSAGE_APPEARANCE_DELAY = 10;
export const PAID_SEND_DELAY = 5000;
export const SCROLL_MIN_DURATION = 300; export const SCROLL_MIN_DURATION = 300;
export const SCROLL_MAX_DURATION = 600; export const SCROLL_MAX_DURATION = 600;

View File

@ -14,7 +14,7 @@ import {
} from '../../../api/types'; } from '../../../api/types';
import { ManagementProgress } from '../../../types'; import { ManagementProgress } from '../../../types';
import { BOT_FATHER_USERNAME, GENERAL_REFETCH_INTERVAL } from '../../../config'; import { BOT_FATHER_USERNAME, GENERAL_REFETCH_INTERVAL, PAID_SEND_DELAY } from '../../../config';
import { copyTextToClipboard } from '../../../util/clipboard'; import { copyTextToClipboard } from '../../../util/clipboard';
import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { oldTranslate } from '../../../util/oldLangProvider'; import { oldTranslate } from '../../../util/oldLangProvider';
@ -62,6 +62,7 @@ import {
selectUserFullInfo, selectUserFullInfo,
} from '../../selectors'; } from '../../selectors';
import { fetchChatByUsername } from './chats'; import { fetchChatByUsername } from './chats';
import { getPeerStarsForMessage } from './messages';
import { getIsWebAppsFullscreenSupported } from '../../../hooks/useAppLayout'; import { getIsWebAppsFullscreenSupported } from '../../../hooks/useAppLayout';
@ -379,7 +380,26 @@ addActionHandler('switchBotInline', (global, actions, payload): ActionReturnType
return undefined; return undefined;
}); });
addActionHandler('sendInlineBotResult', (global, actions, payload): ActionReturnType => { addActionHandler('sendInlineBotApiResult', async (global, actions, payload): Promise<void> => {
const {
chat, id, queryId, replyInfo, sendAs, isSilent, scheduledAt, allowPaidStars,
} = payload;
await callApi('sendInlineBotResult', {
chat,
resultId: id,
queryId,
replyInfo,
sendAs,
isSilent,
scheduleDate: scheduledAt,
allowPaidStars,
});
if (allowPaidStars) actions.loadStarStatus();
});
addActionHandler('sendInlineBotResult', async (global, actions, payload): Promise<void> => {
const { const {
id, queryId, isSilent, scheduledAt, threadId, chatId, id, queryId, isSilent, scheduledAt, threadId, chatId,
tabId = getCurrentTabId(), tabId = getCurrentTabId(),
@ -396,14 +416,39 @@ addActionHandler('sendInlineBotResult', (global, actions, payload): ActionReturn
actions.resetDraftReplyInfo({ tabId }); actions.resetDraftReplyInfo({ tabId });
actions.clearWebPagePreview({ tabId }); actions.clearWebPagePreview({ tabId });
void callApi('sendInlineBotResult', { const starsForOneMessage = await getPeerStarsForMessage(global, chat);
const params = {
chat, chat,
resultId: id, id,
queryId, queryId,
replyInfo, replyInfo,
sendAs: selectSendAs(global, chatId), sendAs: selectSendAs(global, chatId),
isSilent, isSilent,
scheduleDate: scheduledAt, scheduledAt,
allowPaidStars: starsForOneMessage,
};
if (!starsForOneMessage) {
actions.sendInlineBotApiResult(params);
return;
}
// eslint-disable-next-line eslint-multitab-tt/no-getactions-in-actions
actions.showNotification({
localId: queryId,
title: { key: 'ToastTitleMessageSent' },
message: { key: 'ToastMessageSent', variables: { amount: starsForOneMessage } },
actionText: { key: 'ButtonUndo' },
dismissAction: {
action: 'sendInlineBotApiResult',
payload: params,
},
duration: PAID_SEND_DELAY,
shouldShowTimer: true,
disableClickDismiss: true,
icon: 'star',
shouldUseCustomIcon: true,
type: 'paidMessage',
tabId,
}); });
}); });

View File

@ -5,33 +5,33 @@ import type {
ApiDraft, ApiDraft,
ApiError, ApiError,
ApiInputMessageReplyInfo, ApiInputMessageReplyInfo,
ApiInputReplyInfo,
ApiInputStoryReplyInfo, ApiInputStoryReplyInfo,
ApiMessage, ApiMessage,
ApiMessageEntity,
ApiNewPoll,
ApiOnProgress, ApiOnProgress,
ApiPeer, ApiPeer,
ApiSticker,
ApiStory, ApiStory,
ApiStorySkipped,
ApiUser, ApiUser,
ApiVideo,
} from '../../../api/types'; } from '../../../api/types';
import type {
ForwardMessagesParams,
SendMessageParams,
ThreadId,
} from '../../../types';
import type { MessageKey } from '../../../util/keys/messageKey'; import type { MessageKey } from '../../../util/keys/messageKey';
import type { RegularLangFnParameters } from '../../../util/localization';
import type { RequiredGlobalActions } from '../../index'; import type { RequiredGlobalActions } from '../../index';
import type { import type {
ActionReturnType, GlobalState, TabArgs, ActionReturnType, GlobalState, TabArgs,
} from '../../types'; } from '../../types';
import { MAIN_THREAD_ID, MESSAGE_DELETED } from '../../../api/types'; import { MAIN_THREAD_ID, MESSAGE_DELETED } from '../../../api/types';
import { LoadMoreDirection, type ThreadId, type WebPageMediaSize } from '../../../types'; import { LoadMoreDirection } from '../../../types';
import { import {
GIF_MIME_TYPE, GIF_MIME_TYPE,
MAX_MEDIA_FILES_FOR_ALBUM, MAX_MEDIA_FILES_FOR_ALBUM,
MESSAGE_ID_REQUIRED_ERROR, MESSAGE_ID_REQUIRED_ERROR,
MESSAGE_LIST_SLICE, MESSAGE_LIST_SLICE,
RE_TELEGRAM_LINK, PAID_SEND_DELAY, RE_TELEGRAM_LINK,
SERVICE_NOTIFICATIONS_USER_ID, SERVICE_NOTIFICATIONS_USER_ID,
SUPPORTED_AUDIO_CONTENT_TYPES, SUPPORTED_AUDIO_CONTENT_TYPES,
SUPPORTED_PHOTO_CONTENT_TYPES, SUPPORTED_PHOTO_CONTENT_TYPES,
@ -61,6 +61,7 @@ import {
isChatSuperGroup, isChatSuperGroup,
isDeletedUser, isDeletedUser,
isMessageLocal, isMessageLocal,
isPeerUser,
isServiceNotificationMessage, isServiceNotificationMessage,
isUserBot, isUserBot,
splitMessagesForForwarding, splitMessagesForForwarding,
@ -301,14 +302,14 @@ addActionHandler('loadMessage', async (global, actions, payload): Promise<void>
} }
}); });
addActionHandler('sendMessage', (global, actions, payload): ActionReturnType => { addActionHandler('sendMessage', async (global, actions, payload): Promise<void> => {
const { messageList, tabId = getCurrentTabId() } = payload; const { messageList, tabId = getCurrentTabId() } = payload;
const { storyId, peerId: storyPeerId } = selectCurrentViewedStory(global, tabId); const { storyId, peerId: storyPeerId } = selectCurrentViewedStory(global, tabId);
const isStoryReply = Boolean(storyId && storyPeerId); const isStoryReply = Boolean(storyId && storyPeerId);
if (!messageList && !isStoryReply) { if (!messageList && !isStoryReply) {
return undefined; return;
} }
let { chatId, threadId, type } = messageList || {}; let { chatId, threadId, type } = messageList || {};
@ -321,9 +322,11 @@ addActionHandler('sendMessage', (global, actions, payload): ActionReturnType =>
payload = omit(payload, ['tabId']); payload = omit(payload, ['tabId']);
if (type === 'scheduled' && !payload.scheduledAt) { if (type === 'scheduled' && !payload.scheduledAt) {
return updateTabState(global, { global = updateTabState(global, {
contentToBeScheduled: payload, contentToBeScheduled: payload,
}, tabId); }, tabId);
setGlobal(global);
return;
} }
const chat = selectChat(global, chatId!)!; const chat = selectChat(global, chatId!)!;
@ -342,30 +345,36 @@ addActionHandler('sendMessage', (global, actions, payload): ActionReturnType =>
const replyInfo = storyReplyInfo || messageReplyInfo; const replyInfo = storyReplyInfo || messageReplyInfo;
const lastMessageId = selectChatLastMessageId(global, chatId!); const lastMessageId = selectChatLastMessageId(global, chatId!);
const messagePriceInStars = await getPeerStarsForMessage(global, chat);
const params = { const params : SendMessageParams = {
...payload, ...payload,
chat, chat,
replyInfo, replyInfo,
noWebPage: selectNoWebPage(global, chatId!, threadId!), noWebPage: selectNoWebPage(global, chatId!, threadId!),
sendAs: selectSendAs(global, chatId!), sendAs: selectSendAs(global, chatId!),
lastMessageId, lastMessageId,
messagePriceInStars,
isStoryReply,
isPending: messagePriceInStars ? true : undefined,
}; };
if (!isStoryReply) { if (!isStoryReply) {
actions.clearWebPagePreview({ tabId }); actions.clearWebPagePreview({ tabId });
} }
const isSingle = !payload.attachments || payload.attachments.length <= 1; const isSingle = (!payload.attachments || payload.attachments.length <= 1) && !isForwarding;
const isGrouped = !isSingle && payload.shouldGroupMessages; const isGrouped = !isSingle && payload.shouldGroupMessages;
const localMessages: SendMessageParams[] = [];
if (isSingle) { if (isSingle) {
const { attachments, ...restParams } = params; const { attachments, ...restParams } = params;
sendMessage(global, { const sendParams: SendMessageParams = {
...restParams, ...restParams,
attachment: attachments ? attachments[0] : undefined, attachment: attachments ? attachments[0] : undefined,
wasDrafted: Boolean(draft), wasDrafted: Boolean(draft),
}); };
await sendMessageOrReduceLocal(global, sendParams, localMessages);
} else if (isGrouped) { } else if (isGrouped) {
const { const {
text, entities, attachments, ...commonParams text, entities, attachments, ...commonParams
@ -373,7 +382,8 @@ addActionHandler('sendMessage', (global, actions, payload): ActionReturnType =>
const byType = splitAttachmentsByType(attachments!); const byType = splitAttachmentsByType(attachments!);
let hasSentCaption = false; let hasSentCaption = false;
byType.forEach((group, groupIndex) => { for (let groupIndex = 0; groupIndex < byType.length; groupIndex++) {
const group = byType[groupIndex];
const groupedAttachments = split(group as ApiAttachment[], MAX_MEDIA_FILES_FOR_ALBUM); const groupedAttachments = split(group as ApiAttachment[], MAX_MEDIA_FILES_FOR_ALBUM);
for (let i = 0; i < groupedAttachments.length; i++) { for (let i = 0; i < groupedAttachments.length; i++) {
const groupedId = `${Date.now()}${groupIndex}${i}`; const groupedId = `${Date.now()}${groupIndex}${i}`;
@ -383,70 +393,86 @@ addActionHandler('sendMessage', (global, actions, payload): ActionReturnType =>
if (group[0].quick && !group[0].shouldSendAsFile) { if (group[0].quick && !group[0].shouldSendAsFile) {
const [firstAttachment, ...restAttachments] = groupedAttachments[i]; const [firstAttachment, ...restAttachments] = groupedAttachments[i];
sendMessage(global, {
let sendParams: SendMessageParams = {
...commonParams, ...commonParams,
text: isFirst && !hasSentCaption ? text : undefined, text: isFirst && !hasSentCaption ? text : undefined,
entities: isFirst && !hasSentCaption ? entities : undefined, entities: isFirst && !hasSentCaption ? entities : undefined,
attachment: firstAttachment, attachment: firstAttachment,
groupedId: restAttachments.length > 0 ? groupedId : undefined, groupedId: restAttachments.length > 0 ? groupedId : undefined,
wasDrafted: Boolean(draft), wasDrafted: Boolean(draft),
}); };
await sendMessageOrReduceLocal(global, sendParams, localMessages);
hasSentCaption = true; hasSentCaption = true;
restAttachments.forEach((attachment: ApiAttachment) => { for (const attachment of restAttachments) {
sendMessage(global, { sendParams = {
...commonParams, ...commonParams,
attachment, attachment,
groupedId, groupedId,
}); };
}); await sendMessageOrReduceLocal(global, sendParams, localMessages);
}
} else { } else {
const firstAttachments = groupedAttachments[i].slice(0, -1); const firstAttachments = groupedAttachments[i].slice(0, -1);
const lastAttachment = groupedAttachments[i][groupedAttachments[i].length - 1]; const lastAttachment = groupedAttachments[i][groupedAttachments[i].length - 1];
firstAttachments.forEach((attachment: ApiAttachment) => { for (const attachment of firstAttachments) {
sendMessage(global, { const sendParams = {
...commonParams, ...commonParams,
attachment, attachment,
groupedId, groupedId,
}); };
}); await sendMessageOrReduceLocal(global, sendParams, localMessages);
}
sendMessage(global, { const sendParams = {
...commonParams, ...commonParams,
text: isLast && !hasSentCaption ? text : undefined, text: isLast && !hasSentCaption ? text : undefined,
entities: isLast && !hasSentCaption ? entities : undefined, entities: isLast && !hasSentCaption ? entities : undefined,
attachment: lastAttachment, attachment: lastAttachment,
groupedId: firstAttachments.length > 0 ? groupedId : undefined, groupedId: firstAttachments.length > 0 ? groupedId : undefined,
wasDrafted: Boolean(draft), wasDrafted: Boolean(draft),
}); };
await sendMessageOrReduceLocal(global, sendParams, localMessages);
hasSentCaption = true; hasSentCaption = true;
} }
} }
}); }
} else { } else {
const { const {
text, entities, attachments, replyInfo: replyToForFirstMessage, ...commonParams text, entities, attachments, replyInfo: replyToForFirstMessage, ...commonParams
} = params; } = params;
if (text) { if (text) {
sendMessage(global, { const sendParams = {
...commonParams, ...commonParams,
text, text,
entities, entities,
replyInfo: replyToForFirstMessage, replyInfo: replyToForFirstMessage,
wasDrafted: Boolean(draft), wasDrafted: Boolean(draft),
}); };
await sendMessageOrReduceLocal(global, sendParams, localMessages);
} }
attachments?.forEach((attachment: ApiAttachment) => { if (attachments) {
sendMessage(global, { for (const attachment of attachments) {
...commonParams, const sendParams = {
attachment, ...commonParams,
}); attachment,
}); };
await sendMessageOrReduceLocal(global, sendParams, localMessages);
}
}
} }
if (isForwarding) {
return undefined; const localForwards = await executeForwardMessages(global, params, tabId);
if (localForwards) {
localMessages.push(...localForwards);
}
}
if (localMessages?.length) sendMessagesWithNotification(global, localMessages);
}); });
addActionHandler('sendInviteMessages', async (global, actions, payload): Promise<void> => { addActionHandler('sendInviteMessages', async (global, actions, payload): Promise<void> => {
@ -735,21 +761,37 @@ addActionHandler('unpinAllMessages', async (global, actions, payload): Promise<v
}); });
addActionHandler('deleteMessages', (global, actions, payload): ActionReturnType => { addActionHandler('deleteMessages', (global, actions, payload): ActionReturnType => {
const { messageIds, shouldDeleteForAll, tabId = getCurrentTabId() } = payload!; const {
messageIds, shouldDeleteForAll, messageList: payloadMessageList, tabId = getCurrentTabId(),
} = payload!;
const currentMessageList = selectCurrentMessageList(global, tabId); const currentMessageList = selectCurrentMessageList(global, tabId);
if (!currentMessageList) { const messageList = payloadMessageList || currentMessageList;
if (!messageList) {
return; return;
} }
const { chatId, threadId } = currentMessageList; const { chatId, threadId } = messageList;
const chat = selectChat(global, chatId)!; const chat = selectChat(global, chatId)!;
const messageIdsToDelete = messageIds.filter((id) => { const messageIdsToDelete = messageIds.filter((id) => {
const message = selectChatMessage(global, chatId, id); const message = selectChatMessage(global, chatId, id);
return message && !isMessageLocal(message); return message && !isMessageLocal(message);
}); });
Object.values(global.byTabId)
.forEach(({ id }) => {
messageIds.forEach((messageId) => {
const message = selectChatMessage(global, chatId, messageId);
if (message) {
actions.dismissNotification({
localId: getMessageKey(message),
tabId: id,
});
}
});
});
// Only local messages // Only local messages
if (!messageIdsToDelete.length && messageIds.length) { if (!messageIdsToDelete.length && messageIds.length) {
deleteMessages(global, isChatChannel(chat) ? chatId : undefined, messageIds, actions); deleteMessages(global, isChatChannel(chat) || isChatSuperGroup(chat) ? chatId : undefined, messageIds, actions);
return; return;
} }
@ -761,6 +803,25 @@ addActionHandler('deleteMessages', (global, actions, payload): ActionReturnType
} }
}); });
addActionHandler('resetLocalPaidMessages', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
const notifications = selectTabState(global, tabId).notifications;
if (!notifications || !notifications.length) return global;
notifications.forEach((notification) => {
if (notification.type === 'paidMessage') {
const action = notification.dismissAction;
if (action && !Array.isArray(action)) {
// @ts-ignore
actions[action.action](action.payload);
}
actions.dismissNotification({ localId: notification.localId, tabId });
}
});
return global;
});
addActionHandler('deleteParticipantHistory', (global, actions, payload): ActionReturnType => { addActionHandler('deleteParticipantHistory', (global, actions, payload): ActionReturnType => {
const { const {
chatId, peerId, chatId, peerId,
@ -1129,91 +1190,6 @@ addActionHandler('loadExtendedMedia', (global, actions, payload): ActionReturnTy
} }
}); });
addActionHandler('forwardMessages', (global, actions, payload): ActionReturnType => {
const {
isSilent, scheduledAt, tabId = getCurrentTabId(),
} = payload;
const {
fromChatId, messageIds, toChatId, withMyScore, noAuthors, noCaptions, toThreadId = MAIN_THREAD_ID,
} = selectTabState(global, tabId).forwardMessages;
const isCurrentUserPremium = selectIsCurrentUserPremium(global);
const isToMainThread = toThreadId === MAIN_THREAD_ID;
const fromChat = fromChatId ? selectChat(global, fromChatId) : undefined;
const toChat = toChatId ? selectChat(global, toChatId) : undefined;
const messages = fromChatId && messageIds
? messageIds
.sort((a, b) => a - b)
.map((id) => selectChatMessage(global, fromChatId, id)).filter(Boolean)
: undefined;
if (!fromChat || !toChat || !messages || (toThreadId && !isToMainThread && !toChat.isForum)) {
return;
}
const sendAs = selectSendAs(global, toChatId!);
const draft = selectDraft(global, toChatId!, toThreadId || MAIN_THREAD_ID);
const lastMessageId = selectChatLastMessageId(global, toChat.id);
const [realMessages, serviceMessages] = partition(messages, (m) => !isServiceNotificationMessage(m));
const forwardableRealMessages = realMessages.filter((message) => selectCanForwardMessage(global, message));
if (forwardableRealMessages.length) {
const messageBatches = global.config?.maxForwardedCount
? splitMessagesForForwarding(forwardableRealMessages, global.config.maxForwardedCount)
: [forwardableRealMessages];
(async () => {
await rafPromise(); // Wait one frame for any previous `sendMessage` to be processed
messageBatches.forEach((batch) => {
callApi('forwardMessages', {
fromChat,
toChat,
toThreadId,
messages: batch,
isSilent,
scheduledAt,
sendAs,
withMyScore,
noAuthors,
noCaptions,
isCurrentUserPremium,
wasDrafted: Boolean(draft),
lastMessageId,
});
});
})();
}
serviceMessages
.forEach((message) => {
const { text, entities } = message.content.text || {};
const { sticker } = message.content;
const replyInfo = selectMessageReplyInfo(global, toChat.id, toThreadId);
void sendMessage(global, {
chat: toChat,
replyInfo,
text,
entities,
sticker,
isSilent,
scheduledAt,
sendAs,
lastMessageId,
});
});
global = getGlobal();
global = updateTabState(global, {
forwardMessages: {},
isShareMessageModalShown: false,
}, tabId);
setGlobal(global);
});
addActionHandler('loadScheduledHistory', async (global, actions, payload): Promise<void> => { addActionHandler('loadScheduledHistory', async (global, actions, payload): Promise<void> => {
const { chatId } = payload; const { chatId } = payload;
const chat = selectChat(global, chatId); const chat = selectChat(global, chatId);
@ -1336,6 +1312,110 @@ addActionHandler('loadCustomEmojis', async (global, actions, payload): Promise<v
setGlobal(global); setGlobal(global);
}); });
addActionHandler('forwardMessages', (global, actions, payload): ActionReturnType => {
const {
isSilent, scheduledAt, tabId = getCurrentTabId(),
} = payload;
const { toChatId } = selectTabState(global, tabId).forwardMessages;
const toChat = toChatId ? selectChat(global, toChatId) : undefined;
if (!toChat) return;
executeForwardMessages(global, { chat: toChat, isSilent, scheduledAt }, tabId);
});
async function executeForwardMessages(global: GlobalState, sendParams: SendMessageParams, tabId: number) {
const {
fromChatId, messageIds, toChatId, withMyScore, noAuthors, noCaptions, toThreadId = MAIN_THREAD_ID,
} = selectTabState(global, tabId).forwardMessages;
const { messagePriceInStars, isSilent, scheduledAt } = sendParams;
const isCurrentUserPremium = selectIsCurrentUserPremium(global);
const isToMainThread = toThreadId === MAIN_THREAD_ID;
const fromChat = fromChatId ? selectChat(global, fromChatId) : undefined;
const toChat = toChatId ? selectChat(global, toChatId) : undefined;
const messages = fromChatId && messageIds
? messageIds
.sort((a, b) => a - b)
.map((id) => selectChatMessage(global, fromChatId, id)).filter(Boolean)
: undefined;
if (!fromChat || !toChat || !messages || (toThreadId && !isToMainThread && !toChat.isForum)) {
return undefined;
}
const sendAs = selectSendAs(global, toChatId!);
const draft = selectDraft(global, toChatId!, toThreadId || MAIN_THREAD_ID);
const lastMessageId = selectChatLastMessageId(global, toChat.id);
const localMessages: SendMessageParams[] = [];
const [realMessages, serviceMessages] = partition(messages, (m) => !isServiceNotificationMessage(m));
const forwardableRealMessages = realMessages.filter((message) => selectCanForwardMessage(global, message));
if (forwardableRealMessages.length) {
const messageSlices = global.config?.maxForwardedCount
? splitMessagesForForwarding(forwardableRealMessages, global.config.maxForwardedCount)
: [forwardableRealMessages];
for (const slice of messageSlices) {
const forwardParams: ForwardMessagesParams = {
fromChat,
toChat,
toThreadId,
messages: slice,
isSilent,
scheduledAt,
sendAs,
withMyScore,
noAuthors,
noCaptions,
isCurrentUserPremium,
wasDrafted: Boolean(draft),
lastMessageId,
messagePriceInStars,
};
if (!messagePriceInStars) {
callApi('forwardMessages', forwardParams);
} else {
const forwardedLocalMessagesSlice = await callApi('forwardMessagesLocal', forwardParams);
localMessages.push({
...sendParams,
forwardParams: { ...forwardParams, forwardedLocalMessagesSlice },
forwardedLocalMessagesSlice,
});
}
}
}
for (const message of serviceMessages) {
const { text, entities } = message.content.text || {};
const { sticker } = message.content;
const replyInfo = selectMessageReplyInfo(global, toChat.id, toThreadId);
const params: SendMessageParams = {
chat: toChat,
replyInfo,
text,
entities,
sticker,
isSilent,
scheduledAt,
sendAs,
lastMessageId,
};
await sendMessageOrReduceLocal(global, params, localMessages);
}
global = getGlobal();
global = updateTabState(global, {
forwardMessages: {},
isShareMessageModalShown: false,
}, tabId);
setGlobal(global);
return localMessages;
}
async function loadViewportMessages<T extends GlobalState>( async function loadViewportMessages<T extends GlobalState>(
global: T, global: T,
chat: ApiChat, chat: ApiChat,
@ -1525,26 +1605,49 @@ function getViewportSlice(
return { newViewportIds, areSomeLocal, areAllLocal }; return { newViewportIds, areSomeLocal, areAllLocal };
} }
async function sendMessage<T extends GlobalState>(global: T, params: { export async function getPeerStarsForMessage<T extends GlobalState>(
chat: ApiChat; global: T,
text?: string; peer: ApiPeer,
entities?: ApiMessageEntity[]; ): Promise<number | undefined> {
replyInfo?: ApiInputReplyInfo; if (!isPeerUser(peer)) {
attachment?: ApiAttachment; return peer.paidMessagesStars;
sticker?: ApiSticker; }
story?: ApiStory | ApiStorySkipped;
gif?: ApiVideo; if (!peer?.paidMessagesStars) return undefined;
poll?: ApiNewPoll;
isSilent?: boolean; const fullInfo = selectUserFullInfo(global, peer.id);
scheduledAt?: number; if (fullInfo) {
sendAs?: ApiPeer; return fullInfo?.paidMessagesStars;
groupedId?: string; }
wasDrafted?: boolean;
lastMessageId?: number; const result = await callApi('fetchPaidMessagesStarsAmount', peer);
isInvertedMedia?: true; return result;
effectId?: string; }
webPageMediaSize?: WebPageMediaSize;
}) { async function sendMessageOrReduceLocal<T extends GlobalState>(
global: T,
sendParams: SendMessageParams,
localMessages: SendMessageParams[],
) {
if (!sendParams.messagePriceInStars) {
sendMessage(global, sendParams);
} else {
const message = await callApi('sendMessageLocal', sendParams);
if (message) {
localMessages.push({
...sendParams,
localMessage: message,
});
}
}
}
async function sendMessage<T extends GlobalState>(global: T, params: SendMessageParams) {
// @optimization
if (params.replyInfo || IS_IOS) {
await rafPromise();
}
let currentMessageKey: MessageKey | undefined; let currentMessageKey: MessageKey | undefined;
const progressCallback = params.attachment ? (progress: number, messageKey: MessageKey) => { const progressCallback = params.attachment ? (progress: number, messageKey: MessageKey) => {
if (!uploadProgressCallbacks.has(messageKey)) { if (!uploadProgressCallbacks.has(messageKey)) {
@ -1556,14 +1659,7 @@ async function sendMessage<T extends GlobalState>(global: T, params: {
global = updateUploadByMessageKey(global, messageKey, progress); global = updateUploadByMessageKey(global, messageKey, progress);
setGlobal(global); setGlobal(global);
} : undefined; } : undefined;
// @optimization
if (params.replyInfo || IS_IOS) {
await rafPromise();
}
await callApi('sendMessage', params, progressCallback); await callApi('sendMessage', params, progressCallback);
if (progressCallback && currentMessageKey) { if (progressCallback && currentMessageKey) {
global = getGlobal(); global = getGlobal();
global = updateUploadByMessageKey(global, currentMessageKey, undefined); global = updateUploadByMessageKey(global, currentMessageKey, undefined);
@ -1573,6 +1669,92 @@ async function sendMessage<T extends GlobalState>(global: T, params: {
} }
} }
async function sendMessagesWithNotification<T extends GlobalState>(
global: T,
sendParams: SendMessageParams[],
) {
const chat = sendParams[0]?.chat;
if (!chat || !sendParams.length) return;
const starsForOneMessage = await getPeerStarsForMessage(global, chat);
if (!starsForOneMessage) {
// eslint-disable-next-line eslint-multitab-tt/no-getactions-in-actions
getActions().sendMessages({ sendParams });
return;
}
const messageIdsForUndo = sendParams.reduce((ids, params) => {
if (params.localMessage?.id) {
ids.push(params.localMessage.id);
} else if (params.forwardedLocalMessagesSlice?.localMessages) {
const forwardedIds = Object.values(params.forwardedLocalMessagesSlice.localMessages)
.map((forwardedMessage) => forwardedMessage.id)
.filter(Boolean);
ids.push(...forwardedIds);
}
return ids;
}, [] as number[]);
const localForwards = sendParams[0]?.forwardedLocalMessagesSlice?.localMessages;
const firstMessage = sendParams[0]?.localMessage
|| (localForwards && Object.values(localForwards)[0]);
if (!firstMessage) return;
const messagesCount = messageIdsForUndo.length;
const firstSendParam = sendParams[0];
let storySendMessage: RegularLangFnParameters | undefined;
if (sendParams.length === 1 && firstSendParam.isStoryReply) {
const { gif, sticker, isReaction } = firstSendParam;
if (gif) {
storySendMessage = { key: 'ToastTitleMessageSent' };
} else if (sticker) {
storySendMessage = { key: 'StoryTooltipStickerSent' };
} else if (isReaction) {
storySendMessage = { key: 'StoryTooltipReactionSent' };
}
}
const titleKey: RegularLangFnParameters = storySendMessage || (messagesCount === 1 ? { key: 'ToastTitleMessageSent' }
: { key: 'ToastTitleMessagesSent', variables: { count: messagesCount } });
// eslint-disable-next-line eslint-multitab-tt/no-getactions-in-actions
getActions().showNotification({
localId: getMessageKey(firstMessage),
title: titleKey,
message: { key: 'ToastMessageSent', variables: { amount: starsForOneMessage * messagesCount } },
actionText: { key: 'ButtonUndo' },
action: {
action: 'deleteMessages',
payload: { messageList: firstSendParam.messageList, messageIds: messageIdsForUndo, shouldDeleteForAll: true },
},
dismissAction: {
action: 'sendMessages',
payload: {
sendParams,
},
},
duration: PAID_SEND_DELAY,
shouldShowTimer: true,
disableClickDismiss: true,
icon: 'star',
shouldUseCustomIcon: true,
type: 'paidMessage',
});
}
addActionHandler('sendMessages', async (global, actions, payload): Promise<void> => {
const { sendParams } = payload;
await Promise.all(sendParams.map(async (params) => {
if (params.forwardedLocalMessagesSlice && params.forwardParams) {
await rafPromise();
await callApi('forwardApiMessages', params.forwardParams);
} else {
await sendMessage(global, params);
}
}));
if (sendParams.length > 0 && sendParams[0].messagePriceInStars) actions.loadStarStatus();
});
addActionHandler('loadPinnedMessages', async (global, actions, payload): Promise<void> => { addActionHandler('loadPinnedMessages', async (global, actions, payload): Promise<void> => {
const { chatId, threadId } = payload; const { chatId, threadId } = payload;
const chat = selectChat(global, chatId); const chat = selectChat(global, chatId);

View File

@ -408,6 +408,7 @@ addActionHandler('loadPrivacySettings', async (global): Promise<void> => {
callApi('fetchPrivacySettings', 'bio'), callApi('fetchPrivacySettings', 'bio'),
callApi('fetchPrivacySettings', 'birthday'), callApi('fetchPrivacySettings', 'birthday'),
callApi('fetchPrivacySettings', 'gifts'), callApi('fetchPrivacySettings', 'gifts'),
callApi('fetchPrivacySettings', 'noPaidMessages'),
]); ]);
if (result.some((e) => e === undefined)) { if (result.some((e) => e === undefined)) {
@ -427,6 +428,7 @@ addActionHandler('loadPrivacySettings', async (global): Promise<void> => {
bioSettings, bioSettings,
birthdaySettings, birthdaySettings,
giftsSettings, giftsSettings,
noPaidMessagesSettings,
] = result as { ] = result as {
rules: ApiPrivacySettings; rules: ApiPrivacySettings;
}[]; }[];
@ -450,6 +452,7 @@ addActionHandler('loadPrivacySettings', async (global): Promise<void> => {
bio: bioSettings.rules, bio: bioSettings.rules,
birthday: birthdaySettings.rules, birthday: birthdaySettings.rules,
gifts: giftsSettings.rules, gifts: giftsSettings.rules,
noPaidMessages: noPaidMessagesSettings.rules,
}, },
}, },
}; };
@ -703,14 +706,24 @@ addActionHandler('updateGlobalPrivacySettings', async (global, actions, payload)
const shouldHideReadMarks = payload.shouldHideReadMarks ?? Boolean(global.settings.byKey.shouldHideReadMarks); const shouldHideReadMarks = payload.shouldHideReadMarks ?? Boolean(global.settings.byKey.shouldHideReadMarks);
const shouldNewNonContactPeersRequirePremium = payload.shouldNewNonContactPeersRequirePremium const shouldNewNonContactPeersRequirePremium = payload.shouldNewNonContactPeersRequirePremium
?? Boolean(global.settings.byKey.shouldNewNonContactPeersRequirePremium); ?? Boolean(global.settings.byKey.shouldNewNonContactPeersRequirePremium);
// eslint-disable-next-line no-null/no-null
const nonContactPeersPaidStars = payload.nonContactPeersPaidStars === null ? undefined
: payload.nonContactPeersPaidStars || global.settings.byKey.nonContactPeersPaidStars;
global = replaceSettings(global, { shouldArchiveAndMuteNewNonContact, shouldHideReadMarks }); global = getGlobal();
global = replaceSettings(global, {
shouldArchiveAndMuteNewNonContact,
shouldHideReadMarks,
shouldNewNonContactPeersRequirePremium,
nonContactPeersPaidStars,
});
setGlobal(global); setGlobal(global);
const result = await callApi('updateGlobalPrivacySettings', { const result = await callApi('updateGlobalPrivacySettings', {
shouldArchiveAndMuteNewNonContact, shouldArchiveAndMuteNewNonContact,
shouldHideReadMarks, shouldHideReadMarks,
shouldNewNonContactPeersRequirePremium, shouldNewNonContactPeersRequirePremium,
nonContactPeersPaidStars,
}); });
global = getGlobal(); global = getGlobal();
@ -722,6 +735,9 @@ addActionHandler('updateGlobalPrivacySettings', async (global, actions, payload)
shouldNewNonContactPeersRequirePremium: !result shouldNewNonContactPeersRequirePremium: !result
? !shouldNewNonContactPeersRequirePremium ? !shouldNewNonContactPeersRequirePremium
: result.shouldNewNonContactPeersRequirePremium, : result.shouldNewNonContactPeersRequirePremium,
nonContactPeersPaidStars: !result
? undefined
: result.nonContactPeersPaidStars,
}); });
setGlobal(global); setGlobal(global);
}); });

View File

@ -184,6 +184,26 @@ addActionHandler('loadCommonChats', async (global, actions, payload): Promise<vo
setGlobal(global); setGlobal(global);
}); });
addActionHandler('addNoPaidMessagesException', async (global, actions, payload): Promise<void> => {
const { userId, shouldRefundCharged } = payload;
const user = selectUser(global, userId);
if (!user) {
return;
}
const result = await callApi('addNoPaidMessagesException',
{ user, shouldRefundCharged });
if (!result) {
return;
}
global = getGlobal();
global = updateUserFullInfo(global, userId, {
settings: undefined,
});
setGlobal(global);
});
addActionHandler('updateContact', async (global, actions, payload): Promise<void> => { addActionHandler('updateContact', async (global, actions, payload): Promise<void> => {
const { const {
userId, isMuted = false, firstName, lastName, shouldSharePhoneNumber, userId, isMuted = false, firstName, lastName, shouldSharePhoneNumber,

View File

@ -62,6 +62,7 @@ import {
selectIsRightColumnShown, selectIsRightColumnShown,
selectIsViewportNewest, selectIsViewportNewest,
selectMessageIdsByGroupId, selectMessageIdsByGroupId,
selectPeer,
selectPinnedIds, selectPinnedIds,
selectReplyStack, selectReplyStack,
selectRequestedChatTranslationLanguage, selectRequestedChatTranslationLanguage,
@ -71,6 +72,7 @@ import {
selectThreadInfo, selectThreadInfo,
selectViewportIds, selectViewportIds,
} from '../../selectors'; } from '../../selectors';
import { getPeerStarsForMessage } from '../api/messages';
import { getIsMobile } from '../../../hooks/useAppLayout'; import { getIsMobile } from '../../../hooks/useAppLayout';
@ -1093,3 +1095,39 @@ addActionHandler('closeSharePreparedMessageModal', (global, actions, payload): A
sharePreparedMessageModal: undefined, sharePreparedMessageModal: undefined,
}, tabId); }, tabId);
}); });
addActionHandler('updateSharePreparedMessageModalSendArgs', async (global, actions, payload): Promise<void> => {
const { args, tabId = getCurrentTabId() } = payload || {};
const tabState = selectTabState(global, tabId);
if (!tabState.sharePreparedMessageModal) {
return;
}
if (!args) {
global = updateTabState(global, {
sharePreparedMessageModal: {
...tabState.sharePreparedMessageModal,
pendingSendArgs: undefined,
},
}, tabId);
setGlobal(global);
return;
}
const peer = selectPeer(global, args.peerId);
const starsForSendMessage = peer ? await getPeerStarsForMessage(global, peer) : undefined;
global = getGlobal();
global = updateTabState(global, {
sharePreparedMessageModal: {
...tabState.sharePreparedMessageModal,
pendingSendArgs: {
peerId: args.peerId,
threadId: args.threadId,
starsForSendMessage,
},
},
}, tabId);
setGlobal(global);
});

View File

@ -542,6 +542,21 @@ addActionHandler('hideEffectInComposer', (global, actions, payload): ActionRetur
}, tabId); }, tabId);
}); });
addActionHandler('setPaidMessageAutoApprove', (global): ActionReturnType => {
global = {
...global,
settings: {
...global.settings,
byKey: {
...global.settings.byKey,
shouldPaidMessageAutoApprove: true,
},
},
};
return global;
});
addActionHandler('setReactionEffect', (global, actions, payload): ActionReturnType => { addActionHandler('setReactionEffect', (global, actions, payload): ActionReturnType => {
const { const {
chatId, threadId, reaction, tabId = getCurrentTabId(), chatId, threadId, reaction, tabId = getCurrentTabId(),

View File

@ -135,3 +135,19 @@ addActionHandler('resetGiftProfileFilter', (global, actions, payload): ActionRet
peerId, shouldRefresh: true, tabId: tabState.id, peerId, shouldRefresh: true, tabId: tabState.id,
}); });
}); });
addActionHandler('openPaymentMessageConfirmDialogOpen', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
isPaymentMessageConfirmDialogOpen: true,
}, tabId);
});
addActionHandler('closePaymentMessageConfirmDialogOpen', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
isPaymentMessageConfirmDialogOpen: false,
}, tabId);
});

View File

@ -9,6 +9,7 @@ import { addActionHandler, getGlobal, setGlobal } from '../../index';
import { addStoriesForPeer } from '../../reducers'; import { addStoriesForPeer } from '../../reducers';
import { updateTabState } from '../../reducers/tabs'; import { updateTabState } from '../../reducers/tabs';
import { import {
selectChat,
selectCurrentViewedStory, selectCurrentViewedStory,
selectPeer, selectPeer,
selectPeerFirstStoryId, selectPeerFirstStoryId,
@ -18,6 +19,7 @@ import {
selectTabState, selectTabState,
} from '../../selectors'; } from '../../selectors';
import { fetchChatByUsername } from '../api/chats'; import { fetchChatByUsername } from '../api/chats';
import { getPeerStarsForMessage } from '../api/messages';
addActionHandler('openStoryViewer', async (global, actions, payload): Promise<void> => { addActionHandler('openStoryViewer', async (global, actions, payload): Promise<void> => {
const { const {
@ -289,12 +291,16 @@ addActionHandler('copyStoryLink', async (global, actions, payload): Promise<void
}); });
}); });
addActionHandler('sendMessage', (global, actions, payload): ActionReturnType => { addActionHandler('sendMessage', async (global, actions, payload): Promise<void> => {
const { tabId = getCurrentTabId() } = payload; const { tabId = getCurrentTabId() } = payload;
const { storyId, peerId: storyPeerId } = selectCurrentViewedStory(global, tabId); const { storyId, peerId: storyPeerId } = selectCurrentViewedStory(global, tabId);
const isStoryReply = Boolean(storyId && storyPeerId); const isStoryReply = Boolean(storyId && storyPeerId);
if (!isStoryReply) { const chat = storyPeerId ? selectChat(global, storyPeerId) : undefined;
if (!chat) return;
const messagePriceInStars = await getPeerStarsForMessage(global, chat);
if (!isStoryReply || messagePriceInStars) {
return; return;
} }

View File

@ -271,6 +271,7 @@ export const INITIAL_GLOBAL_STATE: GlobalState = {
shouldSuggestStickers: true, shouldSuggestStickers: true,
shouldSuggestCustomEmoji: true, shouldSuggestCustomEmoji: true,
shouldSkipWebAppCloseConfirmation: false, shouldSkipWebAppCloseConfirmation: false,
shouldPaidMessageAutoApprove: false,
shouldUpdateStickerSetOrder: true, shouldUpdateStickerSetOrder: true,
language: 'en', language: 'en',
timeFormat: '24h', timeFormat: '24h',
@ -278,6 +279,7 @@ export const INITIAL_GLOBAL_STATE: GlobalState = {
isConnectionStatusMinimized: true, isConnectionStatusMinimized: true,
shouldArchiveAndMuteNewNonContact: false, shouldArchiveAndMuteNewNonContact: false,
shouldNewNonContactPeersRequirePremium: false, shouldNewNonContactPeersRequirePremium: false,
nonContactPeersPaidStars: 0,
shouldHideReadMarks: false, shouldHideReadMarks: false,
canTranslate: false, canTranslate: false,
canTranslateChats: true, canTranslateChats: true,
@ -423,4 +425,6 @@ export const INITIAL_TAB_STATE: TabState = {
requestedTranslations: { requestedTranslations: {
byChatId: {}, byChatId: {},
}, },
isPaymentMessageConfirmDialogOpen: false,
}; };

View File

@ -73,7 +73,7 @@ import {
selectIsChatWithSelf, selectIsChatWithSelf,
selectRequestedChatTranslationLanguage, selectRequestedChatTranslationLanguage,
} from './chats'; } from './chats';
import { selectPeer } from './peers'; import { selectPeer, selectPeerPaidMessagesStars } from './peers';
import { selectPeerStory } from './stories'; import { selectPeerStory } from './stories';
import { selectIsStickerFavorite } from './symbols'; import { selectIsStickerFavorite } from './symbols';
import { selectTabState } from './tabs'; import { selectTabState } from './tabs';
@ -1331,12 +1331,21 @@ export function selectShouldSchedule<T extends GlobalState>(
return selectCurrentMessageList(global, tabId)?.type === 'scheduled'; return selectCurrentMessageList(global, tabId)?.type === 'scheduled';
} }
export function selectCanSchedule<T extends GlobalState>(
global: T,
...[tabId = getCurrentTabId()]: TabArgs<T>
) {
const chatId = selectCurrentMessageList(global, tabId)?.chatId;
const paidMessagesStars = chatId ? selectPeerPaidMessagesStars(global, chatId) : undefined;
return !paidMessagesStars;
}
export function selectCanScheduleUntilOnline<T extends GlobalState>(global: T, id: string) { export function selectCanScheduleUntilOnline<T extends GlobalState>(global: T, id: string) {
const isChatWithSelf = selectIsChatWithSelf(global, id); const isChatWithSelf = selectIsChatWithSelf(global, id);
const chatBot = selectBot(global, id); const chatBot = selectBot(global, id);
return Boolean( const paidMessagesStars = selectPeerPaidMessagesStars(global, id);
!isChatWithSelf && !chatBot && isUserId(id) && selectUserStatus(global, id)?.wasOnline, return Boolean(!paidMessagesStars
); && !isChatWithSelf && !chatBot && isUserId(id) && selectUserStatus(global, id)?.wasOnline);
} }
export function selectCustomEmojis(message: ApiMessage) { export function selectCustomEmojis(message: ApiMessage) {

View File

@ -3,10 +3,10 @@ import type { GlobalState, TabArgs } from '../types';
import { SERVICE_NOTIFICATIONS_USER_ID } from '../../config'; import { SERVICE_NOTIFICATIONS_USER_ID } from '../../config';
import { getCurrentTabId } from '../../util/establishMultitabRole'; import { getCurrentTabId } from '../../util/establishMultitabRole';
import { isDeletedUser } from '../helpers'; import { isChatAdmin, isDeletedUser, isUserId } from '../helpers';
import { selectChat, selectChatFullInfo } from './chats'; import { selectChat, selectChatFullInfo } from './chats';
import { selectTabState } from './tabs'; import { selectTabState } from './tabs';
import { selectBot, selectUser } from './users'; import { selectBot, selectUser, selectUserFullInfo } from './users';
export function selectPeer<T extends GlobalState>(global: T, peerId: string): ApiPeer | undefined { export function selectPeer<T extends GlobalState>(global: T, peerId: string): ApiPeer | undefined {
return selectUser(global, peerId) || selectChat(global, peerId); return selectUser(global, peerId) || selectChat(global, peerId);
@ -34,3 +34,19 @@ export function selectPeerSavedGifts<T extends GlobalState>(
) : ApiSavedGifts { ) : ApiSavedGifts {
return selectTabState(global, tabId).savedGifts.giftsByPeerId[peerId]; return selectTabState(global, tabId).savedGifts.giftsByPeerId[peerId];
} }
export function selectPeerPaidMessagesStars<T extends GlobalState>(
global: T,
peerId: string,
) {
const isChatWithUser = isUserId(peerId);
if (isChatWithUser) {
const userFullInfo = isChatWithUser ? selectUserFullInfo(global, peerId) : undefined;
return userFullInfo?.paidMessagesStars;
}
const chat = selectChat(global, peerId);
if (!chat) return undefined;
if (isChatAdmin(chat)) return undefined;
return chat.paidMessagesStars;
}

View File

@ -24,6 +24,10 @@ export function selectNewNoncontactPeersRequirePremium<T extends GlobalState>(gl
return global.settings.byKey.shouldNewNonContactPeersRequirePremium; return global.settings.byKey.shouldNewNonContactPeersRequirePremium;
} }
export function selectNonContactPeersPaidStars<T extends GlobalState>(global: T) {
return global.settings.byKey.nonContactPeersPaidStars;
}
export function selectShouldHideReadMarks<T extends GlobalState>(global: T) { export function selectShouldHideReadMarks<T extends GlobalState>(global: T) {
return global.settings.byKey.shouldHideReadMarks; return global.settings.byKey.shouldHideReadMarks;
} }

View File

@ -8,7 +8,6 @@ import type {
ApiChatlistInvite, ApiChatlistInvite,
ApiChatReactions, ApiChatReactions,
ApiChatType, ApiChatType,
ApiContact,
ApiDraft, ApiDraft,
ApiExportedInvite, ApiExportedInvite,
ApiFormattedText, ApiFormattedText,
@ -23,10 +22,10 @@ import type {
ApiMessage, ApiMessage,
ApiMessageEntity, ApiMessageEntity,
ApiMessageSearchContext, ApiMessageSearchContext,
ApiNewPoll,
ApiNotification, ApiNotification,
ApiNotifyPeerType, ApiNotifyPeerType,
ApiPaymentStatus, ApiPaymentStatus,
ApiPeer,
ApiPhoto, ApiPhoto,
ApiPremiumSection, ApiPremiumSection,
ApiPreparedInlineMessage, ApiPreparedInlineMessage,
@ -80,6 +79,7 @@ import type {
Point, Point,
ProfileTabType, ProfileTabType,
ScrollTargetPosition, ScrollTargetPosition,
SendMessageParams,
SettingsScreens, SettingsScreens,
SharedMediaType, SharedMediaType,
Size, Size,
@ -441,25 +441,10 @@ export interface ActionPayloads {
onLoaded?: NoneToVoidFunction; onLoaded?: NoneToVoidFunction;
onError?: NoneToVoidFunction; onError?: NoneToVoidFunction;
} & WithTabId; } & WithTabId;
sendMessage: { sendMessage: Partial<SendMessageParams> & WithTabId;
text?: string; sendMessages: {
entities?: ApiMessageEntity[]; sendParams: SendMessageParams[];
attachments?: ApiAttachment[]; };
sticker?: ApiSticker;
isSilent?: boolean;
scheduledAt?: number;
gif?: ApiVideo;
poll?: ApiNewPoll;
contact?: Partial<ApiContact>;
shouldUpdateStickerSetOrder?: boolean;
shouldGroupMessages?: boolean;
messageList?: MessageList;
isReaction?: true; // Reaction to the story are sent in the form of a message
isInvertedMedia?: true;
effectId?: string;
webPageMediaSize?: WebPageMediaSize;
webPageUrl?: string;
} & WithTabId;
sendInviteMessages: { sendInviteMessages: {
chatId: string; chatId: string;
userIds: string[]; userIds: string[];
@ -478,7 +463,9 @@ export interface ActionPayloads {
deleteMessages: { deleteMessages: {
messageIds: number[]; messageIds: number[];
shouldDeleteForAll?: boolean; shouldDeleteForAll?: boolean;
messageList?: MessageList;
} & WithTabId; } & WithTabId;
resetLocalPaidMessages: WithTabId | undefined;
deleteParticipantHistory: { deleteParticipantHistory: {
peerId: string; peerId: string;
chatId: string; chatId: string;
@ -543,6 +530,12 @@ export interface ActionPayloads {
message: ApiPreparedInlineMessage; message: ApiPreparedInlineMessage;
} & WithTabId; } & WithTabId;
closeSharePreparedMessageModal: WithTabId | undefined; closeSharePreparedMessageModal: WithTabId | undefined;
updateSharePreparedMessageModalSendArgs: {
args?: {
peerId: string;
threadId?: ThreadId;
};
} & WithTabId;
openPreviousReportAdModal: WithTabId | undefined; openPreviousReportAdModal: WithTabId | undefined;
openPreviousReportModal: WithTabId | undefined; openPreviousReportModal: WithTabId | undefined;
closeReportAdModal: WithTabId | undefined; closeReportAdModal: WithTabId | undefined;
@ -1744,6 +1737,10 @@ export interface ActionPayloads {
isMuted?: boolean; isMuted?: boolean;
shouldSharePhoneNumber?: boolean; shouldSharePhoneNumber?: boolean;
} & WithTabId; } & WithTabId;
addNoPaidMessagesException: {
userId: string;
shouldRefundCharged: boolean;
};
loadMoreProfilePhotos: { loadMoreProfilePhotos: {
peerId: string; peerId: string;
isPreload?: boolean; isPreload?: boolean;
@ -1902,7 +1899,18 @@ export interface ActionPayloads {
threadId: ThreadId; threadId: ThreadId;
isSilent?: boolean; isSilent?: boolean;
scheduledAt?: number; scheduledAt?: number;
paidMessagesStars?: number;
} & WithTabId; } & WithTabId;
sendInlineBotApiResult: {
chat: ApiChat;
id: string;
queryId: string;
replyInfo?: ApiInputMessageReplyInfo;
sendAs?: ApiPeer;
isSilent?: boolean;
scheduledAt?: number;
allowPaidStars?: number;
};
resetInlineBot: { resetInlineBot: {
username: string; username: string;
force?: boolean; force?: boolean;
@ -2154,6 +2162,7 @@ export interface ActionPayloads {
requestEffectInComposer: WithTabId; requestEffectInComposer: WithTabId;
hideEffectInComposer: WithTabId; hideEffectInComposer: WithTabId;
setPaidMessageAutoApprove: undefined;
updateArchiveSettings: { updateArchiveSettings: {
isMinimized?: boolean; isMinimized?: boolean;
@ -2304,6 +2313,7 @@ export interface ActionPayloads {
shouldArchiveAndMuteNewNonContact?: boolean; shouldArchiveAndMuteNewNonContact?: boolean;
shouldHideReadMarks?: boolean; shouldHideReadMarks?: boolean;
shouldNewNonContactPeersRequirePremium?: boolean; shouldNewNonContactPeersRequirePremium?: boolean;
nonContactPeersPaidStars?: number | null;
}; };
// Premium // Premium
@ -2475,6 +2485,9 @@ export interface ActionPayloads {
status: ApiPaymentStatus; status: ApiPaymentStatus;
} & WithTabId; } & WithTabId;
openPaymentMessageConfirmDialogOpen: WithTabId | undefined;
closePaymentMessageConfirmDialogOpen: WithTabId | undefined;
// Forums // Forums
toggleForum: { toggleForum: {
chatId: string; chatId: string;

View File

@ -278,6 +278,8 @@ export type TabState = {
byChatId: Record<string, ManagementState>; byChatId: Record<string, ManagementState>;
}; };
isPaymentMessageConfirmDialogOpen: boolean;
storyViewer: { storyViewer: {
isRibbonShown?: boolean; isRibbonShown?: boolean;
isArchivedRibbonShown?: boolean; isArchivedRibbonShown?: boolean;
@ -292,6 +294,7 @@ export type TabState = {
// Used for better switch animation between peers. // Used for better switch animation between peers.
lastViewedByPeerIds?: Record<string, number>; lastViewedByPeerIds?: Record<string, number>;
isPrivacyModalOpen?: boolean; isPrivacyModalOpen?: boolean;
isPaymentConfirmDialogOpen?: boolean;
isStealthModalOpen?: boolean; isStealthModalOpen?: boolean;
viewModal?: { viewModal?: {
storyId: number; storyId: number;
@ -509,6 +512,11 @@ export type TabState = {
webAppKey: string; webAppKey: string;
message: ApiPreparedInlineMessage; message: ApiPreparedInlineMessage;
filter: ApiChatType[]; filter: ApiChatType[];
pendingSendArgs?: {
peerId: string;
threadId?: ThreadId;
starsForSendMessage?: number;
};
}; };
webApps: { webApps: {

View File

@ -50,7 +50,7 @@ export function useViewTransition(): ViewTransitionController {
transition.ready.then(() => { transition.ready.then(() => {
setTransitionState('animating'); setTransitionState('animating');
}).catch((e) => { }).catch((e:unknown) => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(e); console.error(e);
setTransitionState('skipped'); setTransitionState('skipped');

View File

@ -1472,8 +1472,10 @@ account.toggleUsername#58d6b376 username:string active:Bool = Bool;
account.resolveBusinessChatLink#5492e5ee slug:string = account.ResolvedBusinessChatLinks; account.resolveBusinessChatLink#5492e5ee slug:string = account.ResolvedBusinessChatLinks;
account.toggleSponsoredMessages#b9d9a38d enabled:Bool = Bool; account.toggleSponsoredMessages#b9d9a38d enabled:Bool = Bool;
account.getCollectibleEmojiStatuses#2e7b4543 hash:long = account.EmojiStatuses; account.getCollectibleEmojiStatuses#2e7b4543 hash:long = account.EmojiStatuses;
account.addNoPaidMessagesException#6f688aa7 flags:# refund_charged:flags.0?true user_id:InputUser = Bool;
users.getUsers#d91a548 id:Vector<InputUser> = Vector<User>; users.getUsers#d91a548 id:Vector<InputUser> = Vector<User>;
users.getFullUser#b60f5918 id:InputUser = users.UserFull; users.getFullUser#b60f5918 id:InputUser = users.UserFull;
users.getRequirementsToContact#d89a83a3 id:Vector<InputUser> = Vector<RequirementToContact>;
contacts.getContacts#5dd69e12 hash:long = contacts.Contacts; contacts.getContacts#5dd69e12 hash:long = contacts.Contacts;
contacts.importContacts#2c800be5 contacts:Vector<InputContact> = contacts.ImportedContacts; contacts.importContacts#2c800be5 contacts:Vector<InputContact> = contacts.ImportedContacts;
contacts.deleteContacts#96a0e00 id:Vector<InputUser> = Updates; contacts.deleteContacts#96a0e00 id:Vector<InputUser> = Updates;

View File

@ -61,6 +61,7 @@
"account.resolveBusinessChatLink", "account.resolveBusinessChatLink",
"account.toggleSponsoredMessages", "account.toggleSponsoredMessages",
"account.getCollectibleEmojiStatuses", "account.getCollectibleEmojiStatuses",
"account.addNoPaidMessagesException",
"users.getUsers", "users.getUsers",
"users.getFullUser", "users.getFullUser",
"contacts.getContacts", "contacts.getContacts",

View File

@ -103,6 +103,7 @@ $color-message-story-mention-to: #74bcff;
--color-voice-transcribe-button: #e8f3ff; --color-voice-transcribe-button: #e8f3ff;
--color-voice-transcribe-button-own: #cceebf; --color-voice-transcribe-button-own: #cceebf;
--color-toast-action: #64D1FF;
--color-primary: #{$color-primary}; --color-primary: #{$color-primary};
--color-primary-shade: #{color.mix($color-primary, $color-black, 92%)}; --color-primary-shade: #{color.mix($color-primary, $color-black, 92%)};
--color-primary-shade-darker: #{color.mix($color-primary, $color-black, 84%)}; --color-primary-shade-darker: #{color.mix($color-primary, $color-black, 84%)};

View File

@ -1,26 +1,36 @@
import type { TeactNode } from '../lib/teact/teact'; import type { TeactNode } from '../lib/teact/teact';
import type { import type {
ApiAttachment,
ApiBotInlineMediaResult, ApiBotInlineMediaResult,
ApiBotInlineResult, ApiBotInlineResult,
ApiBotInlineSwitchPm, ApiBotInlineSwitchPm,
ApiBotInlineSwitchWebview, ApiBotInlineSwitchWebview,
ApiChat,
ApiChatInviteImporter, ApiChatInviteImporter,
ApiContact,
ApiDocument, ApiDocument,
ApiDraft, ApiDraft,
ApiExportedInvite, ApiExportedInvite,
ApiFakeType, ApiFakeType,
ApiFormattedText, ApiFormattedText,
ApiInputReplyInfo,
ApiLabeledPrice, ApiLabeledPrice,
ApiMediaFormat, ApiMediaFormat,
ApiMessage, ApiMessage,
ApiMessageEntity,
ApiNewPoll,
ApiPeer,
ApiPhoto, ApiPhoto,
ApiReaction, ApiReaction,
ApiReactionWithPaid, ApiReactionWithPaid,
ApiStarGiftRegular, ApiStarGiftRegular,
ApiStarsSubscription, ApiStarsSubscription,
ApiStarsTransaction, ApiStarsTransaction,
ApiSticker,
ApiStickerSet, ApiStickerSet,
ApiStory,
ApiStorySkipped,
ApiThreadInfo, ApiThreadInfo,
ApiTopic, ApiTopic,
ApiTypingStatus, ApiTypingStatus,
@ -114,6 +124,7 @@ export interface ISettings {
isConnectionStatusMinimized: boolean; isConnectionStatusMinimized: boolean;
shouldArchiveAndMuteNewNonContact?: boolean; shouldArchiveAndMuteNewNonContact?: boolean;
shouldNewNonContactPeersRequirePremium?: boolean; shouldNewNonContactPeersRequirePremium?: boolean;
nonContactPeersPaidStars?: number;
shouldHideReadMarks?: boolean; shouldHideReadMarks?: boolean;
canTranslate: boolean; canTranslate: boolean;
canTranslateChats: boolean; canTranslateChats: boolean;
@ -126,6 +137,7 @@ export interface ISettings {
shouldDebugExportedSenders?: boolean; shouldDebugExportedSenders?: boolean;
shouldWarnAboutSvg?: boolean; shouldWarnAboutSvg?: boolean;
shouldSkipWebAppCloseConfirmation: boolean; shouldSkipWebAppCloseConfirmation: boolean;
shouldPaidMessageAutoApprove: boolean;
hasContactJoinedNotifications?: boolean; hasContactJoinedNotifications?: boolean;
hasWebNotifications: boolean; hasWebNotifications: boolean;
hasPushNotifications: boolean; hasPushNotifications: boolean;
@ -191,6 +203,7 @@ export enum SettingsScreens {
PrivacyGroupChatsAllowedContacts, PrivacyGroupChatsAllowedContacts,
PrivacyGroupChatsDeniedContacts, PrivacyGroupChatsDeniedContacts,
PrivacyBlockedUsers, PrivacyBlockedUsers,
PrivacyNoPaidMessages,
Performance, Performance,
Folders, Folders,
FoldersCreateFolder, FoldersCreateFolder,
@ -653,3 +666,63 @@ export type GiftProfileFilterOptions = {
shouldIncludeDisplayed: boolean; shouldIncludeDisplayed: boolean;
shouldIncludeHidden: boolean; shouldIncludeHidden: boolean;
}; };
export type SendMessageParams = {
chat?: ApiChat;
attachments?: ApiAttachment[];
lastMessageId?: number;
text?: string;
entities?: ApiMessageEntity[];
replyInfo?: ApiInputReplyInfo;
attachment?: ApiAttachment;
sticker?: ApiSticker;
story?: ApiStory | ApiStorySkipped;
gif?: ApiVideo;
poll?: ApiNewPoll;
contact?: ApiContact;
isSilent?: boolean;
scheduledAt?: number;
groupedId?: string;
noWebPage?: boolean;
sendAs?: ApiPeer;
shouldGroupMessages?: boolean;
shouldUpdateStickerSetOrder?: boolean;
wasDrafted?: boolean;
isInvertedMedia?: true;
effectId?: string;
webPageMediaSize?: WebPageMediaSize;
webPageUrl?: string;
starsAmount?: number;
isPending?: true;
messageList?: MessageList;
isReaction?: true; // Reaction to the story are sent in the form of a message
messagePriceInStars?: number;
localMessage?: ApiMessage;
forwardedLocalMessagesSlice?: ForwardedLocalMessagesSlice;
isForwarding?: boolean;
forwardParams?: ForwardMessagesParams;
isStoryReply?: boolean;
};
export type ForwardedLocalMessagesSlice = {
messageIds: number[];
localMessages: ApiMessage[];
};
export type ForwardMessagesParams = {
fromChat: ApiChat;
toChat: ApiChat;
toThreadId?: ThreadId;
messages: ApiMessage[];
isSilent?: boolean;
scheduledAt?: number;
sendAs?: ApiPeer;
withMyScore?: boolean;
noAuthors?: boolean;
noCaptions?: boolean;
isCurrentUserPremium?: boolean;
wasDrafted?: boolean;
lastMessageId?: number;
forwardedLocalMessagesSlice?: ForwardedLocalMessagesSlice;
messagePriceInStars?: number;
};

View File

@ -1428,6 +1428,25 @@ export interface LangPair {
'GetMoreStarsLinkText': undefined; 'GetMoreStarsLinkText': undefined;
'StarsGiftCompleted': undefined; 'StarsGiftCompleted': undefined;
'GiftSent': undefined; 'GiftSent': undefined;
'PrivacyDescriptionMessagesContactsAndPremium': undefined;
'PrivacyChargeForMessages': undefined;
'PrivacyDescriptionChargeForMessages': undefined;
'RemoveFeeTitle': undefined;
'ExceptionTitlePrivacyChargeForMessages': undefined;
'ExceptionDescriptionPrivacyChargeForMessages': undefined;
'SectionTitleStarsForForMessages': undefined;
'SubtitlePrivacyAddUsers': undefined;
'PrivacyPaidMessagesValue': undefined;
'ButtonBuyStars': undefined;
'TitleConfirmPayment': undefined;
'ToastTitleMessageSent': undefined;
'ButtonUndo': undefined;
'ConfirmRemoveMessageFee': undefined;
'StoryTooltipGifSent': undefined;
'StoryTooltipStickerSent': undefined;
'StoryTooltipReactionSent': undefined;
'StarsNeededTextSendPaidMessages': undefined;
'PaidMessageTransactionTotal': undefined;
} }
export interface LangPairWithVariables<V extends unknown = LangVariable> { export interface LangPairWithVariables<V extends unknown = LangVariable> {
@ -2273,6 +2292,66 @@ export interface LangPairWithVariables<V extends unknown = LangVariable> {
'stars': V; 'stars': V;
'link': V; 'link': V;
}; };
'SectionDescriptionStarsForForMessages': {
'percent': V;
'amount': V;
};
'SubtitlePrivacyUsersCount': {
'count': V;
};
'FirstMessageInPaidMessagesChat': {
'user': V;
'amount': V;
};
'ComposerPlaceholderPaidMessage': {
'amount': V;
};
'ComposerPlaceholderPaidReply': {
'amount': V;
};
'ConfirmationModalPaymentForOneMessage': {
'user': V;
'amount': V;
};
'ConfirmationModalPaymentForMessages': {
'user': V;
'price': V;
'amount': V;
'count': V;
};
'ButtonPayForMessage': {
'count': V;
};
'ToastTitleMessagesSent': {
'count': V;
};
'ToastMessageSent': {
'amount': V;
};
'ActionPaidOneMessageOutgoing': {
'amount': V;
};
'ActionPaidOneMessageIncoming': {
'amount': V;
'user': V;
};
'PaneMessagePaidMessageCharge': {
'peer': V;
'amount': V;
};
'ConfirmDialogMessageRemoveFee': {
'peer': V;
};
'ConfirmDialogRemoveFeeRefundStars': {
'amount': V;
};
'DescriptionGiftPaidMessage': {
'user': V;
'amount': V;
};
'PaidMessageTransactionDescription': {
'percent': V;
};
} }
export interface LangPairPlural { export interface LangPairPlural {
@ -2549,6 +2628,9 @@ export interface LangPairPluralWithVariables<V extends unknown = LangVariable> {
'from': V; 'from': V;
'count': V; 'count': V;
}; };
'PaidMessageTransaction': {
'count': V;
};
} }
export type RegularLangKey = keyof LangPair; export type RegularLangKey = keyof LangPair;
export type RegularLangKeyWithVariables = keyof LangPairWithVariables; export type RegularLangKeyWithVariables = keyof LangPairWithVariables;

View File

@ -12,11 +12,26 @@ export function formatStarsAsText(lang: LangFn, amount: number) {
return lang('StarsAmountText', { amount }, { pluralValue: amount }); return lang('StarsAmountText', { amount }, { pluralValue: amount });
} }
export function formatStarsAsIcon(lang: LangFn, amount: number, options?: { asFont?: boolean; className?: string }) { export function formatStarsAsIcon(lang: LangFn, amount: number, options?: {
const { asFont, className } = options || {}; asFont?: boolean; className?: string; containerClassName?: string; }) {
const { asFont, className, containerClassName } = options || {};
const icon = asFont const icon = asFont
? <Icon name="star" className={buildClassName('star-amount-icon', className)} /> ? <Icon name="star" className={buildClassName('star-amount-icon', className)} />
: <StarIcon type="gold" className={buildClassName('star-amount-icon', className)} size="adaptive" />; : <StarIcon type="gold" className={buildClassName('star-amount-icon', className)} size="adaptive" />;
if (containerClassName) {
return (
<span className={containerClassName}>
{lang('StarsAmount', { amount }, {
withNodes: true,
specialReplacement: {
[STARS_ICON_PLACEHOLDER]: icon,
},
})}
</span>
);
}
return lang('StarsAmount', { amount }, { return lang('StarsAmount', { amount }, {
withNodes: true, withNodes: true,
specialReplacement: { specialReplacement: {