Gifts Modal: Support marketplace (#5936)

This commit is contained in:
Alexander Zinchuk 2025-06-04 20:41:18 +02:00
parent d7ea4a5748
commit 4330472674
40 changed files with 2132 additions and 312 deletions

View File

@ -1,3 +1,4 @@
import bigInt from 'big-integer';
import { Api as GramJs } from '../../../lib/gramjs';
import type {
@ -6,13 +7,18 @@ import type {
ApiSavedStarGift,
ApiStarGift,
ApiStarGiftAttribute,
ApiStarGiftAttributeCounter,
ApiStarGiftAttributeId,
ApiTypeResaleStarGifts,
} from '../../types';
import { numberToHexColor } from '../../../util/colors';
import { buildApiChatFromPreview } from '../apiBuilders/chats';
import { addDocumentToLocalDb } from '../helpers/localDb';
import { buildApiFormattedText } from './common';
import { getApiChatIdFromMtpPeer } from './peers';
import { buildStickerFromDocument } from './symbols';
import { buildApiUser } from './users';
export function buildApiStarGift(starGift: GramJs.TypeStarGift): ApiStarGift {
if (starGift instanceof GramJs.StarGiftUnique) {
@ -40,7 +46,7 @@ export function buildApiStarGift(starGift: GramJs.TypeStarGift): ApiStarGift {
const {
id, limited, stars, availabilityRemains, availabilityTotal, convertStars, firstSaleDate, lastSaleDate, soldOut,
birthday, upgradeStars, resellMinStars, title,
birthday, upgradeStars, resellMinStars, title, availabilityResale,
} = starGift;
addDocumentToLocalDb(starGift.sticker);
@ -63,6 +69,7 @@ export function buildApiStarGift(starGift: GramJs.TypeStarGift): ApiStarGift {
upgradeStars: upgradeStars?.toJSNumber(),
title,
resellMinStars: resellMinStars?.toJSNumber(),
availabilityResale: availabilityResale?.toJSNumber(),
};
}
@ -101,11 +108,12 @@ export function buildApiStarGiftAttribute(attribute: GramJs.TypeStarGiftAttribut
if (attribute instanceof GramJs.StarGiftAttributeBackdrop) {
const {
name, rarityPermille, centerColor, edgeColor, patternColor, textColor,
name, rarityPermille, centerColor, edgeColor, patternColor, textColor, backdropId,
} = attribute;
return {
type: 'backdrop',
backdropId,
name,
rarityPercent: rarityPermille / 10,
centerColor: numberToHexColor(centerColor),
@ -180,3 +188,92 @@ export function buildApiDisallowedGiftsSettings(
shouldDisallowPremiumGifts: disallowPremiumGifts,
};
}
export function buildApiStarGiftAttributeId(
result: GramJs.TypeStarGiftAttributeId,
): ApiStarGiftAttributeId | undefined {
if (result instanceof GramJs.StarGiftAttributeIdModel) {
return {
type: 'model',
documentId: result.documentId.toString(),
};
}
if (result instanceof GramJs.StarGiftAttributeIdPattern) {
return {
type: 'pattern',
documentId: result.documentId.toString(),
};
}
if (result instanceof GramJs.StarGiftAttributeIdBackdrop) {
return {
type: 'backdrop',
backdropId: result.backdropId,
};
}
return undefined;
}
export function buildApiStarGiftAttributeCounter(
result: GramJs.TypeStarGiftAttributeCounter,
): ApiStarGiftAttributeCounter | undefined {
const {
count,
} = result;
const attribute = buildApiStarGiftAttributeId(result.attribute);
if (!attribute) return undefined;
return {
count,
attribute,
};
}
export function buildApiResaleGifts(
result: GramJs.payments.TypeResaleStarGifts,
): ApiTypeResaleStarGifts {
const {
count,
nextOffset,
attributesHash,
} = result;
const gifts = result.gifts.map((g) => buildApiStarGift(g));
const attributes = result.attributes?.map((a) => buildApiStarGiftAttribute(a)).filter(Boolean);
const users = result.users.map((u) => buildApiUser(u)).filter(Boolean);
const chats = result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean);
const counters = result.counters?.map((c) => buildApiStarGiftAttributeCounter(c)).filter(Boolean);
return {
count,
gifts,
nextOffset,
attributes,
attributesHash: attributesHash?.toString(),
chats,
counters,
users,
};
}
export function buildInputResaleGiftsAttributes(attributes: ApiStarGiftAttributeId[]):
GramJs.TypeStarGiftAttributeId[] {
return attributes.map((attr) => {
switch (attr.type) {
case 'model':
return new GramJs.StarGiftAttributeIdModel({ documentId: bigInt(attr.documentId) });
case 'pattern':
return new GramJs.StarGiftAttributeIdPattern({ documentId: bigInt(attr.documentId) });
case 'backdrop':
return new GramJs.StarGiftAttributeIdBackdrop({ backdropId: attr.backdropId });
default:
throw new Error(`Unknown attribute type: ${(attr as any).type}`);
}
});
}

View File

@ -1,15 +1,17 @@
import bigInt from 'big-integer';
import { Api as GramJs } from '../../../lib/gramjs';
import type { GiftProfileFilterOptions } from '../../../types';
import type { GiftProfileFilterOptions, ResaleGiftsFilterOptions } from '../../../types';
import type {
ApiChat,
ApiPeer,
ApiRequestInputSavedStarGift,
ApiStarGiftAttributeId,
ApiStarGiftRegular,
} from '../../types';
import { buildApiSavedStarGift, buildApiStarGift, buildApiStarGiftAttribute } from '../apiBuilders/gifts';
import { buildApiResaleGifts, buildApiSavedStarGift, buildApiStarGift,
buildApiStarGiftAttribute, buildInputResaleGiftsAttributes } from '../apiBuilders/gifts';
import {
buildApiStarsAmount,
buildApiStarsGiftOptions,
@ -44,6 +46,48 @@ export async function fetchStarGifts() {
return result.gifts.map(buildApiStarGift).filter((gift): gift is ApiStarGiftRegular => gift.type === 'starGift');
}
export async function fetchResaleGifts({
giftId,
offset = '',
limit,
attributesHash = '0',
filter,
}: {
giftId: string;
offset?: string;
limit?: number;
attributesHash?: string;
filter?: ResaleGiftsFilterOptions;
}) {
type GetResaleStarGifts = ConstructorParameters<typeof GramJs.payments.GetResaleStarGifts>[0];
const attributes: ApiStarGiftAttributeId[] = [
...(filter?.backdropAttributes ?? []),
...(filter?.modelAttributes ?? []),
...(filter?.patternAttributes ?? []),
];
const params: GetResaleStarGifts = {
giftId: bigInt(giftId),
offset,
limit,
attributesHash: attributesHash ? bigInt(attributesHash) : undefined,
attributes: buildInputResaleGiftsAttributes(attributes),
...(filter && {
sortByPrice: filter.sortType === 'byPrice' || undefined,
sortByNum: filter.sortType === 'byNumber' || undefined,
} satisfies GetResaleStarGifts),
};
const result = await invokeRequest(new GramJs.payments.GetResaleStarGifts(params));
if (!result) {
return undefined;
}
return buildApiResaleGifts(result);
}
export async function fetchSavedStarGifts({
peer,
offset = '',

View File

@ -1,6 +1,7 @@
import type { ApiWebDocument } from './bots';
import type { ApiChat } from './chats';
import type { ApiFormattedText, ApiSticker, BoughtPaidMedia } from './messages';
import type { ApiUser } from './users';
export interface ApiStarGiftRegular {
type: 'starGift';
@ -10,6 +11,7 @@ export interface ApiStarGiftRegular {
stars: number;
availabilityRemains?: number;
availabilityTotal?: number;
availabilityResale?: number;
starsToConvert: number;
isSoldOut?: true;
firstSaleDate?: number;
@ -54,6 +56,7 @@ export interface ApiStarGiftAttributePattern {
export interface ApiStarGiftAttributeBackdrop {
type: 'backdrop';
backdropId: number;
name: string;
centerColor: string;
edgeColor: string;
@ -95,6 +98,37 @@ export interface ApiSavedStarGift {
upgradeMsgId?: number; // Local field, used for Action Message
}
export type StarGiftAttributeIdModel = {
type: 'model';
documentId: string;
};
export type ApiStarGiftAttributeIdPattern = {
type: 'pattern';
documentId: string;
};
export type ApiStarGiftAttributeIdBackdrop = {
type: 'backdrop';
backdropId: number;
};
export type ApiStarGiftAttributeId = StarGiftAttributeIdModel |
ApiStarGiftAttributeIdPattern | ApiStarGiftAttributeIdBackdrop;
export interface ApiStarGiftAttributeCounter<T extends ApiStarGiftAttributeId = ApiStarGiftAttributeId> {
attribute: T;
count: number;
}
export interface ApiTypeResaleStarGifts {
count: number;
gifts: ApiStarGift[];
nextOffset?: string;
attributes?: ApiStarGiftAttribute[];
attributesHash?: string;
chats: ApiChat[];
counters?: ApiStarGiftAttributeCounter[];
users: ApiUser[];
}
export interface ApiInputSavedStarGiftUser {
type: 'user';
messageId: number;

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none"><path fill="#000" d="m16 29-5.558-5.483a1.475 1.475 0 0 1 0-2.106 1.524 1.524 0 0 1 2.135 0L16 24.788l3.423-3.377a1.524 1.524 0 0 1 2.135 0c.59.581.59 1.524 0 2.106zm-3.423-18.41a1.524 1.524 0 0 1-2.135 0 1.475 1.475 0 0 1 0-2.107L16 3l5.558 5.483c.59.582.59 1.525 0 2.106a1.524 1.524 0 0 1-2.135 0L16 7.212z"/></svg>

After

Width:  |  Height:  |  Size: 392 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none"><path fill="#000" d="M29.722 21.277c.375.34.37.918-.011 1.251a.904.904 0 0 1-1.193-.01l-2.615-2.368v7.977c0 .482-.404.873-.903.873s-.903-.391-.903-.873V20.15l-2.615 2.369a.904.904 0 0 1-1.193.01.832.832 0 0 1-.01-1.252L25 17z"/><path fill="#000" d="M20 4a1 1 0 0 1 1 1v1a5 5 0 0 1 5 5v2.5a1 1 0 1 1-2 0V11a3 3 0 0 0-3-3v1a1 1 0 1 1-2 0V8H9v1a1 1 0 0 1-2 0V8a3 3 0 0 0-3 3v11a3 3 0 0 0 3 3h12.5a1 1 0 1 1 0 2H7a5 5 0 0 1-5-5V11a5 5 0 0 1 5-5V5a1 1 0 0 1 2 0v1h10V5a1 1 0 0 1 1-1"/><path fill="#000" d="M21 12a1 1 0 1 1 0 2H7a1 1 0 1 1 0-2z"/></svg>

After

Width:  |  Height:  |  Size: 622 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none"><path fill="#000" d="M29.722 21.277c.375.34.37.918-.012 1.252a.9.9 0 0 1-1.192-.01l-2.615-2.369v7.977c0 .482-.404.873-.903.873s-.903-.391-.903-.873V20.15l-2.615 2.368a.904.904 0 0 1-1.193.011.832.832 0 0 1-.01-1.252L25 17zM18.755 7.72a1 1 0 0 1 1.94.449l-.02.1-.86 3.73H25l.102.006a1.001 1.001 0 0 1 0 1.99L25 14h-5.648l-2.58 11.18a1 1 0 0 1-1.95-.449L15.684 21H9.736l-.965 4.18a1 1 0 0 1-1.949-.449L7.684 21H3a1 1 0 0 1 0-2h5.146L9.3 14H5a1 1 0 0 1 0-2h4.762l.965-4.18.028-.1a1 1 0 0 1 1.94.449l-.02.1-.86 3.73h5.947l.965-4.18zM10.198 19H16l.102.005q.02.002.04.006L17.3 14h-5.948z"/></svg>

After

Width:  |  Height:  |  Size: 665 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none"><path fill="#000" d="M29.722 21.278c.375.34.37.917-.012 1.25a.9.9 0 0 1-1.192-.01l-2.615-2.368v7.977c0 .482-.404.873-.903.873s-.903-.39-.903-.873V20.15l-2.615 2.369a.9.9 0 0 1-1.192.01.83.83 0 0 1-.012-1.251L25 17zM14.924 24a.866.866 0 0 1-.876-.856V22.09a4.44 4.44 0 0 1-2.005-1.022q-.61-.546-.985-1.416c-.172-.4.054-.844.463-1.012.484-.2 1.024.065 1.28.511q.255.442.6.739.646.555 1.699.555.897 0 1.522-.41.625-.412.625-1.278 0-.778-.482-1.234-.483-.455-2.235-1.033-1.884-.6-2.586-1.433-.7-.834-.701-2.034 0-1.444.92-2.244t1.885-.911V8.856c0-.473.392-.856.876-.856s.877.383.877.856v1.01q1.095.18 1.807.812.412.364.694.823c.24.386.023.864-.399 1.049-.472.207-1.003-.047-1.342-.429a2 2 0 0 0-.278-.254q-.483-.356-1.315-.356-.964 0-1.468.433a1.37 1.37 0 0 0-.504 1.078q0 .734.657 1.156.658.422 2.28.889 1.51.444 2.29 1.41A3.46 3.46 0 0 1 19 18.712q0 1.578-.92 2.4t-2.28 1.022v1.011a.866.866 0 0 1-.876.856"/><path fill="#000" d="M2 16.5C2 9.596 7.596 4 14.5 4c5.456 0 10.095 3.495 11.8 8.367l.156.477.025.098a1 1 0 0 1-1.903.581l-.034-.095-.13-.4C22.98 8.934 19.08 6 14.5 6 8.701 6 4 10.701 4 16.5S8.701 27 14.5 27c2.01 0 3.884-.563 5.477-1.54a1 1 0 1 1 1.046 1.705A12.45 12.45 0 0 1 14.5 29C7.596 29 2 23.404 2 16.5"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1977,5 +1977,25 @@
"StarGiftSaleTransaction" = "Gift Purchase";
"StarGiftPurchaseTransaction" = "Gift Sale";
"GiftBuyConfirmDescription" = "Do you want to buy **{gift}** for **{stars}**?";
"GiftBuyForPeerConfirmDescription" = "Do you want to buy **{gift}** for **{stars}** and gift it to **{peer}**?";
"ComposerTitleForwardFrom" = "From: **{users}**";
"ContextMenuItemMention" = "Mention";
"GiftRibbonResale" = "resale";
"GiftCategoryResale" = "Resale";
"HeaderDescriptionResaleGifts_one" = "{count} for resale";
"HeaderDescriptionResaleGifts_other" = "{count} for resale";
"GiftSortByPrice" = "Sort by Price";
"GiftSortByNumber" = "Sort by Number";
"ContextMenuItemSelectAll" = "Select All";
"ButtonSort" = "Sort";
"GiftAttributeModelPlural_one" = "{count} Model";
"GiftAttributeModelPlural_other" = "{count} Models";
"GiftAttributeBackdropPlural_one" = "{count} Backdrop";
"GiftAttributeBackdropPlural_other" = "{count} Backdrops";
"GiftAttributeSymbolPlural_one" = "{count} Symbol";
"GiftAttributeSymbolPlural_other" = "{count} Symbols";
"ValueGiftSortByDate" = "Date";
"ValueGiftSortByPrice" = "Price";
"ValueGiftSortByNumber" = "Number";
"ResellGiftsNoFound" = "No gifts found";
"ResellGiftsClearFilters" = "Clear Filters";

View File

@ -19,9 +19,10 @@ const COLORS = {
type ColorKey = keyof typeof COLORS;
const COLOR_KEYS = new Set(Object.keys(COLORS) as ColorKey[]);
type GradientColor = readonly [string, string];
type OwnProps = {
color: ColorKey | (string & {});
color: ColorKey | GradientColor | (string & {});
text: string;
className?: string;
};
@ -40,7 +41,13 @@ const GiftRibbon = ({
const isDarkTheme = theme === 'dark';
const gradientColor = colorKey ? COLORS[colorKey][isDarkTheme ? 1 : 0] : undefined;
const gradientColor: GradientColor | undefined
= Array.isArray(color)
? color as GradientColor
: colorKey
? COLORS[colorKey][isDarkTheme ? 1 : 0]
: undefined;
const startColor = gradientColor ? gradientColor[0] : color;
const endColor = gradientColor ? gradientColor[1] : color;

View File

@ -94,13 +94,14 @@ function GiftComposer({
}
}, [shouldDisallowLimitedStarGifts, shouldPayForUpgrade]);
const isStarGift = 'id' in gift;
const isStarGift = 'id' in gift && gift.type === 'starGift';
const isPremiumGift = 'months' in gift;
const hasPremiumByStars = giftByStars && 'amount' in giftByStars;
const isPeerUser = peer && isApiPeerUser(peer);
const isSelf = peerId === currentUserId;
const localMessage = useMemo(() => {
if (!isStarGift) {
if (isPremiumGift) {
const currentGift = shouldPayByStars && hasPremiumByStars ? giftByStars : gift;
return {
id: -1,
@ -121,32 +122,35 @@ function GiftComposer({
} satisfies ApiMessage;
}
return {
id: -1,
chatId: '0',
isOutgoing: false,
senderId: currentUserId,
date: Math.floor(Date.now() / 1000),
content: {
action: {
mediaType: 'action',
type: 'starGift',
message: giftMessage?.length ? {
text: giftMessage,
} : undefined,
isNameHidden: shouldHideName || undefined,
starsToConvert: gift.starsToConvert,
canUpgrade: shouldPayForUpgrade || undefined,
alreadyPaidUpgradeStars: shouldPayForUpgrade ? gift.upgradeStars : undefined,
gift,
peerId,
fromId: currentUserId,
if (isStarGift) {
return {
id: -1,
chatId: '0',
isOutgoing: false,
senderId: currentUserId,
date: Math.floor(Date.now() / 1000),
content: {
action: {
mediaType: 'action',
type: 'starGift',
message: giftMessage?.length ? {
text: giftMessage,
} : undefined,
isNameHidden: shouldHideName || undefined,
starsToConvert: gift.starsToConvert,
canUpgrade: shouldPayForUpgrade || undefined,
alreadyPaidUpgradeStars: shouldPayForUpgrade ? gift.upgradeStars : undefined,
gift,
peerId,
fromId: currentUserId,
},
},
},
} satisfies ApiMessage;
} satisfies ApiMessage;
}
return undefined;
}, [currentUserId, gift, giftMessage, isStarGift,
shouldHideName, shouldPayForUpgrade, peerId,
shouldPayByStars, hasPremiumByStars, giftByStars]);
shouldPayByStars, hasPremiumByStars, giftByStars, isPremiumGift]);
const handleGiftMessageChange = useLastCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
setGiftMessage(e.target.value);
@ -198,14 +202,16 @@ function GiftComposer({
return;
}
openInvoice({
type: 'giftcode',
userIds: [peerId],
currency: gift.currency,
amount: gift.amount,
option: gift,
message: giftMessage ? { text: giftMessage } : undefined,
});
if (isPremiumGift) {
openInvoice({
type: 'giftcode',
userIds: [peerId],
currency: gift.currency,
amount: gift.amount,
option: gift,
message: giftMessage ? { text: giftMessage } : undefined,
});
}
});
const canUseStarsPayment = hasPremiumByStars && starBalance && (starBalance.amount > giftByStars.amount);
@ -320,7 +326,7 @@ function GiftComposer({
? formatStarsAsIcon(lang, giftByStars.amount, { asFont: true })
: isStarGift
? formatStarsAsIcon(lang, gift.stars + (shouldPayForUpgrade ? gift.upgradeStars! : 0), { asFont: true })
: formatCurrency(lang, gift.amount, gift.currency);
: isPremiumGift ? formatCurrency(lang, gift.amount, gift.currency) : undefined;
return (
<div className={styles.footer}>
@ -359,6 +365,8 @@ function GiftComposer({
customBackground && isBackgroundBlurred && styles.blurred,
);
if ((!isStarGift && !isPremiumGift) || !localMessage) return;
return (
<div className={buildClassName(styles.root, 'custom-scroll')}>
<div
@ -375,7 +383,7 @@ function GiftComposer({
style={customBackgroundValue ? `--custom-background: ${customBackgroundValue}` : undefined}
/>
<ActionMessage
key={isStarGift ? gift.id : gift.months}
key={isStarGift ? gift.id : isPremiumGift ? gift.months : undefined}
message={localMessage}
threadId={MAIN_THREAD_ID}
appearanceOrder={0}

View File

@ -31,7 +31,7 @@
}
:global(html.theme-dark) & {
background-color: var(--color-background);
background-color: rgb(33, 33, 33);
}
&:hover::before {
@ -68,6 +68,7 @@
}
.star {
margin-inline-start: 0 !important;
margin-inline-end: 0.125rem;
font-size: 0.75rem !important;
}
@ -86,3 +87,12 @@
margin-inline-start: 0 !important;
margin-inline-end: 0.125rem !important;
}
.radialPattern {
position: absolute;
inset: 0;
}
.stickerWrapper {
position: relative;
}

View File

@ -1,83 +1,160 @@
import React, { memo, useRef } from '../../../lib/teact/teact';
import React, { memo, useMemo, useRef, useState } from '../../../lib/teact/teact';
import { getActions } from '../../../global';
import type {
ApiStarGiftRegular,
ApiStarGift,
} from '../../../api/types';
import buildClassName from '../../../util/buildClassName';
import { formatStarsAsIcon } from '../../../util/localization/format';
import { getStickerFromGift } from '../../common/helpers/gifts';
import { getGiftAttributes } from '../../common/helpers/gifts';
import useFlag from '../../../hooks/useFlag';
import { type ObserveFn, useOnIntersect } from '../../../hooks/useIntersectionObserver';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import AnimatedIconFromSticker from '../../common/AnimatedIconFromSticker';
import GiftRibbon from '../../common/gift/GiftRibbon';
import Icon from '../../common/icons/Icon';
import RadialPatternBackground from '../../common/profile/RadialPatternBackground';
import StickerView from '../../common/StickerView';
import Button from '../../ui/Button';
import styles from './GiftItem.module.scss';
export type OwnProps = {
gift: ApiStarGiftRegular;
gift: ApiStarGift;
observeIntersection?: ObserveFn;
onClick: (gift: ApiStarGiftRegular) => void;
onClick: (gift: ApiStarGift, target: 'original' | 'resell') => void;
isResale?: boolean;
};
const GIFT_STICKER_SIZE = 90;
function GiftItemStar({ gift, observeIntersection, onClick }: OwnProps) {
function GiftItemStar({
gift, observeIntersection, onClick, isResale,
}: OwnProps) {
const { openGiftInfoModal } = getActions();
const ref = useRef<HTMLDivElement>();
const stickerRef = useRef<HTMLDivElement>();
const lang = useLang();
const [shouldPlay, play] = useFlag();
const [isVisible, setIsVisible] = useState(false);
const {
stars,
isLimited,
isSoldOut,
sticker,
} = gift;
const sticker = getStickerFromGift(gift);
const isGiftUnique = gift.type === 'starGiftUnique';
const uniqueGift = isGiftUnique ? gift : undefined;
const regularGift = !isGiftUnique ? gift : undefined;
const stars = !isGiftUnique ? regularGift?.stars : uniqueGift?.resellPriceInStars;
const resellMinStars = regularGift?.resellMinStars;
const priceInStarsAsString = !isGiftUnique && isResale && resellMinStars
? lang.number(resellMinStars) + '+' : stars;
const isLimited = !isGiftUnique && Boolean(regularGift?.isLimited);
const isSoldOut = !isGiftUnique && Boolean(regularGift?.isSoldOut);
const handleGiftClick = useLastCallback(() => {
if (isSoldOut) {
if (isSoldOut && !isResale) {
openGiftInfoModal({ gift });
return;
}
onClick(gift);
onClick(gift, isResale ? 'resell' : 'original');
});
const radialPatternBackdrop = useMemo(() => {
const { backdrop, pattern } = getGiftAttributes(gift) || {};
if (!backdrop || !pattern) {
return undefined;
}
const backdropColors = [backdrop.centerColor, backdrop.edgeColor];
const patternColor = backdrop.patternColor;
return (
<RadialPatternBackground
className={styles.radialPattern}
backgroundColors={backdropColors}
patternColor={patternColor}
patternIcon={pattern.sticker}
/>
);
}, [gift]);
const giftNumber = isGiftUnique ? gift.number : 0;
const giftRibbon = useMemo(() => {
if (isGiftUnique) {
const { backdrop } = getGiftAttributes(gift) || {};
if (!backdrop) {
return undefined;
}
return (
<GiftRibbon
color={[backdrop.centerColor, backdrop.edgeColor]}
text={
lang('GiftSavedNumber', { number: giftNumber })
}
/>
);
}
if (isResale) {
return <GiftRibbon color="green" text={lang('GiftRibbonResale')} />;
}
if (isSoldOut) {
return <GiftRibbon color="red" text={lang('GiftSoldOut')} />;
}
if (isLimited) {
return <GiftRibbon color="blue" text={lang('GiftLimited')} />;
}
return undefined;
}, [isGiftUnique, isResale, gift, isSoldOut, isLimited, lang, giftNumber]);
useOnIntersect(ref, observeIntersection, (entry) => {
if (entry.isIntersecting) play();
const visible = entry.isIntersecting;
setIsVisible(visible);
});
return (
<div
ref={ref}
className={buildClassName(styles.container, styles.starGift)}
className={buildClassName(styles.container, styles.starGift, 'starGiftItem')}
tabIndex={0}
role="button"
onClick={handleGiftClick}
>
<AnimatedIconFromSticker
sticker={sticker}
noLoop
play={shouldPlay}
{radialPatternBackdrop}
<div
ref={stickerRef}
className={styles.stickerWrapper}
style={`width: ${GIFT_STICKER_SIZE}px; height: ${GIFT_STICKER_SIZE}px`}
>
{sticker && (
<StickerView
observeIntersectionForPlaying={observeIntersection}
observeIntersectionForLoading={observeIntersection}
containerRef={stickerRef}
sticker={sticker}
size={GIFT_STICKER_SIZE}
shouldPreloadPreview
/>
)}
</div>
<Button
className={styles.buy}
nonInteractive
size={GIFT_STICKER_SIZE}
/>
<Button className={styles.buy} nonInteractive size="tiny" color="stars" withSparkleEffect pill fluid>
<Icon name="star" className={styles.star} />
<div className={styles.amount}>
{stars}
</div>
size="tiny"
color={isGiftUnique ? 'bluredStarsBadge' : 'stars'}
withSparkleEffect={isVisible}
pill
fluid
>
{formatStarsAsIcon(lang, priceInStarsAsString || 0, { asFont: true, className: styles.star })}
</Button>
{isLimited && !isSoldOut && <GiftRibbon color="blue" text={lang('GiftLimited')} />}
{isSoldOut && <GiftRibbon color="red" text={lang('GiftSoldOut')} />}
{giftRibbon}
</div>
);
}

View File

@ -9,17 +9,37 @@
background-color: var(--color-background);
:global(html.theme-dark) & {
background-color: #181818;
--color-background: #181818;
}
}
.root :global(.modal-dialog),
.transition,
.content {
height: min(92vh, 45rem);
height: min(92vh, 49rem);
max-height: none !important;
}
@media (max-width: 600px) {
.root :global(.modal-dialog),
.transition,
.content {
height: 100%;
}
}
@media (max-width: 600px) {
.root :global(.modal-dialog) {
width: 100%;
max-width: 100% !important;
height: 100%;
border: none !important;
border-radius: 0;
box-shadow: none !important;
}
}
.root :global(.modal-dialog),
.root :global(.modal-content),
.transition {
@ -31,13 +51,22 @@
flex-direction: column;
}
.resaleScreenRoot,
.main {
overflow-y: scroll;
height: 100%;
padding-bottom: 1rem;
padding-inline: 1rem;
padding-bottom: 0.5rem;
padding-inline: 0.5rem;
@include mixins.adapt-padding-to-scrollbar(1rem);
@include mixins.adapt-padding-to-scrollbar(0.5rem);
}
.resaleScreenRoot {
height: calc(100% - 6.25rem);
margin-top: 6.25rem;
}
.main {
height: 100%;
}
.giftSection {
@ -48,6 +77,7 @@
padding: 0.5rem;
}
.resaleStarGiftsContainer,
.starGiftsContainer,
.premiumGiftsGallery {
display: flex;
@ -58,6 +88,7 @@
margin-bottom: 0.75rem;
}
.resaleStarGiftsContainer,
.starGiftsContainer {
display: grid;
grid-template-columns: repeat(3, 1fr);
@ -67,6 +98,10 @@
padding-top: 0.75rem;
}
.resaleStarGiftsContainer {
padding-bottom: 0.625rem;
}
.header {
position: absolute;
z-index: 2;
@ -83,8 +118,19 @@
border-bottom: 0.0625rem solid var(--color-borders);
background: var(--color-background);
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */
transition: height 0.25s ease-out, transform 0.25s ease-out;
}
transition: 0.25s ease-out transform;
.resaleHeader {
overflow: visible;
height: 6.25rem;
}
.resaleHeaderContentContainer {
align-items: center;
justify-items: center;
width: 100%;
}
.headerSlide {
@ -102,11 +148,27 @@
transform: translateY(-100%);
}
.resaleHeaderText,
.commonHeaderText {
unicode-bidi: plaintext;
margin: 0 0 0 4.5rem;
font-size: 1.25rem;
font-weight: var(--font-weight-medium);
line-height: 1.25rem;
}
.resaleHeaderText {
margin: 0;
margin-bottom: 0.25rem !important;
}
.resaleHeaderDescription {
unicode-bidi: plaintext;
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--color-text-secondary);
}
.closeButton {
@ -186,3 +248,36 @@
height: auto;
min-height: calc(100% - 3.5rem);
}
.notFoundGiftsRoot {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 5rem;
}
.notFoundGiftsDescription {
unicode-bidi: plaintext;
margin-block: 1rem;
font-size: 1rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
text-align: center;
}
.notFoundGiftsLink {
font-weight: var(--font-weight-medium);
color: var(--color-links);
transition: opacity 0.15s ease-in;
&:active,
&:hover {
color: var(--color-links);
text-decoration: none;
opacity: 0.85;
}
}

View File

@ -8,6 +8,7 @@ import type {
ApiDisallowedGifts,
ApiPeer,
ApiPremiumGiftCodeOption,
ApiStarGift,
ApiStarGiftRegular,
ApiStarsAmount,
} from '../../../api/types';
@ -17,6 +18,7 @@ import type { StarGiftCategory } from '../../../types';
import { STARS_CURRENCY_CODE } from '../../../config';
import { getUserFullName } from '../../../global/helpers';
import { getPeerTitle, isApiPeerChat, isApiPeerUser } from '../../../global/helpers/peers';
import { selectTabState } from '../../../global/selectors';
import { selectPeer, selectUserFullInfo } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { throttle } from '../../../util/schedulers';
@ -36,6 +38,8 @@ import BalanceBlock from '../stars/BalanceBlock';
import GiftSendingOptions from './GiftComposer';
import GiftItemPremium from './GiftItemPremium';
import GiftItemStar from './GiftItemStar';
import GiftModalResaleScreen from './GiftModalResaleScreen';
import GiftResaleFilters from './GiftResaleFilters';
import StarGiftCategoryList from './StarGiftCategoryList';
import styles from './GiftModal.module.scss';
@ -46,7 +50,7 @@ export type OwnProps = {
modal: TabState['giftModal'];
};
export type GiftOption = ApiPremiumGiftCodeOption | ApiStarGiftRegular;
export type GiftOption = ApiPremiumGiftCodeOption | ApiStarGift;
type StateProps = {
boostPerSentGift?: number;
@ -56,6 +60,8 @@ type StateProps = {
peer?: ApiPeer;
isSelf?: boolean;
disallowedGifts?: ApiDisallowedGifts;
resaleGiftsCount?: number;
areResaleGiftsLoading?: boolean;
};
const AVATAR_SIZE = 100;
@ -72,9 +78,11 @@ const GiftModal: FC<OwnProps & StateProps> = ({
peer,
isSelf,
disallowedGifts,
resaleGiftsCount,
areResaleGiftsLoading,
}) => {
const {
closeGiftModal,
closeGiftModal, openGiftInfoModal, resetResaleGifts, loadResaleGifts,
} = getActions();
const dialogRef = useRef<HTMLDivElement>();
const transitionRef = useRef<HTMLDivElement>();
@ -89,6 +97,7 @@ const GiftModal: FC<OwnProps & StateProps> = ({
const chat = peer && isApiPeerChat(peer) ? peer : undefined;
const [selectedGift, setSelectedGift] = useState<GiftOption | undefined>();
const [selectedResellGift, setSelectedResellGift] = useState<ApiStarGift | undefined>();
const [shouldShowMainScreenHeader, setShouldShowMainScreenHeader] = useState(false);
const [isMainScreenHeaderForStarGifts, setIsMainScreenHeaderForStarGifts] = useState(false);
const [isGiftScreenHeaderForStarGifts, setIsGiftScreenHeaderForStarGifts] = useState(false);
@ -143,14 +152,22 @@ const GiftModal: FC<OwnProps & StateProps> = ({
observe: observeIntersection,
} = useIntersectionObserver({ rootRef: scrollerRef, throttleMs: INTERSECTION_THROTTLE, isDisabled: !isOpen });
const isResaleScreen = Boolean(selectedResellGift) && !selectedGift;
const isGiftScreen = Boolean(selectedGift);
const shouldShowHeader = isGiftScreen || shouldShowMainScreenHeader;
const shouldShowHeader = isResaleScreen || isGiftScreen || shouldShowMainScreenHeader;
const isHeaderForStarGifts = isGiftScreen ? isGiftScreenHeaderForStarGifts : isMainScreenHeaderForStarGifts;
useEffect(() => {
if (selectedResellGift) {
loadResaleGifts({ giftId: selectedResellGift.id });
}
}, [selectedResellGift]);
useEffect(() => {
if (!isOpen) {
setShouldShowMainScreenHeader(false);
setSelectedGift(undefined);
setSelectedResellGift(undefined);
setSelectedCategory('all');
}
}, [isOpen]);
@ -228,7 +245,18 @@ const GiftModal: FC<OwnProps & StateProps> = ({
);
}
const handleGiftClick = useLastCallback((gift: GiftOption) => {
const handleGiftClick = useLastCallback((gift: GiftOption, target?: 'resell' | 'original') => {
if (target === 'resell') {
if (!('id' in gift)) {
return;
}
if (isResaleScreen) {
openGiftInfoModal({ gift, recipientId: renderingModal?.forPeerId });
return;
}
setSelectedResellGift(gift);
return;
}
setSelectedGift(gift);
setIsGiftScreenHeaderForStarGifts('id' in gift);
});
@ -254,16 +282,34 @@ const GiftModal: FC<OwnProps & StateProps> = ({
return (
<div className={styles.starGiftsContainer}>
{starGiftsById && filteredGiftIds?.map((giftId) => {
{starGiftsById && filteredGiftIds?.flatMap((giftId) => {
const gift = starGiftsById[giftId];
return (
const shouldShowResale = selectedCategory !== 'stock' && Boolean(gift.availabilityResale);
const shouldDuplicateAsResale = selectedCategory !== 'resale' && shouldShowResale && !gift.isSoldOut;
const elements = [
<GiftItemStar
key={giftId}
gift={gift}
observeIntersection={observeIntersection}
isResale={shouldShowResale && !shouldDuplicateAsResale}
onClick={handleGiftClick}
/>
);
/>,
];
if (shouldDuplicateAsResale) {
elements.push(
<GiftItemStar
key={`resale_${giftId}`}
isResale
gift={gift}
observeIntersection={observeIntersection}
onClick={handleGiftClick}
/>,
);
}
return elements;
})}
</div>
);
@ -290,12 +336,24 @@ const GiftModal: FC<OwnProps & StateProps> = ({
setSelectedCategory(category);
});
const handleCloseModal = useLastCallback(() => {
setSelectedGift(undefined);
setSelectedResellGift(undefined);
resetResaleGifts();
closeGiftModal();
});
const handleCloseButtonClick = useLastCallback(() => {
if (isResaleScreen) {
setSelectedResellGift(undefined);
resetResaleGifts();
return;
}
if (isGiftScreen) {
setSelectedGift(undefined);
return;
}
closeGiftModal();
handleCloseModal();
});
function renderMainScreen() {
@ -337,17 +395,51 @@ const GiftModal: FC<OwnProps & StateProps> = ({
);
}
const isBackButton = isGiftScreen;
const isBackButton = isGiftScreen || isResaleScreen;
const buttonClassName = buildClassName(
'animated-close-icon',
isBackButton && 'state-back',
);
function renderHeader() {
if (!shouldShowHeader) return undefined;
if (isResaleScreen) {
const isFirstLoading = areResaleGiftsLoading && !resaleGiftsCount;
return (
<div className={styles.resaleHeaderContentContainer}>
<h2 className={styles.resaleHeaderText}>
{selectedResellGift.title}
</h2>
{isFirstLoading
&& (
<div className={styles.resaleHeaderDescription}>
{lang('Loading')}
</div>
)}
{!isFirstLoading && resaleGiftsCount !== undefined
&& (
<div className={styles.resaleHeaderDescription}>
{lang('HeaderDescriptionResaleGifts', {
count: resaleGiftsCount,
}, { withNodes: true, withMarkdown: true, pluralValue: resaleGiftsCount })}
</div>
)}
<GiftResaleFilters dialogRef={dialogRef} />
</div>
);
}
return (
<h2 className={styles.commonHeaderText}>
{lang(isHeaderForStarGifts ? (isSelf ? 'StarsGiftHeaderSelf' : 'StarsGiftHeader') : 'GiftPremiumHeader')}
</h2>
);
}
return (
<Modal
dialogRef={dialogRef}
onClose={closeGiftModal}
onClose={handleCloseModal}
isOpen={isOpen}
isSlim
contentClassName={styles.content}
@ -365,24 +457,32 @@ const GiftModal: FC<OwnProps & StateProps> = ({
<div className={buttonClassName} />
</Button>
<BalanceBlock className={styles.balance} balance={starBalance} withAddButton />
<div className={buildClassName(styles.header, !shouldShowHeader && styles.hiddenHeader)}>
<div className={buildClassName(
styles.header,
isResaleScreen && styles.resaleHeader,
!shouldShowHeader && styles.hiddenHeader)}
>
<Transition
name="slideVerticalFade"
activeKey={Number(isHeaderForStarGifts)}
activeKey={!shouldShowHeader ? 0 : isResaleScreen ? 1 : isHeaderForStarGifts ? 2 : 3}
slideClassName={styles.headerSlide}
>
<h2 className={styles.commonHeaderText}>
{lang(isHeaderForStarGifts ? (isSelf ? 'StarsGiftHeaderSelf' : 'StarsGiftHeader') : 'GiftPremiumHeader')}
</h2>
{renderHeader()}
</Transition>
</div>
<Transition
ref={transitionRef}
className={styles.transition}
name="pushSlide"
activeKey={isGiftScreen ? 1 : 0}
activeKey={isGiftScreen ? 1 : isResaleScreen ? 2 : 0}
>
{!isGiftScreen && renderMainScreen()}
{!isGiftScreen && !isResaleScreen && renderMainScreen()}
{isResaleScreen && selectedResellGift
&& (
<GiftModalResaleScreen
onGiftClick={handleGiftClick}
/>
)}
{isGiftScreen && renderingModal?.forPeerId && (
<GiftSendingOptions
gift={selectedGift}
@ -406,6 +506,10 @@ export default memo(withGlobal<OwnProps>((global, { modal }): StateProps => {
const isSelf = Boolean(currentUserId && modal?.forPeerId === currentUserId);
const userFullInfo = peer ? selectUserFullInfo(global, peer?.id) : undefined;
const { resaleGifts } = selectTabState(global);
const resaleGiftsCount = resaleGifts.count;
const areResaleGiftsLoading = resaleGifts.isLoading !== false;
return {
boostPerSentGift: global.appConfig?.boostsPerSentGift,
starGiftsById: starGifts?.byId,
@ -414,12 +518,15 @@ export default memo(withGlobal<OwnProps>((global, { modal }): StateProps => {
peer,
isSelf,
disallowedGifts: userFullInfo?.disallowedGifts,
resaleGiftsCount,
areResaleGiftsLoading,
};
})(GiftModal));
function getCategoryKey(category: StarGiftCategory) {
if (category === 'all') return -2;
if (category === 'limited') return -1;
if (category === 'stock') return 0;
return category;
if (category === 'all') return 0;
if (category === 'limited') return 1;
if (category === 'resale') return 2;
if (category === 'stock') return 3;
return category + 3;
}

View File

@ -0,0 +1,171 @@
import type { FC } from '../../../lib/teact/teact';
import React, {
memo,
useMemo,
useRef } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type {
ApiStarGift,
} from '../../../api/types';
import type { ResaleGiftsFilterOptions } from '../../../types';
import { RESALE_GIFTS_LIMIT } from '../../../config';
import { selectTabState,
} from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets';
import useInfiniteScroll from '../../../hooks/useInfiniteScroll';
import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import AnimatedIconWithPreview from '../../common/AnimatedIconWithPreview';
import InfiniteScroll from '../../ui/InfiniteScroll';
import Link from '../../ui/Link';
import Transition from '../../ui/Transition';
import GiftItemStar from './GiftItemStar';
import styles from './GiftModal.module.scss';
export type OwnProps = {
onGiftClick: (gift: ApiStarGift) => void;
};
type StateProps = {
gift?: ApiStarGift;
resellGifts?: ApiStarGift[];
filter: ResaleGiftsFilterOptions;
areGiftsAllLoaded?: boolean;
areGiftsLoading?: boolean;
updateIteration: number;
};
const INTERSECTION_THROTTLE = 200;
const GiftModalResaleScreen: FC<OwnProps & StateProps> = ({
resellGifts,
gift,
filter,
areGiftsAllLoaded,
areGiftsLoading,
updateIteration,
onGiftClick,
}) => {
const {
loadResaleGifts,
updateResaleGiftsFilter,
} = getActions();
const scrollerRef = useRef<HTMLDivElement>();
const lang = useLang();
const resellGiftsIds = useMemo(() => {
return resellGifts?.map((g) => g.id);
}, [resellGifts]);
const hasFilter = Boolean(filter?.modelAttributes?.length
|| filter?.patternAttributes?.length || filter?.backdropAttributes?.length);
const handleLoadMoreResellGifts = useLastCallback(() => {
if (gift) {
loadResaleGifts({ giftId: gift.id });
}
});
const isGiftsEmpty = Boolean(!resellGifts || resellGifts.length === 0);
const [viewportIds, onLoadMore] = useInfiniteScroll(
handleLoadMoreResellGifts,
resellGiftsIds,
!gift,
RESALE_GIFTS_LIMIT,
);
const { observe } = useIntersectionObserver({ rootRef: scrollerRef, throttleMs: INTERSECTION_THROTTLE });
const handleResetGiftsFilter = useLastCallback(() => {
updateResaleGiftsFilter({ filter: {
...filter,
modelAttributes: [],
backdropAttributes: [],
patternAttributes: [],
} });
});
function renderNothingFoundGiftsWithFilter() {
return (
<div className={styles.notFoundGiftsRoot}>
<AnimatedIconWithPreview
size={160}
tgsUrl={LOCAL_TGS_URLS.SearchingDuck}
nonInteractive
noLoop
/>
<div className={styles.notFoundGiftsDescription}>
{lang('ResellGiftsNoFound')}
</div>
{hasFilter && (
<Link
className={styles.notFoundGiftsLink}
onClick={handleResetGiftsFilter}
>
{lang('ResellGiftsClearFilters')}
</Link>
)}
</div>
);
}
return (
<div ref={scrollerRef} className={buildClassName(styles.resaleScreenRoot, 'custom-scroll')}>
<Transition
name="zoomFade"
activeKey={updateIteration}
>
{isGiftsEmpty && areGiftsAllLoaded && renderNothingFoundGiftsWithFilter()}
<InfiniteScroll
className={buildClassName(styles.resaleStarGiftsContainer)}
items={viewportIds}
onLoadMore={onLoadMore}
itemSelector=".starGiftItem"
noFastList
preloadBackwards={RESALE_GIFTS_LIMIT}
scrollContainerClosest={`.${styles.resaleScreenRoot}`}
>
{resellGifts?.map((gift) => (
<GiftItemStar
key={gift.id}
gift={gift}
observeIntersection={observe}
isResale
onClick={onGiftClick}
/>
))}
</InfiniteScroll>
</Transition>
</div>
);
};
export default memo(withGlobal<OwnProps>((global): StateProps => {
const {
starGifts,
} = global;
const { resaleGifts } = selectTabState(global);
const gift = resaleGifts?.giftId ? starGifts?.byId[resaleGifts.giftId] : undefined;
const filter = resaleGifts.filter;
const areGiftsAllLoaded = resaleGifts.isAllLoaded;
const areGiftsLoading = resaleGifts.isLoading;
const updateIteration = resaleGifts.updateIteration;
return {
resellGifts: resaleGifts.gifts,
gift,
filter,
areGiftsAllLoaded,
areGiftsLoading,
updateIteration,
};
})(GiftModalResaleScreen));

View File

@ -0,0 +1,160 @@
@use '../../../styles/mixins';
.root {
position: relative;
display: flex;
width: 100%;
margin-top: 0.5rem;
}
.buttonsContainer {
scrollbar-color: rgba(0, 0, 0, 0);
scrollbar-width: none;
overflow-x: auto;
overflow-y: hidden;
display: flex;
align-items: center;
height: 100%;
padding-right: 0.5rem;
padding-left: 0.5rem;
white-space: nowrap;
@include mixins.gradient-border-horizontal(0.5rem, 0.5rem);
&::-webkit-scrollbar {
height: 0;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0);
}
}
.itemIcon {
margin-inline-start: 0.25rem;
}
.sticker {
margin-inline-start: 0.375rem;
}
.backdropAttributeMenuItemText,
.menuItemStickerText,
.menuItemText {
flex-grow: 1;
}
.menuItemStickerText {
margin-inline-start: 1.125rem;
}
.backdropAttributeMenuItemText {
margin-inline-start: 3rem;
}
.backdrop {
position: absolute;
left: 0.625rem;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
}
.menuItemIcon {
margin-inline: 0 !important;
margin-inline-start: 0.5 !important;
}
.item {
display: flex;
align-items: center;
justify-content: center;
width: auto;
margin-inline: 0.25rem;
padding: 0.125rem 0.75rem;
border-radius: 1rem;
font-size: 0.875rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
white-space: nowrap;
background-color: var(--color-background-secondary);
transition: 0.15s background-color;
&:hover {
cursor: pointer;
background-color: var(--color-background-secondary-accent);
}
}
.menuContentContainer {
overflow-y: scroll;
max-height: 20rem;
}
:global(.MenuItem) {
:global(.icon) {
color: var(--color-text);
}
}
.menu {
:global(.bubble) {
top: 2.25rem !important;
}
&.left {
:global(.bubble) {
right: auto !important;
left: 0.125rem !important;
}
}
&.right {
:global(.bubble) {
right: 0.125rem !important;
left: auto !important;
}
}
:global(.SearchInput) {
--color-placeholders: var(--color-text-secondary);
width: 15rem;
border: none;
border-bottom: 0.0625rem solid var(--color-borders);
border-radius: 0;
background-color: transparent;
:global(.form-control) {
caret-color: var(--color-icon-secondary);
}
:global(.icon) {
color: var(--color-icon-secondary);
}
input {
padding-left: 1.25rem;
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
}
:global(.icon-container-left) {
width: 1.25rem;
margin-inline-start: 1rem;
}
}
:global(.icon) {
font-size: 1.25rem !important;
color: var(--color-text);
}
}

View File

@ -0,0 +1,655 @@
import { type MouseEvent as ReactMouseEvent } from 'react';
import type { ElementRef, FC } from '../../../lib/teact/teact';
import React, {
memo,
useCallback,
useMemo,
useRef,
useState } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type {
ApiStarGiftAttribute,
ApiStarGiftAttributeBackdrop,
ApiStarGiftAttributeCounter,
ApiStarGiftAttributeIdBackdrop,
ApiStarGiftAttributeIdPattern,
ApiStarGiftAttributeModel,
ApiStarGiftAttributePattern,
StarGiftAttributeIdModel,
} from '../../../api/types';
import type { ResaleGiftsFilterOptions, ResaleGiftsSortType } from '../../../types';
import { selectTabState,
} from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers';
import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import Icon from '../../common/icons/Icon';
import RadialPatternBackground from '../../common/profile/RadialPatternBackground';
import Menu from '../../ui/Menu';
import MenuItem from '../../ui/MenuItem';
import SearchInput from '../../ui/SearchInput';
import ResaleGiftMenuAttributeSticker from './ResaleGiftMenuAttributeSticker';
import styles from './GiftResaleFilters.module.scss';
type OwnProps = {
dialogRef: ElementRef<HTMLDivElement>;
};
type StateProps = {
filter: ResaleGiftsFilterOptions;
attributes?: ApiStarGiftAttribute[];
counters?: ApiStarGiftAttributeCounter[];
};
const GiftResaleFilters: FC<StateProps & OwnProps> = ({
attributes,
counters,
filter,
dialogRef,
}) => {
const lang = useLang();
const {
updateResaleGiftsFilter,
} = getActions();
const [searchModelQuery, setSearchModelQuery] = useState('');
const [searchBackdropQuery, setSearchBackdropQuery] = useState('');
const [searchPatternQuery, setSearchPatternQuery] = useState('');
const filteredAttributes = useMemo(() => {
const map: {
model: ApiStarGiftAttributeModel[];
pattern: ApiStarGiftAttributePattern[];
backdrop: ApiStarGiftAttributeBackdrop[];
} = {
model: [],
pattern: [],
backdrop: [],
};
for (const counter of counters ?? []) {
const { attribute } = counter;
if (!counter.count) {
continue;
}
const found = attributes?.find((attr) => {
if (attr.type === 'backdrop' && attribute.type === 'backdrop') {
return attr.backdropId === attribute.backdropId;
}
if (attr.type === 'model' && attribute.type === 'model') {
return attr.sticker.id === attribute.documentId;
}
if (attr.type === 'pattern' && attribute.type === 'pattern') {
return attr.sticker.id === attribute.documentId;
}
return false;
});
if (found?.type === 'backdrop') {
map.backdrop.push(found);
}
if (found?.type === 'model') {
map.model.push(found);
}
if (found?.type === 'pattern') {
map.pattern.push(found);
}
}
return map;
}, [attributes, counters]);
const filteredAndSearchedAttributes = useMemo(() => {
const filterBySearch = <T extends { name?: string }>(items: T[], query: string): T[] => {
if (!query.trim()) return items;
return items.filter(
(item): item is T => Boolean(item.name?.toLowerCase().includes(query.toLowerCase())),
);
};
return {
model: filterBySearch(filteredAttributes.model, searchModelQuery),
pattern: filterBySearch(filteredAttributes.pattern, searchPatternQuery),
backdrop: filterBySearch(filteredAttributes.backdrop, searchBackdropQuery),
};
}, [filteredAttributes, searchModelQuery, searchBackdropQuery, searchPatternQuery]);
// Sort Menu
const sortMenuRef = useRef<HTMLDivElement>();
const {
isContextMenuOpen: isSortContextMenuOpen,
contextMenuAnchor: sortContextMenuAnchor,
handleContextMenu: handleSortContextMenu,
handleContextMenuClose: handleSortContextMenuClose,
handleContextMenuHide: handleSortContextMenuHide,
} = useContextMenuHandlers(dialogRef);
const getSortMenuElement = useLastCallback(() => sortMenuRef.current!);
// Model Menu
const modelMenuRef = useRef<HTMLDivElement>();
const {
isContextMenuOpen: isModelContextMenuOpen,
contextMenuAnchor: modelContextMenuAnchor,
handleContextMenu: handleModelContextMenu,
handleContextMenuClose: handleModelContextMenuClose,
handleContextMenuHide: handleModelContextMenuHide,
} = useContextMenuHandlers(dialogRef);
const getModelMenuElement = useLastCallback(
() => modelMenuRef.current!,
);
// Backdrop Menu
const backdropMenuRef = useRef<HTMLDivElement>();
const {
isContextMenuOpen: isBackdropContextMenuOpen,
contextMenuAnchor: backdropContextMenuAnchor,
handleContextMenu: handleBackdropContextMenu,
handleContextMenuClose: handleBackdropContextMenuClose,
handleContextMenuHide: handleBackdropContextMenuHide,
} = useContextMenuHandlers(dialogRef);
const getBackdropMenuElement = useLastCallback(() => backdropMenuRef.current!);
// Pattern Menu
const patternMenuRef = useRef<HTMLDivElement>();
const {
isContextMenuOpen: isPatternContextMenuOpen,
contextMenuAnchor: patternContextMenuAnchor,
handleContextMenu: handlePatternContextMenu,
handleContextMenuClose: handlePatternContextMenuClose,
handleContextMenuHide: handlePatternContextMenuHide,
} = useContextMenuHandlers(dialogRef);
const getPatternMenuElement = useLastCallback(() => patternMenuRef.current!);
const SortMenuButton: FC<{ onTrigger: (e: ReactMouseEvent<HTMLDivElement, MouseEvent>) => void; isOpen?: boolean }>
= useMemo(() => {
const sortType = filter.sortType;
return ({ onTrigger, isOpen: isMenuOpen }) => (
<div
className={styles.item}
onClick={onTrigger}
>
{sortType === 'byDate' && lang('ValueGiftSortByDate')}
{sortType === 'byNumber' && lang('ValueGiftSortByNumber')}
{sortType === 'byPrice' && lang('ValueGiftSortByPrice')}
<Icon
name="dropdown-arrows"
className={styles.itemIcon}
/>
</div>
);
}, [lang, filter]);
const ModelMenuButton:
FC<{ onTrigger: (e: ReactMouseEvent<HTMLDivElement, MouseEvent>) => void; isOpen?: boolean }>
= useMemo(() => {
const attributesCount = filter?.modelAttributes?.length || 0;
return ({ onTrigger, isOpen: isMenuOpen }) => (
<div
className={styles.item}
onClick={onTrigger}
>
{attributesCount === 0 && lang('GiftAttributeModel')}
{attributesCount > 0
&& lang('GiftAttributeModelPlural', { count: attributesCount }, { pluralValue: attributesCount })}
<Icon
name="dropdown-arrows"
className={styles.itemIcon}
/>
</div>
);
}, [lang, filter]);
const BackdropMenuButton:
FC<{ onTrigger: (e: ReactMouseEvent<HTMLDivElement, MouseEvent>) => void; isOpen?: boolean }>
= useMemo(() => {
const attributesCount = filter?.backdropAttributes?.length || 0;
return ({ onTrigger, isOpen: isMenuOpen }) => (
<div
className={styles.item}
onClick={onTrigger}
>
{attributesCount === 0 && lang('GiftAttributeBackdrop')}
{attributesCount > 0
&& lang('GiftAttributeBackdropPlural', { count: attributesCount }, { pluralValue: attributesCount })}
<Icon
name="dropdown-arrows"
className={styles.itemIcon}
/>
</div>
);
}, [lang, filter]);
const PatternMenuButton: FC<{ onTrigger: (e: ReactMouseEvent<HTMLDivElement, MouseEvent>) => void; isOpen?: boolean }>
= useMemo(() => {
const attributesCount = filter?.patternAttributes?.length || 0;
return ({ onTrigger, isOpen: isMenuOpen }) => (
<div
className={styles.item}
onClick={onTrigger}
>
{attributesCount === 0 && lang('GiftAttributeSymbol')}
{attributesCount > 0
&& lang('GiftAttributeSymbolPlural', { count: attributesCount }, { pluralValue: attributesCount })}
<Icon
name="dropdown-arrows"
className={styles.itemIcon}
/>
</div>
);
}, [lang, filter]);
const handleSortMenuItemClick = useLastCallback((type: ResaleGiftsSortType) => {
updateResaleGiftsFilter({ filter: {
...filter,
sortType: type,
} });
});
const handleSelectedAllModelsClick = useLastCallback(() => {
updateResaleGiftsFilter({ filter: {
...filter,
modelAttributes: [],
} });
});
const handleSelectedAllPatternsClick = useLastCallback(() => {
updateResaleGiftsFilter({ filter: {
...filter,
patternAttributes: [],
} });
});
const handleSelectedAllBackdropsClick = useLastCallback(() => {
updateResaleGiftsFilter({ filter: {
...filter,
backdropAttributes: [],
} });
});
const handleModelMenuItemClick = useLastCallback((attribute: ApiStarGiftAttributeModel) => {
if (!counters) return;
const attributes = filter.modelAttributes || [];
const modelAttribute
= counters.find((counter): counter is ApiStarGiftAttributeCounter<StarGiftAttributeIdModel> =>
counter.attribute.type === 'model' && counter.attribute.documentId === attribute.sticker.id,
)?.attribute;
if (!modelAttribute) return;
const isActive = attributes.some((item) => item.documentId === modelAttribute.documentId);
const updatedAttributes = isActive
? attributes.filter((item) => item.documentId !== modelAttribute.documentId)
: [...attributes, modelAttribute];
updateResaleGiftsFilter({ filter: {
...filter,
modelAttributes: updatedAttributes,
} });
});
const handlePatternMenuItemClick = useLastCallback((attribute: ApiStarGiftAttributePattern) => {
if (!counters) return;
const attributes = filter.patternAttributes || [];
const patternAttribute = counters.find(
(counter): counter is ApiStarGiftAttributeCounter<ApiStarGiftAttributeIdPattern> =>
counter.attribute.type === 'pattern' && counter.attribute.documentId === attribute.sticker.id,
)?.attribute;
if (!patternAttribute) return;
const isActive = attributes.some((item) => item.documentId === patternAttribute.documentId);
const updatedAttributes = isActive
? attributes.filter((item) => item.documentId !== patternAttribute.documentId)
: [...attributes, patternAttribute];
updateResaleGiftsFilter({ filter: {
...filter,
patternAttributes: updatedAttributes,
} });
});
const handleBackdropMenuItemClick = useLastCallback((attribute: ApiStarGiftAttributeBackdrop) => {
if (!counters) return;
const attributes = filter.backdropAttributes || [];
const backdropAttribute = counters.find(
(counter): counter is ApiStarGiftAttributeCounter<ApiStarGiftAttributeIdBackdrop> =>
counter.attribute.type === 'backdrop' && counter.attribute.backdropId === attribute.backdropId,
)?.attribute;
if (!backdropAttribute) return;
const isActive = attributes.some((item) => item.backdropId === backdropAttribute.backdropId);
const updatedAttributes = isActive
? attributes.filter((item) => item.backdropId !== backdropAttribute.backdropId)
: [...attributes, backdropAttribute];
updateResaleGiftsFilter({ filter: {
...filter,
backdropAttributes: updatedAttributes,
} });
});
function renderSortMenuItems() {
return (
<>
<MenuItem icon="sort-by-price" onClick={() => { handleSortMenuItemClick('byPrice'); }}>
<div className={styles.menuItemText}>
{lang('GiftSortByPrice')}
</div>
<Icon
className={styles.menuItemIcon}
name={filter?.sortType === 'byPrice' ? 'check' : 'placeholder'}
/>
</MenuItem>
<MenuItem icon="sort-by-date" onClick={() => { handleSortMenuItemClick('byDate'); }}>
<div className={styles.menuItemText}>
{lang('GiftSortByDate')}
</div>
<Icon
className={styles.menuItemIcon}
name={filter?.sortType === 'byDate' ? 'check' : 'placeholder'}
/>
</MenuItem>
<MenuItem icon="sort-by-number"onClick={() => { handleSortMenuItemClick('byNumber'); }}>
<div className={styles.menuItemText}>
{lang('GiftSortByNumber')}
</div>
<Icon
className={styles.menuItemIcon}
name={filter?.sortType === 'byNumber' ? 'check' : 'placeholder'}
/>
</MenuItem>
</>
);
}
function renderSortMenu() {
return (
<Menu
isOpen={isSortContextMenuOpen}
anchor={sortContextMenuAnchor}
ref={sortMenuRef}
className={buildClassName(
styles.menu,
styles.left,
'with-menu-transitions',
)}
getMenuElement={getSortMenuElement}
autoClose
onClose={handleSortContextMenuClose}
onCloseAnimationEnd={handleSortContextMenuHide}
positionX="left"
>
{renderSortMenuItems()}
</Menu>
);
}
const handleSearchModelInputReset = useCallback(() => {
setSearchModelQuery('');
}, []);
const handleSearchBackdropInputReset = useCallback(() => {
setSearchBackdropQuery('');
}, []);
const handleSearchPatternInputReset = useCallback(() => {
setSearchPatternQuery('');
}, []);
const handleSearchInputClick = useLastCallback((e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
});
const modelMenuItemsContainerRef = useRef<HTMLDivElement>();
const { observe } = useIntersectionObserver({
rootRef: modelMenuItemsContainerRef,
isDisabled: !modelContextMenuAnchor,
});
function renderModelMenuItems() {
const models = filteredAndSearchedAttributes.model;
const selectedAttributes = filter.modelAttributes ?? [];
const isSelectedAll = selectedAttributes.length === 0;
return (
<div className={styles.menuContentContainer} ref={modelMenuItemsContainerRef}>
<SearchInput
onClick={handleSearchInputClick}
className={styles.search}
value={searchModelQuery}
onChange={setSearchModelQuery}
onReset={handleSearchModelInputReset}
placeholder={lang('Search')}
/>
<MenuItem icon="select" onClick={handleSelectedAllModelsClick} disabled={isSelectedAll}>
{lang('ContextMenuItemSelectAll')}
</MenuItem>
{models.map((model) => {
const isSelected = isSelectedAll
|| selectedAttributes.some((attr) => attr.documentId === model.sticker.id);
return (
<MenuItem
key={model.name}
onClick={() => {
handleModelMenuItemClick(model);
}}
>
<ResaleGiftMenuAttributeSticker
className={styles.sticker}
sticker={model.sticker}
type="model"
observeIntersectionForLoading={observe}
observeIntersectionForPlaying={observe}
/>
<div className={styles.menuItemStickerText}>
{model.name}
</div>
<Icon
className={styles.menuItemIcon}
name={isSelected ? 'check' : 'placeholder'}
/>
</MenuItem>
);
})}
</div>
);
}
function renderModelMenu() {
return (
<Menu
isOpen={isModelContextMenuOpen}
anchor={modelContextMenuAnchor}
ref={modelMenuRef}
className={buildClassName(
styles.menu,
styles.left,
'with-menu-transitions',
)}
getMenuElement={getModelMenuElement}
autoClose
onClose={handleModelContextMenuClose}
onCloseAnimationEnd={handleModelContextMenuHide}
>
{renderModelMenuItems()}
</Menu>
);
}
function renderBackdropMenuItems() {
const backdrops = filteredAndSearchedAttributes.backdrop;
const selectedAttributes = filter.backdropAttributes ?? [];
const isSelectedAll = selectedAttributes.length === 0;
return (
<div className={styles.menuContentContainer}>
<SearchInput
onClick={handleSearchInputClick}
className={styles.search}
value={searchBackdropQuery}
onChange={setSearchBackdropQuery}
onReset={handleSearchBackdropInputReset}
placeholder={lang('Search')}
/>
<MenuItem icon="select" onClick={handleSelectedAllBackdropsClick} disabled={isSelectedAll}>
{lang('ContextMenuItemSelectAll')}
</MenuItem>
{backdrops.map((backdrop) => {
const isSelected = isSelectedAll
|| selectedAttributes.some((attr) => attr.backdropId === backdrop.backdropId);
return (
<MenuItem
key={backdrop.name}
onClick={() => {
handleBackdropMenuItemClick(backdrop);
}}
>
<RadialPatternBackground
className={styles.backdrop}
backgroundColors={[backdrop.centerColor, backdrop.edgeColor]}
patternColor={backdrop.patternColor}
/>
<div className={styles.backdropAttributeMenuItemText}>
{backdrop.name}
</div>
<Icon
className={styles.menuItemIcon}
name={isSelected ? 'check' : 'placeholder'}
/>
</MenuItem>
);
})}
</div>
);
}
function renderBackdropMenu() {
return (
<Menu
isOpen={isBackdropContextMenuOpen}
anchor={backdropContextMenuAnchor}
ref={backdropMenuRef}
className={buildClassName(styles.menu, styles.right, 'with-menu-transitions')}
getMenuElement={getBackdropMenuElement}
autoClose
onClose={handleBackdropContextMenuClose}
onCloseAnimationEnd={handleBackdropContextMenuHide}
positionX="right"
>
{renderBackdropMenuItems()}
</Menu>
);
}
function renderPatternMenuItems() {
const patterns = filteredAndSearchedAttributes.pattern;
const selectedAttributes = filter.patternAttributes ?? [];
const isSelectedAll = selectedAttributes.length === 0;
return (
<div className={styles.menuContentContainer}>
<SearchInput
onClick={handleSearchInputClick}
className={styles.search}
value={searchPatternQuery}
onChange={setSearchPatternQuery}
onReset={handleSearchPatternInputReset}
placeholder={lang('Search')}
/>
<MenuItem icon="select" onClick={handleSelectedAllPatternsClick} disabled={isSelectedAll}>
{lang('ContextMenuItemSelectAll')}
</MenuItem>
{patterns.map((pattern) => {
const isSelected = isSelectedAll
|| selectedAttributes.some((attr) => attr.documentId === pattern.sticker.id);
return (
<MenuItem
key={pattern.name}
onClick={() => {
handlePatternMenuItemClick(pattern);
}}
>
<ResaleGiftMenuAttributeSticker
className={styles.sticker}
sticker={pattern.sticker}
type="pattern"
/>
<div className={styles.menuItemStickerText}>
{pattern.name}
</div>
<Icon
className={styles.menuItemIcon}
name={isSelected ? 'check' : 'placeholder'}
/>
</MenuItem>
);
})}
</div>
);
}
function renderPatternMenu() {
return (
<Menu
isOpen={isPatternContextMenuOpen}
anchor={patternContextMenuAnchor}
ref={patternMenuRef}
className={buildClassName(styles.menu, styles.right, 'with-menu-transitions')}
getMenuElement={getPatternMenuElement}
autoClose
onClose={handlePatternContextMenuClose}
onCloseAnimationEnd={handlePatternContextMenuHide}
>
{renderPatternMenuItems()}
</Menu>
);
}
return (
<div className={styles.root}>
{Boolean(sortContextMenuAnchor) && renderSortMenu()}
{Boolean(modelContextMenuAnchor) && renderModelMenu()}
{Boolean(backdropContextMenuAnchor) && renderBackdropMenu()}
{Boolean(patternContextMenuAnchor) && renderPatternMenu()}
<div className={styles.buttonsContainer}>
<SortMenuButton
onTrigger={handleSortContextMenu}
isOpen={isSortContextMenuOpen}
/>
<ModelMenuButton
onTrigger={handleModelContextMenu}
isOpen={isModelContextMenuOpen}
/>
<BackdropMenuButton
onTrigger={handleBackdropContextMenu}
isOpen={isBackdropContextMenuOpen}
/>
<PatternMenuButton
onTrigger={handlePatternContextMenu}
isOpen={isPatternContextMenuOpen}
/>
</div>
</div>
);
};
export default memo(withGlobal((global): StateProps => {
const { resaleGifts } = selectTabState(global);
const attributes = resaleGifts.attributes;
const counters = resaleGifts.counters;
const filter = resaleGifts.filter;
return {
attributes,
counters,
filter,
};
})(GiftResaleFilters));

View File

@ -0,0 +1,7 @@
.root {
position: relative;
}
.thumb {
position: absolute;
}

View File

@ -0,0 +1,69 @@
import type { FC } from '../../../lib/teact/teact';
import React, { memo, useRef } from '../../../lib/teact/teact';
import { withGlobal } from '../../../global';
import type { ApiSticker } from '../../../api/types';
import type { ThemeKey } from '../../../types';
import { selectTheme } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { REM } from '../../common/helpers/mediaDimensions';
import useDynamicColorListener from '../../../hooks/stickers/useDynamicColorListener';
import { type ObserveFn } from '../../../hooks/useIntersectionObserver';
import StickerView from '../../common/StickerView';
import styles from './ResaleGiftMenuAttributeSticker.module.scss';
type OwnProps = {
className?: string;
type: 'model' | 'pattern';
sticker: ApiSticker;
observeIntersectionForLoading?: ObserveFn;
observeIntersectionForPlaying?: ObserveFn;
};
type StateProps = {
theme: ThemeKey;
};
const ATTRIBUTE_STICKER_SIZE = 1.5 * REM;
const ResaleGiftMenuAttributeSticker: FC<StateProps & OwnProps> = ({
className,
type,
sticker,
observeIntersectionForLoading,
observeIntersectionForPlaying,
theme,
}) => {
const stickerRef = useRef<HTMLDivElement>();
const customColor = useDynamicColorListener(stickerRef, undefined, type !== 'pattern');
return (
<div
ref={stickerRef}
className={buildClassName(styles.root, className)}
style={`width: ${ATTRIBUTE_STICKER_SIZE}px; height: ${ATTRIBUTE_STICKER_SIZE}px`}
>
<StickerView
containerRef={stickerRef}
sticker={sticker}
size={ATTRIBUTE_STICKER_SIZE}
shouldPreloadPreview
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
thumbClassName={styles.thumb}
customColor={customColor}
/>
</div>
);
};
export default memo(withGlobal<OwnProps>((global): StateProps => {
return {
theme: selectTheme(global),
};
})(ResaleGiftMenuAttributeSticker));

View File

@ -37,6 +37,8 @@ const StarGiftCategoryList = ({
.sort((a, b) => a - b),
[idsByCategory]);
const hasResale = idsByCategory && idsByCategory['resale'].length > 0;
const [selectedCategory, setSelectedCategory] = useState<StarGiftCategory>('all');
function handleItemClick(category: StarGiftCategory) {
@ -50,6 +52,7 @@ const StarGiftCategoryList = ({
if (category === 'all') return lang('AllGiftsCategory');
if (category === 'stock') return lang('StockGiftsCategory');
if (category === 'limited') return lang('LimitedGiftsCategory');
if (category === 'resale') return lang('GiftCategoryResale');
return category;
}
@ -80,6 +83,7 @@ const StarGiftCategoryList = ({
<div ref={ref} className={buildClassName(styles.list, 'no-scrollbar')}>
{renderCategoryItem('all')}
{!areLimitedStarGiftsDisallowed && renderCategoryItem('limited')}
{!areLimitedStarGiftsDisallowed && hasResale && renderCategoryItem('resale')}
{renderCategoryItem('stock')}
{starCategories?.map(renderCategoryItem)}
</div>

View File

@ -53,6 +53,7 @@
font-size: 0.75rem;
}
.starAmountIcon,
.giftResalePriceStar {
margin-inline-start: 0 !important;
}
@ -121,10 +122,6 @@
margin-bottom: 0;
}
.starAmountIcon {
margin-inline-start: 0 !important;
}
.modalHeader {
position: absolute;
z-index: 2;

View File

@ -58,6 +58,7 @@ type StateProps = {
collectibleEmojiStatuses?: ApiEmojiStatusType[];
tonExplorerUrl?: string;
currentUser?: ApiUser;
recipientPeer?: ApiPeer;
};
const STICKER_SIZE = 120;
@ -73,6 +74,7 @@ const GiftInfoModal = ({
collectibleEmojiStatuses,
tonExplorerUrl,
currentUser,
recipientPeer,
}: OwnProps & StateProps) => {
const {
closeGiftInfoModal,
@ -83,6 +85,7 @@ const GiftInfoModal = ({
openGiftUpgradeModal,
showNotification,
buyStarGift,
closeGiftModal,
} = getActions();
const [isConvertConfirmOpen, openConvertConfirm, closeConvertConfirm] = useFlag();
@ -161,7 +164,7 @@ const GiftInfoModal = ({
});
const handleBuyGift = useLastCallback(() => {
if (!savedGift || gift?.type !== 'starGiftUnique' || !gift.resellPriceInStars) return;
if (gift?.type !== 'starGiftUnique' || !gift.resellPriceInStars) return;
setIsConfirmModalOpen(true);
});
@ -170,9 +173,11 @@ const GiftInfoModal = ({
});
const handleConfirmBuyGift = useLastCallback(() => {
if (!savedGift || gift?.type !== 'starGiftUnique' || !gift.resellPriceInStars) return;
const peer = recipientPeer || currentUser;
if (!peer || gift?.type !== 'starGiftUnique' || !gift.resellPriceInStars) return;
closeConfirmModal();
buyStarGift({ slug: gift.slug, stars: gift.resellPriceInStars });
closeGiftModal();
buyStarGift({ peerId: peer.id, slug: gift.slug, stars: gift.resellPriceInStars });
});
const giftAttributes = useMemo(() => {
@ -726,18 +731,34 @@ const GiftInfoModal = ({
>
<GiftTransferPreview
peer={currentUser}
peer={recipientPeer || currentUser}
gift={uniqueGift}
/>
<p>
{lang('GiftBuyConfirmDescription', {
gift: lang('GiftUnique', { title: uniqueGift.title, number: uniqueGift.number }),
stars: formatStarsAsText(lang, resellPriceInStars),
}, {
withNodes: true,
withMarkdown: true,
})}
</p>
{!recipientPeer
&& (
<p>
{lang('GiftBuyConfirmDescription', {
gift: lang('GiftUnique', { title: uniqueGift.title, number: uniqueGift.number }),
stars: formatStarsAsText(lang, resellPriceInStars),
}, {
withNodes: true,
withMarkdown: true,
})}
</p>
)}
{recipientPeer
&& (
<p>
{lang('GiftBuyForPeerConfirmDescription', {
gift: lang('GiftUnique', { title: uniqueGift.title, number: uniqueGift.number }),
stars: formatStarsAsText(lang, resellPriceInStars),
peer: getPeerTitle(lang, recipientPeer),
}, {
withNodes: true,
withMarkdown: true,
})}
</p>
)}
</ConfirmDialog>
)}
{savedGift && (
@ -786,6 +807,8 @@ export default memo(withGlobal<OwnProps>(
const chat = targetPeer && isApiPeerChat(targetPeer) ? targetPeer : undefined;
const hasAdminRights = chat && getHasAdminRight(chat, 'postMessages');
const currentUser = selectUser(global, currentUserId!);
const recipientPeer = modal?.recipientId && currentUserId !== modal.recipientId
? selectPeer(global, modal.recipientId) : undefined;
const currentUserEmojiStatus = currentUser?.emojiStatus;
const collectibleEmojiStatuses = global.collectibleEmojiStatuses?.statuses;
@ -799,6 +822,7 @@ export default memo(withGlobal<OwnProps>(
currentUserEmojiStatus,
collectibleEmojiStatuses,
currentUser,
recipientPeer,
};
},
)(GiftInfoModal));

View File

@ -317,6 +317,12 @@
}
}
&.bluredStarsBadge {
color: var(--color-white);
background: rgba(0, 0, 0, 0.2) !important;
backdrop-filter: blur(50px);
}
&.smaller {
height: 2.75rem;
padding: 0.3125rem;

View File

@ -22,7 +22,7 @@ export type OwnProps = {
size?: 'default' | 'smaller' | 'tiny';
color?: (
'primary' | 'secondary' | 'gray' | 'danger' | 'translucent' | 'translucent-white' | 'translucent-black'
| 'translucent-bordered' | 'dark' | 'green' | 'adaptive' | 'stars'
| 'translucent-bordered' | 'dark' | 'green' | 'adaptive' | 'stars' | 'bluredStarsBadge'
);
backgroundImage?: string;
id?: string;

View File

@ -1,3 +1,4 @@
import type { MouseEvent as ReactMouseEvent } from 'react';
import type { ElementRef, FC } from '../../lib/teact/teact';
import React, {
memo, useEffect, useRef,
@ -43,7 +44,7 @@ type OwnProps = {
onReset?: NoneToVoidFunction;
onFocus?: NoneToVoidFunction;
onBlur?: NoneToVoidFunction;
onClick?: NoneToVoidFunction;
onClick?: (e: ReactMouseEvent<HTMLDivElement, MouseEvent>) => void;
onUpClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDownClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onSpinnerClick?: NoneToVoidFunction;

View File

@ -3,6 +3,7 @@ import type {
} from './api/types';
import type {
GiftProfileFilterOptions,
ResaleGiftsFilterOptions,
} from './types';
export const APP_CODE_NAME = 'A';
@ -104,6 +105,7 @@ export const GROUP_CALL_PARTICIPANTS_LIMIT = 100;
export const STORY_LIST_LIMIT = 100;
export const API_GENERAL_ID_LIMIT = 100;
export const STATISTICS_PUBLIC_FORWARDS_LIMIT = 50;
export const RESALE_GIFTS_LIMIT = 50;
export const STORY_VIEWS_MIN_SEARCH = 15;
export const STORY_MIN_REACTIONS_SORT = 10;
@ -465,3 +467,7 @@ export const DEFAULT_GIFT_PROFILE_FILTER_OPTIONS: GiftProfileFilterOptions = {
shouldIncludeDisplayed: true,
shouldIncludeHidden: true,
} as const;
export const DEFAULT_RESALE_GIFTS_FILTER_OPTIONS: ResaleGiftsFilterOptions = {
sortType: 'byDate',
};

View File

@ -146,13 +146,13 @@ addActionHandler('sendStarGift', (global, actions, payload): ActionReturnType =>
addActionHandler('buyStarGift', (global, actions, payload): ActionReturnType => {
const {
slug, stars, tabId = getCurrentTabId(),
slug, peerId, stars, tabId = getCurrentTabId(),
} = payload;
const inputInvoice: ApiInputInvoiceStarGiftResale = {
type: 'stargiftResale',
slug,
peerId: global.currentUserId!,
peerId,
};
payInputStarInvoice(global, inputInvoice, stars, tabId);

View File

@ -2,6 +2,7 @@ import type { ApiSavedStarGift, ApiStarGiftUnique } from '../../../api/types';
import type { StarGiftCategory } from '../../../types';
import type { ActionReturnType } from '../../types';
import { DEFAULT_RESALE_GIFTS_FILTER_OPTIONS, RESALE_GIFTS_LIMIT } from '../../../config';
import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { buildCollectionByKey } from '../../../util/iteratees';
import { callApi } from '../../../api/gramjs';
@ -11,8 +12,10 @@ import {
appendStarsSubscriptions,
appendStarsTransactions,
replacePeerSavedGifts,
updateChats,
updateStarsBalance,
updateStarsSubscriptionLoading,
updateUsers,
} from '../../reducers';
import { updateTabState } from '../../reducers/tabs';
import {
@ -102,6 +105,7 @@ addActionHandler('loadStarGifts', async (global): Promise<void> => {
all: [],
stock: [],
limited: [],
resale: [],
};
const allStarGiftIds = Object.keys(byId);
@ -114,9 +118,13 @@ addActionHandler('loadStarGifts', async (global): Promise<void> => {
gift.availabilityRemains || !gift.availabilityTotal ? gift.id : undefined
)).filter(Boolean);
const resaleStarGiftIds = allStarGifts.map((gift) => (gift.availabilityResale ? gift.id : undefined))
.filter(Boolean);
idsByCategoryName.all = allStarGiftIds;
idsByCategoryName.limited = limitedStarGiftIds;
idsByCategoryName.stock = stockedStarGiftIds;
idsByCategoryName.resale = resaleStarGiftIds;
allStarGifts.forEach((gift) => {
const starsCategory = gift.stars;
@ -137,6 +145,115 @@ addActionHandler('loadStarGifts', async (global): Promise<void> => {
setGlobal(global);
});
addActionHandler('updateResaleGiftsFilter', (global, actions, payload): ActionReturnType => {
const {
filter, tabId = getCurrentTabId(),
} = payload;
const tabState = selectTabState(global, tabId);
global = updateTabState(global, {
resaleGifts: {
...tabState.resaleGifts,
filter,
},
}, tabId);
if (tabState.resaleGifts.giftId) {
actions.loadResaleGifts({ giftId: tabState.resaleGifts.giftId, shouldRefresh: true, tabId });
}
setGlobal(global);
});
addActionHandler('loadResaleGifts', async (global, actions, payload): Promise<void> => {
const {
giftId, shouldRefresh, tabId = getCurrentTabId(),
} = payload;
let tabState = selectTabState(global, tabId);
if (tabState.resaleGifts.isLoading || (tabState.resaleGifts.isAllLoaded && !shouldRefresh)) return;
global = updateTabState(global, {
resaleGifts: {
...tabState.resaleGifts,
isLoading: true,
...(shouldRefresh && {
count: 0,
nextOffset: undefined,
isAllLoaded: false,
}),
},
}, tabId);
setGlobal(global);
global = getGlobal();
tabState = selectTabState(global, tabId);
const nextOffset = tabState.resaleGifts.nextOffset;
const attributesHash = tabState.resaleGifts.attributesHash;
const filter = tabState.resaleGifts.filter;
const result = await callApi('fetchResaleGifts', {
giftId,
offset: nextOffset,
limit: RESALE_GIFTS_LIMIT,
attributesHash,
filter,
});
if (!result) {
return;
};
const {
chats,
users,
} = result;
global = getGlobal();
tabState = selectTabState(global, tabId);
const currentGifts = tabState.resaleGifts.gifts;
const newGifts = !shouldRefresh ? currentGifts.concat(result.gifts) : result.gifts;
const currentUpdateIteration = tabState.resaleGifts.updateIteration;
const shouldUpdateIteration = tabState.resaleGifts.giftId !== giftId || shouldRefresh;
const updateIteration = shouldUpdateIteration ? currentUpdateIteration + 1 : currentUpdateIteration;
global = updateTabState(global, {
resaleGifts: {
...tabState.resaleGifts,
giftId,
count: result.count || tabState.resaleGifts.count,
gifts: newGifts,
attributes: result.attributes || tabState.resaleGifts.attributes,
counters: result.counters || tabState.resaleGifts.counters,
attributesHash: result.attributesHash,
nextOffset: result.nextOffset,
isLoading: false,
isAllLoaded: !result.nextOffset,
updateIteration,
},
}, tabId);
global = updateUsers(global, buildCollectionByKey(users, 'id'));
global = updateChats(global, buildCollectionByKey(chats, 'id'));
setGlobal(global);
});
addActionHandler('resetResaleGifts', (global, actions, payload): ActionReturnType => {
const {
tabId = getCurrentTabId(),
} = payload || {};
const tabState = selectTabState(global, tabId);
return updateTabState(global, {
resaleGifts: {
updateIteration: tabState.resaleGifts.updateIteration + 1,
filter: DEFAULT_RESALE_GIFTS_FILTER_OPTIONS,
count: 0,
gifts: [],
},
}, tabId);
});
addActionHandler('loadPeerSavedGifts', async (global, actions, payload): Promise<void> => {
const {
peerId, shouldRefresh, tabId = getCurrentTabId(),

View File

@ -256,11 +256,13 @@ addActionHandler('openGiftInfoModal', (global, actions, payload): ActionReturnTy
} = payload;
const peerId = 'peerId' in payload ? payload.peerId : undefined;
const recipientId = 'recipientId' in payload ? payload.recipientId : undefined;
return updateTabState(global, {
giftInfoModal: {
peerId,
gift,
recipientId,
},
}, tabId);
});

View File

@ -9,6 +9,7 @@ import {
DEFAULT_MESSAGE_TEXT_SIZE_PX,
DEFAULT_PATTERN_COLOR,
DEFAULT_PLAYBACK_RATE,
DEFAULT_RESALE_GIFTS_FILTER_OPTIONS,
DEFAULT_VOLUME,
IOS_DEFAULT_MESSAGE_TEXT_SIZE_PX,
MACOS_DEFAULT_MESSAGE_TEXT_SIZE_PX,
@ -389,6 +390,15 @@ export const INITIAL_TAB_STATE: TabState = {
giftsByPeerId: {},
},
resaleGifts: {
gifts: [],
count: 0,
updateIteration: 0,
filter: {
...DEFAULT_RESALE_GIFTS_FILTER_OPTIONS,
},
},
storyViewer: {
isMuted: true,
isRibbonShown: false,

View File

@ -80,6 +80,7 @@ import type {
PerformanceType,
Point,
ProfileTabType,
ResaleGiftsFilterOptions,
ScrollTargetPosition,
SendMessageParams,
SettingsScreens,
@ -2394,6 +2395,14 @@ export interface ActionPayloads {
loadPremiumGifts: undefined;
loadStarGifts: undefined;
updateResaleGiftsFilter: {
filter: ResaleGiftsFilterOptions;
} & WithTabId;
loadResaleGifts: {
giftId: string;
shouldRefresh?: boolean;
} & WithTabId;
resetResaleGifts: WithTabId | undefined;
loadDefaultTopicIcons: undefined;
loadPremiumStickers: undefined;
@ -2403,6 +2412,7 @@ export interface ActionPayloads {
closeGiftModal: WithTabId | undefined;
sendStarGift: StarGiftInfo & WithTabId;
buyStarGift: {
peerId: string;
slug: string;
stars: number;
} & WithTabId;
@ -2419,6 +2429,7 @@ export interface ActionPayloads {
} & WithTabId;
openGiftInfoModal: ({
peerId: string;
recipientId?: string;
gift: ApiSavedStarGift;
} | {
gift: ApiStarGift;

View File

@ -38,6 +38,7 @@ import type {
ApiSponsoredPeer,
ApiStarGift,
ApiStarGiftAttribute,
ApiStarGiftAttributeCounter,
ApiStarGiveawayOption,
ApiStarsSubscription,
ApiStarsTransaction,
@ -76,6 +77,7 @@ import type {
PaymentStep,
ProfileEditProgress,
ProfileTabType,
ResaleGiftsFilterOptions,
ScrollTargetPosition,
SettingsScreens,
SharedMediaType,
@ -217,6 +219,20 @@ export type TabState = {
filter: GiftProfileFilterOptions;
};
resaleGifts: {
giftId?: string;
gifts: ApiStarGift[];
count: number;
attributes?: ApiStarGiftAttribute[];
counters?: ApiStarGiftAttributeCounter[];
nextOffset?: string;
attributesHash?: string;
isLoading?: boolean;
isAllLoaded?: boolean;
filter: ResaleGiftsFilterOptions;
updateIteration: number;
};
leftColumn: {
contentKey: LeftColumnContent;
settingsScreen: SettingsScreens;
@ -761,6 +777,7 @@ export type TabState = {
giftInfoModal?: {
peerId?: string;
recipientId?: string;
gift: ApiSavedStarGift | ApiStarGift;
};

View File

@ -1765,6 +1765,7 @@ payments.getUniqueStarGift#a1974d72 slug:string = payments.UniqueStarGift;
payments.getSavedStarGifts#23830de9 flags:# exclude_unsaved:flags.0?true exclude_saved:flags.1?true exclude_unlimited:flags.2?true exclude_limited:flags.3?true exclude_unique:flags.4?true sort_by_value:flags.5?true peer:InputPeer offset:string limit:int = payments.SavedStarGifts;
payments.getStarGiftWithdrawalUrl#d06e93a8 stargift:InputSavedStarGift password:InputCheckPasswordSRP = payments.StarGiftWithdrawalUrl;
payments.toggleStarGiftsPinnedToTop#1513e7b0 peer:InputPeer stargift:Vector<InputSavedStarGift> = Bool;
payments.getResaleStarGifts#7a5fa236 flags:# sort_by_price:flags.1?true sort_by_num:flags.2?true attributes_hash:flags.0?long gift_id:long attributes:flags.3?Vector<StarGiftAttributeId> offset:string limit:int = payments.ResaleStarGifts;
payments.updateStarGiftPrice#3baea4e1 stargift:InputSavedStarGift resell_stars:long = 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;

View File

@ -319,6 +319,7 @@
"payments.getUniqueStarGift",
"payments.getStarGiftWithdrawalUrl",
"payments.toggleStarGiftsPinnedToTop",
"payments.getResaleStarGifts",
"payments.updateStarGiftPrice",
"langpack.getLangPack",
"langpack.getStrings",

View File

@ -109,193 +109,197 @@ $icons-map: (
"double-badge": "\f148",
"down": "\f149",
"download": "\f14a",
"eats": "\f14b",
"edit": "\f14c",
"email": "\f14d",
"enter": "\f14e",
"expand-modal": "\f14f",
"expand": "\f150",
"eye-crossed-outline": "\f151",
"eye-crossed": "\f152",
"eye-outline": "\f153",
"eye": "\f154",
"favorite-filled": "\f155",
"favorite": "\f156",
"file-badge": "\f157",
"flag": "\f158",
"folder-badge": "\f159",
"folder": "\f15a",
"fontsize": "\f15b",
"forums": "\f15c",
"forward": "\f15d",
"fragment": "\f15e",
"frozen-time": "\f15f",
"fullscreen": "\f160",
"gifs": "\f161",
"gift": "\f162",
"group-filled": "\f163",
"group": "\f164",
"grouped-disable": "\f165",
"grouped": "\f166",
"hand-stop": "\f167",
"hashtag": "\f168",
"heart-outline": "\f169",
"heart": "\f16a",
"help": "\f16b",
"info-filled": "\f16c",
"info": "\f16d",
"install": "\f16e",
"italic": "\f16f",
"key": "\f170",
"keyboard": "\f171",
"lamp": "\f172",
"language": "\f173",
"large-pause": "\f174",
"large-play": "\f175",
"link-badge": "\f176",
"link-broken": "\f177",
"link": "\f178",
"location": "\f179",
"lock-badge": "\f17a",
"lock": "\f17b",
"logout": "\f17c",
"loop": "\f17d",
"mention": "\f17e",
"message-failed": "\f17f",
"message-pending": "\f180",
"message-read": "\f181",
"message-succeeded": "\f182",
"message": "\f183",
"microphone-alt": "\f184",
"microphone": "\f185",
"monospace": "\f186",
"more-circle": "\f187",
"more": "\f188",
"move-caption-down": "\f189",
"move-caption-up": "\f18a",
"mute": "\f18b",
"muted": "\f18c",
"my-notes": "\f18d",
"new-chat-filled": "\f18e",
"next": "\f18f",
"nochannel": "\f190",
"noise-suppression": "\f191",
"non-contacts": "\f192",
"one-filled": "\f193",
"open-in-new-tab": "\f194",
"password-off": "\f195",
"pause": "\f196",
"permissions": "\f197",
"phone-discard-outline": "\f198",
"phone-discard": "\f199",
"phone": "\f19a",
"photo": "\f19b",
"pin-badge": "\f19c",
"pin-list": "\f19d",
"pin": "\f19e",
"pinned-chat": "\f19f",
"pinned-message": "\f1a0",
"pip": "\f1a1",
"play-story": "\f1a2",
"play": "\f1a3",
"poll": "\f1a4",
"previous": "\f1a5",
"privacy-policy": "\f1a6",
"proof-of-ownership": "\f1a7",
"quote-text": "\f1a8",
"quote": "\f1a9",
"radial-badge": "\f1aa",
"readchats": "\f1ab",
"recent": "\f1ac",
"reload": "\f1ad",
"remove-quote": "\f1ae",
"remove": "\f1af",
"reopen-topic": "\f1b0",
"replace": "\f1b1",
"replies": "\f1b2",
"reply-filled": "\f1b3",
"reply": "\f1b4",
"revenue-split": "\f1b5",
"revote": "\f1b6",
"save-story": "\f1b7",
"saved-messages": "\f1b8",
"schedule": "\f1b9",
"search": "\f1ba",
"select": "\f1bb",
"sell-outline": "\f1bc",
"sell": "\f1bd",
"send-outline": "\f1be",
"send": "\f1bf",
"settings-filled": "\f1c0",
"settings": "\f1c1",
"share-filled": "\f1c2",
"share-screen-outlined": "\f1c3",
"share-screen-stop": "\f1c4",
"share-screen": "\f1c5",
"show-message": "\f1c6",
"sidebar": "\f1c7",
"skip-next": "\f1c8",
"skip-previous": "\f1c9",
"smallscreen": "\f1ca",
"smile": "\f1cb",
"sort": "\f1cc",
"speaker-muted-story": "\f1cd",
"speaker-outline": "\f1ce",
"speaker-story": "\f1cf",
"speaker": "\f1d0",
"spoiler-disable": "\f1d1",
"spoiler": "\f1d2",
"sport": "\f1d3",
"star": "\f1d4",
"stars-lock": "\f1d5",
"stats": "\f1d6",
"stealth-future": "\f1d7",
"stealth-past": "\f1d8",
"stickers": "\f1d9",
"stop-raising-hand": "\f1da",
"stop": "\f1db",
"story-caption": "\f1dc",
"story-expired": "\f1dd",
"story-priority": "\f1de",
"story-reply": "\f1df",
"strikethrough": "\f1e0",
"tag-add": "\f1e1",
"tag-crossed": "\f1e2",
"tag-filter": "\f1e3",
"tag-name": "\f1e4",
"tag": "\f1e5",
"timer": "\f1e6",
"toncoin": "\f1e7",
"trade": "\f1e8",
"transcribe": "\f1e9",
"truck": "\f1ea",
"unarchive": "\f1eb",
"underlined": "\f1ec",
"unique-profile": "\f1ed",
"unlist-outline": "\f1ee",
"unlist": "\f1ef",
"unlock-badge": "\f1f0",
"unlock": "\f1f1",
"unmute": "\f1f2",
"unpin": "\f1f3",
"unread": "\f1f4",
"up": "\f1f5",
"user-filled": "\f1f6",
"user-online": "\f1f7",
"user": "\f1f8",
"video-outlined": "\f1f9",
"video-stop": "\f1fa",
"video": "\f1fb",
"view-once": "\f1fc",
"voice-chat": "\f1fd",
"volume-1": "\f1fe",
"volume-2": "\f1ff",
"volume-3": "\f200",
"web": "\f201",
"webapp": "\f202",
"word-wrap": "\f203",
"zoom-in": "\f204",
"zoom-out": "\f205",
"dropdown-arrows": "\f14b",
"eats": "\f14c",
"edit": "\f14d",
"email": "\f14e",
"enter": "\f14f",
"expand-modal": "\f150",
"expand": "\f151",
"eye-crossed-outline": "\f152",
"eye-crossed": "\f153",
"eye-outline": "\f154",
"eye": "\f155",
"favorite-filled": "\f156",
"favorite": "\f157",
"file-badge": "\f158",
"flag": "\f159",
"folder-badge": "\f15a",
"folder": "\f15b",
"fontsize": "\f15c",
"forums": "\f15d",
"forward": "\f15e",
"fragment": "\f15f",
"frozen-time": "\f160",
"fullscreen": "\f161",
"gifs": "\f162",
"gift": "\f163",
"group-filled": "\f164",
"group": "\f165",
"grouped-disable": "\f166",
"grouped": "\f167",
"hand-stop": "\f168",
"hashtag": "\f169",
"heart-outline": "\f16a",
"heart": "\f16b",
"help": "\f16c",
"info-filled": "\f16d",
"info": "\f16e",
"install": "\f16f",
"italic": "\f170",
"key": "\f171",
"keyboard": "\f172",
"lamp": "\f173",
"language": "\f174",
"large-pause": "\f175",
"large-play": "\f176",
"link-badge": "\f177",
"link-broken": "\f178",
"link": "\f179",
"location": "\f17a",
"lock-badge": "\f17b",
"lock": "\f17c",
"logout": "\f17d",
"loop": "\f17e",
"mention": "\f17f",
"message-failed": "\f180",
"message-pending": "\f181",
"message-read": "\f182",
"message-succeeded": "\f183",
"message": "\f184",
"microphone-alt": "\f185",
"microphone": "\f186",
"monospace": "\f187",
"more-circle": "\f188",
"more": "\f189",
"move-caption-down": "\f18a",
"move-caption-up": "\f18b",
"mute": "\f18c",
"muted": "\f18d",
"my-notes": "\f18e",
"new-chat-filled": "\f18f",
"next": "\f190",
"nochannel": "\f191",
"noise-suppression": "\f192",
"non-contacts": "\f193",
"one-filled": "\f194",
"open-in-new-tab": "\f195",
"password-off": "\f196",
"pause": "\f197",
"permissions": "\f198",
"phone-discard-outline": "\f199",
"phone-discard": "\f19a",
"phone": "\f19b",
"photo": "\f19c",
"pin-badge": "\f19d",
"pin-list": "\f19e",
"pin": "\f19f",
"pinned-chat": "\f1a0",
"pinned-message": "\f1a1",
"pip": "\f1a2",
"play-story": "\f1a3",
"play": "\f1a4",
"poll": "\f1a5",
"previous": "\f1a6",
"privacy-policy": "\f1a7",
"proof-of-ownership": "\f1a8",
"quote-text": "\f1a9",
"quote": "\f1aa",
"radial-badge": "\f1ab",
"readchats": "\f1ac",
"recent": "\f1ad",
"reload": "\f1ae",
"remove-quote": "\f1af",
"remove": "\f1b0",
"reopen-topic": "\f1b1",
"replace": "\f1b2",
"replies": "\f1b3",
"reply-filled": "\f1b4",
"reply": "\f1b5",
"revenue-split": "\f1b6",
"revote": "\f1b7",
"save-story": "\f1b8",
"saved-messages": "\f1b9",
"schedule": "\f1ba",
"search": "\f1bb",
"select": "\f1bc",
"sell-outline": "\f1bd",
"sell": "\f1be",
"send-outline": "\f1bf",
"send": "\f1c0",
"settings-filled": "\f1c1",
"settings": "\f1c2",
"share-filled": "\f1c3",
"share-screen-outlined": "\f1c4",
"share-screen-stop": "\f1c5",
"share-screen": "\f1c6",
"show-message": "\f1c7",
"sidebar": "\f1c8",
"skip-next": "\f1c9",
"skip-previous": "\f1ca",
"smallscreen": "\f1cb",
"smile": "\f1cc",
"sort-by-date": "\f1cd",
"sort-by-number": "\f1ce",
"sort-by-price": "\f1cf",
"sort": "\f1d0",
"speaker-muted-story": "\f1d1",
"speaker-outline": "\f1d2",
"speaker-story": "\f1d3",
"speaker": "\f1d4",
"spoiler-disable": "\f1d5",
"spoiler": "\f1d6",
"sport": "\f1d7",
"star": "\f1d8",
"stars-lock": "\f1d9",
"stats": "\f1da",
"stealth-future": "\f1db",
"stealth-past": "\f1dc",
"stickers": "\f1dd",
"stop-raising-hand": "\f1de",
"stop": "\f1df",
"story-caption": "\f1e0",
"story-expired": "\f1e1",
"story-priority": "\f1e2",
"story-reply": "\f1e3",
"strikethrough": "\f1e4",
"tag-add": "\f1e5",
"tag-crossed": "\f1e6",
"tag-filter": "\f1e7",
"tag-name": "\f1e8",
"tag": "\f1e9",
"timer": "\f1ea",
"toncoin": "\f1eb",
"trade": "\f1ec",
"transcribe": "\f1ed",
"truck": "\f1ee",
"unarchive": "\f1ef",
"underlined": "\f1f0",
"unique-profile": "\f1f1",
"unlist-outline": "\f1f2",
"unlist": "\f1f3",
"unlock-badge": "\f1f4",
"unlock": "\f1f5",
"unmute": "\f1f6",
"unpin": "\f1f7",
"unread": "\f1f8",
"up": "\f1f9",
"user-filled": "\f1fa",
"user-online": "\f1fb",
"user": "\f1fc",
"video-outlined": "\f1fd",
"video-stop": "\f1fe",
"video": "\f1ff",
"view-once": "\f200",
"voice-chat": "\f201",
"volume-1": "\f202",
"volume-2": "\f203",
"volume-3": "\f204",
"web": "\f205",
"webapp": "\f206",
"word-wrap": "\f207",
"zoom-in": "\f208",
"zoom-out": "\f209",
);
.icon-active-sessions::before {
@ -520,6 +524,9 @@ $icons-map: (
.icon-download::before {
content: map.get($icons-map, "download");
}
.icon-dropdown-arrows::before {
content: map.get($icons-map, "dropdown-arrows");
}
.icon-eats::before {
content: map.get($icons-map, "eats");
}
@ -907,6 +914,15 @@ $icons-map: (
.icon-smile::before {
content: map.get($icons-map, "smile");
}
.icon-sort-by-date::before {
content: map.get($icons-map, "sort-by-date");
}
.icon-sort-by-number::before {
content: map.get($icons-map, "sort-by-number");
}
.icon-sort-by-price::before {
content: map.get($icons-map, "sort-by-price");
}
.icon-sort::before {
content: map.get($icons-map, "sort");
}

Binary file not shown.

Binary file not shown.

View File

@ -73,6 +73,7 @@ export type FontIconName =
| 'double-badge'
| 'down'
| 'download'
| 'dropdown-arrows'
| 'eats'
| 'edit'
| 'email'
@ -202,6 +203,9 @@ export type FontIconName =
| 'skip-previous'
| 'smallscreen'
| 'smile'
| 'sort-by-date'
| 'sort-by-number'
| 'sort-by-price'
| 'sort'
| 'speaker-muted-story'
| 'speaker-outline'

View File

@ -26,6 +26,8 @@ import type {
ApiPhoto,
ApiReaction,
ApiReactionWithPaid,
ApiStarGiftAttributeIdBackdrop,
ApiStarGiftAttributeIdPattern,
ApiStarGiftRegular,
ApiStarsSubscription,
ApiStarsTransaction,
@ -37,6 +39,7 @@ import type {
ApiTopic,
ApiTypingStatus,
ApiVideo,
StarGiftAttributeIdModel,
} from '../api/types';
import type { DC_IDS } from '../config';
import type { SearchResultKey } from '../util/keys/searchResultKey';
@ -687,7 +690,7 @@ export interface Point {
export type WebPageMediaSize = 'large' | 'small';
export type StarGiftCategory = number | 'all' | 'limited' | 'stock';
export type StarGiftCategory = number | 'all' | 'limited' | 'stock' | 'resale';
export type CallSound = (
'join' | 'allowTalk' | 'leave' | 'connecting' | 'incoming' | 'end' | 'connect' | 'busy' | 'ringing'
@ -705,6 +708,13 @@ export type GiftProfileFilterOptions = {
shouldIncludeDisplayed: boolean;
shouldIncludeHidden: boolean;
};
export type ResaleGiftsSortType = 'byDate' | 'byPrice' | 'byNumber';
export type ResaleGiftsFilterOptions = {
sortType: ResaleGiftsSortType;
modelAttributes?: StarGiftAttributeIdModel[];
patternAttributes?: ApiStarGiftAttributeIdPattern[];
backdropAttributes?: ApiStarGiftAttributeIdBackdrop[];
};
export type SendMessageParams = {
chat?: ApiChat;

View File

@ -1514,6 +1514,17 @@ export interface LangPair {
'StarGiftSaleTransaction': undefined;
'StarGiftPurchaseTransaction': undefined;
'ContextMenuItemMention': undefined;
'GiftRibbonResale': undefined;
'GiftCategoryResale': undefined;
'GiftSortByPrice': undefined;
'GiftSortByNumber': undefined;
'ContextMenuItemSelectAll': undefined;
'ButtonSort': undefined;
'ValueGiftSortByDate': undefined;
'ValueGiftSortByPrice': undefined;
'ValueGiftSortByNumber': undefined;
'ResellGiftsNoFound': undefined;
'ResellGiftsClearFilters': undefined;
}
export interface LangPairWithVariables<V = LangVariable> {
@ -2464,6 +2475,11 @@ export interface LangPairWithVariables<V = LangVariable> {
'gift': V;
'stars': V;
};
'GiftBuyForPeerConfirmDescription': {
'gift': V;
'stars': V;
'peer': V;
};
'ComposerTitleForwardFrom': {
'users': V;
};
@ -2758,6 +2774,18 @@ export interface LangPairPluralWithVariables<V = LangVariable> {
'PaidMessageTransaction': {
'count': V;
};
'HeaderDescriptionResaleGifts': {
'count': V;
};
'GiftAttributeModelPlural': {
'count': V;
};
'GiftAttributeBackdropPlural': {
'count': V;
};
'GiftAttributeSymbolPlural': {
'count': V;
};
}
export type RegularLangKey = keyof LangPair;
export type RegularLangKeyWithVariables = keyof LangPairWithVariables;