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

View File

@ -1,4 +1,5 @@
import { Api as GramJs } from '../../../lib/gramjs'; import { Api as GramJs } from '../../../lib/gramjs';
import { RPCError } from '../../../lib/gramjs/errors';
import type { GiftProfileFilterOptions, ResaleGiftsFilterOptions } from '../../../types'; import type { GiftProfileFilterOptions, ResaleGiftsFilterOptions } from '../../../types';
import type { import type {
@ -389,13 +390,22 @@ export async function fetchStarsTopupOptions() {
export async function fetchUniqueStarGift({ slug }: { export async function fetchUniqueStarGift({ slug }: {
slug: string; 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); const gift = buildApiStarGift(result.gift);
if (gift.type !== 'starGiftUnique') return undefined; if (gift.type !== 'starGiftUnique') return undefined;
return gift; return gift;
} catch (err) {
if (err instanceof RPCError) {
return wrapError(err);
}
return undefined;
}
} }
export async function fetchStarGiftUpgradePreview({ export async function fetchStarGiftUpgradePreview({

View File

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

View File

@ -1572,14 +1572,15 @@
"GiftInfoOwner" = "Owner"; "GiftInfoOwner" = "Owner";
"GiftInfoIssued" = "{issued}/{total} issued"; "GiftInfoIssued" = "{issued}/{total} issued";
"GiftInfoCollectible" = "Collectible #{number}"; "GiftInfoCollectible" = "Collectible #{number}";
"GiftInfoUniqueTitle" = "{name} {number}";
"GiftSavedNumber" = "#{number}"; "GiftSavedNumber" = "#{number}";
"GiftAttributeModel" = "Model"; "GiftAttributeModel" = "Model";
"GiftAttributeBackdrop" = "Backdrop"; "GiftAttributeBackdrop" = "Backdrop";
"GiftAttributeSymbol" = "Symbol"; "GiftAttributeSymbol" = "Symbol";
"GiftRarityUncommon" = "Uncommon"; "GiftRarityUncommon" = "uncommon";
"GiftRarityRare" = "Rare"; "GiftRarityRare" = "rare";
"GiftRarityEpic" = "Epic"; "GiftRarityEpic" = "epic";
"GiftRarityLegendary" = "Legendary"; "GiftRarityLegendary" = "legendary";
"GiftInfoPeerOriginalInfo" = "Gifted to {peer} on {date}."; "GiftInfoPeerOriginalInfo" = "Gifted to {peer} on {date}.";
"GiftInfoPeerOriginalInfoSender" = "Gifted by {sender} to {peer} on {date}."; "GiftInfoPeerOriginalInfoSender" = "Gifted by {sender} to {peer} on {date}.";
"GiftInfoPeerOriginalInfoText" = "Gifted to {peer} on {date} with comment \"{text}\"."; "GiftInfoPeerOriginalInfoText" = "Gifted to {peer} on {date} with comment \"{text}\".";
@ -1923,6 +1924,8 @@
"ActionStarGiftTransferredChannelYou" = "You transferred a gift to {channel}"; "ActionStarGiftTransferredChannelYou" = "You transferred a gift to {channel}";
"ActionStarGiftTransferredMine" = "You transferred a gift to {user}"; "ActionStarGiftTransferredMine" = "You transferred a gift to {user}";
"ActionStarGiftTransferredSelf" = "You transferred a unique collectible"; "ActionStarGiftTransferredSelf" = "You transferred a unique collectible";
"ActionStarGiftCraftedSelf" = "You crafted a new gift!";
"ActionStarGiftCrafted" = "Crafted Gift";
"ActionStarGiftTransferredUnknown" = "Someone transferred you a gift"; "ActionStarGiftTransferredUnknown" = "Someone transferred you a gift";
"ActionStarGiftTransferredUnknownChannel" = "Someone transferred a gift to {channel}"; "ActionStarGiftTransferredUnknownChannel" = "Someone transferred a gift to {channel}";
"ActionStarGiftSoldFromOffer" = "You sold {gift} to {user} for {cost}"; "ActionStarGiftSoldFromOffer" = "You sold {gift} to {user} for {cost}";
@ -1950,6 +1953,8 @@
"ActionStarGiftUnpack" = "Unpack"; "ActionStarGiftUnpack" = "Unpack";
"ActionStarGiftLimitedRibbon" = "1 of {total}"; "ActionStarGiftLimitedRibbon" = "1 of {total}";
"ActionStarGiftUniqueRibbon" = "gift"; "ActionStarGiftUniqueRibbon" = "gift";
"ActionStarGiftUniqueBurnedRibbon" = "burned";
"ActionStarGiftUniqueBurnedError" = "Sorry, this gift has already been burned.";
"ActionStarGiftUniqueModel" = "Model"; "ActionStarGiftUniqueModel" = "Model";
"ActionStarGiftUniqueBackdrop" = "Backdrop"; "ActionStarGiftUniqueBackdrop" = "Backdrop";
"ActionStarGiftUniqueSymbol" = "Symbol"; "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, focusMessage,
openGiftOfferAcceptModal, openGiftOfferAcceptModal,
declineStarGiftOffer, declineStarGiftOffer,
showNotification,
} = getActions(); } = getActions();
const ref = useRef<HTMLDivElement>(); const ref = useRef<HTMLDivElement>();
@ -371,8 +372,19 @@ const ActionMessage = ({
break; break;
} }
case 'starGift': case 'starGift': {
openGiftInfoModalFromMessage({
chatId: message.chatId,
messageId: message.id,
});
break;
}
case 'starGiftUnique': { case 'starGiftUnique': {
if (action.gift.isBurned) {
showNotification({ message: lang('ActionStarGiftUniqueBurnedError') });
break;
}
openGiftInfoModalFromMessage({ openGiftInfoModalFromMessage({
chatId: message.chatId, chatId: message.chatId,
messageId: message.id, messageId: message.id,

View File

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

View File

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

View File

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

View File

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

View File

@ -38,6 +38,7 @@ import Avatar from '../../../common/Avatar';
import BadgeButton from '../../../common/BadgeButton'; import BadgeButton from '../../../common/BadgeButton';
import GiftMenuItems from '../../../common/gift/GiftMenuItems'; import GiftMenuItems from '../../../common/gift/GiftMenuItems';
import GiftTransferPreview from '../../../common/gift/GiftTransferPreview'; import GiftTransferPreview from '../../../common/gift/GiftTransferPreview';
import GiftRarityBadge from '../../../common/GiftRarityBadge';
import Icon from '../../../common/icons/Icon'; import Icon from '../../../common/icons/Icon';
import SafeLink from '../../../common/SafeLink'; import SafeLink from '../../../common/SafeLink';
import Button from '../../../ui/Button'; import Button from '../../../ui/Button';
@ -182,26 +183,6 @@ const GiftInfoModal = ({
const isGiftUnique = gift && gift.type === 'starGiftUnique'; const isGiftUnique = gift && gift.type === 'starGiftUnique';
const uniqueGift = isGiftUnique ? gift : undefined; 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 starGiftUniqueSlug = gift?.type === 'starGiftUnique' ? gift.slug : undefined;
const selfCollectibleStatus = useMemo(() => { const selfCollectibleStatus = useMemo(() => {
@ -322,6 +303,42 @@ const GiftInfoModal = ({
return gift && getGiftAttributes(gift); return gift && getGiftAttributes(gift);
}, [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(() => { const renderFooterButton = useLastCallback(() => {
if (canBuyGift) { if (canBuyGift) {
return ( return (
@ -551,8 +568,8 @@ const GiftInfoModal = ({
backdropAttribute={giftAttributes!.backdrop!} backdropAttribute={giftAttributes!.backdrop!}
patternAttribute={giftAttributes!.pattern!} patternAttribute={giftAttributes!.pattern!}
modelAttribute={giftAttributes!.model!} modelAttribute={giftAttributes!.model!}
title={gift.title} title={uniqueGiftTitle}
subtitle={giftSubtitle} subtitle={uniqueGiftSubtitle}
subtitlePeer={releasedByPeer} subtitlePeer={releasedByPeer}
showManageButtons={canManage} showManageButtons={canManage}
savedGift={savedGift} savedGift={savedGift}
@ -699,7 +716,7 @@ const GiftInfoModal = ({
> >
{model.name} {model.name}
</span> </span>
<BadgeButton>{getGiftRarityTitle(lang, model.rarity)}</BadgeButton> <GiftRarityBadge rarity={model.rarity} />
</span>, </span>,
]); ]);
} }
@ -856,8 +873,8 @@ const GiftInfoModal = ({
canManage, hasConvertOption, isSender, oldLang, tonExplorerUrl, canManage, hasConvertOption, isSender, oldLang, tonExplorerUrl,
gift, giftAttributes, renderFooterButton, isTargetChat, gift, giftAttributes, renderFooterButton, isTargetChat,
isGiftUnique, saleDateInfo, isGiftUnique, saleDateInfo,
canBuyGift, giftOwnerTitle, resellPrice, giftSubtitle, canBuyGift, giftOwnerTitle, resellPrice, uniqueGiftTitle, uniqueGiftSubtitle, releasedByPeer,
releasedByPeer, handleSymbolClick, handleBackdropClick, handleModelClick, handleSymbolClick, handleBackdropClick, handleModelClick,
]); ]);
const moreMenuItems = typeGift && ( const moreMenuItems = typeGift && (

View File

@ -1234,19 +1234,20 @@ addActionHandler('openUniqueGiftBySlug', async (global, actions, payload): Promi
slug, tabId = getCurrentTabId(), slug, tabId = getCurrentTabId(),
} = payload; } = 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({ actions.showNotification({
message: { message: {
key: 'GiftWasNotFound', key: isBurned ? 'ActionStarGiftUniqueBurnedError' : 'GiftWasNotFound',
}, },
tabId, tabId,
}); });
return; return;
} }
actions.openGiftInfoModal({ gift, tabId }); actions.openGiftInfoModal({ gift: result, tabId });
}); });
addActionHandler('openGiftAuctionBySlug', async (global, actions, payload): Promise<void> => { addActionHandler('openGiftAuctionBySlug', async (global, actions, payload): Promise<void> => {

View File

@ -209,6 +209,15 @@ $color-message-story-mention-to: #74bcff;
--color-stars: #FFAA00; --color-stars: #FFAA00;
--color-heart: #ff3c32; --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; --color-negative-progress: #CE4C47;
--vh: 1vh; --vh: 1vh;

View File

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