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;
starref_start_param_prefixes?: 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;
}
@ -164,6 +168,10 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp
maxPinnedStoriesCount: appConfig.stories_pinned_to_top_count_max,
groupTranscribeLevelMin: appConfig.group_transcribe_level_min,
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,
bandwidthPremiumUploadSpeedup: appConfig.upload_premium_speedup_upload,
bandwidthPremiumDownloadSpeedup: appConfig.upload_premium_speedup_download,

View File

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

View File

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

View File

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

View File

@ -535,7 +535,7 @@ export function buildApiStarsTransactionPeer(peer: GramJs.TypeStarsTransactionPe
export function buildApiStarsTransaction(transaction: GramJs.StarsTransaction): ApiStarsTransaction {
const {
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;
if (photo) {
@ -567,6 +567,7 @@ export function buildApiStarsTransaction(transaction: GramJs.StarsTransaction):
giveawayPostId,
starRefCommision,
isGiftUpgrade: stargiftUpgrade,
paidMessages,
};
}

View File

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

View File

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

View File

@ -143,7 +143,7 @@ export async function fetchInlineBotResults({
}
export async function sendInlineBotResult({
chat, replyInfo, resultId, queryId, sendAs, isSilent, scheduleDate,
chat, replyInfo, resultId, queryId, sendAs, isSilent, scheduleDate, allowPaidStars,
}: {
chat: ApiChat;
replyInfo?: ApiInputMessageReplyInfo;
@ -152,6 +152,7 @@ export async function sendInlineBotResult({
sendAs?: ApiPeer;
isSilent?: boolean;
scheduleDate?: number;
allowPaidStars?: number;
}) {
const randomId = generateRandomBigInt();
@ -165,6 +166,7 @@ export async function sendInlineBotResult({
replyTo: replyInfo && buildInputReplyTo(replyInfo),
...(isSilent && { silent: true }),
...(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 { RPCError } from '../../../lib/gramjs/errors';
import type { ThreadId, WebPageMediaSize } from '../../../types';
import type {
ForwardMessagesParams,
SendMessageParams,
ThreadId,
} from '../../../types';
import type {
ApiAttachment,
ApiChat,
ApiContact,
ApiError,
ApiFormattedText,
ApiGlobalMessageSearchType,
@ -15,18 +18,13 @@ import type {
ApiMessageEntity,
ApiMessageSearchContext,
ApiMessageSearchType,
ApiNewPoll,
ApiOnProgress,
ApiPeer,
ApiPoll,
ApiReaction,
ApiSendMessageAction,
ApiSticker,
ApiStory,
ApiStorySkipped,
ApiUser,
ApiUserStatus,
ApiVideo,
MediaContent,
} from '../../types';
import {
@ -255,56 +253,16 @@ export async function fetchMessage({ chat, messageId }: { chat: ApiChat; message
let mediaQueue = Promise.resolve();
export function sendMessage(
{
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,
export function sendMessageLocal(
params: SendMessageParams,
) {
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 {
message: localMessage,
poll: localPoll,
@ -325,6 +283,8 @@ export function sendMessage(
story,
isInvertedMedia,
effectId,
isPending,
messagePriceInStars,
);
sendApiUpdate({
@ -336,6 +296,22 @@ export function sendMessage(
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,
// so in most cases this will be simply ignored
const timeout = setTimeout(() => {
@ -361,6 +337,7 @@ export function sendMessage(
groupedId,
isSilent,
scheduledAt,
messagePriceInStars,
}, randomId, localMessage, onProgress);
}
@ -420,6 +397,7 @@ export function sendMessage(
...(shouldUpdateStickerSetOrder && { updateStickersetsOrder: shouldUpdateStickerSetOrder }),
...(isInvertedMedia && { invertMedia: isInvertedMedia }),
...(effectId && { effect: BigInt(effectId) }),
...(messagePriceInStars && { allowPaidStars: BigInt(messagePriceInStars) }),
}), {
shouldThrow: true,
shouldIgnoreUpdates: true,
@ -443,6 +421,14 @@ export function sendMessage(
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, {
counter: number;
singleMediaByIndex: Record<number, GramJs.InputSingleMedia>;
@ -460,6 +446,7 @@ function sendGroupedMedia(
isSilent,
scheduledAt,
sendAs,
messagePriceInStars,
}: {
chat: ApiChat;
text?: string;
@ -470,6 +457,7 @@ function sendGroupedMedia(
isSilent?: boolean;
scheduledAt?: number;
sendAs?: ApiPeer;
messagePriceInStars?: number;
},
randomId: GramJs.long,
localMessage: ApiMessage,
@ -536,6 +524,7 @@ function sendGroupedMedia(
const { singleMediaByIndex, localMessages } = groupedUploads[groupedId];
delete groupedUploads[groupedId];
const count = Object.values(singleMediaByIndex).length;
const update = await invokeRequest(new GramJs.messages.SendMultiMedia({
clearDraft: true,
@ -545,6 +534,7 @@ function sendGroupedMedia(
...(isSilent && { silent: isSilent }),
...(scheduledAt && { scheduleDate: scheduledAt }),
...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }),
...(messagePriceInStars && { allowPaidStars: BigInt(messagePriceInStars * count) }),
}), {
shouldIgnoreUpdates: true,
});
@ -1534,40 +1524,17 @@ export async function fetchExtendedMedia({
}));
}
export async function forwardMessages({
fromChat,
toChat,
toThreadId,
messages,
isSilent,
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> = {};
export function forwardMessagesLocal(params: ForwardMessagesParams) {
const {
toChat, toThreadId, messages,
scheduledAt, sendAs, noAuthors, noCaptions,
isCurrentUserPremium, wasDrafted, lastMessageId,
} = params;
messages.forEach((message, index) => {
const messageIds = messages.map(({ id }) => id);
const localMessages: ApiMessage[] = [];
messages.forEach((message) => {
const localMessage = buildLocalForwardedMessage({
toChat,
toThreadId: Number(toThreadId),
@ -1579,7 +1546,7 @@ export async function forwardMessages({
lastMessageId,
sendAs,
});
localMessages[randomIds[index].toString()] = localMessage;
localMessages.push(localMessage);
sendApiUpdate({
'@type': localMessage.isScheduled ? 'newScheduledMessage' : 'newMessage',
@ -1589,7 +1556,25 @@ export async function forwardMessages({
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 {
const update = await invokeRequest(new GramJs.messages.ForwardMessages({
fromPeer: buildInputPeer(fromChat.id, fromChat.accessHash),
@ -1603,11 +1588,16 @@ export async function forwardMessages({
...(toThreadId && { topMsgId: Number(toThreadId) }),
...(scheduledAt && { scheduleDate: scheduledAt }),
...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }),
...(priceInStars && { allowPaidStars: BigInt(priceInStars) }),
}), {
shouldThrow: 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) {
Object.values(localMessages).forEach((localMessage) => {
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({
chat,
timestamp,

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import type { ApiChat } from './chats';
import type { ApiUser } from './users';
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 BotsPrivacyType = 'allow' | 'disallow' | 'none';

View File

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

View File

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

View File

@ -1853,3 +1853,41 @@
"GiftPremiumDescriptionYourBalance" = "Your balance is **{stars}**. {link}";
"StarsGiftCompleted"= "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 {
overflow: visible;
flex-shrink: 0;
margin-left: 0.5rem;
width: var(--base-height);
@ -96,6 +97,15 @@
position: absolute;
}
.paidStarsBadgeText {
display: inline-flex;
align-items: center;
.star-amount-icon {
margin-inline-start: 0;
}
}
@media (hover: hover) {
&:not(:active):not(:focus):not(:hover) {
.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 {
.icon-send {
animation: grow-icon 0.4s ease-out;
@ -652,8 +689,13 @@
}
}
.placeholder-star-icon {
line-height: 1;
}
.forced-placeholder,
.placeholder-text {
display: inline-flex;
align-items: center;
position: absolute;
color: var(--color-placeholders);
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, {
memo, useEffect, useMemo, useRef, useSignal, useState,
} from '../../lib/teact/teact';
@ -57,6 +57,7 @@ import { requestMeasure, requestNextMutation } from '../../lib/fasterdom/fasterd
import {
canEditMedia,
getAllowedAttachmentOptions,
getPeerTitle,
getReactionKey,
getStoryKey,
isChatAdmin,
@ -90,6 +91,7 @@ import {
selectNotifyDefaults,
selectNotifyException,
selectNoWebPage,
selectPeerPaidMessagesStars,
selectPeerStory,
selectPerformanceSettingsValue,
selectRequestedDraft,
@ -108,6 +110,7 @@ import { tryParseDeepLink } from '../../util/deepLinkParser';
import deleteLastCharacterOutsideSelection from '../../util/deleteLastCharacterOutsideSelection';
import { processMessageInputForCustomEmoji } from '../../util/emoji/customEmojiManager';
import focusEditableElement from '../../util/focusEditableElement';
import { formatStarsAsIcon } from '../../util/localization/format';
import { MEMO_EMPTY_ARRAY } from '../../util/memo';
import parseHtmlAsFormattedText from '../../util/parseHtmlAsFormattedText';
import { insertHtmlInSelection } from '../../util/selection';
@ -147,6 +150,7 @@ import useEditing from '../middle/composer/hooks/useEditing';
import useEmojiTooltip from '../middle/composer/hooks/useEmojiTooltip';
import useInlineBotTooltip from '../middle/composer/hooks/useInlineBotTooltip';
import useMentionTooltip from '../middle/composer/hooks/useMentionTooltip';
import usePaidMessageConfirmation from '../middle/composer/hooks/usePaidMessageConfirmation';
import useStickerTooltip from '../middle/composer/hooks/useStickerTooltip';
import useVoiceRecording from '../middle/composer/hooks/useVoiceRecording';
@ -175,8 +179,10 @@ import Button from '../ui/Button';
import ResponsiveHoverButton from '../ui/ResponsiveHoverButton';
import Spinner from '../ui/Spinner';
import Transition from '../ui/Transition';
import AnimatedCounter from './AnimatedCounter';
import Avatar from './Avatar';
import Icon from './icons/Icon';
import PaymentMessageConfirmDialog from './PaymentMessageConfirmDialog';
import ReactionAnimatedEmoji from './reactions/ReactionAnimatedEmoji';
import './Composer.scss';
@ -196,7 +202,7 @@ type OwnProps = {
editableInputCssSelector: string;
editableInputId: string;
className?: string;
inputPlaceholder?: string;
inputPlaceholder?: TeactNode | string;
onDropHide?: NoneToVoidFunction;
onForward?: NoneToVoidFunction;
onFocus?: NoneToVoidFunction;
@ -220,6 +226,7 @@ type StateProps =
isSelectModeActive?: boolean;
isReactionPickerOpen?: boolean;
isForwarding?: boolean;
forwardedMessagesCount?: number;
pollModal: TabState['pollModal'];
botKeyboardMessageId?: number;
botKeyboardPlaceholder?: string;
@ -271,13 +278,16 @@ type StateProps =
webPagePreview?: ApiWebPage;
noWebPage?: boolean;
isContactRequirePremium?: boolean;
paidMessagesStars?: number;
effect?: ApiAvailableEffect;
effectReactions?: ApiReaction[];
areEffectsSupported?: boolean;
canPlayEffect?: boolean;
shouldPlayEffect?: boolean;
maxMessageLength: number;
shouldPaidMessageAutoApprove?: boolean;
isSilentPosting?: boolean;
isPaymentMessageConfirmDialogOpen: boolean;
};
enum MainButtonState {
@ -331,6 +341,7 @@ const Composer: FC<OwnProps & StateProps> = ({
isSelectModeActive,
isReactionPickerOpen,
isForwarding,
forwardedMessagesCount,
pollModal,
botKeyboardMessageId,
botKeyboardPlaceholder,
@ -382,6 +393,7 @@ const Composer: FC<OwnProps & StateProps> = ({
webPagePreview,
noWebPage,
isContactRequirePremium,
paidMessagesStars,
effect,
effectReactions,
areEffectsSupported,
@ -393,12 +405,12 @@ const Composer: FC<OwnProps & StateProps> = ({
onFocus,
onBlur,
onForward,
isPaymentMessageConfirmDialogOpen,
}) => {
const {
sendMessage,
clearDraft,
showDialog,
forwardMessages,
openPollModal,
closePollModal,
loadScheduledHistory,
@ -427,6 +439,8 @@ const Composer: FC<OwnProps & StateProps> = ({
// eslint-disable-next-line no-null/no-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
const storyReactionRef = useRef<HTMLButtonElement>(null);
@ -513,6 +527,22 @@ const Composer: FC<OwnProps & StateProps> = ({
const isNeedPremium = isContactRequirePremium && isInStoryViewer;
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 isComposerBlocked = isSendTextBlocked && !editingMessage;
@ -933,6 +963,21 @@ const Composer: FC<OwnProps & StateProps> = ({
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(({
attachments: attachmentsToSend,
sendCompressed = attachmentSettings.shouldCompress,
@ -954,11 +999,6 @@ const Composer: FC<OwnProps & StateProps> = ({
isSilent = isSilent || isSilentPosting;
const { text, entities } = parseHtmlAsFormattedText(getHtml());
if (!text && !attachmentsToSend.length) {
return;
}
if (!validateTextLength(text, true)) return;
if (!checkSlowMode()) return;
isInvertedMedia = text && sendCompressed && sendGrouped ? isInvertedMedia : undefined;
@ -998,12 +1038,24 @@ const Composer: FC<OwnProps & StateProps> = ({
sendGrouped: boolean,
isInvertedMedia?: true,
) => {
sendAttachments({
attachments,
sendCompressed,
sendGrouped,
isInvertedMedia,
});
if (canSendAttachments(attachments)) {
if (editingMessage) {
sendAttachments({
attachments,
sendCompressed,
sendGrouped,
isInvertedMedia,
});
return;
}
handleActionWithPaymentConfirmation(sendAttachments, {
attachments,
sendCompressed,
sendGrouped,
isInvertedMedia,
});
}
});
const handleSendAttachments = useLastCallback((
@ -1013,16 +1065,81 @@ const Composer: FC<OwnProps & StateProps> = ({
scheduledAt?: number,
isInvertedMedia?: true,
) => {
sendAttachments({
attachments,
sendCompressed,
sendGrouped,
isSilent,
scheduledAt,
isInvertedMedia,
});
if (canSendAttachments(attachments)) {
sendAttachments({
attachments,
sendCompressed,
sendGrouped,
isSilent,
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) => {
if (!currentMessageList && !storyId) {
return;
@ -1045,68 +1162,11 @@ const Composer: FC<OwnProps & StateProps> = ({
}
}
const { text, entities } = parseHtmlAsFormattedText(getHtml());
handleSendCore(currentAttachments, isSilent, scheduledAt);
});
if (currentAttachments.length) {
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) {
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 handleSendWithConfirmation = useLastCallback((isSilent = false, scheduledAt?: number) => {
handleActionWithPaymentConfirmation(handleSend, isSilent, scheduledAt);
});
const handleClickBotMenu = useLastCallback(() => {
@ -1215,13 +1275,13 @@ const Composer: FC<OwnProps & StateProps> = ({
forceShowSymbolMenu();
requestCalendar((scheduledAt) => {
cancelForceShowSymbolMenu();
handleMessageSchedule({ gif, isSilent }, scheduledAt, currentMessageList!);
handleActionWithPaymentConfirmation(handleMessageSchedule, { gif, isSilent }, scheduledAt, currentMessageList!);
requestMeasure(() => {
resetComposer(true);
});
});
} else {
sendMessage({ messageList: currentMessageList, gif, isSilent });
handleActionWithPaymentConfirmation(sendMessage, { messageList: currentMessageList, gif, isSilent });
requestMeasure(() => {
resetComposer(true);
});
@ -1250,18 +1310,23 @@ const Composer: FC<OwnProps & StateProps> = ({
forceShowSymbolMenu();
requestCalendar((scheduledAt) => {
cancelForceShowSymbolMenu();
handleMessageSchedule({ sticker, isSilent }, scheduledAt, currentMessageList!);
handleActionWithPaymentConfirmation(
handleMessageSchedule, { sticker, isSilent }, scheduledAt, currentMessageList!,
);
requestMeasure(() => {
resetComposer(shouldPreserveInput);
});
});
} else {
sendMessage({
messageList: currentMessageList,
sticker,
isSilent,
shouldUpdateStickerSetOrder: shouldUpdateStickerSetOrder && canUpdateStickerSetsOrder,
});
handleActionWithPaymentConfirmation(
sendMessage,
{
messageList: currentMessageList,
sticker,
isSilent,
shouldUpdateStickerSetOrder: shouldUpdateStickerSetOrder && canUpdateStickerSetsOrder,
},
);
clearDraft({ chatId, threadId, isLocalOnly: true });
requestMeasure(() => {
@ -1281,20 +1346,24 @@ const Composer: FC<OwnProps & StateProps> = ({
if (isInScheduledList || isScheduleRequested) {
requestCalendar((scheduledAt) => {
handleMessageSchedule({
id: inlineResult.id,
queryId: inlineResult.queryId,
isSilent,
}, scheduledAt, currentMessageList!);
handleActionWithPaymentConfirmation(handleMessageSchedule,
{
id: inlineResult.id,
queryId: inlineResult.queryId,
isSilent,
},
scheduledAt,
currentMessageList!);
});
} else {
sendInlineBotResult({
id: inlineResult.id,
queryId: inlineResult.queryId,
threadId,
chatId,
isSilent,
});
handleActionWithPaymentConfirmation(sendInlineBotResult,
{
id: inlineResult.id,
queryId: inlineResult.queryId,
threadId,
chatId,
isSilent,
});
}
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) => {
if (isInScheduledList) {
requestCalendar((scheduledAt) => {
@ -1458,6 +1531,13 @@ const Composer: FC<OwnProps & StateProps> = ({
if (!isComposerBlocked) {
if (botKeyboardPlaceholder) return botKeyboardPlaceholder;
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) {
return replyToTopic
? lang('ComposerPlaceholderTopic', { topic: replyToTopic.title })
@ -1474,7 +1554,7 @@ const Composer: FC<OwnProps & StateProps> = ({
return lang('ComposerPlaceholderNoText');
}, [
activeVoiceRecording, botKeyboardPlaceholder, chat, inputPlaceholder, isChannel, isComposerBlocked,
isInStoryViewer, isSilentPosting, lang, replyToTopic, threadId, windowWidth,
isInStoryViewer, isSilentPosting, lang, replyToTopic, threadId, windowWidth, paidMessagesStars,
]);
useEffect(() => {
@ -1498,7 +1578,7 @@ const Composer: FC<OwnProps & StateProps> = ({
onForward?.();
break;
case MainButtonState.Send:
void handleSend();
handleSendWithConfirmation();
break;
case MainButtonState.Record: {
if (areVoiceMessagesNotAllowed) {
@ -1586,7 +1666,7 @@ const Composer: FC<OwnProps & StateProps> = ({
entities = customEmojiMessage.entities;
}
sendMessage({ text, entities, isReaction: true });
handleActionWithPaymentConfirmation(sendMessage, { text, entities, isReaction: true });
closeReactionPicker();
});
@ -1622,24 +1702,29 @@ const Composer: FC<OwnProps & StateProps> = ({
});
const handleSendSilent = useLastCallback(() => {
sendSilent();
handleActionWithPaymentConfirmation(sendSilent);
});
const handleSendWhenOnline = useLastCallback(() => {
handleMessageSchedule({}, SCHEDULED_WHEN_ONLINE, currentMessageList!, effect?.id);
handleActionWithPaymentConfirmation(
handleMessageSchedule, {}, SCHEDULED_WHEN_ONLINE, currentMessageList!, effect?.id,
);
});
const handleSendScheduledAttachments = useLastCallback(
(sendCompressed: boolean, sendGrouped: boolean, isInvertedMedia?: true) => {
requestCalendar((scheduledAt) => {
handleMessageSchedule({ sendCompressed, sendGrouped, isInvertedMedia }, scheduledAt, currentMessageList!);
handleActionWithPaymentConfirmation(handleMessageSchedule,
{ sendCompressed, sendGrouped, isInvertedMedia },
scheduledAt,
currentMessageList!);
});
},
);
const handleSendSilentAttachments = useLastCallback(
(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:
return handleSendScheduled;
default:
return handleSend;
return handleSendWithConfirmation;
}
}, [mainButtonState, handleEditComplete]);
}, [mainButtonState, handleEditComplete, handleSendWithConfirmation]);
const withBotCommands = isChatWithBot && botMenuButton?.type === 'commands' && !editingMessage
&& botCommands !== false && !activeVoiceRecording;
const effectEmoji = areEffectsSupported && effect?.emoticon;
const shouldRenderPaidBadge = Boolean(paidMessagesStars && mainButtonState === MainButtonState.Send);
return (
<div className={fullClassName}>
{isInMessageList && canAttachMedia && isReady && (
@ -1702,7 +1789,8 @@ const Composer: FC<OwnProps & StateProps> = ({
shouldForceAsFile={shouldForceAsFile}
isForCurrentMessageList={isForCurrentMessageList}
isForMessage={isInMessageList}
shouldSchedule={isInScheduledList}
shouldSchedule={!paidMessagesStars && isInScheduledList}
canSchedule={!paidMessagesStars}
forceDarkTheme={isInStoryViewer}
onCaptionUpdate={onCaptionUpdate}
onSendSilent={handleSendSilentAttachments}
@ -1717,13 +1805,14 @@ const Composer: FC<OwnProps & StateProps> = ({
editingMessage={editingMessage}
onSendWhenOnline={handleSendWhenOnline}
canScheduleUntilOnline={canScheduleUntilOnline && !isViewOnceEnabled}
paidMessagesStars={paidMessagesStars}
/>
<PollModal
isOpen={pollModal.isOpen}
isQuiz={pollModal.isQuiz}
shouldBeAnonymous={isChannel}
onClear={closePollModal}
onSend={handlePollSend}
onSend={handlePollSendWithPaymentConfirmation}
/>
<SendAsMenu
isOpen={isSendAsMenuOpen}
@ -2109,6 +2198,22 @@ const Composer: FC<OwnProps & StateProps> = ({
{onForward && <Icon name="forward" />}
{isInMessageList && <Icon name="schedule" />}
{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>
{effectEmoji && (
<span className="effect-icon" onClick={handleRemoveEffect}>
@ -2125,7 +2230,7 @@ const Composer: FC<OwnProps & StateProps> = ({
{canShowCustomSendMenu && (
<CustomSendMenu
isOpen={isCustomSendMenuOpen}
canSchedule={isInMessageList && !isViewOnceEnabled}
canSchedule={!paidMessagesStars && isInMessageList && !isViewOnceEnabled}
canScheduleUntilOnline={canScheduleUntilOnline && !isViewOnceEnabled}
onSendSilent={!isChatWithSelf ? handleSendSilent : undefined}
onSendSchedule={!isInScheduledList ? handleSendScheduled : undefined}
@ -2147,6 +2252,16 @@ const Composer: FC<OwnProps & StateProps> = ({
/>
)}
{calendar}
<PaymentMessageConfirmDialog
isOpen={isPaymentMessageConfirmDialogOpen}
onClose={closeConfirmModalPayForMessage}
userName={chat ? getPeerTitle(lang, chat) : undefined}
messagePriceInStars={paidMessagesStars || 0}
messagesCount={messagesCount}
shouldAutoApprove={shouldPaidMessageAutoApprove}
setAutoApprove={setShouldPaidMessageAutoApprove}
confirmHandler={paymentMessageConfirmDialogHandler}
/>
</div>
);
};
@ -2161,12 +2276,18 @@ export default memo(withGlobal<OwnProps>(
const isChatWithSelf = selectIsChatWithSelf(global, chatId);
const isChatWithUser = isUserId(chatId);
const userFullInfo = isChatWithUser ? selectUserFullInfo(global, chatId) : undefined;
const paidMessagesStars = selectPeerPaidMessagesStars(global, chatId);
const chatFullInfo = !isChatWithUser ? selectChatFullInfo(global, chatId) : undefined;
const messageWithActualBotKeyboard = (isChatWithBot || !isChatWithUser)
&& selectNewestMessageWithBotKeyboardButtons(global, chatId, threadId);
const {
language, shouldSuggestStickers, shouldSuggestCustomEmoji, shouldUpdateStickerSetOrder,
shouldPaidMessageAutoApprove,
} = global.settings.byKey;
const {
forwardMessages: { messageIds: forwardMessageIds },
} = selectTabState(global);
const baseEmojiKeywords = global.emojiKeywords[BASE_EMOJI_KEYWORD_LANG];
const emojiKeywords = language !== BASE_EMOJI_KEYWORD_LANG ? global.emojiKeywords[language] : undefined;
const botKeyboardMessageId = messageWithActualBotKeyboard ? messageWithActualBotKeyboard.id : undefined;
@ -2230,6 +2351,7 @@ export default memo(withGlobal<OwnProps>(
const effectReactions = global.reactions.effectReactions;
const maxMessageLength = global.config?.maxMessageLength || DEFAULT_MAX_MESSAGE_LENGTH;
const isForwarding = chatId === tabState.forwardMessages.toChatId;
return {
availableReactions: global.reactions.availableReactions,
@ -2252,7 +2374,8 @@ export default memo(withGlobal<OwnProps>(
isInScheduledList,
botKeyboardMessageId,
botKeyboardPlaceholder: keyboardMessage?.keyboardPlaceholder,
isForwarding: chatId === tabState.forwardMessages.toChatId,
isForwarding,
forwardedMessagesCount: isForwarding ? forwardMessageIds!.length : undefined,
pollModal: tabState.pollModal,
stickersForEmoji: global.stickers.forEmoji.stickers,
customEmojiForEmoji: global.customEmojis.forEmoji.stickers,
@ -2307,7 +2430,10 @@ export default memo(withGlobal<OwnProps>(
canPlayEffect,
shouldPlayEffect,
maxMessageLength,
paidMessagesStars,
shouldPaidMessageAutoApprove,
isSilentPosting,
isPaymentMessageConfirmDialogOpen: tabState.isPaymentMessageConfirmDialogOpen,
};
},
)(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;
onClose: NoneToVoidFunction;
onCloseAnimationEnd?: NoneToVoidFunction;
isLowStackPriority?: boolean;
};
type StateProps = {
@ -54,6 +55,7 @@ const RecipientPicker: FC<OwnProps & StateProps> = ({
onSelectRecipient,
onClose,
onCloseAnimationEnd,
isLowStackPriority,
}) => {
const [search, setSearch] = useState('');
const ids = useMemo(() => {
@ -112,6 +114,7 @@ const RecipientPicker: FC<OwnProps & StateProps> = ({
onSelectChatOrUser={onSelectRecipient}
onClose={onClose}
onCloseAnimationEnd={onCloseAnimationEnd}
isLowStackPriority={isLowStackPriority}
/>
);
};

View File

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

View File

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

View File

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

View File

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

View File

@ -17,7 +17,7 @@ function PrivacyLockedOption({ label }: OwnProps) {
return (
<div
className={styles.contactsAndPremiumOptionTitle}
className={styles.root}
onClick={() => showNotification({ message: lang('OptionPremiumRequiredMessage') })}
>
<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 { 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 useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
import ListItem from '../../ui/ListItem';
import RadioGroup from '../../ui/RadioGroup';
import RangeSlider from '../../ui/RangeSlider';
import PremiumStatusItem from './PremiumStatusItem';
import PrivacyLockedOption from './PrivacyLockedOption';
type OwnProps = {
isActive?: boolean;
onReset: VoidFunction;
onScreenSelect: (screen: SettingsScreens) => void;
};
type StateProps = {
shouldNewNonContactPeersRequirePremium?: boolean;
shouldChargeForMessages?: boolean;
canLimitNewMessagesWithoutPremium?: boolean;
canChargeForMessages?: boolean;
isCurrentUserPremium?: boolean;
starsUsdWithdrawRate: number;
starsPaidMessageCommissionPermille: number;
starsPaidMessageAmountMax?: number;
nonContactPeersPaidStars: number;
noPaidReactionsForUsersCount: number;
};
function PrivacyMessages({
isActive,
canLimitNewMessagesWithoutPremium,
canChargeForMessages,
shouldNewNonContactPeersRequirePremium,
shouldChargeForMessages,
nonContactPeersPaidStars,
isCurrentUserPremium,
starsPaidMessageCommissionPermille,
starsPaidMessageAmountMax,
starsUsdWithdrawRate,
noPaidReactionsForUsersCount,
onReset,
onScreenSelect,
}: OwnProps & StateProps) {
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(() => {
return [
{ value: 'everybody', label: lang('P2PEverybody') },
{ value: 'everybody', label: oldLang('P2PEverybody') },
{
value: 'contacts_and_premium',
label: canChange ? (
lang('PrivacyMessagesContactsAndPremium')
label: canChangeForContactsAndPremium ? (
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) => {
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({
isActive,
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 (
<>
<div className="settings-item">
<h4 className="settings-item-header" dir={lang.isRtl ? 'rtl' : undefined}>
{lang('PrivacyMessagesTitle')}
<h4 className="settings-item-header" dir={oldLang.isRtl ? 'rtl' : undefined}>
{oldLang('PrivacyMessagesTitle')}
</h4>
<RadioGroup
name="privacy-messages"
options={options}
onChange={handleChange}
selected={shouldNewNonContactPeersRequirePremium ? 'contacts_and_premium' : 'everybody'}
selected={selectedValue}
/>
<p className="settings-item-description-larger" dir={lang.isRtl ? 'rtl' : undefined}>
{lang('Privacy.Messages.SectionFooter')}
<p className="settings-item-description-larger" dir={oldLang.isRtl ? 'rtl' : undefined}>
{privacyDescription}
</p>
</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 => {
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 {
shouldNewNonContactPeersRequirePremium: selectNewNoncontactPeersRequirePremium(global),
shouldChargeForMessages: Boolean(nonContactPeersPaidStars),
nonContactPeersPaidStars: nonContactPeersPaidStars || DEFAULT_CHARGE_FOR_MESSAGES,
isCurrentUserPremium: selectIsCurrentUserPremium(global),
canLimitNewMessagesWithoutPremium: global.appConfig?.canLimitNewMessagesWithoutPremium,
canChargeForMessages: global.appConfig?.starsPaidMessagesAvailable,
starsPaidMessageAmountMax: global.appConfig?.starsPaidMessageAmountMax || DEFAULT_MAXIMUM_CHARGE_FOR_MESSAGES,
starsPaidMessageCommissionPermille,
starsUsdWithdrawRate,
noPaidReactionsForUsersCount,
};
})(PrivacyMessages));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,10 @@
import type { RefObject } from 'react';
import type { FC } 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 { MessageDateGroup } from './helpers/groupMessages';
import type { OnIntersectPinnedMessage } from './hooks/usePinnedMessage';
@ -13,10 +14,12 @@ import { SCHEDULED_WHEN_ONLINE } from '../../config';
import {
getMessageHtmlId,
getMessageOriginalId,
getPeerTitle,
isActionMessage,
isOwnMessage,
isServiceNotificationMessage,
} from '../../global/helpers';
import { selectSender } from '../../global/selectors';
import buildClassName from '../../util/buildClassName';
import { formatHumanDate } from '../../util/dates/dateFormat';
import { compact } from '../../util/iteratees';
@ -24,6 +27,7 @@ import { isAlbum } from './helpers/groupMessages';
import { preventMessageInputBlur } from './helpers/preventMessageInputBlur';
import useDerivedSignal from '../../hooks/useDerivedSignal';
import useLang from '../../hooks/useLang';
import useOldLang from '../../hooks/useOldLang';
import usePreviousDeprecated from '../../hooks/usePreviousDeprecated';
import useMessageObservers from './hooks/useMessageObservers';
@ -131,12 +135,42 @@ const MessageListContent: FC<OwnProps> = ({
);
const oldLang = useOldLang();
const lang = useLang();
const unreadDivider = (
<div className={buildClassName(UNREAD_DIVIDER_CLASS, 'local-action-message')} key="unread-messages">
<span>{oldLang('UnreadMessages')}</span>
</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) => {
return acc + messageGroup.senderGroups.flat().length;
}, 0);
@ -228,6 +262,7 @@ const MessageListContent: FC<OwnProps> = ({
return compact([
message.id === memoUnreadDividerBeforeIdRef.current && unreadDivider,
message.paidMessageStars && !withUsers && renderPaidMessageAction(message, album),
<Message
key={key}
message={message}

View File

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

View File

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

View File

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

View File

@ -8,8 +8,8 @@
.button {
background: var(--pattern-color);
width: 10rem;
margin-top: 0.5rem;
width: auto;
margin-top: 1.0625rem;
text-transform: none;
color: var(--color-white);
height: 2.25rem;
@ -28,8 +28,8 @@
flex-direction: column;
align-items: center;
background: var(--pattern-color);
max-width: 15rem;
padding: 0.75rem 0;
max-width: 13.5rem;
padding: 1.0625rem 0;
border-radius: 1.5rem;
&[dir="rtl"] {
@ -60,7 +60,23 @@
}
.description {
white-space: pre;
text-align: center;
display: inline-flex;
align-items: center;
flex-wrap: wrap;
justify-content: center;
padding: 0 1rem;
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 { selectTheme, selectUser } from '../../global/selectors';
import { formatStarsAsIcon } from '../../util/localization/format';
import { LOCAL_TGS_URLS } from '../common/helpers/animatedAssets';
import renderText from '../common/helpers/renderText';
import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback';
import useOldLang from '../../hooks/useOldLang';
import AnimatedIconWithPreview from '../common/AnimatedIconWithPreview';
import Icon from '../common/icons/Icon';
import Sparkles from '../common/Sparkles';
import Button from '../ui/Button';
import styles from './PremiumRequiredMessage.module.scss';
import styles from './RequirementToContactMessage.module.scss';
type OwnProps = {
// eslint-disable-next-line react/no-unused-prop-types
userId: string;
paidMessagesStars?: number;
};
type StateProps = {
@ -24,12 +29,15 @@ type StateProps = {
userName?: string;
};
function PremiumRequiredMessage({ patternColor, userName }: StateProps) {
const lang = useOldLang();
const { openPremiumModal } = getActions();
function RequirementToContactMessage({ patternColor, userName, paidMessagesStars }: OwnProps & StateProps) {
const oldLang = useOldLang();
const lang = useLang();
const { openPremiumModal, openStarsBalanceModal } = getActions();
const handleOpenPremiumModal = useLastCallback(() => openPremiumModal());
const handleGetMoreStars = useLastCallback(() => { openStarsBalanceModal({}); });
return (
<div className={styles.root}>
<div className={styles.inner}>
@ -43,15 +51,41 @@ function PremiumRequiredMessage({ patternColor, userName }: StateProps) {
<Icon name="comments-sticker" className={styles.commentsIcon} />
</div>
<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>
<Button
color="translucent-black"
size="tiny"
onClick={handleOpenPremiumModal}
size="default"
pill
onClick={paidMessagesStars ? handleGetMoreStars : handleOpenPremiumModal}
className={styles.button}
>
{lang('MessagePremiumUnlock')}
{
paidMessagesStars
? (
<>
{lang('ButtonBuyStars')}
<Sparkles preset="button" />
</>
)
: oldLang('MessagePremiumUnlock')
}
</Button>
</div>
</div>
@ -68,5 +102,5 @@ export default memo(
patternColor,
userName: getUserFirstOrLastName(user),
};
})(PremiumRequiredMessage),
})(RequirementToContactMessage),
);

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import type { ChangeEvent, RefObject } from 'react';
import type { FC } from '../../../lib/teact/teact';
import type { FC, TeactNode } from '../../../lib/teact/teact';
import React, {
getIsHeavyAnimating,
memo, useEffect, useLayoutEffect,
@ -58,7 +58,7 @@ type OwnProps = {
isReady: boolean;
isActive: boolean;
getHtml: Signal<string>;
placeholder: string;
placeholder: TeactNode | string;
timedPlaceholderLangKey?: string;
timedPlaceholderDate?: number;
forcedPlaceholder?: string;
@ -168,7 +168,7 @@ const MessageInput: FC<OwnProps & StateProps> = ({
// eslint-disable-next-line no-null/no-null
const absoluteContainerRef = useRef<HTMLDivElement>(null);
const lang = useOldLang();
const oldLang = useOldLang();
const isContextMenuOpenRef = useRef(false);
const [isTextFormatterOpen, openTextFormatter, closeTextFormatter] = useFlag();
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 placeholderAriaLabel = typeof placeholder === 'string' ? placeholder : undefined;
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
className={buildClassName('custom-scroll', SCROLLER_CLASS, isNeedPremium && 'is-need-premium')}
onScroll={onScroll}
@ -584,7 +585,7 @@ const MessageInput: FC<OwnProps & StateProps> = ({
onMouseDown={handleMouseDown}
onContextMenu={IS_ANDROID ? handleAndroidContextMenu : undefined}
onTouchCancel={IS_ANDROID ? processSelectionWithTimeout : undefined}
aria-label={placeholder}
aria-label={placeholderAriaLabel}
onFocus={!isNeedPremium ? onFocus : undefined}
onBlur={!isNeedPremium ? onBlur : undefined}
/>
@ -604,7 +605,7 @@ const MessageInput: FC<OwnProps & StateProps> = ({
) : placeholder}
{isStoryInput && isNeedPremium && (
<Button className="unlock-button" size="tiny" color="adaptive" onClick={handleOpenPremiumModal}>
{lang('StoryRepliesLockedButton')}
{oldLang('StoryRepliesLockedButton')}
</Button>
)}
</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 (
nextMessage.id === firstUnreadId
|| message.senderId !== nextMessage.senderId
|| message.paidMessageStars
|| message.isOutgoing !== nextMessage.isOutgoing
|| message.postAuthorTitle !== nextMessage.postAuthorTitle
|| (isActionMessage(message) && message.content.action?.type !== 'phoneCall')

View File

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

View File

@ -14,6 +14,7 @@
cursor: var(--custom-cursor, pointer);
user-select: none;
.message-price,
.message-time,
.message-imported,
.message-signature,
@ -26,6 +27,22 @@
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 {
display: flex;
align-items: center;

View File

@ -8,6 +8,7 @@ import type {
import buildClassName from '../../../util/buildClassName';
import { formatDateTimeToString, formatPastTimeShort, formatTime } from '../../../util/dates/dateFormat';
import { formatStarsAsIcon } from '../../../util/localization/format';
import { formatIntegerCompact } from '../../../util/textFormat';
import renderText from '../../common/helpers/renderText';
@ -38,6 +39,7 @@ type OwnProps = {
onEffectClick: (e: React.MouseEvent<HTMLDivElement>) => void;
renderQuickReactionButton?: () => TeactNode | undefined;
onOpenThread: NoneToVoidFunction;
paidMessageStars?: number;
};
const MessageMeta: FC<OwnProps> = ({
@ -56,6 +58,7 @@ const MessageMeta: FC<OwnProps> = ({
onTranslationClick,
onEffectClick,
onOpenThread,
paidMessageStars,
}) => {
const { showNotification } = getActions();
@ -176,6 +179,16 @@ const MessageMeta: FC<OwnProps> = ({
{signature && (
<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}>
{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,
} from '../../../api/types';
import { getPeerTitle } from '../../../global/helpers';
import {
getPeerTitle,
} from '../../../global/helpers';
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 buildStyle from '../../../util/buildStyle';
import { formatCurrency } from '../../../util/formatCurrency';
@ -49,6 +54,7 @@ export type StateProps = {
currentUserId?: string;
isPaymentFormLoading?: boolean;
starBalance?: ApiStarsAmount;
paidMessagesStars?: number;
};
const LIMIT_DISPLAY_THRESHOLD = 50;
@ -67,6 +73,7 @@ function GiftComposer({
currentUserId,
isPaymentFormLoading,
starBalance,
paidMessagesStars,
}: OwnProps & StateProps) {
const {
sendStarGift, sendPremiumGiftByStars, openInvoice, openGiftUpgradeModal, openStarsBalanceModal,
@ -202,14 +209,19 @@ function GiftComposer({
const title = getPeerTitle(lang, peer!)!;
return (
<div className={styles.optionsSection}>
<TextArea
className={styles.messageInput}
onChange={handleGiftMessageChange}
value={giftMessage}
label={lang('GiftMessagePlaceholder')}
maxLength={captionLimit}
maxLengthIndicator={symbolsLeft && symbolsLeft < LIMIT_DISPLAY_THRESHOLD ? symbolsLeft.toString() : undefined}
/>
{!paidMessagesStars && (
<TextArea
className={styles.messageInput}
onChange={handleGiftMessageChange}
value={giftMessage}
label={lang('GiftMessagePlaceholder')}
maxLength={captionLimit}
maxLengthIndicator={
symbolsLeft && symbolsLeft < LIMIT_DISPLAY_THRESHOLD ? symbolsLeft.toString() : undefined
}
/>
)}
{canUseStarsPayment && (
<ListItem className={styles.switcher} narrow ripple onClick={toggleShouldPayByStars}>
@ -377,6 +389,7 @@ export default memo(withGlobal<OwnProps>(
backgroundColor,
} = global.settings.themes[theme] || {};
const peer = selectPeer(global, peerId);
const paidMessagesStars = selectPeerPaidMessagesStars(global, peerId);
const tabState = selectTabState(global);
@ -391,6 +404,7 @@ export default memo(withGlobal<OwnProps>(
captionLimit: global.appConfig?.starGiftMaxMessageLength,
currentUserId: global.currentUserId,
isPaymentFormLoading: tabState.isPaymentFormLoading,
paidMessagesStars,
};
},
)(GiftComposer));

View File

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

View File

@ -2,45 +2,78 @@ import React, {
type FC,
memo, useEffect,
} from '../../../lib/teact/teact';
import { getActions, getGlobal } from '../../../global';
import {
getActions, getGlobal, withGlobal,
} from '../../../global';
import type { TabState } from '../../../global/types';
import type { ThreadId } from '../../../types';
import { MAIN_THREAD_ID } from '../../../api/types';
import { getPeerTitle } from '../../../global/helpers';
import { selectPeer } from '../../../global/selectors';
import {
getPeerTitle,
} from '../../../global/helpers';
import {
selectPeer, selectTabState,
} from '../../../global/selectors';
import useFlag from '../../../hooks/useFlag';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
import usePaidMessageConfirmation from '../../middle/composer/hooks/usePaidMessageConfirmation';
import PaymentMessageConfirmDialog from '../../common/PaymentMessageConfirmDialog';
import RecipientPicker from '../../common/RecipientPicker';
export type OwnProps = {
modal: TabState['sharePreparedMessageModal'];
};
const SharePreparedMessageModal: FC<OwnProps> = ({
modal,
type StateProps = {
isPaymentMessageConfirmDialogOpen: boolean;
};
export type SendParams = {
peerName?: string;
starsForSendMessage: number;
};
const SharePreparedMessageModal: FC<OwnProps & StateProps> = ({
modal, isPaymentMessageConfirmDialogOpen,
}) => {
const {
closeSharePreparedMessageModal,
sendInlineBotResult,
sendWebAppEvent,
showNotification,
updateSharePreparedMessageModalSendArgs,
} = getActions();
const lang = useOldLang();
const isOpen = Boolean(modal);
const [isShown, markIsShown, unmarkIsShown] = useFlag();
useEffect(() => {
if (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(() => {
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) {
const global = getGlobal();
const peer = selectPeer(global, id);
@ -65,33 +98,82 @@ const SharePreparedMessageModal: FC<OwnProps> = ({
id: message.result.id,
queryId: message.result.queryId,
});
if (!starsForSendMessage) {
showNotification({
message: lang('BotSharedToOne', getPeerTitle(lang, peer!)),
});
}
sendWebAppEvent({
webAppKey,
event: {
eventType: 'prepared_message_sent',
},
});
showNotification({
message: lang('BotSharedToOne', getPeerTitle(lang, peer!)),
});
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) {
return undefined;
}
return (
<RecipientPicker
isOpen={isOpen}
searchPlaceholder={lang('Search')}
filter={filter}
onSelectRecipient={handleSelectRecipient}
onClose={handleClose}
onCloseAnimationEnd={unmarkIsShown}
/>
<>
<RecipientPicker
isOpen={isOpen}
searchPlaceholder={lang('Search')}
filter={filter}
onSelectRecipient={handleSelectRecipient}
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 { RegularLangKey } from '../../../types/language';
import { PAID_MESSAGES_PURPOSE } from '../../../config';
import { getChatTitle, getPeerTitle, getUserFullName } from '../../../global/helpers';
import { selectChat, selectIsPremiumPurchaseBlocked, selectUser } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
@ -108,6 +109,13 @@ const StarsBalanceModal = ({
return oldLang('StarsNeededTextLink');
}
if (topup?.purpose === PAID_MESSAGES_PURPOSE) {
return lang('StarsNeededTextSendPaidMessages', undefined, {
withMarkdown: true,
withNodes: true,
});
}
return undefined;
}, [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 { buildStarsTransactionCustomPeer } from '../../../../global/helpers/payments';
import {
type LangFn,
} from '../../../../util/localization';
import { formatPercent } from '../../../../util/textFormat';
export function getTransactionTitle(lang: OldLangFn, transaction: ApiStarsTransaction) {
if (transaction.starRefCommision) {
return lang('StarTransactionCommission', formatPercent(transaction.starRefCommision));
export function getTransactionTitle(oldLang: OldLangFn, lang: LangFn, transaction: ApiStarsTransaction) {
if (transaction.paidMessages) {
return lang(
'PaidMessageTransaction',
{ count: transaction.paidMessages },
{
withNodes: true,
pluralValue: transaction.paidMessages,
},
);
}
if (transaction.isGiftUpgrade) return lang('Gift2TransactionUpgraded');
if (transaction.extendedMedia) return lang('StarMediaPurchase');
if (transaction.subscriptionPeriod) return transaction.title || lang('StarSubscriptionPurchase');
if (transaction.isReaction) return lang('StarsReactionsSent');
if (transaction.giveawayPostId) return lang('StarsGiveawayPrizeReceived');
if (transaction.isMyGift) return lang('StarsGiftSent');
if (transaction.isGift) return lang('StarsGiftReceived');
if (transaction.starRefCommision) {
return oldLang('StarTransactionCommission', formatPercent(transaction.starRefCommision));
}
if (transaction.isGiftUpgrade) return oldLang('Gift2TransactionUpgraded');
if (transaction.extendedMedia) return oldLang('StarMediaPurchase');
if (transaction.subscriptionPeriod) return transaction.title || oldLang('StarSubscriptionPurchase');
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) {
return isNegativeStarsAmount(transaction.stars) ? lang('Gift2TransactionSent') : lang('Gift2ConvertedTitle');
return isNegativeStarsAmount(transaction.stars) ? oldLang('Gift2TransactionSent') : oldLang('Gift2ConvertedTitle');
}
const customPeer = (transaction.peer && transaction.peer.type !== 'peer'
&& buildStarsTransactionCustomPeer(transaction.peer)) || undefined;
if (customPeer) return customPeer.title || lang(customPeer.titleKey!);
if (customPeer) return customPeer.title || oldLang(customPeer.titleKey!);
return transaction.title;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -22,6 +22,7 @@ import useShowTransitionDeprecated from '../../hooks/useShowTransitionDeprecated
import CustomEmoji from '../common/CustomEmoji';
import Icon from '../common/icons/Icon';
import StarIcon from '../common/icons/StarIcon';
import Button from './Button';
import Portal from './Portal';
import RoundTimer from './RoundTimer';
@ -54,6 +55,7 @@ const Notification: FC<OwnProps> = ({
dismissAction,
duration = DEFAULT_DURATION,
icon,
shouldUseCustomIcon,
customEmojiIconId,
shouldShowTimer,
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(() => {
if (action) {
if (Array.isArray(action)) {
@ -151,6 +170,28 @@ const Notification: FC<OwnProps> = ({
return 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 (
<Portal className="Notification-container" containerSelector={containerSelector}>
<div
@ -159,26 +200,17 @@ const Notification: FC<OwnProps> = ({
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{customEmojiIconId ? (
<CustomEmoji
className="notification-emoji-icon"
forceAlways
size={CUSTOM_EMOJI_SIZE}
documentId={customEmojiIconId}
/>
) : (
<Icon name={icon || 'info-filled'} className="notification-icon" />
)}
{renderedIcon}
<div className="content">
{renderedTitle && (
<div className="notification-title">{renderedTitle}</div>
)}
{renderedMessage}
</div>
{action && renderedActionText && (
{renderedActionText && (
<Button
color="translucent-white"
onClick={handleClick}
onClick={handleActionClick}
className="notification-button"
>
{renderedActionText}

View File

@ -1,6 +1,6 @@
.root {
position: relative;
color: var(--color-primary);
color: var(--color-toast-action);
font-weight: var(--font-weight-medium);
}
@ -12,7 +12,7 @@
}
.circle {
stroke: var(--color-primary);
stroke: var(--color-toast-action);
fill: transparent;
stroke-width: 2;
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_BETA = process.env.APP_ENV === 'staging';
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_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 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_PLAYBACK_RATE = 1;
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_WAVE_MIN_INTERVAL = 200;
export const MESSAGE_APPEARANCE_DELAY = 10;
export const PAID_SEND_DELAY = 5000;
export const SCROLL_MIN_DURATION = 300;
export const SCROLL_MAX_DURATION = 600;

View File

@ -14,7 +14,7 @@ import {
} from '../../../api/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 { getCurrentTabId } from '../../../util/establishMultitabRole';
import { oldTranslate } from '../../../util/oldLangProvider';
@ -62,6 +62,7 @@ import {
selectUserFullInfo,
} from '../../selectors';
import { fetchChatByUsername } from './chats';
import { getPeerStarsForMessage } from './messages';
import { getIsWebAppsFullscreenSupported } from '../../../hooks/useAppLayout';
@ -379,7 +380,26 @@ addActionHandler('switchBotInline', (global, actions, payload): ActionReturnType
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 {
id, queryId, isSilent, scheduledAt, threadId, chatId,
tabId = getCurrentTabId(),
@ -396,14 +416,39 @@ addActionHandler('sendInlineBotResult', (global, actions, payload): ActionReturn
actions.resetDraftReplyInfo({ tabId });
actions.clearWebPagePreview({ tabId });
void callApi('sendInlineBotResult', {
const starsForOneMessage = await getPeerStarsForMessage(global, chat);
const params = {
chat,
resultId: id,
id,
queryId,
replyInfo,
sendAs: selectSendAs(global, chatId),
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,
ApiError,
ApiInputMessageReplyInfo,
ApiInputReplyInfo,
ApiInputStoryReplyInfo,
ApiMessage,
ApiMessageEntity,
ApiNewPoll,
ApiOnProgress,
ApiPeer,
ApiSticker,
ApiStory,
ApiStorySkipped,
ApiUser,
ApiVideo,
} from '../../../api/types';
import type {
ForwardMessagesParams,
SendMessageParams,
ThreadId,
} from '../../../types';
import type { MessageKey } from '../../../util/keys/messageKey';
import type { RegularLangFnParameters } from '../../../util/localization';
import type { RequiredGlobalActions } from '../../index';
import type {
ActionReturnType, GlobalState, TabArgs,
} from '../../types';
import { MAIN_THREAD_ID, MESSAGE_DELETED } from '../../../api/types';
import { LoadMoreDirection, type ThreadId, type WebPageMediaSize } from '../../../types';
import { LoadMoreDirection } from '../../../types';
import {
GIF_MIME_TYPE,
MAX_MEDIA_FILES_FOR_ALBUM,
MESSAGE_ID_REQUIRED_ERROR,
MESSAGE_LIST_SLICE,
RE_TELEGRAM_LINK,
PAID_SEND_DELAY, RE_TELEGRAM_LINK,
SERVICE_NOTIFICATIONS_USER_ID,
SUPPORTED_AUDIO_CONTENT_TYPES,
SUPPORTED_PHOTO_CONTENT_TYPES,
@ -61,6 +61,7 @@ import {
isChatSuperGroup,
isDeletedUser,
isMessageLocal,
isPeerUser,
isServiceNotificationMessage,
isUserBot,
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 { storyId, peerId: storyPeerId } = selectCurrentViewedStory(global, tabId);
const isStoryReply = Boolean(storyId && storyPeerId);
if (!messageList && !isStoryReply) {
return undefined;
return;
}
let { chatId, threadId, type } = messageList || {};
@ -321,9 +322,11 @@ addActionHandler('sendMessage', (global, actions, payload): ActionReturnType =>
payload = omit(payload, ['tabId']);
if (type === 'scheduled' && !payload.scheduledAt) {
return updateTabState(global, {
global = updateTabState(global, {
contentToBeScheduled: payload,
}, tabId);
setGlobal(global);
return;
}
const chat = selectChat(global, chatId!)!;
@ -342,30 +345,36 @@ addActionHandler('sendMessage', (global, actions, payload): ActionReturnType =>
const replyInfo = storyReplyInfo || messageReplyInfo;
const lastMessageId = selectChatLastMessageId(global, chatId!);
const messagePriceInStars = await getPeerStarsForMessage(global, chat);
const params = {
const params : SendMessageParams = {
...payload,
chat,
replyInfo,
noWebPage: selectNoWebPage(global, chatId!, threadId!),
sendAs: selectSendAs(global, chatId!),
lastMessageId,
messagePriceInStars,
isStoryReply,
isPending: messagePriceInStars ? true : undefined,
};
if (!isStoryReply) {
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 localMessages: SendMessageParams[] = [];
if (isSingle) {
const { attachments, ...restParams } = params;
sendMessage(global, {
const sendParams: SendMessageParams = {
...restParams,
attachment: attachments ? attachments[0] : undefined,
wasDrafted: Boolean(draft),
});
};
await sendMessageOrReduceLocal(global, sendParams, localMessages);
} else if (isGrouped) {
const {
text, entities, attachments, ...commonParams
@ -373,7 +382,8 @@ addActionHandler('sendMessage', (global, actions, payload): ActionReturnType =>
const byType = splitAttachmentsByType(attachments!);
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);
for (let i = 0; i < groupedAttachments.length; i++) {
const groupedId = `${Date.now()}${groupIndex}${i}`;
@ -383,70 +393,86 @@ addActionHandler('sendMessage', (global, actions, payload): ActionReturnType =>
if (group[0].quick && !group[0].shouldSendAsFile) {
const [firstAttachment, ...restAttachments] = groupedAttachments[i];
sendMessage(global, {
let sendParams: SendMessageParams = {
...commonParams,
text: isFirst && !hasSentCaption ? text : undefined,
entities: isFirst && !hasSentCaption ? entities : undefined,
attachment: firstAttachment,
groupedId: restAttachments.length > 0 ? groupedId : undefined,
wasDrafted: Boolean(draft),
});
};
await sendMessageOrReduceLocal(global, sendParams, localMessages);
hasSentCaption = true;
restAttachments.forEach((attachment: ApiAttachment) => {
sendMessage(global, {
for (const attachment of restAttachments) {
sendParams = {
...commonParams,
attachment,
groupedId,
});
});
};
await sendMessageOrReduceLocal(global, sendParams, localMessages);
}
} else {
const firstAttachments = groupedAttachments[i].slice(0, -1);
const lastAttachment = groupedAttachments[i][groupedAttachments[i].length - 1];
firstAttachments.forEach((attachment: ApiAttachment) => {
sendMessage(global, {
for (const attachment of firstAttachments) {
const sendParams = {
...commonParams,
attachment,
groupedId,
});
});
};
await sendMessageOrReduceLocal(global, sendParams, localMessages);
}
sendMessage(global, {
const sendParams = {
...commonParams,
text: isLast && !hasSentCaption ? text : undefined,
entities: isLast && !hasSentCaption ? entities : undefined,
attachment: lastAttachment,
groupedId: firstAttachments.length > 0 ? groupedId : undefined,
wasDrafted: Boolean(draft),
});
};
await sendMessageOrReduceLocal(global, sendParams, localMessages);
hasSentCaption = true;
}
}
});
}
} else {
const {
text, entities, attachments, replyInfo: replyToForFirstMessage, ...commonParams
} = params;
if (text) {
sendMessage(global, {
const sendParams = {
...commonParams,
text,
entities,
replyInfo: replyToForFirstMessage,
wasDrafted: Boolean(draft),
});
};
await sendMessageOrReduceLocal(global, sendParams, localMessages);
}
attachments?.forEach((attachment: ApiAttachment) => {
sendMessage(global, {
...commonParams,
attachment,
});
});
if (attachments) {
for (const attachment of attachments) {
const sendParams = {
...commonParams,
attachment,
};
await sendMessageOrReduceLocal(global, sendParams, localMessages);
}
}
}
return undefined;
if (isForwarding) {
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> => {
@ -735,21 +761,37 @@ addActionHandler('unpinAllMessages', async (global, actions, payload): Promise<v
});
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);
if (!currentMessageList) {
const messageList = payloadMessageList || currentMessageList;
if (!messageList) {
return;
}
const { chatId, threadId } = currentMessageList;
const { chatId, threadId } = messageList;
const chat = selectChat(global, chatId)!;
const messageIdsToDelete = messageIds.filter((id) => {
const message = selectChatMessage(global, chatId, id);
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
if (!messageIdsToDelete.length && messageIds.length) {
deleteMessages(global, isChatChannel(chat) ? chatId : undefined, messageIds, actions);
deleteMessages(global, isChatChannel(chat) || isChatSuperGroup(chat) ? chatId : undefined, messageIds, actions);
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 => {
const {
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> => {
const { chatId } = payload;
const chat = selectChat(global, chatId);
@ -1336,6 +1312,110 @@ addActionHandler('loadCustomEmojis', async (global, actions, payload): Promise<v
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>(
global: T,
chat: ApiChat,
@ -1525,26 +1605,49 @@ function getViewportSlice(
return { newViewportIds, areSomeLocal, areAllLocal };
}
async function sendMessage<T extends GlobalState>(global: T, params: {
chat: ApiChat;
text?: string;
entities?: ApiMessageEntity[];
replyInfo?: ApiInputReplyInfo;
attachment?: ApiAttachment;
sticker?: ApiSticker;
story?: ApiStory | ApiStorySkipped;
gif?: ApiVideo;
poll?: ApiNewPoll;
isSilent?: boolean;
scheduledAt?: number;
sendAs?: ApiPeer;
groupedId?: string;
wasDrafted?: boolean;
lastMessageId?: number;
isInvertedMedia?: true;
effectId?: string;
webPageMediaSize?: WebPageMediaSize;
}) {
export async function getPeerStarsForMessage<T extends GlobalState>(
global: T,
peer: ApiPeer,
): Promise<number | undefined> {
if (!isPeerUser(peer)) {
return peer.paidMessagesStars;
}
if (!peer?.paidMessagesStars) return undefined;
const fullInfo = selectUserFullInfo(global, peer.id);
if (fullInfo) {
return fullInfo?.paidMessagesStars;
}
const result = await callApi('fetchPaidMessagesStarsAmount', peer);
return result;
}
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;
const progressCallback = params.attachment ? (progress: number, messageKey: MessageKey) => {
if (!uploadProgressCallbacks.has(messageKey)) {
@ -1556,14 +1659,7 @@ async function sendMessage<T extends GlobalState>(global: T, params: {
global = updateUploadByMessageKey(global, messageKey, progress);
setGlobal(global);
} : undefined;
// @optimization
if (params.replyInfo || IS_IOS) {
await rafPromise();
}
await callApi('sendMessage', params, progressCallback);
if (progressCallback && currentMessageKey) {
global = getGlobal();
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> => {
const { chatId, threadId } = payload;
const chat = selectChat(global, chatId);

View File

@ -408,6 +408,7 @@ addActionHandler('loadPrivacySettings', async (global): Promise<void> => {
callApi('fetchPrivacySettings', 'bio'),
callApi('fetchPrivacySettings', 'birthday'),
callApi('fetchPrivacySettings', 'gifts'),
callApi('fetchPrivacySettings', 'noPaidMessages'),
]);
if (result.some((e) => e === undefined)) {
@ -427,6 +428,7 @@ addActionHandler('loadPrivacySettings', async (global): Promise<void> => {
bioSettings,
birthdaySettings,
giftsSettings,
noPaidMessagesSettings,
] = result as {
rules: ApiPrivacySettings;
}[];
@ -450,6 +452,7 @@ addActionHandler('loadPrivacySettings', async (global): Promise<void> => {
bio: bioSettings.rules,
birthday: birthdaySettings.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 shouldNewNonContactPeersRequirePremium = payload.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);
const result = await callApi('updateGlobalPrivacySettings', {
shouldArchiveAndMuteNewNonContact,
shouldHideReadMarks,
shouldNewNonContactPeersRequirePremium,
nonContactPeersPaidStars,
});
global = getGlobal();
@ -722,6 +735,9 @@ addActionHandler('updateGlobalPrivacySettings', async (global, actions, payload)
shouldNewNonContactPeersRequirePremium: !result
? !shouldNewNonContactPeersRequirePremium
: result.shouldNewNonContactPeersRequirePremium,
nonContactPeersPaidStars: !result
? undefined
: result.nonContactPeersPaidStars,
});
setGlobal(global);
});

View File

@ -184,6 +184,26 @@ addActionHandler('loadCommonChats', async (global, actions, payload): Promise<vo
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> => {
const {
userId, isMuted = false, firstName, lastName, shouldSharePhoneNumber,

View File

@ -62,6 +62,7 @@ import {
selectIsRightColumnShown,
selectIsViewportNewest,
selectMessageIdsByGroupId,
selectPeer,
selectPinnedIds,
selectReplyStack,
selectRequestedChatTranslationLanguage,
@ -71,6 +72,7 @@ import {
selectThreadInfo,
selectViewportIds,
} from '../../selectors';
import { getPeerStarsForMessage } from '../api/messages';
import { getIsMobile } from '../../../hooks/useAppLayout';
@ -1093,3 +1095,39 @@ addActionHandler('closeSharePreparedMessageModal', (global, actions, payload): A
sharePreparedMessageModal: undefined,
}, 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);
});
addActionHandler('setPaidMessageAutoApprove', (global): ActionReturnType => {
global = {
...global,
settings: {
...global.settings,
byKey: {
...global.settings.byKey,
shouldPaidMessageAutoApprove: true,
},
},
};
return global;
});
addActionHandler('setReactionEffect', (global, actions, payload): ActionReturnType => {
const {
chatId, threadId, reaction, tabId = getCurrentTabId(),

View File

@ -135,3 +135,19 @@ addActionHandler('resetGiftProfileFilter', (global, actions, payload): ActionRet
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 { updateTabState } from '../../reducers/tabs';
import {
selectChat,
selectCurrentViewedStory,
selectPeer,
selectPeerFirstStoryId,
@ -18,6 +19,7 @@ import {
selectTabState,
} from '../../selectors';
import { fetchChatByUsername } from '../api/chats';
import { getPeerStarsForMessage } from '../api/messages';
addActionHandler('openStoryViewer', async (global, actions, payload): Promise<void> => {
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 { storyId, peerId: storyPeerId } = selectCurrentViewedStory(global, tabId);
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;
}

View File

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

View File

@ -73,7 +73,7 @@ import {
selectIsChatWithSelf,
selectRequestedChatTranslationLanguage,
} from './chats';
import { selectPeer } from './peers';
import { selectPeer, selectPeerPaidMessagesStars } from './peers';
import { selectPeerStory } from './stories';
import { selectIsStickerFavorite } from './symbols';
import { selectTabState } from './tabs';
@ -1331,12 +1331,21 @@ export function selectShouldSchedule<T extends GlobalState>(
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) {
const isChatWithSelf = selectIsChatWithSelf(global, id);
const chatBot = selectBot(global, id);
return Boolean(
!isChatWithSelf && !chatBot && isUserId(id) && selectUserStatus(global, id)?.wasOnline,
);
const paidMessagesStars = selectPeerPaidMessagesStars(global, id);
return Boolean(!paidMessagesStars
&& !isChatWithSelf && !chatBot && isUserId(id) && selectUserStatus(global, id)?.wasOnline);
}
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 { getCurrentTabId } from '../../util/establishMultitabRole';
import { isDeletedUser } from '../helpers';
import { isChatAdmin, isDeletedUser, isUserId } from '../helpers';
import { selectChat, selectChatFullInfo } from './chats';
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 {
return selectUser(global, peerId) || selectChat(global, peerId);
@ -34,3 +34,19 @@ export function selectPeerSavedGifts<T extends GlobalState>(
) : ApiSavedGifts {
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;
}
export function selectNonContactPeersPaidStars<T extends GlobalState>(global: T) {
return global.settings.byKey.nonContactPeersPaidStars;
}
export function selectShouldHideReadMarks<T extends GlobalState>(global: T) {
return global.settings.byKey.shouldHideReadMarks;
}

View File

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

View File

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

View File

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

View File

@ -1472,8 +1472,10 @@ account.toggleUsername#58d6b376 username:string active:Bool = Bool;
account.resolveBusinessChatLink#5492e5ee slug:string = account.ResolvedBusinessChatLinks;
account.toggleSponsoredMessages#b9d9a38d enabled:Bool = Bool;
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.getFullUser#b60f5918 id:InputUser = users.UserFull;
users.getRequirementsToContact#d89a83a3 id:Vector<InputUser> = Vector<RequirementToContact>;
contacts.getContacts#5dd69e12 hash:long = contacts.Contacts;
contacts.importContacts#2c800be5 contacts:Vector<InputContact> = contacts.ImportedContacts;
contacts.deleteContacts#96a0e00 id:Vector<InputUser> = Updates;

View File

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

View File

@ -103,6 +103,7 @@ $color-message-story-mention-to: #74bcff;
--color-voice-transcribe-button: #e8f3ff;
--color-voice-transcribe-button-own: #cceebf;
--color-toast-action: #64D1FF;
--color-primary: #{$color-primary};
--color-primary-shade: #{color.mix($color-primary, $color-black, 92%)};
--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 {
ApiAttachment,
ApiBotInlineMediaResult,
ApiBotInlineResult,
ApiBotInlineSwitchPm,
ApiBotInlineSwitchWebview,
ApiChat,
ApiChatInviteImporter,
ApiContact,
ApiDocument,
ApiDraft,
ApiExportedInvite,
ApiFakeType,
ApiFormattedText,
ApiInputReplyInfo,
ApiLabeledPrice,
ApiMediaFormat,
ApiMessage,
ApiMessageEntity,
ApiNewPoll,
ApiPeer,
ApiPhoto,
ApiReaction,
ApiReactionWithPaid,
ApiStarGiftRegular,
ApiStarsSubscription,
ApiStarsTransaction,
ApiSticker,
ApiStickerSet,
ApiStory,
ApiStorySkipped,
ApiThreadInfo,
ApiTopic,
ApiTypingStatus,
@ -114,6 +124,7 @@ export interface ISettings {
isConnectionStatusMinimized: boolean;
shouldArchiveAndMuteNewNonContact?: boolean;
shouldNewNonContactPeersRequirePremium?: boolean;
nonContactPeersPaidStars?: number;
shouldHideReadMarks?: boolean;
canTranslate: boolean;
canTranslateChats: boolean;
@ -126,6 +137,7 @@ export interface ISettings {
shouldDebugExportedSenders?: boolean;
shouldWarnAboutSvg?: boolean;
shouldSkipWebAppCloseConfirmation: boolean;
shouldPaidMessageAutoApprove: boolean;
hasContactJoinedNotifications?: boolean;
hasWebNotifications: boolean;
hasPushNotifications: boolean;
@ -191,6 +203,7 @@ export enum SettingsScreens {
PrivacyGroupChatsAllowedContacts,
PrivacyGroupChatsDeniedContacts,
PrivacyBlockedUsers,
PrivacyNoPaidMessages,
Performance,
Folders,
FoldersCreateFolder,
@ -653,3 +666,63 @@ export type GiftProfileFilterOptions = {
shouldIncludeDisplayed: 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;
'StarsGiftCompleted': 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> {
@ -2273,6 +2292,66 @@ export interface LangPairWithVariables<V extends unknown = LangVariable> {
'stars': 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 {
@ -2549,6 +2628,9 @@ export interface LangPairPluralWithVariables<V extends unknown = LangVariable> {
'from': V;
'count': V;
};
'PaidMessageTransaction': {
'count': V;
};
}
export type RegularLangKey = keyof LangPair;
export type RegularLangKeyWithVariables = keyof LangPairWithVariables;

View File

@ -12,11 +12,26 @@ export function formatStarsAsText(lang: LangFn, amount: number) {
return lang('StarsAmountText', { amount }, { pluralValue: amount });
}
export function formatStarsAsIcon(lang: LangFn, amount: number, options?: { asFont?: boolean; className?: string }) {
const { asFont, className } = options || {};
export function formatStarsAsIcon(lang: LangFn, amount: number, options?: {
asFont?: boolean; className?: string; containerClassName?: string; }) {
const { asFont, className, containerClassName } = options || {};
const icon = asFont
? <Icon name="star" className={buildClassName('star-amount-icon', className)} />
: <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 }, {
withNodes: true,
specialReplacement: {