From e050f427eed3fcf1dabe799a6ffd9ced590ebdfe Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Fri, 10 Nov 2023 13:55:30 +0400 Subject: [PATCH] Introduce giveaway support (#3960) --- src/api/gramjs/apiBuilders/messageContent.ts | 31 ++- src/api/gramjs/apiBuilders/messages.ts | 15 + src/api/gramjs/apiBuilders/payments.ts | 99 ++++++- src/api/gramjs/apiBuilders/stories.ts | 32 --- src/api/gramjs/methods/index.ts | 9 +- src/api/gramjs/methods/payments.ts | 150 +++++++++- src/api/gramjs/methods/stories.ts | 90 ------ src/api/types/messages.ts | 12 + src/api/types/payments.ts | 52 ++++ src/api/types/stories.ts | 19 -- src/bundles/extra.ts | 2 +- ...Link.module.scss => LinkField.module.scss} | 2 +- .../common/{InviteLink.tsx => LinkField.tsx} | 72 +++-- src/components/common/PickerSelectedItem.scss | 4 + src/components/common/PickerSelectedItem.tsx | 3 + .../left/settings/SettingsExperimental.tsx | 2 +- .../folders/SettingsShareChatlist.tsx | 7 +- src/components/main/Main.tsx | 10 + src/components/middle/ActionMessage.tsx | 63 ++++- src/components/middle/MessageList.scss | 11 + .../middle/message/CommentButton.scss | 4 +- .../middle/message/Giveaway.module.scss | 64 +++++ src/components/middle/message/Giveaway.tsx | 258 ++++++++++++++++++ src/components/middle/message/Message.tsx | 6 +- src/components/middle/message/Poll.tsx | 2 +- .../middle/message/_message-content.scss | 3 +- .../message/helpers/buildContentClassName.ts | 4 +- src/components/modals/boost/BoostModal.tsx | 2 +- .../modals/giftcode/GiftCodeModal.async.tsx | 18 ++ .../modals/giftcode/GiftCodeModal.module.scss | 38 +++ .../modals/giftcode/GiftCodeModal.tsx | 160 +++++++++++ .../right/management/ManageInvites.tsx | 7 +- .../right/statistics/BoostStatistics.tsx | 4 +- src/components/ui/Button.scss | 14 + src/components/ui/Button.tsx | 2 +- src/config.ts | 2 +- src/global/actions/api/chats.ts | 7 + src/global/actions/api/payments.ts | 216 ++++++++++++++- src/global/actions/api/stories.ts | 177 +----------- src/global/actions/ui/misc.ts | 10 +- src/global/actions/ui/payments.ts | 8 + src/global/helpers/messageSummary.ts | 5 + src/global/helpers/messages.ts | 4 +- src/global/selectors/messages.ts | 2 +- src/global/selectors/symbols.ts | 16 ++ src/global/types.ts | 16 +- src/lib/gramjs/tl/apiTl.js | 3 + src/lib/gramjs/tl/static/api.json | 5 +- src/util/deeplink.ts | 9 +- src/util/emoji.ts | 5 + 50 files changed, 1368 insertions(+), 388 deletions(-) rename src/components/common/{InviteLink.module.scss => LinkField.module.scss} (94%) rename src/components/common/{InviteLink.tsx => LinkField.tsx} (58%) create mode 100644 src/components/middle/message/Giveaway.module.scss create mode 100644 src/components/middle/message/Giveaway.tsx create mode 100644 src/components/modals/giftcode/GiftCodeModal.async.tsx create mode 100644 src/components/modals/giftcode/GiftCodeModal.module.scss create mode 100644 src/components/modals/giftcode/GiftCodeModal.tsx diff --git a/src/api/gramjs/apiBuilders/messageContent.ts b/src/api/gramjs/apiBuilders/messageContent.ts index 74c96a4e6..2e43ca914 100644 --- a/src/api/gramjs/apiBuilders/messageContent.ts +++ b/src/api/gramjs/apiBuilders/messageContent.ts @@ -6,6 +6,7 @@ import type { ApiDocument, ApiFormattedText, ApiGame, + ApiGiveaway, ApiInvoice, ApiLocation, ApiMessageExtendedMediaPreview, @@ -49,7 +50,7 @@ export function buildMessageContent( const hasUnsupportedMedia = mtpMessage.media instanceof GramJs.MessageMediaUnsupported; if (mtpMessage.message && !hasUnsupportedMedia - && !content.sticker && !content.poll && !content.contact && !(content.video?.isRound)) { + && !content.sticker && !content.poll && !content.contact && !content.video?.isRound) { content = { ...content, text: buildMessageTextContent(mtpMessage.message, mtpMessage.entities), @@ -118,6 +119,9 @@ export function buildMessageMediaContent(media: GramJs.TypeMessageMedia): MediaC const storyData = buildMessageStoryData(media); if (storyData) return { storyData }; + const giveaway = buildGiweawayFromMedia(media); + if (giveaway) return { giveaway }; + return undefined; } @@ -466,6 +470,31 @@ function buildGame(media: GramJs.MessageMediaGame): ApiGame | undefined { }; } +function buildGiweawayFromMedia(media: GramJs.TypeMessageMedia): ApiGiveaway | undefined { + if (!(media instanceof GramJs.MessageMediaGiveaway)) { + return undefined; + } + + return buildGiveaway(media); +} + +function buildGiveaway(media: GramJs.MessageMediaGiveaway): ApiGiveaway | undefined { + const { + channels, months, quantity, untilDate, countriesIso2, onlyNewSubscribers, + } = media; + + const channelIds = channels.map((channel) => buildApiPeerId(channel, 'channel')); + + return { + channelIds, + months, + quantity, + untilDate, + countries: countriesIso2, + isOnlyForNewSubscribers: onlyNewSubscribers, + }; +} + export function buildMessageStoryData(media: GramJs.TypeMessageMedia): ApiMessageStoryData | undefined { if (!(media instanceof GramJs.MessageMediaStory)) { return undefined; diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index 74e6e6794..f7598719c 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -336,6 +336,8 @@ function buildAction( let months: number | undefined; let topicEmojiIconId: string | undefined; let isTopicAction: boolean | undefined; + let slug: string | undefined; + let isGiveaway: boolean | undefined; const targetUserIds = 'users' in action ? action.users && action.users.map((id) => buildApiPeerId(id, 'user')) @@ -523,6 +525,17 @@ function buildAction( translationValues.push('%target_user%'); if (targetPeerId) targetUserIds.push(targetPeerId); + } else if (action instanceof GramJs.MessageActionGiveawayLaunch) { + text = 'BoostingGiveawayJustStarted'; + translationValues.push('%action_origin%'); + } else if (action instanceof GramJs.MessageActionGiftCode) { + text = 'BoostingReceivedGiftNoName'; + slug = action.slug; + months = action.months; + isGiveaway = Boolean(action.viaGiveaway); + if (action.boostPeer) { + targetChatId = getApiChatIdFromMtpPeer(action.boostPeer); + } } else { text = 'ChatList.UnsupportedMessage'; } @@ -541,6 +554,8 @@ function buildAction( amount, currency, giftCryptoInfo, + isGiveaway, + slug, translationValues, call, phoneCall, diff --git a/src/api/gramjs/apiBuilders/payments.ts b/src/api/gramjs/apiBuilders/payments.ts index 070c536b7..e6d8956b2 100644 --- a/src/api/gramjs/apiBuilders/payments.ts +++ b/src/api/gramjs/apiBuilders/payments.ts @@ -1,6 +1,10 @@ -import type { Api as GramJs } from '../../../lib/gramjs'; +import { Api as GramJs } from '../../../lib/gramjs'; + import type { - ApiInvoice, ApiLabeledPrice, ApiPaymentCredentials, + ApiBoostsStatus, + ApiCheckedGiftCode, + ApiGiveawayInfo, + ApiInvoice, ApiLabeledPrice, ApiMyBoost, ApiPaymentCredentials, ApiPaymentForm, ApiPaymentSavedInfo, ApiPremiumPromo, ApiPremiumSubscriptionOption, ApiReceipt, } from '../../types'; @@ -8,6 +12,8 @@ import type { import { buildApiMessageEntity } from './common'; import { omitVirtualClassFields } from './helpers'; import { buildApiDocument, buildApiWebDocument } from './messageContent'; +import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers'; +import { buildStatisticsPercentage } from './statistics'; export function buildShippingOptions(shippingOptions: GramJs.ShippingOption[] | undefined) { if (!shippingOptions) { @@ -197,3 +203,92 @@ function buildApiPremiumSubscriptionOption(option: GramJs.PremiumSubscriptionOpt export function buildApiPaymentCredentials(credentials: GramJs.PaymentSavedCredentialsCard[]): ApiPaymentCredentials[] { return credentials.map(({ id, title }) => ({ id, title })); } + +export function buildApiBoostsStatus(boostStatus: GramJs.premium.BoostsStatus): ApiBoostsStatus { + const { + level, boostUrl, boosts, myBoost, currentLevelBoosts, nextLevelBoosts, premiumAudience, + } = boostStatus; + return { + level, + currentLevelBoosts, + boosts, + hasMyBoost: Boolean(myBoost), + boostUrl, + nextLevelBoosts, + ...(premiumAudience && { premiumSubscribers: buildStatisticsPercentage(premiumAudience) }), + }; +} + +export function buildApiMyBoost(myBoost: GramJs.MyBoost): ApiMyBoost { + const { + date, expires, slot, cooldownUntilDate, peer, + } = myBoost; + + return { + date, + expires, + slot, + cooldownUntil: cooldownUntilDate, + chatId: peer && getApiChatIdFromMtpPeer(peer), + }; +} + +export function buildApiGiveawayInfo(info: GramJs.payments.TypeGiveawayInfo): ApiGiveawayInfo | undefined { + if (info instanceof GramJs.payments.GiveawayInfo) { + const { + startDate, + adminDisallowedChatId, + disallowedCountry, + joinedTooEarlyDate, + participating, + preparingResults, + } = info; + + return { + type: 'active', + startDate, + isParticipating: participating, + adminDisallowedChatId: adminDisallowedChatId?.toString(), + disallowedCountry, + joinedTooEarlyDate, + isPreparingResults: preparingResults, + }; + } else { + const { + activatedCount, + finishDate, + giftCodeSlug, + winner, + refunded, + startDate, + winnersCount, + } = info; + + return { + type: 'results', + startDate, + activatedCount, + finishDate, + winnersCount, + giftCodeSlug, + isRefunded: refunded, + isWinner: winner, + }; + } +} + +export function buildApiCheckedGiftCode(giftcode: GramJs.payments.TypeCheckedGiftCode): ApiCheckedGiftCode { + const { + date, fromId, months, giveawayMsgId, toId, usedDate, viaGiveaway, + } = giftcode; + + return { + date, + months, + toId: toId && buildApiPeerId(toId, 'user'), + fromId: fromId && getApiChatIdFromMtpPeer(fromId), + usedAt: usedDate, + isFromGiveaway: viaGiveaway, + giveawayMessageId: giveawayMsgId, + }; +} diff --git a/src/api/gramjs/apiBuilders/stories.ts b/src/api/gramjs/apiBuilders/stories.ts index 6086b2580..c63b1f443 100644 --- a/src/api/gramjs/apiBuilders/stories.ts +++ b/src/api/gramjs/apiBuilders/stories.ts @@ -1,10 +1,8 @@ import { Api as GramJs } from '../../../lib/gramjs'; import type { - ApiBoostsStatus, ApiMediaArea, ApiMediaAreaCoordinates, - ApiMyBoost, ApiStealthMode, ApiStoryView, ApiTypeStory, @@ -16,7 +14,6 @@ import { buildPrivacyRules } from './common'; import { buildGeoPoint, buildMessageMediaContent, buildMessageTextContent } from './messageContent'; import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers'; import { buildApiReaction, buildReactionCount } from './reactions'; -import { buildStatisticsPercentage } from './statistics'; export function buildApiStory(peerId: string, story: GramJs.TypeStoryItem): ApiTypeStory { if (story instanceof GramJs.StoryItemDeleted) { @@ -169,32 +166,3 @@ export function buildApiPeerStories(peerStories: GramJs.PeerStories) { return buildCollectionByCallback(peerStories.stories, (story) => [story.id, buildApiStory(peerId, story)]); } - -export function buildApiBoostsStatus(boostStatus: GramJs.premium.BoostsStatus): ApiBoostsStatus { - const { - level, boostUrl, boosts, myBoost, currentLevelBoosts, nextLevelBoosts, premiumAudience, - } = boostStatus; - return { - level, - currentLevelBoosts, - boosts, - hasMyBoost: Boolean(myBoost), - boostUrl, - nextLevelBoosts, - ...(premiumAudience && { premiumSubscribers: buildStatisticsPercentage(premiumAudience) }), - }; -} - -export function buildApiMyBoost(myBoost: GramJs.MyBoost): ApiMyBoost { - const { - date, expires, slot, cooldownUntilDate, peer, - } = myBoost; - - return { - date, - expires, - slot, - cooldownUntil: cooldownUntilDate, - chatId: peer && getApiChatIdFromMtpPeer(peer), - }; -} diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 6ef5e32f8..03cc126f4 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -80,10 +80,6 @@ export { allowBotSendMessages, fetchBotCanSendMessage, invokeWebViewCustomMethod, } from './bots'; -export { - validateRequestedInfo, sendPaymentForm, getPaymentForm, getReceipt, fetchPremiumPromo, fetchTemporaryPaymentPassword, -} from './payments'; - export { getGroupCall, joinGroupCall, discardGroupCall, createGroupCall, editGroupCallTitle, editGroupCallParticipant, exportGroupCallInvite, fetchGroupCallParticipants, @@ -111,3 +107,8 @@ export { } from '../localDb'; export * from './stories'; + +export { + validateRequestedInfo, sendPaymentForm, getPaymentForm, getReceipt, fetchPremiumPromo, fetchTemporaryPaymentPassword, + applyBoost, fetchBoostsList, fetchBoostsStatus, fetchGiveawayInfo, fetchMyBoosts, applyGiftCode, checkGiftCode, +} from './payments'; diff --git a/src/api/gramjs/methods/payments.ts b/src/api/gramjs/methods/payments.ts index accd905ab..da87e75a9 100644 --- a/src/api/gramjs/methods/payments.ts +++ b/src/api/gramjs/methods/payments.ts @@ -2,12 +2,18 @@ import BigInt from 'big-integer'; import { Api as GramJs } from '../../../lib/gramjs'; import type { - ApiChat, ApiRequestInputInvoice, + ApiChat, ApiPeer, ApiRequestInputInvoice, OnApiUpdate, } from '../../types'; +import { buildCollectionByCallback } from '../../../util/iteratees'; +import { buildApiChatFromPreview } from '../apiBuilders/chats'; import { + buildApiBoostsStatus, + buildApiCheckedGiftCode, + buildApiGiveawayInfo, buildApiInvoiceFromForm, + buildApiMyBoost, buildApiPaymentForm, buildApiPremiumPromo, buildApiReceipt, @@ -186,3 +192,145 @@ export async function fetchTemporaryPaymentPassword(password: string) { validUntil: result.validUntil, }; } + +export async function fetchMyBoosts() { + const result = await invokeRequest(new GramJs.premium.GetMyBoosts()); + + if (!result) return undefined; + + addEntitiesToLocalDb(result.users); + addEntitiesToLocalDb(result.chats); + + const users = result.users.map(buildApiUser).filter(Boolean); + const chats = result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean); + const boosts = result.myBoosts.map(buildApiMyBoost); + + return { + users, + chats, + boosts, + }; +} + +export function applyBoost({ + chat, + slots, +} : { + chat: ApiChat; + slots: number[]; +}) { + return invokeRequest(new GramJs.premium.ApplyBoost({ + peer: buildInputPeer(chat.id, chat.accessHash), + slots, + }), { + shouldReturnTrue: true, + }); +} + +export async function fetchBoostsStatus({ + chat, +}: { + chat: ApiChat; +}) { + const result = await invokeRequest(new GramJs.premium.GetBoostsStatus({ + peer: buildInputPeer(chat.id, chat.accessHash), + })); + + if (!result) { + return undefined; + } + + return buildApiBoostsStatus(result); +} + +export async function fetchBoostsList({ + chat, + offset = '', + limit, +}: { + chat: ApiChat; + offset?: string; + limit?: number; +}) { + const result = await invokeRequest(new GramJs.premium.GetBoostsList({ + peer: buildInputPeer(chat.id, chat.accessHash), + offset, + limit, + })); + + if (!result) { + return undefined; + } + + addEntitiesToLocalDb(result.users); + + const users = result.users.map(buildApiUser).filter(Boolean); + + const userBoosts = result.boosts.filter((boost) => boost.userId); + const boosterIds = userBoosts.map((boost) => boost.userId!.toString()); + const boosters = buildCollectionByCallback(userBoosts, (boost) => ( + [boost.userId!.toString(), boost.expires] + )); + + return { + count: result.count, + users, + boosters, + boosterIds, + nextOffset: result.nextOffset, + }; +} + +export async function fetchGiveawayInfo({ + peer, + messageId, +}: { + peer: ApiPeer; + messageId: number; +}) { + const result = await invokeRequest(new GramJs.payments.GetGiveawayInfo({ + peer: buildInputPeer(peer.id, peer.accessHash), + msgId: messageId, + })); + + if (!result) { + return undefined; + } + + return buildApiGiveawayInfo(result); +} + +export async function checkGiftCode({ + slug, +}: { + slug: string; +}) { + const result = await invokeRequest(new GramJs.payments.CheckGiftCode({ + slug, + })); + + if (!result) { + return undefined; + } + + addEntitiesToLocalDb(result.users); + addEntitiesToLocalDb(result.chats); + + return { + code: buildApiCheckedGiftCode(result), + users: result.users.map(buildApiUser).filter(Boolean), + chats: result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean), + }; +} + +export function applyGiftCode({ + slug, +}: { + slug: string; +}) { + return invokeRequest(new GramJs.payments.ApplyGiftCode({ + slug, + }), { + shouldReturnTrue: true, + }); +} diff --git a/src/api/gramjs/methods/stories.ts b/src/api/gramjs/methods/stories.ts index 5a24a229d..89f7e6023 100644 --- a/src/api/gramjs/methods/stories.ts +++ b/src/api/gramjs/methods/stories.ts @@ -17,8 +17,6 @@ import { buildCollectionByCallback } from '../../../util/iteratees'; import { buildApiChatFromPreview } from '../apiBuilders/chats'; import { getApiChatIdFromMtpPeer } from '../apiBuilders/peers'; import { - buildApiBoostsStatus, - buildApiMyBoost, buildApiPeerStories, buildApiStealthMode, buildApiStory, @@ -428,91 +426,3 @@ export function activateStealthMode({ shouldReturnTrue: true, }); } - -export async function fetchMyBoosts() { - const result = await invokeRequest(new GramJs.premium.GetMyBoosts()); - - if (!result) return undefined; - - addEntitiesToLocalDb(result.users); - addEntitiesToLocalDb(result.chats); - - const users = result.users.map(buildApiUser).filter(Boolean); - const chats = result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean); - const boosts = result.myBoosts.map(buildApiMyBoost); - - return { - users, - chats, - boosts, - }; -} - -export function applyBoost({ - chat, - slots, -} : { - chat: ApiChat; - slots: number[]; -}) { - return invokeRequest(new GramJs.premium.ApplyBoost({ - peer: buildInputPeer(chat.id, chat.accessHash), - slots, - }), { - shouldReturnTrue: true, - }); -} - -export async function fetchBoostsStatus({ - chat, -}: { - chat: ApiChat; -}) { - const result = await invokeRequest(new GramJs.premium.GetBoostsStatus({ - peer: buildInputPeer(chat.id, chat.accessHash), - })); - - if (!result) { - return undefined; - } - - return buildApiBoostsStatus(result); -} - -export async function fetchBoostersList({ - chat, - offset = '', - limit, -}: { - chat: ApiChat; - offset?: string; - limit?: number; -}) { - const result = await invokeRequest(new GramJs.premium.GetBoostsList({ - peer: buildInputPeer(chat.id, chat.accessHash), - offset, - limit, - })); - - if (!result) { - return undefined; - } - - addEntitiesToLocalDb(result.users); - - const users = result.users.map(buildApiUser).filter(Boolean); - - const userBoosts = result.boosts.filter((boost) => boost.userId); - const boosterIds = userBoosts.map((boost) => boost.userId!.toString()); - const boosters = buildCollectionByCallback(userBoosts, (boost) => ( - [boost.userId!.toString(), boost.expires] - )); - - return { - count: result.count, - users, - boosters, - boosterIds, - nextOffset: result.nextOffset, - }; -} diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 972b436ce..7885c5c0b 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -253,6 +253,15 @@ export type ApiGame = { document?: ApiDocument; }; +export type ApiGiveaway = { + quantity: number; + months: number; + untilDate: number; + isOnlyForNewSubscribers?: true; + countries?: string[]; + channelIds: string[]; +}; + export type ApiNewPoll = { summary: ApiPoll['summary']; quiz?: { @@ -281,6 +290,8 @@ export interface ApiAction { months?: number; topicEmojiIconId?: string; isTopicAction?: boolean; + slug?: string; + isGiveaway?: boolean; } export interface ApiWebPage { @@ -432,6 +443,7 @@ export type MediaContent = { location?: ApiLocation; game?: ApiGame; storyData?: ApiMessageStoryData; + giveaway?: ApiGiveaway; }; export interface ApiMessage { diff --git a/src/api/types/payments.ts b/src/api/types/payments.ts index edc567478..f43b6171e 100644 --- a/src/api/types/payments.ts +++ b/src/api/types/payments.ts @@ -1,6 +1,7 @@ import type { ApiInvoiceContainer } from '../../types'; import type { ApiWebDocument } from './bots'; import type { ApiDocument, ApiMessageEntity, ApiPaymentCredentials } from './messages'; +import type { StatisticsOverviewPercentage } from './statistics'; export interface ApiShippingAddress { streetLine1: string; @@ -77,3 +78,54 @@ export interface ApiPremiumSubscriptionOption { amount: string; botUrl: string; } + +export type ApiBoostsStatus = { + level: number; + currentLevelBoosts: number; + boosts: number; + nextLevelBoosts?: number; + hasMyBoost?: boolean; + boostUrl: string; + premiumSubscribers?: StatisticsOverviewPercentage; +}; + +export type ApiMyBoost = { + slot: number; + chatId?: string; + date: number; + expires: number; + cooldownUntil?: number; +}; + +export type ApiGiveawayInfoActive = { + type: 'active'; + isParticipating?: true; + isPreparingResults?: true; + startDate: number; + joinedTooEarlyDate?: number; + adminDisallowedChatId?: string; + disallowedCountry?: string; +}; + +export type ApiGiveawayInfoResults = { + type: 'results'; + isWinner?: true; + isRefunded?: true; + startDate: number; + finishDate: number; + giftCodeSlug?: string; + winnersCount: number; + activatedCount: number; +}; + +export type ApiGiveawayInfo = ApiGiveawayInfoActive | ApiGiveawayInfoResults; + +export type ApiCheckedGiftCode = { + isFromGiveaway?: true; + fromId?: string; + giveawayMessageId?: number; + toId?: string; + date: number; + months: number; + usedAt?: number; +}; diff --git a/src/api/types/stories.ts b/src/api/types/stories.ts index df76110fd..0355b62f2 100644 --- a/src/api/types/stories.ts +++ b/src/api/types/stories.ts @@ -2,7 +2,6 @@ import type { ApiPrivacySettings } from '../../types'; import type { ApiGeoPoint, ApiReaction, ApiReactionCount, MediaContent, } from './messages'; -import type { StatisticsOverviewPercentage } from './statistics'; export interface ApiStory { '@type'?: 'story'; @@ -109,21 +108,3 @@ export type ApiMediaAreaSuggestedReaction = { }; export type ApiMediaArea = ApiMediaAreaVenue | ApiMediaAreaGeoPoint | ApiMediaAreaSuggestedReaction; - -export type ApiBoostsStatus = { - level: number; - currentLevelBoosts: number; - boosts: number; - nextLevelBoosts?: number; - hasMyBoost?: boolean; - boostUrl: string; - premiumSubscribers?: StatisticsOverviewPercentage; -}; - -export type ApiMyBoost = { - slot: number; - chatId?: string; - date: number; - expires: number; - cooldownUntil?: number; -}; diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 61a0dd27a..27de33ca7 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -20,7 +20,7 @@ export { default as GiftPremiumModal } from '../components/main/premium/GiftPrem export { default as PremiumLimitReachedModal } from '../components/main/premium/common/PremiumLimitReachedModal'; export { default as StatusPickerMenu } from '../components/left/main/StatusPickerMenu'; export { default as BoostModal } from '../components/modals/boost/BoostModal'; - +export { default as GiftCodeModal } from '../components/modals/giftcode/GiftCodeModal'; export { default as ChatlistModal } from '../components/modals/chatlist/ChatlistModal'; export { default as AboutAdsModal } from '../components/common/AboutAdsModal'; diff --git a/src/components/common/InviteLink.module.scss b/src/components/common/LinkField.module.scss similarity index 94% rename from src/components/common/InviteLink.module.scss rename to src/components/common/LinkField.module.scss index 595f76b5b..03c816b69 100644 --- a/src/components/common/InviteLink.module.scss +++ b/src/components/common/LinkField.module.scss @@ -9,7 +9,7 @@ text-overflow: ellipsis; } -.moreMenu { +.moreMenu, .copy { position: absolute; right: 0.5rem; top: 50%; diff --git a/src/components/common/InviteLink.tsx b/src/components/common/LinkField.tsx similarity index 58% rename from src/components/common/InviteLink.tsx rename to src/components/common/LinkField.tsx index 9dda31f87..7b3c51af6 100644 --- a/src/components/common/InviteLink.tsx +++ b/src/components/common/LinkField.tsx @@ -12,22 +12,25 @@ import useLastCallback from '../../hooks/useLastCallback'; import Button from '../ui/Button'; import DropdownMenu from '../ui/DropdownMenu'; import MenuItem from '../ui/MenuItem'; +import Icon from './Icon'; -import styles from './InviteLink.module.scss'; +import styles from './LinkField.module.scss'; type OwnProps = { title?: string; - inviteLink: string; + link: string; isDisabled?: boolean; className?: string; + withShare?: boolean; onRevoke?: VoidFunction; }; const InviteLink: FC = ({ title, - inviteLink, + link, isDisabled, className, + withShare, onRevoke, }) => { const lang = useLang(); @@ -35,20 +38,22 @@ const InviteLink: FC = ({ const { isMobile } = useAppLayout(); - const copyLink = useLastCallback((link: string) => { + const isOnlyCopy = !onRevoke; + + const copyLink = useLastCallback(() => { copyTextToClipboard(link); showNotification({ message: lang('LinkCopied'), }); }); - const handleCopyPrimaryClicked = useLastCallback(() => { + const handleCopyClick = useLastCallback(() => { if (isDisabled) return; - copyLink(inviteLink); + copyLink(); }); const handleShare = useLastCallback(() => { - openChatWithDraft({ text: inviteLink }); + openChatWithDraft({ text: link }); }); const PrimaryLinkMenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => { @@ -75,28 +80,43 @@ const InviteLink: FC = ({
- - {lang('Copy')} - {onRevoke && ( - {lang('RevokeButton')} - )} - + {isOnlyCopy ? ( + + ) : ( + + {lang('Copy')} + {onRevoke && ( + {lang('RevokeButton')} + )} + + )}
- + {withShare && ( + + )} ); }; diff --git a/src/components/common/PickerSelectedItem.scss b/src/components/common/PickerSelectedItem.scss index 5deb3bebe..f0af3e528 100644 --- a/src/components/common/PickerSelectedItem.scss +++ b/src/components/common/PickerSelectedItem.scss @@ -51,6 +51,10 @@ } } + &.fluid { + max-width: unset; + } + .SearchInput & { flex: 1 0 auto; position: relative; diff --git a/src/components/common/PickerSelectedItem.tsx b/src/components/common/PickerSelectedItem.tsx index d58d82433..c0343b1aa 100644 --- a/src/components/common/PickerSelectedItem.tsx +++ b/src/components/common/PickerSelectedItem.tsx @@ -25,6 +25,7 @@ type OwnProps = { forceShowSelf?: boolean; clickArg?: any; className?: string; + fluid?: boolean; onClick: (arg: any) => void; }; @@ -43,6 +44,7 @@ const PickerSelectedItem: FC = ({ chat, user, className, + fluid, isSavedMessages, onClick, }) => { @@ -81,6 +83,7 @@ const PickerSelectedItem: FC = ({ chat?.isForum && 'forum-avatar', isMinimized && 'minimized', canClose && 'closeable', + fluid && 'fluid', ); return ( diff --git a/src/components/left/settings/SettingsExperimental.tsx b/src/components/left/settings/SettingsExperimental.tsx index c4ee07962..3a46ffcc6 100644 --- a/src/components/left/settings/SettingsExperimental.tsx +++ b/src/components/left/settings/SettingsExperimental.tsx @@ -78,7 +78,7 @@ const SettingsExperimental: FC = ({
requestConfetti()} + onClick={() => requestConfetti({})} icon="animations" >
Launch some confetti!
diff --git a/src/components/left/settings/folders/SettingsShareChatlist.tsx b/src/components/left/settings/folders/SettingsShareChatlist.tsx index 574202d60..c544ee6e3 100644 --- a/src/components/left/settings/folders/SettingsShareChatlist.tsx +++ b/src/components/left/settings/folders/SettingsShareChatlist.tsx @@ -21,7 +21,7 @@ import useLang from '../../../../hooks/useLang'; import useLastCallback from '../../../../hooks/useLastCallback'; import AnimatedIcon from '../../../common/AnimatedIcon'; -import InviteLink from '../../../common/InviteLink'; +import LinkField from '../../../common/LinkField'; import Picker from '../../../common/Picker'; import FloatingActionButton from '../../../ui/FloatingActionButton'; import Spinner from '../../../ui/Spinner'; @@ -159,9 +159,10 @@ const SettingsShareChatlist: FC = ({

- diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index 3858251b5..1089c1b9c 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -78,6 +78,7 @@ import MiddleColumn from '../middle/MiddleColumn'; import AttachBotInstallModal from '../modals/attachBotInstall/AttachBotInstallModal.async'; import BoostModal from '../modals/boost/BoostModal.async'; import ChatlistModal from '../modals/chatlist/ChatlistModal.async'; +import GiftCodeModal from '../modals/giftcode/GiftCodeModal.async'; import MapModal from '../modals/map/MapModal.async'; import UrlAuthModal from '../modals/urlAuth/UrlAuthModal.async'; import WebAppModal from '../modals/webApp/WebAppModal.async'; @@ -110,6 +111,7 @@ export interface OwnProps { type StateProps = { isMasterTab?: boolean; chat?: ApiChat; + currentUserId?: string; isLeftColumnOpen: boolean; isMiddleColumnOpen: boolean; isRightColumnOpen: boolean; @@ -155,6 +157,7 @@ type StateProps = { isCurrentUserPremium?: boolean; chatlistModal?: TabState['chatlistModal']; boostModal?: TabState['boostModal']; + giftCodeModal?: TabState['giftCodeModal']; noRightColumnAnimation?: boolean; withInterfaceAnimations?: boolean; isSynced?: boolean; @@ -174,6 +177,7 @@ const Main: FC = ({ isMediaViewerOpen, isStoryViewerOpen, isForwardModalOpen, + currentUserId, hasNotifications, hasDialogs, audioMessage, @@ -214,6 +218,7 @@ const Main: FC = ({ deleteFolderDialog, isMasterTab, chatlistModal, + giftCodeModal, boostModal, noRightColumnAnimation, isSynced, @@ -558,6 +563,7 @@ const Main: FC = ({ isByPhoneNumber={newContactByPhoneNumber} /> + @@ -592,6 +598,7 @@ export default memo(withGlobal( language, wasTimeFormatSetManually, }, }, + currentUserId, } = global; const { @@ -621,6 +628,7 @@ export default memo(withGlobal( deleteFolderDialogModal, chatlistModal, boostModal, + giftCodeModal, } = selectTabState(global); const { chatId: audioChatId, messageId: audioMessageId } = audioPlayer; @@ -637,6 +645,7 @@ export default memo(withGlobal( const deleteFolderDialog = deleteFolderDialogModal ? selectChatFolder(global, deleteFolderDialogModal) : undefined; return { + currentUserId, isLeftColumnOpen: isLeftColumnShown, isMiddleColumnOpen: Boolean(chatId), isRightColumnOpen: selectIsRightColumnShown(global, isMobile), @@ -684,6 +693,7 @@ export default memo(withGlobal( requestedDraft, chatlistModal, boostModal, + giftCodeModal, noRightColumnAnimation, isSynced: global.isSynced, }; diff --git a/src/components/middle/ActionMessage.tsx b/src/components/middle/ActionMessage.tsx index 58450c6bd..8e10cb684 100644 --- a/src/components/middle/ActionMessage.tsx +++ b/src/components/middle/ActionMessage.tsx @@ -12,12 +12,13 @@ import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import type { FocusDirection } from '../../types'; import type { PinnedIntersectionChangedCallback } from './hooks/usePinnedMessage'; -import { getMessageHtmlId, isChatChannel } from '../../global/helpers'; +import { getChatTitle, getMessageHtmlId, isChatChannel } from '../../global/helpers'; import { getMessageReplyInfo } from '../../global/helpers/replies'; import { selectCanPlayAnimatedEmojis, selectChat, selectChatMessage, + selectGiftStickerForDuration, selectIsMessageFocused, selectTabState, selectTopicFromMessage, @@ -25,6 +26,7 @@ import { } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; import { renderActionMessageText } from '../common/helpers/renderActionMessageText'; +import renderText from '../common/helpers/renderText'; import { preventMessageInputBlur } from './helpers/preventMessageInputBlur'; import useContextMenuHandlers from '../../hooks/useContextMenuHandlers'; @@ -61,6 +63,7 @@ type StateProps = { targetUserIds?: string[]; targetMessage?: ApiMessage; targetChatId?: string; + targetChat?: ApiChat; isFocused: boolean; topic?: ApiTopic; focusDirection?: FocusDirection; @@ -82,6 +85,7 @@ const ActionMessage: FC = ({ targetUserIds, targetMessage, targetChatId, + targetChat, isFocused, focusDirection, noFocusHighlight, @@ -95,7 +99,7 @@ const ActionMessage: FC = ({ observeIntersectionForPlaying, onPinnedIntersectionChange, }) => { - const { openPremiumModal, requestConfetti } = getActions(); + const { openPremiumModal, requestConfetti, checkGiftCode } = getActions(); const lang = useLang(); @@ -121,6 +125,7 @@ const ActionMessage: FC = ({ const noAppearanceAnimation = appearanceOrder <= 0; const [isShown, markShown] = useFlag(noAppearanceAnimation); const isGift = Boolean(message.content.action?.text.startsWith('ActionGift')); + const isGiftCode = Boolean(message.content.action?.text.startsWith('BoostingReceivedGift')); const isSuggestedAvatar = message.content.action?.type === 'suggestProfilePhoto' && message.content.action!.photo; useEffect(() => { @@ -141,7 +146,7 @@ const ActionMessage: FC = ({ useEffect(() => { if (isVisible && shouldShowConfettiRef.current) { shouldShowConfettiRef.current = false; - requestConfetti(); + requestConfetti({}); } }, [isVisible, requestConfetti]); @@ -195,6 +200,12 @@ const ActionMessage: FC = ({ }); }; + const handleGiftCodeClick = () => { + const slug = message.content.action?.slug; + if (!slug) return; + checkGiftCode({ slug }); + }; + // TODO Refactoring for action rendering const shouldSkipRender = isInsideTopic && message.content.action?.text === 'TopicWasCreatedAction'; if (shouldSkipRender) { @@ -223,13 +234,47 @@ const ActionMessage: FC = ({ ); } + function renderGiftCode() { + const isFromGiveaway = message.content.action?.isGiveaway; + return ( + + + {lang('BoostingUnclaimedPrize')} + + {renderText(lang(isFromGiveaway ? 'BoostingReceivedGiftFrom' : 'BoostingYouHaveUnclaimedPrize', + getChatTitle(lang, targetChat!)), + ['simple_markdown'])} + + + {renderText(lang( + 'BoostingUnclaimedPrizeDuration', + lang('Months', message.content.action?.months, 'i'), + ), ['simple_markdown'])} + + + {lang('BoostingReceivedGiftOpenBtn')} + + ); + } + const className = buildClassName( 'ActionMessage message-list-item', isFocused && !noFocusHighlight && 'focused', (isGift || isSuggestedAvatar) && 'centered-action', isContextMenuShown && 'has-menu-open', isLastInList && 'last-in-list', - !isGift && !isSuggestedAvatar && 'in-one-row', + !isGift && !isSuggestedAvatar && !isGiftCode && 'in-one-row', transitionClassNames, ); @@ -243,8 +288,9 @@ const ActionMessage: FC = ({ onMouseDown={handleMouseDown} onContextMenu={handleContextMenu} > - {!isSuggestedAvatar && {renderContent()}} + {!isSuggestedAvatar && !isGiftCode && {renderContent()}} {isGift && renderGift()} + {isGiftCode && renderGiftCode()} {isSuggestedAvatar && ( ( const isChat = chat && (isChatChannel(chat) || userId === chatId); const senderUser = !isChat && userId ? selectUser(global, userId) : undefined; const senderChat = isChat ? chat : undefined; - const premiumGiftSticker = global.premiumGifts?.stickers?.[0]; + + const targetChat = targetChatId ? selectChat(global, targetChatId) : undefined; + + const giftDuration = content.action?.months; + const premiumGiftSticker = selectGiftStickerForDuration(global, giftDuration); const topic = selectTopicFromMessage(global, message); return { senderUser, senderChat, + targetChat, targetChatId, targetUserIds, targetMessage, diff --git a/src/components/middle/MessageList.scss b/src/components/middle/MessageList.scss index 6aeebfaca..d8c477b61 100644 --- a/src/components/middle/MessageList.scss +++ b/src/components/middle/MessageList.scss @@ -278,6 +278,17 @@ outline: none; } + .action-message-gift-code { + width: 20rem; + margin-inline: auto; + } + + .action-message-subtitle { + margin-top: 1rem; + font-weight: normal; + text-wrap: balance; + } + .action-message-suggested-avatar { max-width: 16rem; display: flex !important; diff --git a/src/components/middle/message/CommentButton.scss b/src/components/middle/message/CommentButton.scss index 985569525..639ca7ba8 100644 --- a/src/components/middle/message/CommentButton.scss +++ b/src/components/middle/message/CommentButton.scss @@ -119,7 +119,8 @@ .audio &, .voice &, .poll &, - .text & { + .text &, + .giveaway & { border-top: 1px solid var(--color-borders); } @@ -136,6 +137,7 @@ .message-content.audio &, .message-content.voice &, .message-content.poll &, + .message-content.giveaway &, .message-content.has-solid-background.text &, .message-content.has-solid-background.is-forwarded & { width: calc(100% + 1rem); diff --git a/src/components/middle/message/Giveaway.module.scss b/src/components/middle/message/Giveaway.module.scss new file mode 100644 index 000000000..14876ba31 --- /dev/null +++ b/src/components/middle/message/Giveaway.module.scss @@ -0,0 +1,64 @@ +.root { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.title { + display: block; +} + +.gift { + position: relative; +} + +.count { + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + + background-color: var(--color-primary); + color: white; + border-radius: 1rem; + padding: 0.0625rem 0.5rem; + border: 1px solid var(--background-color); + line-height: 1.25; + + :global(.theme-dark .own) & { + background-color: var(--color-text); + color: var(--background-color); + } +} + +.section { + margin-bottom: 1rem; +} + +.description { + line-height: 1.25; + margin-bottom: 0; +} + +.channels { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + margin-block: 0.25rem; +} + +.channel { + background-color: var(--accent-background-color); + color: var(--accent-color); + margin: unset; + + &:hover, &:active, &:focus { + background-color: var(--accent-background-active-color); + } +} + +.button { + margin-bottom: 1rem; +} diff --git a/src/components/middle/message/Giveaway.tsx b/src/components/middle/message/Giveaway.tsx new file mode 100644 index 000000000..4f8d872bc --- /dev/null +++ b/src/components/middle/message/Giveaway.tsx @@ -0,0 +1,258 @@ +import React, { + memo, useMemo, useRef, useState, +} from '../../../lib/teact/teact'; +import { getActions, getGlobal, withGlobal } from '../../../global'; + +import type { + ApiChat, ApiGiveawayInfo, ApiMessage, ApiPeer, ApiSticker, +} from '../../../api/types'; + +import { getChatTitle, getUserFullName, isApiPeerChat } from '../../../global/helpers'; +import { + selectCanPlayAnimatedEmojis, + selectChat, + selectForwardedSender, + selectGiftStickerForDuration, +} from '../../../global/selectors'; +import { formatDateAtTime, formatDateTimeToString } from '../../../util/dateFormat'; +import { isoToEmoji } from '../../../util/emoji'; +import { getServerTime } from '../../../util/serverTime'; +import { callApi } from '../../../api/gramjs'; +import renderText from '../../common/helpers/renderText'; + +import useLang from '../../../hooks/useLang'; +import useLastCallback from '../../../hooks/useLastCallback'; + +import AnimatedIconFromSticker from '../../common/AnimatedIconFromSticker'; +import PickerSelectedItem from '../../common/PickerSelectedItem'; +import Button from '../../ui/Button'; +import ConfirmDialog from '../../ui/ConfirmDialog'; + +import styles from './Giveaway.module.scss'; + +type OwnProps = { + message: ApiMessage; +}; + +type StateProps = { + chat: ApiChat; + sender?: ApiPeer; + giftSticker?: ApiSticker; + canPlayAnimatedEmojis?: boolean; +}; + +const NBSP = '\u00A0'; +const GIFT_STICKER_SIZE = 175; + +const Giveaway = ({ + chat, + sender, + message, + canPlayAnimatedEmojis, + giftSticker, +}: OwnProps & StateProps) => { + const { openChat } = getActions(); + + const isLoadingInfo = useRef(false); + const [giveawayInfo, setGiveawayInfo] = useState(); + + const lang = useLang(); + const { + months, quantity, channelIds, untilDate, countries, + } = message.content.giveaway!; + + const hasEnded = getServerTime() > untilDate; + + const countryList = useMemo(() => { + const translatedNames = new Intl.DisplayNames([lang.code!, 'en'].filter(Boolean), { type: 'region' }); + return countries?.map((countryCode) => ( + `${isoToEmoji(countryCode)}${NBSP}${translatedNames.of(countryCode)}` + )).join(', '); + }, [countries, lang.code]); + + const handleChannelClick = useLastCallback((channelId: string) => { + openChat({ id: channelId }); + }); + + const handleShowInfoClick = useLastCallback(async () => { + if (isLoadingInfo.current) return; + + isLoadingInfo.current = true; + const result = await callApi('fetchGiveawayInfo', { + peer: chat, + messageId: message.id, + }); + setGiveawayInfo(result); + isLoadingInfo.current = false; + }); + + const handleCloseInfo = useLastCallback(() => { + setGiveawayInfo(undefined); + }); + + const giveawayInfoTitle = useMemo(() => { + if (!giveawayInfo) return undefined; + return lang(giveawayInfo.type === 'results' ? 'BoostingGiveawayEnd' : 'BoostingGiveAwayAbout'); + }, [giveawayInfo, lang]); + + function renderGiveawayInfo() { + if (!sender || !giveawayInfo) return undefined; + const isResults = giveawayInfo.type === 'results'; + + const chatTitle = isApiPeerChat(sender) ? getChatTitle(lang, sender) : getUserFullName(sender); + const duration = lang('Chat.Giveaway.Info.Months', months); + const endDate = formatDateAtTime(lang, untilDate * 1000); + const otherChannelsCount = channelIds.length ? channelIds.length - 1 : 0; + const otherChannelsString = lang('Chat.Giveaway.Info.OtherChannels', otherChannelsCount); + const isSeveral = otherChannelsCount > 0; + + const firstKey = isResults ? 'BoostingGiveawayHowItWorksTextEnd' : 'BoostingGiveawayHowItWorksText'; + const firstParagraph = lang(firstKey, [chatTitle, quantity, duration], undefined, quantity); + + let secondKey = ''; + if (isResults) { + secondKey = isSeveral ? 'BoostingGiveawayHowItWorksSubTextSeveralEnd' : 'BoostingGiveawayHowItWorksSubTextEnd'; + } else { + secondKey = isSeveral ? 'BoostingGiveawayHowItWorksSubTextSeveral' : 'BoostingGiveawayHowItWorksSubText'; + } + let secondParagraph = lang(secondKey, [endDate, quantity, chatTitle, otherChannelsCount], undefined, quantity); + if (isResults && giveawayInfo.activatedCount) { + secondParagraph += ` ${lang('BoostingGiveawayUsedLinksPlural', giveawayInfo.activatedCount)}`; + } + + let lastParagraph = ''; + if (isResults && giveawayInfo.isRefunded) { + lastParagraph = lang('BoostingGiveawayCanceledByPayment'); + } else if (isResults) { + lastParagraph = lang(giveawayInfo.isWinner ? 'BoostingGiveawayYouWon' : 'BoostingGiveawayYouNotWon'); + } else if (giveawayInfo.disallowedCountry) { + lastParagraph = lang('BoostingGiveawayNotEligibleCountry'); + } else if (giveawayInfo.adminDisallowedChatId) { + // Since rerenders are not expected, we can use the global state directly + const chatsById = getGlobal().chats.byId; + const disallowedChat = chatsById[giveawayInfo.adminDisallowedChatId]; + const disallowedChatTitle = disallowedChat && getChatTitle(lang, disallowedChat); + lastParagraph = lang('BoostingGiveawayNotEligibleAdmin', disallowedChatTitle); + } else if (giveawayInfo.joinedTooEarlyDate) { + const joinedTooEarlyDate = formatDateAtTime(lang, giveawayInfo.joinedTooEarlyDate * 1000); + lastParagraph = lang('BoostingGiveawayNotEligible', joinedTooEarlyDate); + } else if (giveawayInfo.isParticipating) { + lastParagraph = isSeveral + ? lang('Chat.Giveaway.Info.ParticipatingMany', [chatTitle, otherChannelsCount]) + : lang('Chat.Giveaway.Info.Participating', chatTitle); + } else { + lastParagraph = isSeveral + ? lang('Chat.Giveaway.Info.NotQualifiedMany', [chatTitle, otherChannelsString, endDate]) + : lang('Chat.Giveaway.Info.NotQualified', [chatTitle, endDate]); + } + + return ( + <> +

+ {renderText(firstParagraph, ['simple_markdown'])} +

+

+ {renderText(secondParagraph, ['simple_markdown'])} +

+

+ {renderText(lastParagraph, ['simple_markdown'])} +

+ + ); + } + + return ( +
+
+ + + {`x${quantity}`} + +
+
+ + {renderText(lang('BoostingGiveawayPrizes'), ['simple_markdown'])} + +

+ {renderText(lang('Chat.Giveaway.Info.Subscriptions', quantity), ['simple_markdown'])} +
+ {renderText(lang( + 'ActionGiftPremiumSubtitle', + lang('Chat.Giveaway.Info.Months', months), + ), ['simple_markdown'])} +

+
+
+ + {renderText(lang('BoostingGiveawayMsgParticipants'), ['simple_markdown'])} + +

+ {renderText(lang('BoostingGiveawayMsgAllSubsPlural', channelIds.length), ['simple_markdown'])} +

+
+ {channelIds.map((channelId) => ( + + ))} +
+ {countries?.length && ( + {renderText(lang('Chat.Giveaway.Message.CountriesFrom', countryList))} + )} +
+
+ + {renderText(lang('BoostingWinnersDate'), ['simple_markdown'])} + +

+ {formatDateTimeToString(untilDate * 1000, lang.code, true)} +

+
+ + + {renderGiveawayInfo()} + +
+ ); +}; + +export default memo(withGlobal( + (global, { message }): StateProps => { + const duration = message.content.giveaway!.months; + const chat = selectChat(global, message.chatId)!; + const sender = selectChat(global, message.content.giveaway?.channelIds[0]!) + || selectForwardedSender(global, message) || chat; + + return { + chat, + sender, + giftSticker: selectGiftStickerForDuration(global, duration), + canPlayAnimatedEmojis: selectCanPlayAnimatedEmojis(global), + }; + }, +)(Giveaway)); diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index 43a4ca25d..2b867a2bb 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -152,6 +152,7 @@ import CommentButton from './CommentButton'; import Contact from './Contact'; import ContextMenuContainer from './ContextMenuContainer.async'; import Game from './Game'; +import Giveaway from './Giveaway'; import InlineButtons from './InlineButtons'; import Invoice from './Invoice'; import InvoiceMediaPreview from './InvoiceMediaPreview'; @@ -630,7 +631,7 @@ const Message: FC = ({ text, photo, video, audio, voice, document, sticker, contact, poll, webPage, invoice, location, - action, game, storyData, + action, game, storyData, giveaway, } = getMessageContent(message); const { replyToMsgId, replyToPeerId, isQuote } = messageReplyInfo || {}; @@ -1137,6 +1138,9 @@ const Message: FC = ({ {poll && ( )} + {giveaway && ( + + )} {game && ( = ({ const chosen = poll.results.results?.find((result) => result.isChosen); if (isSubmitting && chosen) { if (chosen.isCorrect) { - requestConfetti(); + requestConfetti({}); } setIsSubmitting(false); } diff --git a/src/components/middle/message/_message-content.scss b/src/components/middle/message/_message-content.scss index e8cf5d581..9305b208b 100644 --- a/src/components/middle/message/_message-content.scss +++ b/src/components/middle/message/_message-content.scss @@ -825,7 +825,8 @@ .forwarded-message { .message-content.contact &, .message-content.voice &, - .message-content.poll & { + .message-content.poll &, + .message-content.giveaway & { // MessageOutgoingStatus's icon needs more space margin-bottom: 0.5rem; } diff --git a/src/components/middle/message/helpers/buildContentClassName.ts b/src/components/middle/message/helpers/buildContentClassName.ts index 539195340..e7590182c 100644 --- a/src/components/middle/message/helpers/buildContentClassName.ts +++ b/src/components/middle/message/helpers/buildContentClassName.ts @@ -34,7 +34,7 @@ export function buildContentClassName( } = {}, ) { const { - text, photo, video, audio, voice, document, poll, webPage, contact, location, invoice, storyData, + text, photo, video, audio, voice, document, poll, webPage, contact, location, invoice, storyData, giveaway, } = getMessageContent(message); const classNames = [MESSAGE_CONTENT_CLASS_NAME]; @@ -87,6 +87,8 @@ export function buildContentClassName( classNames.push('contact'); } else if (poll) { classNames.push('poll'); + } else if (giveaway) { + classNames.push('giveaway'); } else if (webPage) { classNames.push('web-page'); diff --git a/src/components/modals/boost/BoostModal.tsx b/src/components/modals/boost/BoostModal.tsx index d62a8b6b5..915615f48 100644 --- a/src/components/modals/boost/BoostModal.tsx +++ b/src/components/modals/boost/BoostModal.tsx @@ -172,7 +172,7 @@ const BoostModal = ({ const handleApplyBoost = useLastCallback(() => { closeReplaceModal(); applyBoost({ chatId: chat!.id, slots: [boost!.slot] }); - requestConfetti(); + requestConfetti({}); }); const handleProceedPremium = useLastCallback(() => { diff --git a/src/components/modals/giftcode/GiftCodeModal.async.tsx b/src/components/modals/giftcode/GiftCodeModal.async.tsx new file mode 100644 index 000000000..27e11dad0 --- /dev/null +++ b/src/components/modals/giftcode/GiftCodeModal.async.tsx @@ -0,0 +1,18 @@ +import type { FC } from '../../../lib/teact/teact'; +import React from '../../../lib/teact/teact'; + +import type { OwnProps } from './GiftCodeModal'; + +import { Bundles } from '../../../util/moduleLoader'; + +import useModuleLoader from '../../../hooks/useModuleLoader'; + +const GiftCodeModalAsync: FC = (props) => { + const { modal } = props; + const GiftCodeModal = useModuleLoader(Bundles.Extra, 'GiftCodeModal', !modal); + + // eslint-disable-next-line react/jsx-props-no-spreading + return GiftCodeModal ? : undefined; +}; + +export default GiftCodeModalAsync; diff --git a/src/components/modals/giftcode/GiftCodeModal.module.scss b/src/components/modals/giftcode/GiftCodeModal.module.scss new file mode 100644 index 000000000..473cee6ae --- /dev/null +++ b/src/components/modals/giftcode/GiftCodeModal.module.scss @@ -0,0 +1,38 @@ +.content { + display: flex; + flex-direction: column; + gap: 0.5rem; + max-height: min(92vh, 40rem) !important; + overflow-x: hidden; +} + +.clickable { + color: var(--color-primary); + cursor: pointer; +} + +.title { + background-color: var(--color-background-secondary); +} + +.table td { + border: 1px solid var(--color-borders); + padding: 0.25rem 0.5rem; +} + +.chat-item { + margin: 0; + width: fit-content; + background-color: var(--color-background); + color: var(--color-primary); +} + +.logo { + width: 6.25rem; + height: 6.25rem; + align-self: center; +} + +.centered { + text-align: center !important; +} diff --git a/src/components/modals/giftcode/GiftCodeModal.tsx b/src/components/modals/giftcode/GiftCodeModal.tsx new file mode 100644 index 000000000..76c72f852 --- /dev/null +++ b/src/components/modals/giftcode/GiftCodeModal.tsx @@ -0,0 +1,160 @@ +import React, { memo } from '../../../lib/teact/teact'; +import { getActions } from '../../../global'; + +import type { TabState } from '../../../global/types'; + +import { TME_LINK_PREFIX } from '../../../config'; +import buildClassName from '../../../util/buildClassName'; +import { formatDateTimeToString } from '../../../util/dateFormat'; +import renderText from '../../common/helpers/renderText'; + +import useLang from '../../../hooks/useLang'; +import useLastCallback from '../../../hooks/useLastCallback'; + +import LinkField from '../../common/LinkField'; +import PickerSelectedItem from '../../common/PickerSelectedItem'; +import Button from '../../ui/Button'; +import Modal from '../../ui/Modal'; + +import styles from './GiftCodeModal.module.scss'; + +import PremiumLogo from '../../../assets/premium/PremiumLogo.svg'; + +export type OwnProps = { + currentUserId?: string; + modal: TabState['giftCodeModal']; +}; + +const GIFTCODE_PATH = 'giftcode'; + +const GiftCodeModal = ({ + currentUserId, + modal, +}: OwnProps) => { + const { + closeGiftCodeModal, openChat, applyGiftCode, focusMessage, + } = getActions(); + const lang = useLang(); + const isOpen = Boolean(modal); + + const canUse = (!modal?.info.toId || modal?.info.toId === currentUserId) && !modal?.info.usedAt; + + const handleOpenChat = useLastCallback((peerId: string) => { + openChat({ id: peerId }); + closeGiftCodeModal(); + }); + + const handleOpenGiveaway = useLastCallback(() => { + if (!modal || !modal.info.giveawayMessageId) return; + focusMessage({ + chatId: modal.info.fromId!, + messageId: modal.info.giveawayMessageId, + }); + closeGiftCodeModal(); + }); + + const handleButtonClick = useLastCallback(() => { + if (canUse) { + applyGiftCode({ slug: modal!.slug }); + return; + } + closeGiftCodeModal(); + }); + + function renderContent() { + if (!modal) return undefined; + const { slug, info } = modal; + + return ( + <> + +

{renderText(lang('lng_gift_link_about'), ['simple_markdown'])}

+ + + + + + + + + + + + + + + + + + + + + + +
{lang('BoostingFrom')} + {info.fromId ? ( + + ) : lang('BoostingNoRecipient')} +
+ {lang('BoostingTo')} + + {info.toId ? ( + + ) : lang('BoostingNoRecipient')} +
+ {lang('BoostingGift')} + + {lang('BoostingTelegramPremiumFor', lang('Months', info.months, 'i'))} +
+ {lang('BoostingReason')} + + {info.isFromGiveaway && !info.toId ? lang('BoostingIncompleteGiveaway') + : lang(info.isFromGiveaway ? 'BoostingGiveaway' : 'BoostingYouWereSelected')} +
+ {lang('BoostingDate')} + + {formatDateTimeToString(info.date * 1000, lang.code, true)} +
+ + {renderText( + info.usedAt ? lang('BoostingUsedLinkDate', formatDateTimeToString(info.usedAt * 1000, lang.code, true)) + : lang('BoostingSendLinkToAnyone'), + ['simple_markdown'], + )} + + + + ); + } + + return ( + + {renderContent()} + + ); +}; + +export default memo(GiftCodeModal); diff --git a/src/components/right/management/ManageInvites.tsx b/src/components/right/management/ManageInvites.tsx index eb0ee10fa..5238a20d0 100644 --- a/src/components/right/management/ManageInvites.tsx +++ b/src/components/right/management/ManageInvites.tsx @@ -22,7 +22,7 @@ import useInterval from '../../../hooks/useInterval'; import useLang from '../../../hooks/useLang'; import AnimatedIcon from '../../common/AnimatedIcon'; -import InviteLink from '../../common/InviteLink'; +import LinkField from '../../common/LinkField'; import NothingFound from '../../common/NothingFound'; import Button from '../../ui/Button'; import ConfirmDialog from '../../ui/ConfirmDialog'; @@ -283,9 +283,10 @@ const ManageInvites: FC = ({

{isChannel ? lang('PrimaryLinkHelpChannel') : lang('PrimaryLinkHelp')}

{primaryInviteLink && ( - diff --git a/src/components/right/statistics/BoostStatistics.tsx b/src/components/right/statistics/BoostStatistics.tsx index 03ee6bbd3..34ad5b124 100644 --- a/src/components/right/statistics/BoostStatistics.tsx +++ b/src/components/right/statistics/BoostStatistics.tsx @@ -13,7 +13,7 @@ import useLang from '../../../hooks/useLang'; import useLastCallback from '../../../hooks/useLastCallback'; import Icon from '../../common/Icon'; -import InviteLink from '../../common/InviteLink'; +import LinkField from '../../common/LinkField'; import PremiumProgress from '../../common/PremiumProgress'; import PrivateChatInfo from '../../common/PrivateChatInfo'; import ListItem from '../../ui/ListItem'; @@ -135,7 +135,7 @@ const BoostStatistics = ({ )} - + )} diff --git a/src/components/ui/Button.scss b/src/components/ui/Button.scss index cce20a294..e11f88916 100644 --- a/src/components/ui/Button.scss +++ b/src/components/ui/Button.scss @@ -237,6 +237,20 @@ } } + &.adaptive { + --ripple-color: var(--accent-background-active-color); + background-color: var(--accent-background-color); + color: var(--accent-color); + + @include active-styles() { + background-color: var(--accent-background-active-color); + } + + @include no-ripple-styles() { + background-color: var(--accent-background-active-color); + } + } + &.dark { background-color: rgba(0, 0, 0, 0.75); color: white; diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index e290e50b2..a906c94af 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -20,7 +20,7 @@ export type OwnProps = { size?: 'default' | 'smaller' | 'tiny'; color?: ( 'primary' | 'secondary' | 'gray' | 'danger' | 'translucent' | 'translucent-white' | 'translucent-black' - | 'translucent-bordered' | 'dark' | 'green' + | 'translucent-bordered' | 'dark' | 'green' | 'adaptive' ); backgroundImage?: string; id?: string; diff --git a/src/config.ts b/src/config.ts index 64bc704f1..824907880 100644 --- a/src/config.ts +++ b/src/config.ts @@ -52,7 +52,7 @@ export const CUSTOM_EMOJI_PREVIEW_CACHE_DISABLED = false; export const CUSTOM_EMOJI_PREVIEW_CACHE_NAME = 'tt-custom-emoji-preview'; export const MEDIA_CACHE_MAX_BYTES = 512 * 1024; // 512 KB export const CUSTOM_BG_CACHE_NAME = 'tt-custom-bg'; -export const LANG_CACHE_NAME = 'tt-lang-packs-v25'; +export const LANG_CACHE_NAME = 'tt-lang-packs-v26'; export const ASSET_CACHE_NAME = 'tt-assets'; export const AUTODOWNLOAD_FILESIZE_MB_LIMITS = [1, 5, 10, 50, 100, 500]; export const DATA_BROADCAST_CHANNEL_NAME = 'tt-global'; diff --git a/src/global/actions/api/chats.ts b/src/global/actions/api/chats.ts index f7e25da86..c39c5c34d 100644 --- a/src/global/actions/api/chats.ts +++ b/src/global/actions/api/chats.ts @@ -975,6 +975,7 @@ addActionHandler('openTelegramLink', (global, actions, payload): ActionReturnTyp openChatByUsername: openChatByUsernameAction, openStoryViewerByUsername, processBoostParameters, + checkGiftCode, } = actions; if (url.match(RE_TG_LINK)) { @@ -1057,6 +1058,12 @@ addActionHandler('openTelegramLink', (global, actions, payload): ActionReturnTyp return; } + if (part1 === 'giftcode') { + const slug = part2; + checkGiftCode({ slug, tabId }); + return; + } + const chatOrChannelPostId = part2 || undefined; const messageId = part3 ? Number(part3) : undefined; const commentId = params.comment ? Number(params.comment) : undefined; diff --git a/src/global/actions/api/payments.ts b/src/global/actions/api/payments.ts index f105ca756..204661eef 100644 --- a/src/global/actions/api/payments.ts +++ b/src/global/actions/api/payments.ts @@ -5,12 +5,14 @@ import { PaymentStep } from '../../../types'; import { DEBUG_PAYMENT_SMART_GLOCAL } from '../../../config'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; -import { buildCollectionByKey } from '../../../util/iteratees'; +import { buildCollectionByKey, unique } from '../../../util/iteratees'; +import * as langProvider from '../../../util/langProvider'; import { buildQueryString } from '../../../util/requestQuery'; import { callApi } from '../../../api/gramjs'; -import { getStripeError } from '../../helpers'; +import { getStripeError, isChatChannel } from '../../helpers'; import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { + addChats, addUsers, closeInvoice, setInvoiceInfo, setPaymentForm, setPaymentStep, @@ -459,3 +461,213 @@ async function validateRequestedInfo( } setGlobal(global); } + +addActionHandler('openBoostModal', async (global, actions, payload): Promise => { + const { chatId, tabId = getCurrentTabId() } = payload; + const chat = selectChat(global, chatId); + if (!chat || !isChatChannel(chat)) return; + + global = updateTabState(global, { + boostModal: { + chatId, + }, + }, tabId); + setGlobal(global); + + const result = await callApi('fetchBoostsStatus', { + chat, + }); + + if (!result) { + actions.closeBoostModal({ tabId }); + return; + } + + global = getGlobal(); + global = updateTabState(global, { + boostModal: { + chatId, + boostStatus: result, + }, + }, tabId); + setGlobal(global); + + const myBoosts = await callApi('fetchMyBoosts'); + + if (!myBoosts) return; + + global = getGlobal(); + const tabState = selectTabState(global, tabId); + if (!tabState.boostModal) return; + + global = addChats(global, buildCollectionByKey(myBoosts.chats, 'id')); + global = addUsers(global, buildCollectionByKey(myBoosts.users, 'id')); + global = updateTabState(global, { + boostModal: { + ...tabState.boostModal, + myBoosts: myBoosts.boosts, + }, + }, tabId); + setGlobal(global); +}); + +addActionHandler('openBoostStatistics', async (global, actions, payload): Promise => { + const { chatId, tabId = getCurrentTabId() } = payload; + + const chat = selectChat(global, chatId); + if (!chat) return; + + global = updateTabState(global, { + boostStatistics: { + chatId, + }, + }, tabId); + setGlobal(global); + + const [boostsListResult, boostStatusResult] = await Promise.all([ + callApi('fetchBoostsList', { chat }), + callApi('fetchBoostsStatus', { chat }), + ]); + + global = getGlobal(); + if (!boostsListResult || !boostStatusResult) { + global = updateTabState(global, { + boostStatistics: undefined, + }, tabId); + setGlobal(global); + return; + } + + global = addUsers(global, buildCollectionByKey(boostsListResult.users, 'id')); + global = updateTabState(global, { + boostStatistics: { + chatId, + boostStatus: boostStatusResult, + boosters: boostsListResult.boosters, + boosterIds: boostsListResult.boosterIds, + count: boostsListResult.count, + nextOffset: boostsListResult.nextOffset, + }, + }, tabId); + setGlobal(global); +}); + +addActionHandler('loadMoreBoosters', async (global, actions, payload): Promise => { + const { tabId = getCurrentTabId() } = payload || {}; + let tabState = selectTabState(global, tabId); + if (!tabState.boostStatistics) return; + + const chat = selectChat(global, tabState.boostStatistics.chatId); + if (!chat) return; + + global = updateTabState(global, { + boostStatistics: { + ...tabState.boostStatistics, + isLoadingBoosters: true, + }, + }, tabId); + setGlobal(global); + + const result = await callApi('fetchBoostsList', { + chat, + offset: tabState.boostStatistics.nextOffset, + }); + if (!result) return; + + global = getGlobal(); + global = addUsers(global, buildCollectionByKey(result.users, 'id')); + + tabState = selectTabState(global, tabId); + if (!tabState.boostStatistics) return; + + global = updateTabState(global, { + boostStatistics: { + ...tabState.boostStatistics, + boosters: { + ...tabState.boostStatistics.boosters, + ...result.boosters, + }, + boosterIds: unique([...tabState.boostStatistics.boosterIds || [], ...result.boosterIds]), + count: result.count, + nextOffset: result.nextOffset, + isLoadingBoosters: false, + }, + }, tabId); + setGlobal(global); +}); + +addActionHandler('applyBoost', async (global, actions, payload): Promise => { + const { chatId, slots, tabId = getCurrentTabId() } = payload; + + const chat = selectChat(global, chatId); + if (!chat) return; + + const result = await callApi('applyBoost', { + slots, + chat, + }); + + if (!result) { + return; + } + + const newStatusResult = await callApi('fetchBoostsStatus', { + chat, + }); + + if (!newStatusResult) { + return; + } + + global = getGlobal(); + const tabState = selectTabState(global, tabId); + if (!tabState.boostModal?.boostStatus) return; + global = updateTabState(global, { + boostModal: { + ...tabState.boostModal, + boostStatus: newStatusResult, + }, + }, tabId); + setGlobal(global); +}); + +addActionHandler('checkGiftCode', async (global, actions, payload): Promise => { + const { slug, tabId = getCurrentTabId() } = payload; + + const result = await callApi('checkGiftCode', { + slug, + }); + + if (!result) { + actions.showNotification({ + message: langProvider.translate('lng_gift_link_expired'), + tabId, + }); + return; + } + + global = getGlobal(); + global = addUsers(global, buildCollectionByKey(result.users, 'id')); + global = addChats(global, buildCollectionByKey(result.chats, 'id')); + global = updateTabState(global, { + giftCodeModal: { + slug, + info: result.code, + }, + }, tabId); + setGlobal(global); +}); + +addActionHandler('applyGiftCode', async (global, actions, payload): Promise => { + const { slug, tabId = getCurrentTabId() } = payload; + + const result = await callApi('applyGiftCode', { + slug, + }); + + if (!result) { + return; + } + actions.requestConfetti({ tabId }); + actions.closeGiftCodeModal({ tabId }); +}); diff --git a/src/global/actions/api/stories.ts b/src/global/actions/api/stories.ts index afa43c4c4..bcb31a0db 100644 --- a/src/global/actions/api/stories.ts +++ b/src/global/actions/api/stories.ts @@ -2,11 +2,11 @@ import type { ActionReturnType } from '../../types'; import { DEBUG, PREVIEW_AVATAR_COUNT } from '../../../config'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; -import { buildCollectionByKey, unique } from '../../../util/iteratees'; +import { buildCollectionByKey } from '../../../util/iteratees'; import { translate } from '../../../util/langProvider'; import { getServerTime } from '../../../util/serverTime'; import { callApi } from '../../../api/gramjs'; -import { buildApiInputPrivacyRules, isChatChannel } from '../../helpers'; +import { buildApiInputPrivacyRules } from '../../helpers'; import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { addChats, @@ -26,10 +26,8 @@ import { updateStoryViews, updateStoryViewsLoading, } from '../../reducers'; -import { updateTabState } from '../../reducers/tabs'; import { - selectChat, - selectPeer, selectPeerStories, selectPeerStory, selectTabState, + selectPeer, selectPeerStories, selectPeerStory, } from '../../selectors'; const INFINITE_LOOP_MARKER = 100; @@ -504,172 +502,3 @@ addActionHandler('activateStealthMode', (global, actions, payload): ActionReturn callApi('activateStealthMode', { isForPast: isForPast || true, isForFuture: isForFuture || true }); }); - -addActionHandler('openBoostModal', async (global, actions, payload): Promise => { - const { chatId, tabId = getCurrentTabId() } = payload; - const chat = selectChat(global, chatId); - if (!chat || !isChatChannel(chat)) return; - - global = updateTabState(global, { - boostModal: { - chatId, - }, - }, tabId); - setGlobal(global); - - const result = await callApi('fetchBoostsStatus', { - chat, - }); - - if (!result) { - actions.closeBoostModal({ tabId }); - return; - } - - global = getGlobal(); - global = updateTabState(global, { - boostModal: { - chatId, - boostStatus: result, - }, - }, tabId); - setGlobal(global); - - const myBoosts = await callApi('fetchMyBoosts'); - - if (!myBoosts) return; - - global = getGlobal(); - const tabState = selectTabState(global, tabId); - if (!tabState.boostModal) return; - - global = addChats(global, buildCollectionByKey(myBoosts.chats, 'id')); - global = addUsers(global, buildCollectionByKey(myBoosts.users, 'id')); - global = updateTabState(global, { - boostModal: { - ...tabState.boostModal, - myBoosts: myBoosts.boosts, - }, - }, tabId); - setGlobal(global); -}); - -addActionHandler('openBoostStatistics', async (global, actions, payload): Promise => { - const { chatId, tabId = getCurrentTabId() } = payload; - - const chat = selectChat(global, chatId); - if (!chat) return; - - global = updateTabState(global, { - boostStatistics: { - chatId, - }, - }, tabId); - setGlobal(global); - - const [boostersListResult, boostStatusResult] = await Promise.all([ - callApi('fetchBoostersList', { chat }), - callApi('fetchBoostsStatus', { chat }), - ]); - - global = getGlobal(); - if (!boostersListResult || !boostStatusResult) { - global = updateTabState(global, { - boostStatistics: undefined, - }, tabId); - setGlobal(global); - return; - } - - global = addUsers(global, buildCollectionByKey(boostersListResult.users, 'id')); - global = updateTabState(global, { - boostStatistics: { - chatId, - boostStatus: boostStatusResult, - boosters: boostersListResult.boosters, - boosterIds: boostersListResult.boosterIds, - count: boostersListResult.count, - nextOffset: boostersListResult.nextOffset, - }, - }, tabId); - setGlobal(global); -}); - -addActionHandler('loadMoreBoosters', async (global, actions, payload): Promise => { - const { tabId = getCurrentTabId() } = payload || {}; - let tabState = selectTabState(global, tabId); - if (!tabState.boostStatistics) return; - - const chat = selectChat(global, tabState.boostStatistics.chatId); - if (!chat) return; - - global = updateTabState(global, { - boostStatistics: { - ...tabState.boostStatistics, - isLoadingBoosters: true, - }, - }, tabId); - setGlobal(global); - - const result = await callApi('fetchBoostersList', { - chat, - offset: tabState.boostStatistics.nextOffset, - }); - if (!result) return; - - global = getGlobal(); - global = addUsers(global, buildCollectionByKey(result.users, 'id')); - - tabState = selectTabState(global, tabId); - if (!tabState.boostStatistics) return; - - global = updateTabState(global, { - boostStatistics: { - ...tabState.boostStatistics, - boosters: { - ...tabState.boostStatistics.boosters, - ...result.boosters, - }, - boosterIds: unique([...tabState.boostStatistics.boosterIds || [], ...result.boosterIds]), - count: result.count, - nextOffset: result.nextOffset, - isLoadingBoosters: false, - }, - }, tabId); - setGlobal(global); -}); - -addActionHandler('applyBoost', async (global, actions, payload): Promise => { - const { chatId, slots, tabId = getCurrentTabId() } = payload; - - const chat = selectChat(global, chatId); - if (!chat) return; - - const result = await callApi('applyBoost', { - slots, - chat, - }); - - if (!result) { - return; - } - - const newStatusResult = await callApi('fetchBoostsStatus', { - chat, - }); - - if (!newStatusResult) { - return; - } - - global = getGlobal(); - const tabState = selectTabState(global, tabId); - if (!tabState.boostModal?.boostStatus) return; - global = updateTabState(global, { - boostModal: { - ...tabState.boostModal, - boostStatus: newStatusResult, - }, - }, tabId); - setGlobal(global); -}); diff --git a/src/global/actions/ui/misc.ts b/src/global/actions/ui/misc.ts index e288381c4..311193d3e 100644 --- a/src/global/actions/ui/misc.ts +++ b/src/global/actions/ui/misc.ts @@ -455,17 +455,15 @@ addActionHandler('closeGame', (global, actions, payload): ActionReturnType => { addActionHandler('requestConfetti', (global, actions, payload): ActionReturnType => { const { - top, left, width, height, tabId = getCurrentTabId(), - } = payload || {}; + tabId = getCurrentTabId(), ...rest + } = payload; + if (!selectCanAnimateInterface(global)) return undefined; return updateTabState(global, { confetti: { lastConfettiTime: Date.now(), - top, - left, - width, - height, + ...rest, }, }, tabId); }); diff --git a/src/global/actions/ui/payments.ts b/src/global/actions/ui/payments.ts index 66045d965..bbf212552 100644 --- a/src/global/actions/ui/payments.ts +++ b/src/global/actions/ui/payments.ts @@ -31,3 +31,11 @@ addActionHandler('addPaymentError', (global, actions, payload): ActionReturnType }, }, tabId); }); + +addActionHandler('closeGiftCodeModal', (global, actions, payload): ActionReturnType => { + const { tabId = getCurrentTabId() } = payload || {}; + + return updateTabState(global, { + giftCodeModal: undefined, + }, tabId); +}); diff --git a/src/global/helpers/messageSummary.ts b/src/global/helpers/messageSummary.ts index 364036579..4e5b89815 100644 --- a/src/global/helpers/messageSummary.ts +++ b/src/global/helpers/messageSummary.ts @@ -120,6 +120,7 @@ export function getMessageSummaryDescription( location, game, storyData, + giveaway, } = message.content; let hasUsedTruncatedText = false; @@ -190,6 +191,10 @@ export function getMessageSummaryDescription( summary = `🎮 ${game.title}`; } + if (giveaway) { + summary = lang('BoostingGiveawayChannelStarted'); + } + if (storyData) { if (storyData.isMention) { // eslint-disable-next-line eslint-multitab-tt/no-immediate-global diff --git a/src/global/helpers/messages.ts b/src/global/helpers/messages.ts index 907efa7a3..afc94e004 100644 --- a/src/global/helpers/messages.ts +++ b/src/global/helpers/messages.ts @@ -55,12 +55,12 @@ export function getMessageTranscription(message: ApiMessage) { export function hasMessageText(message: ApiMessage | ApiStory) { const { text, sticker, photo, video, audio, voice, document, poll, webPage, contact, invoice, location, - game, action, storyData, + game, action, storyData, giveaway, } = message.content; return Boolean(text) || !( sticker || photo || video || audio || voice || document || contact || poll || webPage || invoice || location - || game || action?.phoneCall || storyData + || game || action?.phoneCall || storyData || giveaway ); } diff --git a/src/global/selectors/messages.ts b/src/global/selectors/messages.ts index 24d9acaa8..76216eefe 100644 --- a/src/global/selectors/messages.ts +++ b/src/global/selectors/messages.ts @@ -569,7 +569,7 @@ export function selectAllowedMessageActions(global: T, me || getServerTime() - message.date < MESSAGE_EDIT_ALLOWED_TIME ) && !( content.sticker || content.contact || content.poll || content.action || content.audio - || (content.video?.isRound) || content.location || content.invoice + || (content.video?.isRound) || content.location || content.invoice || content.giveaway ) && !isForwarded && !message.viaBotId diff --git a/src/global/selectors/symbols.ts b/src/global/selectors/symbols.ts index 169e20c50..a713fb96a 100644 --- a/src/global/selectors/symbols.ts +++ b/src/global/selectors/symbols.ts @@ -6,6 +6,15 @@ import { getCurrentTabId } from '../../util/establishMultitabRole'; import { selectTabState } from './tabs'; import { selectIsCurrentUserPremium } from './users'; +// https://github.com/DrKLO/Telegram/blob/c319639e9a4dff2f22da6762dcebd12d49f5afa1/TMessagesProj/src/main/java/org/telegram/ui/Components/Premium/boosts/cells/msg/GiveawayMessageCell.java#L59 +const MONTH_EMOTICON: Record = { + 1: `${1}\u{FE0F}\u20E3`, + 3: `${2}\u{FE0F}\u20E3`, + 6: `${3}\u{FE0F}\u20E3`, + 12: `${4}\u{FE0F}\u20E3`, + 24: `${5}\u{FE0F}\u20E3`, +}; + export function selectIsStickerFavorite(global: T, sticker: ApiSticker) { const { stickers } = global.stickers.favorite; return stickers && stickers.some(({ id }) => id === sticker.id); @@ -140,3 +149,10 @@ export function selectIsAlwaysHighPriorityEmoji( return stickerSet.id === global.appConfig?.defaultEmojiStatusesStickerSetId || stickerSet.id === RESTRICTED_EMOJI_SET_ID; } + +export function selectGiftStickerForDuration(global: T, duration = 1) { + const stickers = global.premiumGifts?.stickers; + if (!stickers) return undefined; + const emoji = MONTH_EMOTICON[duration]; + return stickers.find((sticker) => sticker.emoji === emoji) || stickers[0]; +} diff --git a/src/global/types.ts b/src/global/types.ts index ea6d35208..a4055728f 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -14,6 +14,7 @@ import type { ApiChatlistInvite, ApiChatReactions, ApiChatType, + ApiCheckedGiftCode, ApiConfig, ApiContact, ApiCountry, @@ -634,6 +635,11 @@ export type TabState = { nextOffset?: string; count?: number; }; + + giftCodeModal?: { + slug: string; + info: ApiCheckedGiftCode; + }; }; export type GlobalState = { @@ -1805,6 +1811,14 @@ export interface ActionPayloads { isEnabled: boolean; }; + checkGiftCode: { + slug: string; + } & WithTabId; + applyGiftCode: { + slug: string; + } & WithTabId; + closeGiftCodeModal: WithTabId | undefined; + checkChatlistInvite: { slug: string; } & WithTabId; @@ -2522,7 +2536,7 @@ export interface ActionPayloads { left: number; width: number; height: number; - } & WithTabId) | undefined; + } & WithTabId) | WithTabId; updateAttachmentSettings: { shouldCompress?: boolean; diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index 2c5558ebe..a679cdbf1 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -1433,6 +1433,9 @@ payments.getPaymentReceipt#2478d1cc peer:InputPeer msg_id:int = payments.Payment payments.validateRequestedInfo#b6c8f12b flags:# save:flags.0?true invoice:InputInvoice info:PaymentRequestedInfo = payments.ValidatedRequestedInfo; payments.sendPaymentForm#2d03522f flags:# form_id:long invoice:InputInvoice requested_info_id:flags.0?string shipping_option_id:flags.1?string credentials:InputPaymentCredentials tip_amount:flags.2?long = payments.PaymentResult; payments.getSavedInfo#227d824b = payments.SavedInfo; +payments.checkGiftCode#8e51b4c1 slug:string = payments.CheckedGiftCode; +payments.applyGiftCode#f6e26854 slug:string = Updates; +payments.getGiveawayInfo#f4239425 peer:InputPeer msg_id:int = payments.GiveawayInfo; phone.requestCall#42ff96ed flags:# video:flags.0?true user_id:InputUser random_id:int g_a_hash:bytes protocol:PhoneCallProtocol = phone.PhoneCall; phone.acceptCall#3bd2b4a0 peer:InputPhoneCall g_b:bytes protocol:PhoneCallProtocol = phone.PhoneCall; phone.confirmCall#2efe1722 peer:InputPhoneCall g_a:bytes key_fingerprint:long protocol:PhoneCallProtocol = phone.PhoneCall; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index 71bf12c38..dbb2d69a8 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -318,5 +318,8 @@ "premium.getBoostsStatus", "premium.getBoostersList", "premium.applyBoost", - "premium.getMyBoosts" + "premium.getMyBoosts", + "payments.checkGiftCode", + "payments.applyGiftCode", + "payments.getGiveawayInfo" ] diff --git a/src/util/deeplink.ts b/src/util/deeplink.ts index 9b8e8a433..ac1c959cb 100644 --- a/src/util/deeplink.ts +++ b/src/util/deeplink.ts @@ -7,7 +7,7 @@ import { IS_SAFARI } from './windowEnvironment'; type DeepLinkMethod = 'resolve' | 'login' | 'passport' | 'settings' | 'join' | 'addstickers' | 'addemoji' | 'setlanguage' | 'addtheme' | 'confirmphone' | 'socks' | 'proxy' | 'privatepost' | 'bg' | 'share' | 'msg' | 'msg_url' | -'invoice' | 'addlist' | 'boost'; +'invoice' | 'addlist' | 'boost' | 'giftcode'; export const processDeepLink = (url: string) => { const { @@ -29,6 +29,7 @@ export const processDeepLink = (url: string) => { checkChatlistInvite, openStoryViewerByUsername, processBoostParameters, + checkGiftCode, } = getActions(); // Safari thinks the path in tg://path links is hostname for some reason @@ -157,6 +158,12 @@ export const processDeepLink = (url: string) => { processBoostParameters({ usernameOrId: channel || domain, isPrivate }); break; } + + case 'giftcode': { + const { slug } = params; + checkGiftCode({ slug }); + break; + } default: // Unsupported deeplink diff --git a/src/util/emoji.ts b/src/util/emoji.ts index 963da6120..55689d2e4 100644 --- a/src/util/emoji.ts +++ b/src/util/emoji.ts @@ -115,6 +115,11 @@ export function uncompressEmoji(data: EmojiRawData): EmojiData { } export function isoToEmoji(iso: string) { + // Special case for Fragment numbers + if (iso === 'FT') { + return '\uD83C\uDFF4\u200D\u2620\uFE0F'; + } + const code = iso.toUpperCase(); if (!/^[A-Z]{2}$/.test(code)) return iso;