Gift Modal: Support My gifts category (#6316)

This commit is contained in:
Alexander Zinchuk 2025-10-11 19:07:20 +02:00
parent 104fbb1d54
commit aacd4a8429
24 changed files with 419 additions and 175 deletions

View File

@ -1452,6 +1452,7 @@
"StarsGiftHeader" = "Send a Gift";
"StarsGiftHeaderSelf" = "Buy a Gift";
"StarGiftDescription" = "Give {user} gifts that can be kept on the profile or converted to Stars.";
"StarGiftDescriptionCollectibles" = "Collectible gifts are unique digital items you exchange or sell.";
"StarGiftDescriptionSelf" = "Buy yourself a gift to add to your profile or reserve for later.\n\nLimited-edition gifts upgraded to collectibles can be gifted to others.";
"StarGiftDescriptionChannel" = "Select gift to show appreciation to **{peer}**.";
"GiftLimited" = "limited";
@ -1583,8 +1584,6 @@
"StarsAmountText_one" = "{amount} Star";
"StarsAmountText_other" = "{amount} Stars";
"AllGiftsCategory" = "All gifts";
"LimitedGiftsCategory" = "Limited";
"StockGiftsCategory" = "In Stock";
"PremiumGiftDescription" = "Premium";
"SendPaidReaction" = "Send ⭐️{amount}";
"StarsPay" = "Confirm and Pay {amount}";
@ -2070,7 +2069,8 @@
"ComposerTitleForwardFrom" = "From: **{users}**";
"ContextMenuItemMention" = "Mention";
"GiftRibbonResale" = "resale";
"GiftCategoryResale" = "Resale";
"GiftCategoryCollectibles" = "Collectibles";
"GiftCategoryMyGifts" = "My Gifts";
"HeaderDescriptionResaleGifts_one" = "{count} for resale";
"HeaderDescriptionResaleGifts_other" = "{count} for resale";
"GiftSortByPrice" = "Sort by Price";
@ -2285,6 +2285,7 @@
"GiftValueForSaleOnFragment" = "for sale on Fragment";
"GiftValueForSaleOnTelegram" = "for sale on Telegram";
"EmbeddedMessageNoCaption" = "Caption removed";
"ConfirmBuyGiftForTonDescription" = "The seller only accepts TON as payment.";
"TitleGiftLocked" = "Gift Locked";
"GiftLockedMessage" = "This gift is currently only available to earlier Telegram users. It will unlock for your account in about **{relativeDate}**.";
"QuickPreview" = "Quick Preview";

View File

@ -15,5 +15,6 @@ export { default as GiftUpgradeModal } from '../components/modals/gift/upgrade/G
export { default as GiftStatusInfoModal } from '../components/modals/gift/status/GiftStatusInfoModal';
export { default as GiftWithdrawModal } from '../components/modals/gift/withdraw/GiftWithdrawModal';
export { default as GiftTransferModal } from '../components/modals/gift/transfer/GiftTransferModal';
export { default as GiftTransferConfirmModal } from '../components/modals/gift/transfer/GiftTransferConfirmModal';
export { default as ChatRefundModal } from '../components/modals/stars/chatRefund/ChatRefundModal';
export { default as PriceConfirmModal } from '../components/modals/priceConfirm/PriceConfirmModal';

View File

@ -217,6 +217,7 @@ const Main = ({
loadPremiumGifts,
loadTonGifts,
loadStarGifts,
loadMyCollectibleGifts,
loadDefaultTopicIcons,
loadAddedStickers,
loadFavoriteStickers,
@ -332,6 +333,7 @@ const Main = ({
loadPremiumGifts();
loadTonGifts();
loadStarGifts();
loadMyCollectibleGifts();
loadAvailableEffects();
loadBirthdayNumbersStickers();
loadRestrictedEmojiStickers();

View File

@ -25,6 +25,7 @@ import GiftLockedModal from './gift/locked/GiftLockedModal.async';
import GiftRecipientPicker from './gift/recipient/GiftRecipientPicker.async';
import GiftResalePriceComposerModal from './gift/resale/GiftResalePriceComposerModal.async';
import GiftStatusInfoModal from './gift/status/GiftStatusInfoModal.async';
import GiftTransferConfirmModal from './gift/transfer/GiftTransferConfirmModal.async';
import GiftTransferModal from './gift/transfer/GiftTransferModal.async';
import GiftUpgradeModal from './gift/upgrade/GiftUpgradeModal.async';
import GiftInfoValueModal from './gift/value/GiftInfoValueModal.async';
@ -95,6 +96,7 @@ type ModalKey = keyof Pick<TabState,
'sharePreparedMessageModal' |
'giftStatusInfoModal' |
'giftTransferModal' |
'giftTransferConfirmModal' |
'chatRefundModal' |
'priceConfirmModal' |
'isFrozenAccountModalOpen' |
@ -156,6 +158,7 @@ const MODALS: ModalRegistry = {
preparedMessageModal: PreparedMessageModal,
sharePreparedMessageModal: SharePreparedMessageModal,
giftTransferModal: GiftTransferModal,
giftTransferConfirmModal: GiftTransferConfirmModal,
chatRefundModal: ChatRefundModal,
priceConfirmModal: PriceConfirmModal,
isFrozenAccountModalOpen: FrozenAccountModal,

View File

@ -11,6 +11,7 @@
justify-content: center;
min-width: 0;
margin-inline: 0.125rem;
padding: 0.625rem;
padding-top: 0;
border-radius: 0.625rem;
@ -69,6 +70,10 @@
line-height: 1;
}
.transferBadge {
font-size: 0.8125rem !important;
}
.star {
margin-inline-start: 0 !important;
margin-inline-end: 0.125rem;

View File

@ -29,6 +29,7 @@ export type OwnProps = {
observeIntersection?: ObserveFn;
onClick: (gift: ApiStarGift, target: 'original' | 'resell') => void;
isResale?: boolean;
withTransferBadge?: boolean;
};
type StateProps = {
@ -38,7 +39,7 @@ type StateProps = {
const GIFT_STICKER_SIZE = 90;
function GiftItemStar({
gift, observeIntersection, onClick, isResale, isCurrentUserPremium,
gift, observeIntersection, onClick, isResale, isCurrentUserPremium, withTransferBadge,
}: OwnProps & StateProps) {
const { openGiftInfoModal, openPremiumModal, showNotification, checkCanSendGift } = getActions();
@ -72,7 +73,7 @@ function GiftItemStar({
: getPriceAmount(uniqueGift?.resellPrice);
const priceCurrency = priceInfo?.currency || STARS_CURRENCY_CODE;
const resellMinStars = regularGift?.resellMinStars;
const priceInStarsAsString = !isGiftUnique && isResale && resellMinStars
const formattedPrice = !isGiftUnique && isResale && resellMinStars
? lang.number(resellMinStars) + '+' : priceInfo?.amount || 0;
const isLimited = !isGiftUnique && Boolean(regularGift?.isLimited);
const isSoldOut = !isGiftUnique && Boolean(regularGift?.isSoldOut);
@ -175,6 +176,24 @@ function GiftItemStar({
setIsVisible(visible);
});
const badgeContent = useMemo(() => {
if (withTransferBadge) {
return lang('GiftTransferTitle');
}
if (priceCurrency === TON_CURRENCY_CODE) {
return formatTonAsIcon(lang, formattedPrice || 0, {
shouldConvertFromNanos: true,
className: styles.star,
});
}
return formatStarsAsIcon(lang, formattedPrice || 0, {
asFont: true,
className: styles.star,
});
}, [withTransferBadge, priceCurrency, formattedPrice, lang]);
return (
<div
ref={ref}
@ -212,17 +231,18 @@ function GiftItemStar({
</div>
<Button
className={styles.buy}
className={buildClassName(
styles.buy,
withTransferBadge && styles.transferBadge,
)}
nonInteractive
size="tiny"
color={isGiftUnique ? 'bluredStarsBadge' : 'stars'}
withSparkleEffect={isVisible}
withSparkleEffect={isVisible && !withTransferBadge}
pill
fluid
>
{priceCurrency === TON_CURRENCY_CODE
? formatTonAsIcon(lang, priceInStarsAsString || 0, { shouldConvertFromNanos: true, className: styles.star })
: formatStarsAsIcon(lang, priceInStarsAsString || 0, { asFont: true, className: styles.star })}
{badgeContent}
</Button>
{giftRibbon}
{isLocked && <Icon name="lock-badge" className={styles.lockIcon} />}

View File

@ -81,7 +81,7 @@
.starGiftsContainer,
.premiumGiftsGallery {
display: flex;
gap: 0.625rem;
gap: 0.5rem;
align-items: center;
justify-content: center;

View File

@ -9,6 +9,7 @@ import type {
ApiDisallowedGifts,
ApiPeer,
ApiPremiumGiftCodeOption,
ApiSavedStarGift,
ApiStarGift,
ApiStarGiftRegular,
ApiStarsAmount,
@ -34,6 +35,7 @@ import Avatar from '../../common/Avatar';
import InteractiveSparkles from '../../common/InteractiveSparkles';
import SafeLink from '../../common/SafeLink';
import Button from '../../ui/Button';
import InfiniteScroll from '../../ui/InfiniteScroll';
import Modal from '../../ui/Modal';
import Transition from '../../ui/Transition';
import BalanceBlock from '../stars/BalanceBlock';
@ -56,6 +58,8 @@ type StateProps = {
boostPerSentGift?: number;
starGiftsById?: Record<string, ApiStarGiftRegular>;
starGiftIdsByCategory?: Record<StarGiftCategory, string[]>;
myCollectibleGiftsById?: Record<string, ApiSavedStarGift>;
myCollectibleGiftIds?: string[];
starBalance?: ApiStarsAmount;
peer?: ApiPeer;
isSelf?: boolean;
@ -77,6 +81,8 @@ const GiftModal: FC<OwnProps & StateProps> = ({
modal,
starGiftsById,
starGiftIdsByCategory,
myCollectibleGiftsById,
myCollectibleGiftIds,
starBalance,
peer,
isSelf,
@ -87,7 +93,14 @@ const GiftModal: FC<OwnProps & StateProps> = ({
tabId,
}) => {
const {
closeGiftModal, openGiftInfoModal, resetResaleGifts, loadResaleGifts, openGiftInMarket, closeResaleGiftsMarket,
closeGiftModal,
openGiftInfoModal,
resetResaleGifts,
loadResaleGifts,
openGiftInMarket,
closeResaleGiftsMarket,
loadMyCollectibleGifts,
openGiftTransferConfirmModal,
} = getActions();
const dialogRef = useRef<HTMLDivElement>();
const transitionRef = useRef<HTMLDivElement>();
@ -122,6 +135,7 @@ const GiftModal: FC<OwnProps & StateProps> = ({
const areUnlimitedStarGiftsDisallowed = !isSelf && disallowedGifts?.shouldDisallowUnlimitedStarGifts;
const areLimitedStarGiftsDisallowed = !isSelf && disallowedGifts?.shouldDisallowLimitedStarGifts;
const areUniqueStarGiftsDisallowed = !isSelf && disallowedGifts?.shouldDisallowUniqueStarGifts;
const oldLang = useOldLang();
const lang = useLang();
@ -206,19 +220,29 @@ const GiftModal: FC<OwnProps & StateProps> = ({
),
}, { withNodes: true });
const starGiftDescription = chat
? lang('StarGiftDescriptionChannel', { peer: getPeerTitle(lang, chat) }, {
withNodes: true,
withMarkdown: true,
})
: isSelf
? lang('StarGiftDescriptionSelf', undefined, {
const starGiftDescription = useMemo(() => {
if (chat) {
return lang('StarGiftDescriptionChannel', { peer: getPeerTitle(lang, chat) }, {
withNodes: true,
withMarkdown: true,
});
}
if (isSelf) {
return lang('StarGiftDescriptionSelf', undefined, {
withNodes: true,
renderTextFilters: ['br'],
})
: lang('StarGiftDescription', {
user: getUserFullName(user)!,
}, { withNodes: true, withMarkdown: true });
});
}
if (selectedCategory === 'resale') {
return lang('StarGiftDescriptionCollectibles');
}
return lang('StarGiftDescription', {
user: getUserFullName(user)!,
}, { withNodes: true, withMarkdown: true });
}, [chat, isSelf, selectedCategory, user, lang]);
function renderGiftPremiumHeader() {
return (
@ -268,22 +292,62 @@ const GiftModal: FC<OwnProps & StateProps> = ({
setIsGiftScreenHeaderForStarGifts('id' in gift);
});
const handleMyGiftClick = useLastCallback((gift: ApiStarGift) => {
if (gift.type === 'starGift' || !myCollectibleGiftsById || !peer?.id) return;
const savedGift = myCollectibleGiftsById[gift.id];
openGiftTransferConfirmModal({
gift: savedGift,
recipientId: peer.id,
});
});
const handleLoadMore = useLastCallback(() => {
if (selectedCategory === 'myCollectibles') {
loadMyCollectibleGifts();
}
});
function renderStarGifts() {
if (selectedCategory === 'myCollectibles') {
return (
<InfiniteScroll
className={styles.starGiftsContainer}
items={myCollectibleGiftIds}
onLoadMore={handleLoadMore}
scrollContainerClosest={`.${styles.main}`}
itemSelector=".starGiftItem"
>
{myCollectibleGiftsById && myCollectibleGiftIds?.map((giftId) => {
const savedGift = myCollectibleGiftsById[giftId];
if (!savedGift) return undefined;
return (
<GiftItemStar
key={giftId}
gift={savedGift.gift}
observeIntersection={observeIntersection}
onClick={handleMyGiftClick}
withTransferBadge
/>
);
})}
</InfiniteScroll>
);
}
const filteredGiftIds = starGiftIdsByCategory?.[selectedCategory]?.filter((giftId) => {
const gift = starGiftsById?.[giftId];
if (!gift) return false;
const { isLimited, isSoldOut, upgradeStars } = gift;
if (areUnlimitedStarGiftsDisallowed && !areLimitedStarGiftsDisallowed) {
return isLimited;
}
if (areLimitedStarGiftsDisallowed && !areUnlimitedStarGiftsDisallowed) {
return !isLimited && !isSoldOut;
}
if (areUnlimitedStarGiftsDisallowed && areLimitedStarGiftsDisallowed) {
return Boolean(isLimited && Boolean(upgradeStars));
const { isLimited, availabilityResale } = gift;
if (areLimitedStarGiftsDisallowed && isLimited) {
return !areUniqueStarGiftsDisallowed ? availabilityResale : false;
}
if (areUnlimitedStarGiftsDisallowed && !isLimited) return false;
return true;
});
@ -291,8 +355,9 @@ const GiftModal: FC<OwnProps & StateProps> = ({
<div className={styles.starGiftsContainer}>
{starGiftsById && filteredGiftIds?.flatMap((giftId) => {
const gift = starGiftsById[giftId];
const shouldShowResale = selectedCategory !== 'stock' && Boolean(gift.availabilityResale);
const shouldDuplicateAsResale = selectedCategory !== 'resale' && shouldShowResale && !gift.isSoldOut;
const shouldShowResale = Boolean(gift.availabilityResale) && !areUniqueStarGiftsDisallowed;
const shouldDuplicateAsResale = selectedCategory !== 'resale'
&& shouldShowResale && !gift.isSoldOut && !areLimitedStarGiftsDisallowed;
const elements = [
<GiftItemStar
@ -399,7 +464,9 @@ const GiftModal: FC<OwnProps & StateProps> = ({
{renderStarGiftsHeader()}
{renderStarGiftsDescription()}
<StarGiftCategoryList
areLimitedStarGiftsDisallowed={areLimitedStarGiftsDisallowed}
areCollectibleStarGiftsDisallowed={areUniqueStarGiftsDisallowed}
isSelf={isSelf}
hasCollectible={Boolean(myCollectibleGiftIds?.length)}
onCategoryChanged={onCategoryChanged}
/>
<Transition
@ -535,6 +602,8 @@ export default memo(withGlobal<OwnProps>((global, { modal }): Complete<StateProp
boostPerSentGift: global.appConfig.boostsPerSentGift,
starGiftsById: starGifts?.byId,
starGiftIdsByCategory: starGifts?.idsByCategory,
myCollectibleGiftsById: global.myCollectibleGifts?.byId,
myCollectibleGiftIds: global.myCollectibleGifts?.ids,
starBalance: stars?.balance,
peer,
isSelf,
@ -548,8 +617,6 @@ export default memo(withGlobal<OwnProps>((global, { modal }): Complete<StateProp
function getCategoryKey(category: StarGiftCategory) {
if (category === 'all') return 0;
if (category === 'limited') return 1;
if (category === 'resale') return 2;
if (category === 'stock') return 3;
return category + 3;
if (category === 'myCollectibles') return 1;
return 2;
}

View File

@ -10,7 +10,7 @@
flex-wrap: nowrap;
gap: 0.0625rem;
align-items: center;
justify-content: flex-start;
justify-content: center;
// Prevent first item from being always partially obscured
margin-left: -0.5rem;

View File

@ -1,5 +1,5 @@
import {
memo, useMemo, useRef, useState,
memo, useRef, useState,
} from '../../../lib/teact/teact';
import { withGlobal } from '../../../global';
@ -10,12 +10,12 @@ import buildClassName from '../../../util/buildClassName';
import useHorizontalScroll from '../../../hooks/useHorizontalScroll';
import useLang from '../../../hooks/useLang';
import StarIcon from '../../common/icons/StarIcon';
import styles from './StarGiftCategoryList.module.scss';
type OwnProps = {
areLimitedStarGiftsDisallowed?: boolean;
areCollectibleStarGiftsDisallowed?: boolean;
isSelf?: boolean;
hasCollectible?: boolean;
onCategoryChanged: (category: StarGiftCategory) => void;
};
@ -26,16 +26,13 @@ type StateProps = {
const StarGiftCategoryList = ({
idsByCategory,
onCategoryChanged,
areLimitedStarGiftsDisallowed,
areCollectibleStarGiftsDisallowed,
isSelf,
hasCollectible,
}: StateProps & OwnProps) => {
const ref = useRef<HTMLDivElement>();
const lang = useLang();
const starCategories: number[] | undefined = useMemo(() => idsByCategory && Object.keys(idsByCategory)
.filter((category) => category !== 'all' && category !== 'limited')
.map(Number)
.sort((a, b) => a - b),
[idsByCategory]);
const hasResale = idsByCategory && idsByCategory['resale'].length > 0;
@ -50,9 +47,8 @@ const StarGiftCategoryList = ({
function renderCategoryName(category: StarGiftCategory) {
if (category === 'all') return lang('AllGiftsCategory');
if (category === 'stock') return lang('StockGiftsCategory');
if (category === 'limited') return lang('LimitedGiftsCategory');
if (category === 'resale') return lang('GiftCategoryResale');
if (category === 'myCollectibles') return lang('GiftCategoryMyGifts');
if (category === 'resale') return lang('GiftCategoryCollectibles');
return category;
}
@ -65,13 +61,6 @@ const StarGiftCategoryList = ({
)}
onClick={() => handleItemClick(category)}
>
{Number.isInteger(category) && (
<StarIcon
className={styles.star}
type="gold"
size="middle"
/>
)}
{renderCategoryName(category)}
</div>
);
@ -82,10 +71,8 @@ const StarGiftCategoryList = ({
return (
<div ref={ref} className={buildClassName(styles.list, 'no-scrollbar')}>
{renderCategoryItem('all')}
{!areLimitedStarGiftsDisallowed && renderCategoryItem('limited')}
{!areLimitedStarGiftsDisallowed && hasResale && renderCategoryItem('resale')}
{renderCategoryItem('stock')}
{starCategories?.map(renderCategoryItem)}
{!areCollectibleStarGiftsDisallowed && !isSelf && hasCollectible && renderCategoryItem('myCollectibles')}
{!areCollectibleStarGiftsDisallowed && hasResale && renderCategoryItem('resale')}
</div>
);
};

View File

@ -19,6 +19,17 @@
font-size: 1.25rem;
}
.descriptionConfirm {
padding-bottom: 0.75rem;
color: var(--color-text-secondary);
}
.titleConfirm {
margin-bottom: 0.75rem;
font-size: 1.25rem;
font-weight: var(--font-weight-medium);
}
.header {
display: flex;
flex-direction: column;

View File

@ -938,10 +938,19 @@ const GiftInfoModal = ({
confirmHandler={handleConfirmBuyGift}
>
{uniqueGift.resaleTonOnly
&& (
<div className={styles.descriptionConfirm}>
{lang('ConfirmBuyGiftForTonDescription')}
</div>
)}
<GiftTransferPreview
peer={recipientPeer || currentUser}
gift={uniqueGift}
/>
<div className={styles.titleConfirm}>
{lang('TitleConfirmPayment')}
</div>
{!recipientPeer
&& (
<p>

View File

@ -0,0 +1,20 @@
import type { FC } from '../../../../lib/teact/teact';
import type { OwnProps } from './GiftTransferConfirmModal';
import { Bundles } from '../../../../util/moduleLoader';
import useModuleLoader from '../../../../hooks/useModuleLoader';
const GiftTransferConfirmModalAsync: FC<OwnProps> = (props) => {
const { modal } = props;
const GiftTransferConfirmModal = useModuleLoader(
Bundles.Stars,
'GiftTransferConfirmModal',
!modal,
);
return GiftTransferConfirmModal ? <GiftTransferConfirmModal {...props} /> : undefined;
};
export default GiftTransferConfirmModalAsync;

View File

@ -0,0 +1,108 @@
import { memo } from '../../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../../global';
import type { ApiPeer } from '../../../../api/types';
import type { TabState } from '../../../../global/types';
import { getPeerTitle } from '../../../../global/helpers/peers';
import { selectPeer } from '../../../../global/selectors';
import { formatStarsAsIcon, formatStarsAsText } from '../../../../util/localization/format';
import useCurrentOrPrev from '../../../../hooks/useCurrentOrPrev';
import useLang from '../../../../hooks/useLang';
import useLastCallback from '../../../../hooks/useLastCallback';
import GiftTransferPreview from '../../../common/gift/GiftTransferPreview';
import ConfirmDialog from '../../../ui/ConfirmDialog';
export type OwnProps = {
modal: TabState['giftTransferConfirmModal'];
};
type StateProps = {
selectedPeer?: ApiPeer;
};
const GiftTransferConfirmModal = ({ modal, selectedPeer }: OwnProps & StateProps) => {
const {
closeGiftTransferConfirmModal, transferGift, openChat, closeGiftModal, closeGiftTransferModal,
} = getActions();
const lang = useLang();
const isOpen = Boolean(modal);
const renderingModal = useCurrentOrPrev(modal);
const renderingSelectedPeer = useCurrentOrPrev(selectedPeer);
const handleConfirm = useLastCallback(() => {
if (!renderingModal?.gift.inputGift || !renderingModal.recipientId) return;
transferGift({
gift: renderingModal.gift.inputGift,
recipientId: renderingModal.recipientId,
transferStars: renderingModal.gift.transferStars,
});
closeGiftTransferConfirmModal();
openChat({ id: renderingModal.recipientId });
closeGiftModal();
closeGiftTransferModal();
});
if (!renderingModal) return undefined;
const { gift } = renderingModal;
const uniqueGift = gift.gift.type === 'starGiftUnique' ? gift.gift : undefined;
if (!uniqueGift) return undefined;
return (
<ConfirmDialog
isOpen={isOpen}
noDefaultTitle
onClose={closeGiftTransferConfirmModal}
confirmLabel={gift.transferStars
? lang(
'GiftTransferConfirmButton',
{ amount: formatStarsAsIcon(lang, gift.transferStars, { asFont: true }) },
{ withNodes: true },
) : lang('GiftTransferConfirmButtonFree')}
confirmHandler={handleConfirm}
>
{renderingSelectedPeer && (
<GiftTransferPreview
peer={renderingSelectedPeer}
gift={uniqueGift}
/>
)}
<p>
{gift.transferStars
? lang('GiftTransferConfirmDescription', {
gift: lang('GiftUnique', { title: uniqueGift.title, number: uniqueGift.number }),
amount: formatStarsAsText(lang, gift.transferStars),
peer: getPeerTitle(lang, renderingSelectedPeer!),
}, {
withNodes: true,
withMarkdown: true,
})
: lang('GiftTransferConfirmDescriptionFree', {
gift: lang('GiftUnique', { title: uniqueGift.title, number: uniqueGift.number }),
peer: getPeerTitle(lang, renderingSelectedPeer!),
}, {
withNodes: true,
withMarkdown: true,
})}
</p>
</ConfirmDialog>
);
};
export default memo(
withGlobal<OwnProps>((global, { modal }): Complete<StateProps> => {
const selectedPeer = modal?.recipientId ? selectPeer(global, modal.recipientId) : undefined;
return {
selectedPeer,
};
})(GiftTransferConfirmModal),
);

View File

@ -1,19 +1,15 @@
import {
memo, useEffect, useMemo, useState,
memo, useMemo, useState,
} from '../../../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../../../global';
import type { ApiStarGiftUnique } from '../../../../api/types';
import type { TabState } from '../../../../global/types';
import type { UniqueCustomPeer } from '../../../../types';
import { ALL_FOLDER_ID } from '../../../../config';
import { getPeerTitle } from '../../../../global/helpers/peers';
import { selectCanGift, selectPeer } from '../../../../global/selectors';
import { selectCanGift } from '../../../../global/selectors';
import { unique } from '../../../../util/iteratees';
import { formatStarsAsIcon, formatStarsAsText } from '../../../../util/localization/format';
import { MEMO_EMPTY_ARRAY } from '../../../../util/memo';
import { getGiftAttributes } from '../../../common/helpers/gifts';
import sortChatIds from '../../../common/helpers/sortChatIds';
import useCurrentOrPrev from '../../../../hooks/useCurrentOrPrev';
@ -22,10 +18,8 @@ import useLang from '../../../../hooks/useLang';
import useLastCallback from '../../../../hooks/useLastCallback';
import usePeerSearch from '../../../../hooks/usePeerSearch';
import GiftTransferPreview from '../../../common/gift/GiftTransferPreview';
import PeerPicker from '../../../common/pickers/PeerPicker';
import PickerModal from '../../../common/pickers/PickerModal';
import ConfirmDialog from '../../../ui/ConfirmDialog';
export type OwnProps = {
modal: TabState['giftTransferModal'];
@ -41,7 +35,11 @@ type Categories = 'withdraw';
const GiftTransferModal = ({
modal, contactIds, currentUserId,
}: OwnProps & StateProps) => {
const { closeGiftTransferModal, openGiftWithdrawModal, transferGift } = getActions();
const {
closeGiftTransferModal,
openGiftWithdrawModal,
openGiftTransferConfirmModal,
} = getActions();
const isOpen = Boolean(modal);
const lang = useLang();
@ -49,16 +47,6 @@ const GiftTransferModal = ({
const [searchQuery, setSearchQuery] = useState<string>('');
const renderingModal = useCurrentOrPrev(modal);
const uniqueGift = renderingModal?.gift?.gift as ApiStarGiftUnique;
const giftAttributes = uniqueGift && getGiftAttributes(uniqueGift);
const [selectedId, setSelectedId] = useState<string | undefined>();
const renderingSelectedPeerId = useCurrentOrPrev(selectedId);
const renderingSelectedPeer = useMemo(() => {
const global = getGlobal();
return renderingSelectedPeerId ? selectPeer(global, renderingSelectedPeerId) : undefined;
}, [renderingSelectedPeerId]);
const orderedChatIds = useFolderManagerForOrderedIds(ALL_FOLDER_ID);
@ -107,26 +95,13 @@ const GiftTransferModal = ({
false);
}, [isLoading, foundIds, currentUserId]);
const closeConfirmModal = useLastCallback(() => {
setSelectedId(undefined);
});
const handlePeerSelect = useLastCallback((peerId: string) => {
if (!renderingModal?.gift) return;
useEffect(() => {
if (!isOpen) {
setSelectedId(undefined);
}
}, [isOpen]);
const handleTransfer = useLastCallback(() => {
if (!renderingModal?.gift.inputGift) return;
transferGift({
gift: renderingModal.gift.inputGift,
recipientId: renderingSelectedPeerId!,
transferStars: renderingModal.gift.transferStars,
openGiftTransferConfirmModal({
gift: renderingModal.gift,
recipientId: peerId,
});
closeConfirmModal();
closeGiftTransferModal();
});
return (
@ -151,47 +126,8 @@ const GiftTransferModal = ({
filterValue={searchQuery}
filterPlaceholder={lang('Search')}
onFilterChange={setSearchQuery}
onSelectedIdChange={setSelectedId}
onSelectedIdChange={handlePeerSelect}
/>
{giftAttributes && (
<ConfirmDialog
isOpen={Boolean(selectedId)}
noDefaultTitle
onClose={closeConfirmModal}
confirmLabel={renderingModal?.gift.transferStars
? lang(
'GiftTransferConfirmButton',
{ amount: formatStarsAsIcon(lang, renderingModal.gift.transferStars, { asFont: true }) },
{ withNodes: true },
) : lang('GiftTransferConfirmButtonFree')}
confirmHandler={handleTransfer}
>
{renderingSelectedPeer && (
<GiftTransferPreview
peer={renderingSelectedPeer}
gift={uniqueGift}
/>
)}
<p>
{renderingModal?.gift.transferStars
? lang('GiftTransferConfirmDescription', {
gift: lang('GiftUnique', { title: uniqueGift.title, number: uniqueGift.number }),
amount: formatStarsAsText(lang, renderingModal.gift.transferStars),
peer: getPeerTitle(lang, renderingSelectedPeer!),
}, {
withNodes: true,
withMarkdown: true,
})
: lang('GiftTransferConfirmDescriptionFree', {
gift: lang('GiftUnique', { title: uniqueGift.title, number: uniqueGift.number }),
peer: getPeerTitle(lang, renderingSelectedPeer!),
}, {
withNodes: true,
withMarkdown: true,
})}
</p>
</ConfirmDialog>
)}
</PickerModal>
);
};

View File

@ -1,5 +1,4 @@
import type { ApiSavedStarGift, ApiStarGiftUnique } from '../../../api/types';
import type { StarGiftCategory } from '../../../types';
import type { ActionReturnType } from '../../types';
import {
@ -8,7 +7,7 @@ import {
TON_CURRENCY_CODE,
} from '../../../config';
import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { buildCollectionByKey } from '../../../util/iteratees';
import { buildCollectionByCallback, buildCollectionByKey } from '../../../util/iteratees';
import { callApi } from '../../../api/gramjs';
import { RESALE_GIFTS_LIMIT } from '../../../limits';
import { areInputSavedGiftsEqual, getRequestInputSavedStarGift } from '../../helpers/payments';
@ -136,49 +135,83 @@ addActionHandler('loadStarGifts', async (global): Promise<void> => {
const byId = buildCollectionByKey(result.gifts, 'id');
const idsByCategoryName: Record<StarGiftCategory, string[]> = {
all: [],
stock: [],
limited: [],
resale: [],
};
const allStarGiftIds = Object.keys(byId);
const allStarGifts = Object.values(byId);
const limitedStarGiftIds = allStarGifts.map((gift) => (gift.isLimited ? gift.id : undefined))
.filter(Boolean);
const stockedStarGiftIds = allStarGifts.map((gift) => (
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;
if (!idsByCategoryName[starsCategory]) {
idsByCategoryName[starsCategory] = [];
}
idsByCategoryName[starsCategory].push(gift.id);
});
global = {
...global,
starGifts: {
byId,
idsByCategory: idsByCategoryName,
idsByCategory: {
all: allStarGiftIds,
resale: resaleStarGiftIds,
myCollectibles: [],
},
},
};
setGlobal(global);
});
addActionHandler('loadMyCollectibleGifts', async (global, actions, payload): Promise<void> => {
const { shouldRefresh } = payload || {};
const currentUserId = global.currentUserId;
if (!currentUserId) return;
const currentMyCollectibleGifts = global.myCollectibleGifts;
const localNextOffset = currentMyCollectibleGifts?.nextOffset;
if (currentMyCollectibleGifts && !localNextOffset && !shouldRefresh) return;
const peer = selectPeer(global, currentUserId);
if (!peer) return;
const result = await callApi('fetchSavedStarGifts', {
peer,
offset: !shouldRefresh ? localNextOffset : undefined,
filter: {
sortType: 'byDate',
shouldIncludeUnique: true,
shouldIncludeUnlimited: false,
shouldIncludeUpgradable: false,
shouldIncludeLimited: false,
shouldIncludeDisplayed: true,
shouldIncludeHidden: true,
},
});
if (!result) return;
global = getGlobal();
const gifts = result.gifts;
const byId = buildCollectionByCallback(gifts, (savedGift) => (
[savedGift.gift.id, savedGift]
));
const ids = gifts.map((gift) => gift.gift.id);
global = {
...global,
myCollectibleGifts: {
byId: {
...!shouldRefresh && (global.myCollectibleGifts?.byId || {}),
...byId,
},
ids: [
...!shouldRefresh ? (global.myCollectibleGifts?.ids || []) : [],
...ids,
],
nextOffset: result.nextOffset,
},
};
setGlobal(global);
});
addActionHandler('updateResaleGiftsFilter', (global, actions, payload): ActionReturnType => {
const {
filter, tabId = getCurrentTabId(),
@ -338,6 +371,9 @@ addActionHandler('reloadPeerSavedGifts', (global, actions, payload): ActionRetur
actions.loadPeerSavedGifts({ peerId, shouldRefresh: true, tabId: tabState.id });
}
});
if (peerId === global.currentUserId) {
actions.loadMyCollectibleGifts({ shouldRefresh: true });
}
});
addActionHandler('loadStarsSubscriptions', async (global): Promise<void> => {

View File

@ -298,6 +298,8 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
global = updateTabState(global, {
isWaitingForStarGiftTransfer: undefined,
}, tabId);
actions.reloadPeerSavedGifts({ peerId: global.currentUserId! });
}
});

View File

@ -146,6 +146,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
},
tabId,
});
actions.reloadPeerSavedGifts({ peerId: starGiftModalState.forPeerId });
actions.requestConfetti({ withStars: true, tabId });
actions.closeGiftModal({ tabId });
}

View File

@ -434,6 +434,21 @@ addActionHandler('openGiftTransferModal', (global, actions, payload): ActionRetu
addTabStateResetterAction('closeGiftTransferModal', 'giftTransferModal');
addActionHandler('openGiftTransferConfirmModal', (global, actions, payload): ActionReturnType => {
const {
gift, recipientId, tabId = getCurrentTabId(),
} = payload;
return updateTabState(global, {
giftTransferConfirmModal: {
gift,
recipientId,
},
}, tabId);
});
addTabStateResetterAction('closeGiftTransferConfirmModal', 'giftTransferConfirmModal');
addActionHandler('updateSelectedGiftCollection', (global, actions, payload): ActionReturnType => {
const { peerId, collectionId, tabId = getCurrentTabId() } = payload;
const tabState = selectTabState(global, tabId);

View File

@ -2544,6 +2544,9 @@ export interface ActionPayloads {
loadPremiumGifts: undefined;
loadTonGifts: undefined;
loadStarGifts: undefined;
loadMyCollectibleGifts: {
shouldRefresh?: true;
} | undefined;
updateResaleGiftsFilter: {
filter: ResaleGiftsFilterOptions;
} & WithTabId;
@ -2643,6 +2646,11 @@ export interface ActionPayloads {
recipientId: string;
} & WithTabId;
closeGiftTransferModal: WithTabId | undefined;
openGiftTransferConfirmModal: {
gift: ApiSavedStarGift;
recipientId: string;
} & WithTabId;
closeGiftTransferConfirmModal: WithTabId | undefined;
updateSelectedGiftCollection: {
peerId: string;
collectionId: number;

View File

@ -28,6 +28,7 @@ import type {
ApiReaction,
ApiReactionKey,
ApiSavedReactionTag,
ApiSavedStarGift,
ApiSession,
ApiSponsoredMessage,
ApiStarGiftCollection,
@ -311,6 +312,11 @@ export type GlobalState = {
byId: Record<string, ApiStarGiftRegular>;
idsByCategory: Record<StarGiftCategory, string[]>;
};
myCollectibleGifts?: {
byId: Record<string, ApiSavedStarGift>;
ids: string[];
nextOffset?: string;
};
starGiftCollections?: {
byPeerId: Record<string, ApiStarGiftCollection[]>;
};

View File

@ -849,6 +849,11 @@ export type TabState = {
gift: ApiSavedStarGift;
};
giftTransferConfirmModal?: {
gift: ApiSavedStarGift;
recipientId: string;
};
giftUpgradeModal?: {
sampleAttributes: ApiStarGiftAttribute[];
recipientId?: string;

View File

@ -669,7 +669,7 @@ export type WebPageMediaSize = 'large' | 'small';
export type AttachmentCompression = 'compress' | 'original';
export type StarGiftCategory = number | 'all' | 'limited' | 'stock' | 'resale';
export type StarGiftCategory = 'all' | 'myCollectibles' | 'resale';
export type CallSound = (
'join' | 'allowTalk' | 'leave' | 'connecting' | 'incoming' | 'end' | 'connect' | 'busy' | 'ringing'

View File

@ -1232,6 +1232,7 @@ export interface LangPair {
'GiftPremiumDescriptionLink': undefined;
'StarsGiftHeader': undefined;
'StarsGiftHeaderSelf': undefined;
'StarGiftDescriptionCollectibles': undefined;
'StarGiftDescriptionSelf': undefined;
'GiftLimited': undefined;
'GiftSoldOut': undefined;
@ -1304,8 +1305,6 @@ export interface LangPair {
'GiftWithdrawTitle': undefined;
'GiftWithdrawSubmit': undefined;
'AllGiftsCategory': undefined;
'LimitedGiftsCategory': undefined;
'StockGiftsCategory': undefined;
'PremiumGiftDescription': undefined;
'StarsReactionLinkText': undefined;
'StarsReactionLink': undefined;
@ -1578,7 +1577,8 @@ export interface LangPair {
'StarGiftPurchaseTransaction': undefined;
'ContextMenuItemMention': undefined;
'GiftRibbonResale': undefined;
'GiftCategoryResale': undefined;
'GiftCategoryCollectibles': undefined;
'GiftCategoryMyGifts': undefined;
'GiftSortByPrice': undefined;
'GiftSortByNumber': undefined;
'ContextMenuItemSelectAll': undefined;
@ -1709,6 +1709,7 @@ export interface LangPair {
'GiftValueForSaleOnFragment': undefined;
'GiftValueForSaleOnTelegram': undefined;
'EmbeddedMessageNoCaption': undefined;
'ConfirmBuyGiftForTonDescription': undefined;
'TitleGiftLocked': undefined;
'QuickPreview': undefined;
}