Giveaway: Creating giveaway in channels (#4339)

Co-authored-by: zubiden <19638254+zubiden@users.noreply.github.com>
This commit is contained in:
Alexander Zinchuk 2024-04-19 13:37:50 +04:00
parent 9807dfb5d5
commit 97d3d31f10
61 changed files with 2853 additions and 196 deletions

View File

@ -52,6 +52,8 @@ export interface GramJsAppConfig extends LimitsConfig {
autologin_token: string;
url_auth_domains: string[];
premium_purchase_blocked: boolean;
giveaway_gifts_purchase_available: boolean;
giveaway_add_peers_max: number;
premium_bot_username: string;
premium_invoice_slug: string;
premium_promo_order: string[];
@ -59,6 +61,8 @@ export interface GramJsAppConfig extends LimitsConfig {
hidden_members_group_size_min: number;
autoarchive_setting_available: boolean;
authorization_autoconfirm_period: number;
giveaway_boosts_per_premium: number;
giveaway_countries_max: number;
// Forums
topics_pinned_limit: number;
// Stories
@ -109,11 +113,15 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp
premiumInvoiceSlug: appConfig.premium_invoice_slug,
premiumPromoOrder: appConfig.premium_promo_order as ApiPremiumSection[],
isPremiumPurchaseBlocked: appConfig.premium_purchase_blocked,
isGiveawayGiftsPurchaseAvailable: appConfig.giveaway_gifts_purchase_available,
defaultEmojiStatusesStickerSetId: appConfig.default_emoji_statuses_stickerset_id,
topicsPinnedLimit: appConfig.topics_pinned_limit,
maxUserReactionsDefault: appConfig.reactions_user_max_default,
maxUserReactionsPremium: appConfig.reactions_user_max_premium,
hiddenMembersMinCount: appConfig.hidden_members_group_size_min,
giveawayAddPeersMax: appConfig.giveaway_add_peers_max,
giveawayBoostsPerPremium: appConfig.giveaway_boosts_per_premium,
giveawayCountriesMax: appConfig.giveaway_countries_max,
canDisplayAutoarchiveSetting: appConfig.autoarchive_setting_available,
limits: {
uploadMaxFileparts: getLimit(appConfig, 'upload_max_fileparts', 'uploadMaxFileparts'),

View File

@ -6,7 +6,7 @@ import type {
ApiCheckedGiftCode,
ApiGiveawayInfo,
ApiInvoice, ApiLabeledPrice, ApiMyBoost, ApiPaymentCredentials,
ApiPaymentForm, ApiPaymentSavedInfo, ApiPremiumPromo, ApiPremiumSubscriptionOption,
ApiPaymentForm, ApiPaymentSavedInfo, ApiPremiumGiftCodeOption, ApiPremiumPromo, ApiPremiumSubscriptionOption,
ApiReceipt,
} from '../../types';
@ -14,7 +14,7 @@ import { buildApiMessageEntity } from './common';
import { omitVirtualClassFields } from './helpers';
import { buildApiDocument, buildApiWebDocument } from './messageContent';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers';
import { buildStatisticsPercentage } from './statistics';
import { buildPrepaidGiveaway, buildStatisticsPercentage } from './statistics';
export function buildShippingOptions(shippingOptions: GramJs.ShippingOption[] | undefined) {
if (!shippingOptions) {
@ -209,7 +209,7 @@ export function buildApiPaymentCredentials(credentials: GramJs.PaymentSavedCrede
export function buildApiBoostsStatus(boostStatus: GramJs.premium.BoostsStatus): ApiBoostsStatus {
const {
level, boostUrl, boosts, myBoost, currentLevelBoosts, nextLevelBoosts, premiumAudience,
level, boostUrl, boosts, myBoost, currentLevelBoosts, nextLevelBoosts, premiumAudience, prepaidGiveaways,
} = boostStatus;
return {
level,
@ -219,6 +219,7 @@ export function buildApiBoostsStatus(boostStatus: GramJs.premium.BoostsStatus):
boostUrl,
nextLevelBoosts,
...(premiumAudience && { premiumSubscribers: buildStatisticsPercentage(premiumAudience) }),
...(prepaidGiveaways && { prepaidGiveaways: prepaidGiveaways.map(buildPrepaidGiveaway) }),
};
}
@ -295,3 +296,16 @@ export function buildApiCheckedGiftCode(giftcode: GramJs.payments.TypeCheckedGif
giveawayMessageId: giveawayMsgId,
};
}
export function buildApiPremiumGiftCodeOption(option: GramJs.PremiumGiftCodeOption): ApiPremiumGiftCodeOption {
const {
amount, currency, months, users,
} = option;
return {
amount: amount.toJSNumber(),
currency,
months,
users,
};
}

View File

@ -6,7 +6,7 @@ import type {
ApiMessagePublicForward,
ApiPostStatistics,
ApiStoryPublicForward,
StatisticsGraph,
PrepaidGiveaway, StatisticsGraph,
StatisticsMessageInteractionCounter,
StatisticsOverviewItem,
StatisticsOverviewPercentage,
@ -214,6 +214,15 @@ export function buildStatisticsPercentage(data: GramJs.StatsPercentValue): Stati
};
}
export function buildPrepaidGiveaway(prepaidGiveaway: GramJs.PrepaidGiveaway): PrepaidGiveaway {
return {
id: prepaidGiveaway.id.toString(),
date: prepaidGiveaway.date,
months: prepaidGiveaway.months,
quantity: prepaidGiveaway.quantity,
};
}
function getOverviewPeriod(data: GramJs.StatsDateRangeDays): StatisticsOverviewPeriod {
return {
maxDate: data.maxDate,

View File

@ -12,12 +12,13 @@ import type {
ApiFormattedText,
ApiGroupCall,
ApiInputReplyInfo,
ApiInputStorePaymentPurpose,
ApiMessageEntity,
ApiNewPoll,
ApiPhoneCall,
ApiPhoto,
ApiPoll,
ApiReaction,
ApiPremiumGiftCodeOption, ApiReaction,
ApiReportReason,
ApiRequestInputInvoice,
ApiSendMessageAction,
@ -566,16 +567,69 @@ export function buildInputPhoneCall({ id, accessHash }: ApiPhoneCall) {
});
}
export function buildInputStorePaymentPurpose(purpose: ApiInputStorePaymentPurpose):
GramJs.TypeInputStorePaymentPurpose {
if (purpose.type === 'giftcode') {
return new GramJs.InputStorePaymentPremiumGiftCode({
users: purpose.users.map((user) => buildInputEntity(user.id, user.accessHash) as GramJs.InputUser),
boostPeer: purpose.boostChannel
? buildInputPeer(purpose.boostChannel.id, purpose.boostChannel.accessHash)
: undefined,
currency: purpose.currency,
amount: BigInt(purpose.amount),
});
}
const randomId = generateRandomBigInt();
return new GramJs.InputStorePaymentPremiumGiveaway({
boostPeer: buildInputPeer(purpose.chat.id, purpose.chat.accessHash),
additionalPeers: purpose.additionalChannels?.map((chat) => buildInputPeer(chat.id, chat.accessHash)),
countriesIso2: purpose.countries,
prizeDescription: purpose.prizeDescription,
onlyNewSubscribers: purpose.isOnlyForNewSubscribers || undefined,
winnersAreVisible: purpose.areWinnersVisible || undefined,
untilDate: purpose.untilDate,
currency: purpose.currency,
amount: BigInt(purpose.amount),
randomId,
});
}
function buildPremiumGiftCodeOption(optionData: ApiPremiumGiftCodeOption) {
return new GramJs.PremiumGiftCodeOption({
users: optionData.users,
months: optionData.months,
currency: optionData.currency,
amount: BigInt(optionData.amount),
});
}
export function buildInputInvoice(invoice: ApiRequestInputInvoice) {
if ('slug' in invoice) {
return new GramJs.InputInvoiceSlug({
slug: invoice.slug,
});
} else {
return new GramJs.InputInvoiceMessage({
peer: buildInputPeer(invoice.chat.id, invoice.chat.accessHash),
msgId: invoice.messageId,
});
switch (invoice.type) {
case 'message': {
return new GramJs.InputInvoiceMessage({
peer: buildInputPeer(invoice.chat.id, invoice.chat.accessHash),
msgId: invoice.messageId,
});
}
case 'slug': {
return new GramJs.InputInvoiceSlug({
slug: invoice.slug,
});
}
case 'giveaway':
default: {
const purpose = buildInputStorePaymentPurpose(invoice.purpose);
const option = buildPremiumGiftCodeOption(invoice.option);
return new GramJs.InputInvoicePremiumGiftCode({
purpose,
option,
});
}
}
}

View File

@ -101,5 +101,6 @@ export * from './stories';
export {
validateRequestedInfo, sendPaymentForm, getPaymentForm, getReceipt, fetchPremiumPromo, fetchTemporaryPaymentPassword,
applyBoost, fetchBoostsList, fetchBoostsStatus, fetchGiveawayInfo, fetchMyBoosts, applyGiftCode, checkGiftCode,
applyBoost, fetchBoostList, fetchBoostStatus, fetchGiveawayInfo, fetchMyBoosts, applyGiftCode, checkGiftCode,
getPremiumGiftCodeOptions, launchPrepaidGiveaway,
} from './payments';

View File

@ -2,7 +2,7 @@ import BigInt from 'big-integer';
import { Api as GramJs } from '../../../lib/gramjs';
import type {
ApiChat, ApiPeer, ApiRequestInputInvoice,
ApiChat, ApiInputStorePaymentPurpose, ApiPeer, ApiRequestInputInvoice,
OnApiUpdate,
} from '../../types';
@ -15,12 +15,15 @@ import {
buildApiInvoiceFromForm,
buildApiMyBoost,
buildApiPaymentForm,
buildApiPremiumGiftCodeOption,
buildApiPremiumPromo,
buildApiReceipt,
buildShippingOptions,
} from '../apiBuilders/payments';
import { buildApiUser } from '../apiBuilders/users';
import { buildInputInvoice, buildInputPeer, buildShippingInfo } from '../gramjsBuilders';
import {
buildInputInvoice, buildInputPeer, buildInputStorePaymentPurpose, buildShippingInfo,
} from '../gramjsBuilders';
import {
addEntitiesToLocalDb,
deserializeBytes,
@ -241,7 +244,7 @@ export async function applyBoost({
};
}
export async function fetchBoostsStatus({
export async function fetchBoostStatus({
chat,
}: {
chat: ApiChat;
@ -257,7 +260,7 @@ export async function fetchBoostsStatus({
return buildApiBoostsStatus(result);
}
export async function fetchBoostsList({
export async function fetchBoostList({
chat,
offset = '',
limit,
@ -348,3 +351,37 @@ export function applyGiftCode({
shouldReturnTrue: true,
});
}
export async function getPremiumGiftCodeOptions({
chat,
}: {
chat?: ApiChat;
}) {
const result = await invokeRequest(new GramJs.payments.GetPremiumGiftCodeOptions({
boostPeer: chat && buildInputPeer(chat.id, chat.accessHash),
}));
if (!result) {
return undefined;
}
return result.map(buildApiPremiumGiftCodeOption);
}
export function launchPrepaidGiveaway({
chat,
giveawayId,
paymentPurpose,
}: {
chat: ApiChat;
giveawayId: string;
paymentPurpose: ApiInputStorePaymentPurpose;
}) {
return invokeRequest(new GramJs.payments.LaunchPrepaidGiveaway({
peer: buildInputPeer(chat.id, chat.accessHash),
giveawayId: BigInt(giveawayId),
purpose: buildInputStorePaymentPurpose(paymentPurpose),
}), {
shouldReturnTrue: true,
});
}

View File

@ -2,6 +2,7 @@ import type { ThreadId } from '../../types';
import type { ApiWebDocument } from './bots';
import type { ApiGroupCall, PhoneCallAction } from './calls';
import type { ApiChat } from './chats';
import type { ApiInputStorePaymentPurpose, ApiPremiumGiftCodeOption } from './payments';
import type { ApiMessageStoryData, ApiWebPageStoryData } from './stories';
export interface ApiDimensions {
@ -170,22 +171,66 @@ export interface ApiPoll {
};
}
// First type used for state, second - for API requests
export type ApiInputInvoice = {
/* Used for Invoice UI */
export type ApiInputInvoiceMessage = {
type: 'message';
chatId: string;
messageId: number;
isExtendedMedia?: boolean;
} | {
};
export type ApiInputInvoiceSlug = {
type: 'slug';
slug: string;
};
export type ApiRequestInputInvoice = {
export type ApiInputInvoiceGiveaway = {
type: 'giveaway';
chatId: string;
additionalChannelIds?: string[];
isOnlyForNewSubscribers?: boolean;
areWinnersVisible?: boolean;
prizeDescription?: string;
countries?: string[];
untilDate: number;
currency: string;
amount: number;
option: ApiPremiumGiftCodeOption;
};
export type ApiInputInvoiceGiftCode = {
type: 'giftcode';
userIds: string[];
boostChannelId?: string;
currency: string;
amount: number;
option: ApiPremiumGiftCodeOption;
};
export type ApiInputInvoice = ApiInputInvoiceMessage | ApiInputInvoiceSlug | ApiInputInvoiceGiveaway
| ApiInputInvoiceGiftCode;
/* Used for Invoice request */
export type ApiRequestInputInvoiceMessage = {
type: 'message';
chat: ApiChat;
messageId: number;
} | {
};
export type ApiRequestInputInvoiceSlug = {
type: 'slug';
slug: string;
};
export type ApiRequestInputInvoiceGiveaway = {
type: 'giveaway';
purpose: ApiInputStorePaymentPurpose;
option: ApiPremiumGiftCodeOption;
};
export type ApiRequestInputInvoice = ApiRequestInputInvoiceMessage | ApiRequestInputInvoiceSlug
| ApiRequestInputInvoiceGiveaway;
export interface ApiInvoice {
text: string;
title: string;

View File

@ -185,6 +185,10 @@ export interface ApiAppConfig {
premiumInvoiceSlug: string;
premiumBotUsername: string;
isPremiumPurchaseBlocked: boolean;
isGiveawayGiftsPurchaseAvailable: boolean;
giveawayAddPeersMax: number;
giveawayBoostsPerPremium: number;
giveawayCountriesMax: number;
premiumPromoOrder: ApiPremiumSection[];
defaultEmojiStatusesStickerSetId: string;
maxUniqueReactions: number;

View File

@ -1,8 +1,10 @@
import type { ApiPremiumSection } from '../../global/types';
import type { ApiInvoiceContainer } from '../../types';
import type { ApiWebDocument } from './bots';
import type { ApiChat } from './chats';
import type { ApiDocument, ApiMessageEntity, ApiPaymentCredentials } from './messages';
import type { StatisticsOverviewPercentage } from './statistics';
import type { PrepaidGiveaway, StatisticsOverviewPercentage } from './statistics';
import type { ApiUser } from './users';
export interface ApiShippingAddress {
streetLine1: string;
@ -81,6 +83,36 @@ export interface ApiPremiumSubscriptionOption {
botUrl: string;
}
export type ApiInputStorePaymentGiveaway = {
type: 'giveaway';
isOnlyForNewSubscribers?: boolean;
areWinnersVisible?: boolean;
chat: ApiChat;
additionalChannels?: ApiChat[];
countries?: string[];
prizeDescription?: string;
untilDate: number;
currency: string;
amount: number;
};
export type ApiInputStorePaymentGiftcode = {
type: 'giftcode';
users: ApiUser[];
boostChannel?: ApiChat;
currency: string;
amount: number;
};
export type ApiInputStorePaymentPurpose = ApiInputStorePaymentGiveaway | ApiInputStorePaymentGiftcode;
export interface ApiPremiumGiftCodeOption {
users: number;
months: number;
currency: string;
amount: number;
}
export type ApiBoostsStatus = {
level: number;
currentLevelBoosts: number;
@ -89,6 +121,7 @@ export type ApiBoostsStatus = {
hasMyBoost?: boolean;
boostUrl: string;
premiumSubscribers?: StatisticsOverviewPercentage;
prepaidGiveaways?: PrepaidGiveaway[];
};
export type ApiMyBoost = {
@ -131,3 +164,10 @@ export type ApiCheckedGiftCode = {
months: number;
usedAt?: number;
};
export interface ApiPrepaidGiveaway {
id: string;
months: number;
quantity: number;
date: number;
}

View File

@ -54,6 +54,7 @@ export interface ApiBoostStatistics {
boosts: number;
premiumSubscribers: StatisticsOverviewPercentage;
remainingBoosts: number;
prepaidGiveaways: PrepaidGiveaway[];
}
export interface ApiMessagePublicForward {
@ -105,6 +106,13 @@ export interface StatisticsOverviewPercentage {
percentage: string;
}
export interface PrepaidGiveaway {
id: string;
months: number;
quantity: number;
date: number;
}
export interface StatisticsOverviewPeriod {
maxDate: number;
minDate: number;

View File

@ -0,0 +1,16 @@
<svg width="42" height="42" viewBox="0 0 42 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3714_1812)">
<circle cx="21" cy="21" r="21" fill="url(#paint0_linear_3714_1812)"/>
<path d="M19.8104 14.6019H18.0718C17.186 14.6019 16.4983 14.3775 16.0088 13.9287C15.5193 13.4798 15.2745 12.9528 15.2745 12.3477C15.2745 11.7556 15.4718 11.2919 15.8663 10.9566C16.2608 10.6213 16.7716 10.4536 17.3987 10.4536C18.0637 10.4536 18.6319 10.687 19.1033 11.1536C19.5747 11.6203 19.8104 12.2464 19.8104 13.032V14.6019H21.976V13.032C21.976 12.2464 22.0625 11.6203 22.5343 11.1536C23.0061 10.687 23.5777 10.4536 24.2491 10.4536C24.8691 10.4536 25.3761 10.6213 25.7702 10.9566C26.1642 11.2919 26.3613 11.7556 26.3613 12.3477C26.3613 12.9528 26.1167 13.4798 25.6275 13.9287C25.1384 14.3775 24.4509 14.6019 23.5651 14.6019H21.976H27.3553C27.6828 14.2976 27.9407 13.9414 28.1289 13.5332C28.3172 13.1251 28.4113 12.6776 28.4113 12.1909C28.4113 11.4767 28.2318 10.8412 27.8729 10.2845C27.5139 9.7278 27.0326 9.29235 26.4289 8.97814C25.8252 8.66394 25.1494 8.50684 24.4017 8.50684C23.578 8.50684 22.8469 8.71684 22.2084 9.13684C21.5698 9.55685 21.1083 10.1509 20.824 10.9191C20.5397 10.1509 20.0764 9.55685 19.4341 9.13684C18.7918 8.71684 18.0589 8.50684 17.2354 8.50684C16.4948 8.50684 15.8208 8.66394 15.2136 8.97814C14.6063 9.29235 14.1231 9.7278 13.7641 10.2845C13.4051 10.8412 13.2256 11.4767 13.2256 12.1909C13.2256 12.6776 13.3197 13.1251 13.508 13.5332C13.6962 13.9414 13.9542 14.2976 14.2817 14.6019H19.8104Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.8065 14.5815V17.6802C19.8065 17.7095 19.8136 17.7383 19.8272 17.7642C19.8736 17.8526 19.9829 17.8867 20.0713 17.8403L20.5552 17.5864C20.7656 17.476 21.0168 17.476 21.2272 17.5864L21.7111 17.8403C21.737 17.8539 21.7659 17.861 21.7951 17.861C21.895 17.861 21.9759 17.7801 21.9759 17.6802V14.5815H30.2919C31.0906 14.5815 31.7382 15.2291 31.7382 16.0278V18.9203C31.7382 19.7191 31.0906 20.3666 30.2919 20.3666H29.5688L29.5689 20.3847C29.5105 20.3728 29.45 20.3666 29.388 20.3666L25.853 20.366C26.5258 20.5657 26.9995 21.1976 26.9801 21.9201L26.9762 21.9965C26.9717 22.0565 26.9639 22.116 26.9527 22.1745L29.388 22.1744C29.45 22.1744 29.5105 22.1682 29.5689 22.1563L29.5688 28.6826C29.5688 30.2801 28.2737 31.5751 26.6762 31.5751H21.9759V28.6307C21.9759 28.4026 21.8684 28.1892 21.6881 28.0533L21.6371 28.0182L21.0834 27.6707C20.9659 27.597 20.8165 27.597 20.699 27.6707L20.1453 28.0182C19.9345 28.1505 19.8065 28.3819 19.8065 28.6307V31.5751H15.1062C13.5087 31.5751 12.2136 30.2801 12.2136 28.6826V22.1744L14.8451 22.1744C14.7669 21.7644 14.8508 21.3259 15.1087 20.9637L15.1625 20.8923C15.3642 20.64 15.6366 20.4579 15.9408 20.3664L12.0329 20.3666H11.4905C10.6918 20.3666 10.0442 19.7191 10.0442 18.9203V16.0278C10.0442 15.2291 10.6918 14.5815 11.4905 14.5815H19.8065ZM20.4385 18.8781L19.4063 21.2551C19.3332 21.4233 19.1731 21.5371 18.9903 21.5507L16.3586 21.7472C16.2218 21.7574 16.0954 21.8241 16.0098 21.9312C15.8396 22.1441 15.8742 22.4547 16.0871 22.6249L16.9531 23.3172C17.3653 23.6467 17.8959 23.7903 18.4181 23.7135L20.7655 23.3685C20.8642 23.3539 20.961 23.4052 21.0044 23.4951C21.0592 23.6087 21.0115 23.7452 20.898 23.8L18.8773 24.7754C18.4277 24.9924 18.0952 25.3947 17.9668 25.877L17.6241 27.1635C17.5903 27.2906 17.6086 27.4259 17.6749 27.5395C17.8125 27.7749 18.1148 27.8541 18.3501 27.7166L20.6422 26.3771C20.796 26.2872 20.9864 26.2872 21.1402 26.3771L23.4512 27.7277C23.5629 27.7929 23.6957 27.8117 23.821 27.7801C24.0853 27.7134 24.2455 27.4451 24.1788 27.1808L23.563 24.7403C23.516 24.5542 23.5812 24.3577 23.7301 24.2366L25.7138 22.6234C25.8191 22.5378 25.8844 22.4126 25.8945 22.2773C25.9148 22.0054 25.7109 21.7686 25.4391 21.7483L22.7921 21.5507C22.6093 21.5371 22.4492 21.4233 22.3762 21.2551L21.3439 18.8781C21.2941 18.7634 21.2025 18.6718 21.0878 18.622C20.8378 18.5134 20.5471 18.6281 20.4385 18.8781Z" fill="white"/>
</g>
<defs>
<linearGradient id="paint0_linear_3714_1812" x1="21" y1="0" x2="21" y2="42" gradientUnits="userSpaceOnUse">
<stop stop-color="#5CAFFA"/>
<stop offset="1" stop-color="#408ACF"/>
</linearGradient>
<clipPath id="clip0_3714_1812">
<rect width="42" height="42" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@ -0,0 +1,21 @@
<svg width="42" height="42" viewBox="0 0 42 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2257_22597)">
<g clip-path="url(#clip1_2257_22597)">
<circle cx="21" cy="21" r="21" fill="url(#paint0_linear_2257_22597)"/>
<path d="M19.8104 14.6024H18.0718C17.186 14.6024 16.4983 14.378 16.0088 13.9292C15.5193 13.4803 15.2745 12.9533 15.2745 12.3482C15.2745 11.7561 15.4718 11.2924 15.8663 10.9571C16.2608 10.6218 16.7716 10.4541 17.3987 10.4541C18.0637 10.4541 18.6319 10.6874 19.1033 11.1541C19.5747 11.6208 19.8104 12.2469 19.8104 13.0324V14.6024H21.976V13.0324C21.976 12.2469 22.0625 11.6208 22.5343 11.1541C23.0061 10.6874 23.5777 10.4541 24.2491 10.4541C24.8691 10.4541 25.3761 10.6218 25.7702 10.9571C26.1642 11.2924 26.3613 11.7561 26.3613 12.3482C26.3613 12.9533 26.1167 13.4803 25.6276 13.9292C25.1384 14.378 24.4509 14.6024 23.5651 14.6024H21.976H27.3553C27.6828 14.2981 27.9407 13.9419 28.1289 13.5337C28.3172 13.1255 28.4113 12.6781 28.4113 12.1914C28.4113 11.4772 28.2318 10.8417 27.8729 10.285C27.5139 9.72829 27.0326 9.29283 26.4289 8.97863C25.8252 8.66443 25.1494 8.50732 24.4017 8.50732C23.5781 8.50732 22.8469 8.71733 22.2084 9.13733C21.5698 9.55734 21.1083 10.1514 20.824 10.9196C20.5397 10.1514 20.0764 9.55734 19.4341 9.13733C18.7918 8.71733 18.0589 8.50732 17.2354 8.50732C16.4948 8.50732 15.8209 8.66443 15.2136 8.97863C14.6063 9.29283 14.1231 9.72829 13.7641 10.285C13.4051 10.8417 13.2256 11.4772 13.2256 12.1914C13.2256 12.6781 13.3197 13.1255 13.508 13.5337C13.6962 13.9419 13.9542 14.2981 14.2818 14.6024H19.8104Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.8064 14.5815V17.6802C19.8064 17.7095 19.8136 17.7383 19.8271 17.7642C19.8735 17.8526 19.9828 17.8867 20.0712 17.8403L20.5552 17.5864C20.7656 17.476 21.0167 17.476 21.2271 17.5864L21.7111 17.8403C21.737 17.8539 21.7658 17.861 21.7951 17.861C21.8949 17.861 21.9758 17.7801 21.9758 17.6802V14.5815H30.2918C31.0906 14.5815 31.7381 15.2291 31.7381 16.0278V18.9203C31.7381 19.7191 31.0906 20.3666 30.2918 20.3666H29.5687L29.5689 20.3847C29.5104 20.3728 29.4499 20.3666 29.3879 20.3666L25.8529 20.366C26.5257 20.5657 26.9995 21.1976 26.9801 21.9201L26.9762 21.9965C26.9717 22.0565 26.9638 22.116 26.9526 22.1745L29.3879 22.1744C29.4499 22.1744 29.5104 22.1682 29.5689 22.1563L29.5687 28.6826C29.5687 30.2801 28.2737 31.5751 26.6762 31.5751H21.9758V28.6307C21.9758 28.4026 21.8683 28.1892 21.688 28.0533L21.6371 28.0182L21.0833 27.6707C20.9658 27.597 20.8165 27.597 20.699 27.6707L20.1452 28.0182C19.9344 28.1505 19.8064 28.3819 19.8064 28.6307V31.5751H15.1061C13.5086 31.5751 12.2136 30.2801 12.2136 28.6826V22.1744L14.8451 22.1744C14.7668 21.7644 14.8508 21.3259 15.1086 20.9637L15.1625 20.8923C15.3642 20.64 15.6365 20.4579 15.9408 20.3664L12.0328 20.3666H11.4905C10.6917 20.3666 10.0442 19.7191 10.0442 18.9203V16.0278C10.0442 15.2291 10.6917 14.5815 11.4905 14.5815H19.8064ZM20.4384 18.8781L19.4062 21.2551C19.3332 21.4233 19.173 21.5371 18.9902 21.5507L16.3585 21.7472C16.2217 21.7574 16.0954 21.8241 16.0097 21.9312C15.8395 22.1441 15.8741 22.4547 16.0871 22.6249L16.953 23.3172C17.3653 23.6467 17.8959 23.7903 18.418 23.7135L20.7654 23.3685C20.8642 23.3539 20.9609 23.4052 21.0043 23.4951C21.0591 23.6087 21.0115 23.7452 20.8979 23.8L18.8772 24.7754C18.4277 24.9924 18.0952 25.3947 17.9667 25.877L17.6241 27.1635C17.5902 27.2906 17.6085 27.4259 17.6749 27.5395C17.8124 27.7749 18.1147 27.8541 18.35 27.7166L20.6421 26.3771C20.796 26.2872 20.9863 26.2872 21.1402 26.3771L23.4512 27.7277C23.5628 27.7929 23.6956 27.8117 23.821 27.7801C24.0853 27.7134 24.2455 27.4451 24.1788 27.1808L23.5629 24.7403C23.516 24.5542 23.5812 24.3577 23.7301 24.2366L25.7137 22.6234C25.819 22.5378 25.8844 22.4126 25.8945 22.2773C25.9148 22.0054 25.7109 21.7686 25.439 21.7483L22.7921 21.5507C22.6093 21.5371 22.4491 21.4233 22.3761 21.2551L21.3439 18.8781C21.294 18.7634 21.2025 18.6718 21.0877 18.622C20.8377 18.5134 20.547 18.6281 20.4384 18.8781Z" fill="white"/>
</g>
</g>
<defs>
<linearGradient id="paint0_linear_2257_22597" x1="21" y1="0" x2="21" y2="42" gradientUnits="userSpaceOnUse">
<stop stop-color="#9AD164"/>
<stop offset="1" stop-color="#46BA43"/>
</linearGradient>
<clipPath id="clip0_2257_22597">
<rect width="42" height="42" fill="white"/>
</clipPath>
<clipPath id="clip1_2257_22597">
<rect width="42" height="42" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -0,0 +1,21 @@
<svg width="42" height="42" viewBox="0 0 42 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2257_22664)">
<g clip-path="url(#clip1_2257_22664)">
<circle cx="21" cy="21" r="21" fill="url(#paint0_linear_2257_22664)"/>
<path d="M19.8104 14.6024H18.0718C17.186 14.6024 16.4983 14.378 16.0088 13.9292C15.5193 13.4803 15.2745 12.9533 15.2745 12.3482C15.2745 11.7561 15.4718 11.2924 15.8663 10.9571C16.2608 10.6218 16.7716 10.4541 17.3987 10.4541C18.0637 10.4541 18.6319 10.6874 19.1033 11.1541C19.5747 11.6208 19.8104 12.2469 19.8104 13.0324V14.6024H21.976V13.0324C21.976 12.2469 22.0625 11.6208 22.5343 11.1541C23.0061 10.6874 23.5777 10.4541 24.2491 10.4541C24.8691 10.4541 25.3761 10.6218 25.7702 10.9571C26.1642 11.2924 26.3613 11.7561 26.3613 12.3482C26.3613 12.9533 26.1167 13.4803 25.6276 13.9292C25.1384 14.378 24.4509 14.6024 23.5651 14.6024H21.976H27.3553C27.6828 14.2981 27.9407 13.9419 28.1289 13.5337C28.3172 13.1255 28.4113 12.6781 28.4113 12.1914C28.4113 11.4772 28.2318 10.8417 27.8729 10.285C27.5139 9.72829 27.0326 9.29283 26.4289 8.97863C25.8252 8.66443 25.1494 8.50732 24.4017 8.50732C23.5781 8.50732 22.8469 8.71733 22.2084 9.13733C21.5698 9.55734 21.1083 10.1514 20.824 10.9196C20.5397 10.1514 20.0764 9.55734 19.4341 9.13733C18.7918 8.71733 18.0589 8.50732 17.2354 8.50732C16.4948 8.50732 15.8209 8.66443 15.2136 8.97863C14.6063 9.29283 14.1231 9.72829 13.7641 10.285C13.4051 10.8417 13.2256 11.4772 13.2256 12.1914C13.2256 12.6781 13.3197 13.1255 13.508 13.5337C13.6962 13.9419 13.9542 14.2981 14.2818 14.6024H19.8104Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.8064 14.5815V17.6802C19.8064 17.7095 19.8136 17.7383 19.8271 17.7642C19.8735 17.8526 19.9828 17.8867 20.0712 17.8403L20.5552 17.5864C20.7656 17.476 21.0167 17.476 21.2271 17.5864L21.7111 17.8403C21.737 17.8539 21.7658 17.861 21.7951 17.861C21.8949 17.861 21.9758 17.7801 21.9758 17.6802V14.5815H30.2918C31.0906 14.5815 31.7381 15.2291 31.7381 16.0278V18.9203C31.7381 19.7191 31.0906 20.3666 30.2918 20.3666H29.5687L29.5689 20.3847C29.5104 20.3728 29.4499 20.3666 29.3879 20.3666L25.8529 20.366C26.5257 20.5657 26.9995 21.1976 26.9801 21.9201L26.9762 21.9965C26.9717 22.0565 26.9638 22.116 26.9526 22.1745L29.3879 22.1744C29.4499 22.1744 29.5104 22.1682 29.5689 22.1563L29.5687 28.6826C29.5687 30.2801 28.2737 31.5751 26.6762 31.5751H21.9758V28.6307C21.9758 28.4026 21.8683 28.1892 21.688 28.0533L21.6371 28.0182L21.0833 27.6707C20.9658 27.597 20.8165 27.597 20.699 27.6707L20.1452 28.0182C19.9344 28.1505 19.8064 28.3819 19.8064 28.6307V31.5751H15.1061C13.5086 31.5751 12.2136 30.2801 12.2136 28.6826V22.1744L14.8451 22.1744C14.7668 21.7644 14.8508 21.3259 15.1086 20.9637L15.1625 20.8923C15.3642 20.64 15.6365 20.4579 15.9408 20.3664L12.0328 20.3666H11.4905C10.6917 20.3666 10.0442 19.7191 10.0442 18.9203V16.0278C10.0442 15.2291 10.6917 14.5815 11.4905 14.5815H19.8064ZM20.4384 18.8781L19.4062 21.2551C19.3332 21.4233 19.173 21.5371 18.9902 21.5507L16.3585 21.7472C16.2217 21.7574 16.0954 21.8241 16.0097 21.9312C15.8395 22.1441 15.8741 22.4547 16.0871 22.6249L16.953 23.3172C17.3653 23.6467 17.8959 23.7903 18.418 23.7135L20.7654 23.3685C20.8642 23.3539 20.9609 23.4052 21.0043 23.4951C21.0591 23.6087 21.0115 23.7452 20.8979 23.8L18.8772 24.7754C18.4277 24.9924 18.0952 25.3947 17.9667 25.877L17.6241 27.1635C17.5902 27.2906 17.6085 27.4259 17.6749 27.5395C17.8124 27.7749 18.1147 27.8541 18.35 27.7166L20.6421 26.3771C20.796 26.2872 20.9863 26.2872 21.1402 26.3771L23.4512 27.7277C23.5628 27.7929 23.6956 27.8117 23.821 27.7801C24.0853 27.7134 24.2455 27.4451 24.1788 27.1808L23.5629 24.7403C23.516 24.5542 23.5812 24.3577 23.7301 24.2366L25.7137 22.6234C25.819 22.5378 25.8844 22.4126 25.8945 22.2773C25.9148 22.0054 25.7109 21.7686 25.439 21.7483L22.7921 21.5507C22.6093 21.5371 22.4491 21.4233 22.3761 21.2551L21.3439 18.8781C21.294 18.7634 21.2025 18.6718 21.0877 18.622C20.8377 18.5134 20.547 18.6281 20.4384 18.8781Z" fill="white"/>
</g>
</g>
<defs>
<linearGradient id="paint0_linear_2257_22664" x1="21" y1="0" x2="21" y2="42" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF845E"/>
<stop offset="1" stop-color="#D45246"/>
</linearGradient>
<clipPath id="clip0_2257_22664">
<rect width="42" height="42" fill="white"/>
</clipPath>
<clipPath id="clip1_2257_22664">
<rect width="42" height="42" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -0,0 +1,15 @@
<svg width="42" height="42" viewBox="0 0 42 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3714_1830)">
<circle cx="21" cy="21" r="21" fill="url(#paint0_linear_3714_1830)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.5858 18.3749C19.6155 18.3749 21.2608 16.7295 21.2608 14.6999C21.2608 12.6703 19.6155 11.0249 17.5858 11.0249C15.5562 11.0249 13.9108 12.6703 13.9108 14.6999C13.9108 16.7295 15.5562 18.3749 17.5858 18.3749ZM25.9877 18.3749C27.7274 18.3749 29.1377 16.9646 29.1377 15.2249C29.1377 13.4852 27.7274 12.0749 25.9877 12.0749C24.248 12.0749 22.8377 13.4852 22.8377 15.2249C22.8377 16.9646 24.248 18.3749 25.9877 18.3749ZM22.3466 22.0789C23.8955 22.9225 24.8683 24.1514 25.4793 25.3623C25.8218 26.0412 25.8486 26.709 25.648 27.2999C25.2367 28.5114 23.87 29.3999 22.3108 29.3999H12.8608C10.5412 29.3999 8.6475 27.4332 9.69238 25.3623C10.7785 23.2096 13.0083 20.9999 17.5858 20.9999C19.4037 20.9999 20.8512 21.3484 22.004 21.9034C22.0078 21.9052 22.0116 21.9071 22.0154 21.9089C22.1286 21.9636 22.239 22.0203 22.3466 22.0789ZM23.6023 21.2025C25.116 22.1853 26.0961 23.4846 26.7261 24.7332C27.1679 25.6088 27.2607 26.4923 27.0924 27.2999H29.6615C31.4012 27.2999 32.8196 25.8279 32.0503 24.2676C31.2301 22.6041 29.5262 20.8763 25.9865 20.8763C25.0693 20.8763 24.2753 20.9924 23.588 21.1933L23.6023 21.2025Z" fill="white"/>
</g>
<defs>
<linearGradient id="paint0_linear_3714_1830" x1="21" y1="0" x2="21" y2="42" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF8AAC"/>
<stop offset="1" stop-color="#D95574"/>
</linearGradient>
<clipPath id="clip0_3714_1830">
<rect width="42" height="42" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -17,6 +17,8 @@ export { default as AttachBotInstallModal } from '../components/modals/attachBot
export { default as DeleteFolderDialog } from '../components/main/DeleteFolderDialog';
export { default as PremiumMainModal } from '../components/main/premium/PremiumMainModal';
export { default as GiftPremiumModal } from '../components/main/premium/GiftPremiumModal';
export { default as GiveawayModal } from '../components/main/premium/GiveawayModal';
export { default as AppendEntityPickerModal } from '../components/main/AppendEntityPickerModal';
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';
@ -31,6 +33,7 @@ export { default as UnpinAllMessagesModal } from '../components/common/UnpinAllM
export { default as MessageSelectToolbar } from '../components/middle/MessageSelectToolbar';
export { default as SeenByModal } from '../components/common/SeenByModal';
export { default as PrivacySettingsNoticeModal } from '../components/common/PrivacySettingsNoticeModal';
export { default as CountryPickerModal } from '../components/common/CountryPickerModal';
export { default as ReactorListModal } from '../components/middle/ReactorListModal';
export { default as EmojiInteractionAnimation } from '../components/middle/EmojiInteractionAnimation';
export { default as ChatLanguageModal } from '../components/middle/ChatLanguageModal';

View File

@ -74,7 +74,7 @@ const CalendarModal: FC<OwnProps> = ({
const passedSelectedDate = useMemo(() => (selectedAt ? new Date(selectedAt) : new Date()), [selectedAt]);
const prevIsOpen = usePrevious(isOpen);
const [isTimeInputFocused, markTimeInputAsFocused, unmarkTimeInputAsFocused] = useFlag(false);
const [isTimeInputFocused, markTimeInputAsFocused] = useFlag(false);
const [selectedDate, setSelectedDate] = useState<Date>(passedSelectedDate);
const [currentMonthAndYear, setCurrentMonthAndYear] = useState<Date>(
@ -91,6 +91,9 @@ const CalendarModal: FC<OwnProps> = ({
const currentYear = currentMonthAndYear.getFullYear();
const currentMonth = currentMonthAndYear.getMonth();
const isDisabled = (isFutureMode && selectedDate.getTime() < minDate.getTime())
|| (isPastMode && selectedDate.getTime() > maxDate.getTime());
useEffect(() => {
if (!prevIsOpen && isOpen) {
setSelectedDate(passedSelectedDate);
@ -169,8 +172,14 @@ const CalendarModal: FC<OwnProps> = ({
}
const handleSubmit = useCallback(() => {
onSubmit(selectedDate);
}, [onSubmit, selectedDate]);
if (isFutureMode && selectedDate < minDate) {
onSubmit(minDate);
} else if (isPastMode && selectedDate > maxDate) {
onSubmit(maxDate);
} else {
onSubmit(selectedDate);
}
}, [isFutureMode, isPastMode, minDate, maxDate, onSubmit, selectedDate]);
const handleChangeHours = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replace(/[^\d]+/g, '');
@ -220,7 +229,6 @@ const CalendarModal: FC<OwnProps> = ({
value={selectedHours}
onChange={handleChangeHours}
onFocus={markTimeInputAsFocused}
onBlur={unmarkTimeInputAsFocused}
/>
:
<input
@ -230,7 +238,6 @@ const CalendarModal: FC<OwnProps> = ({
value={selectedMinutes}
onChange={handleChangeMinutes}
onFocus={markTimeInputAsFocused}
onBlur={unmarkTimeInputAsFocused}
/>
</div>
);
@ -322,14 +329,19 @@ const CalendarModal: FC<OwnProps> = ({
{withTimePicker && renderTimePicker()}
<div className="footer">
<Button onClick={handleSubmit}>
{submitLabel}
</Button>
{secondButtonLabel && (
<Button onClick={onSecondButtonClick} isText>
{secondButtonLabel}
<div className="footer">
<Button
onClick={handleSubmit}
disabled={isDisabled}
>
{submitLabel}
</Button>
)}
{secondButtonLabel && (
<Button onClick={onSecondButtonClick} isText>
{secondButtonLabel}
</Button>
)}
</div>
</div>
</Modal>
);

View File

@ -0,0 +1,18 @@
import type { FC } from '../../lib/teact/teact';
import React from '../../lib/teact/teact';
import type { OwnProps } from './CountryPickerModal';
import { Bundles } from '../../util/moduleLoader';
import useModuleLoader from '../../hooks/useModuleLoader';
const CountryPickerModalAsync: FC<OwnProps> = (props) => {
const { isOpen } = props;
const CountryPickerModal = useModuleLoader(Bundles.Extra, 'CountryPickerModal', !isOpen);
// eslint-disable-next-line react/jsx-props-no-spreading,react/jsx-no-undef
return CountryPickerModal ? <CountryPickerModal {...props} /> : undefined;
};
export default CountryPickerModalAsync;

View File

@ -0,0 +1,29 @@
.root :global(.modal-dialog) {
max-width: 55vh;
}
.pickerSelector {
display: flex;
align-items: center;
}
.pickerTitle {
flex: 1;
margin: 0 0 0 1.25rem;
font-size: 1.25rem;
@media (max-width: 600px) {
margin-left: 0.75rem;
}
}
.picker {
height: 70vh;
}
.footer {
display: flex;
justify-content: flex-end;
flex-direction: column;
}

View File

@ -0,0 +1,118 @@
import type { FC } from '../../lib/teact/teact';
import React, {
memo, useMemo, useState,
} from '../../lib/teact/teact';
import { getActions } from '../../global';
import type { ApiCountry } from '../../api/types';
import buildClassName from '../../util/buildClassName';
import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback';
import usePrevious from '../../hooks/usePrevious';
import Button from '../ui/Button';
import Modal from '../ui/Modal';
import Icon from './Icon';
import Picker from './Picker';
import styles from './CountryPickerModal.module.scss';
export type OwnProps = {
isOpen: boolean;
onClose: () => void;
onSubmit: (value: string[]) => void;
countryList: ApiCountry[];
selectionLimit?: number | undefined;
};
const CountryPickerModal: FC<OwnProps> = ({
isOpen,
onClose,
onSubmit,
countryList,
selectionLimit,
}) => {
const { showNotification } = getActions();
const lang = useLang();
const [selectedCountryIds, setSelectedCountryIds] = useState<string[]>([]);
const prevSelectedCountryIds = usePrevious(selectedCountryIds);
const noPickerScrollRestore = prevSelectedCountryIds === selectedCountryIds;
const displayedIds = useMemo(() => {
if (!countryList) {
return [];
}
return countryList
.filter((country) => !country.isHidden)
.map((country) => country.iso2);
}, [countryList]);
const handleSelectedIdsChange = useLastCallback((newSelectedIds: string[]) => {
if (selectionLimit && newSelectedIds.length > selectionLimit) {
showNotification({
message: lang('BoostingSelectUpToWarningCountries', selectionLimit),
});
return;
}
setSelectedCountryIds(newSelectedIds);
});
const handleSubmit = useLastCallback(() => {
onSubmit(selectedCountryIds);
onClose();
});
return (
<Modal
className={styles.root}
isOpen={isOpen}
onClose={onClose}
onEnter={handleSubmit}
>
<div className={styles.container}>
<div className={styles.pickerSelector}>
<Button
round
size="smaller"
color="translucent"
onClick={onClose}
>
<Icon name="close" />
</Button>
<h4 className={styles.pickerTitle}>
{lang('BoostingSelectCountry')}
</h4>
</div>
</div>
<div className={buildClassName(styles.main, 'custom-scroll')}>
<Picker
className={styles.picker}
itemIds={displayedIds}
selectedIds={selectedCountryIds}
onSelectedIdsChange={handleSelectedIdsChange}
noScrollRestore={noPickerScrollRestore}
isCountryList
countryList={countryList}
/>
</div>
<div className={styles.footer}>
<Button
size="smaller"
onClick={handleSubmit}
>
{lang('SelectCountries.OK')}
</Button>
</div>
</Modal>
);
};
export default memo(CountryPickerModal);

View File

@ -3,9 +3,12 @@ import React, {
memo, useEffect, useMemo, useRef,
} from '../../lib/teact/teact';
import type { ApiCountry } from '../../api/types';
import { requestMeasure } from '../../lib/fasterdom/fasterdom';
import { isUserId } from '../../global/helpers';
import buildClassName from '../../util/buildClassName';
import { buildCollectionByKey } from '../../util/iteratees';
import { MEMO_EMPTY_ARRAY } from '../../util/memo';
import useInfiniteScroll from '../../hooks/useInfiniteScroll';
@ -42,6 +45,8 @@ type OwnProps = {
onFilterChange?: (value: string) => void;
onDisabledClick?: (id: string) => void;
onLoadMore?: () => void;
isCountryList?: boolean;
countryList?: ApiCountry[];
};
// Focus slows down animation, also it breaks transition layout in Chrome
@ -69,6 +74,8 @@ const Picker: FC<OwnProps> = ({
onFilterChange,
onDisabledClick,
onLoadMore,
isCountryList,
countryList,
}) => {
// eslint-disable-next-line no-null/no-null
const inputRef = useRef<HTMLInputElement>(null);
@ -92,17 +99,18 @@ const Picker: FC<OwnProps> = ({
const lockedIdsSet = useMemo(() => new Set(lockedIds), [lockedIds]);
const sortedItemIds = useMemo(() => {
return itemIds.sort((a, b) => {
const aIsLocked = lockedIdsSet.has(a);
const bIsLocked = lockedIdsSet.has(b);
if (aIsLocked && !bIsLocked) {
return -1;
const lockedBucket: string[] = [];
const unlockedBucket: string[] = [];
itemIds.forEach((id) => {
if (lockedIdsSet.has(id)) {
lockedBucket.push(id);
} else {
unlockedBucket.push(id);
}
if (!aIsLocked && bIsLocked) {
return 1;
}
return 0;
});
return lockedBucket.concat(unlockedBucket);
}, [itemIds, lockedIdsSet]);
const handleItemClick = useLastCallback((id: string) => {
@ -130,6 +138,22 @@ const Picker: FC<OwnProps> = ({
const lang = useLang();
const countriesByIso = useMemo(() => {
if (!countryList) return undefined;
return buildCollectionByKey(countryList, 'iso2');
}, [countryList]);
const renderChatInfo = (id: string) => {
if (isCountryList && countriesByIso) {
const country = countriesByIso[id];
return <div>{country.defaultName}</div>;
} else if (isUserId(id)) {
return <PrivateChatInfo forceShowSelf={forceShowSelf} userId={id} />;
} else {
return <GroupChatInfo chatId={id} />;
}
};
return (
<div className={buildClassName('Picker', className)}>
{isSearchable && (
@ -194,11 +218,7 @@ const Picker: FC<OwnProps> = ({
ripple
>
{!isRoundCheckbox ? renderCheckbox() : undefined}
{isUserId(id) ? (
<PrivateChatInfo forceShowSelf={forceShowSelf} userId={id} />
) : (
<GroupChatInfo chatId={id} />
)}
{renderChatInfo(id)}
{isRoundCheckbox ? renderCheckbox() : undefined}
</ListItem>
);

View File

@ -2,7 +2,7 @@ import type { ApiBoostsStatus } from '../../../api/types';
export function getBoostProgressInfo(boostInfo: ApiBoostsStatus, freezeOnLevelUp?: boolean) {
const {
level, boosts, currentLevelBoosts, nextLevelBoosts, hasMyBoost,
level, boosts, currentLevelBoosts, nextLevelBoosts, hasMyBoost, prepaidGiveaways,
} = boostInfo;
const isJustUpgraded = freezeOnLevelUp && boosts === currentLevelBoosts && hasMyBoost;
@ -23,5 +23,6 @@ export function getBoostProgressInfo(boostInfo: ApiBoostsStatus, freezeOnLevelUp
levelProgress,
remainingBoosts,
isMaxLevel,
prepaidGiveaways,
};
}

View File

@ -0,0 +1,18 @@
import type { FC } from '../../lib/teact/teact';
import React from '../../lib/teact/teact';
import type { OwnProps } from './AppendEntityPickerModal';
import { Bundles } from '../../util/moduleLoader';
import useModuleLoader from '../../hooks/useModuleLoader';
const AppendEntityPickerModalAsync: FC<OwnProps> = (props) => {
const { isOpen } = props;
const AppendEntityPickerModal = useModuleLoader(Bundles.Extra, 'AppendEntityPickerModal', !isOpen);
// eslint-disable-next-line react/jsx-props-no-spreading
return AppendEntityPickerModal ? <AppendEntityPickerModal {...props} /> : undefined;
};
export default AppendEntityPickerModalAsync;

View File

@ -0,0 +1,46 @@
.root :global(.modal-content) {
padding: 0;
}
.root :global(.modal-dialog) {
max-width: 55vh;
}
.root :global(.modal-dialog), .root :global(.modal-content) {
overflow: hidden;
}
.main {
height: 90vh;
}
.filter {
padding: 0.375rem 1rem 0.25rem 0.75rem;
margin-bottom: 0.625rem;
background-color: var(--color-background);
box-shadow: inset 0 -0.0625rem 0 0 var(--color-background-secondary-accent);
border-bottom: 0.625rem solid var(--color-background-secondary);
display: flex;
flex-flow: row wrap;
align-items: center;
flex-shrink: 0;
overflow-y: auto;
max-height: 20rem;
}
.title {
margin: 0;
}
.buttons {
width: 100%;
background: var(--color-background);
position: absolute;
bottom: 0;
z-index: 1;
padding: 0.75rem;
}
.picker {
height: 70vh;
}

View File

@ -0,0 +1,277 @@
import type { FC } from '../../lib/teact/teact';
import React, {
memo,
useMemo,
useState,
} from '../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../global';
import type { ApiChat, ApiChatMember, ApiUserStatus } from '../../api/types';
import {
filterChatsByName,
filterUsersByName, isChatChannel, isChatPublic, isUserBot, sortUserIds,
} from '../../global/helpers';
import { selectChat, selectChatFullInfo } from '../../global/selectors';
import buildClassName from '../../util/buildClassName';
import { unique } from '../../util/iteratees';
import sortChatIds from '../common/helpers/sortChatIds';
import useFlag from '../../hooks/useFlag';
import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback';
import Icon from '../common/Icon';
import Picker from '../common/Picker';
import Button from '../ui/Button';
import ConfirmDialog from '../ui/ConfirmDialog';
import Modal from '../ui/Modal';
import styles from './AppendEntityPicker.module.scss';
export type OwnProps = {
isOpen?: boolean;
onClose: () => void;
chatId?: string;
entityType: 'members' | 'channels' | undefined;
onSubmit: (value: string[]) => void;
selectionLimit: number;
};
interface StateProps {
chatId?: string;
members?: ApiChatMember[];
adminMembersById?: Record<string, ApiChatMember>;
userStatusesById: Record<string, ApiUserStatus>;
channelList?: (ApiChat | undefined)[] | undefined;
isChannel?: boolean;
currentUserId?: string | undefined;
}
const AppendEntityPickerModal: FC<OwnProps & StateProps> = ({
chatId,
isOpen,
onClose,
members,
adminMembersById,
userStatusesById,
entityType,
isChannel,
onSubmit,
currentUserId,
selectionLimit,
}) => {
const { showNotification } = getActions();
const lang = useLang();
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useFlag();
const [selectedChannelIds, setSelectedChannelIds] = useState<string[]>([]);
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>([]);
const [pendingChannelId, setPendingChannelId] = useState<string | undefined>(undefined);
const [searchQuery, setSearchQuery] = useState<string>('');
const channelsIds = useMemo(() => {
const chatsById = getGlobal().chats.byId;
const activeChatIds = getGlobal().chats.listIds.active;
return activeChatIds!.map((id) => chatsById[id])
.filter((chat) => chat && isChatChannel(chat) && chat.id !== chatId)
.map((chat) => chat!.id);
}, [chatId]);
const adminIds = useMemo(() => {
return adminMembersById && Object.keys(adminMembersById);
}, [adminMembersById]);
const memberIds = useMemo(() => {
const usersById = getGlobal().users.byId;
if (!members || !usersById) {
return [];
}
const userIds = sortUserIds(
members.map(({ userId }) => userId),
usersById,
userStatusesById,
);
return adminIds ? userIds.filter((userId) => userId !== currentUserId) : userIds;
}, [adminIds, currentUserId, members, userStatusesById]);
const displayedMembersIds = useMemo(() => {
const usersById = getGlobal().users.byId;
const filteredContactIds = memberIds ? filterUsersByName(memberIds, usersById, searchQuery) : [];
return sortChatIds(unique(filteredContactIds).filter((userId) => {
const user = usersById[userId];
if (!user) {
return true;
}
return !isUserBot(user);
}));
}, [memberIds, searchQuery]);
const displayedChannelIds = useMemo(() => {
const chatsById = getGlobal().chats.byId;
const foundChannelIds = channelsIds ? filterChatsByName(lang, channelsIds, chatsById, searchQuery) : [];
return sortChatIds(unique(foundChannelIds).filter((contactId) => {
const chat = chatsById[contactId];
if (!chat) {
return true;
}
return isChannel;
}),
false,
selectedChannelIds);
}, [channelsIds, lang, searchQuery, selectedChannelIds, isChannel]);
const handleCloseButtonClick = useLastCallback(() => {
onSubmit([]);
onClose();
});
const handleSendIdList = useLastCallback(() => {
onSubmit(entityType === 'members' ? selectedMemberIds : selectedChannelIds);
onClose();
});
const confirmPrivateLinkChannelSelection = useLastCallback(() => {
if (pendingChannelId) {
setSelectedChannelIds((prevIds) => unique([...prevIds, pendingChannelId]));
}
closeConfirmModal();
});
const handleSelectedMembersChange = useLastCallback((newSelectedIds: string[]) => {
if (newSelectedIds.length > selectionLimit) {
showNotification({
message: lang('BoostingSelectUpToWarningUsers', selectionLimit),
});
return;
}
setSelectedMemberIds(newSelectedIds);
});
const handleSelectedChannelIdsChange = useLastCallback((newSelectedIds: string[]) => {
const chatsById = getGlobal().chats.byId;
const newlyAddedIds = newSelectedIds.filter((id) => !selectedChannelIds.includes(id));
const privateLinkChannelId = newlyAddedIds.find((id) => {
const chat = chatsById[id];
return chat && !isChatPublic(chat);
});
if (selectedChannelIds?.length >= selectionLimit) {
showNotification({
message: lang('BoostingSelectUpToWarningChannelsPlural', selectionLimit),
});
return;
}
if (privateLinkChannelId) {
setPendingChannelId(privateLinkChannelId);
openConfirmModal();
} else {
setSelectedChannelIds(newSelectedIds);
}
});
const handleClose = useLastCallback(() => {
onSubmit([]);
onClose();
});
function renderSearchField() {
return (
<div className={styles.filter} dir={lang.isRtl ? 'rtl' : undefined}>
<Button
round
size="smaller"
color="translucent"
// eslint-disable-next-line react/jsx-no-bind
onClick={handleCloseButtonClick}
ariaLabel={lang('Close')}
>
<Icon name="close" />
</Button>
<h3 className={styles.title}>{lang(entityType === 'channels'
? 'RequestPeer.ChooseChannelTitle' : 'BoostingAwardSpecificUsers')}
</h3>
</div>
);
}
return (
<Modal
className={styles.root}
isOpen={isOpen}
onClose={handleClose}
onEnter={handleSendIdList}
>
<div className={styles.main}>
{renderSearchField()}
<div className={buildClassName(styles.main, 'custom-scroll')}>
<Picker
className={styles.picker}
itemIds={entityType === 'members' ? displayedMembersIds : displayedChannelIds}
selectedIds={entityType === 'members' ? selectedMemberIds : selectedChannelIds}
filterValue={searchQuery}
filterPlaceholder={lang('Search')}
searchInputId="new-members-picker-search"
onSelectedIdsChange={entityType === 'channels'
? handleSelectedChannelIdsChange : handleSelectedMembersChange}
onFilterChange={setSearchQuery}
isSearchable
/>
</div>
<div className={styles.buttons}>
<Button size="smaller" onClick={handleSendIdList}>
{lang('Save')}
</Button>
</div>
</div>
<ConfirmDialog
title={lang('BoostingGiveawayPrivateChannel')}
text={lang('BoostingGiveawayPrivateChannelWarning')}
confirmLabel={lang('Add')}
isOpen={isConfirmModalOpen}
onClose={closeConfirmModal}
confirmHandler={confirmPrivateLinkChannelSelection}
/>
</Modal>
);
};
export default memo(withGlobal<OwnProps>((global, { chatId, entityType }): StateProps => {
const { statusesById: userStatusesById } = global.users;
let isChannel;
let members: ApiChatMember[] | undefined;
let adminMembersById: Record<string, ApiChatMember> | undefined;
let currentUserId: string | undefined;
if (entityType === 'members') {
currentUserId = global.currentUserId;
const chatFullInfo = chatId ? selectChatFullInfo(global, chatId) : undefined;
if (chatFullInfo) {
members = chatFullInfo.members;
adminMembersById = chatFullInfo.adminMembersById;
}
} else if (entityType === 'channels') {
const chat = chatId ? selectChat(global, chatId) : undefined;
if (chat) {
isChannel = isChatChannel(chat);
}
}
return {
chatId,
members,
adminMembersById,
userStatusesById,
isChannel,
currentUserId,
};
})(AppendEntityPickerModal));

View File

@ -101,6 +101,7 @@ import InviteViaLinkModal from './InviteViaLinkModal.async';
import NewContactModal from './NewContactModal.async';
import Notifications from './Notifications.async';
import PremiumLimitReachedModal from './premium/common/PremiumLimitReachedModal.async';
import GiveawayModal from './premium/GiveawayModal.async';
import PremiumMainModal from './premium/PremiumMainModal.async';
import SafeLinkModal from './SafeLinkModal.async';
@ -156,6 +157,8 @@ type StateProps = {
isPaymentModalOpen?: boolean;
isReceiptModalOpen?: boolean;
isReactionPickerOpen: boolean;
isAppendModalOpen?: boolean;
isGiveawayModalOpen?: boolean;
isCurrentUserPremium?: boolean;
chatlistModal?: TabState['chatlistModal'];
boostModal?: TabState['boostModal'];
@ -214,6 +217,7 @@ const Main: FC<OwnProps & StateProps> = ({
currentUserName,
urlAuth,
isPremiumModalOpen,
isGiveawayModalOpen,
isPaymentModalOpen,
isReceiptModalOpen,
isReactionPickerOpen,
@ -595,7 +599,8 @@ const Main: FC<OwnProps & StateProps> = ({
<AttachBotInstallModal bot={attachBotToInstall} />
<AttachBotRecipientPicker requestedAttachBotInChat={requestedAttachBotInChat} />
<MessageListHistoryHandler />
<PremiumMainModal isOpen={isPremiumModalOpen} />
{isPremiumModalOpen && <PremiumMainModal isOpen={isPremiumModalOpen} />}
{isGiveawayModalOpen && <GiveawayModal isOpen={isGiveawayModalOpen} />}
<PremiumLimitReachedModal limit={limitReached} />
<PaymentModal isOpen={isPaymentModalOpen} onClose={closePaymentModal} />
<ReceiptModal isOpen={isReceiptModalOpen} onClose={clearReceipt} />
@ -638,6 +643,7 @@ export default memo(withGlobal<OwnProps>(
newContact,
ratingPhoneCall,
premiumModal,
giveawayModal,
isMasterTab,
payment,
limitReachedModal,
@ -703,6 +709,7 @@ export default memo(withGlobal<OwnProps>(
urlAuth,
isCurrentUserPremium: selectIsCurrentUserPremium(global),
isPremiumModalOpen: premiumModal?.isOpen,
isGiveawayModalOpen: giveawayModal?.isOpen,
limitReached: limitReachedModal?.limit,
isPaymentModalOpen: payment.isPaymentModalOpen,
isReceiptModalOpen: Boolean(payment.receipt),

View File

@ -15,7 +15,6 @@ import {
import { formatCurrency } from '../../../util/formatCurrency';
import renderText from '../../common/helpers/renderText';
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
import useLang from '../../../hooks/useLang';
import Avatar from '../../common/Avatar';
@ -45,31 +44,28 @@ const GiftPremiumModal: FC<OwnProps & StateProps> = ({
const { openPremiumModal, closeGiftPremiumModal, openUrl } = getActions();
const lang = useLang();
const renderedUser = useCurrentOrPrev(user, true);
const renderedGifts = useCurrentOrPrev(gifts, true);
const [selectedOption, setSelectedOption] = useState<number | undefined>();
const firstGift = renderedGifts?.[0];
const fullMonthlyAmount = useMemo(() => {
if (!renderedGifts || renderedGifts.length === 0 || !firstGift) {
if (!gifts?.length) {
return undefined;
}
const basicGift = renderedGifts.reduce((acc, gift) => {
return gift.months < firstGift.months ? gift : firstGift;
}, firstGift);
const basicGift = gifts.reduce((acc, gift) => {
return gift.months < acc.months ? gift : acc;
});
return Math.floor(basicGift.amount / basicGift.months);
}, [firstGift, renderedGifts]);
}, [gifts]);
useEffect(() => {
if (isOpen) {
setSelectedOption(firstGift?.months);
if (isOpen && gifts?.length) {
setSelectedOption(gifts[0].months);
}
}, [firstGift?.months, isOpen]);
}, [gifts, isOpen]);
const selectedGift = useMemo(() => {
return renderedGifts?.find((gift) => gift.months === selectedOption);
}, [renderedGifts, selectedOption]);
return gifts?.find((gift) => gift.months === selectedOption);
}, [gifts, selectedOption]);
const handleSubmit = useCallback(() => {
if (!selectedGift) {
@ -121,7 +117,7 @@ const GiftPremiumModal: FC<OwnProps & StateProps> = ({
<i className="icon icon-close" />
</Button>
<Avatar
peer={renderedUser}
peer={user}
size="jumbo"
className={styles.avatar}
/>
@ -130,13 +126,13 @@ const GiftPremiumModal: FC<OwnProps & StateProps> = ({
</h2>
<p className={styles.description}>
{renderText(
lang('GiftTelegramPremiumDescription', getUserFirstOrLastName(renderedUser)),
lang('GiftTelegramPremiumDescription', getUserFirstOrLastName(user)),
['emoji', 'simple_markdown'],
)}
</p>
<div className={styles.options}>
{renderedGifts?.map((gift) => (
{gifts?.map((gift) => (
<PremiumSubscriptionOption
key={gift.amount}
option={gift}

View File

@ -0,0 +1,18 @@
import type { FC } from '../../../lib/teact/teact';
import React from '../../../lib/teact/teact';
import type { OwnProps } from './GiveawayModal';
import { Bundles } from '../../../util/moduleLoader';
import useModuleLoader from '../../../hooks/useModuleLoader';
const GiveawayModalAsync: FC<OwnProps> = (props) => {
const { isOpen } = props;
const GiveawayModal = useModuleLoader(Bundles.Extra, 'GiveawayModal', !isOpen);
// eslint-disable-next-line react/jsx-props-no-spreading
return GiveawayModal ? <GiveawayModal {...props} /> : undefined;
};
export default GiveawayModalAsync;

View File

@ -0,0 +1,280 @@
@use '../../../styles/mixins';
.root {
--premium-gradient: linear-gradient(88.39deg, #6C93FF -2.56%, #976FFF 51.27%, #DF69D1 107.39%);
--premium-feature-background: linear-gradient(65.85deg, #6C93FF -0.24%, #976FFF 53.99%, #DF69D1 110.53%);
user-select: none;
}
.root :global(.modal-content) {
padding: 0;
}
.root :global(.modal-dialog) {
height: min(calc(55vh + 41px + 193px), 90vh);
max-width: 26.25rem;
overflow: hidden;
}
.button {
font-weight: 500;
font-size: 1rem;
height: 3rem;
}
.main {
height: 100%;
overflow-y: scroll;
display: flex;
flex-direction: column;
align-items: center;
}
.logo {
margin: 1rem;
width: 6.25rem;
height: 6.25rem;
min-height: 6.25rem;
}
.header-text {
font-size: 1.5rem;
font-weight: 500;
text-align: center;
margin-inline: 0.5rem;
}
.description {
text-align: center;
margin-inline: 2.5rem;
margin-bottom: 2rem;
}
.header {
z-index: 2;
display: flex;
align-items: center;
border-bottom: 0.0625rem solid var(--color-borders);
position: absolute;
width: 100%;
left: 0;
top: 0;
height: 3.5rem;
padding: 0.5rem;
background: var(--color-background);
transition: 0.25s ease-out transform;
}
.hidden-header {
transform: translateY(-100%);
}
.close-button {
position: absolute;
top: 0.5rem;
left: 0.5rem;
z-index: 3;
}
.premium-header-text {
font-size: 1.25rem;
font-weight: 500;
margin: 0 0 0 3rem;
unicode-bidi: plaintext;
}
.primary-footer-text {
color: var(--color-text);
}
.section {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0 0.375rem;
border-top: 0.0625rem solid var(--color-borders);
}
.types {
border-top: none;
}
.footer {
position: absolute;
width: 100%;
background: var(--color-background);
border-top: 0.0625rem solid var(--color-borders);
left: 0;
bottom: 0;
padding: 1rem;
z-index: 1;
}
.options {
width: 100%;
}
.giveawayTitle {
margin: 0.8125rem 0 0 1rem;
font-size: 1rem;
color: var(--color-links);
@include mixins.adapt-margin-to-scrollbar(0.8125rem);
}
.subscription {
margin: 1rem 1.625rem 1.3125rem 1.5rem;
font-size: 1rem;
font-weight: 400;
color: var(--color-text-secondary);
@include mixins.adapt-margin-to-scrollbar(1rem);
}
.subscriptionOption {
margin-bottom: 0;
padding-inline: 3.5rem 1rem;
box-shadow: none;
}
.status {
display: flex;
align-items: center;
gap: 1rem;
justify-content: space-between;
width: 100%;
padding: 0 0.375rem 0 1.125rem;
margin-bottom: 1.3125rem;
}
.info {
flex-grow: 1;
}
.titleInfo {
margin: 0;
}
.month {
color: var(--color-text-secondary);
margin: 0;
}
.quantity {
display: flex;
justify-content: space-between;
align-items: center;
}
.floatingBadge {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-links);
background-color: rgba(78, 142, 229, 0.1);
position: relative;
z-index: 1;
}
.floatingBadgeColor {
margin: 0.8125rem 0.8125rem 0 1rem;
padding: 0.1875rem 0.75rem 0.25rem 0.5rem;
border-radius: 1rem;
background-color: rgba(78, 142, 229, 0.1);
@include mixins.adapt-margin-to-scrollbar(0.8125rem);
}
.floatingBadgeButtonColor {
padding: 0.25rem 0.375rem 0.25rem 0.1875rem;
border-radius: 0.375rem;
background-color: white;
margin-left: 0.5rem;
}
.floatingBadgeIcon {
font-size: 1.125rem;
margin-right: 0.1875rem;
}
.floatingBadgeValue {
font-size: 0.875rem;
font-weight: 500;
}
.subscriptionFooter {
margin-bottom: 6rem;
}
.premiumFeatures {
margin-bottom: 0;
font-size: 1rem;
color: var(--color-text-secondary);
}
.dateButton {
margin-bottom: 1rem;
padding-right: 1.1875rem;
display: flex;
padding-left: 1rem;
justify-content: space-between;
align-items: center;
}
.title {
color: var(--color-text);;
font-size: 1rem;
text-transform: initial;
margin: 0;
}
.checkboxSection {
display: flex;
justify-content: space-between;
padding: 1rem;
}
.prizesSection {
display: flex;
align-items: center;
padding: 1rem;
}
.prizesInput {
flex-grow: 1;
margin-left: 1rem;
margin-bottom: 0;
}
.subLabelClassName {
margin-top: 0.1875rem;
color: var(--color-links) !important;
}
.prepaidImg {
width: 2.75rem;
height: 2.75rem;
}
.addButton {
margin-bottom: 1rem;
}
.addChannel {
margin-inline-start: initial !important;
}
@media (max-width: 600px) {
.root :global(.modal-dialog) {
width: 100%;
height: 100%;
max-width: 100% !important;
border-radius: 0;
}
.root .transition {
height: 100%;
}
}

View File

@ -0,0 +1,741 @@
import type { ChangeEvent } from 'react';
import type { FC } from '../../../lib/teact/teact';
import React, {
memo, useEffect, useMemo, useRef, useState,
} from '../../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../../global';
import type {
ApiCountry, ApiPremiumGiftCodeOption, ApiPrepaidGiveaway, ApiUser,
} from '../../../api/types';
import {
GIVEAWAY_BOOST_PER_PREMIUM,
GIVEAWAY_MAX_ADDITIONAL_CHANNELS,
GIVEAWAY_MAX_ADDITIONAL_COUNTRIES,
GIVEAWAY_MAX_ADDITIONAL_USERS,
} from '../../../config';
import { getUserFullName } from '../../../global/helpers';
import {
selectTabState,
} from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { formatDateTimeToString } from '../../../util/date/dateFormat';
import renderText from '../../common/helpers/renderText';
import useFlag from '../../../hooks/useFlag';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import CalendarModal from '../../common/CalendarModal';
import CountryPickerModal from '../../common/CountryPickerModal';
import GroupChatInfo from '../../common/GroupChatInfo';
import Icon from '../../common/Icon';
import Button from '../../ui/Button';
import ConfirmDialog from '../../ui/ConfirmDialog';
import InputText from '../../ui/InputText';
import Link from '../../ui/Link';
import ListItem from '../../ui/ListItem';
import Modal from '../../ui/Modal';
import RadioGroup from '../../ui/RadioGroup';
import RangeSliderWithMarks from '../../ui/RangeSliderWithMarks';
import Switcher from '../../ui/Switcher';
import AppendEntityPickerModal from '../AppendEntityPickerModal';
import GiveawayTypeOption from './GiveawayTypeOption';
import PremiumSubscriptionOption from './PremiumSubscriptionOption';
import styles from './GiveawayModal.module.scss';
import GiftBlueRound from '../../../assets/premium/GiftBlueRound.svg';
import GiftGreenRound from '../../../assets/premium/GiftGreenRound.svg';
import GiftRedRound from '../../../assets/premium/GiftRedRound.svg';
import GiveawayUsersRound from '../../../assets/premium/GiveawayUsersRound.svg';
import PremiumLogo from '../../../assets/premium/PremiumLogo.svg';
export type OwnProps = {
isOpen?: boolean;
userIds?: string[];
};
type StateProps = {
chatId?: string;
gifts?: ApiPremiumGiftCodeOption[];
isOpen?: boolean;
fromUser?: ApiUser;
selectedMemberList?: string[] | undefined;
selectedChannelList?: string[] | undefined;
giveawayBoostPerPremiumLimit?: number;
userSelectionLimit?: number;
countryList: ApiCountry[];
prepaidGiveaway?: ApiPrepaidGiveaway;
countrySelectionLimit: number | undefined;
};
type GiveawayAction = 'createRandomlyUsers' | 'createSpecificUsers';
type ApiGiveawayType = 'random_users' | 'specific_users';
type SubscribersType = 'all' | 'new';
interface TypeOption {
name: string;
text: string;
value: ApiGiveawayType;
img: string;
actions?: GiveawayAction;
isLink: boolean;
onClickAction?: () => void;
}
const DEFAULT_CUSTOM_EXPIRE_DATE = 86400 * 3 * 1000; // 3 days
const MAX_ADDITIONAL_CHANNELS = 9;
const DEFAULT_BOOST_COUNT = 5;
const GIVEAWAY_IMG_LIST: { [key: number]: string } = {
3: GiftGreenRound,
6: GiftBlueRound,
12: GiftRedRound,
};
const GiveawayModal: FC<OwnProps & StateProps> = ({
chatId,
gifts,
isOpen,
selectedMemberList,
selectedChannelList,
giveawayBoostPerPremiumLimit = GIVEAWAY_BOOST_PER_PREMIUM,
countryList,
prepaidGiveaway,
countrySelectionLimit = GIVEAWAY_MAX_ADDITIONAL_COUNTRIES,
userSelectionLimit = GIVEAWAY_MAX_ADDITIONAL_USERS,
}) => {
// eslint-disable-next-line no-null/no-null
const dialogRef = useRef<HTMLDivElement>(null);
const {
closeGiveawayModal, openInvoice, openPremiumModal,
launchPrepaidGiveaway,
} = getActions();
const lang = useLang();
const [isCalendarOpened, openCalendar, closeCalendar] = useFlag();
const [isCountryPickerModalOpen, openCountryPickerModal, closeCountryPickerModal] = useFlag();
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useFlag();
const [isEntityPickerModalOpen, openEntityPickerModal, closeEntityPickerModal] = useFlag();
const [entityType, setEntityType] = useState<'members' | 'channels' | undefined>(undefined);
const TYPE_OPTIONS: TypeOption[] = [{
name: 'BoostingCreateGiveaway',
text: 'BoostingWinnersRandomly',
value: 'random_users',
img: GiftBlueRound,
actions: 'createRandomlyUsers',
isLink: false,
}, {
name: 'BoostingAwardSpecificUsers',
text: 'BoostingSelectRecipients',
value: 'specific_users',
img: GiveawayUsersRound,
actions: 'createSpecificUsers',
isLink: true,
onClickAction: () => {
openEntityPickerModal();
setEntityType('members');
},
}];
const [customExpireDate, setCustomExpireDate] = useState<number>(Date.now() + DEFAULT_CUSTOM_EXPIRE_DATE);
const [isHeaderHidden, setHeaderHidden] = useState(true);
const [selectedUserCount, setSelectedUserCount] = useState<number>(DEFAULT_BOOST_COUNT);
const [selectedGiveawayOption, setGiveawayOption] = useState<ApiGiveawayType>(TYPE_OPTIONS[0].value);
const [selectedSubscriberOption, setSelectedSubscriberOption] = useState<SubscribersType>('all');
const [selectedMonthOption, setSelectedMonthOption] = useState<number | undefined>();
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
const [selectedChannelIds, setSelectedChannelIds] = useState<string[]>([]);
const [selectedCountriesIds, setSelectedCountriesIds] = useState<string[] | undefined>([]);
const [shouldShowWinners, setShouldShowWinners] = useState<boolean>(false);
const [shouldShowPrizes, setShouldShowPrizes] = useState<boolean>(false);
const [prizeDescription, setPrizeDescription] = useState<string | undefined>(undefined);
const [dataPrepaidGiveaway, setDataPrepaidGiveaway] = useState<ApiPrepaidGiveaway | undefined>(undefined);
const boostQuantity = selectedUserCount * giveawayBoostPerPremiumLimit;
const isRandomUsers = selectedGiveawayOption === 'random_users';
const SUBSCRIBER_OPTIONS = useMemo(() => [
{
value: 'all',
label: lang('BoostingAllSubscribers'),
subLabel: selectedCountriesIds && selectedCountriesIds.length > 0
? lang('Giveaway.ReceiverType.Countries', selectedCountriesIds.length)
: lang('BoostingFromAllCountries'),
},
{
value: 'new',
label: lang('BoostingNewSubscribers'),
subLabel: selectedCountriesIds && selectedCountriesIds.length > 0
? lang('Giveaway.ReceiverType.Countries', selectedCountriesIds.length)
: lang('BoostingFromAllCountries'),
},
], [lang, selectedCountriesIds]);
const monthQuantity = lang('Months', selectedMonthOption);
const selectedGift = useMemo(() => {
return gifts!.find((gift) => gift.months === selectedMonthOption && gift.users === selectedUserCount);
}, [gifts, selectedMonthOption, selectedUserCount]);
const filteredGifts = useMemo(() => {
return gifts?.filter((gift) => gift.users
=== (selectedUserIds?.length ? selectedUserIds?.length : selectedUserCount));
}, [gifts, selectedUserIds, selectedUserCount]);
const fullMonthlyAmount = useMemo(() => {
if (!filteredGifts?.length) {
return undefined;
}
const basicGift = filteredGifts.reduce((acc, gift) => {
return gift.amount < acc.amount ? gift : acc;
});
return Math.floor(basicGift.amount / basicGift.months);
}, [filteredGifts]);
const userCountOptions = useMemo(() => {
const uniqueUserCounts = new Set(gifts?.map((gift) => gift.users));
return Array.from(uniqueUserCounts).sort((a, b) => a - b);
}, [gifts]);
useEffect(() => {
if (isOpen) {
setSelectedMonthOption(prepaidGiveaway ? prepaidGiveaway.months : gifts?.[0].months);
}
}, [gifts, isOpen, prepaidGiveaway]);
useEffect(() => {
if (prepaidGiveaway) {
setSelectedUserCount(prepaidGiveaway.quantity);
setDataPrepaidGiveaway(prepaidGiveaway);
}
}, [prepaidGiveaway]);
useEffect(() => {
if (selectedMemberList) {
setSelectedUserIds(selectedMemberList);
}
}, [selectedMemberList]);
useEffect(() => {
if (selectedChannelList) {
setSelectedChannelIds(selectedChannelList);
}
}, [selectedChannelList]);
const handlePremiumClick = useLastCallback(() => {
openPremiumModal();
});
const handleClick = useLastCallback(() => {
if (selectedUserIds?.length) {
openInvoice({
type: 'giftcode',
boostChannelId: chatId!,
userIds: selectedUserIds,
currency: selectedGift!.currency,
amount: selectedGift!.amount,
option: selectedGift!,
});
} else {
openInvoice({
type: 'giveaway',
chatId: chatId!,
additionalChannelIds: selectedChannelIds,
isOnlyForNewSubscribers: selectedSubscriberOption === 'new',
countries: selectedCountriesIds,
areWinnersVisible: shouldShowWinners,
prizeDescription,
untilDate: customExpireDate / 1000,
currency: selectedGift!.currency,
amount: selectedGift!.amount,
option: selectedGift!,
});
}
closeGiveawayModal();
});
const confirmLaunchPrepaidGiveaway = useLastCallback(() => {
launchPrepaidGiveaway({
chatId: chatId!,
giveawayId: dataPrepaidGiveaway!.id,
paymentPurpose: {
additionalChannelIds: selectedChannelIds,
countries: selectedCountriesIds,
prizeDescription,
areWinnersVisible: shouldShowWinners,
untilDate: customExpireDate / 1000,
currency: selectedGift!.currency,
amount: selectedGift!.amount,
},
});
closeConfirmModal();
closeGiveawayModal();
});
const handleUserCountChange = useLastCallback((newValue) => {
setSelectedUserCount(newValue);
});
const handlePrizeDescriptionChange = useLastCallback((e: ChangeEvent<HTMLInputElement>) => {
setPrizeDescription(e.target.value);
});
const userNames = useMemo(() => {
const usersById = getGlobal().users.byId;
return selectedUserIds?.map((userId) => getUserFullName(usersById[userId])).join(', ');
}, [selectedUserIds]);
const handleAdd = useLastCallback(() => {
openEntityPickerModal();
setEntityType('channels');
});
function handleScroll(e: React.UIEvent<HTMLDivElement>) {
const { scrollTop } = e.currentTarget;
setHeaderHidden(scrollTop <= 150);
}
const handleChangeSubscriberOption = useLastCallback((value) => {
setSelectedSubscriberOption(value);
});
const handleChangeTypeOption = useLastCallback((value: ApiGiveawayType) => {
setGiveawayOption(value);
setSelectedUserIds([]);
});
const handleExpireDateChange = useLastCallback((date: Date) => {
setCustomExpireDate(date.getTime());
closeCalendar();
});
const handleSetCountriesListChange = useLastCallback((value: string[]) => {
setSelectedCountriesIds(value);
});
const handleSetIdsListChange = useLastCallback((value: string[]) => {
return entityType === 'members'
? (value?.length ? setSelectedUserIds(value) : setGiveawayOption('random_users'))
: setSelectedChannelIds(value);
});
const handleClose = useLastCallback(() => {
closeGiveawayModal();
});
const handleShouldShowWinnersChange = useLastCallback((e: ChangeEvent<HTMLInputElement>) => {
setShouldShowWinners(e.target.checked);
});
const handleShouldShowPrizesChange = useLastCallback((e: ChangeEvent<HTMLInputElement>) => {
setShouldShowPrizes(e.target.checked);
});
const onClickActionHandler = useLastCallback(() => {
openCountryPickerModal();
});
if (!gifts) return undefined;
function renderTypeOptions() {
return (
<div className={styles.options}>
{TYPE_OPTIONS.map((option) => {
return (
<GiveawayTypeOption
key={option.name}
name={option.name}
text={option.text}
option={option.value}
img={option.img}
onChange={handleChangeTypeOption}
checked={selectedGiveawayOption === option.value}
isLink={option.isLink}
userNames={userNames}
selectedMemberIds={selectedUserIds}
onClickAction={option.onClickAction}
/>
);
})}
</div>
);
}
function renderSubscribersOptions() {
return (
<div className={styles.options}>
<RadioGroup
name="subscribers"
options={SUBSCRIBER_OPTIONS}
selected={selectedSubscriberOption}
onChange={handleChangeSubscriberOption}
onClickAction={onClickActionHandler}
subLabelClassName={styles.subLabelClassName}
isLink
/>
</div>
);
}
function renderSubscriptionOptions() {
return (
<div className={styles.options}>
{filteredGifts?.map((gift) => (
<PremiumSubscriptionOption
isGiveaway
key={gift.months}
option={gift}
userCount={gift.users}
fullMonthlyAmount={fullMonthlyAmount!}
checked={gift.months === selectedMonthOption}
onChange={setSelectedMonthOption}
/>
))}
</div>
);
}
function renderPremiumFeaturesLink() {
const info = lang('GiftPremiumListFeaturesAndTerms');
const parts = info.match(/([^*]*)\*([^*]+)\*(.*)/);
if (!parts || parts.length < 4) {
return undefined;
}
return (
<p className={styles.premiumFeatures}>
{parts[1]}
<Link isPrimary onClick={handlePremiumClick}>{parts[2]}</Link>
{parts[3]}
</p>
);
}
function deleteParticipantsHandler(id: string) {
const filteredChannelIds = selectedChannelIds.filter((channelId) => channelId !== id);
setSelectedChannelIds(filteredChannelIds);
}
return (
<Modal
className={styles.root}
onClose={handleClose}
isOpen={isOpen}
dialogRef={dialogRef}
>
<div className={styles.main} onScroll={handleScroll}>
<Button
round
size="smaller"
className={styles.closeButton}
color="translucent"
onClick={handleClose}
ariaLabel={lang('Close')}
>
<Icon name="close" />
</Button>
<img className={styles.logo} src={PremiumLogo} alt="" draggable={false} />
<h2 className={styles.headerText}>
{renderText(lang('BoostingBoostsViaGifts'))}
</h2>
<div className={styles.description}>
{renderText(lang('BoostingGetMoreBoost'))}
</div>
<div className={buildClassName(styles.header, isHeaderHidden && styles.hiddenHeader)}>
<h2 className={styles.premiumHeaderText}>
{lang('BoostingBoostsViaGifts')}
</h2>
</div>
{dataPrepaidGiveaway ? (
<div className={styles.status}>
<div>
<img className={styles.prepaidImg} src={GIVEAWAY_IMG_LIST[dataPrepaidGiveaway.months]} alt="" />
</div>
<div className={styles.info}>
<h3 className={styles.title}>
{lang('BoostingTelegramPremiumCountPlural', dataPrepaidGiveaway.quantity)}
</h3>
<p className={styles.month}>{lang('PrepaidGiveawayMonths', dataPrepaidGiveaway.months)}</p>
</div>
<div className={styles.quantity}>
<div className={buildClassName(styles.floatingBadge, styles.floatingBadgeColor)}>
<Icon name="boost" className={styles.floatingBadgeIcon} />
<div className={styles.floatingBadgeValue} dir={lang.isRtl ? 'rtl' : undefined}>
{dataPrepaidGiveaway.quantity * (giveawayBoostPerPremiumLimit ?? GIVEAWAY_BOOST_PER_PREMIUM)}
</div>
</div>
</div>
</div>
) : (
<div className={buildClassName(styles.section, styles.types)}>
{renderTypeOptions()}
</div>
)}
{isRandomUsers && (
<>
{!dataPrepaidGiveaway && (
<>
<div className={styles.section}>
<div className={styles.quantity}>
<h2 className={styles.giveawayTitle}>
{lang('BoostingQuantityPrizes')}
</h2>
<div className={buildClassName(styles.floatingBadge, styles.floatingBadgeColor)}>
<Icon name="boost" className={styles.floatingBadgeIcon} />
<div className={styles.floatingBadgeValue} dir={lang.isRtl ? 'rtl' : undefined}>
{boostQuantity}
</div>
</div>
</div>
<RangeSliderWithMarks
rangeCount={selectedUserCount}
marks={userCountOptions}
onChange={handleUserCountChange}
/>
</div>
<div className={styles.subscription}>
{renderText(lang('BoostingChooseHowMany'))}
</div>
</>
)}
<div className={styles.section}>
<h2 className={styles.giveawayTitle}>
{lang('BoostingChannelsIncludedGiveaway')}
</h2>
<ListItem
inactive
className="chat-item-clickable contact-list-item"
>
<GroupChatInfo
chatId={chatId!}
status={lang('BoostingChannelWillReceiveBoost', boostQuantity, 'i')}
/>
</ListItem>
{selectedChannelIds?.map((channelId) => {
return (
<ListItem
ripple
key={channelId}
className="chat-item-clickable contact-list-item"
/* eslint-disable-next-line react/jsx-no-bind */
onClick={() => deleteParticipantsHandler(channelId)}
rightElement={(<Icon name="close" />)}
>
<GroupChatInfo
chatId={channelId.toString()}
/>
</ListItem>
);
})}
{selectedChannelIds.length < MAX_ADDITIONAL_CHANNELS && (
<ListItem
icon="add"
ripple
onClick={handleAdd}
className={styles.addButton}
iconClassName={styles.addChannel}
>
{lang('BoostingAddChannel')}
</ListItem>
)}
</div>
<div className={styles.section}>
<h2 className={styles.giveawayTitle}>
{lang('BoostingEligibleUsers')}
</h2>
{renderSubscribersOptions()}
</div>
<div className={styles.subscription}>
{renderText(lang('BoostGift.LimitSubscribersInfo'))}
</div>
<div className={styles.section}>
<div className={styles.checkboxSection}>
<h2 className={styles.title}>
{lang('BoostingGiveawayAdditionalPrizes')}
</h2>
<Switcher
label={lang('BoostingGiveawayAdditionalPrizes')}
checked={shouldShowPrizes}
onChange={handleShouldShowPrizesChange}
/>
</div>
{shouldShowPrizes && (
<div className={styles.prizesSection}>
<h2 className={styles.title}>
{dataPrepaidGiveaway ? dataPrepaidGiveaway.quantity : selectedUserCount}
</h2>
<InputText
className={styles.prizesInput}
value={prizeDescription}
onChange={handlePrizeDescriptionChange}
label={lang('BoostingGiveawayEnterYourPrize')}
/>
</div>
)}
</div>
{shouldShowPrizes ? (
<div className={styles.subscription}>
{prizeDescription?.length ? renderText(lang('BoostingGiveawayAdditionPrizeCountNameHint',
dataPrepaidGiveaway
? [dataPrepaidGiveaway.quantity, prizeDescription, monthQuantity]
: [selectedUserCount, prizeDescription, monthQuantity],
undefined,
selectedMonthOption), ['simple_markdown']) : renderText(lang('BoostingGiveawayAdditionPrizeCountHint',
dataPrepaidGiveaway
? [dataPrepaidGiveaway.quantity, monthQuantity]
: [selectedUserCount, monthQuantity],
undefined,
selectedMonthOption), ['simple_markdown'])}
</div>
) : (
<div className={styles.subscription}>
{renderText(lang('BoostingGiveawayAdditionPrizeHint'))}
</div>
)}
<div className={styles.section}>
<div className={styles.checkboxSection}>
<h2 className={styles.title}>
{lang('BoostingGiveawayShowWinners')}
</h2>
<Switcher
label={lang('BoostingGiveawayAdditionalPrizes')}
checked={shouldShowWinners}
onChange={handleShouldShowWinnersChange}
/>
</div>
</div>
<div className={styles.subscription}>
{renderText(lang('BoostingGiveawayShowWinnersHint'))}
</div>
<div className={buildClassName(styles.section, dataPrepaidGiveaway && styles.subscriptionFooter)}>
<h2 className={styles.giveawayTitle}>
{lang('BoostingDateWhenGiveawayEnds')}
</h2>
<Button
ariaLabel={lang('BoostGift.DateEnds')}
className={buildClassName(styles.dateButton, 'expire-limit')}
isText
onClick={openCalendar}
>
<h3 className={styles.title}>
{lang('BoostGift.DateEnds')}
</h3>
{formatDateTimeToString(customExpireDate, lang.code)}
</Button>
</div>
</>
)}
{!dataPrepaidGiveaway && (
<>
<div className={styles.section}>
<h2 className={styles.giveawayTitle}>
{lang('BoostingDurationOfPremium')}
</h2>
{renderSubscriptionOptions()}
</div>
<div className={buildClassName(styles.subscription, styles.subscriptionFooter)}>
{renderPremiumFeaturesLink()}
</div>
</>
)}
{selectedGiveawayOption && (
<div className={styles.footer}>
<Button className={styles.button} onClick={dataPrepaidGiveaway ? openConfirmModal : handleClick}>
{lang('BoostingStartGiveaway')}
<div className={styles.quantity}>
<div className={buildClassName(styles.floatingBadge, styles.floatingBadgeButtonColor)}>
<Icon name="boost" className={styles.floatingBadgeIcon} />
<div className={styles.floatingBadgeValue} dir={lang.isRtl ? 'rtl' : undefined}>
{dataPrepaidGiveaway ? dataPrepaidGiveaway.quantity : boostQuantity}
</div>
</div>
</div>
</Button>
</div>
)}
</div>
<CalendarModal
isOpen={isCalendarOpened}
isFutureMode
withTimePicker
onClose={closeCalendar}
onSubmit={handleExpireDateChange}
selectedAt={customExpireDate}
submitButtonLabel={lang('Save')}
/>
<CountryPickerModal
isOpen={isCountryPickerModalOpen}
onClose={closeCountryPickerModal}
countryList={countryList}
onSubmit={handleSetCountriesListChange}
selectionLimit={countrySelectionLimit}
/>
<AppendEntityPickerModal
isOpen={isEntityPickerModalOpen}
onClose={closeEntityPickerModal}
entityType={entityType}
chatId={chatId}
onSubmit={handleSetIdsListChange}
selectionLimit={entityType === 'members' ? userSelectionLimit : GIVEAWAY_MAX_ADDITIONAL_CHANNELS}
/>
<ConfirmDialog
title={lang('BoostingStartGiveawayConfirmTitle')}
text={lang('BoostingStartGiveawayConfirmText')}
confirmLabel={lang('Start')}
isOpen={isConfirmModalOpen}
onClose={closeConfirmModal}
confirmHandler={confirmLaunchPrepaidGiveaway}
/>
</Modal>
);
};
export default memo(withGlobal<OwnProps>((global): StateProps => {
const {
giveawayModal,
} = selectTabState(global);
return {
chatId: giveawayModal?.chatId,
gifts: giveawayModal?.gifts,
selectedMemberList: giveawayModal?.selectedMemberIds,
selectedChannelList: giveawayModal?.selectedChannelIds,
giveawayBoostPerPremiumLimit: global.appConfig?.giveawayBoostsPerPremium,
userSelectionLimit: global.appConfig?.giveawayAddPeersMax,
countrySelectionLimit: global.appConfig?.giveawayCountriesMax,
countryList: global.countryList.general,
prepaidGiveaway: giveawayModal?.prepaidGiveaway,
};
})(GiveawayModal));

View File

@ -0,0 +1,99 @@
.wrapper {
position: relative;
display: block;
padding-inline: 3.5rem 1rem;
border: none;
margin-bottom: 0;
cursor: var(--custom-cursor, pointer);
line-height: 1.5rem;
}
.input {
position: absolute;
z-index: var(--z-below);
opacity: 0;
&:checked ~ .content {
&::before {
border-color: var(--color-primary);
}
&::after {
opacity: 1;
}
}
}
.input:not(:checked) ~ .content.notChecked::before,
.input:not(:checked) ~ .content.notChecked::after {
border-color: transparent;
opacity: 0;
}
.content {
display: flex;
align-items: center;
padding: 0.5rem 0;
gap: 1.25rem;
&::before,
&::after {
content: "";
display: block;
position: absolute;
inset-inline-start: 1.0625rem;
top: 50%;
width: 1.25rem;
height: 1.25rem;
transform: translateY(-50%);
}
&::before {
border: 2px solid var(--color-borders-input);
border-radius: 50%;
background-color: var(--color-background);
opacity: 1;
transition: border-color 0.1s ease, opacity 0.1s ease;
}
&::after {
inset-inline-start: 1.375rem;
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
background: var(--color-primary);
opacity: 0;
transition: opacity 0.1s ease;
}
}
.giveaway {
display: flex;
flex-direction: column;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.title {
color: var(--color-text);
margin-bottom: 0;
}
.link {
display: flex;
align-items: center;
color: var(--color-links);
}
.optionImg {
width: 2.75rem;
height: 2.75rem;
}
@media (max-width: 450px) {
.contentText {
line-height: 1rem;
}
}

View File

@ -0,0 +1,90 @@
import type { ChangeEvent } from 'react';
import type { FC } from '../../../lib/teact/teact';
import React, { memo } from '../../../lib/teact/teact';
import buildClassName from '../../../util/buildClassName';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import Icon from '../../common/Icon';
import styles from './GiveawayTypeOption.module.scss';
type ApiGiveawayType = 'random_users' | 'specific_users';
type OwnProps = {
option: ApiGiveawayType;
name: string;
text: string;
img: string;
checked?: boolean;
isLink: boolean;
className?: string;
onChange: (value: ApiGiveawayType) => void;
onClickAction?: () => void;
userNames?: string;
selectedMemberIds?: string[];
};
const GiveawayTypeOption: FC<OwnProps> = ({
option, checked,
name, text, img,
isLink, onChange, onClickAction, className,
userNames, selectedMemberIds,
}) => {
const lang = useLang();
let displayText: string | undefined = lang(text);
if (isLink && selectedMemberIds?.length) {
displayText = selectedMemberIds.length > 2 ? `${selectedMemberIds.length}` : userNames;
}
const handleChange = useLastCallback((e: ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
onChange(option);
}
});
const handleClick = useLastCallback(() => {
onClickAction?.();
});
return (
<label
className={buildClassName(styles.wrapper, className)}
dir={lang.isRtl ? 'rtl' : undefined}
onClick={handleClick}
role="button"
tabIndex={0}
>
<input
className={styles.input}
type="radio"
name="giveaway_option"
value={option}
checked={checked}
onChange={handleChange}
onClick={(e) => e.stopPropagation()}
/>
<div className={styles.content}>
<img className={styles.optionImg} src={img} alt="" draggable={false} />
<div className={styles.giveaway}>
<h3 className={styles.title}>
{lang(`${name}`)}
</h3>
{isLink ? (
<div className={styles.link}>
<span>{displayText}</span>
<Icon name="next" />
</div>
) : (
<span className={styles.contentText}>{displayText}</span>
)}
</div>
</div>
</label>
);
};
export default memo(GiveawayTypeOption);

View File

@ -166,6 +166,7 @@ const PremiumMainModal: FC<OwnProps & StateProps> = ({
if (premiumSlug) {
openInvoice({
type: 'slug',
slug: premiumSlug,
});
} else if (premiumBotUsername) {

View File

@ -12,6 +12,16 @@
line-height: 1.5rem;
}
.giveawayWrapper {
position: relative;
display: block;
padding-inline: 3.6875rem 1rem;
cursor: var(--custom-cursor, pointer);
line-height: 1.5rem;
}
.active {
box-shadow: 0 0 0 0.125rem var(--color-primary);
}
@ -108,3 +118,10 @@
margin-inline-end: 0.5rem;
align-self: baseline;
}
.giveawayDiscount {
font-size: 0.8125rem;
font-weight: 500;
border-radius: 0.375rem;
padding: 0.1875rem;
}

View File

@ -2,6 +2,8 @@ import type { ChangeEvent } from 'react';
import type { FC } from '../../../lib/teact/teact';
import React, { memo, useCallback, useMemo } from '../../../lib/teact/teact';
import type { ApiPremiumGiftCodeOption, ApiPremiumGiftOption } from '../../../api/types';
import buildClassName from '../../../util/buildClassName';
import { formatCurrency } from '../../../util/formatCurrency';
@ -10,11 +12,9 @@ import useLang from '../../../hooks/useLang';
import styles from './PremiumSubscriptionOption.module.scss';
type OwnProps = {
option: {
months: number;
currency: string;
amount: number;
};
option: ApiPremiumGiftOption | ApiPremiumGiftCodeOption;
isGiveaway?: boolean;
userCount?: number;
checked?: boolean;
fullMonthlyAmount?: number;
className?: string;
@ -22,11 +22,14 @@ type OwnProps = {
};
const PremiumSubscriptionOption: FC<OwnProps> = ({
option, checked, fullMonthlyAmount, onChange, className,
option, checked, fullMonthlyAmount,
onChange, className, isGiveaway, userCount,
}) => {
const lang = useLang();
const { months, amount, currency } = option;
const {
months, amount, currency,
} = option;
const perMonth = Math.floor(amount / months);
const discount = useMemo(() => {
@ -44,8 +47,8 @@ const PremiumSubscriptionOption: FC<OwnProps> = ({
return (
<label
className={buildClassName(
styles.wrapper,
checked && styles.active,
isGiveaway ? styles.giveawayWrapper : styles.wrapper,
(checked && !isGiveaway) && styles.active,
className,
)}
dir={lang.isRtl ? 'rtl' : undefined}
@ -53,20 +56,29 @@ const PremiumSubscriptionOption: FC<OwnProps> = ({
<input
className={styles.input}
type="radio"
name="gift_option"
name="subscription_option"
value={months}
checked={checked}
onChange={handleChange}
/>
<div className={styles.content}>
<div className={styles.month}>{lang('Months', months)}</div>
<div className={styles.perMonth}>
{lang('PricePerMonth', formatCurrency(perMonth, currency, lang.code))}
{Boolean(discount) && (
<span className={styles.discount} title={lang('GiftDiscount')}> &minus;{discount}% </span>
<div className={styles.month}>
{Boolean(discount) && isGiveaway && (
<span
className={buildClassName(styles.giveawayDiscount, isGiveaway && styles.discount)}
title={lang('GiftDiscount')}
> &minus;{discount}%
</span>
)}
{lang('Months', months)}
</div>
<div className={styles.perMonth}>
{isGiveaway ? `${formatCurrency(amount, currency, lang.code)} x ${userCount!}`
: lang('PricePerMonth', formatCurrency(perMonth, currency, lang.code))}
</div>
<div className={styles.amount}>
{formatCurrency(isGiveaway ? amount * userCount! : amount, currency, lang.code)}
</div>
<div className={styles.amount}>{formatCurrency(amount, currency, lang.code)}</div>
</div>
</label>
);

View File

@ -15,7 +15,7 @@
}
.pinned-message-border-wrapper {
background-color: var(--color-primary-opacity);
background-color: var(--color-primary-opacity-hover);
position: relative;
will-change: transform;
transition: transform 0.25s ease-in-out;

View File

@ -52,6 +52,7 @@ const InvoiceMediaPreview: FC<OwnProps> = ({
const handleClick = useLastCallback(() => {
openInvoice({
type: 'message',
chatId,
messageId: id,
isExtendedMedia: true,

View File

@ -191,6 +191,7 @@ const useWebAppFrame = (
slug: eventData.slug,
});
openInvoice({
type: 'slug',
slug: eventData.slug,
});
}

View File

@ -38,3 +38,52 @@
.loadMoreSpinner {
margin-inline-end: 1rem;
}
.status {
display: flex;
align-items: center;
gap: 1rem;
justify-content: space-between;
width: 100%;
}
.info {
flex-grow: 1;
}
.month {
color: var(--color-text-secondary);
margin: 0;
}
.quantity {
display: flex;
justify-content: space-between;
align-items: center;
}
.floatingBadge {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-links);
position: relative;
z-index: 1;
}
.floatingBadgeButtonColor {
padding: 0.25rem 0.75rem 0.375rem 0.5625rem;
border-radius: 1rem;
background-color: var(--color-primary-opacity);
margin-left: 0.5rem;
}
.floatingBadgeIcon {
font-size: 1.125rem;
margin-right: 0.1875rem;
}
.floatingBadgeValue {
font-size: 0.875rem;
font-weight: 500;
}

View File

@ -1,10 +1,11 @@
import React, { memo, useMemo } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiBoostStatistics } from '../../../api/types';
import type { ApiBoostStatistics, ApiPrepaidGiveaway } from '../../../api/types';
import type { TabState } from '../../../global/types';
import { selectTabState } from '../../../global/selectors';
import { GIVEAWAY_BOOST_PER_PREMIUM } from '../../../config';
import { selectIsGiveawayGiftsPurchaseAvailable, selectTabState } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { formatDateAtTime } from '../../../util/date/dateFormat';
import { getBoostProgressInfo } from '../../common/helpers/boostInfo';
@ -23,14 +24,32 @@ import StatisticsOverview from './StatisticsOverview';
import styles from './BoostStatistics.module.scss';
import GiftBlueRound from '../../../assets/premium/GiftBlueRound.svg';
import GiftGreenRound from '../../../assets/premium/GiftGreenRound.svg';
import GiftRedRound from '../../../assets/premium/GiftRedRound.svg';
type StateProps = {
boostStatistics: TabState['boostStatistics'];
isGiveawayAvailable?: boolean;
chatId: string;
giveawayBoostsPerPremium?: number;
};
const GIVEAWAY_IMG_LIST: { [key: number]: string } = {
3: GiftGreenRound,
6: GiftBlueRound,
12: GiftRedRound,
};
const BoostStatistics = ({
boostStatistics,
isGiveawayAvailable,
chatId,
giveawayBoostsPerPremium,
}: StateProps) => {
const { openChat, loadMoreBoosters, closeBoostStatistics } = getActions();
const {
openChat, loadMoreBoosters, closeBoostStatistics, openGiveawayModal,
} = getActions();
const lang = useLang();
const isLoaded = boostStatistics?.boostStatus;
@ -50,6 +69,7 @@ const BoostStatistics = ({
boosts: 0,
levelProgress: 0,
remainingBoosts: 0,
prepaidGiveaways: [],
};
}
return getBoostProgressInfo(status);
@ -63,6 +83,7 @@ const BoostStatistics = ({
boosts,
premiumSubscribers: status.premiumSubscribers!,
remainingBoosts,
prepaidGiveaways: status.prepaidGiveaways!,
} satisfies ApiBoostStatistics;
}, [status, boosts, currentLevel, remainingBoosts]);
@ -78,10 +99,18 @@ const BoostStatistics = ({
closeBoostStatistics();
});
const handleGiveawayClick = useLastCallback(() => {
openGiveawayModal({ chatId });
});
const handleLoadMore = useLastCallback(() => {
loadMoreBoosters();
});
const launchPrepaidGiveawayHandler = useLastCallback((prepaidGiveaway: ApiPrepaidGiveaway) => {
openGiveawayModal({ chatId, prepaidGiveaway });
});
return (
<div className={buildClassName(styles.root, 'custom-scroll')}>
{!isLoaded && <Loading />}
@ -97,6 +126,42 @@ const BoostStatistics = ({
/>
<StatisticsOverview className={styles.stats} statistics={statsOverview} type="boost" />
</div>
{statsOverview.prepaidGiveaways && (
<div className={styles.section}>
<h4 className={styles.sectionHeader} dir={lang.isRtl ? 'rtl' : undefined}>
{lang('BoostingPreparedGiveaways')}
</h4>
{statsOverview?.prepaidGiveaways?.map((prepaidGiveaway) => (
<ListItem
key={prepaidGiveaway.id}
className="chat-item-clickable"
// eslint-disable-next-line react/jsx-no-bind
onClick={() => launchPrepaidGiveawayHandler(prepaidGiveaway)}
>
<div className={buildClassName(styles.status, 'status-clickable')}>
<div>
<img src={GIVEAWAY_IMG_LIST[prepaidGiveaway.months]} alt="Giveaway" />
</div>
<div className={styles.info}>
<h3>
{lang('BoostingTelegramPremiumCountPlural', prepaidGiveaway.quantity)}
</h3>
<p className={styles.month}>{lang('PrepaidGiveawayMonths', prepaidGiveaway.months)}</p>
</div>
<div className={styles.quantity}>
<div className={buildClassName(styles.floatingBadge, styles.floatingBadgeButtonColor)}>
<Icon name="boost" className={styles.floatingBadgeIcon} />
<div className={styles.floatingBadgeValue} dir={lang.isRtl ? 'rtl' : undefined}>
{prepaidGiveaway.quantity * (giveawayBoostsPerPremium ?? GIVEAWAY_BOOST_PER_PREMIUM)}
</div>
</div>
</div>
</div>
</ListItem>
))}
<p className="text-muted hint" key="links-hint">{lang('BoostingSelectPaidGiveaway')}</p>
</div>
)}
<div className={styles.section}>
<h4 className={styles.sectionHeader} dir={lang.isRtl ? 'rtl' : undefined}>
{lang('Boosters')}
@ -136,6 +201,14 @@ const BoostStatistics = ({
)}
</div>
<LinkField className={styles.section} link={status!.boostUrl} withShare title={lang('LinkForBoosting')} />
{isGiveawayAvailable && (
<div className={styles.section}>
<ListItem icon="gift" ripple onClick={handleGiveawayClick}>
{lang('BoostingGetBoostsViaGifts')}
</ListItem>
<p className="text-muted hint" key="links-hint">{lang('BoostingGetMoreBoosts')}</p>
</div>
)}
</>
)}
</div>
@ -146,9 +219,15 @@ export default memo(withGlobal(
(global): StateProps => {
const tabState = selectTabState(global);
const boostStatistics = tabState.boostStatistics;
const isGiveawayAvailable = selectIsGiveawayGiftsPurchaseAvailable(global);
const chatId = boostStatistics && boostStatistics.chatId;
const giveawayBoostsPerPremium = global.appConfig?.giveawayBoostsPerPremium;
return {
boostStatistics,
isGiveawayAvailable,
chatId: chatId!,
giveawayBoostsPerPremium,
};
},
)(BoostStatistics));

View File

@ -73,6 +73,10 @@
color: var(--color-text-secondary);
unicode-bidi: plaintext;
}
.subLabelLink {
cursor: pointer;
}
}
input:checked ~ .Radio-main {

View File

@ -1,4 +1,4 @@
import type { ChangeEvent } from 'react';
import type { ChangeEvent, MouseEventHandler } from 'react';
import type { FC, TeactNode } from '../../lib/teact/teact';
import React, { memo } from '../../lib/teact/teact';
@ -18,16 +18,20 @@ type OwnProps = {
value: string;
checked: boolean;
disabled?: boolean;
isLink?: boolean;
hidden?: boolean;
isLoading?: boolean;
className?: string;
subLabelClassName?: string;
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
onSubLabelClick?: MouseEventHandler<HTMLSpanElement> | undefined;
};
const Radio: FC<OwnProps> = ({
id,
label,
subLabel,
subLabelClassName,
value,
name,
checked,
@ -36,6 +40,8 @@ const Radio: FC<OwnProps> = ({
isLoading,
className,
onChange,
isLink,
onSubLabelClick,
}) => {
const lang = useLang();
const fullClassName = buildClassName(
@ -59,7 +65,16 @@ const Radio: FC<OwnProps> = ({
/>
<div className="Radio-main">
<span className="label" dir={lang.isRtl ? 'auto' : undefined}>{label}</span>
{subLabel && <span className="subLabel" dir={lang.isRtl ? 'auto' : undefined}>{subLabel}</span>}
{subLabel
&& (
<span
className={buildClassName(subLabelClassName, 'subLabel', isLink ? 'subLabelLink' : undefined)}
dir={lang.isRtl ? 'auto' : undefined}
onClick={isLink ? onSubLabelClick : undefined}
>
{subLabel}
</span>
)}
</div>
{isLoading && <Spinner />}
</label>

View File

@ -0,0 +1,10 @@
.wrapper {
position: relative;
display: block;
border: none;
margin-bottom: 0;
cursor: var(--custom-cursor, pointer);
line-height: 1.5rem;
}

View File

@ -2,12 +2,14 @@ import type { ChangeEvent } from 'react';
import type { FC, TeactNode } from '../../lib/teact/teact';
import React, { memo, useCallback } from '../../lib/teact/teact';
import useLastCallback from '../../hooks/useLastCallback';
import Radio from './Radio';
export type IRadioOption = {
export type IRadioOption<T = string> = {
label: TeactNode;
subLabel?: string;
value: string;
value: T;
hidden?: boolean;
className?: string;
};
@ -20,6 +22,10 @@ type OwnProps = {
disabled?: boolean;
loadingOption?: string;
onChange: (value: string, event: ChangeEvent<HTMLInputElement>) => void;
onClickAction?: (value: string) => void;
isLink?: boolean;
subLabelClassName?: string;
subLabel?: string | undefined;
};
const RadioGroup: FC<OwnProps> = ({
@ -30,19 +36,28 @@ const RadioGroup: FC<OwnProps> = ({
disabled,
loadingOption,
onChange,
onClickAction,
subLabelClassName,
isLink,
subLabel,
}) => {
const handleChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
const { value } = event.currentTarget;
onChange(value, event);
}, [onChange]);
const onSubLabelClick = useLastCallback((value: string) => () => {
onClickAction?.(value);
});
return (
<div id={id} className="radio-group">
{options.map((option) => (
<Radio
name={name}
label={option.label}
subLabel={option.subLabel}
subLabel={subLabel || option.subLabel}
subLabelClassName={subLabelClassName}
value={option.value}
checked={option.value === selected}
hidden={option.hidden}
@ -50,6 +65,8 @@ const RadioGroup: FC<OwnProps> = ({
isLoading={loadingOption ? loadingOption === option.value : undefined}
className={option.className}
onChange={handleChange}
onSubLabelClick={onSubLabelClick(option.value)}
isLink={isLink}
/>
))}
</div>

View File

@ -0,0 +1,132 @@
@use '../../styles/mixins';
@mixin thumb-styles() {
border: none;
width: 1rem;
height: 1rem;
background: var(--color-links);
border-radius: 50%;
cursor: var(--custom-cursor, pointer);
transform: scale(1);
transition: transform 0.30s ease-in-out;
z-index: 2;
-webkit-appearance: none;
appearance: none;
&:hover {
transform: scale(1.5);
}
}
.dotWrapper {
width: 90%;
margin-left: 1rem;
padding: 1rem 0 0;
}
.sliderContainer {
--fill-percentage: 0%;
position: relative;
width: 100%;
}
.marksContainer {
width: 100%;
pointer-events: none;
display: flex;
justify-content: space-between;
}
.mark {
font-size: 0.8125rem;
font-weight: 700;
color: var(--color-text-secondary);
position: relative;
display: flex;
justify-content: center;
width: 0.1875rem;
}
.active {
color: var(--color-links);
}
.slider {
position: relative;
-webkit-appearance: none;
width: 100%;
height: 0.1875rem;
outline: none;
transition: opacity 0.2s;
z-index: 1;
background: linear-gradient(to right,
var(--color-links) 0%,
var(--color-links) var(--fill-percentage),
var(--color-text-secondary) var(--fill-percentage),
var(--color-text-secondary) 100%);
}
.slider::before,
.slider::after {
content: '';
position: absolute;
top: 0;
height: 100%;
transition: transform 0.2s ease;
z-index: -1;
transform-origin: left;
}
.slider::before {
left: 0;
background-color: var(--color-links);
transform: scaleX(var(--fill-percentage-before));
}
.slider::after {
right: 0;
background: var(--color-text-secondary);
transform: scaleX(var(--fill-percentage-after));
transform-origin: right;
}
.slider::-webkit-slider-thumb {
@include thumb-styles();
}
.slider::-moz-range-thumb {
@include thumb-styles();
}
.slider::-ms-thumb {
@include thumb-styles();
}
.tickMarks {
position: absolute;
top: 2rem;
left: 0;
right: 0;
height: 100%;
display: flex;
justify-content: space-between;
pointer-events: none;
z-index: 1;
}
.tick {
position: relative;
width: 0.25rem;
height: 0.5rem;
background-color: var(--color-text-secondary);
border-radius: 0.1875rem;
}
.filled {
background-color: var(--color-links);
}
.tickUnfilled {
background-color: var(--color-text-secondary);
}

View File

@ -0,0 +1,85 @@
import type { ChangeEvent } from 'react';
import type { FC } from '../../lib/teact/teact';
import React, {
memo, useLayoutEffect, useMemo, useRef,
} from '../../lib/teact/teact';
import buildClassName from '../../util/buildClassName';
import styles from './RangeSliderWithMarks.module.scss';
export type OwnProps = {
marks: number[];
onChange: (value: number) => void;
rangeCount: number;
};
const RangeSliderWithMarks: FC<OwnProps> = ({ marks, onChange, rangeCount }) => {
// eslint-disable-next-line no-null/no-null
const sliderRef = useRef<HTMLInputElement | null>(null);
const fillPercentage = useMemo(() => {
return ((marks.indexOf(rangeCount) / (marks.length - 1)) * 100).toFixed(2);
}, [marks, rangeCount]);
const rangeCountIndex = useMemo(() => marks.indexOf(rangeCount), [marks, rangeCount]);
const rangeValue = useMemo(() => {
return marks.indexOf(rangeCount).toString();
}, [marks, rangeCount]);
useLayoutEffect(() => {
sliderRef.current!.style.setProperty('--fill-percentage', `${fillPercentage}%`);
}, [fillPercentage]);
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
const index = parseInt(event.target.value, 10);
const newValue = marks[index];
onChange(newValue);
};
return (
<div className={styles.dotWrapper}>
<form>
<div className={styles.sliderContainer}>
<div className={styles.tickMarks}>
{marks.map((mark, index) => {
const isFilled = index <= rangeCountIndex;
return (
<div
key={mark}
className={buildClassName(
styles.tick,
isFilled ? styles.filled : styles.tickUnfilled,
)}
/>
);
})}
</div>
<div className={styles.marksContainer}>
{marks.map((mark) => (
<div
key={mark}
className={buildClassName(styles.mark, rangeCount === mark && styles.active)}
>
{mark}
</div>
))}
</div>
<input
ref={sliderRef}
type="range"
className={styles.slider}
min="0"
max={marks.length - 1}
value={rangeValue}
onChange={handleChange}
step="1"
/>
</div>
</form>
</div>
);
};
export default memo(RangeSliderWithMarks);

View File

@ -312,6 +312,10 @@ export const GENERAL_TOPIC_ID = 1;
export const STORY_EXPIRE_PERIOD = 86400; // 1 day
export const STORY_VIEWERS_EXPIRE_PERIOD = 86400; // 1 day
export const FRESH_AUTH_PERIOD = 86400; // 1 day
export const GIVEAWAY_BOOST_PER_PREMIUM = 4;
export const GIVEAWAY_MAX_ADDITIONAL_CHANNELS = 10;
export const GIVEAWAY_MAX_ADDITIONAL_USERS = 10;
export const GIVEAWAY_MAX_ADDITIONAL_COUNTRIES = 10;
export const LIGHT_THEME_BG_COLOR = '#99BA92';
export const DARK_THEME_BG_COLOR = '#0F0F0F';

View File

@ -104,6 +104,7 @@ addActionHandler('clickBotInlineButton', (global, actions, payload): ActionRetur
return;
}
actions.openInvoice({
type: 'message',
chatId: chat.id,
messageId,
tabId,

View File

@ -1403,11 +1403,13 @@ addActionHandler('openTelegramLink', (global, actions, payload): ActionReturnTyp
});
} else if (part1.startsWith('$')) {
openInvoice({
type: 'slug',
slug: part1.substring(1),
tabId,
});
} else if (part1 === 'invoice') {
openInvoice({
type: 'slug',
slug: part2,
tabId,
});

View File

@ -7,9 +7,11 @@ import { DEBUG_PAYMENT_SMART_GLOCAL } from '../../../config';
import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { buildCollectionByKey, unique } from '../../../util/iteratees';
import * as langProvider from '../../../util/langProvider';
import { getStripeError } from '../../../util/payments/stripe';
import { buildQueryString } from '../../../util/requestQuery';
import { callApi } from '../../../api/gramjs';
import { getStripeError, isChatChannel, isChatSuperGroup } from '../../helpers';
import { isChatChannel, isChatSuperGroup } from '../../helpers';
import { getRequestInputInvoice } from '../../helpers/payments';
import { addActionHandler, getGlobal, setGlobal } from '../../index';
import {
addChats,
@ -48,38 +50,24 @@ addActionHandler('validateRequestedInfo', (global, actions, payload): ActionRetu
return;
}
if ('slug' in inputInvoice) {
void validateRequestedInfo(global, inputInvoice, requestInfo, saveInfo, tabId);
} else {
const chat = selectChat(global, inputInvoice.chatId);
if (!chat) {
return;
}
void validateRequestedInfo(global, {
chat,
messageId: inputInvoice.messageId,
}, requestInfo, saveInfo, tabId);
const requestInputInvoice = getRequestInputInvoice(global, inputInvoice);
if (!requestInputInvoice) {
return;
}
validateRequestedInfo(global, requestInputInvoice, requestInfo, saveInfo, tabId);
});
addActionHandler('openInvoice', async (global, actions, payload): Promise<void> => {
const { tabId = getCurrentTabId() } = payload;
let invoice: ApiInvoice | undefined;
if ('slug' in payload) {
invoice = await getPaymentForm(global, { slug: payload.slug }, tabId);
} else {
const chat = selectChat(global, payload.chatId);
if (!chat) {
return;
}
const { tabId = getCurrentTabId(), ...inputInvoice } = payload;
invoice = await getPaymentForm(global, {
chat,
messageId: payload.messageId,
}, tabId);
const requestInputInvoice = getRequestInputInvoice(global, inputInvoice);
if (!requestInputInvoice) {
return;
}
const invoice = await getPaymentForm(global, requestInputInvoice, tabId);
if (!invoice) {
return;
}
@ -200,21 +188,9 @@ addActionHandler('sendPaymentForm', async (global, actions, payload): Promise<vo
return;
}
let requestInputInvoice;
if ('slug' in inputInvoice) {
requestInputInvoice = {
slug: inputInvoice.slug,
};
} else {
const chat = selectChat(global, inputInvoice.chatId);
if (!chat) {
return;
}
requestInputInvoice = {
chat,
messageId: inputInvoice.messageId,
};
const requestInputInvoice = getRequestInputInvoice(global, inputInvoice);
if (!requestInputInvoice) {
return;
}
global = updatePayment(global, { status: 'pending' }, tabId);
@ -406,6 +382,46 @@ addActionHandler('openPremiumModal', async (global, actions, payload): Promise<v
actions.closeReactionPicker({ tabId });
});
addActionHandler('openGiveawayModal', async (global, actions, payload): Promise<void> => {
const {
chatId, prepaidGiveaway,
tabId = getCurrentTabId(),
} = payload;
const chat = selectChat(global, chatId);
if (!chat) return;
const result = await callApi('getPremiumGiftCodeOptions', {
chat,
});
if (!result) {
return;
}
global = getGlobal();
const isOpen = Boolean(chatId);
global = updateTabState(global, {
giveawayModal: {
chatId,
gifts: result,
isOpen,
prepaidGiveaway,
},
}, tabId);
setGlobal(global);
});
addActionHandler('closeGiveawayModal', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
giveawayModal: undefined,
}, tabId);
});
addActionHandler('openGiftPremiumModal', async (global, actions, payload): Promise<void> => {
const { forUserId, tabId = getCurrentTabId() } = payload || {};
const result = await callApi('fetchPremiumPromo');
@ -484,7 +500,7 @@ addActionHandler('openBoostModal', async (global, actions, payload): Promise<voi
}, tabId);
setGlobal(global);
const result = await callApi('fetchBoostsStatus', {
const result = await callApi('fetchBoostStatus', {
chat,
});
@ -535,8 +551,8 @@ addActionHandler('openBoostStatistics', async (global, actions, payload): Promis
setGlobal(global);
const [boostsListResult, boostStatusResult] = await Promise.all([
callApi('fetchBoostsList', { chat }),
callApi('fetchBoostsStatus', { chat }),
callApi('fetchBoostList', { chat }),
callApi('fetchBoostStatus', { chat }),
]);
global = getGlobal();
@ -578,7 +594,7 @@ addActionHandler('loadMoreBoosters', async (global, actions, payload): Promise<v
}, tabId);
setGlobal(global);
const result = await callApi('fetchBoostsList', {
const result = await callApi('fetchBoostList', {
chat,
offset: tabState.boostStatistics.nextOffset,
});
@ -754,3 +770,36 @@ addActionHandler('applyGiftCode', async (global, actions, payload): Promise<void
actions.requestConfetti({ tabId });
actions.closeGiftCodeModal({ tabId });
});
addActionHandler('launchPrepaidGiveaway', async (global, actions, payload): Promise<void> => {
const {
chatId, giveawayId, paymentPurpose, tabId = getCurrentTabId(),
} = payload;
const chat = selectChat(global, chatId);
if (!chat) return;
const additionalChannels = paymentPurpose?.additionalChannelIds?.map((id) => selectChat(global, id)).filter(Boolean);
const result = await callApi('launchPrepaidGiveaway', {
chat,
giveawayId,
paymentPurpose: {
type: 'giveaway',
chat,
areWinnersVisible: paymentPurpose?.areWinnersVisible,
additionalChannels,
countries: paymentPurpose?.countries,
prizeDescription: paymentPurpose.prizeDescription,
untilDate: paymentPurpose.untilDate,
currency: paymentPurpose.currency,
amount: paymentPurpose.amount,
},
});
if (!result) {
return;
}
actions.openBoostStatistics({ chatId, tabId });
});

View File

@ -4,7 +4,6 @@ export * from './messages';
export * from './messageSummary';
export * from './messageMedia';
export * from './localSearch';
export * from './payments';
export * from './reactions';
export * from './bots';
export * from './media';

View File

@ -1,51 +1,73 @@
import type { ApiFieldError } from '../../api/types';
import type { ApiInputInvoice, ApiRequestInputInvoice } from '../../api/types';
import type { GlobalState } from '../types';
const STRIPE_ERRORS: Record<string, ApiFieldError> = {
missing_payment_information: {
field: 'cardNumber',
message: 'Incorrect card number',
},
invalid_number: {
field: 'cardNumber',
message: 'Incorrect card number',
},
number: {
field: 'cardNumber',
message: 'Incorrect card number',
},
exp_year: {
field: 'expiry',
message: 'Incorrect year',
},
exp_month: {
field: 'expiry',
message: 'Incorrect month',
},
invalid_expiry_year: {
field: 'expiry',
message: 'Incorrect year',
},
invalid_expiry_month: {
field: 'expiry',
message: 'Incorrect month',
},
cvc: {
field: 'cvv',
message: 'Incorrect CVV',
},
invalid_cvc: {
field: 'cvv',
message: 'Incorrect CVV',
},
};
import { selectChat, selectUser } from '../selectors';
export function getStripeError(error: {
code: string;
message: string;
param?: string;
}) {
const { message: description, code, param } = error;
const { field, message } = param ? STRIPE_ERRORS[param] : STRIPE_ERRORS[code];
export function getRequestInputInvoice<T extends GlobalState>(
global: T, inputInvoice: ApiInputInvoice,
): ApiRequestInputInvoice | undefined {
if (inputInvoice.type === 'slug') return inputInvoice;
return { field, message, description };
if (inputInvoice.type === 'message') {
const chat = selectChat(global, inputInvoice.chatId);
if (!chat) {
return undefined;
}
return {
type: 'message',
chat,
messageId: inputInvoice.messageId,
};
}
if (inputInvoice.type === 'giftcode') {
const {
userIds, boostChannelId, amount, currency, option,
} = inputInvoice;
const users = userIds.map((id) => selectUser(global, id)).filter(Boolean);
const boostChannel = boostChannelId ? selectChat(global, boostChannelId) : undefined;
return {
type: 'giveaway',
option,
purpose: {
type: 'giftcode',
amount,
currency,
users,
boostChannel,
},
};
}
if (inputInvoice.type === 'giveaway') {
const {
chatId, additionalChannelIds, amount, currency, option, untilDate, areWinnersVisible, countries,
isOnlyForNewSubscribers, prizeDescription,
} = inputInvoice;
const chat = selectChat(global, chatId);
if (!chat) {
return undefined;
}
const additionalChannels = additionalChannelIds?.map((id) => selectChat(global, id)).filter(Boolean);
return {
type: 'giveaway',
option,
purpose: {
type: 'giveaway',
amount,
currency,
chat,
additionalChannels,
untilDate,
areWinnersVisible,
countries,
isOnlyForNewSubscribers,
prizeDescription,
},
};
}
return undefined;
}

View File

@ -1,7 +1,7 @@
import type { GlobalState, TabArgs } from '../types';
import { getCurrentTabId } from '../../util/establishMultitabRole';
import { buildChatThreadKey } from '../helpers';
import { buildChatThreadKey } from '../helpers/localSearch';
import { selectCurrentMessageList } from './messages';
import { selectTabState } from './tabs';

View File

@ -4,7 +4,7 @@ import type { GlobalState, TabArgs } from '../types';
import { NewChatMembersProgress, RightColumnContent } from '../../types';
import { getCurrentTabId } from '../../util/establishMultitabRole';
import { getMessageVideo, getMessageWebPageVideo } from '../helpers';
import { getMessageVideo, getMessageWebPageVideo } from '../helpers/messageMedia';
import { selectCurrentTextSearch } from './localSearch';
import { selectCurrentManagement } from './management';
import { selectIsStatisticsShown } from './statistics';

View File

@ -31,6 +31,10 @@ export function selectIsPremiumPurchaseBlocked<T extends GlobalState>(global: T)
return global.appConfig?.isPremiumPurchaseBlocked ?? true;
}
export function selectIsGiveawayGiftsPurchaseAvailable<T extends GlobalState>(global: T) {
return global.appConfig?.isGiveawayGiftsPurchaseAvailable ?? true;
}
// Slow, not to be used in `withGlobal`
export function selectUserByPhoneNumber<T extends GlobalState>(global: T, phoneNumber: string) {
const phoneNumberCleaned = phoneNumber.replace(/[^0-9]/g, '');

View File

@ -44,7 +44,9 @@ import type {
ApiPhoneCall,
ApiPhoto,
ApiPostStatistics,
ApiPremiumGiftCodeOption,
ApiPremiumPromo,
ApiPrepaidGiveaway,
ApiQuickReply,
ApiReaction,
ApiReactionKey,
@ -625,6 +627,15 @@ export type TabState = {
isSuccess?: boolean;
};
giveawayModal?: {
chatId: string;
isOpen?: boolean;
gifts?: ApiPremiumGiftCodeOption[];
selectedMemberIds?: string[];
selectedChannelIds?: string[];
prepaidGiveaway?: ApiPrepaidGiveaway;
};
giftPremiumModal?: {
isOpen?: boolean;
forUserId?: string;
@ -1989,6 +2000,20 @@ export interface ActionPayloads {
} & WithTabId;
closeGiftCodeModal: WithTabId | undefined;
launchPrepaidGiveaway: {
chatId: string;
giveawayId: string;
paymentPurpose: {
additionalChannelIds?: string[];
areWinnersVisible?: boolean;
countries?: string[];
prizeDescription?: string;
untilDate: number;
currency: string;
amount: number;
};
} & WithTabId;
checkChatlistInvite: {
slug: string;
} & WithTabId;
@ -2914,6 +2939,13 @@ export interface ActionPayloads {
} & WithTabId) | undefined;
closePremiumModal: WithTabId | undefined;
openGiveawayModal: ({
chatId: string;
gifts?: number[] | undefined;
prepaidGiveaway?: ApiPrepaidGiveaway | undefined;
} & WithTabId);
closeGiveawayModal: WithTabId | undefined;
transcribeAudio: {
chatId: string;
messageId: number;

View File

@ -1525,9 +1525,11 @@ 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.getPremiumGiftCodeOptions#2757ba54 flags:# boost_peer:flags.0?InputPeer = Vector<PremiumGiftCodeOption>;
payments.checkGiftCode#8e51b4c1 slug:string = payments.CheckedGiftCode;
payments.applyGiftCode#f6e26854 slug:string = Updates;
payments.getGiveawayInfo#f4239425 peer:InputPeer msg_id:int = payments.GiveawayInfo;
payments.launchPrepaidGiveaway#5ff58f20 peer:InputPeer giveaway_id:long purpose:InputStorePaymentPurpose = Updates;
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;

View File

@ -343,5 +343,7 @@
"premium.getBoostsList",
"payments.checkGiftCode",
"payments.applyGiftCode",
"payments.getGiveawayInfo"
"payments.getGiveawayInfo",
"payments.getPremiumGiftCodeOptions",
"payments.launchPrepaidGiveaway"
]

View File

@ -101,8 +101,8 @@ $color-message-story-mention-to: #74bcff;
--color-primary-shade: #{color.mix($color-primary, $color-black, 92%)};
--color-primary-shade-darker: #{color.mix($color-primary, $color-black, 84%)};
--color-primary-shade-rgb: #{toRGB(color.mix($color-primary, $color-black, 92%))};
--color-primary-opacity: rgba(var(--color-primary), 0.25);
--color-primary-opacity-hover: rgba(var(--color-primary), 0.15);
--color-primary-opacity: rgba(var(--color-primary), 0.15);
--color-primary-opacity-hover: rgba(var(--color-primary), 0.25);
--color-primary-tint: rgba(var(--color-primary), 0.1);
--color-green: #{$color-green};
--color-green-darker: #{color.mix($color-green, $color-black, 84%)};

View File

@ -1,7 +1,7 @@
{
"--color-primary": ["#3390EC", "#8774E1"],
"--color-primary-opacity": ["#50A2E940", "#8378DB80"],
"--color-primary-opacity-hover": ["#50A2E926", "#8378DBA0"],
"--color-primary-opacity": ["#50A2E91E", "#8378DB1E"],
"--color-primary-opacity-hover": ["#50A2E940", "#8378DB40"],
"--color-primary-tint": ["#3390ec1a", "#8774e11a"],
"--color-primary-shade": ["#4a95d6", "#7b71c6"],
"--color-background": ["#FFFFFF", "#212121"],

View File

@ -150,7 +150,7 @@ export const processDeepLink = (url: string): boolean => {
case 'invoice': {
const { slug } = params;
openInvoice({ slug });
openInvoice({ type: 'slug', slug });
break;
}

View File

@ -0,0 +1,51 @@
import type { ApiFieldError } from '../../api/types';
const STRIPE_ERRORS: Record<string, ApiFieldError> = {
missing_payment_information: {
field: 'cardNumber',
message: 'Incorrect card number',
},
invalid_number: {
field: 'cardNumber',
message: 'Incorrect card number',
},
number: {
field: 'cardNumber',
message: 'Incorrect card number',
},
exp_year: {
field: 'expiry',
message: 'Incorrect year',
},
exp_month: {
field: 'expiry',
message: 'Incorrect month',
},
invalid_expiry_year: {
field: 'expiry',
message: 'Incorrect year',
},
invalid_expiry_month: {
field: 'expiry',
message: 'Incorrect month',
},
cvc: {
field: 'cvv',
message: 'Incorrect CVV',
},
invalid_cvc: {
field: 'cvv',
message: 'Incorrect CVV',
},
};
export function getStripeError(error: {
code: string;
message: string;
param?: string;
}) {
const { message: description, code, param } = error;
const { field, message } = param ? STRIPE_ERRORS[param] : STRIPE_ERRORS[code];
return { field, message, description };
}