diff --git a/src/api/gramjs/apiBuilders/gifts.ts b/src/api/gramjs/apiBuilders/gifts.ts index 5d5d6c30b..57274a8af 100644 --- a/src/api/gramjs/apiBuilders/gifts.ts +++ b/src/api/gramjs/apiBuilders/gifts.ts @@ -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}`); + } + }); +} diff --git a/src/api/gramjs/methods/stars.ts b/src/api/gramjs/methods/stars.ts index 4093f590a..24e079ddc 100644 --- a/src/api/gramjs/methods/stars.ts +++ b/src/api/gramjs/methods/stars.ts @@ -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[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 = '', diff --git a/src/api/types/stars.ts b/src/api/types/stars.ts index 58c0e5c30..3c8f95962 100644 --- a/src/api/types/stars.ts +++ b/src/api/types/stars.ts @@ -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 { + 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; diff --git a/src/assets/font-icons/dropdown-arrows.svg b/src/assets/font-icons/dropdown-arrows.svg new file mode 100644 index 000000000..f666d90cd --- /dev/null +++ b/src/assets/font-icons/dropdown-arrows.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/font-icons/sort-by-date.svg b/src/assets/font-icons/sort-by-date.svg new file mode 100644 index 000000000..efa84e2cc --- /dev/null +++ b/src/assets/font-icons/sort-by-date.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/font-icons/sort-by-number.svg b/src/assets/font-icons/sort-by-number.svg new file mode 100644 index 000000000..c578de76a --- /dev/null +++ b/src/assets/font-icons/sort-by-number.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/font-icons/sort-by-price.svg b/src/assets/font-icons/sort-by-price.svg new file mode 100644 index 000000000..3c4358a81 --- /dev/null +++ b/src/assets/font-icons/sort-by-price.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 86e143b00..6581ca48e 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -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"; \ No newline at end of file diff --git a/src/components/common/gift/GiftRibbon.tsx b/src/components/common/gift/GiftRibbon.tsx index a5143ffc5..c57566e51 100644 --- a/src/components/common/gift/GiftRibbon.tsx +++ b/src/components/common/gift/GiftRibbon.tsx @@ -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; diff --git a/src/components/modals/gift/GiftComposer.tsx b/src/components/modals/gift/GiftComposer.tsx index 3d313775e..4e06c3caf 100644 --- a/src/components/modals/gift/GiftComposer.tsx +++ b/src/components/modals/gift/GiftComposer.tsx @@ -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) => { 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 (
@@ -359,6 +365,8 @@ function GiftComposer({ customBackground && isBackgroundBlurred && styles.blurred, ); + if ((!isStarGift && !isPremiumGift) || !localMessage) return; + return (
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(); + const stickerRef = useRef(); 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 ( + + ); + }, [gift]); + + const giftNumber = isGiftUnique ? gift.number : 0; + + const giftRibbon = useMemo(() => { + if (isGiftUnique) { + const { backdrop } = getGiftAttributes(gift) || {}; + if (!backdrop) { + return undefined; + } + return ( + + ); + } + if (isResale) { + return ; + } + if (isSoldOut) { + return ; + } + if (isLimited) { + return ; + } + return undefined; + }, [isGiftUnique, isResale, gift, isSoldOut, isLimited, lang, giftNumber]); + useOnIntersect(ref, observeIntersection, (entry) => { - if (entry.isIntersecting) play(); + const visible = entry.isIntersecting; + setIsVisible(visible); }); return (
- + {sticker && ( + + )} + +
+ - {isLimited && !isSoldOut && } - {isSoldOut && } + {giftRibbon}
); } diff --git a/src/components/modals/gift/GiftModal.module.scss b/src/components/modals/gift/GiftModal.module.scss index def4ff5be..e8d6ffbdc 100644 --- a/src/components/modals/gift/GiftModal.module.scss +++ b/src/components/modals/gift/GiftModal.module.scss @@ -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; + } +} diff --git a/src/components/modals/gift/GiftModal.tsx b/src/components/modals/gift/GiftModal.tsx index 0bf4f4295..1bce32869 100644 --- a/src/components/modals/gift/GiftModal.tsx +++ b/src/components/modals/gift/GiftModal.tsx @@ -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 = ({ peer, isSelf, disallowedGifts, + resaleGiftsCount, + areResaleGiftsLoading, }) => { const { - closeGiftModal, + closeGiftModal, openGiftInfoModal, resetResaleGifts, loadResaleGifts, } = getActions(); const dialogRef = useRef(); const transitionRef = useRef(); @@ -89,6 +97,7 @@ const GiftModal: FC = ({ const chat = peer && isApiPeerChat(peer) ? peer : undefined; const [selectedGift, setSelectedGift] = useState(); + const [selectedResellGift, setSelectedResellGift] = useState(); const [shouldShowMainScreenHeader, setShouldShowMainScreenHeader] = useState(false); const [isMainScreenHeaderForStarGifts, setIsMainScreenHeaderForStarGifts] = useState(false); const [isGiftScreenHeaderForStarGifts, setIsGiftScreenHeaderForStarGifts] = useState(false); @@ -143,14 +152,22 @@ const GiftModal: FC = ({ 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 = ({ ); } - 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 = ({ return (
- {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 = [ - ); + />, + ]; + + if (shouldDuplicateAsResale) { + elements.push( + , + ); + } + + return elements; })}
); @@ -290,12 +336,24 @@ const GiftModal: FC = ({ 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 = ({ ); } - 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 ( +
+

+ {selectedResellGift.title} +

+ {isFirstLoading + && ( +
+ {lang('Loading')} +
+ )} + {!isFirstLoading && resaleGiftsCount !== undefined + && ( +
+ {lang('HeaderDescriptionResaleGifts', { + count: resaleGiftsCount, + }, { withNodes: true, withMarkdown: true, pluralValue: resaleGiftsCount })} +
+ )} + +
+ ); + } + return ( +

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

+ ); + } + return ( = ({
-
+
-

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

+ {renderHeader()}
- {!isGiftScreen && renderMainScreen()} + {!isGiftScreen && !isResaleScreen && renderMainScreen()} + {isResaleScreen && selectedResellGift + && ( + + )} {isGiftScreen && renderingModal?.forPeerId && ( ((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((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; } diff --git a/src/components/modals/gift/GiftModalResaleScreen.tsx b/src/components/modals/gift/GiftModalResaleScreen.tsx new file mode 100644 index 000000000..93b7893c2 --- /dev/null +++ b/src/components/modals/gift/GiftModalResaleScreen.tsx @@ -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 = ({ + resellGifts, + gift, + filter, + areGiftsAllLoaded, + areGiftsLoading, + updateIteration, + onGiftClick, +}) => { + const { + loadResaleGifts, + updateResaleGiftsFilter, + } = getActions(); + const scrollerRef = useRef(); + + 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 ( +
+ +
+ {lang('ResellGiftsNoFound')} +
+ {hasFilter && ( + + {lang('ResellGiftsClearFilters')} + + )} +
+ ); + } + + return ( +
+ + {isGiftsEmpty && areGiftsAllLoaded && renderNothingFoundGiftsWithFilter()} + + {resellGifts?.map((gift) => ( + + ))} + + +
+ ); +}; + +export default memo(withGlobal((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)); diff --git a/src/components/modals/gift/GiftResaleFilters.module.scss b/src/components/modals/gift/GiftResaleFilters.module.scss new file mode 100644 index 000000000..12233393e --- /dev/null +++ b/src/components/modals/gift/GiftResaleFilters.module.scss @@ -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); + } +} diff --git a/src/components/modals/gift/GiftResaleFilters.tsx b/src/components/modals/gift/GiftResaleFilters.tsx new file mode 100644 index 000000000..f3986ca6e --- /dev/null +++ b/src/components/modals/gift/GiftResaleFilters.tsx @@ -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; +}; +type StateProps = { + filter: ResaleGiftsFilterOptions; + attributes?: ApiStarGiftAttribute[]; + counters?: ApiStarGiftAttributeCounter[]; +}; + +const GiftResaleFilters: FC = ({ + 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 = (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(); + const { + isContextMenuOpen: isSortContextMenuOpen, + contextMenuAnchor: sortContextMenuAnchor, + handleContextMenu: handleSortContextMenu, + handleContextMenuClose: handleSortContextMenuClose, + handleContextMenuHide: handleSortContextMenuHide, + } = useContextMenuHandlers(dialogRef); + const getSortMenuElement = useLastCallback(() => sortMenuRef.current!); + + // Model Menu + const modelMenuRef = useRef(); + const { + isContextMenuOpen: isModelContextMenuOpen, + contextMenuAnchor: modelContextMenuAnchor, + handleContextMenu: handleModelContextMenu, + handleContextMenuClose: handleModelContextMenuClose, + handleContextMenuHide: handleModelContextMenuHide, + } = useContextMenuHandlers(dialogRef); + const getModelMenuElement = useLastCallback( + () => modelMenuRef.current!, + ); + + // Backdrop Menu + const backdropMenuRef = useRef(); + const { + isContextMenuOpen: isBackdropContextMenuOpen, + contextMenuAnchor: backdropContextMenuAnchor, + handleContextMenu: handleBackdropContextMenu, + handleContextMenuClose: handleBackdropContextMenuClose, + handleContextMenuHide: handleBackdropContextMenuHide, + } = useContextMenuHandlers(dialogRef); + const getBackdropMenuElement = useLastCallback(() => backdropMenuRef.current!); + + // Pattern Menu + const patternMenuRef = useRef(); + const { + isContextMenuOpen: isPatternContextMenuOpen, + contextMenuAnchor: patternContextMenuAnchor, + handleContextMenu: handlePatternContextMenu, + handleContextMenuClose: handlePatternContextMenuClose, + handleContextMenuHide: handlePatternContextMenuHide, + } = useContextMenuHandlers(dialogRef); + const getPatternMenuElement = useLastCallback(() => patternMenuRef.current!); + + const SortMenuButton: FC<{ onTrigger: (e: ReactMouseEvent) => void; isOpen?: boolean }> + = useMemo(() => { + const sortType = filter.sortType; + return ({ onTrigger, isOpen: isMenuOpen }) => ( +
+ {sortType === 'byDate' && lang('ValueGiftSortByDate')} + {sortType === 'byNumber' && lang('ValueGiftSortByNumber')} + {sortType === 'byPrice' && lang('ValueGiftSortByPrice')} + +
+ ); + }, [lang, filter]); + + const ModelMenuButton: + FC<{ onTrigger: (e: ReactMouseEvent) => void; isOpen?: boolean }> + = useMemo(() => { + const attributesCount = filter?.modelAttributes?.length || 0; + return ({ onTrigger, isOpen: isMenuOpen }) => ( +
+ {attributesCount === 0 && lang('GiftAttributeModel')} + {attributesCount > 0 + && lang('GiftAttributeModelPlural', { count: attributesCount }, { pluralValue: attributesCount })} + +
+ ); + }, [lang, filter]); + const BackdropMenuButton: + FC<{ onTrigger: (e: ReactMouseEvent) => void; isOpen?: boolean }> + = useMemo(() => { + const attributesCount = filter?.backdropAttributes?.length || 0; + return ({ onTrigger, isOpen: isMenuOpen }) => ( +
+ {attributesCount === 0 && lang('GiftAttributeBackdrop')} + {attributesCount > 0 + && lang('GiftAttributeBackdropPlural', { count: attributesCount }, { pluralValue: attributesCount })} + +
+ ); + }, [lang, filter]); + const PatternMenuButton: FC<{ onTrigger: (e: ReactMouseEvent) => void; isOpen?: boolean }> + = useMemo(() => { + const attributesCount = filter?.patternAttributes?.length || 0; + return ({ onTrigger, isOpen: isMenuOpen }) => ( +
+ {attributesCount === 0 && lang('GiftAttributeSymbol')} + {attributesCount > 0 + && lang('GiftAttributeSymbolPlural', { count: attributesCount }, { pluralValue: attributesCount })} + +
+ ); + }, [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 => + 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 => + 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 => + 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 ( + <> + { handleSortMenuItemClick('byPrice'); }}> +
+ {lang('GiftSortByPrice')} +
+ +
+ { handleSortMenuItemClick('byDate'); }}> +
+ {lang('GiftSortByDate')} +
+ + +
+ { handleSortMenuItemClick('byNumber'); }}> +
+ {lang('GiftSortByNumber')} +
+ +
+ + ); + } + + function renderSortMenu() { + return ( + + {renderSortMenuItems()} + + ); + } + + 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(); + const { observe } = useIntersectionObserver({ + rootRef: modelMenuItemsContainerRef, + isDisabled: !modelContextMenuAnchor, + }); + + function renderModelMenuItems() { + const models = filteredAndSearchedAttributes.model; + const selectedAttributes = filter.modelAttributes ?? []; + const isSelectedAll = selectedAttributes.length === 0; + return ( +
+ + + {lang('ContextMenuItemSelectAll')} + + {models.map((model) => { + const isSelected = isSelectedAll + || selectedAttributes.some((attr) => attr.documentId === model.sticker.id); + return ( + { + handleModelMenuItemClick(model); + }} + > + +
+ {model.name} +
+ +
+ ); + })} +
+ ); + } + + function renderModelMenu() { + return ( + + {renderModelMenuItems()} + + ); + } + + function renderBackdropMenuItems() { + const backdrops = filteredAndSearchedAttributes.backdrop; + const selectedAttributes = filter.backdropAttributes ?? []; + const isSelectedAll = selectedAttributes.length === 0; + + return ( +
+ + + {lang('ContextMenuItemSelectAll')} + + {backdrops.map((backdrop) => { + const isSelected = isSelectedAll + || selectedAttributes.some((attr) => attr.backdropId === backdrop.backdropId); + + return ( + { + handleBackdropMenuItemClick(backdrop); + }} + > + +
+ {backdrop.name} +
+ +
+ ); + })} +
+ ); + } + + function renderBackdropMenu() { + return ( + + {renderBackdropMenuItems()} + + ); + } + + function renderPatternMenuItems() { + const patterns = filteredAndSearchedAttributes.pattern; + const selectedAttributes = filter.patternAttributes ?? []; + const isSelectedAll = selectedAttributes.length === 0; + + return ( +
+ + + {lang('ContextMenuItemSelectAll')} + + {patterns.map((pattern) => { + const isSelected = isSelectedAll + || selectedAttributes.some((attr) => attr.documentId === pattern.sticker.id); + + return ( + { + handlePatternMenuItemClick(pattern); + }} + > + + + +
+ {pattern.name} +
+ +
+ ); + })} +
+ ); + } + + function renderPatternMenu() { + return ( + + {renderPatternMenuItems()} + + ); + } + + return ( +
+ {Boolean(sortContextMenuAnchor) && renderSortMenu()} + {Boolean(modelContextMenuAnchor) && renderModelMenu()} + {Boolean(backdropContextMenuAnchor) && renderBackdropMenu()} + {Boolean(patternContextMenuAnchor) && renderPatternMenu()} +
+ + + + +
+
+ ); +}; + +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)); diff --git a/src/components/modals/gift/ResaleGiftMenuAttributeSticker.module.scss b/src/components/modals/gift/ResaleGiftMenuAttributeSticker.module.scss new file mode 100644 index 000000000..af02899d6 --- /dev/null +++ b/src/components/modals/gift/ResaleGiftMenuAttributeSticker.module.scss @@ -0,0 +1,7 @@ +.root { + position: relative; +} + +.thumb { + position: absolute; +} diff --git a/src/components/modals/gift/ResaleGiftMenuAttributeSticker.tsx b/src/components/modals/gift/ResaleGiftMenuAttributeSticker.tsx new file mode 100644 index 000000000..aa89bc455 --- /dev/null +++ b/src/components/modals/gift/ResaleGiftMenuAttributeSticker.tsx @@ -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 = ({ + className, + type, + sticker, + observeIntersectionForLoading, + observeIntersectionForPlaying, + theme, +}) => { + const stickerRef = useRef(); + + const customColor = useDynamicColorListener(stickerRef, undefined, type !== 'pattern'); + + return ( +
+ +
+ ); +}; + +export default memo(withGlobal((global): StateProps => { + return { + theme: selectTheme(global), + }; +})(ResaleGiftMenuAttributeSticker)); diff --git a/src/components/modals/gift/StarGiftCategoryList.tsx b/src/components/modals/gift/StarGiftCategoryList.tsx index 2a1132100..d1e63386a 100644 --- a/src/components/modals/gift/StarGiftCategoryList.tsx +++ b/src/components/modals/gift/StarGiftCategoryList.tsx @@ -37,6 +37,8 @@ const StarGiftCategoryList = ({ .sort((a, b) => a - b), [idsByCategory]); + const hasResale = idsByCategory && idsByCategory['resale'].length > 0; + const [selectedCategory, setSelectedCategory] = useState('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 = ({
{renderCategoryItem('all')} {!areLimitedStarGiftsDisallowed && renderCategoryItem('limited')} + {!areLimitedStarGiftsDisallowed && hasResale && renderCategoryItem('resale')} {renderCategoryItem('stock')} {starCategories?.map(renderCategoryItem)}
diff --git a/src/components/modals/gift/info/GiftInfoModal.module.scss b/src/components/modals/gift/info/GiftInfoModal.module.scss index dd64b03c6..3e3e65947 100644 --- a/src/components/modals/gift/info/GiftInfoModal.module.scss +++ b/src/components/modals/gift/info/GiftInfoModal.module.scss @@ -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; diff --git a/src/components/modals/gift/info/GiftInfoModal.tsx b/src/components/modals/gift/info/GiftInfoModal.tsx index 31268ca64..1b5e02f51 100644 --- a/src/components/modals/gift/info/GiftInfoModal.tsx +++ b/src/components/modals/gift/info/GiftInfoModal.tsx @@ -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 = ({ > -

- {lang('GiftBuyConfirmDescription', { - gift: lang('GiftUnique', { title: uniqueGift.title, number: uniqueGift.number }), - stars: formatStarsAsText(lang, resellPriceInStars), - }, { - withNodes: true, - withMarkdown: true, - })} -

+ {!recipientPeer + && ( +

+ {lang('GiftBuyConfirmDescription', { + gift: lang('GiftUnique', { title: uniqueGift.title, number: uniqueGift.number }), + stars: formatStarsAsText(lang, resellPriceInStars), + }, { + withNodes: true, + withMarkdown: true, + })} +

+ )} + {recipientPeer + && ( +

+ {lang('GiftBuyForPeerConfirmDescription', { + gift: lang('GiftUnique', { title: uniqueGift.title, number: uniqueGift.number }), + stars: formatStarsAsText(lang, resellPriceInStars), + peer: getPeerTitle(lang, recipientPeer), + }, { + withNodes: true, + withMarkdown: true, + })} +

+ )} )} {savedGift && ( @@ -786,6 +807,8 @@ export default memo(withGlobal( 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( currentUserEmojiStatus, collectibleEmojiStatuses, currentUser, + recipientPeer, }; }, )(GiftInfoModal)); diff --git a/src/components/ui/Button.scss b/src/components/ui/Button.scss index b31f993a6..e749a46ec 100644 --- a/src/components/ui/Button.scss +++ b/src/components/ui/Button.scss @@ -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; diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index 3cf375f20..a2e860324 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -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; diff --git a/src/components/ui/SearchInput.tsx b/src/components/ui/SearchInput.tsx index a43c49e1d..d984dc4ab 100644 --- a/src/components/ui/SearchInput.tsx +++ b/src/components/ui/SearchInput.tsx @@ -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) => void; onUpClick?: (event: React.MouseEvent) => void; onDownClick?: (event: React.MouseEvent) => void; onSpinnerClick?: NoneToVoidFunction; diff --git a/src/config.ts b/src/config.ts index bf0c67915..fa78bc607 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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', +}; diff --git a/src/global/actions/api/payments.ts b/src/global/actions/api/payments.ts index cf703da0a..f0745100c 100644 --- a/src/global/actions/api/payments.ts +++ b/src/global/actions/api/payments.ts @@ -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); diff --git a/src/global/actions/api/stars.ts b/src/global/actions/api/stars.ts index 8adec6458..021b381d6 100644 --- a/src/global/actions/api/stars.ts +++ b/src/global/actions/api/stars.ts @@ -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 => { all: [], stock: [], limited: [], + resale: [], }; const allStarGiftIds = Object.keys(byId); @@ -114,9 +118,13 @@ addActionHandler('loadStarGifts', async (global): Promise => { 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 => { 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 => { + 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 => { const { peerId, shouldRefresh, tabId = getCurrentTabId(), diff --git a/src/global/actions/ui/stars.ts b/src/global/actions/ui/stars.ts index b202f8a34..40477c06c 100644 --- a/src/global/actions/ui/stars.ts +++ b/src/global/actions/ui/stars.ts @@ -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); }); diff --git a/src/global/initialState.ts b/src/global/initialState.ts index 757321a63..19e5045d1 100644 --- a/src/global/initialState.ts +++ b/src/global/initialState.ts @@ -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, diff --git a/src/global/types/actions.ts b/src/global/types/actions.ts index c392cd603..012ad25c2 100644 --- a/src/global/types/actions.ts +++ b/src/global/types/actions.ts @@ -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; diff --git a/src/global/types/tabState.ts b/src/global/types/tabState.ts index 1258a2124..2b6908d1b 100644 --- a/src/global/types/tabState.ts +++ b/src/global/types/tabState.ts @@ -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; }; diff --git a/src/lib/gramjs/tl/apiTl.ts b/src/lib/gramjs/tl/apiTl.ts index 2f1ef58ff..6ff916c1b 100644 --- a/src/lib/gramjs/tl/apiTl.ts +++ b/src/lib/gramjs/tl/apiTl.ts @@ -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 = 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 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; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index 5d9f58f71..ec6145828 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -319,6 +319,7 @@ "payments.getUniqueStarGift", "payments.getStarGiftWithdrawalUrl", "payments.toggleStarGiftsPinnedToTop", + "payments.getResaleStarGifts", "payments.updateStarGiftPrice", "langpack.getLangPack", "langpack.getStrings", diff --git a/src/styles/icons.scss b/src/styles/icons.scss index 1c6489d46..69a0c33c5 100644 --- a/src/styles/icons.scss +++ b/src/styles/icons.scss @@ -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"); } diff --git a/src/styles/icons.woff b/src/styles/icons.woff index c0bd67fb6..8b8701bb9 100644 Binary files a/src/styles/icons.woff and b/src/styles/icons.woff differ diff --git a/src/styles/icons.woff2 b/src/styles/icons.woff2 index fdab72768..685c38f3a 100644 Binary files a/src/styles/icons.woff2 and b/src/styles/icons.woff2 differ diff --git a/src/types/icons/font.ts b/src/types/icons/font.ts index 4cb9156ce..3f4741db2 100644 --- a/src/types/icons/font.ts +++ b/src/types/icons/font.ts @@ -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' diff --git a/src/types/index.ts b/src/types/index.ts index b42ed2e54..76960eeac 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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; diff --git a/src/types/language.d.ts b/src/types/language.d.ts index 25e1ccdf9..0fa38024a 100644 --- a/src/types/language.d.ts +++ b/src/types/language.d.ts @@ -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 { @@ -2464,6 +2475,11 @@ export interface LangPairWithVariables { 'gift': V; 'stars': V; }; + 'GiftBuyForPeerConfirmDescription': { + 'gift': V; + 'stars': V; + 'peer': V; + }; 'ComposerTitleForwardFrom': { 'users': V; }; @@ -2758,6 +2774,18 @@ export interface LangPairPluralWithVariables { '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;