diff --git a/src/api/gramjs/apiBuilders/appConfig.ts b/src/api/gramjs/apiBuilders/appConfig.ts index 587686e0d..11110debb 100644 --- a/src/api/gramjs/apiBuilders/appConfig.ts +++ b/src/api/gramjs/apiBuilders/appConfig.ts @@ -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, diff --git a/src/api/gramjs/apiBuilders/chats.ts b/src/api/gramjs/apiBuilders/chats.ts index 029fcadd2..ee5d57470 100644 --- a/src/api/gramjs/apiBuilders/chats.ts +++ b/src/api/gramjs/apiBuilders/chats.ts @@ -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(), }; } diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index be750d50e..00b51b10a 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -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 }), }; diff --git a/src/api/gramjs/apiBuilders/misc.ts b/src/api/gramjs/apiBuilders/misc.ts index 5d13e786c..eaff248ef 100644 --- a/src/api/gramjs/apiBuilders/misc.ts +++ b/src/api/gramjs/apiBuilders/misc.ts @@ -101,6 +101,8 @@ export function buildPrivacyKey(key: GramJs.TypePrivacyKey): ApiPrivacyKey | und return 'birthday'; case 'PrivacyKeyStarGiftsAutoSave': return 'gifts'; + case 'PrivacyKeyNoPaidMessages': + return 'noPaidMessages'; } return undefined; diff --git a/src/api/gramjs/apiBuilders/payments.ts b/src/api/gramjs/apiBuilders/payments.ts index a1afb5136..3ac4b68ed 100644 --- a/src/api/gramjs/apiBuilders/payments.ts +++ b/src/api/gramjs/apiBuilders/payments.ts @@ -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, }; } diff --git a/src/api/gramjs/apiBuilders/users.ts b/src/api/gramjs/apiBuilders/users.ts index d011f2017..19aec1766 100644 --- a/src/api/gramjs/apiBuilders/users.ts +++ b/src/api/gramjs/apiBuilders/users.ts @@ -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(), }; } diff --git a/src/api/gramjs/gramjsBuilders/index.ts b/src/api/gramjs/gramjsBuilders/index.ts index 16f2d4ce6..e47cd80a7 100644 --- a/src/api/gramjs/gramjsBuilders/index.ts +++ b/src/api/gramjs/gramjsBuilders/index.ts @@ -488,6 +488,9 @@ export function buildInputPrivacyKey(privacyKey: ApiPrivacyKey) { case 'gifts': return new GramJs.InputPrivacyKeyStarGiftsAutoSave(); + + case 'noPaidMessages': + return new GramJs.InputPrivacyKeyNoPaidMessages(); } return undefined; diff --git a/src/api/gramjs/methods/bots.ts b/src/api/gramjs/methods/bots.ts index d44112d16..1d4bb6482 100644 --- a/src/api/gramjs/methods/bots.ts +++ b/src/api/gramjs/methods/bots.ts @@ -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) }), })); } diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index ff3e1d290..335105a67 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -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; @@ -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 = {}; +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 = {}; + 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, diff --git a/src/api/gramjs/methods/settings.ts b/src/api/gramjs/methods/settings.ts index e721baaef..1fb36e8d9 100644 --- a/src/api/gramjs/methods/settings.ts +++ b/src/api/gramjs/methods/settings.ts @@ -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), }; } diff --git a/src/api/gramjs/methods/users.ts b/src/api/gramjs/methods/users.ts index 620e67896..49f900cfd 100644 --- a/src/api/gramjs/methods/users.ts +++ b/src/api/gramjs/methods/users.ts @@ -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, diff --git a/src/api/types/chats.ts b/src/api/types/chats.ts index 6e3ab466f..e5d1fbe5e 100644 --- a/src/api/types/chats.ts +++ b/src/api/types/chats.ts @@ -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; diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 7aaab9825..deccb8126 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -585,6 +585,7 @@ export interface ApiMessage { isVideoProcessingPending?: true; areReactionsPossible?: true; reportDeliveryUntilDate?: number; + paidMessageStars?: number; } export interface ApiReactions { diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index 37f1adc61..9652f73ae 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -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; diff --git a/src/api/types/settings.ts b/src/api/types/settings.ts index 12a54ff24..a8bfbe224 100644 --- a/src/api/types/settings.ts +++ b/src/api/types/settings.ts @@ -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'; diff --git a/src/api/types/stars.ts b/src/api/types/stars.ts index 4e07ac662..01dfc111d 100644 --- a/src/api/types/stars.ts +++ b/src/api/types/stars.ts @@ -180,6 +180,7 @@ export interface ApiStarsTransaction { subscriptionPeriod?: number; starRefCommision?: number; isGiftUpgrade?: true; + paidMessages?: number; } export interface ApiStarsSubscription { diff --git a/src/api/types/users.ts b/src/api/types/users.ts index 62d6de2a9..281ef33ac 100644 --- a/src/api/types/users.ts +++ b/src/api/types/users.ts @@ -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; } diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index c7d86685e..37c4a2f92 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -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"; \ No newline at end of file diff --git a/src/components/common/Composer.scss b/src/components/common/Composer.scss index e31af8474..7160c7547 100644 --- a/src/components/common/Composer.scss +++ b/src/components/common/Composer.scss @@ -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; diff --git a/src/components/common/Composer.tsx b/src/components/common/Composer.tsx index 3852a14e3..6e705ee4d 100644 --- a/src/components/common/Composer.tsx +++ b/src/components/common/Composer.tsx @@ -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 = ({ isSelectModeActive, isReactionPickerOpen, isForwarding, + forwardedMessagesCount, pollModal, botKeyboardMessageId, botKeyboardPlaceholder, @@ -382,6 +393,7 @@ const Composer: FC = ({ webPagePreview, noWebPage, isContactRequirePremium, + paidMessagesStars, effect, effectReactions, areEffectsSupported, @@ -393,12 +405,12 @@ const Composer: FC = ({ onFocus, onBlur, onForward, + isPaymentMessageConfirmDialogOpen, }) => { const { sendMessage, clearDraft, showDialog, - forwardMessages, openPollModal, closePollModal, loadScheduledHistory, @@ -427,6 +439,8 @@ const Composer: FC = ({ // eslint-disable-next-line no-null/no-null const inputRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const counterRef = useRef(null); // eslint-disable-next-line no-null/no-null const storyReactionRef = useRef(null); @@ -513,6 +527,22 @@ const Composer: FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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(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 = ({ } } - 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(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 = ({ 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 = ({ 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 = ({ 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(editableInputCssSelector); @@ -1331,6 +1400,10 @@ const Composer: FC = ({ } }); + const handlePollSendWithPaymentConfirmation = useLastCallback((poll: ApiNewPoll) => { + handleActionWithPaymentConfirmation(handlePollSend, poll); + }); + const sendSilent = useLastCallback((additionalArgs?: ScheduledMessageArgs) => { if (isInScheduledList) { requestCalendar((scheduledAt) => { @@ -1458,6 +1531,13 @@ const Composer: FC = ({ 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 = ({ 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 = ({ onForward?.(); break; case MainButtonState.Send: - void handleSend(); + handleSendWithConfirmation(); break; case MainButtonState.Record: { if (areVoiceMessagesNotAllowed) { @@ -1586,7 +1666,7 @@ const Composer: FC = ({ entities = customEmojiMessage.entities; } - sendMessage({ text, entities, isReaction: true }); + handleActionWithPaymentConfirmation(sendMessage, { text, entities, isReaction: true }); closeReactionPicker(); }); @@ -1622,24 +1702,29 @@ const Composer: FC = ({ }); 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 = ({ 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 (
{isInMessageList && canAttachMedia && isReady && ( @@ -1702,7 +1789,8 @@ const Composer: FC = ({ 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 = ({ editingMessage={editingMessage} onSendWhenOnline={handleSendWhenOnline} canScheduleUntilOnline={canScheduleUntilOnline && !isViewOnceEnabled} + paidMessagesStars={paidMessagesStars} /> = ({ {onForward && } {isInMessageList && } {isInMessageList && } + {effectEmoji && ( @@ -2125,7 +2230,7 @@ const Composer: FC = ({ {canShowCustomSendMenu && ( = ({ /> )} {calendar} +
); }; @@ -2161,12 +2276,18 @@ export default memo(withGlobal( 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( 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( 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( canPlayEffect, shouldPlayEffect, maxMessageLength, + paidMessagesStars, + shouldPaidMessageAutoApprove, isSilentPosting, + isPaymentMessageConfirmDialogOpen: tabState.isPaymentMessageConfirmDialogOpen, }; }, )(Composer)); diff --git a/src/components/common/PaymentMessageConfirmDialog.module.scss b/src/components/common/PaymentMessageConfirmDialog.module.scss new file mode 100644 index 000000000..028d989cd --- /dev/null +++ b/src/components/common/PaymentMessageConfirmDialog.module.scss @@ -0,0 +1,5 @@ +.checkBox { + margin-top: 0.375rem; + margin-inline: -1.125rem; + padding-inline-start: 3.5rem; +} diff --git a/src/components/common/PaymentMessageConfirmDialog.tsx b/src/components/common/PaymentMessageConfirmDialog.tsx new file mode 100644 index 000000000..e6230e5cc --- /dev/null +++ b/src/components/common/PaymentMessageConfirmDialog.tsx @@ -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; + confirmHandler: NoneToVoidFunction; +}; + +const PaymentMessageConfirmDialog: FC = ({ + 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 ( + + {confirmPaymentMessage} + + + ); +}; + +export default memo(PaymentMessageConfirmDialog); diff --git a/src/components/common/RecipientPicker.tsx b/src/components/common/RecipientPicker.tsx index 87df0031c..69af6832a 100644 --- a/src/components/common/RecipientPicker.tsx +++ b/src/components/common/RecipientPicker.tsx @@ -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 = ({ onSelectRecipient, onClose, onCloseAnimationEnd, + isLowStackPriority, }) => { const [search, setSearch] = useState(''); const ids = useMemo(() => { @@ -112,6 +114,7 @@ const RecipientPicker: FC = ({ onSelectChatOrUser={onSelectRecipient} onClose={onClose} onCloseAnimationEnd={onCloseAnimationEnd} + isLowStackPriority={isLowStackPriority} /> ); }; diff --git a/src/components/common/StickerSetModal.tsx b/src/components/common/StickerSetModal.tsx index c89920657..c7382d305 100644 --- a/src/components/common/StickerSetModal.tsx +++ b/src/components/common/StickerSetModal.tsx @@ -16,6 +16,7 @@ import { selectCurrentMessageList, selectIsChatWithSelf, selectIsCurrentUserPremium, + selectPeerPaidMessagesStars, selectShouldSchedule, selectStickerSet, selectThreadInfo, @@ -276,12 +277,13 @@ export default memo(withGlobal( : 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, diff --git a/src/components/common/pickers/ChatOrUserPicker.tsx b/src/components/common/pickers/ChatOrUserPicker.tsx index 176cd8e45..698bff2fc 100644 --- a/src/components/common/pickers/ChatOrUserPicker.tsx +++ b/src/components/common/pickers/ChatOrUserPicker.tsx @@ -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 = ({ onSelectChatOrUser, onClose, onCloseAnimationEnd, + isLowStackPriority, }) => { const { loadTopics } = getActions(); @@ -323,6 +325,7 @@ const ChatOrUserPicker: FC = ({ className={buildClassName('ChatOrUserPicker', className)} onClose={onClose} onCloseAnimationEnd={onCloseAnimationEnd} + isLowStackPriority={isLowStackPriority} > {() => { diff --git a/src/components/left/LeftColumn.tsx b/src/components/left/LeftColumn.tsx index fc10126ce..40357ac93 100644 --- a/src/components/left/LeftColumn.tsx +++ b/src/components/left/LeftColumn.tsx @@ -339,6 +339,11 @@ function LeftColumn({ case SettingsScreens.DoNotTranslate: setSettingsScreen(SettingsScreens.Language); return; + + case SettingsScreens.PrivacyNoPaidMessages: + setSettingsScreen(SettingsScreens.PrivacyMessages); + return; + default: break; } diff --git a/src/components/left/settings/PrivacyLockedOption.module.scss b/src/components/left/settings/PrivacyLockedOption.module.scss index be9e90ade..a9e452d90 100644 --- a/src/components/left/settings/PrivacyLockedOption.module.scss +++ b/src/components/left/settings/PrivacyLockedOption.module.scss @@ -1,4 +1,4 @@ -.contacts_and_premium_option-title { +.root { cursor: pointer; } diff --git a/src/components/left/settings/PrivacyLockedOption.tsx b/src/components/left/settings/PrivacyLockedOption.tsx index 26c2a5eb1..c23eca3f7 100644 --- a/src/components/left/settings/PrivacyLockedOption.tsx +++ b/src/components/left/settings/PrivacyLockedOption.tsx @@ -17,7 +17,7 @@ function PrivacyLockedOption({ label }: OwnProps) { return (
showNotification({ message: lang('OptionPremiumRequiredMessage') })} > {label} diff --git a/src/components/left/settings/PrivacyMessages.tsx b/src/components/left/settings/PrivacyMessages.tsx index 9f85359ff..fae81b552 100644 --- a/src/components/left/settings/PrivacyMessages.tsx +++ b/src/components/left/settings/PrivacyMessages.tsx @@ -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(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') ) : ( - + ), - hidden: !canChange, + hidden: !canChangeForContactsAndPremium, + }, + { + value: 'charge_for_messages', + label: canChangeChargeForMessages ? ( + 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 ( +
+

+ {lang('SectionTitleStarsForForMessages')} +

+ +

+ {lang('SectionDescriptionStarsForForMessages', { + percent: starsPaidMessageCommissionPermille * 100, + amount: formatCurrencyAsString( + chargeForMessages * starsUsdWithdrawRate * starsPaidMessageCommissionPermille, + 'USD', + lang.code, + + ), + }, { + withNodes: true, + })} +

+
+ ); + } + + function renderSectionNoPaidMessagesForUsers() { + const itemSubtitle = !noPaidReactionsForUsersCount ? lang('SubtitlePrivacyAddUsers') + : oldLang('Users', noPaidReactionsForUsersCount, 'i'); + + return ( +
+

+ {lang('RemoveFeeTitle')} +

+ { + onScreenSelect(SettingsScreens.PrivacyNoPaidMessages); + }} + > +
+ {lang('ExceptionTitlePrivacyChargeForMessages')} + { + itemSubtitle + } + +
+
+
+ ); + } + 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 ( <>
-

- {lang('PrivacyMessagesTitle')} +

+ {oldLang('PrivacyMessagesTitle')}

-

- {lang('Privacy.Messages.SectionFooter')} +

+ {privacyDescription}

- {!canChange && } + {canChangeChargeForMessages + && selectedValue === 'charge_for_messages' && renderSectionStarsAmountForPaidMessages()} + {canChangeChargeForMessages && selectedValue === 'charge_for_messages' && renderSectionNoPaidMessagesForUsers()} + {!isCurrentUserPremium && } ); } export default memo(withGlobal((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)); diff --git a/src/components/left/settings/Settings.tsx b/src/components/left/settings/Settings.tsx index 381bb4a8e..021c4e8c7 100644 --- a/src/components/left/settings/Settings.tsx +++ b/src/components/left/settings/Settings.tsx @@ -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 = ({ [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 = ({ case SettingsScreens.PrivacyForwardingAllowedContacts: case SettingsScreens.PrivacyVoiceMessagesAllowedContacts: case SettingsScreens.PrivacyGroupChatsAllowedContacts: + case SettingsScreens.PrivacyNoPaidMessages: return ( @@ -396,7 +402,6 @@ const Settings: FC = ({ return ( @@ -407,6 +412,7 @@ const Settings: FC = ({ ); diff --git a/src/components/left/settings/SettingsHeader.tsx b/src/components/left/settings/SettingsHeader.tsx index 447bca784..6172d088d 100644 --- a/src/components/left/settings/SettingsHeader.tsx +++ b/src/components/left/settings/SettingsHeader.tsx @@ -163,6 +163,9 @@ const SettingsHeader: FC = ({ case SettingsScreens.PrivacyPhoneP2PDeniedContacts: return

{oldLang('NeverAllow')}

; + case SettingsScreens.PrivacyNoPaidMessages: + return

{lang('RemoveFeeTitle')}

; + case SettingsScreens.Performance: return

{lang('MenuAnimations')}

; diff --git a/src/components/left/settings/SettingsPrivacy.tsx b/src/components/left/settings/SettingsPrivacy.tsx index aa52a5e43..0a02b210a 100644 --- a/src/components/left/settings/SettingsPrivacy.tsx +++ b/src/components/left/settings/SettingsPrivacy.tsx @@ -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 = ({ canDisplayAutoarchiveSetting, shouldArchiveAndMuteNewNonContact, shouldNewNonContactPeersRequirePremium, + shouldChargeForMessages, canDisplayChatInTitle, canSetPasscode, privacy, @@ -332,9 +334,10 @@ const SettingsPrivacy: FC = ({
{oldLang('PrivacyMessagesTitle')} - {shouldNewNonContactPeersRequirePremium - ? oldLang('PrivacyMessagesContactsAndPremium') - : oldLang('P2PEverybody')} + {shouldChargeForMessages ? lang('PrivacyPaidMessagesValue') + : shouldNewNonContactPeersRequirePremium + ? oldLang('PrivacyMessagesContactsAndPremium') + : oldLang('P2PEverybody')}
@@ -402,7 +405,7 @@ export default memo(withGlobal( settings: { byKey: { hasPassword, isSensitiveEnabled, canChangeSensitive, shouldArchiveAndMuteNewNonContact, - canDisplayChatInTitle, shouldNewNonContactPeersRequirePremium, + canDisplayChatInTitle, shouldNewNonContactPeersRequirePremium, nonContactPeersPaidStars, }, privacy, }, @@ -413,6 +416,8 @@ export default memo(withGlobal( appConfig, } = global; + const shouldChargeForMessages = Boolean(nonContactPeersPaidStars); + return { isCurrentUserPremium: selectIsCurrentUserPremium(global), hasPassword, @@ -424,6 +429,7 @@ export default memo(withGlobal( shouldArchiveAndMuteNewNonContact, canChangeSensitive, shouldNewNonContactPeersRequirePremium, + shouldChargeForMessages, privacy, canDisplayChatInTitle, canSetPasscode: selectCanSetPasscode(global), diff --git a/src/components/left/settings/SettingsPrivacyVisibilityExceptionList.tsx b/src/components/left/settings/SettingsPrivacyVisibilityExceptionList.tsx index 9cd3a74eb..5056cee37 100644 --- a/src/components/left/settings/SettingsPrivacyVisibilityExceptionList.tsx +++ b/src/components/left/settings/SettingsPrivacyVisibilityExceptionList.tsx @@ -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 = ({ isActive, currentUserId, settings, - onScreenSelect, + usersOnly = false, onReset, }) => { const { setPrivacySettings } = getActions(); @@ -120,7 +120,10 @@ const SettingsPrivacyVisibilityExceptionList: FC = ({ 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 = ({ ...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 = ({ : (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; diff --git a/src/components/left/settings/helpers/privacy.ts b/src/components/left/settings/helpers/privacy.ts index 3cca7a76d..ca441a4be 100644 --- a/src/components/left/settings/helpers/privacy.ts +++ b/src/components/left/settings/helpers/privacy.ts @@ -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; diff --git a/src/components/main/Dialogs.tsx b/src/components/main/Dialogs.tsx index 4656f2892..dad18a65e 100644 --- a/src/components/main/Dialogs.tsx +++ b/src/components/main/Dialogs.tsx @@ -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 = ({ dialogs, currentMessageList }) => { } sendMessage({ - contact: pick(contactRequest, ['firstName', 'lastName', 'phoneNumber']), + contact: contactRequest, messageList: currentMessageList, }); closeModal(); diff --git a/src/components/main/Notifications.tsx b/src/components/main/Notifications.tsx index 3a0dca812..d781df924 100644 --- a/src/components/main/Notifications.tsx +++ b/src/components/main/Notifications.tsx @@ -21,7 +21,7 @@ const Notifications: FC = ({ notifications }) => { return (
{notifications.map((notification) => ( - + ))}
); diff --git a/src/components/middle/MessageList.tsx b/src/components/middle/MessageList.tsx index f9016d800..8258e4310 100644 --- a/src/components/middle/MessageList.tsx +++ b/src/components/middle/MessageList.tsx @@ -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 = ({ isServiceNotificationsChat, currentUserId, isContactRequirePremium, + paidMessagesStars, areAdsEnabled, channelJoinInfo, isChatProtected, @@ -689,8 +691,10 @@ const MessageList: FC = ({ {restrictionReason ? restrictionReason.text : `This is a private ${isChannelChat ? 'channel' : 'chat'}`}
+ ) : paidMessagesStars && isPrivate && !hasMessages && !shouldRenderGreeting ? ( + ) : isContactRequirePremium && !hasMessages ? ( - + ) : (isBot || isNonContact) && !hasMessages ? ( ) : shouldRenderGreeting ? ( diff --git a/src/components/middle/MessageListContent.tsx b/src/components/middle/MessageListContent.tsx index eca1c2cce..ff90b6157 100644 --- a/src/components/middle/MessageListContent.tsx +++ b/src/components/middle/MessageListContent.tsx @@ -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 = ({ ); const oldLang = useOldLang(); + const lang = useLang(); const unreadDivider = (
{oldLang('UnreadMessages')}
); + const renderPaidMessageAction = (message: ApiMessage, album?: IAlbum) => { + if (message.paidMessageStars) { + const messagesLength = album?.messages?.length || 1; + const amount = message.paidMessageStars * messagesLength; + return ( +
+ { + message.isOutgoing + ? lang('ActionPaidOneMessageOutgoing', { + amount, + }) + : (() => { + const sender = selectSender(getGlobal(), message); + const userTitle = sender ? getPeerTitle(lang, sender) : ''; + return lang('ActionPaidOneMessageIncoming', { + user: userTitle, + amount, + }); + })() + } + +
+ ); + } + return undefined; + }; const messageCountToAnimate = noAppearanceAnimation ? 0 : messageGroups.reduce((acc, messageGroup) => { return acc + messageGroup.senderGroups.flat().length; }, 0); @@ -228,6 +262,7 @@ const MessageListContent: FC = ({ return compact([ message.id === memoUnreadDividerBeforeIdRef.current && unreadDivider, + message.paidMessageStars && !withUsers && renderPaidMessageAction(message, album), .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; } diff --git a/src/components/middle/MiddleColumn.tsx b/src/components/middle/MiddleColumn.tsx index e9204d6ae..8de94a747 100644 --- a/src/components/middle/MiddleColumn.tsx +++ b/src/components/middle/MiddleColumn.tsx @@ -50,6 +50,7 @@ import { selectIsInSelectMode, selectIsRightColumnShown, selectIsUserBlocked, + selectPeerPaidMessagesStars, selectPinnedIds, selectTabState, selectTheme, @@ -153,6 +154,7 @@ type StateProps = { canShowOpenChatButton?: boolean; isContactRequirePremium?: boolean; topics?: Record; + 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( ) ); - 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( canShowOpenChatButton, isContactRequirePremium, topics, + paidMessagesStars, }; }, )(MiddleColumn)); diff --git a/src/components/middle/MiddleHeaderPanes.tsx b/src/components/middle/MiddleHeaderPanes.tsx index 1c6c4824d..52e897e89 100644 --- a/src/components/middle/MiddleHeaderPanes.tsx +++ b/src/components/middle/MiddleHeaderPanes.tsx @@ -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(FALLBACK_PANE_STATE); const [getBotAdState, setBotAdState] = useSignal(FALLBACK_PANE_STATE); const [getBotVerificationState, setBotVerificationState] = useSignal(FALLBACK_PANE_STATE); + const [getPaidMessageChargeState, setPaidMessageChargeState] = useSignal(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} /> + openPremiumModal()); + const handleGetMoreStars = useLastCallback(() => { openStarsBalanceModal({}); }); + return (
@@ -43,15 +51,41 @@ function PremiumRequiredMessage({ patternColor, userName }: StateProps) {
- {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']) + }
@@ -68,5 +102,5 @@ export default memo( patternColor, userName: getUserFirstOrLastName(user), }; - })(PremiumRequiredMessage), + })(RequirementToContactMessage), ); diff --git a/src/components/middle/composer/AttachmentModal.module.scss b/src/components/middle/composer/AttachmentModal.module.scss index 1614b6a97..f13c0e6b9 100644 --- a/src/components/middle/composer/AttachmentModal.module.scss +++ b/src/components/middle/composer/AttachmentModal.module.scss @@ -86,6 +86,11 @@ } } +.sendButtonStar { + margin-inline-start: 0 !important; + margin-inline-end: 0.125rem !important; +} + .attachments { max-height: 26rem; min-height: 5rem; diff --git a/src/components/middle/composer/AttachmentModal.tsx b/src/components/middle/composer/AttachmentModal.tsx index 0543a0302..69b3c2e56 100644 --- a/src/components/middle/composer/AttachmentModal.tsx +++ b/src/components/middle/composer/AttachmentModal.tsx @@ -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 = ({ onRemoveSymbol, onEmojiSelect, canScheduleUntilOnline, + canSchedule, onSendWhenOnline, + paidMessagesStars, }) => { // eslint-disable-next-line no-null/no-null const ref = useRef(null); @@ -152,7 +158,8 @@ const AttachmentModal: FC = ({ const svgRef = useRef(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(null); @@ -426,7 +433,7 @@ const AttachmentModal: FC = ({ 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 = ({ })(); 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 = ({ } return ( -
+
@@ -510,7 +518,7 @@ const AttachmentModal: FC = ({ positionX="right" > {Boolean(!editingMessage) && ( - {lang('Add')} + {oldLang('Add')} )} {hasMedia && ( <> @@ -518,12 +526,12 @@ const AttachmentModal: FC = ({ canInvertMedia && (!isInvertedMedia ? ( // eslint-disable-next-line react/jsx-no-bind setIsInvertedMedia(true)}> - {lang('PreviewSender.MoveTextUp')} + {oldLang('PreviewSender.MoveTextUp')} ) : ( // eslint-disable-next-line react/jsx-no-bind setIsInvertedMedia(undefined)}> - {lang(('PreviewSender.MoveTextDown'))} + {oldLang(('PreviewSender.MoveTextDown'))} )) } @@ -531,7 +539,7 @@ const AttachmentModal: FC = ({ !shouldForceAsFile && !shouldForceCompression && (isSendingCompressed ? ( // eslint-disable-next-line react/jsx-no-bind setShouldSendCompressed(false)}> - {lang(isMultiple ? 'Attachment.SendAsFiles' : 'Attachment.SendAsFile')} + {oldLang(isMultiple ? 'Attachment.SendAsFiles' : 'Attachment.SendAsFile')} ) : ( // eslint-disable-next-line react/jsx-no-bind @@ -543,11 +551,11 @@ const AttachmentModal: FC = ({ {isSendingCompressed && hasAnySpoilerable && Boolean(!editingMessage) && ( hasSpoiler ? ( - {lang('Attachment.DisableSpoiler')} + {oldLang('Attachment.DisableSpoiler')} ) : ( - {lang('Attachment.EnableSpoiler')} + {oldLang('Attachment.EnableSpoiler')} ) )} @@ -576,6 +584,12 @@ const AttachmentModal: FC = ({ } const isBottomDividerShown = !areAttachmentsScrolledToBottom || !isCaptionNotScrolled; + const buttonSendCaption = paidMessagesStars ? formatStarsAsIcon(lang, + attachmentsLength * paidMessagesStars, + { + className: styles.sendButtonStar, + asFont: true, + }) : oldLang('Send'); return ( = ({ forceDarkTheme && 'component-theme-dark', )} noBackdropClose + isLowStackPriority >
= ({ onDragOver={handleDragOver} onDragLeave={handleDragLeave} onClick={unmarkHovered} - data-attach-description={lang('Preview.Dragging.AddItems', 10)} + data-attach-description={oldLang('Preview.Dragging.AddItems', 10)} data-dropzone > @@ -684,7 +699,7 @@ const AttachmentModal: FC = ({ 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 = ({ onClick={handleSendClick} onContextMenu={canShowCustomSendMenu ? handleContextMenu : undefined} > - {shouldSchedule && !editingMessage ? lang('Next') : editingMessage ? lang('Save') : lang('Send')} + {shouldSchedule && !editingMessage ? oldLang('Next') + : editingMessage ? oldLang('Save') : buttonSendCaption} {canShowCustomSendMenu && ( ; - placeholder: string; + placeholder: TeactNode | string; timedPlaceholderLangKey?: string; timedPlaceholderDate?: number; forcedPlaceholder?: string; @@ -168,7 +168,7 @@ const MessageInput: FC = ({ // eslint-disable-next-line no-null/no-null const absoluteContainerRef = useRef(null); - const lang = useOldLang(); + const oldLang = useOldLang(); const isContextMenuOpenRef = useRef(false); const [isTextFormatterOpen, openTextFormatter, closeTextFormatter] = useFlag(); const [textFormatterAnchorPosition, setTextFormatterAnchorPosition] = useState(); @@ -561,9 +561,10 @@ const MessageInput: FC = ({ ); const inputScrollerContentClass = buildClassName('input-scroller-content', isNeedPremium && 'is-need-premium'); + const placeholderAriaLabel = typeof placeholder === 'string' ? placeholder : undefined; return ( -
+
= ({ 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 = ({ ) : placeholder} {isStoryInput && isNeedPremium && ( )} diff --git a/src/components/middle/composer/hooks/usePaidMessageConfirmation.ts b/src/components/middle/composer/hooks/usePaidMessageConfirmation.ts new file mode 100644 index 000000000..c6aed9912 --- /dev/null +++ b/src/components/middle/composer/hooks/usePaidMessageConfirmation.ts @@ -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(undefined); + + const closeConfirmDialog = useLastCallback(() => { + getActions().closePaymentMessageConfirmDialogOpen(); + }); + + const handleWithConfirmation = void>( + handler: T, + ...args: Parameters + ) => { + 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, + }; +} diff --git a/src/components/middle/helpers/groupMessages.ts b/src/components/middle/helpers/groupMessages.ts index ca96ebba8..57202548e 100644 --- a/src/components/middle/helpers/groupMessages.ts +++ b/src/components/middle/helpers/groupMessages.ts @@ -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') diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index 67cf4bb0c..9c6cbc279 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -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 = ({ maxTimestamp, lastPlaybackTimestamp, onIntersectPinnedMessage, + paidMessageStars, + isChatWithUser, }) => { const { toggleMessageSelection, @@ -787,6 +791,10 @@ const Message: FC = ({ 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 = ({ onEffectClick={handleEffectClick} onTranslationClick={handleTranslationClick} onOpenThread={handleOpenThread} + paidMessageStars={paidMessageStarsInMeta} /> ); @@ -1119,7 +1128,7 @@ const Message: FC = ({ {hasAnimatedEmoji && animatedCustomEmoji && ( = ({ {hasAnimatedEmoji && animatedEmoji && ( ( } = 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( poll, maxTimestamp, lastPlaybackTimestamp, + paidMessageStars, + isChatWithUser, }; }, )(Message)); diff --git a/src/components/middle/message/MessageMeta.scss b/src/components/middle/message/MessageMeta.scss index 28673b017..8d9fa6f7b 100644 --- a/src/components/middle/message/MessageMeta.scss +++ b/src/components/middle/message/MessageMeta.scss @@ -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; diff --git a/src/components/middle/message/MessageMeta.tsx b/src/components/middle/message/MessageMeta.tsx index 6cdbde99a..e188924aa 100644 --- a/src/components/middle/message/MessageMeta.tsx +++ b/src/components/middle/message/MessageMeta.tsx @@ -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) => void; renderQuickReactionButton?: () => TeactNode | undefined; onOpenThread: NoneToVoidFunction; + paidMessageStars?: number; }; const MessageMeta: FC = ({ @@ -56,6 +58,7 @@ const MessageMeta: FC = ({ onTranslationClick, onEffectClick, onOpenThread, + paidMessageStars, }) => { const { showNotification } = getActions(); @@ -176,6 +179,16 @@ const MessageMeta: FC = ({ {signature && ( {renderText(signature)} )} + {paidMessageStars && ( + { + formatStarsAsIcon(lang, paidMessageStars, { + asFont: true, + className: 'message-price-star-icon', + containerClassName: 'message-price-stars-container', + }) + } + + )} {message.forwardInfo?.isImported && ( <> diff --git a/src/components/middle/panes/PaidMessageChargePane.module.scss b/src/components/middle/panes/PaidMessageChargePane.module.scss new file mode 100644 index 000000000..baf0facd2 --- /dev/null +++ b/src/components/middle/panes/PaidMessageChargePane.module.scss @@ -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; +} diff --git a/src/components/middle/panes/PaidMessageChargePane.tsx b/src/components/middle/panes/PaidMessageChargePane.tsx new file mode 100644 index 000000000..ad751967e --- /dev/null +++ b/src/components/middle/panes/PaidMessageChargePane.tsx @@ -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 = ({ + 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 ( +
+
+ {message} +
+ + + + {dialogMessage} + + +
+ ); +}; + +export default memo(withGlobal( + (global, { peerId }): StateProps => { + const chat = selectChat(global, peerId); + const peerFullInfo = selectUserFullInfo(global, peerId); + const chargedPaidMessageStars = peerFullInfo?.settings?.chargedPaidMessageStars; + + return { + chargedPaidMessageStars, + chat, + }; + }, +)(PaidMessageChargePane)); diff --git a/src/components/modals/gift/GiftComposer.tsx b/src/components/modals/gift/GiftComposer.tsx index 7ca1d0cd9..50ffb4536 100644 --- a/src/components/modals/gift/GiftComposer.tsx +++ b/src/components/modals/gift/GiftComposer.tsx @@ -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 (
-