From eda7c3ee7724bc735b0d0471a5160e110bd286f1 Mon Sep 17 00:00:00 2001 From: zubiden <19638254+zubiden@users.noreply.github.com> Date: Sun, 20 Oct 2024 18:52:54 +0200 Subject: [PATCH] Introduce Paid reactions (#4906) --- src/api/gramjs/apiBuilders/appConfig.ts | 2 + src/api/gramjs/apiBuilders/chats.ts | 2 +- src/api/gramjs/apiBuilders/payments.ts | 3 +- src/api/gramjs/apiBuilders/reactions.ts | 41 ++- src/api/gramjs/apiBuilders/stories.ts | 8 +- src/api/gramjs/gramjsBuilders/index.ts | 39 ++- src/api/gramjs/methods/chats.ts | 2 + src/api/gramjs/methods/messages.ts | 4 + src/api/gramjs/methods/reactions.ts | 31 ++- src/api/gramjs/updates/mtpUpdateHandler.ts | 8 +- src/api/types/chats.ts | 1 + src/api/types/messages.ts | 24 +- src/api/types/misc.ts | 7 + src/api/types/payments.ts | 1 + src/api/types/stories.ts | 8 +- src/api/types/updates.ts | 7 +- src/assets/fonts/Roboto-Round-Regular.woff2 | Bin 0 -> 42556 bytes src/assets/fonts/roboto.css | 6 + src/assets/localization/fallback.strings | 4 + src/assets/tgs/stars/StarReaction.tgs | Bin 0 -> 5734 bytes src/assets/tgs/stars/StarReactionEffect.tgs | Bin 0 -> 23087 bytes src/bundles/extra.ts | 1 + src/components/common/AnimatedCounter.tsx | 4 +- src/components/common/Composer.tsx | 13 +- src/components/common/CustomEmojiPicker.tsx | 24 +- src/components/common/PeerBadge.module.scss | 44 ++++ src/components/common/PeerBadge.tsx | 50 ++++ src/components/common/Sparkles.module.scss | 49 ++++ src/components/common/Sparkles.tsx | 156 +++++++++++ src/components/common/StickerSet.tsx | 9 +- .../common/helpers/animatedAssets.ts | 4 + src/components/common/icons/StarIcon.tsx | 101 +++---- .../common/reactions/PaidReactionEmoji.tsx | 148 +++++++++++ .../reactions/ReactionAnimatedEmoji.tsx | 10 +- .../{ => reactions}/ReactionEmoji.module.scss | 0 .../common/{ => reactions}/ReactionEmoji.tsx | 61 +++-- .../{ => reactions}/ReactionStaticEmoji.scss | 0 .../{ => reactions}/ReactionStaticEmoji.tsx | 31 ++- .../left/settings/SettingsQuickReaction.tsx | 4 +- .../left/settings/SettingsStickers.tsx | 2 +- src/components/main/Main.tsx | 2 + src/components/main/Notifications.tsx | 25 +- src/components/middle/ReactorListModal.tsx | 2 +- .../middle/message/ContextMenuContainer.tsx | 26 +- src/components/middle/message/Message.tsx | 9 +- .../middle/message/MessageContextMenu.tsx | 9 + .../reactions/ReactionButton.module.scss | 26 ++ .../message/reactions/ReactionButton.tsx | 155 ++++++++++- .../message/reactions/ReactionPicker.tsx | 65 +++-- .../reactions/ReactionPickerLimited.tsx | 39 ++- .../message/reactions/ReactionSelector.tsx | 30 ++- .../ReactionSelectorCustomReaction.tsx | 64 ++++- .../middle/message/reactions/Reactions.tsx | 59 ++++- .../message/reactions/SavedTagButton.tsx | 7 +- src/components/modals/ModalContainer.tsx | 4 + .../paidReaction/PaidReactionModal.async.tsx | 18 ++ .../PaidReactionModal.module.scss | 70 +++++ .../modals/paidReaction/PaidReactionModal.tsx | 247 ++++++++++++++++++ .../paidReaction/StarSlider.module.scss | 137 ++++++++++ .../modals/paidReaction/StarSlider.tsx | 134 ++++++++++ .../modals/stars/StarsBalanceModal.tsx | 84 ++++-- .../transaction/PaidMediaThumb.module.scss | 4 +- .../transaction/StarsTransactionItem.tsx | 10 +- .../transaction/StarsTransactionModal.tsx | 15 +- .../right/management/ManageReactions.tsx | 2 +- src/components/story/StoryFooter.tsx | 9 +- src/components/story/StoryView.tsx | 2 +- src/components/ui/Modal.scss | 4 +- src/components/ui/Notification.scss | 20 +- src/components/ui/Notification.tsx | 66 +++-- src/components/ui/RoundTimer.module.scss | 24 ++ src/components/ui/RoundTimer.tsx | 60 +++++ src/config.ts | 1 + src/global/actions/api/chats.ts | 2 - src/global/actions/api/messages.ts | 5 + src/global/actions/api/reactions.ts | 69 ++++- src/global/actions/apiUpdaters/messages.ts | 7 + src/global/actions/apiUpdaters/misc.ts | 13 + src/global/actions/ui/chats.ts | 5 + src/global/actions/ui/messages.ts | 14 + src/global/actions/ui/misc.ts | 9 +- src/global/actions/ui/payments.ts | 23 +- src/global/actions/ui/reactions.ts | 50 +++- src/global/cache.ts | 27 +- src/global/helpers/reactions.ts | 76 +++++- src/global/types.ts | 58 +++- src/hooks/useContextMenuHandlers.ts | 4 +- src/lib/gramjs/tl/apiTl.js | 2 + src/lib/gramjs/tl/static/api.json | 2 + src/styles/_variables.scss | 1 + src/styles/index.scss | 1 + src/types/index.ts | 3 +- src/types/language.d.ts | 11 +- src/util/fonts.ts | 2 +- src/util/notifications.ts | 7 +- src/util/textFormat.ts | 2 +- 96 files changed, 2414 insertions(+), 317 deletions(-) create mode 100644 src/assets/fonts/Roboto-Round-Regular.woff2 create mode 100644 src/assets/tgs/stars/StarReaction.tgs create mode 100644 src/assets/tgs/stars/StarReactionEffect.tgs create mode 100644 src/components/common/PeerBadge.module.scss create mode 100644 src/components/common/PeerBadge.tsx create mode 100644 src/components/common/Sparkles.module.scss create mode 100644 src/components/common/Sparkles.tsx create mode 100644 src/components/common/reactions/PaidReactionEmoji.tsx rename src/components/common/{ => reactions}/ReactionEmoji.module.scss (100%) rename src/components/common/{ => reactions}/ReactionEmoji.tsx (57%) rename src/components/common/{ => reactions}/ReactionStaticEmoji.scss (100%) rename src/components/common/{ => reactions}/ReactionStaticEmoji.tsx (60%) create mode 100644 src/components/modals/paidReaction/PaidReactionModal.async.tsx create mode 100644 src/components/modals/paidReaction/PaidReactionModal.module.scss create mode 100644 src/components/modals/paidReaction/PaidReactionModal.tsx create mode 100644 src/components/modals/paidReaction/StarSlider.module.scss create mode 100644 src/components/modals/paidReaction/StarSlider.tsx create mode 100644 src/components/ui/RoundTimer.module.scss create mode 100644 src/components/ui/RoundTimer.tsx diff --git a/src/api/gramjs/apiBuilders/appConfig.ts b/src/api/gramjs/apiBuilders/appConfig.ts index c181bfd75..26cd5e49b 100644 --- a/src/api/gramjs/apiBuilders/appConfig.ts +++ b/src/api/gramjs/apiBuilders/appConfig.ts @@ -65,6 +65,7 @@ export interface GramJsAppConfig extends LimitsConfig { giveaway_boosts_per_premium: number; giveaway_countries_max: number; boosts_per_sent_gift: number; + stars_paid_reaction_amount_max: number; // Forums topics_pinned_limit: number; // Stories @@ -163,6 +164,7 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp bandwidthPremiumUploadSpeedup: appConfig.upload_premium_speedup_upload, bandwidthPremiumDownloadSpeedup: appConfig.upload_premium_speedup_download, channelRestrictAdsLevelMin: appConfig.channel_restrict_sponsored_level_min, + paidReactionMaxAmount: appConfig.stars_paid_reaction_amount_max, isChannelRevenueWithdrawalEnabled: appConfig.channel_revenue_withdrawal_enabled, isStarsGiftsEnabled: appConfig.stars_gifts_enabled, }; diff --git a/src/api/gramjs/apiBuilders/chats.ts b/src/api/gramjs/apiBuilders/chats.ts index efee9972b..9d00e4216 100644 --- a/src/api/gramjs/apiBuilders/chats.ts +++ b/src/api/gramjs/apiBuilders/chats.ts @@ -530,7 +530,7 @@ export function buildApiChatReactions(chatReactions?: GramJs.TypeChatReactions): if (chatReactions instanceof GramJs.ChatReactionsSome) { return { type: 'some', - allowed: chatReactions.reactions.map(buildApiReaction).filter(Boolean), + allowed: chatReactions.reactions.map((r) => buildApiReaction(r)).filter(Boolean), }; } diff --git a/src/api/gramjs/apiBuilders/payments.ts b/src/api/gramjs/apiBuilders/payments.ts index 5460c74c1..90fe40bfa 100644 --- a/src/api/gramjs/apiBuilders/payments.ts +++ b/src/api/gramjs/apiBuilders/payments.ts @@ -503,7 +503,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, + date, id, peer, stars, description, photo, title, refund, extendedMedia, failed, msgId, pending, gift, reaction, } = transaction; if (photo) { @@ -527,6 +527,7 @@ export function buildApiStarsTransaction(transaction: GramJs.StarsTransaction): messageId: msgId, isGift: gift, extendedMedia: boughtExtendedMedia, + isReaction: reaction, }; } diff --git a/src/api/gramjs/apiBuilders/reactions.ts b/src/api/gramjs/apiBuilders/reactions.ts index 0e1f10c1c..f3f35a73f 100644 --- a/src/api/gramjs/apiBuilders/reactions.ts +++ b/src/api/gramjs/apiBuilders/reactions.ts @@ -3,11 +3,12 @@ import { Api as GramJs } from '../../../lib/gramjs'; import type { ApiAvailableEffect, ApiAvailableReaction, + ApiMessageReactor, ApiPeerReaction, ApiReaction, ApiReactionCount, - ApiReactionEmoji, ApiReactions, + ApiReactionWithPaid, ApiSavedReactionTag, } from '../../types'; @@ -16,7 +17,7 @@ import { getApiChatIdFromMtpPeer } from './peers'; export function buildMessageReactions(reactions: GramJs.MessageReactions): ApiReactions { const { - recentReactions, results, canSeeList, reactionsAsTags, + recentReactions, results, canSeeList, reactionsAsTags, topReactors, } = reactions; return { @@ -24,15 +25,21 @@ export function buildMessageReactions(reactions: GramJs.MessageReactions): ApiRe canSeeList, results: results.map(buildReactionCount).filter(Boolean).sort(reactionCountComparator), recentReactions: recentReactions?.map(buildMessagePeerReaction).filter(Boolean), + topReactors: topReactors?.map(buildApiMessageReactor).filter(Boolean), }; } function reactionCountComparator(a: ApiReactionCount, b: ApiReactionCount) { + if (a.reaction.type === 'paid') return -1; + if (b.reaction.type === 'paid') return 1; + const diff = b.count - a.count; if (diff) return diff; + if (a.chosenOrder !== undefined && b.chosenOrder !== undefined) { return a.chosenOrder - b.chosenOrder; } + if (a.chosenOrder !== undefined) return 1; if (b.chosenOrder !== undefined) return -1; return 0; @@ -41,7 +48,7 @@ function reactionCountComparator(a: ApiReactionCount, b: ApiReactionCount) { export function buildReactionCount(reactionCount: GramJs.ReactionCount): ApiReactionCount | undefined { const { chosenOrder, count, reaction } = reactionCount; - const apiReaction = buildApiReaction(reaction); + const apiReaction = buildApiReaction(reaction, true); if (!apiReaction) return undefined; return { @@ -51,6 +58,20 @@ export function buildReactionCount(reactionCount: GramJs.ReactionCount): ApiReac }; } +export function buildApiMessageReactor(reactor: GramJs.MessageReactor): ApiMessageReactor { + const { + count, my, top, anonymous, peerId, + } = reactor; + + return { + peerId: peerId && getApiChatIdFromMtpPeer(peerId), + count, + isMe: my, + isTop: top, + isAnonymous: anonymous, + }; +} + export function buildMessagePeerReaction(userReaction: GramJs.MessagePeerReaction): ApiPeerReaction | undefined { const { peerId, reaction, big, unread, date, my, @@ -69,19 +90,29 @@ export function buildMessagePeerReaction(userReaction: GramJs.MessagePeerReactio }; } -export function buildApiReaction(reaction: GramJs.TypeReaction): ApiReaction | undefined { +export function buildApiReaction(reaction: GramJs.TypeReaction, withPaid?: never): ApiReaction | undefined; +export function buildApiReaction(reaction: GramJs.TypeReaction, withPaid: true): ApiReactionWithPaid | undefined; +export function buildApiReaction(reaction: GramJs.TypeReaction, withPaid?: true): ApiReactionWithPaid | undefined { if (reaction instanceof GramJs.ReactionEmoji) { return { + type: 'emoji', emoticon: reaction.emoticon, }; } if (reaction instanceof GramJs.ReactionCustomEmoji) { return { + type: 'custom', documentId: reaction.documentId.toString(), }; } + if (withPaid && reaction instanceof GramJs.ReactionPaid) { + return { + type: 'paid', + }; + } + return undefined; } @@ -112,7 +143,7 @@ export function buildApiAvailableReaction(availableReaction: GramJs.AvailableRea staticIcon: buildApiDocument(staticIcon), aroundAnimation: aroundAnimation ? buildApiDocument(aroundAnimation) : undefined, centerIcon: centerIcon ? buildApiDocument(centerIcon) : undefined, - reaction: { emoticon: reaction } as ApiReactionEmoji, + reaction: { type: 'emoji', emoticon: reaction }, title, isInactive: inactive, isPremium: premium, diff --git a/src/api/gramjs/apiBuilders/stories.ts b/src/api/gramjs/apiBuilders/stories.ts index e8de34c78..a369b55ed 100644 --- a/src/api/gramjs/apiBuilders/stories.ts +++ b/src/api/gramjs/apiBuilders/stories.ts @@ -58,6 +58,8 @@ export function buildApiStory(peerId: string, story: GramJs.TypeStoryItem): ApiT content.text = buildMessageTextContent(caption, entities); } + const reaction = sentReaction && buildApiReaction(sentReaction); + return omitUndefined({ id, peerId, @@ -75,7 +77,7 @@ export function buildApiStory(peerId: string, story: GramJs.TypeStoryItem): ApiT isOut: out, visibility: privacy && buildPrivacyRules(privacy), mediaAreas: mediaAreas?.map(buildApiMediaArea).filter(Boolean), - sentReaction: sentReaction && buildApiReaction(sentReaction), + sentReaction: reaction, forwardInfo: fwdFrom && buildApiStoryForwardInfo(fwdFrom), fromId: fromId && getApiChatIdFromMtpPeer(fromId), }); @@ -197,7 +199,9 @@ export function buildApiMediaArea(area: GramJs.TypeMediaArea): ApiMediaArea | un } = area; const apiReaction = buildApiReaction(reaction); - if (!apiReaction) return undefined; + if (!apiReaction) { + return undefined; + } return { type: 'suggestedReaction', diff --git a/src/api/gramjs/gramjsBuilders/index.ts b/src/api/gramjs/gramjsBuilders/index.ts index df1801467..9fafdacbd 100644 --- a/src/api/gramjs/gramjsBuilders/index.ts +++ b/src/api/gramjs/gramjsBuilders/index.ts @@ -18,7 +18,8 @@ import type { ApiPhoneCall, ApiPhoto, ApiPoll, - ApiPremiumGiftCodeOption, ApiReaction, + ApiPremiumGiftCodeOption, + ApiReactionWithPaid, ApiReportReason, ApiRequestInputInvoice, ApiSendMessageAction, @@ -291,6 +292,15 @@ export function generateRandomBigInt() { return readBigIntFromBuffer(generateRandomBytes(8), true, true); } +export function generateRandomTimestampedBigInt() { + // 32 bits for timestamp, 32 bits are random + const buffer = generateRandomBytes(8); + const timestampBuffer = Buffer.alloc(4); + timestampBuffer.writeUInt32LE(Math.floor(Date.now() / 1000), 0); + buffer.set(timestampBuffer, 4); + return readBigIntFromBuffer(buffer, true, true); +} + export function generateRandomInt() { return readBigIntFromBuffer(generateRandomBytes(4), true, true).toJSNumber(); } @@ -650,20 +660,21 @@ export function buildInputInvoice(invoice: ApiRequestInputInvoice) { } } -export function buildInputReaction(reaction?: ApiReaction) { - if (reaction && 'emoticon' in reaction) { - return new GramJs.ReactionEmoji({ - emoticon: reaction.emoticon, - }); +export function buildInputReaction(reaction?: ApiReactionWithPaid) { + switch (reaction?.type) { + case 'emoji': + return new GramJs.ReactionEmoji({ + emoticon: reaction.emoticon, + }); + case 'custom': + return new GramJs.ReactionCustomEmoji({ + documentId: BigInt(reaction.documentId), + }); + case 'paid': + return new GramJs.ReactionPaid(); + default: + return new GramJs.ReactionEmpty(); } - - if (reaction && 'documentId' in reaction) { - return new GramJs.ReactionCustomEmoji({ - documentId: BigInt(reaction.documentId), - }); - } - - return new GramJs.ReactionEmpty(); } export function buildInputChatReactions(chatReactions?: ApiChatReactions) { diff --git a/src/api/gramjs/methods/chats.ts b/src/api/gramjs/methods/chats.ts index 80011c5d0..6bd400f79 100644 --- a/src/api/gramjs/methods/chats.ts +++ b/src/api/gramjs/methods/chats.ts @@ -602,6 +602,7 @@ async function getFullChannelInfo( boostsApplied, boostsUnrestrict, canViewRevenue: canViewMonetization, + paidReactionsAvailable, } = result.fullChat; if (chatPhoto) { @@ -691,6 +692,7 @@ async function getFullChannelInfo( hasPinnedStories: Boolean(storiesPinnedAvailable), boostsApplied, boostsToUnrestrict: boostsUnrestrict, + isPaidReactionAvailable: paidReactionsAvailable, }, chats, userStatusesById: statusesById, diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index 03329a733..d36c13590 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -1069,6 +1069,10 @@ export async function fetchFactChecks({ return results.flatMap((result) => result!).map(buildApiFactCheck); } +export function fetchPaidReactionPrivacy() { + return invokeRequest(new GramJs.messages.GetPaidReactionPrivacy(), { shouldReturnTrue: true }); +} + export async function fetchDiscussionMessage({ chat, messageId, }: { diff --git a/src/api/gramjs/methods/reactions.ts b/src/api/gramjs/methods/reactions.ts index 1f54596e4..72836f3ab 100644 --- a/src/api/gramjs/methods/reactions.ts +++ b/src/api/gramjs/methods/reactions.ts @@ -20,7 +20,7 @@ import { buildMessagePeerReaction, } from '../apiBuilders/reactions'; import { buildStickerFromDocument } from '../apiBuilders/symbols'; -import { buildInputPeer, buildInputReaction } from '../gramjsBuilders'; +import { buildInputPeer, buildInputReaction, generateRandomTimestampedBigInt } from '../gramjsBuilders'; import localDb from '../localDb'; import { invokeRequest } from './client'; @@ -150,6 +150,29 @@ export function sendReaction({ }); } +export function sendPaidReaction({ + chat, + messageId, + count, + isPrivate, +}: { + chat: ApiChat; + messageId: number; + count: number; + isPrivate?: boolean; +}) { + return invokeRequest(new GramJs.messages.SendPaidReaction({ + peer: buildInputPeer(chat.id, chat.accessHash), + msgId: messageId, + randomId: generateRandomTimestampedBigInt(), + count, + private: isPrivate || undefined, + }), { + shouldReturnTrue: true, + shouldThrow: true, + }); +} + export function fetchMessageReactions({ ids, chat, }: { @@ -215,7 +238,7 @@ export async function fetchTopReactions({ hash = '0' }: { hash?: string }) { return { hash: String(result.hash), - reactions: result.reactions.map(buildApiReaction).filter(Boolean), + reactions: result.reactions.map((r) => buildApiReaction(r)).filter(Boolean), }; } @@ -231,7 +254,7 @@ export async function fetchRecentReactions({ hash = '0' }: { hash?: string }) { return { hash: String(result.hash), - reactions: result.reactions.map(buildApiReaction).filter(Boolean), + reactions: result.reactions.map((r) => buildApiReaction(r)).filter(Boolean), }; } @@ -250,7 +273,7 @@ export async function fetchDefaultTagReactions({ hash = '0' }: { hash?: string } return { hash: String(result.hash), - reactions: result.reactions.map(buildApiReaction).filter(Boolean), + reactions: result.reactions.map((r) => buildApiReaction(r)).filter(Boolean), }; } diff --git a/src/api/gramjs/updates/mtpUpdateHandler.ts b/src/api/gramjs/updates/mtpUpdateHandler.ts index 0595e422b..44e2b70c6 100644 --- a/src/api/gramjs/updates/mtpUpdateHandler.ts +++ b/src/api/gramjs/updates/mtpUpdateHandler.ts @@ -1009,11 +1009,12 @@ export function updater(update: Update) { lastReadId: update.maxId, }); } else if (update instanceof GramJs.UpdateSentStoryReaction) { + const reaction = buildApiReaction(update.reaction); sendApiUpdate({ '@type': 'updateSentStoryReaction', peerId: getApiChatIdFromMtpPeer(update.peer), storyId: update.storyId, - reaction: buildApiReaction(update.reaction), + reaction, }); } else if (update instanceof GramJs.UpdateStoriesStealthMode) { sendApiUpdate({ @@ -1044,6 +1045,11 @@ export function updater(update: Update) { '@type': 'updateStarsBalance', balance: update.balance.toJSNumber(), }); + } else if (update instanceof GramJs.UpdatePaidReactionPrivacy) { + sendApiUpdate({ + '@type': 'updatePaidReactionPrivacy', + isPrivate: update.private, + }); } else if (update instanceof LocalUpdatePremiumFloodWait) { sendApiUpdate({ '@type': 'updatePremiumFloodWait', diff --git a/src/api/types/chats.ts b/src/api/types/chats.ts index 6dc4acb49..82f2b129c 100644 --- a/src/api/types/chats.ts +++ b/src/api/types/chats.ts @@ -136,6 +136,7 @@ export interface ApiChatFullInfo { areParticipantsHidden?: boolean; isTranslationDisabled?: true; hasPinnedStories?: boolean; + isPaidReactionAvailable?: boolean; boostsApplied?: number; boostsToUnrestrict?: number; diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 8e88baf48..57b1caacd 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -688,6 +688,7 @@ export interface ApiReactions { areTags?: boolean; results: ApiReactionCount[]; recentReactions?: ApiPeerReaction[]; + topReactors?: ApiMessageReactor[]; } export interface ApiPeerReaction { @@ -699,10 +700,20 @@ export interface ApiPeerReaction { addedDate: number; } +export interface ApiMessageReactor { + isTop?: true; + isMe?: true; + count: number; + isAnonymous?: true; + peerId?: string; +} + export interface ApiReactionCount { chosenOrder?: number; count: number; - reaction: ApiReaction; + reaction: ApiReactionWithPaid; + localAmount?: number; + localIsPrivate?: boolean; } export interface ApiAvailableReaction { @@ -741,16 +752,23 @@ type ApiChatReactionsSome = { export type ApiChatReactions = ApiChatReactionsAll | ApiChatReactionsSome; export type ApiReactionEmoji = { + type: 'emoji'; emoticon: string; }; export type ApiReactionCustomEmoji = { + type: 'custom'; documentId: string; }; -export type ApiReaction = ApiReactionEmoji | ApiReactionCustomEmoji; +export type ApiReactionPaid = { + type: 'paid'; +}; -export type ApiReactionKey = `${string}-${string}`; +export type ApiReaction = ApiReactionEmoji | ApiReactionCustomEmoji; +export type ApiReactionWithPaid = ApiReaction | ApiReactionPaid; + +export type ApiReactionKey = `${string}-${string}` | 'paid' | 'unsupported'; export type ApiSavedReactionTag = { reaction: ApiReaction; diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index 2909cbdd8..51cc444bc 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -1,4 +1,5 @@ import type { ApiLimitType, ApiPremiumSection, CallbackAction } from '../../global/types'; +import type { IconName } from '../../types/icons'; import type { ApiDocument, ApiPhoto, ApiReaction } from './messages'; import type { ApiUser } from './users'; @@ -110,10 +111,15 @@ export type ApiNotification = { localId: string; title?: string; message: string; + cacheBreaker?: string; actionText?: string; action?: CallbackAction | CallbackAction[]; className?: string; duration?: number; + disableClickDismiss?: boolean; + shouldShowTimer?: boolean; + icon?: IconName; + dismissAction?: CallbackAction; }; export type ApiError = { @@ -210,6 +216,7 @@ export interface ApiAppConfig { bandwidthPremiumUploadSpeedup?: number; bandwidthPremiumDownloadSpeedup?: number; channelRestrictAdsLevelMin?: number; + paidReactionMaxAmount?: number; isChannelRevenueWithdrawalEnabled?: boolean; isStarsGiftsEnabled?: boolean; } diff --git a/src/api/types/payments.ts b/src/api/types/payments.ts index 2a949a5f8..869d933fc 100644 --- a/src/api/types/payments.ts +++ b/src/api/types/payments.ts @@ -303,6 +303,7 @@ export interface ApiStarsTransaction { isGift?: true; isPrizeStars?: true; isMyGift?: true; // Used only for outgoing star gift messages + isReaction?: true; hasFailed?: true; isPending?: true; date: number; diff --git a/src/api/types/stories.ts b/src/api/types/stories.ts index 4aa0e1118..fa069b85b 100644 --- a/src/api/types/stories.ts +++ b/src/api/types/stories.ts @@ -1,6 +1,12 @@ import type { ApiPrivacySettings } from '../../types'; import type { - ApiGeoPoint, ApiMessage, ApiReaction, ApiReactionCount, ApiSticker, ApiStoryForwardInfo, MediaContent, + ApiGeoPoint, + ApiMessage, + ApiReaction, + ApiReactionCount, + ApiSticker, + ApiStoryForwardInfo, + MediaContent, } from './messages'; export interface ApiStory { diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index 902f54913..8adf0f6af 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -765,6 +765,11 @@ export type ApiUpdateEntities = { threadInfos?: ApiThreadInfo[]; }; +export type ApiUpdatePaidReactionPrivacy = { + '@type': 'updatePaidReactionPrivacy'; + isPrivate: boolean; +}; + export type ApiUpdate = ( ApiUpdateReady | ApiUpdateSession | ApiUpdateWebAuthTokenFailed | ApiUpdateRequestUserUpdate | ApiUpdateAuthorizationState | ApiUpdateAuthorizationError | ApiUpdateConnectionState | ApiUpdateCurrentUser | @@ -797,7 +802,7 @@ export type ApiUpdate = ( ApiUpdateViewForumAsMessages | ApiUpdateSavedDialogPinned | ApiUpdatePinnedSavedDialogIds | ApiUpdateChatLastMessage | ApiUpdateDeleteSavedHistory | ApiUpdatePremiumFloodWait | ApiUpdateStarsBalance | ApiUpdateQuickReplyMessage | ApiUpdateQuickReplies | ApiDeleteQuickReply | ApiUpdateDeleteQuickReplyMessages | - ApiUpdateDeleteProfilePhoto | ApiUpdateNewProfilePhoto | ApiUpdateEntities + ApiUpdateDeleteProfilePhoto | ApiUpdateNewProfilePhoto | ApiUpdateEntities | ApiUpdatePaidReactionPrivacy ); export type OnApiUpdate = (update: ApiUpdate) => void; diff --git a/src/assets/fonts/Roboto-Round-Regular.woff2 b/src/assets/fonts/Roboto-Round-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..0a8478d2d0a99b0d0670462cee145108aabc38fc GIT binary patch literal 42556 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¬@Y?=`(_vKIx9qjPr=Nt&(v9EkFBo7v-ejbMiHdUCEPp`*5DQLc&M=NY- zGGa06FcpO9pIxFx>k*Ke?c6dOa#qlKA7z4=PyNoI6#(`7tmJ>6tN_H_kxDr&2(`O@ zS4L%D3`hkni=3O#`s2#JxQ4dY9H@UuN*4Lc%xfCutg=pt@K{=H9|wL@?ekl(&*5=C=!JwRXVE)Urzvy@8c0;A+4E zo0jinAh8o*awyv-#~QalPn|W7G~GsW5r(`3jp?B~l$ZJ z?hlYDTQ{6yllPho07(O!{!z+pV`9re2jCveV`B<9aTG%8n8-()#$IbX$a(JoIoaC$ zu8oP}Bh5BavgO?^>Q&lUC|Q*$AHH)C<0&ex7wjPH({>Y}&Hp`kb1}oUy7`q=0Z%*B z-};&6J)kEmdp$RzoojI;^r)vIus~1(50M{3*<)WWH8e3KH%%=w0~07B1c;kseBl(t z8o0!35IhK7);_VN-0xj(0;@8Wihsk|=hj zPk@leU^R@%e}x(=a8pl*-u?6@eS=~EMAV#(|3IOyYbHf=}NqD-P`5-_ef*D2^MjrYZp72;D8v` zU`dsrZUYX`C%`V}B1bruS=d%0WlxJBF5)rYSM&aAW+ZWliK0Qwg`Loc8Ybc93g`_k z-VyU1F0~@EVstz@eT?>|$jGd#?4sePUTYPjF&lr4uq5s2t#29x6OQhsx*;6ip*5S1 zhJ>P)A>n^ni6KHV9)5}(kk*5KX@S@S=`qQ_XB3QxwG5?Eucb(xy$&;!#<5znR09fh z{MLOH$M$j6T{hJfdjpx28Km8QRIwu&Mr3%oySQFUiN4$w8+>NRJrIeKxXGW>wJwL` zp0lmO1yda_f%ytn($uYI<1Bng66sn0NeNc6jXx;Wk;V7rI7t+=w*caaeou3_y6-3c zqP5V3G3mT&nb;QBaPc>DQoFH~bcr{#>;Gl*;(qn-ql9(Rvv>EZ|IM#^%CzAnS(y2o zz-eU*UpsGvJ0X){=jdiy+?ZR+9H>M>(|zli{sRD=4Xwa2Kwfbwr>5b%5DLkz+V19# zHV%If31E9^#Q|uNQR;)N2Q1&GGLCA1bEfx#@M#9A|C{+7h1y@5C(ZNXQ{Ut(`_zST zDOqCRi!YI=iAF#Y!_z_eng%UjZKqU2D4e>g*Kb9Z>SGOE_AaS*3c&F#o^@E)8qjyk zzOeaocHut`K78A4V3Nq~eQy|SlCGx4MZ>FrW~J>#)a?C2u~Y)ZU3P)ph@vK#Kta@_ ziAsg{`SJF7FG<;4I@R6rq3OC~_)&>TAw8#IiL2&KLQ1d!49U>{@qnp@Moxwy$;OGA z(NkPgE>;1rT7VxSWbm|4$!TG*bMbwEFw;9mV3)`#CJXc${_vACyY}&*-}uttmOXW; zAZj`HC%CN*>rJnwSIvdFSFhSKaEahffmY~~*C>Yw~xpN*FFe!+RuOmzq(<_5%qNges5er5j6i*9v7T#VSwrgMJg# zG#@eJ^=W-8L?|e=UA%p?i$*nYeaWOSmp17bya@^w5Gz%oZn8Abu~G^v0jF++UCpo! zEN{?n#r`D*@h@gu7dK2L??-TjFFAU~*;&?a`lh$%>Y`Uj`h91@5GUiLIv-jRWi-%q z0WavD+xGX%F{tK0(zti%ur9R_3lo|6X^_sFO(Gnd~?xpnP(;p zy}P`t0c6#ZV1m{o1Xgy~JB|4+%8&ioNC?D>ZS1XKO`3PIUIL?1YKEE(Uy%}YdcADc zCuSxDMf_O)r^nJY4ivlq3p&J}w3mjWXhllQSNe)jO6Gv_X$*PI<~)k?33=9_f&7Lha@F?(~7m#zsvEE1++9UhV$5+_`!9(Cza zm76?};0&m4*i${|J+xb#H2NR)CFz`eJK>(K0M%c$i?FJ?2MB+vtyd~oA=Pb)*K$x< z7H?9`3Rl9IWK{d4gE%ATb|_a4WB=}xSsfy6;_qnNQ~lY`O&6UeuxdRxb{CVbl07>w zY|-GCpeK5zz+n%zTQVz#cdu$+11fiFDxMmgQm$2Ow|jPY~rh!M#$0v!MdtlYRHUJgv}AI^LuI6T5ePYS}+5|+rJnpco+Z~FZU z#au?MfLDYzVz-L+icT_{UO^pkNY>rq>dc@{$<>%u!8oqwQ&0$PkTsC4o#t_`_bC9j zys*^1TS_kPY9lhRe0ea;Fv1T8S1I6?RyOn9mH=8P5(E+hokLjsCnX5*Exf6saf(26 z*0+rWOsQxqs%x{dMqqb0@o26>6;{=&3V>qi&o&HNR3I9bm&k%BiAG|kiJz7|r^6=~ znxzJrmI-$$=_(l!cX(Q2(b(*&`L5KCNBD#PftVy7Hk{Z>Spk%KnC)=cM1F5K>Zx_F zAMAA=3i+E=P|U|hRn7>Q9W^xDM{s9`@{(iI(KGMEYjR!9>npKnf3`%Q zrD>{m*ype~8!FV8M^96y*EcxA4NQtSiSg0mi48Tq+RUS_>AzpI{EV4I&#E`ncAIU& zC26%_IEL*Ly;!3$TY{c0M<&@seR&%|I zhJ~_!{wU7jB!~UhN<&R7LeErU(xw)nu}-v@&aQ#aK!uXXxFe-JCVF*tl@5XvGt8-5 zU92$+EAyb5c<%Mv!r}+i8R{Xcmhm+LMz4DV7d(_cpu{BI0eh$eNJh}n(p&>qEAhq9 z35CLo;Fo*~|5AW2Bv>)v7zY>lqF}#XG7SLz%x+MBP-%mM;9D8MplNCG&cexuzr3FkdEC;FGjuA&m34JCH-kjZw#r^zd%kfQt;F4m!LF_p6K>2npIw-SpPCunzHfj83k_HNeX@3Dg z^?lspsGv+7t*E6n<6COgHLh4^qleQs-?!}Qv%3^>ghOo}YChZ;E_|8~0{N&xhs$+S zV!3cp9}?Ipc6Yh9HFEeGjhJ8<*I9FTw%B|p3A%nOgIAm+$aRLBG35GPE~;c2v=FoX zBSd(Pmpq}=jCNmh5l)sku`qElIyr4AbPB^kfO>~wOv(hw)@lIH&rwJ2ltwW@`Z)W3 zwdV6JW)~Lhu@4}=ntTKPqm%$JbhF#8=}Y>uF51(ReMI~qw~X@fhgT%k;C)nlXe=NF zRTEJ8rscqo$ z!Y~dH`Ps+u3F#e{U`Ou?pY&^OYQ^q!>lN_|V&#q?ydc=`RHDg@E-LH$JV92M7TW=( z#1!DpBuzj^MLPbXy`nQ;MyXFu{rDT{%bAJIJCpSwME&F(@z)_QugEhl77 zFq5e|UMFkAbox2;Dw-&x1!q@nxGIyJQA_C28cT9_D}1_2oIyslDw)v*=Yn{K;#%g# zP3N!e=TAaMD70X=)@d-b1mbraqITKQZE|tk+Kc(FA>FT8SKC~a{w36zbEZIYZb%}t zX8xQkKr$1OFn)NUaEL7){|Ewvf-pJwngxq*OBPLLL2A~_-E$QudBL_u<86L}3Eh=c zf_?uo)XWLF-Loc#@U9Lu#Fpiw6eok9e>xx7-!5RxY17>wH6AL7-)s~CMfx9jar^o! zw-@uKe;nm*$5a3Cl0a43O(LyK$sqr#ohP%m@>&e0;6eJfBkbt?vIYn(Kt`Kd4T0E6 zT8gi8`(q)+IV8uYjJH8Oopu92;w;VUgepbT1f`Y~F1}FnvBYVGym}BQn>P-zfA%CrBaK1y#36w_CacMJ4{V}R9>6A9XnkUIsm?0QvqLzA^#X_p+ zev82L?KJv9%#sR54>VIPBoZl-!JFsLe#AXRxr^L28+@jT$Z(9Cp`jjSVFZ=xHxX>g z#?1vTLY&rj{1!f8_uJZIQ)Xp@i&A>ZAd^30u(iL^j!}E!-TMT)ESBK!`$W1W{>u6G z9P~wm^)VAwUkyYh84p51-!fhA!)y}?!$0#|{y`oB8(kXG2D;6ahPtzTh!w7-;>(mD z3th-;^HbZ)1~~YZA@I&R7nzWp6w%)tY*OTJuVip4_#IYW-0#;#so2@|JWa_xe>r-iE=T=X1 z?JPx26^PhDiU$B$TK6qmhPM4xHjpwJLN(-BWN0%$FGq~m>RbQg*J}xGe9sFJ5-J%T zC8jg3BuHQL3y7|cTi0UAbEtCD-r9<&(SNyhdZY)#E-Aw$5ICBU1}3Rp|wSqPGI_{vA`c*rb%O=`(YwTe>) z+Hk843XDZin)PRDG~O?pgKR94Mj_Ar`kQyPw^Y1t0>)|h`0B2=`_&oM;(a{M(*CwN zq6t*+_A>g|r^%LrlD&BTJKI`kR`MvIl~N6VuDe7(&vm{_`^_V7^d=^O*8Y4z{SOp~ zhu{SKLhU=koP_9V`R_6&OC3W~KgcWPt3*-2Iw9Qx2Pmgkl%E@lFJRNJ?RYVrcq3;g zN+6$)fF0y2)9Z#<`+0=+ z?%IoEA`I67n_dR=wFmA9FnUsiDv;^<{WX~j41F#<9y!EB&^i_);mYBi`I}a4V(7Ga zFwe(_M8S3+ZVBfjx<&k*iv4;kC68zGG^8kXPSSuQLi`?iIYC??%B*f_ zzyGZpo`Elraa&72yS~IrOFVZTxJ>~g#aux*6w!q<$JeM_R@WDj$_U<7I2=JNX8Snw zVMCTzk;f_48KUF&mtqf};cwu*No+IV*>*!40NUk-f{ zw4sYkK=9@4`Pu5)e*Z(c*_ozZ#=~ak*Ua{CzR_rpne2JOS)D0bV$ElTx#`<~?x}X7 z3<@Q>_Ony@ok^Ape$5G;nWfpfW(6q2Zya!#rJHbgnfg4C%P~WhSrZ2AQ+%?|9U^6@ zahq*pzGDX<=216FD*IAJAAhLWw7k3})E~g7k}*8&u&29Cv%v#N$P2nXc$UGV2=_pI z@Y_7_MU)U*u&s_yE!mUWg*ktQhxf)+T{8{}vhn73OLY?%S0wOUh*2WRh{eS$l6!VBZowLBTNe2 zSX7xq=GAs3)xrf~qY-U#TRQQtXfj6xp2sgw0Q{J`~-jyKSdvDWSOLgR0&ts&B`L3-OJ z;YduP#h$(|CAGSP!lAS3=2FA+Qev%{-+pRg)}uQrG}(8XYT}=l`rpV+6a?nqc^-l5 zUKMmdhq+3OKXf!J)zDX01!S>Pd$(%H5~)q2q1z_B_e;hVYjLkKqfo=vO+L=k_6aF< zqmOf8G9|D2<&8YZQj=`P)6^11Z>!O{l1-0_P~;il{lDhrwG-f&xWZ0@?5){{lRv$b zO1aLa`nRXU^_|cPf576n?9E~Twgo)34qjkmSnG=|sUU8uFLY--rPH%lV{kovDi#9} zt7M3~DCDvFdj{cUXZAfzVmt<4x{EP9ztZaHSCL=*9*b69>e!LY(XPO)YdrjA* zb(_M&mh=7;M<+gvt9We%gXMG=_dCJ6YaGbo37KO1HK02NMr5JT5fg$e=~y^`;3xX# za+W{BLo!9l(ra+XqpE-VGK~=lAd*LpxbO!wx`&xW1QpbLspk^%_MK9s_75$|+i;#N zQl0G}KJ~i6HZpc`niel+5z;m}FVByZuT#=1dw239-t3XzOV{CaAu}8}zaDnZE&{>8 z#Bvi3_cbk3G&dc&KE=&lPQYs2H^2I@?c7>KG1>z650Qe25~RXZIqZXbOA(qaO+4=( zhDAmLz>*>%+JmzTK*lLZd##cCzVl4z!i}0Urf%hffo))OKd3Y_kFzL6%-fKe-eYd3 zF}N2@tULV6)vun5%NjO}g=@-o-Ue+RZEGX=nCD87p+#qA>qQrAfth;2y#OG{o+FEt z`YZ!ft{vZ4?^m*g9jg~NHdD-lL$y>;SghuHoilOfy4XZX#qaa`CcEv&FF>_}@b|gf z)!clachHz+iK9ze$GI>%icXz|`~t<)5M%b#M8l4?5 z8|ZJLr}(z27*X$E(Nh z`>!qS4x%Zlmh#UU2;h&QIhc0_vX$z8X7Y!yvo(Dy30_)}*y}L|6I+;|`1eMQZTWY? zVlccQ1tSxoN;nkNPr^L$PnQGv0@RGw;m@S5i97qrKSTh9`FZ<)rg zwl>xJ8!m*0ephd1Zl~n5U^4EazMqhQH13LN8}p`zgimanP1hmNFVXuJ-fH?KC;*rv zi7)R2cfWd<`N;v}<(hNUH-JZkbt;t(>cVTCdcCu&9mBdlumuGbU7KdHuTB*eU;7B= z^h&whQlP6#l9mglq-j!eg%Y-Z zFL{>bmkP_J+~{KF%x;==_i^T|pskN36h*lEFh@16&BSm#jRgw7aV)8@!xKo(W(vkBerEwvJZUoG1l z|E*aJg@rLroO9=Gi`i677u6G}WfG}WQYdc3#~mH4@rloBMn;~9cO@8zSrYK3nt{Q` zxA%(Izy3;q$oB1h@R(TR+j|`$21t@zU4<|gQV=2L?~<%-ep0d8v0=CAD>0h zM~#CEH!>gda(W$A?UbLiY#~>_KxZ)M48XbKtjoU~&f7V_r#)J- zpk#p~`@Ti_e1R5?)i2_!sbPz>Ptu3pEXsS@W|QfIEw|sE2LVB|Wu*cZEtsqGiYh0L z0>zAxk}h7Cvqp?~Mf24ZG$AXjs3^ks^gQNl7mf)>q22riz05jhog`Eo#IHJ3i-n-l z!sgmk;8XZ^B>m~MMWx7hKF*rm^lGFnHj!;Q? zlY6oGd#QotqD6Y8N{?q5e*X?WAgWf;q*94;jX3*-oiXS7bP4a~I){C8jaPDQ8Z6TO zOBSE`gNSAN4)<v$T=fJw8$?vG(!ywgrbv+B6@1K^_tQ2X6oKXLt7( z3k`kht%8wJVC~7CZt?ib^9 z)ai5jdwSqBoSG$aF8M5|?m2?O5BoJsm;5cls$U)N^tfEnSkeXXw=M7PnOvcOgQs_! z?|S;87OPXLbe(eD-FgHf_$D;J)c40!Daq0D$5K!4pG(b-|901GpXe|*9 zVcOO0_EcB9-F}}BuJWxHu~4XXD;2U@Al{8=<)Fgh>9~0<_l>nRmjaO|uB+XF0awbE z-U!m(c56$u9YGZqOQjM$o;?cfxx99%W}shI$Z43Ga=H@9MARbyR>qv`>pYo}yc;>M z{~0pPD>m|JFQ^4}|pwH3h(eqPk5ySM`;>0;tA&KWys+68?H7x_O0 zgZ7uU%F1SKT&^uAE0}o%WVP)ca}a_@Rh7T@LH)`kd3`I8JV;0avRyNRKf*2xU|C%p z!nXY~-3;-{cF1J2TZK4Xca3?JfyV7SV%mDEkKZ+$&fhW_zWb%_J%9(KD+{b4O5hUs*Da&$?_aZoM#`h$c+|$0pz89T$#kW=?%SWxEu^wZ3$qxvL|ES& z5luwfqkiet8S)$%GpN(dqW>M{l@lJJCW zIivff6WMRb36Sz%UAS#^lhZjh`RrJ$;kh2>gE&6i{?+!xdJ|_~z5gP04EQ73T$pti zO?^Jmu!ll5JFmPA>Dp~z|2L75uq*6bxGYj1soMjqJB88mm{$TzYsLa@zo%ftB$d7` zoZXcbtvC4ez9nqUgvzJ#<&sa|7p?t07LiNhl0+wkzo5eUG$IYD z+d!rYfb0#MFCOE@hg|_@U>T`osm*T-^nnVX+}CfjIjfI)g5S}tVoZg`=}ccpN;{nu z&xwCe`mdOrGI`{b{FjD==y8^gBEIyf&Y8 ziES*XT;Vdg#>TrkD+C+~5wI+zPY^4MJ#ij0x&JnW`2hf1TBS8!22q45QQ9l>$!l^v z;HSGV*-vgE&oPl?YiSrBX2uXQgql+HP63*Vl}7g;nVvg?zMWp;wfHRk0jtc*S)fR5 zpHNACHfHLZ!(gtTr%i`yLheAhFmCP-0=etr@|DNpH35s?G8{dA>*w1{BCx*k`Zf@n zowX{Po9j6BT$Oujtzj-;44B5_|61^LLKso0PywHWRT-b1HJRg$KyyiNf5|iYQmOf3 zp8kG4snm4Tvq%*9)g)HJ%zN)s`@gXoH85>g zTf8Eg6Fj9T%g;}~RdzMWV7i0nw6<$)GdzG0gjHI!OlrChAYK*c`TQTsPj+;zmEo<; z`3?XyOX4^&SP=9o!ZLv{kEeLY_qcKiPoq9cPM4(0hXifPg@U2B=sv1=r}mY7$D{}n zAsQ{-B!34GdUAC9sW;O%xX$jW*L31{F836YFN#>RZo>OnG2KqQ*xc@`-sx(F$;1(s|#dF`x2XtjJV`vL4v-mf~n5fwB0j*on z1oj5D@MJQX?2tW!$#Bm<%Ac)GrxqAwGX3p(3Jh-)Vu#;2#tG4FnoZkZXf{Cr z;z{~q&&{P*Qvl$1)Vl1OAsaWO#|RyaA6ybVMf8g5hshMJg2%rTsL!7Qx)FRs*?FVMa6+NwYQNJO3;`mA7-chuV^EtK^w z!D1cF$t0Aix>o~(DRs+aJH3@%-Dq)?X1;%);^Ip{#V@r+tzur{M4)cnM|}5oUTXLkcVd9D&WoTMQ3eq)Rvio?QN|%VpDqtXvj0Rm_6FsVu3*f z*lwy;yAG|G$z+9j003s|9(1!kZjUcTH((p9s{A==vg)vEye5#X0_CWoKsXSZ#uC#v z*V;S0z4o9j)JWENrWOc=0_0Ux>cTx6zb!g<<;vo6k8Br*rI61$>YG4e@ppzi9wCx@ z1Vcavtx^!p9Pl$;e36K+b(7?^*Vd~AUyoh7Q~;p}Qu$8i2x05KV%79|ZF&W-`eO$? z`r+uROrt@q2Abq$wx~VQx&WAM@FbH=W>_-R`|3cCV`}Q<8==j0mQ`5=_=)kg#^D-& zbu~ifFKcbF#qF`i=|+E5pjMi!I;57U_GhcWLF}82cL4ZT``*@($qeZm*x_LSDnQl0 ztEyO0G4Z92T5UM`=V6io+_np=cDUOZx_|;D3%VU#fvNbKsIIMmL2!_l1>9D)iNE$; z*=+&@K$)~%hr_o2LgIs6`6EErtEP^PFFdG-$7sFB)afI>t49?&gXQ*3{!F+{blxyD zIJtIi{5@;`;-^xdx{FA;dUow~=*$&;wGY?};KzKwGi zqm;T64{zkCkJMIJ@y{Qf{`nTg@-0<<&oaq}>RkzC_zq5^m&JN4ORKlfPYXt7kNC)} zg(Z1;Hsh~b;F*(57tP8OiArhaYVfaMbFP5-ep0K==GB=s2+5Aa^kDUe8auDZ5z;FdyRB`B@H9O zr~i;5wg`Lr?2M2|L=RQ;jrrJk9$}p+uO7&1k!<{5nh1^grJPVC9viDWLMpPB)pZa{ z>KciAq_}&iBV(CLBLA0pdWe+?{3ct|fW5vQ?oq4-zjb#6`k?&+l{f6)4B9&zxL2Qn zt*e7#>d|Vob>zk~>Z+oSyvbSh5w4h2z;OI9pH!#=hZc$ws z#l>NSGI+bL{9TUE z_lKH+MTr5rCuz}f?XN^sy5-SXZU zy!BmxFxJIK&nrhJ>ST4&iSodvLxRc|d^s=nHVT7NzT8a~mj#+%d|$(92a~yOUo0Y` zTYq*GNjXeOG^#01r{5Y4R%kSjw*3m{Nd}f`ihy@$ehIM*TF$qI6b36I%30UDJkXeW zx>|X!O#zXUK9O*i*wqj!M3f{rNJ*}aq6_MRL%4!IeMlF~kt<^CAg?M?@awOrXo=mZ z(;9vs@B1yeTPY4Dc8|q$X);%-Nd6;Q^{ZF8t9_juk*?Dk!vxIPCwD9)*PsD(JP#~ z0yOH*2?J-hWsM{(i3Fs6Svnn%`K6MGBwSNn5vm9ybygfp|icxP^ni|tEz!9GF@H|J4hsh^{(<7nh3}6yM;;@_kfq- zp}PTJdxziDt9y~W&4g7~cNNx><=p@N^2Ib1uh?kYk>U3aw^)pUj~;k?3;F=PqyHu|BXQT73cfrUxZ8oq1z0YC= z<6e)?-QVv!qK&EQ&rsOFKAv+^=9V?h;apq4*IZu%n?a^OPK@Z5ED1w2Nn<02_176UI#l`dXFCWGVi7K2;ScT~9rp=4@t;`~4Au-)1gclSybr@3`?SHGNzp!; zp+Mm~@Yah5bDOb8UmnVBx{)fBQgo!4FvAhAdXorK4R3l%aJtU7@v1}vwEBI(i|o;~ z+QrsN365X@IuS7L_=%X*Khjev{w%a##>*Ls-+om5fZOZb~k)&sF-(sip{z< z#VdYo3Viu8@r9UvGbxIk9#UX>kki8o+$cHpk?9k%*~b4%v(`bg-t7d3~J& zFV}x_R6~7xe0Ju!ZZhlg3`-ZlV#^$?>+Gix3zr?6ddvJfWU!bgnI%gvD9bN}|9x!q zQvSQ5qP*F_{DH3cVNI>Rv3gjI0{{*J#JL;@Af%rFV1I%gjum6(?cToNJWsA3Jnc|6 z*-5w6pyjv+9j`*R$cJbz{)Jo~{m7uvF-(0v%~TKiH$=ul?vQ(u@=0V~ARTYG15tMi zXp_`ARYuR7!$z6nq67(MuXqrpW$d0=@2bamp)iXsuP_2Ea{6EMzl+ohNyID zyphfN`zHoDE?kBM>j9}BET0adGaM5QXDBpdn~?6Tr`R8I`vICyy~}~BR(i+`ysWv= zvXMY40$C@BHDMdpY4V2YbwyeP3u4 zW4=9KriaX`X=$hMWK1D?279OW(RK5+LK%*Z$v|x7Ra=lh4q)5iTis3;FH7$n!qN&W zCW39FGfrqi9?n+fBY9ak%pe-rxQC)b^0?jMT}f4#Kjx_!pKmRjJy<|ykeCyU!n{1p z@2}+Q{qyJ#@^J{Vzu5CCXLD|Vnm?0Q@VI&-HD|)2iBSH&2;mj7VkYd zQyz;`v+GXI-bCmf@Jj#Mq?vf8>gJYHV!U*?`2As55sPN>IGO`Z+i_pZFV+SdcXcY+ zRd{u`PPm~MZrIi$dff!KRSUd%CnrF+IqdNc;t7<=ARP37=mtklXvtXW{?2se{WnvG z9JuQ=NirOrhA45pdFwNB(iS*cp?Cruz%*%5;wG+jB#qOG0W3*nV(R16^_wJk8Q17p zpTH|fn7L+W2gQ||`g1b8EWLwMO87yZ(vYw>(%bj?jHk_O_1ebN4U$FdAR&rymHnz> zPjKOc)6M&nA83GTkR9Am-D9^k)fe#6?w>cyi&&D+=DeATw1U?teTiGr`&M#MQL*Vv zo2-b<{$;*0BU1{6a?S0f%E4_NU^}=h?D5s7RZ67#znz68&H{nZV$e>|dLd_I3$()Jn>9VN8HD5|)iiTD^Zf?(IzA1XoH zFVM!aV%e8e)6`_Bv(j3eI9}+<{qwCWE#eOewN+NEY^z3az8LSZDI}CO#=)=Cid0~L%2B_>6kO+rrW6&r z3IjG|xoA-X9ZFqzf@taVEgFsfp6&C7wK+nh{xlkk4zie-t-_`GC8by7_E+vWTzH`d zuP_ruW%dZr1d3(?gn&Fwz~j(R;~j<7JD6Yd6%5gtT5|J!hTL zWKm7vSMDA4iPA|trjS;qjx1YN6`ae1-3xM!WKc#0c(X(&TfdA2-J5U|LF$Q)V;qZ` z&1Nk}agepetnoMXKR*6jzA)q0`PR1n&1vSx2xuv62;ys;m9WCazeEv%U1S&q?g>hK zC>ZDTpUqF_7nVJ-B$9mo#t;;q1oOgeKtbR*nef9cd!9}H?O;KclQEPbWDUsx25G3Z zSs)wUK&~kG6?odZ=5OHBICi%)mfp-FHnMb*tvP8;1Ey21*IND*tiz0=UiQtk(G_-< z&)cIZnv2wUTYP^N|JZ2Qc%XebMg6#PtUJyU(KKkXl2-&ZDeO<#DgVB*MZYm(V zikncN(7dI$=9!W9Mc<062%K8nlFt6JAdH;M@t^J$Mb@D6 zW$zl`_1qWvSNj$1ORb&n)P^xD>-Vj4olP2U)O1J9Tg=r_xQAkPK-RQCv#kcId`ojx z-WG1WtaGm&PmJnn9$n{k#VxV6_RYR0VC2_HHWGZt(A(N>4O=5CF^!nU&dxwqW*i7X zjc=kO=;@5VNnRi1p)?+iZaYU$Qj0(lW@q2}GFgU&*>(lHvT&2OfpqzwupS|2gTagN zf%?pG=X^$hV+`M$;pFOt~fV!wf|_xF&qjE+I_j30Tj(V zfk&8QAJnPSu`lO{B!WN1@4emmr=?)R_0?$y4x62;vC5L6MM&9y@$<(#E z$BP66ked+#)Aj?g)-%M-I0?z}PeZy;XqiD|CV$InJ_jQgESNA!Zt-{M$Nyh=s<*e# zOrn&se0e1*rBi`&EPP_wq1%VT9fd*4L0MbYLAO$4VhoezWHxh|?h7CUCPi76YOa62$jLPbX+kkQ66I=Js&r7; zhc~jG;+#MBBO2x3LEXec&$JyiKH>M{6QzPZ@tHJXC>F8;vGp6d+_KmQwx9s45#^qF z#S`fn%BmKt_KzeCBcHZX#%s}t05*Hfx1+#*_l-lFOF0`At#65D;sE3edqb1B0$l1p z9P%iZ*v!}`v_;$#kL!lMIGag|uO!1RmfUce`dI##u+aXxPvbBJ1$>C=><;8sOt7sytLr_;}rV z&D>pSr+uwG>RP$?V+^|BkwSyUpcfhKQuMpfp>T9N?4fjtbH2z2L0+)3$XUb*WQfc4 z^3XkzgsbN;cwFZCXVq;%2^WH(5!n+h7TbU_`ck*fN;eqh`wtB4+q}FQEdLPUt`DzfFTgByfiO*_Y9-OALYmLs3Sy8}hO^1ZF?;1!M+@*B+i$vYE3h0}k?w;1nwh4JLE zug^K}agP9&!Xs{Wm_a1eGdD~+RP9=6c{vhSZ_*7yqTHnuWH>Rz+XI3;qBupx#Jzk8 z00gk>?PZm@V+*0?1_m2<^0Rv?S=bLRB6V~HBBJ=rLAmI%WPck9EoZ2K{hDLC1qm-t z>;2$!&)|!#`vkC?M)NrWi2s48X$8gyX|QH$cTZVv=^jM?ixmIIUHMCeh~PFQHqxzi zKhMM9Sia?Hz2AQB8Spv7>ol4>x5_RC-bSDc(8Yg*x~;AH_|1699vJ>Yp^VE_Kbyr* zqV2b#X%h=d^NL|bVq=XDaXlV~50QFOy6$JulG;$}%0{B=E3J|mIC~F`FL9payYYbL zZEy*W6Pajy(&_l=d2-fr*L8d1!4>Js8%f2Zl!;8{Q~5l#!()1bB(*;P2jjnzY{!L# zc{^_Ksx0u49uwruuxzaiH2c+{8IGa&843r1(Dz{4d2}Q*Mwx~p*c(6>zXz_al47$*O0!}~&hb^pMV=wTXWVHc2l8p!x4Z6vGNp$hVfqthz85a7CH5`{% z!e5v35-{pM=@0WQpuuBA&GF>jymmfC^Yf)<@J(M7!!Z=A{DY5A)?iDk|3$)aaJtKU zuejQBZ!m`)tiC0grKnj|O$>Ih%J^mbl4!IuDvDli6#6h4I&ZrIyFoc5gf-OUYOg7t_GdB2sob&e==R>E?g%@(za~ny1!a{2H zFz>y4cBSNh17MQ(#GAeQcCz#c;%D&#Jw$fhCXst{judoL-Nu}>`#69@MMZ2iUG;B` zfzC9(%Dc)F{3EIcW*O2pd&~Bz$DUfLZ;g22J!ni5RyGaq`QPfod5OMUbqN_XD5bLD zFUkE(7q>t*BcHns+nARf`tv^@-j7P+oG zqlZ04=~e_-E%N)^wRyJ6&lk#lLVt~6K}~s}%>U~`##5EI5urO9$o7ZazK0x14*J$c zV_31)if1!?Ej5I{K(}shZ+VKW;ru}EK}^1z$pE2Risg=1 zOYU{(JPqazqM!9z`U5(jmt*Z7eHDe}CMzOka`A9@N0B=)J|5R~AV>GuD^-t;S#uls zi8-Jt>ex>v-DZQE!xnu>uKb6t9-g8@5$640u#;ZPX)ZXlhrBXjl+$!@G+3+(Wij0k ze=jaJ@f(F1m63!;ptRtJpx+WmC>bRGSt3ae3M2)g(OZMd?(LR)_qgE6AJ4zl;~V8H zgMB-T!1QGv0qnzKAI;Kn3e024g>sc}6`n+g{J7`eTE}-)6#Q)^?A)O(b*qcyw!voG zb{c(HLUDe{1ahzHZ-!y=^P|Tb@P5mViX)lVpbnZ@jhjJ3>U_b-J2Np+ZH98WjtZu# zC0KvV0`B2LP`R`*4t|-J05dOZG4Ne7e~Z;NIKe;6G+5U?q+B`|46zXJkaOFwM5J_K zb!ihf0Y@Wnp%l`gDjwe(oz`VNZgJ_Y)Y6?-iYaUb*DOv2tcPm8l0+c%!A0YT2#!rx z>A&_^5W00=m+#=lXeKUa_FwtehXs_=B)v^oa9})l3mN&C6m1^oXjwaZO=Z@P#6@F) z!>8j`S75voDwFz+3e{9n?*htL|=$0x{QNV{TnmOG&2t4*W){a->I1`8$b0Z+$S}M{$w_DR8kk zpWzS)&qz+|E+g7;m^_9PHbnS|A?P^EJ2eg>2-{nduxf?OR7+6WY0OB-dR+)mLeptwIE!)G&{kGC{L%CvAp8>=uI+f z)z{kOxJ{B?MV-Ux&1=b}7SjAhMs$wUoiMSHiq-$^gsg(@c0IqV#Xz%4*8H~vMv6L9 zn$oUjH401B3%XMmY=z{y_n0eoM1`&$We51WxB93jvBU}`Fy&>p!@94yA;rAIuh+_O ztjeL8JGQ7j7$M1m_~$x%B~0pfaIe4=-6!<}Wb5})4bk@NsxGD7z)z_xr08NDsWpO< zR3O8=&zPO{du<~N7?yA{jVR&YFw>pPJ(iO?_%R-(zYWX&O<&niQGi#G3V2xPZTVK~ ztiEON?jYUy-N0Q>eV%-rDZO2PnTgvt{QLzN1m(KbB&+WsNM@HnsO7AnGdNro;BB|} zG6fS!#l&vErd-nzFI{2^YMmykK7M9CgX`QX}&lvkd} zR6PE3bgPeNy)V5)TfKzZOj>^6nj3!RL43Z-Sh-b)=!0`Ov-vsq_yfW}f52S*)`MJ8 zAdA6k&x?lr@lkvjk*$Qt2JBqmg1$@3T!|*#0X(2Mcz> zz4^rDHTKPa#-rXJNI@H6mXE})r>C8wqY&_IZDx+N1mJ(Kemw15Cyjo#%bNU3m#(*U^dH&yL&sR_myysJ&j3lz zs$8MCHbD}cT&*lzv`8dcv?ibW%4;g^Y$uI=t}`S_%j^~~TpbcTv#yg#lz8GotP)Z3bvQiF4GlCQGHY}OxT!KFSe6Q+A4iC5_zEyt!mSM*C zAImZMbSspuM2Uf2-Jn0}i_YMP(W|Q+zRQR1uAC5}zsRAhMn?lp?=+ta`c^G4!@0ab z?Sq(n4?_%`2p1#OFbE-9HbT)sBcotNiWSSm>&;5HRnNRnjL8Vp=$BOOpKqosi+uha zCKW=c*VP2HZE162(EI5BuBy~OtSA&WR@LUdc36!K01iTCItjS`hystm&a{*n93LuH zsn%(5%$J*d_crr6FWOfuE8^si60SWXrVtT@ix!D17OlBYeYq3AkeyVz!sdP02R$Wp z3~43S4RyC%h|lV>&Ao=i(+QP0Hl5MiBZkVOxn4!7LD)rPQ^DUQOgJ;z&dYv0(?Pf4 z*tQoXMnK0=%b5>7{J5OyeP3;sB(DQxbdnRgpxjP1qz^M)f!Jc^pO69Is+85uo8=6i z<@<&{n^%k__YS7=Hf(z9PfI-8iMPxrN#1M7DeV7?G9%kBM&Dx9s+oe#q($SC!TYK8 z%DpmCLZrO%$k346tmW0YwvN7}w#?C7Z*0(nO-(w#-Uq=&@RX$Y8#P?I>WI_SZIw_nMYufYFE{)*& zkbq<2>bc^X?OFMG#bgpM$Glvpn#g~jB~uEw|FJZY4QGAN z(I!WcVdwK!t+;r=)7%nWE)Qv2>;NwC!{4(cQ~nel?e5}a%3@FG0$(P5+1RQ=4w6KZcSlGf)YzQe59XZ)piv2N- z_%2R_T~yl${gi`NVCtZse|o#V;V}H?_VzRI zzM><`hh?(Xx?^It`iCH1jWW1uoDp0-POh;xJcPH_9~b3S%L8D&cq0FBOLgsoNZ9v+ z+xI)C3R8IJG$*WHbF9E0@kgPB^^d$R^xTmdCElqoolSt@fIwkIup$WR_26uEMnxo? z?tbjSm8>wVg5Q@i=`R=W@O>0Gk>cn=be$pg+45

vr&aMTY{|sN{{BmyU>|w2Ud! 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 literal 0 HcmV?d00001 diff --git a/src/assets/fonts/roboto.css b/src/assets/fonts/roboto.css index 2efd24522..8e64a2e07 100644 --- a/src/assets/fonts/roboto.css +++ b/src/assets/fonts/roboto.css @@ -145,3 +145,9 @@ font-display:swap; unicode-range: U+0600-06FF; } + +@font-face { + font-family: 'Roboto Round'; + src: url('Roboto-Round-Regular.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 642269eb2..11243d67f 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -1283,6 +1283,10 @@ "CreditsBoxHistoryEntryGiftOutAbout" = "With Stars, {user} will be able to unlock content and services on Telegram. {link}" "CreditsBoxOutAbout" = "Review the {link} for Stars." "GiftStarsOutgoing" = "With Stars, {user} will be able to unlock content and services on Telegram." +"SendPaidReaction" = "Send ⭐️{amount}" +"StarsReactionTerms" = "By sending Stars you agree to the {link}" +"StarsReactionLinkText" = "Terms of Service" +"StarsReactionLink" = "https://telegram.org/tos/stars" "MiniAppsMoreTabs_one" = "{botName} & {count} Other"; "MiniAppsMoreTabs_other" = "{botName} & {count} Others"; "PrizeCredits" = "Your prize is {count} Stars." diff --git a/src/assets/tgs/stars/StarReaction.tgs b/src/assets/tgs/stars/StarReaction.tgs new file mode 100644 index 0000000000000000000000000000000000000000..2b5234fd500470cb2844a494a513c0042a8e0f51 GIT binary patch literal 5734 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 YgbBc5v%)(!D;&N3f4k0BC&P*W0Igad0{{R3 literal 0 HcmV?d00001 diff --git a/src/assets/tgs/stars/StarReactionEffect.tgs b/src/assets/tgs/stars/StarReactionEffect.tgs new file mode 100644 index 0000000000000000000000000000000000000000..2cad9593e3ff9480050e5c66c2679013be84e1c3 GIT binary patch literal 23087 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 LWg!;; }; const ANIMATION_TIME = 200; @@ -31,6 +32,7 @@ const AnimatedCounter: FC = ({ text, className, isDisabled, + ref, }) => { const { isRtl } = useLang(); @@ -58,7 +60,7 @@ const AnimatedCounter: FC = ({ }, [shouldAnimate, text]); return ( - + {characters} ); diff --git a/src/components/common/Composer.tsx b/src/components/common/Composer.tsx index b2d751860..7127370a7 100644 --- a/src/components/common/Composer.tsx +++ b/src/components/common/Composer.tsx @@ -50,11 +50,13 @@ import { import { requestMeasure, requestNextMutation } from '../../lib/fasterdom/fasterdom'; import { getAllowedAttachmentOptions, + getReactionKey, getStoryKey, hasReplaceableMedia, isChatAdmin, isChatChannel, isChatSuperGroup, + isSameReaction, isUserId, } from '../../global/helpers'; import { @@ -436,8 +438,7 @@ const Composer: FC = ({ const { emojiSet, members: groupChatMembers, botCommands: chatBotCommands } = chatFullInfo || {}; const chatEmojiSetId = emojiSet?.id; - const isSentStoryReactionHeart = sentStoryReaction && 'emoticon' in sentStoryReaction - ? sentStoryReaction.emoticon === HEART_REACTION.emoticon : false; + const isSentStoryReactionHeart = sentStoryReaction && isSameReaction(sentStoryReaction, HEART_REACTION); useEffect(processMessageInputForCustomEmoji, [getHtml]); @@ -1503,9 +1504,11 @@ const Composer: FC = ({ let text: string | undefined; let entities: ApiMessageEntity[] | undefined; - if ('emoticon' in reaction) { + if (reaction.type === 'emoji') { text = reaction.emoticon; - } else { + } + + if (reaction.type === 'custom') { const sticker = getGlobal().customEmojis.byId[reaction.documentId]; if (!sticker) { return; @@ -1983,7 +1986,7 @@ const Composer: FC = ({ > {sentStoryReaction && ( void; - onReactionSelect?: (reaction: ApiReaction) => void; selectedReactionIds?: string[]; isStatusPicker?: boolean; isReactionPicker?: boolean; isTranslucent?: boolean; + onCustomEmojiSelect: (sticker: ApiSticker) => void; + onReactionSelect?: (reaction: ApiReactionWithPaid) => void; + onReactionContext?: (reaction: ApiReactionWithPaid) => void; onContextMenuOpen?: NoneToVoidFunction; onContextMenuClose?: NoneToVoidFunction; onContextMenuClick?: NoneToVoidFunction; @@ -86,6 +87,7 @@ type StateProps = { canAnimate?: boolean; isSavedMessages?: boolean; isCurrentUserPremium?: boolean; + isWithPaidReaction?: boolean; }; const HEADER_BUTTON_WIDTH = 2.5 * REM; // px (including margin) @@ -128,8 +130,10 @@ const CustomEmojiPicker: FC = ({ defaultTopicIconsId, defaultStatusIconsId, defaultTagReactions, + isWithPaidReaction, onCustomEmojiSelect, onReactionSelect, + onReactionContext, onContextMenuOpen, onContextMenuClose, onContextMenuClick, @@ -186,7 +190,10 @@ const CustomEmojiPicker: FC = ({ } if (isReactionPicker && !isSavedMessages) { - const topReactionsSlice = topReactions?.slice(0, TOP_REACTIONS_COUNT) || []; + const topReactionsSlice: ApiReactionWithPaid[] = topReactions?.slice(0, TOP_REACTIONS_COUNT) || []; + if (isWithPaidReaction) { + topReactionsSlice.unshift({ type: 'paid' }); + } if (topReactionsSlice?.length) { defaultSets.push({ id: TOP_SYMBOL_SET_ID, @@ -271,6 +278,7 @@ const CustomEmojiPicker: FC = ({ addedCustomEmojiIds, isReactionPicker, isStatusPicker, withDefaultTopicIcons, recentCustomEmojis, customEmojiFeaturedIds, stickerSetsById, topReactions, availableReactions, lang, recentReactions, defaultStatusIconsId, defaultTopicIconsId, isSavedMessages, defaultTagReactions, chatEmojiSetId, + isWithPaidReaction, ]); const noPopulatedSets = useMemo(() => ( @@ -303,10 +311,6 @@ const CustomEmojiPicker: FC = ({ onCustomEmojiSelect(emoji); }); - const handleReactionSelect = useLastCallback((reaction: ApiReaction) => { - onReactionSelect?.(reaction); - }); - function renderCover(stickerSet: StickerSetOrReactionsSetOrRecent, index: number) { const firstSticker = stickerSet.stickers?.[0]; const buttonClassName = buildClassName( @@ -441,7 +445,8 @@ const CustomEmojiPicker: FC = ({ selectedReactionIds={selectedReactionIds} availableReactions={availableReactions} isTranslucent={isTranslucent} - onReactionSelect={handleReactionSelect} + onReactionSelect={onReactionSelect} + onReactionContext={onReactionContext} onStickerSelect={handleEmojiSelect} onContextMenuOpen={onContextMenuOpen} onContextMenuClose={onContextMenuClose} @@ -495,6 +500,7 @@ export default memo(withGlobal( topReactions: isReactionPicker ? topReactions : undefined, recentReactions: isReactionPicker ? recentReactions : undefined, chatEmojiSetId: chatFullInfo?.emojiSet?.id, + isWithPaidReaction: isReactionPicker && chatFullInfo?.isPaidReactionAvailable, availableReactions: isReactionPicker ? availableReactions : undefined, defaultTagReactions: isReactionPicker ? defaultTags : undefined, }; diff --git a/src/components/common/PeerBadge.module.scss b/src/components/common/PeerBadge.module.scss new file mode 100644 index 000000000..fba40137c --- /dev/null +++ b/src/components/common/PeerBadge.module.scss @@ -0,0 +1,44 @@ +.root { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.top { + display: grid; + place-items: center; + position: relative; +} + +.badge { + position: absolute; + bottom: -0.5rem; + left: 50%; + transform: translateX(-50%); + z-index: 1; + + display: flex; + align-items: center; + gap: 0.125rem; + font-size: 0.75rem; + font-weight: 500; + line-height: 1; + white-space: nowrap; + padding: 0.25rem; + + background-color: var(--color-primary); + color: var(--color-white); + border: 2px solid var(--color-background); + border-radius: 1rem; +} + +.text { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + text-align: center; + font-size: 0.875rem; + width: 100%; + margin-bottom: 0; +} diff --git a/src/components/common/PeerBadge.tsx b/src/components/common/PeerBadge.tsx new file mode 100644 index 000000000..4e3d30875 --- /dev/null +++ b/src/components/common/PeerBadge.tsx @@ -0,0 +1,50 @@ +import React, { memo } from '../../lib/teact/teact'; + +import type { ApiPeer } from '../../api/types'; +import type { CustomPeer } from '../../types'; +import type { IconName } from '../../types/icons'; + +import buildClassName from '../../util/buildClassName'; + +import Avatar from './Avatar'; +import Icon from './icons/Icon'; + +import styles from './PeerBadge.module.scss'; + +type OwnProps = { + peer: ApiPeer | CustomPeer; + text?: string; + badgeText: string; + badgeIcon?: IconName; + className?: string; + badgeClassName?: string; + onClick?: NoneToVoidFunction; +}; + +const PeerBadge = ({ + peer, + text, + badgeText, + badgeIcon, + className, + badgeClassName, + onClick, +}: OwnProps) => { + return ( +

+
+ +
+ {badgeIcon && } + {badgeText} +
+
+ {text &&

{text}

} +
+ ); +}; + +export default memo(PeerBadge); diff --git a/src/components/common/Sparkles.module.scss b/src/components/common/Sparkles.module.scss new file mode 100644 index 000000000..32ab0d680 --- /dev/null +++ b/src/components/common/Sparkles.module.scss @@ -0,0 +1,49 @@ +.root { + position: absolute; + width: 100%; + height: 100%; + z-index: -1; + line-height: 1; + pointer-events: none; +} + +.progress { + --_progress: 0; + + z-index: 0; + font-size: 0.75rem; + opacity: 0.8; + overflow: hidden; +} + +.reaction { + font-size: 0.5rem; +} + +.symbol { + --_duration-shift: 0s; + --_shift-x: 0; + --_shift-y: 0; + + position: absolute; + + width: 0.5rem; + height: 0.5rem; + + animation: sparkle 5s infinite; + animation-delay: var(--_duration-shift); +} + +@keyframes sparkle { + 0% { + opacity: 0; + transform: translate(0, 0); + } + 15% { + opacity: 1; + } + 100% { + opacity: 0; + transform: translate(var(--_shift-x), var(--_shift-y)); + } +} diff --git a/src/components/common/Sparkles.tsx b/src/components/common/Sparkles.tsx new file mode 100644 index 000000000..10efd7d7b --- /dev/null +++ b/src/components/common/Sparkles.tsx @@ -0,0 +1,156 @@ +import React, { memo } from '../../lib/teact/teact'; + +import buildClassName from '../../util/buildClassName'; +import buildStyle from '../../util/buildStyle'; + +import styles from './Sparkles.module.scss'; + +type ReactionParameters = { + preset: 'reaction'; +}; + +type ProgressParameters = { + preset: 'progress'; +}; + +type PresetParameters = ReactionParameters | ProgressParameters; + +type OwnProps = { + className?: string; +} & PresetParameters; + +const SYMBOL = '✦'; +const ANIMATION_DURATION = 5; + +// Values are in percents +const REACTION_POSITIONS = [{ + x: 20, + y: 0, + size: 100, + durationShift: 10, +}, { + x: 15, + y: 15, + size: 75, + durationShift: 70, +}, { + x: 10, + y: 35, + size: 75, + durationShift: 90, +}, { + x: 20, + y: 70, + size: 125, + durationShift: 30, +}, { + x: 40, + y: 10, + size: 125, + durationShift: 0, +}, { + x: 45, + y: 60, + size: 75, + durationShift: 60, +}, { + x: 60, + y: -10, + size: 100, + durationShift: 20, +}, { + x: 55, + y: 40, + size: 75, + durationShift: 60, +}, { + x: 70, + y: 65, + size: 100, + durationShift: 90, +}, { + x: 80, + y: 10, + size: 75, + durationShift: 30, +}, { + x: 80, + y: 45, + size: 125, + durationShift: 0, +}]; +const PROGRESS_POSITIONS = generateRandomProgressPositions(100); + +const Sparkles = ({ + className, + ...presetSettings +}: OwnProps) => { + if (presetSettings.preset === 'reaction') { + return ( +
+ {REACTION_POSITIONS.map((position) => { + const shiftX = Math.cos(Math.atan2(-50 + position.y, -50 + position.x)) * 100; + const shiftY = Math.sin(Math.atan2(-50 + position.y, -50 + position.x)) * 100; + return ( +
+ {SYMBOL} +
+ ); + })} +
+ ); + } + + if (presetSettings.preset === 'progress') { + return ( +
+ {PROGRESS_POSITIONS.map((position) => { + return ( +
+ {SYMBOL} +
+ ); + })} +
+ ); + } + + return undefined; +}; + +function generateRandomProgressPositions(count: number) { + const positions = []; + for (let i = 0; i < count; i++) { + positions.push({ + x: Math.random() * 100, + y: Math.random() * 100, + velocityX: (Math.random() * 5 + 15) * 100, + velocityY: (Math.random() * 10 - 5) * 100, + scale: (Math.random() * 0.5 + 0.5) * 100, + durationShift: Math.random() * 100, + }); + } + return positions; +} + +export default memo(Sparkles); diff --git a/src/components/common/StickerSet.tsx b/src/components/common/StickerSet.tsx index a85c8a309..d8a1c2bcf 100644 --- a/src/components/common/StickerSet.tsx +++ b/src/components/common/StickerSet.tsx @@ -4,7 +4,7 @@ import React, { } from '../../lib/teact/teact'; import { getActions, getGlobal } from '../../global'; -import type { ApiAvailableReaction, ApiReaction, ApiSticker } from '../../api/types'; +import type { ApiAvailableReaction, ApiReactionWithPaid, ApiSticker } from '../../api/types'; import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import type { StickerSetOrReactionsSetOrRecent } from '../../types'; @@ -35,7 +35,7 @@ import useWindowSize from '../../hooks/window/useWindowSize'; import Button from '../ui/Button'; import ConfirmDialog from '../ui/ConfirmDialog'; import Icon from './icons/Icon'; -import ReactionEmoji from './ReactionEmoji'; +import ReactionEmoji from './reactions/ReactionEmoji'; import StickerButton from './StickerButton'; import grey from '../../assets/icons/forumTopic/grey.svg'; @@ -65,7 +65,8 @@ type OwnProps = { observeIntersectionForShowingItems: ObserveFn; availableReactions?: ApiAvailableReaction[]; onStickerSelect?: (sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean) => void; - onReactionSelect?: (reaction: ApiReaction) => void; + onReactionSelect?: (reaction: ApiReactionWithPaid) => void; + onReactionContext?: (reaction: ApiReactionWithPaid) => void; onStickerUnfave?: (sticker: ApiSticker) => void; onStickerFave?: (sticker: ApiSticker) => void; onStickerRemoveRecent?: (sticker: ApiSticker) => void; @@ -105,6 +106,7 @@ const StickerSet: FC = ({ observeIntersectionForPlayingItems, observeIntersectionForShowingItems, onReactionSelect, + onReactionContext, onStickerSelect, onStickerUnfave, onStickerFave, @@ -351,6 +353,7 @@ const StickerSet: FC = ({ availableReactions={availableReactions} observeIntersection={observeIntersectionForPlayingItems} onClick={onReactionSelect!} + onContextMenu={onReactionContext} sharedCanvasRef={sharedCanvasRef} sharedCanvasHqRef={sharedCanvasHqRef} forcePlayback={forcePlayback} diff --git a/src/components/common/helpers/animatedAssets.ts b/src/components/common/helpers/animatedAssets.ts index 0608051fd..4053e6b0f 100644 --- a/src/components/common/helpers/animatedAssets.ts +++ b/src/components/common/helpers/animatedAssets.ts @@ -26,6 +26,8 @@ import FoldersAll from '../../../assets/tgs/settings/FoldersAll.tgs'; import FoldersNew from '../../../assets/tgs/settings/FoldersNew.tgs'; import FoldersShare from '../../../assets/tgs/settings/FoldersShare.tgs'; import Lock from '../../../assets/tgs/settings/Lock.tgs'; +import StarReaction from '../../../assets/tgs/stars/StarReaction.tgs'; +import StarReactionEffect from '../../../assets/tgs/stars/StarReactionEffect.tgs'; import Unlock from '../../../assets/tgs/Unlock.tgs'; export const LOCAL_TGS_URLS = { @@ -58,4 +60,6 @@ export const LOCAL_TGS_URLS = { LastSeen, Mention, Fragment, + StarReactionEffect, + StarReaction, }; diff --git a/src/components/common/icons/StarIcon.tsx b/src/components/common/icons/StarIcon.tsx index 8721cc9d4..756b1f7f7 100644 --- a/src/components/common/icons/StarIcon.tsx +++ b/src/components/common/icons/StarIcon.tsx @@ -11,18 +11,19 @@ type OwnProps = { type?: 'gold' | 'premium' | 'regular'; size?: 'small' | 'middle' | 'big' | 'adaptive'; className?: string; + style?: string; onClick?: VoidFunction; }; /* eslint-disable max-len */ const STAR_PATH = 'M6.63869 12.1902L3.50621 14.1092C3.18049 14.3087 2.75468 14.2064 2.55515 13.8807C2.45769 13.7216 2.42864 13.5299 2.47457 13.3491L2.95948 11.4405C3.13452 10.7515 3.60599 10.1756 4.24682 9.86791L7.6642 8.22716C7.82352 8.15067 7.89067 7.95951 7.81418 7.80019C7.75223 7.67116 7.61214 7.59896 7.47111 7.62338L3.66713 8.28194C2.89387 8.41581 2.1009 8.20228 1.49941 7.69823L0.297703 6.69116C0.00493565 6.44581 -0.0335059 6.00958 0.211842 5.71682C0.33117 5.57442 0.502766 5.48602 0.687982 5.47153L4.35956 5.18419C4.61895 5.16389 4.845 4.99974 4.94458 4.75937L6.36101 1.3402C6.5072 0.987302 6.91179 0.819734 7.26469 0.965925C7.43413 1.03612 7.56876 1.17075 7.63896 1.3402L9.05539 4.75937C9.15496 4.99974 9.38101 5.16389 9.6404 5.18419L13.3322 5.47311C13.713 5.50291 13.9975 5.83578 13.9677 6.2166C13.9534 6.39979 13.8667 6.56975 13.7269 6.68896L10.9114 9.08928C10.7131 9.25826 10.6267 9.52425 10.6876 9.77748L11.5532 13.3733C11.6426 13.7447 11.414 14.1182 11.0427 14.2076C10.8642 14.2506 10.676 14.2208 10.5195 14.1249L7.36128 12.1902C7.13956 12.0544 6.8604 12.0544 6.63869 12.1902Z'; -const GOLD_STAR_PATH = 'M10.5197 16.2049L6.46899 18.6864C6.04779 18.9444 5.49716 18.8121 5.23913 18.3909C5.11311 18.1852 5.07554 17.9373 5.13494 17.7035L5.762 15.2354C5.98835 14.3444 6.59803 13.5997 7.42671 13.2018L11.8459 11.0801C12.0519 10.9812 12.1387 10.734 12.0398 10.528C11.9597 10.3611 11.7786 10.2677 11.5962 10.2993L6.67709 11.1509C5.67715 11.324 4.65172 11.0479 3.87392 10.3961L2.31994 9.09382C1.94135 8.77655 1.89164 8.21245 2.20891 7.83386C2.36321 7.64972 2.58511 7.53541 2.82462 7.51667L7.5725 7.1451C7.90793 7.11885 8.20025 6.90658 8.32901 6.59574L10.1607 2.17427C10.3497 1.71792 10.8729 1.50123 11.3292 1.69028C11.5484 1.78105 11.7225 1.95514 11.8132 2.17427L13.6449 6.59574C13.7736 6.90658 14.066 7.11885 14.4014 7.1451L19.1754 7.51871C19.6678 7.55725 20.0358 7.9877 19.9972 8.48015C19.9787 8.71704 19.8666 8.93682 19.6858 9.09098L16.0449 12.1949C15.7886 12.4134 15.6768 12.7574 15.7556 13.0849L16.8749 17.7348C16.9905 18.215 16.6949 18.698 16.2147 18.8137C15.9839 18.8692 15.7406 18.8307 15.5382 18.7068L11.4541 16.2049C11.1674 16.0292 10.8064 16.0292 10.5197 16.2049Z'; /* eslint-enable max-len */ const StarIcon: FC = ({ type = 'regular', size = 'small', className, + style, onClick, }) => { const randomId = useUniqueId(); @@ -38,6 +39,7 @@ const StarIcon: FC = ({ onClick && styles.clickable, styles[size], )} + style={style} > {type === 'gold' ? @@ -49,66 +51,75 @@ const StarIcon: FC = ({ }; function GoldStarIcon({ randomId }: { randomId: string }) { - const fillId = `${randomId}-fill`; - const stroke1Id = `${randomId}-stroke1`; - const stroke2Id = `${randomId}-stroke2`; + const mask1Id = `${randomId}-mask1`; + const mask2Id = `${randomId}-mask2`; + const gradient1Id = `${randomId}-gradient1`; + const gradient2Id = `${randomId}-gradient2`; + const gradient3Id = `${randomId}-gradient3`; return ( - + + + + + + + + + + + + + + + + - - - + + - - + + - - - - - + + + + + - - ); } diff --git a/src/components/common/reactions/PaidReactionEmoji.tsx b/src/components/common/reactions/PaidReactionEmoji.tsx new file mode 100644 index 000000000..f40077830 --- /dev/null +++ b/src/components/common/reactions/PaidReactionEmoji.tsx @@ -0,0 +1,148 @@ +import React, { + memo, useMemo, useRef, useState, +} from '../../../lib/teact/teact'; +import { getActions, withGlobal } from '../../../global'; + +import type { ApiReaction, ApiReactionPaid } from '../../../api/types'; +import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; + +import { isSameReaction } from '../../../global/helpers'; +import { selectPerformanceSettingsValue, selectTabState } from '../../../global/selectors'; +import buildClassName from '../../../util/buildClassName'; +import { IS_ANDROID, IS_IOS } from '../../../util/windowEnvironment'; +import { LOCAL_TGS_URLS } from '../helpers/animatedAssets'; +import { REM } from '../helpers/mediaDimensions'; + +import useEffectWithPrevDeps from '../../../hooks/useEffectWithPrevDeps'; +import { useIsIntersecting } from '../../../hooks/useIntersectionObserver'; +import useLastCallback from '../../../hooks/useLastCallback'; +import useShowTransition from '../../../hooks/useShowTransition'; + +import AnimatedIcon from '../AnimatedIcon'; +import StarIcon from '../icons/StarIcon'; + +import styles from './ReactionAnimatedEmoji.module.scss'; + +type OwnProps = { + containerId: string; + reaction: ApiReactionPaid; + className?: string; + size?: number; + effectSize?: number; + localAmount?: number; + observeIntersection?: ObserveFn; +}; + +type StateProps = { + activeReactions?: ApiReaction[]; + withEffects?: boolean; +}; + +const ICON_SIZE = 1.5 * REM; +const EFFECT_SIZE = 6.5 * REM; +const MAX_EFFECT_COUNT = (IS_IOS || IS_ANDROID) ? 2 : 5; +const QUALITY = (IS_IOS || IS_ANDROID) ? 2 : 3; + +const PaidReactionEmoji = ({ + containerId, + reaction, + className, + size = ICON_SIZE, + effectSize = EFFECT_SIZE, + activeReactions, + localAmount, + withEffects, + observeIntersection, +}: OwnProps & StateProps) => { + const { stopActiveReaction } = getActions(); + + // eslint-disable-next-line no-null/no-null + const ref = useRef(null); + // eslint-disable-next-line no-null/no-null + const effectRef = useRef(null); + + const [effectsIds, setEffectsIds] = useState([]); + + const isIntersecting = useIsIntersecting(ref, observeIntersection); + + const activeReaction = useMemo(() => ( + activeReactions?.find((active) => isSameReaction(active, reaction)) + ), [activeReactions, reaction]); + + const shouldPlayEffect = Boolean( + withEffects && activeReaction, + ); + const canAddMoreEffects = effectsIds.length < MAX_EFFECT_COUNT; + + useEffectWithPrevDeps(([prevLocalAmount]) => { + if (!shouldPlayEffect) { + setEffectsIds([]); + return; + } + + if (!localAmount || localAmount <= (prevLocalAmount || 0)) { + return; + } + + if (canAddMoreEffects) { + setEffectsIds((prev) => [...prev, Date.now()]); + } + }, [localAmount, canAddMoreEffects, shouldPlayEffect]); + + const { + shouldRender: shouldRenderEffect, + } = useShowTransition({ + ref: effectRef, + noMountTransition: true, + isOpen: shouldPlayEffect, + className: 'slow', + withShouldRender: true, + }); + + const handleEnded = useLastCallback(() => { + const newEffectsIds = effectsIds.slice(1); + setEffectsIds(newEffectsIds); + if (!newEffectsIds.length) { + stopActiveReaction({ containerId, reaction }); + } + }); + + const rootClassName = buildClassName( + styles.root, + shouldRenderEffect && styles.animating, + className, + ); + + return ( +
+ + {shouldRenderEffect && effectsIds.map((id) => ( + + ))} +
+ ); +}; + +export default memo(withGlobal( + (global, { containerId }) => { + const { activeReactions } = selectTabState(global); + + const withEffects = selectPerformanceSettingsValue(global, 'reactionEffects'); + + return { + activeReactions: activeReactions?.[containerId], + withEffects, + }; + }, +)(PaidReactionEmoji)); diff --git a/src/components/common/reactions/ReactionAnimatedEmoji.tsx b/src/components/common/reactions/ReactionAnimatedEmoji.tsx index 71f910423..37ba02f45 100644 --- a/src/components/common/reactions/ReactionAnimatedEmoji.tsx +++ b/src/components/common/reactions/ReactionAnimatedEmoji.tsx @@ -3,7 +3,11 @@ import React, { } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; -import type { ApiAvailableReaction, ApiReaction, ApiStickerSet } from '../../../api/types'; +import type { + ApiAvailableReaction, + ApiReaction, + ApiStickerSet, +} from '../../../api/types'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import { isSameReaction } from '../../../global/helpers'; @@ -21,8 +25,8 @@ import useCustomEmoji from '../hooks/useCustomEmoji'; import AnimatedSticker from '../AnimatedSticker'; import CustomEmoji from '../CustomEmoji'; -import ReactionStaticEmoji from '../ReactionStaticEmoji'; import CustomEmojiEffect from './CustomEmojiEffect'; +import ReactionStaticEmoji from './ReactionStaticEmoji'; import styles from './ReactionAnimatedEmoji.module.scss'; @@ -73,7 +77,7 @@ const ReactionAnimatedEmoji = ({ // eslint-disable-next-line no-null/no-null const ref = useRef(null); - const isCustom = 'documentId' in reaction; + const isCustom = reaction.type === 'custom'; const availableReaction = useMemo(() => ( availableReactions?.find((r) => isSameReaction(r.reaction, reaction)) diff --git a/src/components/common/ReactionEmoji.module.scss b/src/components/common/reactions/ReactionEmoji.module.scss similarity index 100% rename from src/components/common/ReactionEmoji.module.scss rename to src/components/common/reactions/ReactionEmoji.module.scss diff --git a/src/components/common/ReactionEmoji.tsx b/src/components/common/reactions/ReactionEmoji.tsx similarity index 57% rename from src/components/common/ReactionEmoji.tsx rename to src/components/common/reactions/ReactionEmoji.tsx index 084a1eaab..9428fe7ac 100644 --- a/src/components/common/ReactionEmoji.tsx +++ b/src/components/common/reactions/ReactionEmoji.tsx @@ -1,27 +1,29 @@ -import type { FC } from '../../lib/teact/teact'; +import type { FC } from '../../../lib/teact/teact'; import React, { - memo, useMemo, useRef, -} from '../../lib/teact/teact'; + memo, useEffect, useMemo, useRef, +} from '../../../lib/teact/teact'; -import type { ApiAvailableReaction, ApiReaction } from '../../api/types'; -import type { ObserveFn } from '../../hooks/useIntersectionObserver'; +import type { ApiAvailableReaction, ApiReactionWithPaid } from '../../../api/types'; +import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; -import { EMOJI_SIZE_PICKER } from '../../config'; -import { getDocumentMediaHash, isSameReaction } from '../../global/helpers'; -import buildClassName from '../../util/buildClassName'; +import { EMOJI_SIZE_PICKER } from '../../../config'; +import { getDocumentMediaHash, isSameReaction } from '../../../global/helpers'; +import buildClassName from '../../../util/buildClassName'; +import { LOCAL_TGS_URLS } from '../helpers/animatedAssets'; -import useCoordsInSharedCanvas from '../../hooks/useCoordsInSharedCanvas'; -import useLastCallback from '../../hooks/useLastCallback'; -import useMedia from '../../hooks/useMedia'; -import useMediaTransitionDeprecated from '../../hooks/useMediaTransitionDeprecated'; +import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; +import useCoordsInSharedCanvas from '../../../hooks/useCoordsInSharedCanvas'; +import useLastCallback from '../../../hooks/useLastCallback'; +import useMedia from '../../../hooks/useMedia'; +import useMediaTransitionDeprecated from '../../../hooks/useMediaTransitionDeprecated'; -import AnimatedIconWithPreview from './AnimatedIconWithPreview'; -import CustomEmoji from './CustomEmoji'; +import AnimatedIconWithPreview from '../AnimatedIconWithPreview'; +import CustomEmoji from '../CustomEmoji'; import styles from './ReactionEmoji.module.scss'; type OwnProps = { - reaction: ApiReaction; + reaction: ApiReactionWithPaid; availableReactions?: ApiAvailableReaction[]; className?: string; isSelected?: boolean; @@ -30,7 +32,8 @@ type OwnProps = { sharedCanvasRef?: React.RefObject; sharedCanvasHqRef?: React.RefObject; forcePlayback?: boolean; - onClick: (reaction: ApiReaction) => void; + onClick: (reaction: ApiReactionWithPaid) => void; + onContextMenu?: (reaction: ApiReactionWithPaid) => void; }; const ReactionEmoji: FC = ({ @@ -43,10 +46,11 @@ const ReactionEmoji: FC = ({ sharedCanvasHqRef, forcePlayback, onClick, + onContextMenu, }) => { // eslint-disable-next-line no-null/no-null const ref = useRef(null); - const isCustom = 'documentId' in reaction; + const isCustom = reaction.type === 'custom'; const availableReaction = useMemo(() => ( availableReactions?.find((available) => isSameReaction(available.reaction, reaction)) ), [availableReactions, reaction]); @@ -57,6 +61,25 @@ const ReactionEmoji: FC = ({ availableReaction?.selectAnimation ? getDocumentMediaHash(availableReaction.selectAnimation, 'full') : undefined, !animationId, ); + + const { + isContextMenuOpen, + handleBeforeContextMenu, + handleContextMenu, + handleContextMenuClose, + handleContextMenuHide, + } = useContextMenuHandlers(ref, reaction.type !== 'paid', undefined, undefined, undefined, true); + + useEffect(() => { + if (isContextMenuOpen) { + onContextMenu?.(reaction); + + handleContextMenuClose(); + handleContextMenuHide(); + } + }, [handleContextMenuClose, onContextMenu, handleContextMenuHide, isContextMenuOpen, reaction]); + + const tgsUrl = reaction.type === 'paid' ? LOCAL_TGS_URLS.StarReaction : mediaData; const handleClick = useLastCallback(() => { onClick(reaction); }); @@ -75,6 +98,8 @@ const ReactionEmoji: FC = ({ onClick={handleClick} title={availableReaction?.title} data-sticker-id={isCustom ? reaction.documentId : undefined} + onMouseDown={handleBeforeContextMenu} + onContextMenu={handleContextMenu} > {isCustom ? ( = ({ /> ) : ( = ({ withIconHeart, observeIntersection, }) => { - const isCustom = 'documentId' in reaction; const availableReaction = useMemo(() => ( availableReactions?.find((available) => isSameReaction(available.reaction, reaction)) ), [availableReactions, reaction]); const staticIconId = availableReaction?.staticIcon?.id; - const mediaData = useMedia(`document${staticIconId}`, !staticIconId, ApiMediaFormat.BlobUrl); + const mediaHash = staticIconId ? `document${staticIconId}` : undefined; + const mediaData = useMedia(mediaHash); const transitionClassNames = useMediaTransitionDeprecated(mediaData); - const shouldApplySizeFix = 'emoticon' in reaction && reaction.emoticon === '🦄'; - const shouldReplaceWithHeartIcon = withIconHeart && 'emoticon' in reaction && reaction.emoticon === '❤'; + const shouldApplySizeFix = reaction.type === 'emoji' && reaction.emoticon === '🦄'; + const shouldReplaceWithHeartIcon = withIconHeart && reaction.type === 'emoji' && reaction.emoticon === '❤'; - if (isCustom) { + if (reaction.type === 'custom') { return ( = ({ const handleChange = useCallback((reaction: string) => { setDefaultReaction({ - reaction: { emoticon: reaction }, + reaction: { type: 'emoji', emoticon: reaction }, }); }, [setDefaultReaction]); diff --git a/src/components/left/settings/SettingsStickers.tsx b/src/components/left/settings/SettingsStickers.tsx index ed2295b19..c7a102c41 100644 --- a/src/components/left/settings/SettingsStickers.tsx +++ b/src/components/left/settings/SettingsStickers.tsx @@ -22,7 +22,7 @@ import useHistoryBack from '../../../hooks/useHistoryBack'; import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver'; import useOldLang from '../../../hooks/useOldLang'; -import ReactionStaticEmoji from '../../common/ReactionStaticEmoji'; +import ReactionStaticEmoji from '../../common/reactions/ReactionStaticEmoji'; import StickerSetCard from '../../common/StickerSetCard'; import Checkbox from '../../ui/Checkbox'; import ListItem from '../../ui/ListItem'; diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index 72888095f..ae4ba737e 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -247,6 +247,7 @@ const Main = ({ loadStarStatus, loadAvailableEffects, loadTopBotApps, + loadPaidReactionPrivacy, } = getActions(); if (DEBUG && !DEBUG_isLogged) { @@ -330,6 +331,7 @@ const Main = ({ loadSavedReactionTags(); loadAuthorizations(); loadTopBotApps(); + loadPaidReactionPrivacy(); } }, [isMasterTab, isSynced]); diff --git a/src/components/main/Notifications.tsx b/src/components/main/Notifications.tsx index f6d012e14..42649746b 100644 --- a/src/components/main/Notifications.tsx +++ b/src/components/main/Notifications.tsx @@ -23,18 +23,23 @@ const Notifications: FC = ({ notifications }) => { return (
- {notifications.map(({ - message, className, localId, action, actionText, title, duration, - }) => ( + {notifications.map((notification) => ( dismissNotification({ localId })} + onDismiss={() => dismissNotification({ localId: notification.localId })} /> ))}
diff --git a/src/components/middle/ReactorListModal.tsx b/src/components/middle/ReactorListModal.tsx index 404dff167..e25288f1a 100644 --- a/src/components/middle/ReactorListModal.tsx +++ b/src/components/middle/ReactorListModal.tsx @@ -26,7 +26,7 @@ import useOldLang from '../../hooks/useOldLang'; import Avatar from '../common/Avatar'; import FullNameTitle from '../common/FullNameTitle'; import PrivateChatInfo from '../common/PrivateChatInfo'; -import ReactionStaticEmoji from '../common/ReactionStaticEmoji'; +import ReactionStaticEmoji from '../common/reactions/ReactionStaticEmoji'; import Button from '../ui/Button'; import InfiniteScroll from '../ui/InfiniteScroll'; import ListItem from '../ui/ListItem'; diff --git a/src/components/middle/message/ContextMenuContainer.tsx b/src/components/middle/message/ContextMenuContainer.tsx index 7cb2fe44a..261b23346 100644 --- a/src/components/middle/message/ContextMenuContainer.tsx +++ b/src/components/middle/message/ContextMenuContainer.tsx @@ -134,6 +134,7 @@ type StateProps = { isInSavedMessages?: boolean; isChannel?: boolean; canReplyInChat?: boolean; + isWithPaidReaction?: boolean; }; const selection = window.getSelection(); @@ -192,9 +193,10 @@ const ContextMenuContainer: FC = ({ canSelectLanguage, isReactionPickerOpen, isInSavedMessages, + canReplyInChat, + isWithPaidReaction, onClose, onCloseAnimationEnd, - canReplyInChat, }) => { const { openThread, @@ -229,6 +231,8 @@ const ContextMenuContainer: FC = ({ loadOutboxReadDate, copyMessageLink, openDeleteMessageModal, + addLocalPaidReaction, + openPaidReactionModal, } = getActions(); const lang = useOldLang(); @@ -531,6 +535,22 @@ const ContextMenuContainer: FC = ({ closeMenu(); }); + const handleSendPaidReaction = useLastCallback(() => { + addLocalPaidReaction({ + chatId: message.chatId, messageId: message.id, count: 1, + }); + closeMenu(); + }); + + const handlePaidReactionModalOpen = useLastCallback(() => { + openPaidReactionModal({ + chatId: message.chatId, + messageId: message.id, + }); + + closeMenu(); + }); + const handleReactionPickerOpen = useLastCallback((position: IAnchorPosition) => { openMessageReactionPicker({ chatId: message.chatId, messageId: message.id, position }); }); @@ -577,6 +597,7 @@ const ContextMenuContainer: FC = ({ availableReactions={availableReactions} topReactions={topReactions} defaultTagReactions={defaultTagReactions} + isWithPaidReaction={isWithPaidReaction} message={message} isPrivate={isPrivate} isCurrentUserPremium={isCurrentUserPremium} @@ -644,6 +665,8 @@ const ContextMenuContainer: FC = ({ onClosePoll={openClosePollDialog} onShowSeenBy={handleOpenSeenByModal} onToggleReaction={handleToggleReaction} + onSendPaidReaction={handleSendPaidReaction} + onShowPaidReactionModal={handlePaidReactionModalOpen} onShowReactors={handleOpenReactorListModal} onReactionPickerOpen={handleReactionPickerOpen} onTranslate={handleTranslate} @@ -821,6 +844,7 @@ export default memo(withGlobal( isInSavedMessages, isChannel, canReplyInChat, + isWithPaidReaction: chatFullInfo?.isPaidReactionAvailable, }; }, )(ContextMenuContainer)); diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index d1bb2365d..c3ec6c24f 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -153,7 +153,7 @@ import FakeIcon from '../../common/FakeIcon'; import Icon from '../../common/icons/Icon'; import StarIcon from '../../common/icons/StarIcon'; import MessageText from '../../common/MessageText'; -import ReactionStaticEmoji from '../../common/ReactionStaticEmoji'; +import ReactionStaticEmoji from '../../common/reactions/ReactionStaticEmoji'; import TopicChip from '../../common/TopicChip'; import Button from '../../ui/Button'; import Album from './Album'; @@ -294,6 +294,7 @@ type StateProps = { canTranscribeVoice?: boolean; viaBusinessBot?: ApiUser; effect?: ApiAvailableEffect; + availableStars?: number; }; type MetaPosition = @@ -414,6 +415,7 @@ const Message: FC = ({ canTranscribeVoice, viaBusinessBot, effect, + availableStars, onIntersectPinnedMessage, }) => { const { @@ -1042,6 +1044,7 @@ const Message: FC = ({ noRecentReactors={isChannel} tags={tags} isCurrentUserPremium={isPremium} + availableStars={availableStars} /> ); } @@ -1649,6 +1652,7 @@ const Message: FC = ({ observeIntersection={observeIntersectionForPlaying} noRecentReactors={isChannel} tags={tags} + availableStars={availableStars} /> )} @@ -1798,6 +1802,8 @@ export default memo(withGlobal( const effect = effectId ? global.availableEffectById[effectId] : undefined; + const { balance: availableStars } = global.stars || {}; + return { theme: selectTheme(global), forceSenderName, @@ -1884,6 +1890,7 @@ export default memo(withGlobal( canTranscribeVoice, viaBusinessBot, effect, + availableStars, }; }, )(Message)); diff --git a/src/components/middle/message/MessageContextMenu.tsx b/src/components/middle/message/MessageContextMenu.tsx index 494635483..7cf582918 100644 --- a/src/components/middle/message/MessageContextMenu.tsx +++ b/src/components/middle/message/MessageContextMenu.tsx @@ -51,6 +51,7 @@ type OwnProps = { message: ApiMessage | ApiSponsoredMessage; canSendNow?: boolean; enabledReactions?: ApiChatReactions; + isWithPaidReaction?: boolean; reactionsLimit?: number; canReschedule?: boolean; canReply?: boolean; @@ -121,6 +122,8 @@ type OwnProps = { onShowOriginal?: NoneToVoidFunction; onSelectLanguage?: NoneToVoidFunction; onToggleReaction?: (reaction: ApiReaction) => void; + onSendPaidReaction?: NoneToVoidFunction; + onShowPaidReactionModal?: NoneToVoidFunction; onReactionPickerOpen?: (position: IAnchorPosition) => void; }; @@ -138,6 +141,7 @@ const MessageContextMenu: FC = ({ isPrivate, isCurrentUserPremium, enabledReactions, + isWithPaidReaction, reactionsLimit, anchor, targetHref, @@ -201,6 +205,8 @@ const MessageContextMenu: FC = ({ onShowSeenBy, onShowReactors, onToggleReaction, + onSendPaidReaction, + onShowPaidReactionModal, onCopyMessages, onAboutAdsClick, onSponsoredHide, @@ -356,6 +362,9 @@ const MessageContextMenu: FC = ({ currentReactions={!isSponsoredMessage ? message.reactions?.results : undefined} reactionsLimit={reactionsLimit} onToggleReaction={onToggleReaction!} + onSendPaidReaction={onSendPaidReaction} + onShowPaidReactionModal={onShowPaidReactionModal} + isWithPaidReaction={isWithPaidReaction} isPrivate={isPrivate} isReady={isReady} canBuyPremium={canBuyPremium} diff --git a/src/components/middle/message/reactions/ReactionButton.module.scss b/src/components/middle/message/reactions/ReactionButton.module.scss index 73897d0c3..816e43139 100644 --- a/src/components/middle/message/reactions/ReactionButton.module.scss +++ b/src/components/middle/message/reactions/ReactionButton.module.scss @@ -11,6 +11,18 @@ --reaction-text-color: var(--text-color-reaction-chosen); } + &.paid { + --reaction-background: #FFBC2E33 !important; + --reaction-background-hover: #FFBC2E55 !important; + --reaction-text-color: #E98111 !important; + } + + &.paid.chosen { + --reaction-background: #FFBC2E !important; + --reaction-background-hover: #FFBC2ECC !important; + --reaction-text-color: #FFFFFF !important; + } + display: flex; flex-direction: row; height: 1.875rem; @@ -114,3 +126,17 @@ .disabled { opacity: 0.5; } + +.paidCounter { + font-family: var(--font-family-rounded); + font-size: 2.5rem; + font-variant-numeric: tabular-nums; + color: #FFBC2E; + + position: absolute; + top: -150%; + right: 50%; + transform: translateX(50%); + -webkit-text-stroke: 1px #E58E0D; + z-index: 1; +} diff --git a/src/components/middle/message/reactions/ReactionButton.tsx b/src/components/middle/message/reactions/ReactionButton.tsx index 071a22ace..e66699847 100644 --- a/src/components/middle/message/reactions/ReactionButton.tsx +++ b/src/components/middle/message/reactions/ReactionButton.tsx @@ -1,8 +1,10 @@ -import React, { memo } from '../../../../lib/teact/teact'; +import React, { memo, useEffect, useRef } from '../../../../lib/teact/teact'; +import { getActions } from '../../../../global'; import type { ApiPeer, ApiReaction, ApiReactionCount, } from '../../../../api/types'; +import type { GlobalState } from '../../../../global/types'; import type { ObserveFn } from '../../../../hooks/useIntersectionObserver'; import { isReactionChosen } from '../../../../global/helpers'; @@ -10,28 +12,44 @@ import buildClassName from '../../../../util/buildClassName'; import { formatIntegerCompact } from '../../../../util/textFormat'; import { REM } from '../../../common/helpers/mediaDimensions'; +import useSelector from '../../../../hooks/data/useSelector'; +import useContextMenuHandlers from '../../../../hooks/useContextMenuHandlers'; +import useEffectWithPrevDeps from '../../../../hooks/useEffectWithPrevDeps'; import useLastCallback from '../../../../hooks/useLastCallback'; +import usePrevious from '../../../../hooks/usePrevious'; +import useShowTransition from '../../../../hooks/useShowTransition'; import AnimatedCounter from '../../../common/AnimatedCounter'; import AvatarList from '../../../common/AvatarList'; +import PaidReactionEmoji from '../../../common/reactions/PaidReactionEmoji'; import ReactionAnimatedEmoji from '../../../common/reactions/ReactionAnimatedEmoji'; +import Sparkles from '../../../common/Sparkles'; import Button from '../../../ui/Button'; import styles from './ReactionButton.module.scss'; const REACTION_SIZE = 1.25 * REM; +const MAX_SCALE = 3; type OwnProps = { + chatId: string; + messageId: number; reaction: ApiReactionCount; containerId: string; isOwnMessage?: boolean; recentReactors?: ApiPeer[]; className?: string; chosenClassName?: string; + availableStars?: number; observeIntersection?: ObserveFn; onClick?: (reaction: ApiReaction) => void; + onPaidClick?: (count: number) => void; }; +function selectAreStarsLoaded(global: GlobalState) { + return Boolean(global.stars); +} + const ReactionButton = ({ reaction, containerId, @@ -39,36 +57,153 @@ const ReactionButton = ({ recentReactors, className, chosenClassName, + availableStars, + chatId, + messageId, observeIntersection, onClick, + onPaidClick, }: OwnProps) => { - const handleClick = useLastCallback(() => { + const { openStarsBalanceModal, resetLocalPaidReactions, openPaidReactionModal } = getActions(); + // eslint-disable-next-line no-null/no-null + const ref = useRef(null); + // eslint-disable-next-line no-null/no-null + const counterRef = useRef(null); + const animationRef = useRef(); + + const isPaid = reaction.reaction.type === 'paid'; + + const areStarsLoaded = useSelector(selectAreStarsLoaded); + + const handlePaidClick = useLastCallback((count = 1) => { + onPaidClick?.(count); + }); + + const handleClick = useLastCallback((e: React.MouseEvent) => { + if (reaction.reaction.type === 'paid') { + e.stopPropagation(); // Prevent default message double click behavior + handlePaidClick(); + return; + } + onClick?.(reaction.reaction); }); + const { + isContextMenuOpen, + handleBeforeContextMenu, + handleContextMenu, + handleContextMenuClose, + handleContextMenuHide, + } = useContextMenuHandlers(ref, reaction.reaction.type !== 'paid', undefined, undefined, undefined, true); + + useEffect(() => { + if (isContextMenuOpen) { + openPaidReactionModal({ + chatId, + messageId, + }); + + handleContextMenuClose(); + handleContextMenuHide(); + } + }, [handleContextMenuClose, handleContextMenuHide, isContextMenuOpen, chatId, messageId]); + + useEffectWithPrevDeps(([prevReaction]) => { + const amount = reaction.localAmount; + const button = ref.current; + if (!amount || !button || amount === prevReaction?.localAmount) return; + + if (areStarsLoaded && (!availableStars || amount > availableStars)) { + openStarsBalanceModal({ + originReaction: { + chatId, + messageId, + amount, + }, + }); + resetLocalPaidReactions({ + chatId, + messageId, + }); + return; + } + + const currentScale = Number(getComputedStyle(button).scale) || 1; + animationRef.current?.cancel(); + // Animate scaling by 20%, and then returning to 1 + animationRef.current = button.animate([ + { scale: currentScale }, + { scale: Math.min(currentScale * 1.2, MAX_SCALE), offset: 0.2 }, + { scale: 1 }, + ], { + duration: 500 * currentScale, + easing: 'ease-out', + }); + }, [reaction, availableStars, areStarsLoaded, chatId, messageId]); + + const prevAmount = usePrevious(reaction.localAmount); + + const { + shouldRender: shouldRenderPaidCounter, + } = useShowTransition({ + isOpen: Boolean(reaction.localAmount), + ref: counterRef, + className: 'slow', + withShouldRender: true, + }); + return ( ); diff --git a/src/components/middle/message/reactions/ReactionPicker.tsx b/src/components/middle/message/reactions/ReactionPicker.tsx index 0c38b697c..b84fd44fc 100644 --- a/src/components/middle/message/reactions/ReactionPicker.tsx +++ b/src/components/middle/message/reactions/ReactionPicker.tsx @@ -2,18 +2,19 @@ import type { FC } from '../../../../lib/teact/teact'; import React, { memo, useMemo, useRef } from '../../../../lib/teact/teact'; import { getActions, getGlobal, withGlobal } from '../../../../global'; -import type { IAnchorPosition } from '../../../../types'; -import { - type ApiAvailableEffect, - type ApiMessage, - type ApiMessageEntity, - type ApiReaction, - type ApiReactionCustomEmoji, - type ApiSticker, - type ApiStory, - type ApiStorySkipped, - MAIN_THREAD_ID, +import type { + ApiAvailableEffect, + ApiMessage, + ApiMessageEntity, + ApiReaction, + ApiReactionCustomEmoji, + ApiReactionWithPaid, + ApiSticker, + ApiStory, + ApiStorySkipped, } from '../../../../api/types'; +import type { IAnchorPosition } from '../../../../types'; +import { MAIN_THREAD_ID } from '../../../../api/types'; import { getReactionKey, getStoryKey, isUserId } from '../../../../global/helpers'; import { @@ -78,7 +79,7 @@ const ReactionPicker: FC = ({ }) => { const { toggleReaction, closeReactionPicker, sendMessage, showNotification, sendStoryReaction, saveEffectInDraft, - requestEffectInComposer, + requestEffectInComposer, addLocalPaidReaction, openPaidReactionModal, } = getActions(); const lang = useOldLang(); @@ -128,22 +129,40 @@ const ReactionPicker: FC = ({ closeReactionPicker(); }); - const handleToggleReaction = useLastCallback((reaction: ApiReaction) => { + const handleToggleReaction = useLastCallback((reaction: ApiReactionWithPaid) => { if (!renderedChatId || !renderedMessageId) { return; } - toggleReaction({ - chatId: renderedChatId, messageId: renderedMessageId, reaction, shouldAddToRecent: true, + if (reaction.type === 'paid') { + addLocalPaidReaction({ + chatId: renderedChatId, messageId: renderedMessageId, count: 1, + }); + } else { + toggleReaction({ + chatId: renderedChatId, messageId: renderedMessageId, reaction, shouldAddToRecent: true, + }); + } + closeReactionPicker(); + }); + + const handleReactionContextMenu = useLastCallback((reaction: ApiReactionWithPaid) => { + if (reaction.type !== 'paid') return; + + openPaidReactionModal({ + chatId: renderedChatId!, + messageId: renderedMessageId!, }); closeReactionPicker(); }); - const handleStoryReactionSelect = useLastCallback((item: ApiReaction | ApiSticker) => { - const reaction = 'id' in item ? { documentId: item.id } : item; + const handleStoryReactionSelect = useLastCallback((item: ApiReactionWithPaid | ApiSticker) => { + if ('type' in item && item.type === 'paid') return; // Not supported for stories - const sticker = 'documentId' in item - ? getGlobal().customEmojis.byId[item.documentId] : 'emoticon' in item ? undefined : item; + const reaction = 'id' in item ? { type: 'custom', documentId: item.id } as const : item; + + const sticker = 'type' in item && item.type === 'custom' ? getGlobal().customEmojis.byId[item.documentId] + : 'id' in item ? item : undefined; if (sticker && !sticker.isFree && !isCurrentUserPremium) { showNotification({ @@ -175,7 +194,7 @@ const ReactionPicker: FC = ({ let text: string | undefined; let entities: ApiMessageEntity[] | undefined; - if ('emoticon' in item) { + if ('type' in item && item.type === 'emoji') { text = item.emoticon; } else { const customEmojiMessage = parseHtmlAsFormattedText(buildCustomEmojiHtml(sticker!)); @@ -193,7 +212,7 @@ const ReactionPicker: FC = ({ if (chatId) saveEffectInDraft({ chatId, threadId: MAIN_THREAD_ID, effectId }); - if (effectId) requestEffectInComposer({ }); + if (effectId) requestEffectInComposer({}); closeReactionPicker(); }); @@ -253,12 +272,14 @@ const ReactionPicker: FC = ({ isTranslucent={isTranslucent} onCustomEmojiSelect={renderedStoryId ? handleStoryReactionSelect : handleToggleCustomReaction} onReactionSelect={renderedStoryId ? handleStoryReactionSelect : handleToggleReaction} + onReactionContext={handleReactionContextMenu} /> {!shouldUseFullPicker && Boolean(renderedChatId) && ( @@ -285,7 +306,7 @@ export default memo(withGlobal((global): StateProps => { const areSomeReactionsAllowed = chatFullInfo?.enabledReactions?.type === 'some'; const { maxUniqueReactions } = global.appConfig || {}; const areAllReactionsAllowed = chatFullInfo?.enabledReactions?.type === 'all' - && chatFullInfo?.enabledReactions?.areCustomAllowed; + && chatFullInfo?.enabledReactions?.areCustomAllowed; const currentReactions = message?.reactions?.results; const shouldUseCurrentReactions = Boolean(maxUniqueReactions && currentReactions diff --git a/src/components/middle/message/reactions/ReactionPickerLimited.tsx b/src/components/middle/message/reactions/ReactionPickerLimited.tsx index 45f97ced4..7dd1ab61f 100644 --- a/src/components/middle/message/reactions/ReactionPickerLimited.tsx +++ b/src/components/middle/message/reactions/ReactionPickerLimited.tsx @@ -8,6 +8,7 @@ import { withGlobal } from '../../../../global'; import type { ApiAvailableReaction, ApiChatReactions, ApiMessage, ApiReaction, + ApiReactionWithPaid, } from '../../../../api/types'; import { @@ -20,22 +21,24 @@ import { REM } from '../../../common/helpers/mediaDimensions'; import useAppLayout from '../../../../hooks/useAppLayout'; import useWindowSize from '../../../../hooks/window/useWindowSize'; -import ReactionEmoji from '../../../common/ReactionEmoji'; +import ReactionEmoji from '../../../common/reactions/ReactionEmoji'; import styles from './ReactionPickerLimited.module.scss'; type OwnProps = { chatId: string; loadAndPlay: boolean; - onReactionSelect?: (reaction: ApiReaction) => void; selectedReactionIds?: string[]; message?: ApiMessage; + onReactionSelect: (reaction: ApiReactionWithPaid) => void; + onReactionContext?: (reaction: ApiReactionWithPaid) => void; }; type StateProps = { enabledReactions?: ApiChatReactions; availableReactions?: ApiAvailableReaction[]; topReactions: ApiReaction[]; + isWithPaidReaction?: boolean; canAnimate?: boolean; isSavedMessages?: boolean; reactionsLimit?: number; @@ -56,9 +59,11 @@ const ReactionPickerLimited: FC = ({ availableReactions, topReactions, selectedReactionIds, - onReactionSelect, + isWithPaidReaction, message, reactionsLimit, + onReactionSelect, + onReactionContext, }) => { // eslint-disable-next-line no-null/no-null const sharedCanvasRef = useRef(null); @@ -74,18 +79,34 @@ const ReactionPickerLimited: FC = ({ const allAvailableReactions = useMemo(() => { if (shouldUseCurrentReactions) { - return currentReactions.map(({ reaction }) => reaction); + const reactions = currentReactions.map(({ reaction }) => reaction); + if (isWithPaidReaction) { + reactions.unshift({ type: 'paid' }); + } + return reactions; } + if (!enabledReactions) { return []; } if (enabledReactions.type === 'all') { - return sortReactions((availableReactions || []).map(({ reaction }) => reaction), topReactions); + const reactionsToSort: ApiReactionWithPaid[] = (availableReactions || []).map(({ reaction }) => reaction); + if (isWithPaidReaction) { + reactionsToSort.unshift({ type: 'paid' }); + } + return sortReactions(reactionsToSort, topReactions); } - return sortReactions(enabledReactions.allowed, topReactions); - }, [availableReactions, enabledReactions, topReactions, shouldUseCurrentReactions, currentReactions]); + const reactionsToSort: ApiReactionWithPaid[] = enabledReactions.allowed; + if (isWithPaidReaction) { + reactionsToSort.unshift({ type: 'paid' }); + } + + return sortReactions(reactionsToSort, topReactions); + }, [ + availableReactions, enabledReactions, topReactions, shouldUseCurrentReactions, currentReactions, isWithPaidReaction, + ]); const pickerHeight = useMemo(() => { const pickerWidth = Math.min(MODAL_MAX_WIDTH_REM * REM, windowWidth); @@ -118,6 +139,7 @@ const ReactionPickerLimited: FC = ({ loadAndPlay={loadAndPlay} availableReactions={availableReactions} onClick={onReactionSelect!} + onContextMenu={onReactionContext} sharedCanvasRef={sharedCanvasRef} sharedCanvasHqRef={sharedCanvasHqRef} /> @@ -134,13 +156,14 @@ export default memo(withGlobal( const { availableReactions, topReactions } = global.reactions; const { maxUniqueReactions } = global.appConfig || {}; - const { enabledReactions } = selectChatFullInfo(global, chatId) || {}; + const { enabledReactions, isPaidReactionAvailable } = selectChatFullInfo(global, chatId) || {}; return { enabledReactions, availableReactions, topReactions, reactionsLimit: maxUniqueReactions, + isWithPaidReaction: isPaidReactionAvailable, }; }, )(ReactionPickerLimited)); diff --git a/src/components/middle/message/reactions/ReactionSelector.tsx b/src/components/middle/message/reactions/ReactionSelector.tsx index 476a7f41f..ebb801e87 100644 --- a/src/components/middle/message/reactions/ReactionSelector.tsx +++ b/src/components/middle/message/reactions/ReactionSelector.tsx @@ -3,7 +3,12 @@ import React, { memo, useMemo, useRef } from '../../../../lib/teact/teact'; import { getActions } from '../../../../global'; import type { - ApiAvailableReaction, ApiChatReactions, ApiReaction, ApiReactionCount, + ApiAvailableReaction, + ApiChatReactions, + ApiReaction, + ApiReactionCount, + ApiReactionCustomEmoji, + ApiReactionPaid, } from '../../../../api/types'; import type { IAnchorPosition } from '../../../../types'; @@ -22,6 +27,8 @@ import ReactionSelectorReaction from './ReactionSelectorReaction'; import './ReactionSelector.scss'; +type RenderableReactions = (ApiAvailableReaction | ApiReactionCustomEmoji | ApiReactionPaid)[]; + type OwnProps = { enabledReactions?: ApiChatReactions; isPrivate?: boolean; @@ -39,8 +46,11 @@ type OwnProps = { isInSavedMessages?: boolean; isInStoryViewer?: boolean; isForEffects?: boolean; + isWithPaidReaction?: boolean; onClose?: NoneToVoidFunction; onToggleReaction: (reaction: ApiReaction) => void; + onSendPaidReaction?: NoneToVoidFunction; + onShowPaidReactionModal?: NoneToVoidFunction; onShowMore: (position: IAnchorPosition) => void; }; @@ -64,8 +74,11 @@ const ReactionSelector: FC = ({ isInStoryViewer, isForEffects, effectReactions, + isWithPaidReaction, onClose, onToggleReaction, + onSendPaidReaction, + onShowPaidReactionModal, onShowMore, }) => { const { openPremiumModal } = getActions(); @@ -87,8 +100,8 @@ const ReactionSelector: FC = ({ return allAvailableReactions?.map((reaction) => reaction.reaction); })(); - const filteredReactions = reactions?.map((reaction) => { - const isCustomReaction = 'documentId' in reaction; + const filteredReactions: RenderableReactions = reactions?.map((reaction) => { + const isCustomReaction = reaction.type === 'custom'; const availableReaction = allAvailableReactions?.find((r) => isSameReaction(r.reaction, reaction)); if (isForEffects) return availableReaction; @@ -103,11 +116,14 @@ const ReactionSelector: FC = ({ return isCustomReaction ? reaction : availableReaction; }).filter(Boolean) || []; - return sortReactions(filteredReactions, topReactions); + const sortedReactions = sortReactions(filteredReactions, topReactions); + if (isWithPaidReaction) { + sortedReactions.unshift({ type: 'paid' }); + } + return sortedReactions; }, [ allAvailableReactions, currentReactions, defaultTagReactions, enabledReactions, isInSavedMessages, isPrivate, - topReactions, isForEffects, effectReactions, shouldUseCurrentReactions, - + topReactions, isForEffects, effectReactions, shouldUseCurrentReactions, isWithPaidReaction, ]); const reactionsToRender = useMemo(() => { @@ -196,6 +212,8 @@ const ReactionSelector: FC = ({ key={getReactionKey(reaction)} isReady={isReady} onToggleReaction={onToggleReaction} + onSendPaidReaction={onSendPaidReaction} + onShowPaidReactionModal={onShowPaidReactionModal} reaction={reaction} noAppearAnimation={!canPlayAnimatedEmojis} chosen={userReactionIndexes.has(i)} diff --git a/src/components/middle/message/reactions/ReactionSelectorCustomReaction.tsx b/src/components/middle/message/reactions/ReactionSelectorCustomReaction.tsx index ee295add0..b7c608439 100644 --- a/src/components/middle/message/reactions/ReactionSelectorCustomReaction.tsx +++ b/src/components/middle/message/reactions/ReactionSelectorCustomReaction.tsx @@ -1,11 +1,16 @@ import type { FC } from '../../../../lib/teact/teact'; -import React, { memo } from '../../../../lib/teact/teact'; +import React, { memo, useEffect, useRef } from '../../../../lib/teact/teact'; -import type { ApiReaction, ApiReactionCustomEmoji } from '../../../../api/types'; +import type { ApiReaction, ApiReactionCustomEmoji, ApiReactionPaid } from '../../../../api/types'; import buildClassName from '../../../../util/buildClassName'; +import { LOCAL_TGS_URLS } from '../../../common/helpers/animatedAssets'; import { REM } from '../../../common/helpers/mediaDimensions'; +import useContextMenuHandlers from '../../../../hooks/useContextMenuHandlers'; +import useLastCallback from '../../../../hooks/useLastCallback'; + +import AnimatedIcon from '../../../common/AnimatedIcon'; import CustomEmoji from '../../../common/CustomEmoji'; import Icon from '../../../common/icons/Icon'; @@ -14,13 +19,15 @@ import styles from './ReactionSelectorReaction.module.scss'; const REACTION_SIZE = 2 * REM; type OwnProps = { - reaction: ApiReactionCustomEmoji; + reaction: ApiReactionCustomEmoji | ApiReactionPaid; chosen?: boolean; isReady?: boolean; noAppearAnimation?: boolean; style?: string; isLocked?: boolean; onToggleReaction: (reaction: ApiReaction) => void; + onSendPaidReaction?: NoneToVoidFunction; + onShowPaidReactionModal?: NoneToVoidFunction; }; const ReactionSelectorCustomReaction: FC = ({ @@ -31,27 +38,64 @@ const ReactionSelectorCustomReaction: FC = ({ style, isLocked, onToggleReaction, + onSendPaidReaction, + onShowPaidReactionModal, }) => { - function handleClick() { + // eslint-disable-next-line no-null/no-null + const ref = useRef(null); + const handleClick = useLastCallback(() => { + if (reaction.type === 'paid') { + onSendPaidReaction?.(); + return; + } + onToggleReaction(reaction); - } + }); + + const { + isContextMenuOpen, + handleBeforeContextMenu, + handleContextMenu, + handleContextMenuClose, + handleContextMenuHide, + } = useContextMenuHandlers(ref, reaction.type !== 'paid', undefined, undefined, undefined, true); + + useEffect(() => { + if (isContextMenuOpen) { + onShowPaidReactionModal?.(); + + handleContextMenuClose(); + handleContextMenuHide(); + } + }, [handleContextMenuClose, onShowPaidReactionModal, handleContextMenuHide, isContextMenuOpen]); return (
- + {reaction.type === 'paid' ? ( + + ) : ( + + )} {isLocked && ( )} diff --git a/src/components/middle/message/reactions/Reactions.tsx b/src/components/middle/message/reactions/Reactions.tsx index 31010300c..f1285f3a4 100644 --- a/src/components/middle/message/reactions/Reactions.tsx +++ b/src/components/middle/message/reactions/Reactions.tsx @@ -1,5 +1,5 @@ import type { FC } from '../../../../lib/teact/teact'; -import React, { memo, useMemo } from '../../../../lib/teact/teact'; +import React, { memo, useEffect, useMemo } from '../../../../lib/teact/teact'; import { getActions, getGlobal } from '../../../../global'; import type { @@ -17,6 +17,7 @@ import { selectPeer } from '../../../../global/selectors'; import buildClassName from '../../../../util/buildClassName'; import { getMessageKey } from '../../../../util/keys/messageKey'; +import useEffectOnce from '../../../../hooks/useEffectOnce'; import useLastCallback from '../../../../hooks/useLastCallback'; import useOldLang from '../../../../hooks/useOldLang'; @@ -35,9 +36,11 @@ type OwnProps = { isCurrentUserPremium?: boolean; observeIntersection?: ObserveFn; noRecentReactors?: boolean; + availableStars?: number; }; const MAX_RECENT_AVATARS = 3; +const PAID_SEND_DELAY = 5000; const Reactions: FC = ({ message, @@ -49,12 +52,16 @@ const Reactions: FC = ({ noRecentReactors, isCurrentUserPremium, tags, + availableStars, }) => { const { toggleReaction, + addLocalPaidReaction, updateMiddleSearch, performMiddleSearch, openPremiumModal, + resetLocalPaidReactions, + showNotification, } = getActions(); const lang = useOldLang(); @@ -109,7 +116,7 @@ const Reactions: FC = ({ return; } - updateMiddleSearch({ chatId: message.chatId, threadId, update: { savedTag: reaction } }); + updateMiddleSearch({ chatId: message.chatId, threadId, update: { savedTag: reaction as ApiReaction } }); performMiddleSearch({ chatId: message.chatId, threadId }); return; } @@ -121,6 +128,40 @@ const Reactions: FC = ({ }); }); + const paidLocalCount = useMemo(() => results.find((r) => r.reaction.type === 'paid')?.localAmount || 0, [results]); + + const handlePaidClick = useLastCallback((count: number) => { + addLocalPaidReaction({ + chatId: message.chatId, + messageId: message.id, + count, + }); + }); + + useEffect(() => { + if (!paidLocalCount) return; + + showNotification({ + localId: getMessageKey(message), + title: lang('StarsSentTitle'), + message: lang('StarsSentText', paidLocalCount), + actionText: lang('StarsSentUndo'), + cacheBreaker: paidLocalCount.toString(), + action: { + action: 'resetLocalPaidReactions', + payload: { chatId: message.chatId, messageId: message.id }, + }, + dismissAction: { + action: 'sendPaidReaction', + payload: { chatId: message.chatId, messageId: message.id }, + }, + duration: PAID_SEND_DELAY, + shouldShowTimer: true, + disableClickDismiss: true, + icon: 'star', + }); + }, [lang, message, paidLocalCount]); + const handleRemoveReaction = useLastCallback((reaction: ApiReaction) => { toggleReaction({ chatId: message.chatId, @@ -129,6 +170,14 @@ const Reactions: FC = ({ }); }); + // Reset paid reactions on unmount + useEffectOnce(() => () => { + resetLocalPaidReactions({ + chatId: message.chatId, + messageId: message.id, + }); + }); + return (
= ({ containerId={messageKey} isOwnMessage={message.isOutgoing} isChosen={isChosen} - reaction={reaction.reaction} + reaction={reaction.reaction as ApiReaction} tag={tag} withContextMenu={isCurrentUserPremium} onClick={handleClick} @@ -156,6 +205,8 @@ const Reactions: FC = ({ ) : ( = ({ recentReactors={recentReactors} reaction={reaction} onClick={handleClick} + onPaidClick={handlePaidClick} observeIntersection={observeIntersection} + availableStars={availableStars} /> ) ))} diff --git a/src/components/middle/message/reactions/SavedTagButton.tsx b/src/components/middle/message/reactions/SavedTagButton.tsx index 58eac8268..fcc38407d 100644 --- a/src/components/middle/message/reactions/SavedTagButton.tsx +++ b/src/components/middle/message/reactions/SavedTagButton.tsx @@ -1,7 +1,10 @@ import React, { memo, useRef } from '../../../../lib/teact/teact'; import { getActions } from '../../../../global'; -import type { ApiReaction, ApiSavedReactionTag } from '../../../../api/types'; +import type { + ApiReaction, + ApiSavedReactionTag, +} from '../../../../api/types'; import type { ObserveFn } from '../../../../hooks/useIntersectionObserver'; import buildClassName from '../../../../util/buildClassName'; @@ -90,7 +93,7 @@ const SavedTagButton = ({ handleContextMenu, handleContextMenuClose, handleContextMenuHide, - } = useContextMenuHandlers(ref, !withContextMenu); + } = useContextMenuHandlers(ref, !withContextMenu, undefined, undefined, undefined, true); const getTriggerElement = useLastCallback(() => ref.current); const getRootElement = useLastCallback(() => document.body); diff --git a/src/components/modals/ModalContainer.tsx b/src/components/modals/ModalContainer.tsx index ddaef243b..c29e08be1 100644 --- a/src/components/modals/ModalContainer.tsx +++ b/src/components/modals/ModalContainer.tsx @@ -14,6 +14,7 @@ import GiftCodeModal from './giftcode/GiftCodeModal.async'; import InviteViaLinkModal from './inviteViaLink/InviteViaLinkModal.async'; import MapModal from './map/MapModal.async'; import OneTimeMediaModal from './oneTimeMedia/OneTimeMediaModal.async'; +import PaidReactionModal from './paidReaction/PaidReactionModal.async'; import ReportAdModal from './reportAd/ReportAdModal.async'; import StarsBalanceModal from './stars/StarsBalanceModal.async'; import StarsPaymentModal from './stars/StarsPaymentModal.async'; @@ -35,6 +36,8 @@ type ModalKey = keyof Pick; @@ -66,6 +69,7 @@ const MODALS: ModalRegistry = { isStarPaymentModalOpen: StarsPaymentModal, starsBalanceModal: StarsBalanceModal, starsTransactionModal: StarsTransactionInfoModal, + paidReactionModal: PaidReactionModal, }; const MODAL_KEYS = Object.keys(MODALS) as ModalKey[]; const MODAL_ENTRIES = Object.entries(MODALS) as Entries; diff --git a/src/components/modals/paidReaction/PaidReactionModal.async.tsx b/src/components/modals/paidReaction/PaidReactionModal.async.tsx new file mode 100644 index 000000000..d247565d4 --- /dev/null +++ b/src/components/modals/paidReaction/PaidReactionModal.async.tsx @@ -0,0 +1,18 @@ +import type { FC } from '../../../lib/teact/teact'; +import React from '../../../lib/teact/teact'; + +import type { OwnProps } from './PaidReactionModal'; + +import { Bundles } from '../../../util/moduleLoader'; + +import useModuleLoader from '../../../hooks/useModuleLoader'; + +const PaidReactionModalAsync: FC = (props) => { + const { modal } = props; + const PaidReactionModal = useModuleLoader(Bundles.Extra, 'PaidReactionModal', !modal); + + // eslint-disable-next-line react/jsx-props-no-spreading + return PaidReactionModal ? : undefined; +}; + +export default PaidReactionModalAsync; diff --git a/src/components/modals/paidReaction/PaidReactionModal.module.scss b/src/components/modals/paidReaction/PaidReactionModal.module.scss new file mode 100644 index 000000000..1fdfdbf48 --- /dev/null +++ b/src/components/modals/paidReaction/PaidReactionModal.module.scss @@ -0,0 +1,70 @@ +.content { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.title { + font-size: 1.5rem; + margin-bottom: 0; + margin-top: 1.25rem; +} + +.slider { + margin-top: 1.5rem; + flex-shrink: 0; +} + +.description { + margin-bottom: 1.5rem; +} + +.title, .description { + text-align: center; +} + +.modalBalance { + position: absolute; + top: 0.75rem; + right: 1.25rem; + z-index: 3; +} + +.topLabel { + background-image: var(--stars-gradient); + color: var(--color-white); + border-radius: 1rem; + padding: 0.25rem 0.75rem; +} + +.top { + display: flex; + justify-content: space-around; + margin-top: 1rem; +} + +.topBadge { + background-image: var(--stars-gradient); +} + +.buttonStar { + margin-inline-start: 0.25rem; +} + +.topPeer { + overflow: hidden; + flex-basis: 0; + flex-grow: 1; +} + +.checkbox { + align-self: center; + margin-bottom: 1.5rem; +} + +.disclaimer { + font-size: 0.875rem; + align-self: center; + color: var(--color-text-secondary); + margin-bottom: 0; +} diff --git a/src/components/modals/paidReaction/PaidReactionModal.tsx b/src/components/modals/paidReaction/PaidReactionModal.tsx new file mode 100644 index 000000000..345d7b734 --- /dev/null +++ b/src/components/modals/paidReaction/PaidReactionModal.tsx @@ -0,0 +1,247 @@ +import React, { + memo, useEffect, useMemo, useState, +} from '../../../lib/teact/teact'; +import { getActions, getGlobal, withGlobal } from '../../../global'; + +import type { ApiChat, ApiMessage, ApiUser } from '../../../api/types'; +import type { TabState } from '../../../global/types'; +import type { CustomPeer } from '../../../types'; + +import { getChatTitle, getUserFullName } from '../../../global/helpers'; +import { selectChat, selectChatMessage, selectUser } from '../../../global/selectors'; +import { formatInteger } from '../../../util/textFormat'; +import renderText from '../../common/helpers/renderText'; + +import useFlag from '../../../hooks/useFlag'; +import useLang from '../../../hooks/useLang'; +import useLastCallback from '../../../hooks/useLastCallback'; +import useOldLang from '../../../hooks/useOldLang'; + +import Icon from '../../common/icons/Icon'; +import PeerBadge from '../../common/PeerBadge'; +import SafeLink from '../../common/SafeLink'; +import Button from '../../ui/Button'; +import Checkbox from '../../ui/Checkbox'; +import Modal from '../../ui/Modal'; +import Separator from '../../ui/Separator'; +import BalanceBlock from '../stars/BalanceBlock'; +import StarSlider from './StarSlider'; + +import styles from './PaidReactionModal.module.scss'; + +export type OwnProps = { + modal: TabState['paidReactionModal']; +}; + +type StateProps = { + message?: ApiMessage; + chat?: ApiChat; + maxAmount: number; + starBalance?: number; + defaultPrivacy?: boolean; +}; + +type ReactorData = { + amount: number; + localAmount: number; + isMe?: boolean; + isAnonymous?: boolean; + user?: ApiUser; +}; + +const MAX_TOP_REACTORS = 3; +const DEFAULT_STARS_AMOUNT = 50; +const MAX_REACTION_AMOUNT = 2500; +const ANONYMOUS_PEER: CustomPeer = { + avatarIcon: 'author-hidden', + customPeerAvatarColor: '#9eaab5', + isCustomPeer: true, + titleKey: 'StarsReactionAnonymous', +}; + +const PaidReactionModal = ({ + modal, + chat, + message, + maxAmount, + starBalance, + defaultPrivacy, +}: OwnProps & StateProps) => { + const { closePaidReactionModal, addLocalPaidReaction } = getActions(); + + const [starsAmount, setStarsAmount] = useState(DEFAULT_STARS_AMOUNT); + const [isTouched, markTouched, unmarkTouched] = useFlag(); + const [shouldShowUp, setShouldShowUp] = useState(true); + + const oldLang = useOldLang(); + const lang = useLang(); + + const handleAnonimityChange = useLastCallback((e: React.ChangeEvent) => { + setShouldShowUp(e.target.checked); + }); + + const handleAmountChange = useLastCallback((value: number) => { + setStarsAmount(value); + markTouched(); + }); + + useEffect(() => { + if (!modal) { + unmarkTouched(); + } + }, [modal]); + + useEffect(() => { + const currentReactor = message?.reactions?.topReactors?.find((reactor) => reactor.isMe); + if (currentReactor) { + setShouldShowUp(!currentReactor.isAnonymous); + return; + } + + setShouldShowUp(defaultPrivacy || true); + }, [defaultPrivacy, message?.reactions?.topReactors]); + + const handleSend = useLastCallback(() => { + if (!modal) return; + + addLocalPaidReaction({ + chatId: modal.chatId, + messageId: modal.messageId, + count: starsAmount, + isPrivate: !shouldShowUp, + }); + closePaidReactionModal(); + }); + + const topReactors = useMemo(() => { + const global = getGlobal(); + const all = message?.reactions?.topReactors; + if (!all) { + return undefined; + } + + const result: ReactorData[] = []; + let hasMe = false; + + all.forEach((reactor) => { + const user = reactor.peerId ? selectUser(global, reactor.peerId) : undefined; + if (!user && !reactor.isAnonymous && !reactor.isMe) return; + + if (reactor.isMe) { + hasMe = true; + } + + result.push({ + amount: reactor.count, + localAmount: reactor.isMe && isTouched ? starsAmount : 0, + isMe: reactor.isMe, + isAnonymous: reactor.isAnonymous, + user, + }); + }); + + if (!hasMe && isTouched) { + const me = selectUser(global, global.currentUserId!); + result.push({ + amount: 0, + localAmount: starsAmount, + isMe: true, + user: me, + }); + } + + result.sort((a, b) => (b.amount + b.localAmount) - (a.amount + a.localAmount)); + + return result.slice(0, MAX_TOP_REACTORS); + }, [isTouched, message?.reactions?.topReactors, starsAmount]); + + const chatTitle = chat && getChatTitle(oldLang, chat); + + return ( + + {starBalance !== undefined && } + +

{oldLang('StarsReactionTitle')}

+
+ {renderText(oldLang('StarsReactionText', chatTitle), ['simple_markdown', 'emoji'])} +
+ + {topReactors &&
{oldLang('StarsReactionTopSenders')}
} +
+ {topReactors && ( +
+ {topReactors.map((reactor) => { + const countText = formatInteger(reactor.amount + reactor.localAmount); + const peer = (reactor.isAnonymous || !reactor.user || (reactor.isMe && !shouldShowUp)) + ? ANONYMOUS_PEER : reactor.user; + const text = 'isCustomPeer' in peer ? oldLang(peer.titleKey) : getUserFullName(peer); + return ( + + ); + })} +
+ )} + + +

+ {lang('StarsReactionTerms', { + link: , + }, { + withNodes: true, + })} +

+
+ ); +}; + +export default memo(withGlobal( + (global, { modal }): StateProps => { + const chat = modal && selectChat(global, modal.chatId); + const message = modal && selectChatMessage(global, modal.chatId, modal.messageId); + const starBalance = global.stars?.balance; + const maxAmount = global.appConfig?.paidReactionMaxAmount || MAX_REACTION_AMOUNT; + const defaultPrivacy = global.settings.paidReactionPrivacy; + + return { + chat, + message, + starBalance, + maxAmount, + defaultPrivacy, + }; + }, +)(PaidReactionModal)); diff --git a/src/components/modals/paidReaction/StarSlider.module.scss b/src/components/modals/paidReaction/StarSlider.module.scss new file mode 100644 index 000000000..155049a22 --- /dev/null +++ b/src/components/modals/paidReaction/StarSlider.module.scss @@ -0,0 +1,137 @@ +@use "../../../styles/mixins"; + +.root { + --_size: 1.875rem; + --progress: 0; + + position: relative; + padding-top: 4rem; + overflow-x: hidden; + + @include mixins.reset-range(); +} + +.slider { + height: var(--_size) !important; + margin-bottom: 0 !important; + cursor: pointer; + + &::-webkit-slider-runnable-track { + height: var(--_size); + border-radius: 1rem; + background-color: var(--color-background-secondary); + } + + &::-moz-range-track { + height: var(--_size); + border-radius: 1rem; + background-color: var(--color-background-secondary); + } + + &::-webkit-slider-thumb { + height: var(--_size); + width: var(--_size); + background-color: transparent; + border: none; + outline: none; + box-shadow: none; + } + + &::-moz-range-thumb { + height: var(--_size); + width: var(--_size); + background-color: transparent; + border: none; + outline: none; + box-shadow: none; + } +} + +.sparkles { + left: 0; + bottom: 0; + height: var(--_size); + pointer-events: none; + + --_width: calc(var(--progress) * 100% - 1rem); + mask-image: linear-gradient(to right, black var(--_width), transparent calc(var(--_width) + 0.5rem)); + + color: white; +} + +.progress { + position: absolute; + left: 0; + bottom: 0; + height: var(--_size); + pointer-events: none; + border-radius: 1rem; + + min-width: var(--_size); + width: calc(var(--_size) + (var(--progress) * (100% - var(--_size)))); + + background-image: var(--stars-gradient); + + &::after { + content: ""; + position: absolute; + right: 0.125rem; + top: 0.125rem; + width: 1.625rem; + height: 1.625rem; + border-radius: 50%; + background-color: white; + z-index: 1; + } +} + +.floatingBadgeWrapper { + --_min-x: 0; + --_max-x: 100%; + + position: absolute; + left: 0; + right: 0; + transform: + translateX( + clamp( + var(--_min-x), + calc(var(--_size) / 2 + var(--progress) * (100% - var(--_size))), + var(--_max-x), + ) + ); + pointer-events: none; +} + +.floatingBadge { + --_speed: 0; + position: absolute; + top: -1rem; + left: 0; + transform: translate(-50%, -100%); +} + +.floatingBadgeText { + display: flex; + align-items: center; + gap: 0.125rem; + + padding: 0.5rem 1rem; + border-radius: 2rem; + + background-image: var(--stars-gradient); + + line-height: 1; + font-size: 1.5rem; + font-weight: 500; + color: white; + white-space: nowrap; +} + +.floatingBadgeTriangle { + position: absolute; + left: 50%; + bottom: 0; + transform: translate(-50%, 33%); + z-index: -1; +} diff --git a/src/components/modals/paidReaction/StarSlider.tsx b/src/components/modals/paidReaction/StarSlider.tsx new file mode 100644 index 000000000..a0fd9f2e0 --- /dev/null +++ b/src/components/modals/paidReaction/StarSlider.tsx @@ -0,0 +1,134 @@ +import React, { + memo, useMemo, useRef, useState, +} from '../../../lib/teact/teact'; + +import { requestMeasure, requestMutation } from '../../../lib/fasterdom/fasterdom'; +import buildClassName from '../../../util/buildClassName'; +import { formatInteger } from '../../../util/textFormat'; + +import useEffectOnce from '../../../hooks/useEffectOnce'; +import useLastCallback from '../../../hooks/useLastCallback'; +import useResizeObserver from '../../../hooks/useResizeObserver'; + +import AnimatedCounter from '../../common/AnimatedCounter'; +import Icon from '../../common/icons/Icon'; +import Sparkles from '../../common/Sparkles'; + +import styles from './StarSlider.module.scss'; + +type OwnProps = { + maxValue: number; + defaultValue: number; + className?: string; + onChange: (value: number) => void; +}; + +const DEFAULT_POINTS = [50, 100, 500, 1000, 2000, 5000, 10000]; + +const StarSlider = ({ + maxValue, + defaultValue, + className, + onChange, +}: OwnProps) => { + // eslint-disable-next-line no-null/no-null + const floatingBadgeRef = useRef(null); + + const points = useMemo(() => { + const result = []; + for (let i = 0; i < DEFAULT_POINTS.length; i++) { + if (DEFAULT_POINTS[i] < maxValue) { + result.push(DEFAULT_POINTS[i]); + } + + if (DEFAULT_POINTS[i] >= maxValue) { + result.push(maxValue); + break; + } + } + + return result; + }, [maxValue]); + + const [value, setValue] = useState(0); + + useEffectOnce(() => { + setValue(getProgress(points, defaultValue)); + }); + + const updateSafeBadgePosition = useLastCallback(() => { + const badge = floatingBadgeRef.current; + if (!badge) return; + const parent = badge.parentElement!; + + requestMeasure(() => { + const safeMinX = parent.offsetLeft + badge.offsetWidth / 2; + const safeMaxX = parent.offsetLeft + parent.offsetWidth - badge.offsetWidth / 2; + + requestMutation(() => { + parent.style.setProperty('--_min-x', `${safeMinX}px`); + parent.style.setProperty('--_max-x', `${safeMaxX}px`); + }); + }); + }); + + useResizeObserver(floatingBadgeRef, updateSafeBadgePosition); + + const handleChange = useLastCallback((event: React.ChangeEvent) => { + const newValue = Number(event.currentTarget.value); + setValue(newValue); + + onChange(getValue(points, newValue)); + }); + + return ( +
+
+
+
+ + +
+ + + + + + + + + +
+
+
+ + +
+ ); +}; + +function getProgress(points: number[], value: number) { + const pointIndex = points.findIndex((point) => value <= point); + const prevPoint = points[pointIndex - 1] || 1; + const nextPoint = points[pointIndex] || points[points.length - 1]; + const progress = (value - prevPoint) / (nextPoint - prevPoint); + return pointIndex + progress; +} + +function getValue(points: number[], progress: number) { + const pointIndex = Math.floor(progress); + const prevPoint = points[pointIndex - 1] || 1; + const nextPoint = points[pointIndex] || points[points.length - 1]; + const value = prevPoint + (nextPoint - prevPoint) * (progress - pointIndex); + return Math.round(value); +} + +export default memo(StarSlider); diff --git a/src/components/modals/stars/StarsBalanceModal.tsx b/src/components/modals/stars/StarsBalanceModal.tsx index b6a03ab0b..a933f62a7 100644 --- a/src/components/modals/stars/StarsBalanceModal.tsx +++ b/src/components/modals/stars/StarsBalanceModal.tsx @@ -1,16 +1,17 @@ import React, { memo, useEffect, useMemo, useState, } from '../../../lib/teact/teact'; -import { getActions, withGlobal } from '../../../global'; +import { getActions, getGlobal, withGlobal } from '../../../global'; -import type { ApiUser } from '../../../api/types'; +import type { ApiStarTopupOption } from '../../../api/types'; import type { GlobalState, TabState } from '../../../global/types'; -import { getUserFullName } from '../../../global/helpers'; -import { selectIsPremiumPurchaseBlocked, selectUser } from '../../../global/selectors'; +import { getChatTitle, getUserFullName } from '../../../global/helpers'; +import { selectChat, selectIsPremiumPurchaseBlocked, selectUser } from '../../../global/selectors'; import buildClassName from '../../../util/buildClassName'; import renderText from '../../common/helpers/renderText'; +import useFlag from '../../../hooks/useFlag'; import useLang from '../../../hooks/useLang'; import useLastCallback from '../../../hooks/useLastCallback'; import useOldLang from '../../../hooks/useOldLang'; @@ -24,6 +25,7 @@ import Modal from '../../ui/Modal'; import TabList, { type TabWithProperties } from '../../ui/TabList'; import Transition from '../../ui/Transition'; import BalanceBlock from './BalanceBlock'; +import StarTopupOptionList from './StarTopupOptionList'; import TransactionItem from './transaction/StarsTransactionItem'; import styles from './StarsBalanceModal.module.scss'; @@ -44,15 +46,14 @@ export type OwnProps = { type StateProps = { starsBalanceState?: GlobalState['stars']; - originPaymentBot?: ApiUser; canBuyPremium?: boolean; }; const StarsBalanceModal = ({ - modal, starsBalanceState, originPaymentBot, canBuyPremium, + modal, starsBalanceState, canBuyPremium, }: OwnProps & StateProps) => { const { - closeStarsBalanceModal, loadStarsTransactions, openStarsGiftingModal, openStarsGiftModal, + closeStarsBalanceModal, loadStarsTransactions, openStarsGiftingModal, openInvoice, } = getActions(); const { balance, history } = starsBalanceState || {}; @@ -62,13 +63,35 @@ const StarsBalanceModal = ({ const [isHeaderHidden, setHeaderHidden] = useState(true); const [selectedTabIndex, setSelectedTabIndex] = useState(0); + const [areBuyOptionsShown, showBuyOptions, hideBuyOptions] = useFlag(); const isOpen = Boolean(modal && starsBalanceState); - const productStarsPrice = modal?.originPayment?.invoice?.amount; - const starsNeeded = productStarsPrice ? productStarsPrice - (balance || 0) : undefined; - const originBotName = originPaymentBot && getUserFullName(originPaymentBot); - const shouldShowTransactions = Boolean(history?.all?.transactions.length && !modal?.originPayment); + const { originPayment, originReaction } = modal || {}; + + const ongoingTransactionAmount = originPayment?.invoice?.amount || originReaction?.amount; + const starsNeeded = ongoingTransactionAmount ? ongoingTransactionAmount - (balance || 0) : undefined; + const starsNeededText = useMemo(() => { + if (!starsNeeded || starsNeeded < 0) return undefined; + const global = getGlobal(); + + if (originReaction) { + const channel = selectChat(global, originReaction.chatId); + if (!channel) return undefined; + return oldLang('StarsNeededTextReactions', getChatTitle(oldLang, channel)); + } + + if (originPayment) { + const bot = selectUser(global, originPayment.botId!); + if (!bot) return undefined; + return oldLang('StarsNeededText', getUserFullName(bot)); + } + + return undefined; + }, [oldLang, originPayment, originReaction, starsNeeded]); + + const shouldShowTransactions = Boolean(history?.all?.transactions.length && !originPayment && !originReaction); + const shouldSuggestGifting = !originPayment && !originReaction; useEffect(() => { if (!isOpen) { @@ -77,6 +100,15 @@ const StarsBalanceModal = ({ } }, [isOpen]); + useEffect(() => { + if (ongoingTransactionAmount) { + showBuyOptions(); + return; + } + + hideBuyOptions(); + }, [ongoingTransactionAmount]); + const tosText = useMemo(() => { if (!isOpen) return undefined; @@ -105,8 +137,13 @@ const StarsBalanceModal = ({ openStarsGiftingModal({}); }); - const openStarsInfoModalHandler = useLastCallback(() => { - openStarsGiftModal({}); + const handleBuyStars = useLastCallback((option: ApiStarTopupOption) => { + openInvoice({ + type: 'stars', + stars: option.stars, + currency: option.currency, + amount: option.amount, + }); }); return ( @@ -137,19 +174,19 @@ const StarsBalanceModal = ({
{renderText( - starsNeeded ? oldLang('StarsNeededText', originBotName) : oldLang('TelegramStarsInfo'), + starsNeededText || oldLang('TelegramStarsInfo'), ['simple_markdown', 'emoji'], )}
- {canBuyPremium && ( + {canBuyPremium && !areBuyOptionsShown && ( )} - {canBuyPremium && ( + {canBuyPremium && !areBuyOptionsShown && shouldSuggestGifting && (
{tosText} @@ -201,13 +245,9 @@ const StarsBalanceModal = ({ }; export default memo(withGlobal( - (global, { modal }): StateProps => { - const botId = modal?.originPayment?.botId; - const bot = botId ? selectUser(global, botId) : undefined; - + (global): StateProps => { return { starsBalanceState: global.stars, - originPaymentBot: bot, canBuyPremium: !selectIsPremiumPurchaseBlocked(global), }; }, diff --git a/src/components/modals/stars/transaction/PaidMediaThumb.module.scss b/src/components/modals/stars/transaction/PaidMediaThumb.module.scss index 207341a01..f864e0ced 100644 --- a/src/components/modals/stars/transaction/PaidMediaThumb.module.scss +++ b/src/components/modals/stars/transaction/PaidMediaThumb.module.scss @@ -12,8 +12,8 @@ } .preview { - height: 3rem; - width: 3rem; + height: 2.75rem; + width: 2.75rem; grid-auto-columns: 0.25rem; grid-auto-rows: 0.25rem; diff --git a/src/components/modals/stars/transaction/StarsTransactionItem.tsx b/src/components/modals/stars/transaction/StarsTransactionItem.tsx index 10176ad09..fa1b8052d 100644 --- a/src/components/modals/stars/transaction/StarsTransactionItem.tsx +++ b/src/components/modals/stars/transaction/StarsTransactionItem.tsx @@ -50,7 +50,15 @@ const StarsTransactionItem = ({ transaction }: OwnProps) => { const peer = useSelector(selectOptionalPeer(peerId)); const data = useMemo(() => { - let title = transaction.title || (transaction.extendedMedia ? lang('StarMediaPurchase') : undefined); + let title = transaction.title; + if (transaction.extendedMedia) { + title = lang('StarMediaPurchase'); + } + + if (transaction.isReaction) { + title = lang('StarsReactionsSent'); + } + let description; let status: string | undefined; let avatarPeer: ApiPeer | CustomPeer | undefined; diff --git a/src/components/modals/stars/transaction/StarsTransactionModal.tsx b/src/components/modals/stars/transaction/StarsTransactionModal.tsx index cc0c48594..bdcf81216 100644 --- a/src/components/modals/stars/transaction/StarsTransactionModal.tsx +++ b/src/components/modals/stars/transaction/StarsTransactionModal.tsx @@ -133,7 +133,18 @@ const StarsTransactionModal: FC = ({ const peerId = transaction.peer?.type === 'peer' ? transaction.peer.id : undefined; const toName = transaction.peer && oldLang(getStarsPeerTitleKey(transaction.peer)); - const title = transaction.title || (customPeer ? oldLang(customPeer.titleKey) : undefined); + let title = transaction.title; + if (!title && customPeer) { + title = oldLang(customPeer.titleKey); + } + + if (!title && transaction.extendedMedia) { + title = oldLang('StarMediaPurchase'); + } + + if (!title && transaction.isReaction) { + title = oldLang('StarsReactionsSent'); + } const messageLink = peer && transaction.messageId ? getMessageLink(peer, undefined, transaction.messageId) : undefined; @@ -198,7 +209,7 @@ const StarsTransactionModal: FC = ({ ]); if (messageLink) { - tableData.push([oldLang('Stars.Transaction.Media'), ]); + tableData.push([oldLang('Stars.Transaction.Reaction.Post'), ]); } if (isPrizeStars) { diff --git a/src/components/right/management/ManageReactions.tsx b/src/components/right/management/ManageReactions.tsx index 1ca8ee4b1..fc98c21dc 100644 --- a/src/components/right/management/ManageReactions.tsx +++ b/src/components/right/management/ManageReactions.tsx @@ -18,7 +18,7 @@ import { selectChat, selectChatFullInfo } from '../../../global/selectors'; import useHistoryBack from '../../../hooks/useHistoryBack'; import useOldLang from '../../../hooks/useOldLang'; -import ReactionStaticEmoji from '../../common/ReactionStaticEmoji'; +import ReactionStaticEmoji from '../../common/reactions/ReactionStaticEmoji'; import Checkbox from '../../ui/Checkbox'; import FloatingActionButton from '../../ui/FloatingActionButton'; import RadioGroup from '../../ui/RadioGroup'; diff --git a/src/components/story/StoryFooter.tsx b/src/components/story/StoryFooter.tsx index f8e37acf6..2b05020ce 100644 --- a/src/components/story/StoryFooter.tsx +++ b/src/components/story/StoryFooter.tsx @@ -4,7 +4,9 @@ import { getActions, getGlobal } from '../../global'; import type { ApiStory } from '../../api/types'; import { HEART_REACTION } from '../../config'; -import { getStoryKey, isUserId } from '../../global/helpers'; +import { + getReactionKey, getStoryKey, isSameReaction, isUserId, +} from '../../global/helpers'; import buildClassName from '../../util/buildClassName'; import useLastCallback from '../../hooks/useLastCallback'; @@ -35,8 +37,7 @@ const StoryFooter = ({ const { viewsCount, forwardsCount, reactionsCount } = views || {}; const isChannel = !isUserId(peerId); - const isSentStoryReactionHeart = sentReaction && 'emoticon' in sentReaction - ? sentReaction.emoticon === HEART_REACTION.emoticon : false; + const isSentStoryReactionHeart = sentReaction && isSameReaction(sentReaction, HEART_REACTION); const canForward = Boolean( (isOut || isChannel) @@ -152,7 +153,7 @@ const StoryFooter = ({ > {sentReaction && ( void; action?: CallbackAction | CallbackAction[]; actionText?: string; className?: string; + icon?: IconName; + shouldDisableClickDismiss?: boolean; + dismissAction?: CallbackAction; + shouldShowTimer?: boolean; + cacheBreaker?: string; + onDismiss: NoneToVoidFunction; }; const DEFAULT_DURATION = 3000; const ANIMATION_DURATION = 150; const Notification: FC = ({ - title, className, - message, duration = DEFAULT_DURATION, containerId, onDismiss, - action, actionText, + title, + className, + message, + duration = DEFAULT_DURATION, + containerId, + icon, + action, + actionText, + shouldDisableClickDismiss, + dismissAction, + shouldShowTimer, + cacheBreaker, + onDismiss, }) => { const actions = getActions(); @@ -47,10 +65,15 @@ const Notification: FC = ({ const timerRef = useRef(null); const { transitionClassNames } = useShowTransitionDeprecated(isOpen); - const closeAndDismiss = useCallback(() => { + const closeAndDismiss = useLastCallback((force?: boolean) => { + if (!force && shouldDisableClickDismiss) return; setIsOpen(false); setTimeout(onDismiss, ANIMATION_DURATION + ANIMATION_END_DELAY); - }, [onDismiss]); + if (dismissAction) { + // @ts-ignore + actions[dismissAction.action](dismissAction.payload); + } + }); const handleClick = useCallback(() => { if (action) { @@ -68,7 +91,7 @@ const Notification: FC = ({ useEffect(() => (isOpen ? captureEscKeyListener(closeAndDismiss) : undefined), [isOpen, closeAndDismiss]); useEffect(() => { - timerRef.current = window.setTimeout(closeAndDismiss, duration); + timerRef.current = window.setTimeout(() => closeAndDismiss(true), duration); return () => { if (timerRef.current) { @@ -76,18 +99,23 @@ const Notification: FC = ({ timerRef.current = undefined; } }; - }, [duration, closeAndDismiss]); + }, [duration, cacheBreaker]); // Reset timer if `cacheBreaker` changes - const handleMouseEnter = useCallback(() => { + const handleMouseEnter = useLastCallback(() => { + if (shouldDisableClickDismiss) return; if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = undefined; } - }, []); + }); - const handleMouseLeave = useCallback(() => { + const handleMouseLeave = useLastCallback(() => { + if (shouldDisableClickDismiss) return; + if (timerRef.current) { + clearTimeout(timerRef.current); + } timerRef.current = window.setTimeout(closeAndDismiss, duration); - }, [duration, closeAndDismiss]); + }); return ( @@ -97,6 +125,7 @@ const Notification: FC = ({ onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} > +
{title &&
{title}
} {message} @@ -105,11 +134,14 @@ const Notification: FC = ({ )} + {shouldShowTimer && ( + + )}
); diff --git a/src/components/ui/RoundTimer.module.scss b/src/components/ui/RoundTimer.module.scss new file mode 100644 index 000000000..583ef182f --- /dev/null +++ b/src/components/ui/RoundTimer.module.scss @@ -0,0 +1,24 @@ +.root { + position: relative; + color: var(--color-primary); + font-weight: 500; +} + +.svg { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.circle { + stroke: var(--color-primary); + fill: transparent; + stroke-width: 2; + stroke-linecap: round; + transition: stroke-dashoffset 1s linear, stroke 0.2s; + + @starting-style { + stroke-dashoffset: 0; + } +} diff --git a/src/components/ui/RoundTimer.tsx b/src/components/ui/RoundTimer.tsx new file mode 100644 index 000000000..52eb5dbef --- /dev/null +++ b/src/components/ui/RoundTimer.tsx @@ -0,0 +1,60 @@ +import React, { memo, useEffect, useState } from '../../lib/teact/teact'; + +import buildClassName from '../../util/buildClassName'; +import { formatCountdownShort } from '../../util/dates/dateFormat'; + +import useInterval from '../../hooks/schedulers/useInterval'; +import useOldLang from '../../hooks/useOldLang'; + +import AnimatedCounter from '../common/AnimatedCounter'; + +import styles from './RoundTimer.module.scss'; + +type OwnProps = { + duration: number; + className?: string; + onEnd?: NoneToVoidFunction; +}; + +const UPDATE_FREQUENCY = 1000; +const TIMER_RADIUS = 14; + +const RoundTimer = ({ duration, className, onEnd }: OwnProps) => { + const [timeLeft, setTimeLeft] = useState(duration); + const lang = useOldLang(); + + useInterval( + () => setTimeLeft((prev) => prev - 1), + timeLeft > 0 ? UPDATE_FREQUENCY : undefined, + ); + + useEffect(() => { + if (timeLeft <= 0) { + onEnd?.(); + } + }, [timeLeft, onEnd]); + + useEffect(() => { + setTimeLeft(duration); + }, [duration]); + + return ( +
+ + + + +
+ ); +}; + +export default memo(RoundTimer); diff --git a/src/config.ts b/src/config.ts index e3777c6f7..0bd1a93bc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -295,6 +295,7 @@ export const COUNTRIES_WITH_12H_TIME_FORMAT = new Set(['AU', 'BD', 'CA', 'CO', ' export const API_CHAT_TYPES = ['bots', 'channels', 'chats', 'users'] as const; export const HEART_REACTION: ApiReactionEmoji = { + type: 'emoji', emoticon: '❤', }; diff --git a/src/global/actions/api/chats.ts b/src/global/actions/api/chats.ts index ee32c3c1a..ad1b5c5de 100644 --- a/src/global/actions/api/chats.ts +++ b/src/global/actions/api/chats.ts @@ -233,8 +233,6 @@ addActionHandler('openChat', (global, actions, payload): ActionReturnType => { } else if (isChatOnlySummary && !chat.isMin) { actions.requestChatUpdate({ chatId: id }); } - actions.closeStoryViewer({ tabId }); - actions.closeStarsBalanceModal({ tabId }); }); addActionHandler('openSavedDialog', (global, actions, payload): ActionReturnType => { diff --git a/src/global/actions/api/messages.ts b/src/global/actions/api/messages.ts index e199373ac..a8ff5b7e3 100644 --- a/src/global/actions/api/messages.ts +++ b/src/global/actions/api/messages.ts @@ -2094,6 +2094,11 @@ addActionHandler('loadFactChecks', async (global, actions, payload): Promise { + callApi('fetchPaidReactionPrivacy'); + return undefined; +}); + addActionHandler('loadOutboxReadDate', async (global, actions, payload): Promise => { const { chatId, messageId } = payload; diff --git a/src/global/actions/api/reactions.ts b/src/global/actions/api/reactions.ts index bac4d785c..461ef8f10 100644 --- a/src/global/actions/api/reactions.ts +++ b/src/global/actions/api/reactions.ts @@ -1,4 +1,4 @@ -import type { ApiReactionEmoji } from '../../../api/types'; +import type { ApiError, ApiReactionEmoji } from '../../../api/types'; import type { ActionReturnType } from '../../types'; import { ApiMediaFormat } from '../../../api/types'; @@ -12,6 +12,7 @@ import * as mediaLoader from '../../../util/mediaLoader'; import requestActionTimeout from '../../../util/requestActionTimeout'; import { callApi } from '../../../api/gramjs'; import { + addPaidReaction, getDocumentMediaHash, getReactionKey, getUserReactions, @@ -92,6 +93,7 @@ addActionHandler('loadAvailableEffects', async (global): Promise => { for (const effect of effects) { if (effect.effectAnimationId) { const reaction: ApiReactionEmoji = { + type: 'emoji', emoticon: effect.emoticon, }; reactions.push(reaction); @@ -240,6 +242,71 @@ addActionHandler('toggleReaction', async (global, actions, payload): Promise { + const { + chatId, messageId, count, isPrivate, tabId = getCurrentTabId(), + } = payload; + const chat = selectChat(global, chatId); + const message = selectChatMessage(global, chatId, messageId); + + if (!chat || !message) { + return; + } + + const currentReactions = message.reactions?.results || []; + const newReactions = addPaidReaction(currentReactions, count, isPrivate); + global = updateChatMessage(global, message.chatId, message.id, { + reactions: { + ...currentReactions, + results: newReactions, + }, + }); + setGlobal(global); + + const messageKey = getMessageKey(message); + if (selectPerformanceSettingsValue(global, 'reactionEffects')) { + actions.startActiveReaction({ + containerId: messageKey, + reaction: { + type: 'paid', + }, + tabId, + }); + } +}); + +addActionHandler('sendPaidReaction', async (global, actions, payload): Promise => { + const { + chatId, messageId, forcedAmount, tabId = getCurrentTabId(), + } = payload; + const chat = selectChat(global, chatId); + const message = selectChatMessage(global, chatId, messageId); + + if (!chat || !message) { + return; + } + + const paidReaction = message.reactions?.results?.find((r) => r.reaction.type === 'paid'); + const count = forcedAmount || paidReaction?.localAmount || 0; + if (!count) { + return; + } + actions.resetLocalPaidReactions({ chatId, messageId }); + + try { + await callApi('sendPaidReaction', { + chat, + messageId, + count, + isPrivate: paidReaction?.localIsPrivate, + }); + } catch (error) { + if ((error as ApiError).message === 'BALANCE_TOO_LOW') { + actions.openStarsBalanceModal({ originReaction: { chatId, messageId, amount: count }, tabId }); + } + } +}); + addActionHandler('startActiveReaction', (global, actions, payload): ActionReturnType => { const { containerId, reaction, tabId = getCurrentTabId() } = payload; const tabState = selectTabState(global, tabId); diff --git a/src/global/actions/apiUpdaters/messages.ts b/src/global/actions/apiUpdaters/messages.ts index 793266c02..89e5da391 100644 --- a/src/global/actions/apiUpdaters/messages.ts +++ b/src/global/actions/apiUpdaters/messages.ts @@ -19,6 +19,7 @@ import { getMessageKey, isLocalMessageId } from '../../../util/keys/messageKey'; import { notifyAboutMessage } from '../../../util/notifications'; import { onTickEnd } from '../../../util/schedulers'; import { + addPaidReaction, checkIfHasUnreadReactions, getIsSavedDialog, getMessageContent, getMessageText, isActionMessage, isMessageLocal, isUserId, } from '../../helpers'; @@ -788,6 +789,12 @@ function updateReactions( return global; } + const localPaidReaction = currentReactions?.results.find((r) => r.localAmount); + // Save local count on update, but reset if we sent reaction + if (localPaidReaction?.localAmount) { + reactions.results = addPaidReaction(reactions.results, localPaidReaction.localAmount); + } + global = updateChatMessage(global, chatId, id, { reactions }); if (!isOutgoing) { diff --git a/src/global/actions/apiUpdaters/misc.ts b/src/global/actions/apiUpdaters/misc.ts index 2b5a8cc68..430c57e28 100644 --- a/src/global/actions/apiUpdaters/misc.ts +++ b/src/global/actions/apiUpdaters/misc.ts @@ -172,6 +172,19 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { actions.processPremiumFloodWait({ isUpload: update.isUpload, }); + break; + } + + case 'updatePaidReactionPrivacy': { + return { + ...global, + settings: { + ...global.settings, + paidReactionPrivacy: update.isPrivate, + }, + }; + setGlobal(global); + break; } } diff --git a/src/global/actions/ui/chats.ts b/src/global/actions/ui/chats.ts index 146a769c7..37490b817 100644 --- a/src/global/actions/ui/chats.ts +++ b/src/global/actions/ui/chats.ts @@ -38,6 +38,11 @@ addActionHandler('processOpenChatOrThread', (global, actions, payload): ActionRe } actions.hideEffectInComposer({ tabId }); + actions.closeStoryViewer({ tabId }); + actions.closeStarsBalanceModal({ tabId }); + actions.closeStarsBalanceModal({ tabId }); + actions.closeStarsTransactionModal({ tabId }); + if (!currentMessageList || ( currentMessageList.chatId !== chatId || currentMessageList.threadId !== threadId diff --git a/src/global/actions/ui/messages.ts b/src/global/actions/ui/messages.ts index a809c14a0..f849f1499 100644 --- a/src/global/actions/ui/messages.ts +++ b/src/global/actions/ui/messages.ts @@ -948,6 +948,20 @@ addActionHandler('openPreviousReportAdModal', (global, actions, payload): Action }, tabId); }); +addActionHandler('openPaidReactionModal', (global, actions, payload): ActionReturnType => { + const { chatId, messageId, tabId = getCurrentTabId() } = payload; + return updateTabState(global, { + paidReactionModal: { chatId, messageId }, + }, tabId); +}); + +addActionHandler('closePaidReactionModal', (global, actions, payload): ActionReturnType => { + const { tabId = getCurrentTabId() } = payload || {}; + return updateTabState(global, { + paidReactionModal: undefined, + }, tabId); +}); + function copyTextForMessages(global: GlobalState, chatId: string, messageIds: number[]) { const { type: messageListType, threadId } = selectCurrentMessageList(global) || {}; const lang = langProvider.oldTranslate; diff --git a/src/global/actions/ui/misc.ts b/src/global/actions/ui/misc.ts index 9138927f8..adf8ad3e8 100644 --- a/src/global/actions/ui/misc.ts +++ b/src/global/actions/ui/misc.ts @@ -305,10 +305,13 @@ addActionHandler('reorderStickerSets', (global, actions, payload): ActionReturnT addActionHandler('showNotification', (global, actions, payload): ActionReturnType => { const { tabId = getCurrentTabId(), ...notification } = payload; - notification.localId = generateUniqueId(); + const hasLocalId = notification.localId; + notification.localId ||= generateUniqueId(); const newNotifications = [...selectTabState(global, tabId).notifications]; - const existingNotificationIndex = newNotifications.findIndex((n) => n.message === notification.message); + const existingNotificationIndex = newNotifications.findIndex((n) => ( + hasLocalId ? n.localId === notification.localId : n.message === notification.message + )); if (existingNotificationIndex !== -1) { newNotifications.splice(existingNotificationIndex, 1); } @@ -522,7 +525,7 @@ addActionHandler('setReactionEffect', (global, actions, payload): ActionReturnTy chatId, threadId, reaction, tabId = getCurrentTabId(), } = payload; - const emoticon = reaction && 'emoticon' in reaction && reaction.emoticon; + const emoticon = reaction?.type === 'emoji' && reaction.emoticon; if (!emoticon) return; const effect = Object.values(global.availableEffectById) diff --git a/src/global/actions/ui/payments.ts b/src/global/actions/ui/payments.ts index d045d1b8e..538578405 100644 --- a/src/global/actions/ui/payments.ts +++ b/src/global/actions/ui/payments.ts @@ -12,7 +12,10 @@ addActionHandler('closePaymentModal', (global, actions, payload): ActionReturnTy const { tabId = getCurrentTabId() } = payload || {}; const payment = selectTabState(global, tabId).payment; const status = payment.status || 'cancelled'; - const originPayment = selectTabState(global, tabId).starsBalanceModal?.originPayment; + const starsBalanceModal = selectTabState(global, tabId).starsBalanceModal; + const originPayment = starsBalanceModal?.originPayment; + const originReaction = starsBalanceModal?.originReaction; + global = clearPayment(global, tabId); global = closeInvoice(global, tabId); global = updateTabState(global, { @@ -20,7 +23,7 @@ addActionHandler('closePaymentModal', (global, actions, payload): ActionReturnTy ...selectTabState(global, tabId).payment, status, }, - ...(originPayment && { + ...((originPayment || originReaction) && { starsBalanceModal: undefined, }), }, tabId); @@ -32,6 +35,16 @@ addActionHandler('closePaymentModal', (global, actions, payload): ActionReturnTy isStarPaymentModalOpen: true, }, tabId); } + + // Send reaction + if (originReaction) { + actions.sendPaidReaction({ + chatId: originReaction.chatId, + messageId: originReaction.messageId, + forcedAmount: originReaction.amount, + tabId, + }); + } return global; }); @@ -56,13 +69,17 @@ addActionHandler('closeGiftCodeModal', (global, actions, payload): ActionReturnT }); addActionHandler('openStarsBalanceModal', (global, actions, payload): ActionReturnType => { - const { originPayment, tabId = getCurrentTabId() } = payload || {}; + const { originPayment, originReaction, tabId = getCurrentTabId() } = payload || {}; global = clearPayment(global, tabId); + // Always refresh status on opening + actions.loadStarStatus(); + return updateTabState(global, { starsBalanceModal: { originPayment, + originReaction, }, }, tabId); }); diff --git a/src/global/actions/ui/reactions.ts b/src/global/actions/ui/reactions.ts index f041b6bd7..fcbf3c837 100644 --- a/src/global/actions/ui/reactions.ts +++ b/src/global/actions/ui/reactions.ts @@ -1,9 +1,11 @@ import type { ActionReturnType } from '../../types'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; +import { getMessageKey } from '../../../util/keys/messageKey'; import { addActionHandler } from '../../index'; +import { updateChatMessage } from '../../reducers'; import { updateTabState } from '../../reducers/tabs'; -import { selectTabState } from '../../selectors'; +import { selectChatMessage, selectTabState } from '../../selectors'; addActionHandler('processOpenChatOrThread', (global, actions, payload): ActionReturnType => { const { @@ -32,7 +34,7 @@ addActionHandler('openMessageReactionPicker', (global, actions, payload): Action messageId, position, tabId = getCurrentTabId(), - } = payload!; + } = payload; return updateTabState(global, { reactionPicker: { @@ -50,7 +52,7 @@ addActionHandler('openStoryReactionPicker', (global, actions, payload): ActionRe position, sendAsMessage, tabId = getCurrentTabId(), - } = payload!; + } = payload; return updateTabState(global, { reactionPicker: { @@ -67,7 +69,7 @@ addActionHandler('openEffectPicker', (global, actions, payload): ActionReturnTyp position, chatId, tabId = getCurrentTabId(), - } = payload!; + } = payload; return updateTabState(global, { reactionPicker: { @@ -93,3 +95,43 @@ addActionHandler('closeReactionPicker', (global, actions, payload): ActionReturn }, }, tabId); }); + +addActionHandler('resetLocalPaidReactions', (global, actions, payload): ActionReturnType => { + const { chatId, messageId } = payload; + const message = selectChatMessage(global, chatId, messageId); + if (!message) { + return undefined; + } + + const { reactions } = message; + + if (!reactions) { + return undefined; + } + + const updatedResults = reactions.results.map((reaction) => { + if (reaction.localAmount) { + if (!reaction.count) return undefined; + return { + ...reaction, + localAmount: undefined, + }; + } + return reaction; + }).filter(Boolean); + + Object.values(global.byTabId) + .forEach(({ id: tabId }) => { + actions.dismissNotification({ + localId: getMessageKey(message), + tabId, + }); + }); + + return updateChatMessage(global, chatId, messageId, { + reactions: { + ...reactions, + results: updatedResults, + }, + }); +}); diff --git a/src/global/cache.ts b/src/global/cache.ts index 4c18eadb3..ab1b2c87c 100644 --- a/src/global/cache.ts +++ b/src/global/cache.ts @@ -245,6 +245,11 @@ function unsafeMigrateCache(cached: GlobalState, initialState: GlobalState) { if (!cached.topBotApps) { cached.topBotApps = initialState.topBotApps; } + + if (!cached.reactions.defaultTags?.[0]?.type) { + cached.reactions = initialState.reactions; + } + if (!cached.users.commonChatsById) { cached.users.commonChatsById = initialState.users.commonChatsById; } @@ -523,7 +528,8 @@ function reduceMessages(global: T): GlobalState['messages const cleanedById = Object.values(byId).reduce((acc, message) => { if (!message) return acc; - const cleanedMessage = omitLocalMedia(message); + let cleanedMessage = omitLocalMedia(message); + cleanedMessage = omitLocalPaidReactions(cleanedMessage); acc[message.id] = cleanedMessage; return acc; }, {} as Record); @@ -540,6 +546,25 @@ function reduceMessages(global: T): GlobalState['messages }; } +function omitLocalPaidReactions(message: ApiMessage): ApiMessage { + if (!message.reactions?.results.length) return message; + return { + ...message, + reactions: { + ...message.reactions, + results: message.reactions.results.map((reaction) => { + if (reaction.localAmount) { + return { + ...reaction, + localAmount: undefined, + }; + } + return reaction; + }), + }, + }; +} + function omitLocalMedia(message: ApiMessage): ApiMessage { const { photo, video, document, sticker, diff --git a/src/global/helpers/reactions.ts b/src/global/helpers/reactions.ts index f7b444be8..34e511310 100644 --- a/src/global/helpers/reactions.ts +++ b/src/global/helpers/reactions.ts @@ -6,6 +6,7 @@ import type { ApiReactionCount, ApiReactionKey, ApiReactions, + ApiReactionWithPaid, } from '../../api/types'; import type { GlobalState } from '../types'; @@ -20,18 +21,26 @@ export function checkIfHasUnreadReactions(global: GlobalState, reactions: ApiRea } export function areReactionsEmpty(reactions: ApiReactions) { - return !reactions.results.some(({ count }) => count > 0); + return !reactions.results.some(({ count, localAmount }) => count || localAmount); } -export function getReactionKey(reaction: ApiReaction): ApiReactionKey { - if ('emoticon' in reaction) { - return `emoji-${reaction.emoticon}`; +export function getReactionKey(reaction: ApiReactionWithPaid): ApiReactionKey { + switch (reaction.type) { + case 'emoji': + return `emoji-${reaction.emoticon}`; + case 'custom': + return `document-${reaction.documentId}`; + case 'paid': + return 'paid'; + default: { + // Legacy reactions + const uniqueValue = (reaction as any).emoticon || (reaction as any).documentId; + return `unsupported-${uniqueValue}`; + } } - - return `document-${reaction.documentId}`; } -export function isSameReaction(first?: ApiReaction, second?: ApiReaction) { +export function isSameReaction(first?: ApiReactionWithPaid, second?: ApiReactionWithPaid) { if (first === second) { return true; } @@ -43,9 +52,9 @@ export function isSameReaction(first?: ApiReaction, second?: ApiReaction) { return getReactionKey(first) === getReactionKey(second); } -export function canSendReaction(reaction: ApiReaction, chatReactions: ApiChatReactions) { +export function canSendReaction(reaction: ApiReactionWithPaid, chatReactions: ApiChatReactions) { if (chatReactions.type === 'all') { - return 'emoticon' in reaction || chatReactions.areCustomAllowed; + return reaction.type === 'emoji' || chatReactions.areCustomAllowed; } if (chatReactions.type === 'some') { @@ -55,13 +64,17 @@ export function canSendReaction(reaction: ApiReaction, chatReactions: ApiChatRea return false; } -export function sortReactions( +export function sortReactions( reactions: T[], - topReactions?: ApiReaction[], + topReactions?: ApiReactionWithPaid[], ): T[] { return reactions.slice().sort((left, right) => { - const reactionOne = left ? ('reaction' in left ? left.reaction : left) as ApiReaction : undefined; - const reactionTwo = right ? ('reaction' in right ? right.reaction : right) as ApiReaction : undefined; + const reactionOne = left ? ('reaction' in left ? left.reaction : left) as ApiReactionWithPaid : undefined; + const reactionTwo = right ? ('reaction' in right ? right.reaction : right) as ApiReactionWithPaid : undefined; + + if (reactionOne?.type === 'paid') return -1; + if (reactionTwo?.type === 'paid') return 1; + const indexOne = topReactions?.findIndex((reaction) => isSameReaction(reaction, reactionOne)) || 0; const indexTwo = topReactions?.findIndex((reaction) => isSameReaction(reaction, reactionTwo)) || 0; return ( @@ -73,7 +86,8 @@ export function sortReactions( export function getUserReactions(message: ApiMessage): ApiReaction[] { return message.reactions?.results?.filter((r): r is Required => isReactionChosen(r)) .sort((a, b) => a.chosenOrder - b.chosenOrder) - .map((r) => r.reaction) || []; + .map((r) => r.reaction) + .filter((r): r is ApiReaction => r.type !== 'paid') || []; } export function isReactionChosen(reaction: ApiReactionCount) { @@ -108,3 +122,37 @@ export function updateReactionCount(reactionCount: ApiReactionCount[], newReacti return results; } + +export function addPaidReaction( + reactionCount: ApiReactionCount[], count: number, isAnonymous?: boolean, +): ApiReactionCount[] { + const results: ApiReactionCount[] = []; + const hasPaid = reactionCount.some((current) => current.reaction.type === 'paid'); + if (hasPaid) { + reactionCount.forEach((current) => { + if (current.reaction.type === 'paid') { + results.push({ + ...current, + localAmount: (current.localAmount || 0) + count, + chosenOrder: -1, + localIsPrivate: isAnonymous !== undefined ? isAnonymous : current.localIsPrivate, + }); + return; + } + + results.push(current); + }); + + return results; + } + + return [ + { + reaction: { type: 'paid' }, + count: 0, + chosenOrder: -1, + localAmount: count, + }, + ...reactionCount, + ]; +} diff --git a/src/global/types.ts b/src/global/types.ts index afaf523d6..05c5bd10c 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -57,6 +57,7 @@ import type { ApiQuickReply, ApiReaction, ApiReactionKey, + ApiReactionWithPaid, ApiReceiptRegular, ApiReportReason, ApiSavedReactionTag, @@ -422,7 +423,7 @@ export type TabState = { }; activeEmojiInteractions?: ActiveEmojiInteraction[]; - activeReactions: Record; + activeReactions: Record; middleSearch: { byChatThreadKey: Record; @@ -824,6 +825,11 @@ export type TabState = { info: ApiCheckedGiftCode; }; + paidReactionModal?: { + chatId: string; + messageId: number; + }; + inviteViaLinkModal?: { missingUsers: ApiMissingInvitedUser[]; chatId: string; @@ -841,6 +847,11 @@ export type TabState = { starsBalanceModal?: { originPayment?: TabState['payment']; + originReaction?: { + chatId: string; + messageId: number; + amount: number; + }; }; isStarPaymentModalOpen?: true; }; @@ -1172,6 +1183,7 @@ export type GlobalState = { privacy: Partial>; notifyExceptions?: Record; lastPremiumBandwithNotificationDate?: number; + paidReactionPrivacy?: boolean; }; push?: { @@ -2294,6 +2306,11 @@ export interface ActionPayloads { }; openStarsBalanceModal: { originPayment?: TabState['payment']; + originReaction?: { + chatId: string; + messageId: number; + amount: number; + }; } & WithTabId; closeStarsBalanceModal: WithTabId | undefined; @@ -2389,6 +2406,8 @@ export interface ActionPayloads { shouldIncludeGrouped?: boolean; } & WithTabId; + loadPaidReactionPrivacy: undefined; + sendPollVote: { chatId: string; messageId: number; @@ -2454,6 +2473,23 @@ export interface ActionPayloads { shouldAddToRecent?: boolean; } & WithTabId; + sendPaidReaction: { + chatId: string; + messageId: number; + forcedAmount?: number; + isPrivate?: boolean; + } & WithTabId; + addLocalPaidReaction: { + chatId: string; + messageId: number; + count: number; + isPrivate?: boolean; + } & WithTabId; + resetLocalPaidReactions: { + chatId: string; + messageId: number; + }; + setDefaultReaction: { reaction: ApiReaction; }; @@ -2470,11 +2506,11 @@ export interface ActionPayloads { startActiveReaction: { containerId: string; - reaction: ApiReaction; + reaction: ApiReactionWithPaid; } & WithTabId; stopActiveReaction: { containerId: string; - reaction?: ApiReaction; + reaction?: ApiReactionWithPaid; } & WithTabId; openEffectPicker: { @@ -3157,15 +3193,7 @@ export interface ActionPayloads { url?: string; } & WithTabId; closeUrlAuthModal: WithTabId | undefined; - showNotification: { - localId?: string; - title?: string; - message: string; - className?: string; - duration?: number; - actionText?: string; - action?: CallbackAction | CallbackAction[]; - } & WithTabId; + showNotification: Omit & { localId?: string } & WithTabId; showAllowedMessageTypesNotification: { chatId: string; } & WithTabId; @@ -3321,6 +3349,12 @@ export interface ActionPayloads { openStarsGiftingModal: WithTabId | undefined; closeStarsGiftingModal: WithTabId | undefined; + openPaidReactionModal: { + chatId: string; + messageId: number; + } & WithTabId; + closePaidReactionModal: WithTabId | undefined; + openDeleteMessageModal: ({ message?: ApiMessage; isSchedule?: boolean; diff --git a/src/hooks/useContextMenuHandlers.ts b/src/hooks/useContextMenuHandlers.ts index 964a08292..6e7d4c2c4 100644 --- a/src/hooks/useContextMenuHandlers.ts +++ b/src/hooks/useContextMenuHandlers.ts @@ -24,6 +24,7 @@ const useContextMenuHandlers = ( shouldDisableOnLink?: boolean, shouldDisableOnLongTap?: boolean, getIsReady?: Signal, + shouldDisablePropagation?: boolean, ) => { const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); const [contextMenuAnchor, setContextMenuAnchor] = useState(undefined); @@ -133,7 +134,7 @@ const useContextMenuHandlers = ( if (isMenuDisabled) { return; } - e.stopPropagation(); + if (shouldDisablePropagation) e.stopPropagation(); clearLongPressTimer(); timer = window.setTimeout(() => emulateContextMenuEvent(e), LONG_TAP_DURATION_MS); @@ -154,6 +155,7 @@ const useContextMenuHandlers = ( }; }, [ contextMenuAnchor, isMenuDisabled, shouldDisableOnLongTap, elementRef, shouldDisableOnLink, getIsReady, + shouldDisablePropagation, ]); return { diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index 1ff4d28b7..a390b151e 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -1563,6 +1563,8 @@ messages.sendQuickReplyMessages#6c750de1 peer:InputPeer shortcut_id:int id:Vecto messages.getAvailableEffects#dea20a39 hash:int = messages.AvailableEffects; messages.getFactCheck#b9cdc5ee peer:InputPeer msg_id:Vector = Vector; messages.requestMainWebView#c9e01e7b flags:# compact:flags.7?true peer:InputPeer bot:InputUser start_param:flags.1?string theme_params:flags.0?DataJSON platform:string = WebViewResult; +messages.sendPaidReaction#9dd6a67b flags:# peer:InputPeer msg_id:int count:int random_id:long private:flags.0?Bool = Updates; +messages.getPaidReactionPrivacy#472455aa = Updates; updates.getState#edd4882a = updates.State; updates.getDifference#19c2f763 flags:# pts:int pts_limit:flags.1?int pts_total_limit:flags.0?int date:int qts:int qts_limit:flags.2?int = updates.Difference; updates.getChannelDifference#3173d78 flags:# force:flags.0?true channel:InputChannel filter:ChannelMessagesFilter pts:int limit:int = updates.ChannelDifference; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index 3a0fd0680..d2d4dd8dd 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -176,6 +176,7 @@ "messages.sendQuickReplyMessages", "messages.getFactCheck", "messages.requestMainWebView", + "messages.getPaidReactionPrivacy", "updates.getState", "updates.getDifference", "updates.getChannelDifference", @@ -310,6 +311,7 @@ "messages.getSavedReactionTags", "messages.updateSavedReactionTag", "messages.getDefaultTagReactions", + "messages.sendPaidReaction", "help.getPremiumPromo", "channels.deactivateAllUsernames", "channels.toggleForum", diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index c9b1fca33..3a9025bdc 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -192,6 +192,7 @@ $color-message-story-mention-to: #74bcff; --color-deleted-account: #9eaab5; --color-archive: #9eaab5; + --stars-gradient: linear-gradient(90deg, #FFAA00 0%, #FFCD3A 100%); --color-heart: #ff3c32; diff --git a/src/styles/index.scss b/src/styles/index.scss index 86026491f..18a22766f 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -33,6 +33,7 @@ body { Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; --font-family-monospace: "Cascadia Mono", "Roboto Mono", "Droid Sans Mono", 'SF Mono', "Menlo", "Ubuntu Mono", "Consolas", monospace; + --font-family-rounded: -ui-rounded, "Roboto Round"; @media (max-width: 600px) { height: calc(var(--vh, 1vh) * 100); diff --git a/src/types/index.ts b/src/types/index.ts index 8b1709b88..93e564c46 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -13,6 +13,7 @@ import type { ApiMessage, ApiPhoto, ApiReaction, + ApiReactionWithPaid, ApiStickerSet, ApiUser, ApiVideo, @@ -274,7 +275,7 @@ export enum SettingsScreens { export type StickerSetOrReactionsSetOrRecent = Pick & { reactions?: ApiReaction[] }; +)> & { reactions?: ApiReactionWithPaid[] }; export enum LeftColumnContent { ChatList, diff --git a/src/types/language.d.ts b/src/types/language.d.ts index e151f3db9..21b42b2a5 100644 --- a/src/types/language.d.ts +++ b/src/types/language.d.ts @@ -1513,7 +1513,7 @@ export interface LangPair { 'RemoveEffect': undefined; 'ReplyInPrivateMessage': undefined; 'ProfileOpenAppAbout': { - 'terms': string; + 'terms': string | number; }; 'ProfileOpenAppTerms': undefined; 'ProfileBotOpenAppInfoLink': undefined; @@ -1539,12 +1539,21 @@ export interface LangPair { 'GiftStarsOutgoing': { 'user': string | number; }; + 'SendPaidReaction': { + 'amount': string | number; + }; + 'StarsReactionTerms': { + 'link': string | number; + }; + 'StarsReactionLinkText': undefined; + 'StarsReactionLink': undefined; 'MiniAppsMoreTabs': { 'botName': string | number; }; 'PrizeCredits': { 'count': string | number; }; + } export type LangKey = keyof LangPair; diff --git a/src/util/fonts.ts b/src/util/fonts.ts index 97ee6f5ff..3254d3c2b 100644 --- a/src/util/fonts.ts +++ b/src/util/fonts.ts @@ -1,4 +1,4 @@ -const SITE_FONTS = ['400 1em Roboto', '500 1em Roboto']; +const SITE_FONTS = ['400 1em Roboto', '500 1em Roboto', "500 1em 'Roboto Round'"]; export default function preloadFonts() { if ('fonts' in document) { diff --git a/src/util/notifications.ts b/src/util/notifications.ts index 8deb7693c..c4f2d2b82 100644 --- a/src/util/notifications.ts +++ b/src/util/notifications.ts @@ -401,10 +401,11 @@ async function getAvatar(chat: ApiPeer) { function getReactionEmoji(reaction: ApiPeerReaction) { let emoji; - if ('emoticon' in reaction.reaction) { + if (reaction.reaction.type === 'emoji') { emoji = reaction.reaction.emoticon; } - if ('documentId' in reaction.reaction) { + + if (reaction.reaction.type === 'custom') { // eslint-disable-next-line eslint-multitab-tt/no-immediate-global emoji = getGlobal().customEmojis.byId[reaction.reaction.documentId]?.emoji; } @@ -470,7 +471,7 @@ export async function notifyAboutMessage({ if (isReaction && !activeReaction) return; // If this is a custom emoji reaction we need to make sure it is loaded - if (isReaction && activeReaction && 'documentId' in activeReaction.reaction) { + if (isReaction && activeReaction && activeReaction.reaction.type === 'custom') { await loadCustomEmoji(activeReaction.reaction.documentId); } diff --git a/src/util/textFormat.ts b/src/util/textFormat.ts index aa586e9cc..146606a64 100644 --- a/src/util/textFormat.ts +++ b/src/util/textFormat.ts @@ -11,7 +11,7 @@ export function formatInteger(value: number) { function formatFixedNumber(number: number) { const fixed = String(number.toFixed(1)); if (fixed.substr(-2) === '.0') { - return Math.round(number); + return Math.floor(number); } return number.toFixed(1).replace('.', ',');