Star Gift: Support crafted in readonly mode (#6702)

This commit is contained in:
Alexander Zinchuk 2026-02-22 23:43:39 +01:00
parent 6c3f009388
commit ee452ef474
15 changed files with 183 additions and 53 deletions

View File

@ -36,7 +36,7 @@ export function buildApiStarGift(starGift: GramJs.TypeStarGift): ApiStarGift {
const {
id, num, ownerId, ownerName, title, attributes, availabilityIssued, availabilityTotal, slug, ownerAddress,
giftAddress, resellAmount, releasedBy, resaleTonOnly, requirePremium, valueCurrency, valueAmount, giftId,
valueUsdAmount,
valueUsdAmount, burned, crafted,
} = starGift;
return {
@ -61,6 +61,8 @@ export function buildApiStarGift(starGift: GramJs.TypeStarGift): ApiStarGift {
valueUsdAmount: toJSNumber(valueUsdAmount),
regularGiftId: giftId.toString(),
offerMinStars: starGift.offerMinStars,
isBurned: burned,
isCrafted: crafted,
};
}

View File

@ -1,4 +1,5 @@
import { Api as GramJs } from '../../../lib/gramjs';
import { RPCError } from '../../../lib/gramjs/errors';
import type { GiftProfileFilterOptions, ResaleGiftsFilterOptions } from '../../../types';
import type {
@ -389,13 +390,22 @@ export async function fetchStarsTopupOptions() {
export async function fetchUniqueStarGift({ slug }: {
slug: string;
}) {
const result = await invokeRequest(new GramJs.payments.GetUniqueStarGift({ slug }));
try {
const result = await invokeRequest(new GramJs.payments.GetUniqueStarGift({ slug }), {
shouldThrow: true,
});
if (!result) return undefined;
if (!result) return undefined;
const gift = buildApiStarGift(result.gift);
if (gift.type !== 'starGiftUnique') return undefined;
return gift;
const gift = buildApiStarGift(result.gift);
if (gift.type !== 'starGiftUnique') return undefined;
return gift;
} catch (err) {
if (err instanceof RPCError) {
return wrapError(err);
}
return undefined;
}
}
export async function fetchStarGiftUpgradePreview({

View File

@ -61,6 +61,8 @@ export interface ApiStarGiftUnique {
valueAmount?: number;
valueUsdAmount?: number;
offerMinStars?: number;
isBurned?: true;
isCrafted?: true;
}
export type ApiStarGift = ApiStarGiftRegular | ApiStarGiftUnique;

View File

@ -1572,14 +1572,15 @@
"GiftInfoOwner" = "Owner";
"GiftInfoIssued" = "{issued}/{total} issued";
"GiftInfoCollectible" = "Collectible #{number}";
"GiftInfoUniqueTitle" = "{name} {number}";
"GiftSavedNumber" = "#{number}";
"GiftAttributeModel" = "Model";
"GiftAttributeBackdrop" = "Backdrop";
"GiftAttributeSymbol" = "Symbol";
"GiftRarityUncommon" = "Uncommon";
"GiftRarityRare" = "Rare";
"GiftRarityEpic" = "Epic";
"GiftRarityLegendary" = "Legendary";
"GiftRarityUncommon" = "uncommon";
"GiftRarityRare" = "rare";
"GiftRarityEpic" = "epic";
"GiftRarityLegendary" = "legendary";
"GiftInfoPeerOriginalInfo" = "Gifted to {peer} on {date}.";
"GiftInfoPeerOriginalInfoSender" = "Gifted by {sender} to {peer} on {date}.";
"GiftInfoPeerOriginalInfoText" = "Gifted to {peer} on {date} with comment \"{text}\".";
@ -1923,6 +1924,8 @@
"ActionStarGiftTransferredChannelYou" = "You transferred a gift to {channel}";
"ActionStarGiftTransferredMine" = "You transferred a gift to {user}";
"ActionStarGiftTransferredSelf" = "You transferred a unique collectible";
"ActionStarGiftCraftedSelf" = "You crafted a new gift!";
"ActionStarGiftCrafted" = "Crafted Gift";
"ActionStarGiftTransferredUnknown" = "Someone transferred you a gift";
"ActionStarGiftTransferredUnknownChannel" = "Someone transferred a gift to {channel}";
"ActionStarGiftSoldFromOffer" = "You sold {gift} to {user} for {cost}";
@ -1950,6 +1953,8 @@
"ActionStarGiftUnpack" = "Unpack";
"ActionStarGiftLimitedRibbon" = "1 of {total}";
"ActionStarGiftUniqueRibbon" = "gift";
"ActionStarGiftUniqueBurnedRibbon" = "burned";
"ActionStarGiftUniqueBurnedError" = "Sorry, this gift has already been burned.";
"ActionStarGiftUniqueModel" = "Model";
"ActionStarGiftUniqueBackdrop" = "Backdrop";
"ActionStarGiftUniqueSymbol" = "Symbol";

View File

@ -0,0 +1,21 @@
.root {
&.uncommon {
color: var(--color-gift-uncommon);
background-color: var(--color-gift-uncommon-bg);
}
&.rare {
color: var(--color-gift-rare);
background-color: var(--color-gift-rare-bg);
}
&.epic {
color: var(--color-gift-epic);
background-color: var(--color-gift-epic-bg);
}
&.legendary {
color: var(--color-gift-legendary);
background-color: var(--color-gift-legendary-bg);
}
}

View File

@ -0,0 +1,28 @@
import { memo } from '../../lib/teact/teact';
import type { ApiStarGiftAttributeRarity } from '../../api/types';
import buildClassName from '../../util/buildClassName';
import { getGiftRarityTitle } from './helpers/gifts';
import useLang from '../../hooks/useLang';
import BadgeButton from './BadgeButton';
import styles from './GiftRarityBadge.module.scss';
type OwnProps = {
rarity: ApiStarGiftAttributeRarity;
};
const GiftRarityBadge = ({ rarity }: OwnProps) => {
const lang = useLang();
return (
<BadgeButton className={buildClassName(styles.root, rarity.type !== 'regular' && styles[rarity.type])}>
{getGiftRarityTitle(lang, rarity)}
</BadgeButton>
);
};
export default memo(GiftRarityBadge);

View File

@ -158,6 +158,7 @@ const ActionMessage = ({
focusMessage,
openGiftOfferAcceptModal,
declineStarGiftOffer,
showNotification,
} = getActions();
const ref = useRef<HTMLDivElement>();
@ -371,8 +372,19 @@ const ActionMessage = ({
break;
}
case 'starGift':
case 'starGift': {
openGiftInfoModalFromMessage({
chatId: message.chatId,
messageId: message.id,
});
break;
}
case 'starGiftUnique': {
if (action.gift.isBurned) {
showNotification({ message: lang('ActionStarGiftUniqueBurnedError') });
break;
}
openGiftInfoModalFromMessage({
chatId: message.chatId,
messageId: message.id,

View File

@ -702,6 +702,7 @@ const ActionMessageText = ({
if (isSavedMessages) {
if (isUpgrade) return lang('ActionStarGiftUpgradedSelf');
if (isTransferred) return lang('ActionStarGiftTransferredSelf');
if (gift.isCrafted) return lang('ActionStarGiftCraftedSelf');
}
if (isUpgrade) {

View File

@ -121,20 +121,24 @@ const StarGiftAction = ({
)}
</div>
<GiftRibbon
color={adaptedPatternColor}
text={lang('ActionStarGiftUniqueRibbon')}
color={action.gift.isBurned ? 'red' : adaptedPatternColor}
text={action.gift.isBurned
? lang('ActionStarGiftUniqueBurnedRibbon')
: lang('ActionStarGiftUniqueRibbon')}
/>
<div className={styles.info}>
<h3 className={styles.title}>
{isSelf ? lang('ActionStarGiftSelf') : lang(
shouldShowFrom ? 'ActionStarGiftFrom' : 'ActionStarGiftTo',
{
peer: renderPeerLink(peer?.id, peerTitle || fallbackPeerTitle),
},
{
withNodes: true,
},
)}
{isSelf
? (action.gift.isCrafted ? lang('ActionStarGiftCrafted') : lang('ActionStarGiftSelf'))
: lang(
shouldShowFrom ? 'ActionStarGiftFrom' : 'ActionStarGiftTo',
{
peer: renderPeerLink(peer?.id, peerTitle || fallbackPeerTitle),
},
{
withNodes: true,
},
)}
</h3>
<div className={styles.subtitle} style={`color: ${backdrop.textColor}`}>
{lang('GiftUnique', { title: action.gift.title, number: action.gift.number })}

View File

@ -34,7 +34,7 @@ type OwnProps = {
modelAttribute: ApiStarGiftAttributeModel;
backdropAttribute: ApiStarGiftAttributeBackdrop;
patternAttribute: ApiStarGiftAttributePattern;
title?: string;
title?: TeactNode;
badge?: TeactNode;
subtitle?: TeactNode;
subtitlePeer?: ApiPeer;
@ -114,7 +114,7 @@ const UniqueGiftHeader = ({
{Boolean(badge) && (
<div className={styles.badge}>{badge}</div>
)}
{title && <h1 className={styles.title}>{title}</h1>}
{Boolean(title) && <h1 className={styles.title}>{title}</h1>}
{Boolean(subtitle) && (
<div
className={buildClassName(styles.subtitle, subtitlePeer && styles.subtitleBadge)}

View File

@ -2,6 +2,16 @@
overflow: hidden;
}
.uniqueTitleNumber {
&.small {
font-size: 0.75em;
}
&.regular {
font-size: 0.875em;
}
}
.checkBox {
margin-inline: -1rem;
}

View File

@ -38,6 +38,7 @@ import Avatar from '../../../common/Avatar';
import BadgeButton from '../../../common/BadgeButton';
import GiftMenuItems from '../../../common/gift/GiftMenuItems';
import GiftTransferPreview from '../../../common/gift/GiftTransferPreview';
import GiftRarityBadge from '../../../common/GiftRarityBadge';
import Icon from '../../../common/icons/Icon';
import SafeLink from '../../../common/SafeLink';
import Button from '../../../ui/Button';
@ -182,26 +183,6 @@ const GiftInfoModal = ({
const isGiftUnique = gift && gift.type === 'starGiftUnique';
const uniqueGift = isGiftUnique ? gift : undefined;
const giftSubtitle = useMemo(() => {
if (!gift || gift.type !== 'starGiftUnique') return undefined;
if (releasedByPeer) {
const releasedByUsername = `@${getMainUsername(releasedByPeer)}`;
const ownerTitle = releasedByUsername || getPeerTitle(lang, releasedByPeer);
const fallbackText = isApiPeerUser(releasedByPeer)
? lang('ActionFallbackUser')
: lang('ActionFallbackChannel');
return lang('GiftInfoCollectibleBy', {
number: gift.number, owner: ownerTitle || fallbackText }, {
withNodes: true,
withMarkdown: true,
});
}
return lang('GiftInfoCollectible', { number: gift.number });
}, [gift, releasedByPeer, lang]);
const starGiftUniqueSlug = gift?.type === 'starGiftUnique' ? gift.slug : undefined;
const selfCollectibleStatus = useMemo(() => {
@ -322,6 +303,42 @@ const GiftInfoModal = ({
return gift && getGiftAttributes(gift);
}, [gift]);
const uniqueGiftTitle = useMemo(() => {
if (!gift || gift.type !== 'starGiftUnique' || !giftAttributes?.backdrop) return undefined;
const numberColor = giftAttributes.backdrop.textColor;
const digitCount = String(gift.number).length;
const numberSizeClass = digitCount >= 6 ? styles.small : styles.regular;
const styledNumber = (
<span className={buildClassName(styles.uniqueTitleNumber, numberSizeClass)} style={`color: ${numberColor}`}>
{lang('GiftSavedNumber', { number: gift.number })}
</span>
);
return lang('GiftInfoUniqueTitle', {
name: gift.title,
number: styledNumber,
}, { withNodes: true });
}, [gift, giftAttributes, lang]);
const uniqueGiftSubtitle = useMemo(() => {
if (!gift || gift.type !== 'starGiftUnique') return undefined;
if (releasedByPeer) {
const releasedByUsername = `@${getMainUsername(releasedByPeer)}`;
const ownerTitle = releasedByUsername || getPeerTitle(lang, releasedByPeer);
const fallbackText = isApiPeerUser(releasedByPeer)
? lang('ActionFallbackUser')
: lang('ActionFallbackChannel');
return ownerTitle || fallbackText;
}
const modelName = giftAttributes?.model?.name;
return modelName;
}, [gift, giftAttributes, releasedByPeer, lang]);
const renderFooterButton = useLastCallback(() => {
if (canBuyGift) {
return (
@ -551,8 +568,8 @@ const GiftInfoModal = ({
backdropAttribute={giftAttributes!.backdrop!}
patternAttribute={giftAttributes!.pattern!}
modelAttribute={giftAttributes!.model!}
title={gift.title}
subtitle={giftSubtitle}
title={uniqueGiftTitle}
subtitle={uniqueGiftSubtitle}
subtitlePeer={releasedByPeer}
showManageButtons={canManage}
savedGift={savedGift}
@ -699,7 +716,7 @@ const GiftInfoModal = ({
>
{model.name}
</span>
<BadgeButton>{getGiftRarityTitle(lang, model.rarity)}</BadgeButton>
<GiftRarityBadge rarity={model.rarity} />
</span>,
]);
}
@ -856,8 +873,8 @@ const GiftInfoModal = ({
canManage, hasConvertOption, isSender, oldLang, tonExplorerUrl,
gift, giftAttributes, renderFooterButton, isTargetChat,
isGiftUnique, saleDateInfo,
canBuyGift, giftOwnerTitle, resellPrice, giftSubtitle,
releasedByPeer, handleSymbolClick, handleBackdropClick, handleModelClick,
canBuyGift, giftOwnerTitle, resellPrice, uniqueGiftTitle, uniqueGiftSubtitle, releasedByPeer,
handleSymbolClick, handleBackdropClick, handleModelClick,
]);
const moreMenuItems = typeGift && (

View File

@ -1234,19 +1234,20 @@ addActionHandler('openUniqueGiftBySlug', async (global, actions, payload): Promi
slug, tabId = getCurrentTabId(),
} = payload;
const gift = await callApi('fetchUniqueStarGift', { slug });
const result = await callApi('fetchUniqueStarGift', { slug });
if (!gift) {
if (!result || 'error' in result) {
const isBurned = result && 'error' in result && result.errorMessage === 'STARGIFT_ALREADY_BURNED';
actions.showNotification({
message: {
key: 'GiftWasNotFound',
key: isBurned ? 'ActionStarGiftUniqueBurnedError' : 'GiftWasNotFound',
},
tabId,
});
return;
}
actions.openGiftInfoModal({ gift, tabId });
actions.openGiftInfoModal({ gift: result, tabId });
});
addActionHandler('openGiftAuctionBySlug', async (global, actions, payload): Promise<void> => {

View File

@ -209,6 +209,15 @@ $color-message-story-mention-to: #74bcff;
--color-stars: #FFAA00;
--color-heart: #ff3c32;
--color-gift-uncommon: #40A920;
--color-gift-uncommon-bg: rgba(64, 169, 32, 0.15);
--color-gift-rare: #11AABE;
--color-gift-rare-bg: rgba(17, 170, 190, 0.15);
--color-gift-epic: #955CDB;
--color-gift-epic-bg: rgba(149, 92, 219, 0.15);
--color-gift-legendary: #BF7600;
--color-gift-legendary-bg: rgba(191, 118, 0, 0.15);
--color-negative-progress: #CE4C47;
--vh: 1vh;

View File

@ -1521,6 +1521,8 @@ export interface LangPair {
'ActionGiftUniqueSent': undefined;
'ActionStarGiftUpgradedSelf': undefined;
'ActionStarGiftTransferredSelf': undefined;
'ActionStarGiftCraftedSelf': undefined;
'ActionStarGiftCrafted': undefined;
'ActionStarGiftTransferredUnknown': undefined;
'ActionStarGiftNoConvertTextYou': undefined;
'ActionStarGiftDisplaying': undefined;
@ -1529,6 +1531,8 @@ export interface LangPair {
'ActionStarGiftUpgraded': undefined;
'ActionStarGiftUnpack': undefined;
'ActionStarGiftUniqueRibbon': undefined;
'ActionStarGiftUniqueBurnedRibbon': undefined;
'ActionStarGiftUniqueBurnedError': undefined;
'ActionStarGiftUniqueModel': undefined;
'ActionStarGiftUniqueBackdrop': undefined;
'ActionStarGiftUniqueSymbol': undefined;
@ -2377,6 +2381,10 @@ export interface LangPairWithVariables<V = LangVariable> {
'GiftInfoCollectible': {
'number': V;
};
'GiftInfoUniqueTitle': {
'name': V;
'number': V;
};
'GiftSavedNumber': {
'number': V;
};