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 000000000..0a8478d2d Binary files /dev/null and b/src/assets/fonts/Roboto-Round-Regular.woff2 differ 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 000000000..2b5234fd5 Binary files /dev/null and b/src/assets/tgs/stars/StarReaction.tgs differ diff --git a/src/assets/tgs/stars/StarReactionEffect.tgs b/src/assets/tgs/stars/StarReactionEffect.tgs new file mode 100644 index 000000000..2cad9593e Binary files /dev/null and b/src/assets/tgs/stars/StarReactionEffect.tgs differ diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 4e47b14f2..8bab60b36 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -30,6 +30,7 @@ export { default as ChatlistModal } from '../components/modals/chatlist/Chatlist export { default as StarsBalanceModal } from '../components/modals/stars/StarsBalanceModal'; export { default as StarPaymentModal } from '../components/modals/stars/StarsPaymentModal'; export { default as StarsTransactionInfoModal } from '../components/modals/stars/transaction/StarsTransactionModal'; +export { default as PaidReactionModal } from '../components/modals/paidReaction/PaidReactionModal'; export { default as AboutAdsModal } from '../components/common/AboutAdsModal'; export { default as AboutMonetizationModal } from '../components/common/AboutMonetizationModal'; diff --git a/src/components/common/AnimatedCounter.tsx b/src/components/common/AnimatedCounter.tsx index e3975647e..284e54280 100644 --- a/src/components/common/AnimatedCounter.tsx +++ b/src/components/common/AnimatedCounter.tsx @@ -16,6 +16,7 @@ type OwnProps = { text: string; className?: string; isDisabled?: boolean; + ref?: React.RefObject; }; 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('.', ',');