From 180aef57bfe097c3edd9f57475a3824c8f691846 Mon Sep 17 00:00:00 2001 From: zubiden <19638254+zubiden@users.noreply.github.com> Date: Sun, 5 Jan 2025 20:18:43 +0100 Subject: [PATCH] Support unique gifts (#5394) --- src/api/gramjs/apiBuilders/messages.ts | 3 +- src/api/gramjs/apiBuilders/payments.ts | 116 ++++++- src/api/gramjs/methods/payments.ts | 51 +-- src/api/types/payments.ts | 59 +++- src/assets/localization/fallback.strings | 16 + src/components/common/BadgeButton.module.scss | 5 +- src/components/common/BadgeButton.tsx | 2 +- src/components/common/PeerChip.tsx | 6 +- .../common/gift/GiftRibbon.module.scss | 2 +- .../common/gift/UserGift.module.scss | 15 +- src/components/common/gift/UserGift.tsx | 65 +++- src/components/common/helpers/gifts.ts | 56 ++++ .../RadialPatternBackground.module.scss | 18 + .../profile/RadialPatternBackground.tsx | 167 ++++++++++ src/components/middle/ActionMessage.tsx | 12 +- .../modals/common/TableInfoModal.tsx | 3 +- src/components/modals/gift/GiftItemStar.tsx | 42 +-- src/components/modals/gift/GiftModal.tsx | 60 ++-- .../modals/gift/StarGiftCategoryList.tsx | 12 +- .../gift/info/GiftInfoModal.module.scss | 34 ++ .../modals/gift/info/GiftInfoModal.tsx | 307 ++++++++++++++---- .../gift/recipient/GiftRecipientPicker.tsx | 8 +- .../modals/stars/helpers/transaction.ts | 1 + .../transaction/StarsTransactionItem.tsx | 4 + .../transaction/StarsTransactionModal.tsx | 39 ++- .../suggestedStatus/SuggestedStatusModal.tsx | 1 + src/components/right/Profile.tsx | 7 +- src/global/actions/api/stars.ts | 23 +- src/global/cache.ts | 5 - src/global/initialState.ts | 4 +- src/global/selectors/symbols.ts | 4 - src/global/types/globalState.ts | 7 +- src/types/index.ts | 6 +- src/types/language.d.ts | 36 ++ 34 files changed, 937 insertions(+), 259 deletions(-) create mode 100644 src/components/common/helpers/gifts.ts create mode 100644 src/components/common/profile/RadialPatternBackground.module.scss create mode 100644 src/components/common/profile/RadialPatternBackground.tsx diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index 3dcb832e0..a3dbfa7cf 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -374,8 +374,7 @@ function buildApiMessageActionStarGift(action: GramJs.MessageActionStarGift) : A isNameHidden: Boolean(nameHidden), isSaved: Boolean(saved), isConverted: Boolean(converted), - // ToDo: Use `!` temporarily to support layer 196 - gift: buildApiStarGift(gift)!, + gift: buildApiStarGift(gift), message: message && buildApiFormattedText(message), starsToConvert: convertStars?.toJSNumber(), }; diff --git a/src/api/gramjs/apiBuilders/payments.ts b/src/api/gramjs/apiBuilders/payments.ts index 970305228..6326a521e 100644 --- a/src/api/gramjs/apiBuilders/payments.ts +++ b/src/api/gramjs/apiBuilders/payments.ts @@ -20,6 +20,7 @@ import type { ApiPrepaidStarsGiveaway, ApiReceipt, ApiStarGift, + ApiStarGiftAttribute, ApiStarGiveawayOption, ApiStarsAmount, ApiStarsGiveawayWinnerOption, @@ -31,13 +32,15 @@ import type { BoughtPaidMedia, } from '../../types'; -import { addWebDocumentToLocalDb } from '../helpers'; +import { numberToHexColor } from '../../../util/colors'; +import { addDocumentToLocalDb, addWebDocumentToLocalDb } from '../helpers'; import { buildApiStarsSubscriptionPricing } from './chats'; import { buildApiFormattedText, buildApiMessageEntity } from './common'; import { omitVirtualClassFields } from './helpers'; import { buildApiDocument, buildApiWebDocument, buildMessageMediaContent } from './messageContent'; import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers'; import { buildStatisticsPercentage } from './statistics'; +import { buildStickerFromDocument } from './symbols'; export function buildShippingOptions(shippingOptions: GramJs.ShippingOption[] | undefined) { if (!shippingOptions) { @@ -536,7 +539,7 @@ export function buildApiStarsTransactionPeer(peer: GramJs.TypeStarsTransactionPe export function buildApiStarsTransaction(transaction: GramJs.StarsTransaction): ApiStarsTransaction { const { date, id, peer, stars, description, photo, title, refund, extendedMedia, failed, msgId, pending, gift, reaction, - subscriptionPeriod, stargift, giveawayPostId, starrefCommissionPermille, + subscriptionPeriod, stargift, giveawayPostId, starrefCommissionPermille, stargiftUpgrade, } = transaction; if (photo) { @@ -547,7 +550,6 @@ export function buildApiStarsTransaction(transaction: GramJs.StarsTransaction): .filter(Boolean) as BoughtPaidMedia[]; const starRefCommision = starrefCommissionPermille ? starrefCommissionPermille / 10 : undefined; - const supportedStarGift = (stargift instanceof GramJs.StarGift) ? stargift : undefined; return { id, @@ -565,9 +567,10 @@ export function buildApiStarsTransaction(transaction: GramJs.StarsTransaction): extendedMedia: boughtExtendedMedia, subscriptionPeriod, isReaction: reaction, - starGift: supportedStarGift && buildApiStarGift(supportedStarGift), + starGift: stargift && buildApiStarGift(stargift), giveawayPostId, starRefCommision, + isGiftUpgrade: stargiftUpgrade, }; } @@ -610,18 +613,38 @@ export function buildApiStarTopupOption(option: GramJs.TypeStarsTopupOption): Ap }; } -export function buildApiStarGift(startGift: GramJs.TypeStarGift): ApiStarGift | undefined { - const isTypeSupported = startGift instanceof GramJs.StarGift; - if (!isTypeSupported) return undefined; +export function buildApiStarGift(starGift: GramJs.TypeStarGift): ApiStarGift { + if (starGift instanceof GramJs.StarGiftUnique) { + const { + id, num, ownerId, title, attributes, availabilityIssued, availabilityTotal, + } = starGift; + + return { + type: 'starGiftUnique', + id: id.toString(), + number: num, + ownerId: buildApiPeerId(ownerId, 'user'), + attributes: attributes.map(buildApiStarGiftAttribute).filter(Boolean), + title, + totalCount: availabilityTotal, + issuedCount: availabilityIssued, + }; + } + const { - id, limited, sticker, stars, availabilityRemains, availabilityTotal, convertStars, firstSaleDate, lastSaleDate, - soldOut, - } = startGift; + id, limited, stars, availabilityRemains, availabilityTotal, convertStars, firstSaleDate, lastSaleDate, soldOut, + birthday, upgradeStars, + } = starGift; + + addDocumentToLocalDb(starGift.sticker); + + const sticker = buildStickerFromDocument(starGift.sticker)!; return { + type: 'starGift', id: id.toString(), isLimited: limited, - stickerId: sticker.id.toString(), + sticker, stars: stars.toJSNumber(), availabilityRemains, availabilityTotal, @@ -629,17 +652,84 @@ export function buildApiStarGift(startGift: GramJs.TypeStarGift): ApiStarGift | firstSaleDate, lastSaleDate, isSoldOut: soldOut, + isBirthday: birthday, + upgradeStars: upgradeStars?.toJSNumber(), }; } +export function buildApiStarGiftAttribute(attribute: GramJs.TypeStarGiftAttribute): ApiStarGiftAttribute | undefined { + if (attribute instanceof GramJs.StarGiftAttributeModel) { + const sticker = buildStickerFromDocument(attribute.document); + if (!sticker) { + return undefined; + } + + addDocumentToLocalDb(attribute.document); + + return { + type: 'model', + name: attribute.name, + rarityPercent: attribute.rarityPermille / 10, + sticker, + }; + } + + if (attribute instanceof GramJs.StarGiftAttributePattern) { + const sticker = buildStickerFromDocument(attribute.document); + if (!sticker) { + return undefined; + } + + addDocumentToLocalDb(attribute.document); + + return { + type: 'pattern', + name: attribute.name, + rarityPercent: attribute.rarityPermille / 10, + sticker, + }; + } + + if (attribute instanceof GramJs.StarGiftAttributeBackdrop) { + const { + name, rarityPermille, centerColor, edgeColor, patternColor, textColor, + } = attribute; + + return { + type: 'backdrop', + name, + rarityPercent: rarityPermille / 10, + centerColor: numberToHexColor(centerColor), + edgeColor: numberToHexColor(edgeColor), + patternColor: numberToHexColor(patternColor), + textColor: numberToHexColor(textColor), + }; + } + + if (attribute instanceof GramJs.StarGiftAttributeOriginalDetails) { + const { + date, recipientId, message, senderId, + } = attribute; + + return { + type: 'originalDetails', + date, + recipientId: recipientId && buildApiPeerId(recipientId, 'user'), + message: message && buildApiFormattedText(message), + senderId: senderId && buildApiPeerId(senderId, 'user'), + }; + } + + return undefined; +} + export function buildApiUserStarGift(userStarGift: GramJs.UserStarGift): ApiUserStarGift { const { gift, date, convertStars, fromId, message, msgId, nameHidden, unsaved, } = userStarGift; return { - // ToDo: Use `!` temporarily to support layer 196 - gift: buildApiStarGift(gift)!, + gift: buildApiStarGift(gift), date, starsToConvert: convertStars?.toJSNumber(), fromId: fromId && buildApiPeerId(fromId, 'user'), diff --git a/src/api/gramjs/methods/payments.ts b/src/api/gramjs/methods/payments.ts index 37e70fca5..ce9fa1ecd 100644 --- a/src/api/gramjs/methods/payments.ts +++ b/src/api/gramjs/methods/payments.ts @@ -2,8 +2,12 @@ import BigInt from 'big-integer'; import { Api as GramJs } from '../../../lib/gramjs'; import type { - ApiChat, ApiInputStorePaymentPurpose, ApiPeer, ApiRequestInputInvoice, - ApiSticker, ApiThemeParameters, + ApiChat, + ApiInputStorePaymentPurpose, + ApiPeer, + ApiRequestInputInvoice, + ApiStarGiftRegular, + ApiThemeParameters, ApiUser, } from '../../types'; @@ -29,7 +33,6 @@ import { buildShippingOptions, } from '../apiBuilders/payments'; import { buildApiPeerId } from '../apiBuilders/peers'; -import { buildStickerFromDocument } from '../apiBuilders/symbols'; import { buildInputInvoice, buildInputPeer, buildInputStorePaymentPurpose, buildInputThemeParams, buildShippingInfo, } from '../gramjsBuilders'; @@ -430,22 +433,8 @@ export async function fetchStarGifts() { return undefined; } - const gifts = result.gifts.map(buildApiStarGift).filter(Boolean); - const stickers : Record = {}; - - result.gifts.forEach((gift) => { - if (!(gift instanceof GramJs.StarGift)) return; - if (gift.sticker instanceof GramJs.Document) { - localDb.documents[String(gift.sticker.id)] = gift.sticker; - } - - const sticker = buildStickerFromDocument(gift.sticker); - if (sticker) { - stickers[sticker.id] = sticker; - } - }); - - return { gifts, stickers }; + // Right now, only regular star gifts can be bought, but API are not specific + return result.gifts.map(buildApiStarGift).filter((gift): gift is ApiStarGiftRegular => gift.type === 'starGift'); } export async function fetchUserStarGifts({ @@ -467,12 +456,10 @@ export async function fetchUserStarGifts({ return undefined; } - const supportedGifts = result.gifts.filter( - ((gift) => gift instanceof GramJs.StarGift), - ).map(buildApiUserStarGift); + const gifts = result.gifts.map(buildApiUserStarGift); return { - gifts: supportedGifts, + gifts, nextOffset: result.nextOffset, }; } @@ -528,13 +515,10 @@ export async function fetchStarsStatus() { if (!result) { return undefined; } - const supportedHistory = result.history?.filter( - (transaction) => !(transaction.stargift instanceof GramJs.StarGiftUnique), - ); return { nextHistoryOffset: result.nextOffset, - history: supportedHistory?.map(buildApiStarsTransaction), + history: result.history?.map(buildApiStarsTransaction), nextSubscriptionOffset: result.subscriptionsNextOffset, subscriptions: result.subscriptions?.map(buildApiStarsSubscription), balance: buildApiStarsAmount(result.balance), @@ -564,13 +548,9 @@ export async function fetchStarsTransactions({ return undefined; } - const supportedHistory = result.history?.filter( - (transaction) => !(transaction.stargift instanceof GramJs.StarGiftUnique), - ); - return { nextOffset: result.nextOffset, - history: supportedHistory?.map(buildApiStarsTransaction), + history: result.history?.map(buildApiStarsTransaction), balance: buildApiStarsAmount(result.balance), }; } @@ -589,15 +569,12 @@ export async function fetchStarsTransactionById({ })], })); - const supportedHistory = result?.history?.filter( - (transaction) => !(transaction.stargift instanceof GramJs.StarGiftUnique), - ); - if (!supportedHistory?.[0]) { + if (!result?.history?.[0]) { return undefined; } return { - transaction: buildApiStarsTransaction(supportedHistory[0]), + transaction: buildApiStarsTransaction(result?.history[0]), }; } diff --git a/src/api/types/payments.ts b/src/api/types/payments.ts index a62fa18f3..cee796f4e 100644 --- a/src/api/types/payments.ts +++ b/src/api/types/payments.ts @@ -7,6 +7,7 @@ import type { ApiInvoice, ApiMessageEntity, ApiPaymentCredentials, + ApiSticker, BoughtPaidMedia, } from './messages'; import type { StatisticsOverviewPercentage } from './statistics'; @@ -189,10 +190,11 @@ export type ApiInputStorePaymentStarsGiveaway = { export type ApiInputStorePaymentPurpose = ApiInputStorePaymentGiveaway | ApiInputStorePaymentGiftcode | ApiInputStorePaymentStarsTopup | ApiInputStorePaymentStarsGift | ApiInputStorePaymentStarsGiveaway; -export type ApiStarGift = { +export interface ApiStarGiftRegular { + type: 'starGift'; isLimited?: true; id: string; - stickerId: string; + sticker: ApiSticker; stars: number; availabilityRemains?: number; availabilityTotal?: number; @@ -200,7 +202,57 @@ export type ApiStarGift = { isSoldOut?: true; firstSaleDate?: number; lastSaleDate?: number; -}; + isBirthday?: true; + upgradeStars?: number; +} + +export interface ApiStarGiftUnique { + type: 'starGiftUnique'; + id: string; + title: string; + number: number; + ownerId: string; + issuedCount: number; + totalCount: number; + attributes: ApiStarGiftAttribute[]; +} + +export type ApiStarGift = ApiStarGiftRegular | ApiStarGiftUnique; + +export interface ApiStarGiftAttributeModel { + type: 'model'; + name: string; + rarityPercent: number; + sticker: ApiSticker; +} + +export interface ApiStarGiftAttributePattern { + type: 'pattern'; + name: string; + rarityPercent: number; + sticker: ApiSticker; +} + +export interface ApiStarGiftAttributeBackdrop { + type: 'backdrop'; + name: string; + centerColor: string; + edgeColor: string; + patternColor: string; + textColor: string; + rarityPercent: number; +} + +export interface ApiStarGiftAttributeOriginalDetails { + type: 'originalDetails'; + senderId?: string; + recipientId: string; + date: number; + message?: ApiFormattedText; +} + +export type ApiStarGiftAttribute = ApiStarGiftAttributeModel | ApiStarGiftAttributePattern +| ApiStarGiftAttributeBackdrop | ApiStarGiftAttributeOriginalDetails; export interface ApiUserStarGift { isNameHidden?: boolean; @@ -371,6 +423,7 @@ export interface ApiStarsTransaction { extendedMedia?: BoughtPaidMedia[]; subscriptionPeriod?: number; starRefCommision?: number; + isGiftUpgrade?: true; } export interface ApiStarsSubscription { diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 3cf79d48a..fb70cee5d 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -1298,7 +1298,9 @@ "GiftPremiumDescriptionLinkCaption" = "See Features >"; "GiftPremiumDescriptionLink" = "https://telegram.org/faq_premium"; "StarsGiftHeader" = "Send a Gift"; +"StarsGiftHeaderSelf" = "Buy a Gift"; "StarGiftDescription" = "Give {user} gifts that can be kept on the profile or converted to Stars."; +"StarGiftDescriptionSelf" = "Buy yourself a gift to display on your page or reserve for later.\n\nLimited-edition gifts upgraded to collectibles can be gifted to others later."; "GiftLimited" = "limited"; "GiftDiscount" = "-{percent}%"; "GiftSoldCount" = "{count} sold"; @@ -1342,11 +1344,25 @@ "GiftInfoSoldOutTitle" = "Unavailable"; "GiftInfoSoldOutDescription" = "This gift has been sold out"; "GiftInfoSenderHidden" = "Only you can see the sender's name and message."; +"GiftInfoOwner" = "Owner"; +"GiftInfoAvailability" = "Availability"; +"GiftInfoIssued" = "{issued}/{total} issued"; +"GiftInfoCollectible" = "Collectible #{number}"; +"GiftAttributeModel" = "Model"; +"GiftAttributeBackdrop" = "Backdrop"; +"GiftAttributeSymbol" = "Symbol"; +"GiftInfoOriginalInfo" = "Gifted to {user} on {date}." +"GiftInfoOriginalInfoSender" = "Gifted by {sender} to {user} on {date}." +"GiftInfoOriginalInfoText" = "Gifted to {user} on {date} with comment \"{text}\"." +"GiftInfoOriginalInfoTextSender" = "Gifted by {sender} to {user} on {date} with comment \"{text}\"." +"GiftInfoStatus" = "Status"; +"GiftInfoStatusNonUnique" = "Non-Unique"; "StarsAmount" = "⭐️{amount}"; "StarsAmountText_one" = "{amount} Star"; "StarsAmountText_other" = "{amount} Stars"; "AllGiftsCategory" = "All gifts"; "LimitedGiftsCategory" = "Limited"; +"StockGiftsCategory" = "In Stock"; "PremiumGiftDescription" = "Premium"; "SendPaidReaction" = "Send ⭐️{amount}"; "StarsPay" = "Confirm and Pay {amount}"; diff --git a/src/components/common/BadgeButton.module.scss b/src/components/common/BadgeButton.module.scss index 8bd2fbc9b..d32c7ba50 100644 --- a/src/components/common/BadgeButton.module.scss +++ b/src/components/common/BadgeButton.module.scss @@ -6,9 +6,12 @@ background-color: var(--accent-background-active-color); color: var(--accent-color); - cursor: var(--custom-cursor, pointer); filter: brightness(1); transition: 150ms filter ease-in; +} + +.clickable { + cursor: var(--custom-cursor, pointer); &:hover { filter: brightness(1.1); diff --git a/src/components/common/BadgeButton.tsx b/src/components/common/BadgeButton.tsx index 68cdf9696..e2e179f9c 100644 --- a/src/components/common/BadgeButton.tsx +++ b/src/components/common/BadgeButton.tsx @@ -16,7 +16,7 @@ const BadgeButton = ({ onClick, }: OwnProps) => { return ( -
+
{children}
); diff --git a/src/components/common/PeerChip.tsx b/src/components/common/PeerChip.tsx index a56ddab5f..6c55ccbab 100644 --- a/src/components/common/PeerChip.tsx +++ b/src/components/common/PeerChip.tsx @@ -35,6 +35,7 @@ type OwnProps = { className?: string; fluid?: boolean; withPeerColors?: boolean; + withEmojiStatus?: boolean; clickArg?: T; onClick?: (arg: T) => void; }; @@ -59,6 +60,7 @@ const PeerChip = ({ fluid, isSavedMessages, withPeerColors, + withEmojiStatus, onClick, }: OwnProps & StateProps) => { const lang = useOldLang(); @@ -91,7 +93,9 @@ const PeerChip = ({ ); titleText = getPeerTitle(lang, anyPeer) || title; - titleElement = title || ; + titleElement = title || ( + + ); } const fullClassName = buildClassName( diff --git a/src/components/common/gift/GiftRibbon.module.scss b/src/components/common/gift/GiftRibbon.module.scss index ad3a5cfd2..175a6952f 100644 --- a/src/components/common/gift/GiftRibbon.module.scss +++ b/src/components/common/gift/GiftRibbon.module.scss @@ -12,7 +12,7 @@ left: 50%; transform: translate(-50%, -50%) translate(6px, -6px) rotate(45deg); - font-size: 0.6875rem; + font-size: 0.625rem; font-weight: var(--font-weight-semibold); color: var(--color-white); white-space: nowrap; diff --git a/src/components/common/gift/UserGift.module.scss b/src/components/common/gift/UserGift.module.scss index a2c0c85c3..2ece9c3e9 100644 --- a/src/components/common/gift/UserGift.module.scss +++ b/src/components/common/gift/UserGift.module.scss @@ -21,6 +21,7 @@ border-radius: 0.625rem; background-color: var(--color-hover-overlay); pointer-events: none; + z-index: 1; } &:hover::before { @@ -34,15 +35,6 @@ left: 0.25rem; } -.stars { - display: flex; - align-items: center; - gap: 0.125rem; - - color: #E88011; - font-weight: var(--font-weight-medium); -} - .hiddenGift { display: grid; place-items: center; @@ -60,3 +52,8 @@ font-size: 1.25rem; backdrop-filter: blur(0.5rem); } + +.radialPattern { + position: absolute; + inset: 0; +} diff --git a/src/components/common/gift/UserGift.tsx b/src/components/common/gift/UserGift.tsx index 0c591a871..49cbac0fc 100644 --- a/src/components/common/gift/UserGift.tsx +++ b/src/components/common/gift/UserGift.tsx @@ -1,20 +1,22 @@ -import React, { memo } from '../../../lib/teact/teact'; +import React, { memo, useMemo, useRef } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; -import type { ApiSticker, ApiUser, ApiUserStarGift } from '../../../api/types'; +import type { ApiUser, ApiUserStarGift } from '../../../api/types'; -import { STARS_CURRENCY_CODE } from '../../../config'; import { selectUser } from '../../../global/selectors'; -import { formatCurrency } from '../../../util/formatCurrency'; import { CUSTOM_PEER_HIDDEN } from '../../../util/objects/customPeer'; import { formatIntegerCompact } from '../../../util/textFormat'; +import { getGiftAttributes, getStickerFromGift, getTotalGiftAvailability } from '../helpers/gifts'; +import useFlag from '../../../hooks/useFlag'; +import { type ObserveFn, useOnIntersect } from '../../../hooks/useIntersectionObserver'; import useLastCallback from '../../../hooks/useLastCallback'; import useOldLang from '../../../hooks/useOldLang'; import AnimatedIconFromSticker from '../AnimatedIconFromSticker'; import Avatar from '../Avatar'; import Icon from '../icons/Icon'; +import RadialPatternBackground from '../profile/RadialPatternBackground'; import GiftRibbon from './GiftRibbon'; import styles from './UserGift.module.scss'; @@ -22,20 +24,28 @@ import styles from './UserGift.module.scss'; type OwnProps = { userId: string; gift: ApiUserStarGift; + observeIntersection?: ObserveFn; }; type StateProps = { fromPeer?: ApiUser; - sticker?: ApiSticker; }; const GIFT_STICKER_SIZE = 90; const UserGift = ({ - userId, gift, fromPeer, sticker, + userId, + gift, + fromPeer, + observeIntersection, }: OwnProps & StateProps) => { const { openGiftInfoModal } = getActions(); + // eslint-disable-next-line no-null/no-null + const ref = useRef(null); + + const [shouldPlay, play] = useFlag(); + const oldLang = useOldLang(); const handleClick = useLastCallback(() => { @@ -45,16 +55,48 @@ const UserGift = ({ }); }); + const handleOnIntersect = useLastCallback((entry: IntersectionObserverEntry) => { + if (entry.isIntersecting) play(); + }); + const avatarPeer = (gift.isNameHidden || !fromPeer) ? CUSTOM_PEER_HIDDEN : fromPeer; + const sticker = getStickerFromGift(gift.gift); + + const radialPatternBackdrop = useMemo(() => { + const { backdrop, pattern } = getGiftAttributes(gift.gift) || {}; + + if (!backdrop || !pattern) { + return undefined; + } + + const backdropColors = [backdrop.centerColor, backdrop.edgeColor]; + const patternColor = backdrop.patternColor; + + return ( + + ); + }, [gift.gift]); + + useOnIntersect(ref, observeIntersection, sticker ? handleOnIntersect : undefined); + if (!sticker) return undefined; + const totalIssued = getTotalGiftAvailability(gift.gift); + return ( -
+
+ {radialPatternBackdrop} @@ -63,13 +105,10 @@ const UserGift = ({
)} -
- {formatCurrency(gift.gift.stars, STARS_CURRENCY_CODE)} -
- {gift.gift.availabilityTotal && ( + {totalIssued && ( )}
@@ -78,11 +117,9 @@ const UserGift = ({ export default memo(withGlobal( (global, { gift }): StateProps => { - const sticker = global.stickers.starGifts.stickers[gift.gift.stickerId]; const fromPeer = gift.fromId ? selectUser(global, gift.fromId) : undefined; return { - sticker, fromPeer, }; }, diff --git a/src/components/common/helpers/gifts.ts b/src/components/common/helpers/gifts.ts new file mode 100644 index 000000000..c784e79be --- /dev/null +++ b/src/components/common/helpers/gifts.ts @@ -0,0 +1,56 @@ +import type { + ApiFormattedText, + ApiStarGift, + ApiStarGiftAttributeBackdrop, + ApiStarGiftAttributeModel, + ApiStarGiftAttributeOriginalDetails, + ApiStarGiftAttributePattern, + ApiSticker, +} from '../../../api/types'; + +export type GiftAttributes = { + model?: ApiStarGiftAttributeModel; + originalDetails?: ApiStarGiftAttributeOriginalDetails; + pattern?: ApiStarGiftAttributePattern; + backdrop?: ApiStarGiftAttributeBackdrop; +}; + +export function getStickerFromGift(gift: ApiStarGift): ApiSticker | undefined { + if (gift.type === 'starGift') { + return gift.sticker; + } + + return gift.attributes.find((attr): attr is ApiStarGiftAttributeModel => attr.type === 'model')?.sticker; +} + +export function getTotalGiftAvailability(gift: ApiStarGift): number | undefined { + if (gift.type === 'starGift') { + return gift.availabilityTotal; + } + + return gift.totalCount; +} + +export function getGiftMessage(gift: ApiStarGift): ApiFormattedText | undefined { + if (gift.type !== 'starGiftUnique') return undefined; + + return gift.attributes.find((attr): attr is ApiStarGiftAttributeOriginalDetails => attr.type === 'model')?.message; +} + +export function getGiftAttributes(gift: ApiStarGift): GiftAttributes | undefined { + if (gift.type !== 'starGiftUnique') return undefined; + + const model = gift.attributes.find((attr): attr is ApiStarGiftAttributeModel => attr.type === 'model'); + const backdrop = gift.attributes.find((attr): attr is ApiStarGiftAttributeBackdrop => attr.type === 'backdrop'); + const pattern = gift.attributes.find((attr): attr is ApiStarGiftAttributePattern => attr.type === 'pattern'); + const originalDetails = gift.attributes.find((attr): attr is ApiStarGiftAttributeOriginalDetails => ( + attr.type === 'originalDetails' + )); + + return { + model, + originalDetails, + pattern, + backdrop, + }; +} diff --git a/src/components/common/profile/RadialPatternBackground.module.scss b/src/components/common/profile/RadialPatternBackground.module.scss new file mode 100644 index 000000000..d6e252be0 --- /dev/null +++ b/src/components/common/profile/RadialPatternBackground.module.scss @@ -0,0 +1,18 @@ +.root { + border-radius: inherit; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(var(--_bg-1), var(--_bg-2)), radial-gradient(circle, #ffffff32, #ffffff00); + } +} + +.canvas { + width: 100%; + height: 100%; + object-fit: cover; + position: relative; +} diff --git a/src/components/common/profile/RadialPatternBackground.tsx b/src/components/common/profile/RadialPatternBackground.tsx new file mode 100644 index 000000000..baa7a83f4 --- /dev/null +++ b/src/components/common/profile/RadialPatternBackground.tsx @@ -0,0 +1,167 @@ +import React, { + memo, useEffect, useRef, useSignal, useState, +} from '../../../lib/teact/teact'; + +import type { ApiSticker } from '../../../api/types'; + +import { requestMutation } from '../../../lib/fasterdom/fasterdom'; +import { getStickerMediaHash } from '../../../global/helpers'; +import buildClassName from '../../../util/buildClassName'; +import buildStyle from '../../../util/buildStyle'; +import { preloadImage } from '../../../util/files'; + +import useLastCallback from '../../../hooks/useLastCallback'; +import useMedia from '../../../hooks/useMedia'; +import useResizeObserver from '../../../hooks/useResizeObserver'; +import { useSignalEffect } from '../../../hooks/useSignalEffect'; + +import styles from './RadialPatternBackground.module.scss'; + +type OwnProps = { + backgroundColors: string[]; + patternColor: string; + patternIcon: ApiSticker; + className?: string; +}; + +const RINGS = 3; +const BASE_RING_ITEM_COUNT = 8; +const RING_INCREMENT = 0.5; +const CENTER_EMPTINESS = 0.05; +const MAX_RADIUS = 0.5; +const BASE_ICON_SIZE = 20; + +const MIN_SIZE = 200; + +const PATTERN_POSITIONS = (() => { + const coordinates: { x: number; y: number; alpha: number; sizeFactor: number }[] = []; + for (let ring = 1; ring <= RINGS; ring++) { + const ringItemCount = Math.floor(BASE_RING_ITEM_COUNT * (1 + (ring - 1) * RING_INCREMENT)); + const ringProgress = ring / RINGS; + const ringRadius = CENTER_EMPTINESS + (MAX_RADIUS - CENTER_EMPTINESS) * ringProgress; + + for (let i = 0; i < ringItemCount; i++) { + const angle = (i / ringItemCount) * Math.PI * 2; + // Slightly oval + const xOffset = ringRadius * 1.71 * Math.cos(angle); + const yOffset = ringRadius * Math.sin(angle); + + const x = 0.5 + xOffset; + const y = 0.5 + yOffset; + const alpha = 0.2 + Math.min((1 - ringProgress + (Math.random() / 2 - 0.5)), 0) * 0.8; + + const sizeFactor = 1.4 - ringProgress * Math.random(); + + coordinates.push({ + x, y, alpha, sizeFactor, + }); + } + } + return coordinates; +})(); + +const RadialPatternBackground = ({ + backgroundColors, + patternColor, + patternIcon, + className, +}: OwnProps) => { + // eslint-disable-next-line no-null/no-null + const containerRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const canvasRef = useRef(null); + + const [getContainerSize, setContainerSize] = useSignal({ width: 0, height: 0 }); + + const [emojiImage, setEmojiImage] = useState(); + + const previewMediaHash = getStickerMediaHash(patternIcon, 'preview'); + const previewUrl = useMedia(previewMediaHash); + + useEffect(() => { + if (!previewUrl) return; + preloadImage(previewUrl).then(setEmojiImage); + }, [previewUrl]); + + useResizeObserver(containerRef, (entry) => { + setContainerSize({ + width: entry.contentRect.width, + height: entry.contentRect.height, + }); + }); + + useEffect(() => { + const container = containerRef.current; + if (container) { + setContainerSize({ + width: container.clientWidth, + height: container.clientHeight, + }); + } + }, [setContainerSize]); + + const draw = useLastCallback(() => { + const canvas = canvasRef.current; + if (!canvas || !emojiImage) return; + const ctx = canvas.getContext('2d')!; + const { width, height } = canvas; + if (!width || !height) return; + + ctx.save(); + PATTERN_POSITIONS.forEach(({ + x, y, alpha, sizeFactor, + }) => { + const centerShift = (width - Math.max(width, MIN_SIZE)) / 2; // Shift coords if canvas is smaller than `MIN_SIZE` + const renderX = x * Math.max(width, MIN_SIZE) + centerShift; + const renderY = y * Math.max(height, MIN_SIZE) + centerShift; + + const size = BASE_ICON_SIZE * sizeFactor * (centerShift ? 0.8 : 1); + + ctx.globalAlpha = alpha; + ctx.drawImage(emojiImage, renderX - size / 2, renderY - size / 2, size, size); + }); + ctx.restore(); + + ctx.save(); + ctx.fillStyle = patternColor; + ctx.globalCompositeOperation = 'source-atop'; + ctx.fillRect(0, 0, width, height); + ctx.restore(); + }); + + useEffect(() => { + draw(); + }, [emojiImage]); + + useSignalEffect(() => { + const { width, height } = getContainerSize(); + const canvas = canvasRef.current!; + if (!width || !height) { + return; + } + + const maxSide = Math.max(width, height); + const dpr = window.devicePixelRatio; + requestMutation(() => { + canvas.width = maxSide * dpr; + canvas.height = maxSide * dpr; + + draw(); + }); + }, [getContainerSize]); + + return ( +
+ +
+ ); +}; + +export default memo(RadialPatternBackground); diff --git a/src/components/middle/ActionMessage.tsx b/src/components/middle/ActionMessage.tsx index 8f83a0d82..83988c2db 100644 --- a/src/components/middle/ActionMessage.tsx +++ b/src/components/middle/ActionMessage.tsx @@ -21,7 +21,6 @@ import { selectGiftStickerForStars, selectIsCurrentUserPremium, selectIsMessageFocused, - selectStarGiftSticker, selectTabState, selectTheme, selectTopicFromMessage, @@ -81,7 +80,6 @@ type StateProps = { focusDirection?: FocusDirection; noFocusHighlight?: boolean; premiumGiftSticker?: ApiSticker; - starGiftSticker?: ApiSticker; starsGiftSticker?: ApiSticker; canPlayAnimatedEmojis?: boolean; patternColor?: string; @@ -108,7 +106,6 @@ const ActionMessage: FC = ({ focusDirection, noFocusHighlight, premiumGiftSticker, - starGiftSticker, starsGiftSticker, isInsideTopic, topic, @@ -471,7 +468,7 @@ const ActionMessage: FC = ({ function renderStarGift() { const starGift = message.content.action?.starGift; - if (!starGift) return undefined; + if (!starGift || starGift.gift.type === 'starGiftUnique') return undefined; return ( = ({ > = ({ > ( const giftDuration = content.action?.months; const premiumGiftSticker = selectGiftStickerForDuration(global, giftDuration); - const starGift = content.action?.type === 'starGift' ? content.action.starGift?.gift : undefined; const starCount = content.action?.stars; - const starGiftSticker = starGift?.stickerId ? selectStarGiftSticker(global, starGift.stickerId) : undefined; const starsGiftSticker = selectGiftStickerForStars(global, starCount); const topic = selectTopicFromMessage(global, message); @@ -658,7 +653,6 @@ export default memo(withGlobal( targetMessage, isFocused, premiumGiftSticker, - starGiftSticker, starsGiftSticker, topic, patternColor, diff --git a/src/components/modals/common/TableInfoModal.tsx b/src/components/modals/common/TableInfoModal.tsx index 50da4c6e3..ce275efa7 100644 --- a/src/components/modals/common/TableInfoModal.tsx +++ b/src/components/modals/common/TableInfoModal.tsx @@ -15,7 +15,7 @@ import Modal from '../../ui/Modal'; import styles from './TableInfoModal.module.scss'; -type ChatItem = { chatId: string }; +type ChatItem = { chatId: string; withEmojiStatus?: boolean }; export type TableData = [TeactNode | undefined, TeactNode | ChatItem][]; @@ -76,6 +76,7 @@ const TableInfoModal = ({ className={styles.chatItem} forceShowSelf fluid + withEmojiStatus={value.withEmojiStatus} clickArg={value.chatId} onClick={handleOpenChat} /> diff --git a/src/components/modals/gift/GiftItemStar.tsx b/src/components/modals/gift/GiftItemStar.tsx index 173ac25ce..64722f483 100644 --- a/src/components/modals/gift/GiftItemStar.tsx +++ b/src/components/modals/gift/GiftItemStar.tsx @@ -1,13 +1,14 @@ -import React, { memo } from '../../../lib/teact/teact'; -import { getActions, withGlobal } from '../../../global'; +import React, { memo, useRef } from '../../../lib/teact/teact'; +import { getActions } from '../../../global'; import type { - ApiStarGift, - ApiSticker, + ApiStarGiftRegular, } from '../../../api/types'; import buildClassName from '../../../util/buildClassName'; +import useFlag from '../../../hooks/useFlag'; +import { type ObserveFn, useOnIntersect } from '../../../hooks/useIntersectionObserver'; import useLang from '../../../hooks/useLang'; import useLastCallback from '../../../hooks/useLastCallback'; @@ -19,24 +20,27 @@ import Button from '../../ui/Button'; import styles from './GiftItem.module.scss'; export type OwnProps = { - gift: ApiStarGift; - onClick: (gift: ApiStarGift) => void; -}; - -export type StateProps = { - sticker?: ApiSticker; + gift: ApiStarGiftRegular; + observeIntersection?: ObserveFn; + onClick: (gift: ApiStarGiftRegular) => void; }; const GIFT_STICKER_SIZE = 90; -function GiftItemStar({ sticker, gift, onClick }: OwnProps & StateProps) { +function GiftItemStar({ gift, observeIntersection, onClick }: OwnProps) { const { openGiftInfoModal } = getActions(); + + // eslint-disable-next-line no-null/no-null + const ref = useRef(null); + const lang = useLang(); + const [shouldPlay, play] = useFlag(); const { stars, isLimited, isSoldOut, + sticker, } = gift; const handleGiftClick = useLastCallback(() => { @@ -48,10 +52,13 @@ function GiftItemStar({ sticker, gift, onClick }: OwnProps & StateProps) { onClick(gift); }); - if (!sticker) return undefined; + useOnIntersect(ref, observeIntersection, (entry) => { + if (entry.isIntersecting) play(); + }); return (
@@ -75,12 +83,4 @@ function GiftItemStar({ sticker, gift, onClick }: OwnProps & StateProps) { ); } -export default memo(withGlobal( - (global, { gift }): StateProps => { - const sticker = global.stickers.starGifts.stickers[gift.stickerId]; - - return { - sticker, - }; - }, -)(GiftItemStar)); +export default memo(GiftItemStar); diff --git a/src/components/modals/gift/GiftModal.tsx b/src/components/modals/gift/GiftModal.tsx index fb85b19f3..8a9c5bb44 100644 --- a/src/components/modals/gift/GiftModal.tsx +++ b/src/components/modals/gift/GiftModal.tsx @@ -6,7 +6,7 @@ import { getActions, withGlobal } from '../../../global'; import type { ApiPremiumGiftCodeOption, - ApiStarGift, + ApiStarGiftRegular, ApiStarsAmount, ApiUser, } from '../../../api/types'; @@ -18,6 +18,7 @@ import { selectUser } from '../../../global/selectors'; import buildClassName from '../../../util/buildClassName'; import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev'; +import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver'; import useLang from '../../../hooks/useLang'; import useLastCallback from '../../../hooks/useLastCallback'; import useOldLang from '../../../hooks/useOldLang'; @@ -41,17 +42,19 @@ export type OwnProps = { modal: TabState['giftModal']; }; -export type GiftOption = ApiPremiumGiftCodeOption | ApiStarGift; +export type GiftOption = ApiPremiumGiftCodeOption | ApiStarGiftRegular; type StateProps = { boostPerSentGift?: number; - starGiftsById?: Record; + starGiftsById?: Record; starGiftCategoriesByName: Record; starBalance?: ApiStarsAmount; user?: ApiUser; + isSelf?: boolean; }; const AVATAR_SIZE = 100; +const INTERSECTION_THROTTLE = 200; const PremiumGiftModal: FC = ({ modal, @@ -59,6 +62,7 @@ const PremiumGiftModal: FC = ({ starGiftCategoriesByName, starBalance, user, + isSelf, }) => { const { closeGiftModal, requestConfetti, @@ -70,6 +74,9 @@ const PremiumGiftModal: FC = ({ // eslint-disable-next-line no-null/no-null const giftHeaderRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const scrollerRef = useRef(null); + const isOpen = Boolean(modal); const renderingModal = useCurrentOrPrev(modal); @@ -91,6 +98,10 @@ const PremiumGiftModal: FC = ({ return filteredGifts?.reduce((prev, gift) => (prev.amount < gift.amount ? prev : gift)); }, [filteredGifts]); + const { + observe: observeIntersection, + } = useIntersectionObserver({ rootRef: scrollerRef, throttleMs: INTERSECTION_THROTTLE, isDisabled: !isOpen }); + const showConfetti = useLastCallback(() => { const dialog = dialogRef.current; if (!dialog) return; @@ -145,9 +156,14 @@ const PremiumGiftModal: FC = ({ ), }, { withNodes: true }); - const starGiftDescription = lang('StarGiftDescription', { - user: getUserFullName(user)!, - }, { withNodes: true }); + const starGiftDescription = isSelf + ? lang('StarGiftDescriptionSelf', undefined, { + withNodes: true, + renderTextFilters: ['br'], + }) + : lang('StarGiftDescription', { + user: getUserFullName(user)!, + }, { withNodes: true, withMarkdown: true }); function renderGiftPremiumHeader() { return ( @@ -168,7 +184,7 @@ const PremiumGiftModal: FC = ({ function renderStarGiftsHeader() { return (

- {lang('StarsGiftHeader')} + {lang(isSelf ? 'StarsGiftHeaderSelf' : 'StarsGiftHeader')}

); } @@ -195,6 +211,7 @@ const PremiumGiftModal: FC = ({ return ( ); @@ -233,7 +250,7 @@ const PremiumGiftModal: FC = ({ function renderMainScreen() { return ( -
+
= ({ />
- {renderGiftPremiumHeader()} - {renderGiftPremiumDescription()} - - {renderPremiumGifts()} + {!isSelf && renderGiftPremiumHeader()} + {!isSelf && renderGiftPremiumDescription()} + {!isSelf && renderPremiumGifts()} {renderStarGiftsHeader()} {renderStarGiftsDescription()} @@ -294,7 +310,7 @@ const PremiumGiftModal: FC = ({ slideClassName={styles.headerSlide} >

- {lang(isHeaderForStarGifts ? 'StarsGiftHeader' : 'GiftPremiumHeader')} + {lang(isHeaderForStarGifts ? (isSelf ? 'StarsGiftHeaderSelf' : 'StarsGiftHeader') : 'GiftPremiumHeader')}

@@ -314,9 +330,15 @@ const PremiumGiftModal: FC = ({ }; export default memo(withGlobal((global, { modal }): StateProps => { - const { starGiftsById, starGiftCategoriesByName, stars } = global; + const { + starGiftsById, + starGiftCategoriesByName, + stars, + currentUserId, + } = global; const user = modal?.forUserId ? selectUser(global, modal.forUserId) : undefined; + const isSelf = Boolean(currentUserId && modal?.forUserId === currentUserId); return { boostPerSentGift: global.appConfig?.boostsPerSentGift, @@ -324,15 +346,13 @@ export default memo(withGlobal((global, { modal }): StateProps => { starGiftCategoriesByName, starBalance: stars?.balance, user, + isSelf, }; })(PremiumGiftModal)); function getCategoryKey(category: StarGiftCategory) { - if (category === 'all') { - return -1; - } - if (category === 'limited') { - return 0; - } + if (category === 'all') return -2; + if (category === 'stock') return -1; + if (category === 'limited') return 0; return category; } diff --git a/src/components/modals/gift/StarGiftCategoryList.tsx b/src/components/modals/gift/StarGiftCategoryList.tsx index a26d7af57..1936a4d35 100644 --- a/src/components/modals/gift/StarGiftCategoryList.tsx +++ b/src/components/modals/gift/StarGiftCategoryList.tsx @@ -46,12 +46,9 @@ const StarGiftCategoryList = ({ } function renderCategoryName(category: StarGiftCategory) { - if (category === 'all') { - return lang('AllGiftsCategory'); - } - if (category === 'limited') { - return lang('LimitedGiftsCategory'); - } + if (category === 'all') return lang('AllGiftsCategory'); + if (category === 'stock') return lang('StockGiftsCategory'); + if (category === 'limited') return lang('LimitedGiftsCategory'); return category; } @@ -64,7 +61,7 @@ const StarGiftCategoryList = ({ )} onClick={() => handleItemClick(category)} > - {category !== 'all' && category !== 'limited' && ( + {Number.isInteger(category) && ( {renderCategoryItem('all')} + {renderCategoryItem('stock')} {renderCategoryItem('limited')} {starCategories.map(renderCategoryItem)}
diff --git a/src/components/modals/gift/info/GiftInfoModal.module.scss b/src/components/modals/gift/info/GiftInfoModal.module.scss index 62ca2ceb3..e4714263a 100644 --- a/src/components/modals/gift/info/GiftInfoModal.module.scss +++ b/src/components/modals/gift/info/GiftInfoModal.module.scss @@ -1,3 +1,7 @@ +.modal :global(.modal-dialog) { + overflow: hidden; +} + .header { display: flex; flex-direction: column; @@ -25,6 +29,7 @@ .description { text-align: center; + color: var(--_color-description, var(--color-text)); } .footerDescription { @@ -44,3 +49,32 @@ align-items: center; gap: 0.125rem; } + +.radialPattern { + position: absolute; + top: -3rem; + left: -1.5rem; + right: -1.5rem; + height: 16.5rem; + + z-index: -1; +} + +.uniqueAttribute { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.uniqueGift { + gap: 0; + + .giftSticker { + margin-block: 1rem; + } + + .title { + font-size: 1.25rem; + color: white; + } +} diff --git a/src/components/modals/gift/info/GiftInfoModal.tsx b/src/components/modals/gift/info/GiftInfoModal.tsx index b949809c2..f50597705 100644 --- a/src/components/modals/gift/info/GiftInfoModal.tsx +++ b/src/components/modals/gift/info/GiftInfoModal.tsx @@ -1,17 +1,22 @@ +import type { TeactNode } from '../../../../lib/teact/teact'; import React, { memo, useMemo } from '../../../../lib/teact/teact'; -import { getActions, withGlobal } from '../../../../global'; +import { getActions, getGlobal, withGlobal } from '../../../../global'; -import type { ApiSticker, ApiUser } from '../../../../api/types'; +import type { + ApiUser, +} from '../../../../api/types'; import type { TabState } from '../../../../global/types'; import { getUserFullName } from '../../../../global/helpers'; -import { selectStarGiftSticker, selectUser } from '../../../../global/selectors'; +import { selectUser } from '../../../../global/selectors'; import buildClassName from '../../../../util/buildClassName'; +import buildStyle from '../../../../util/buildStyle'; import { formatDateTimeToString } from '../../../../util/dates/dateFormat'; import { formatStarsAsIcon, formatStarsAsText } from '../../../../util/localization/format'; import { CUSTOM_PEER_HIDDEN } from '../../../../util/objects/customPeer'; import { getServerTime } from '../../../../util/serverTime'; -import { formatInteger } from '../../../../util/textFormat'; +import { formatInteger, formatPercent } from '../../../../util/textFormat'; +import { getGiftAttributes, getStickerFromGift } from '../../../common/helpers/gifts'; import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities'; import useCurrentOrPrev from '../../../../hooks/useCurrentOrPrev'; @@ -24,6 +29,7 @@ import AnimatedIconFromSticker from '../../../common/AnimatedIconFromSticker'; import Avatar from '../../../common/Avatar'; import BadgeButton from '../../../common/BadgeButton'; import StarIcon from '../../../common/icons/StarIcon'; +import RadialPatternBackground from '../../../common/profile/RadialPatternBackground'; import Button from '../../../ui/Button'; import ConfirmDialog from '../../../ui/ConfirmDialog'; import Link from '../../../ui/Link'; @@ -36,7 +42,6 @@ export type OwnProps = { }; type StateProps = { - sticker?: ApiSticker; userFrom?: ApiUser; targetUser?: ApiUser; currentUserId?: string; @@ -46,7 +51,7 @@ type StateProps = { const STICKER_SIZE = 120; const GiftInfoModal = ({ - modal, sticker, userFrom, targetUser, currentUserId, starGiftMaxConvertPeriod, + modal, userFrom, targetUser, currentUserId, starGiftMaxConvertPeriod, }: OwnProps & StateProps) => { const { closeGiftInfoModal, @@ -65,13 +70,16 @@ const GiftInfoModal = ({ const { gift: typeGift } = renderingModal || {}; const isUserGift = typeGift && 'gift' in typeGift; const userGift = isUserGift ? typeGift : undefined; - const canUpdate = Boolean(userGift?.fromId && userGift.messageId); + const canUpdate = Boolean(userGift?.messageId); const isSender = userGift?.fromId === currentUserId; const canConvertDifference = (userGift && starGiftMaxConvertPeriod && ( userGift.date + starGiftMaxConvertPeriod - getServerTime() )) || 0; const conversionLeft = Math.ceil(canConvertDifference / 60 / 60 / 24); + const gift = isUserGift ? typeGift.gift : typeGift; + const giftSticker = gift && getStickerFromGift(gift); + const handleClose = useLastCallback(() => { closeGiftInfoModal(); }); @@ -94,15 +102,38 @@ const GiftInfoModal = ({ handleClose(); }); + const giftAttributes = useMemo(() => { + return gift && getGiftAttributes(gift); + }, [gift]); + + const radialPatternBackdrop = useMemo(() => { + const { backdrop, pattern } = giftAttributes || {}; + + if (!backdrop || !pattern || !isOpen) { + return undefined; + } + + const backdropColors = [backdrop.centerColor, backdrop.edgeColor]; + const patternColor = backdrop.patternColor; + + return ( + + ); + }, [giftAttributes, isOpen]); + const modalData = useMemo(() => { - if (!typeGift) { + if (!typeGift || !gift) { return undefined; } const { - fromId, isNameHidden, message, starsToConvert, isUnsaved, isConverted, + fromId, isNameHidden, starsToConvert, isUnsaved, isConverted, } = userGift || {}; - const gift = isUserGift ? typeGift.gift : typeGift; const isVisibleForMe = isNameHidden && targetUser; @@ -110,6 +141,11 @@ const GiftInfoModal = ({ if (!userGift) { return lang('GiftInfoSoldOutDescription'); } + if (gift.type === 'starGiftUnique') { + return lang('GiftInfoCollectible', { + number: gift.number, + }); + } if (!canUpdate && !isSender) return undefined; if (!starsToConvert || canConvertDifference < 0) return undefined; if (isConverted) { @@ -149,14 +185,32 @@ const GiftInfoModal = ({ }); })(); + function getTitle() { + if (!userGift) return lang('GiftInfoSoldOutTitle'); + if (gift?.type === 'starGiftUnique') return gift.title; + + return canUpdate ? lang('GiftInfoReceived') : lang('GiftInfoTitle'); + } + + const descriptionColor = giftAttributes?.backdrop?.textColor; + const header = ( -
- +
+ {radialPatternBackdrop} +

- {!userGift && lang('GiftInfoSoldOutTitle')} - {userGift && lang(canUpdate ? 'GiftInfoReceived' : 'GiftInfoTitle')} + {getTitle()}

- {userGift && ( + {gift.type === 'starGift' && (

{formatInteger(gift.stars)} @@ -173,68 +227,187 @@ const GiftInfoModal = ({ ); const tableData: TableData = []; - if (fromId || isNameHidden) { + if (gift.type === 'starGift') { + if ((fromId || isNameHidden)) { + tableData.push([ + lang('GiftInfoFrom'), + fromId ? { chatId: fromId } : ( + <> + + {oldLang(CUSTOM_PEER_HIDDEN.titleKey!)} + + ), + ]); + } + + if (userGift?.date) { + tableData.push([ + lang('GiftInfoDate'), + formatDateTimeToString(userGift.date * 1000, lang.code, true), + ]); + } + + if (gift.firstSaleDate) { + tableData.push([ + lang('GiftInfoFirstSale'), + formatDateTimeToString(gift.firstSaleDate * 1000, lang.code, true), + ]); + } + + if (gift.lastSaleDate) { + tableData.push([ + lang('GiftInfoLastSale'), + formatDateTimeToString(gift.lastSaleDate * 1000, lang.code, true), + ]); + } + tableData.push([ - lang('GiftInfoFrom'), - fromId ? { chatId: fromId } : ( - <> - - {oldLang(CUSTOM_PEER_HIDDEN.titleKey!)} - - ), + lang('GiftInfoValue'), +

+ {formatStarsAsIcon(lang, gift.stars)} + {canUpdate && canConvertDifference > 0 && Boolean(starsToConvert) && ( + + {lang('GiftInfoConvert', { amount: starsToConvert }, { pluralValue: starsToConvert })} + + )} +
, ]); + + if (gift.availabilityTotal) { + tableData.push([ + lang('GiftInfoAvailability'), + lang('GiftInfoAvailabilityValue', { + count: gift.availabilityRemains || 0, + total: gift.availabilityTotal, + }, { + pluralValue: gift.availabilityRemains || 0, + }), + ]); + } + + if (gift.upgradeStars) { + tableData.push([ + lang('GiftInfoStatus'), + lang('GiftInfoStatusNonUnique'), + ]); + } + + if (userGift?.message) { + tableData.push([ + undefined, + renderTextWithEntities(userGift.message), + ]); + } } - if (userGift?.date) { + if (gift.type === 'starGiftUnique') { + const { + model, backdrop, pattern, originalDetails, + } = giftAttributes || {}; tableData.push([ - lang('GiftInfoDate'), - formatDateTimeToString(userGift.date * 1000, lang.code, true), + lang('GiftInfoOwner'), + { chatId: gift.ownerId }, ]); - } - if (gift.firstSaleDate) { - tableData.push([ - lang('GiftInfoFirstSale'), - formatDateTimeToString(gift.firstSaleDate * 1000, lang.code, true), - ]); - } + if (model) { + tableData.push([ + lang('GiftAttributeModel'), + + {model.name}{formatPercent(model.rarityPercent)} + , + ]); + } - if (gift.lastSaleDate) { - tableData.push([ - lang('GiftInfoLastSale'), - formatDateTimeToString(gift.lastSaleDate * 1000, lang.code, true), - ]); - } + if (backdrop) { + tableData.push([ + lang('GiftAttributeBackdrop'), + + {backdrop.name}{formatPercent(backdrop.rarityPercent)} + , + ]); + } - tableData.push([ - lang('GiftInfoValue'), -
- {formatStarsAsIcon(lang, gift.stars)} - {canUpdate && canConvertDifference > 0 && Boolean(starsToConvert) && ( - - {lang('GiftInfoConvert', { amount: starsToConvert }, { pluralValue: starsToConvert })} - - )} -
, - ]); + if (pattern) { + tableData.push([ + lang('GiftAttributeSymbol'), + + {pattern.name}{formatPercent(pattern.rarityPercent)} + , + ]); + } - if (gift.availabilityTotal) { tableData.push([ lang('GiftInfoAvailability'), - lang('GiftInfoAvailabilityValue', { - count: gift.availabilityRemains || 0, - total: gift.availabilityTotal, - }, { - pluralValue: gift.availabilityRemains || 0, + lang('GiftInfoIssued', { + issued: gift.issuedCount, + total: gift.totalCount, }), ]); - } - if (message) { - tableData.push([ - undefined, - renderTextWithEntities(message), - ]); + if (originalDetails) { + const { + date, recipientId, message, senderId, + } = originalDetails; + const global = getGlobal(); // User names does not need to be reactive + + const openChat = (id: string) => { + openChatWithInfo({ id }); + closeGiftInfoModal(); + }; + + const recipient = selectUser(global, recipientId)!; + const sender = senderId ? selectUser(global, senderId) : undefined; + + const formattedDate = formatDateTimeToString(date * 1000, lang.code, true); + const recipientLink = ( + // eslint-disable-next-line react/jsx-no-bind + openChat(recipientId)} isPrimary> + {getUserFullName(recipient)} + + ); + + let text: TeactNode | undefined; + if (!sender || senderId === recipientId) { + text = message ? lang('GiftInfoOriginalInfoText', { + user: recipientLink, + text: renderTextWithEntities(message), + date: formattedDate, + }, { + withNodes: true, + }) : lang('GiftInfoOriginalInfo', { + user: recipientLink, + date: formattedDate, + }, { + withNodes: true, + }); + } else { + const senderLink = ( + // eslint-disable-next-line react/jsx-no-bind + openChat(sender.id)} isPrimary> + {getUserFullName(sender)} + + ); + text = message ? lang('GiftInfoOriginalInfoTextSender', { + user: recipientLink, + sender: senderLink, + text: renderTextWithEntities(message), + date: formattedDate, + }, { + withNodes: true, + }) : lang('GiftInfoOriginalInfoSender', { + user: recipientLink, + date: formattedDate, + sender: senderLink, + }, { + withNodes: true, + }); + } + + tableData.push([ + undefined, + {text}, + ]); + } } const footer = ( @@ -274,7 +447,10 @@ const GiftInfoModal = ({ tableData, footer, }; - }, [typeGift, userGift, isUserGift, targetUser, sticker, lang, canUpdate, canConvertDifference, isSender, oldLang]); + }, [ + typeGift, userGift, targetUser, giftSticker, lang, canUpdate, canConvertDifference, isSender, oldLang, gift, + radialPatternBackdrop, giftAttributes, + ]); return ( <> @@ -283,6 +459,7 @@ const GiftInfoModal = ({ header={modalData?.header} tableData={modalData?.tableData} footer={modalData?.footer} + className={styles.modal} onClose={handleClose} /> {userGift && ( @@ -323,16 +500,12 @@ export default memo(withGlobal( (global, { modal }): StateProps => { const typeGift = modal?.gift; const isUserGift = typeGift && 'gift' in typeGift; - const gift = isUserGift ? typeGift.gift : typeGift; - const stickerId = gift?.stickerId; - const sticker = stickerId ? selectStarGiftSticker(global, stickerId) : undefined; const fromId = isUserGift && typeGift.fromId; const userFrom = fromId ? selectUser(global, fromId) : undefined; const targetUser = modal?.userId ? selectUser(global, modal.userId) : undefined; return { - sticker, userFrom, targetUser, currentUserId: global.currentUserId, diff --git a/src/components/modals/gift/recipient/GiftRecipientPicker.tsx b/src/components/modals/gift/recipient/GiftRecipientPicker.tsx index ca3d19914..648f6cfb6 100644 --- a/src/components/modals/gift/recipient/GiftRecipientPicker.tsx +++ b/src/components/modals/gift/recipient/GiftRecipientPicker.tsx @@ -42,7 +42,8 @@ const GiftRecipientPicker: FC = ({ const displayedUserIds = useMemo(() => { const usersById = getGlobal().users.byId; - const filteredContactIds = userIds ? filterUsersByName(userIds, usersById, searchQuery) : []; + const idsWithSelf = userIds ? userIds.concat(currentUserId!) : undefined; + const filteredContactIds = idsWithSelf ? filterUsersByName(idsWithSelf, usersById, searchQuery) : []; return sortChatIds(unique(filteredContactIds).filter((userId) => { const user = usersById[userId]; @@ -50,8 +51,8 @@ const GiftRecipientPicker: FC = ({ return true; } - return !isUserBot(user) && userId !== currentUserId; - })); + return !isUserBot(user); + }), undefined, [currentUserId!]); }, [currentUserId, searchQuery, userIds]); const handleSelectedUserIdsChange = useLastCallback((selectedId: string) => { @@ -79,6 +80,7 @@ const GiftRecipientPicker: FC = ({ isSearchable withDefaultPadding withStatus + forceShowSelf /> ); diff --git a/src/components/modals/stars/helpers/transaction.ts b/src/components/modals/stars/helpers/transaction.ts index c401e0456..242bfd51f 100644 --- a/src/components/modals/stars/helpers/transaction.ts +++ b/src/components/modals/stars/helpers/transaction.ts @@ -8,6 +8,7 @@ export function getTransactionTitle(lang: OldLangFn, transaction: ApiStarsTransa if (transaction.starRefCommision) { return lang('StarTransactionCommission', formatPercent(transaction.starRefCommision)); } + if (transaction.isGiftUpgrade) return lang('Gift2TransactionUpgraded'); if (transaction.extendedMedia) return lang('StarMediaPurchase'); if (transaction.subscriptionPeriod) return transaction.title || lang('StarSubscriptionPurchase'); if (transaction.isReaction) return lang('StarsReactionsSent'); diff --git a/src/components/modals/stars/transaction/StarsTransactionItem.tsx b/src/components/modals/stars/transaction/StarsTransactionItem.tsx index c311af471..b8256d14c 100644 --- a/src/components/modals/stars/transaction/StarsTransactionItem.tsx +++ b/src/components/modals/stars/transaction/StarsTransactionItem.tsx @@ -71,6 +71,10 @@ const StarsTransactionItem = ({ transaction, className }: OwnProps) => { avatarPeer = customPeer; } + if (transaction.isGiftUpgrade && transaction.starGift?.type === 'starGiftUnique') { + description = transaction.starGift.title; + } + if (transaction.photo) { avatarPeer = undefined; } diff --git a/src/components/modals/stars/transaction/StarsTransactionModal.tsx b/src/components/modals/stars/transaction/StarsTransactionModal.tsx index 8034e1237..7d84f442e 100644 --- a/src/components/modals/stars/transaction/StarsTransactionModal.tsx +++ b/src/components/modals/stars/transaction/StarsTransactionModal.tsx @@ -14,11 +14,12 @@ import { buildStarsTransactionCustomPeer, formatStarsTransactionAmount } from '. import { selectCanPlayAnimatedEmojis, selectGiftStickerForStars, - selectPeer, selectStarGiftSticker, + selectPeer, } from '../../../../global/selectors'; import buildClassName from '../../../../util/buildClassName'; import { copyTextToClipboard } from '../../../../util/clipboard'; import { formatDateTimeToString } from '../../../../util/dates/dateFormat'; +import { getStickerFromGift } from '../../../common/helpers/gifts'; import { getTransactionTitle, isNegativeStarsAmount } from '../helpers/transaction'; import useLang from '../../../../hooks/useLang'; @@ -57,6 +58,8 @@ const StarsTransactionModal: FC = ({ const oldLang = useOldLang(); const { transaction } = modal || {}; + const sticker = transaction?.starGift ? getStickerFromGift(transaction.starGift) : topSticker; + const handleOpenMedia = useLastCallback(() => { const media = transaction?.extendedMedia; if (!media) return; @@ -73,7 +76,7 @@ const StarsTransactionModal: FC = ({ } const { - giveawayPostId, photo, stars, + giveawayPostId, photo, stars, isGiftUpgrade, starGift, } = transaction; const customPeer = (transaction.peer && transaction.peer.type !== 'peer' @@ -84,7 +87,7 @@ const StarsTransactionModal: FC = ({ const title = getTransactionTitle(oldLang, transaction); - const messageLink = peer && transaction.messageId + const messageLink = peer && transaction.messageId && !isGiftUpgrade ? getMessageLink(peer, undefined, transaction.messageId) : undefined; const giveawayMessageLink = peer && giveawayPostId && getMessageLink(peer, undefined, giveawayPostId); @@ -98,9 +101,11 @@ const StarsTransactionModal: FC = ({ : areAllVideos ? oldLang('Stars.Transfer.Videos', mediaAmount) : oldLang('Media', mediaAmount); - const description = transaction.description || (media ? mediaText : undefined); + const description = transaction.description + || (isGiftUpgrade && starGift?.type === 'starGiftUnique' ? starGift.title : undefined) + || (media ? mediaText : undefined); - const shouldDisplayAvatar = !media && !topSticker; + const shouldDisplayAvatar = !media && !sticker; const avatarPeer = !photo ? (peer || customPeer) : undefined; const header = ( @@ -112,10 +117,10 @@ const StarsTransactionModal: FC = ({ onClick={handleOpenMedia} /> )} - {!media && topSticker && ( + {!media && sticker && ( = ({ {shouldDisplayAvatar && ( )} - {!topSticker && ( + {!sticker && ( = ({ ]); } + if (isGiftUpgrade) { + tableData.push([ + oldLang('StarGiftReason'), + oldLang('StarGiftReasonUpgrade'), + ]); + } + let peerLabel; - if (isNegativeStarsAmount(stars) || transaction.isMyGift) { + if (isGiftUpgrade) { + peerLabel = oldLang('Stars.Transaction.GiftFrom'); + } else if (isNegativeStarsAmount(stars) || transaction.isMyGift) { peerLabel = oldLang('Stars.Transaction.To'); } else if (transaction.starRefCommision) { peerLabel = oldLang('StarsTransaction.StarRefReason.Miniapp'); @@ -222,7 +236,7 @@ const StarsTransactionModal: FC = ({ tableData, footer, }; - }, [transaction, oldLang, lang, peer, topSticker, canPlayAnimatedEmojis]); + }, [transaction, oldLang, lang, peer, sticker, canPlayAnimatedEmojis]); const prevModalData = usePrevious(starModalData); const renderingModalData = prevModalData || starModalData; @@ -248,13 +262,10 @@ export default memo(withGlobal( const starCount = modal?.transaction.stars; const starsGiftSticker = modal?.transaction.isGift && selectGiftStickerForStars(global, starCount?.amount); - const starGiftStickerId = modal?.transaction.starGift?.stickerId; - const starGiftSticker = starGiftStickerId && selectStarGiftSticker(global, starGiftStickerId); - return { peer, canPlayAnimatedEmojis: selectCanPlayAnimatedEmojis(global), - topSticker: starGiftSticker || starsGiftSticker, + topSticker: starsGiftSticker, }; }, )(StarsTransactionModal)); diff --git a/src/components/modals/suggestedStatus/SuggestedStatusModal.tsx b/src/components/modals/suggestedStatus/SuggestedStatusModal.tsx index 18ede3a04..0cc86ed63 100644 --- a/src/components/modals/suggestedStatus/SuggestedStatusModal.tsx +++ b/src/components/modals/suggestedStatus/SuggestedStatusModal.tsx @@ -123,6 +123,7 @@ const SuggestedStatusModal = ({ modal, currentUser, bot }: OwnProps & StateProps {mockPeerWithStatus && ( )}
) : resultType === 'gifts' ? ( (gifts?.map((gift) => ( - + ))) ) : undefined}
diff --git a/src/global/actions/api/stars.ts b/src/global/actions/api/stars.ts index a868a4034..ab2602415 100644 --- a/src/global/actions/api/stars.ts +++ b/src/global/actions/api/stars.ts @@ -89,26 +89,27 @@ addActionHandler('loadStarGifts', async (global): Promise => { return; } - const { gifts, stickers } = result; - - const starGiftsById = buildCollectionByKey(gifts, 'id'); + const starGiftsById = buildCollectionByKey(result, 'id'); const starGiftCategoriesByName: Record = { all: [], + stock: [], limited: [], }; const allStarGiftIds = Object.keys(starGiftsById); const allStarGifts = Object.values(starGiftsById); - const limitedStarGiftIds = allStarGifts.map( - (gift) => { - return gift.isLimited ? gift.id : undefined; - }, - ).filter(Boolean) as string[]; + const limitedStarGiftIds = allStarGifts.map((gift) => (gift.isLimited ? gift.id : undefined)) + .filter(Boolean) as string[]; + + const stockedStarGiftIds = allStarGifts.map((gift) => ( + gift.availabilityRemains || !gift.availabilityTotal ? gift.id : undefined + )).filter(Boolean) as string[]; starGiftCategoriesByName.all = allStarGiftIds; starGiftCategoriesByName.limited = limitedStarGiftIds; + starGiftCategoriesByName.stock = stockedStarGiftIds; allStarGifts.forEach((gift) => { const starsCategory = gift.stars; @@ -123,12 +124,6 @@ addActionHandler('loadStarGifts', async (global): Promise => { ...global, starGiftsById, starGiftCategoriesByName, - stickers: { - ...global.stickers, - starGifts: { - stickers, - }, - }, }; setGlobal(global); }); diff --git a/src/global/cache.ts b/src/global/cache.ts index 6c485b632..49b46dcc5 100644 --- a/src/global/cache.ts +++ b/src/global/cache.ts @@ -260,11 +260,6 @@ function unsafeMigrateCache(cached: GlobalState, initialState: GlobalState) { if (!cached.messages.pollById) { cached.messages.pollById = initialState.messages.pollById; } - - if (!cached.stickers.starGifts) { - cached.stickers.starGifts = initialState.stickers.starGifts; - cached.users.giftsById = initialState.users.giftsById; - } } function updateCache(force?: boolean) { diff --git a/src/global/initialState.ts b/src/global/initialState.ts index b0bca22d2..9698ecf09 100644 --- a/src/global/initialState.ts +++ b/src/global/initialState.ts @@ -183,6 +183,7 @@ export const INITIAL_GLOBAL_STATE: GlobalState = { starGiftCategoriesByName: { all: [], limited: [], + stock: [], }, stickers: { @@ -207,9 +208,6 @@ export const INITIAL_GLOBAL_STATE: GlobalState = { stickers: [], emojis: [], }, - starGifts: { - stickers: {}, - }, forEmoji: {}, }, diff --git a/src/global/selectors/symbols.ts b/src/global/selectors/symbols.ts index af9e2152d..86646ba1e 100644 --- a/src/global/selectors/symbols.ts +++ b/src/global/selectors/symbols.ts @@ -171,10 +171,6 @@ export function selectIsAlwaysHighPriorityEmoji( || stickerSet.id === RESTRICTED_EMOJI_SET_ID; } -export function selectStarGiftSticker(global: T, id: string) { - return global.stickers.starGifts.stickers[id]; -} - export function selectGiftStickerForDuration(global: T, duration = 1) { const stickers = global.premiumGifts?.stickers; if (!stickers) return undefined; diff --git a/src/global/types/globalState.ts b/src/global/types/globalState.ts index dfd53e9b6..bb4c56934 100644 --- a/src/global/types/globalState.ts +++ b/src/global/types/globalState.ts @@ -27,7 +27,7 @@ import type { ApiSavedReactionTag, ApiSession, ApiSponsoredMessage, - ApiStarGift, + ApiStarGiftRegular, ApiStarsAmount, ApiStarTopupOption, ApiStealthMode, @@ -290,7 +290,7 @@ export type GlobalState = { }; }; availableEffectById: Record; - starGiftsById: Record; + starGiftsById: Record; starGiftCategoriesByName: Record; stickers: { @@ -328,9 +328,6 @@ export type GlobalState = { stickers: ApiSticker[]; emojis: ApiSticker[]; }; - starGifts: { - stickers: Record; - }; }; customEmojis: { diff --git a/src/types/index.ts b/src/types/index.ts index 48e971316..c1ab60966 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -17,7 +17,7 @@ import type { ApiPhoto, ApiReaction, ApiReactionWithPaid, - ApiStarGift, + ApiStarGiftRegular, ApiStarsSubscription, ApiStarsTransaction, ApiStickerSet, @@ -561,7 +561,7 @@ export type ConfettiStyle = 'poppers' | 'top-down'; export type StarGiftInfo = { userId: string; - gift: ApiStarGift; + gift: ApiStarGiftRegular; shouldHideName?: boolean; message?: ApiFormattedText; }; @@ -631,7 +631,7 @@ export type ConfettiParams = OptionalCombine<{ export type WebPageMediaSize = 'large' | 'small'; -export type StarGiftCategory = number | 'all' | 'limited'; +export type StarGiftCategory = number | 'all' | 'limited' | 'stock'; export type CallSound = ( 'join' | 'allowTalk' | 'leave' | 'connecting' | 'incoming' | 'end' | 'connect' | 'busy' | 'ringing' diff --git a/src/types/language.d.ts b/src/types/language.d.ts index 48853d979..a4d61efb0 100644 --- a/src/types/language.d.ts +++ b/src/types/language.d.ts @@ -1108,6 +1108,8 @@ export interface LangPair { 'GiftPremiumDescriptionLinkCaption': undefined; 'GiftPremiumDescriptionLink': undefined; 'StarsGiftHeader': undefined; + 'StarsGiftHeaderSelf': undefined; + 'StarGiftDescriptionSelf': undefined; 'GiftLimited': undefined; 'GiftSoldOut': undefined; 'GiftMessagePlaceholder': undefined; @@ -1130,8 +1132,15 @@ export interface LangPair { 'GiftInfoSoldOutTitle': undefined; 'GiftInfoSoldOutDescription': undefined; 'GiftInfoSenderHidden': undefined; + 'GiftInfoOwner': undefined; + 'GiftAttributeModel': undefined; + 'GiftAttributeBackdrop': undefined; + 'GiftAttributeSymbol': undefined; + 'GiftInfoStatus': undefined; + 'GiftInfoStatusNonUnique': undefined; 'AllGiftsCategory': undefined; 'LimitedGiftsCategory': undefined; + 'StockGiftsCategory': undefined; 'PremiumGiftDescription': undefined; 'StarsReactionLinkText': undefined; 'StarsReactionLink': undefined; @@ -1547,6 +1556,33 @@ export interface LangPairWithVariables { 'GiftInfoSaved': { 'link': V; }; + 'GiftInfoIssued': { + 'issued': V; + 'total': V; + }; + 'GiftInfoCollectible': { + 'number': V; + }; + 'GiftInfoOriginalInfo': { + 'user': V; + 'date': V; + }; + 'GiftInfoOriginalInfoSender': { + 'sender': V; + 'user': V; + 'date': V; + }; + 'GiftInfoOriginalInfoText': { + 'user': V; + 'date': V; + 'text': V; + }; + 'GiftInfoOriginalInfoTextSender': { + 'sender': V; + 'user': V; + 'date': V; + 'text': V; + }; 'StarsAmount': { 'amount': V; };