Support unique gifts (#5394)

This commit is contained in:
zubiden 2025-01-05 20:18:43 +01:00 committed by Alexander Zinchuk
parent 9e6aa47013
commit 180aef57bf
34 changed files with 937 additions and 259 deletions

View File

@ -374,8 +374,7 @@ function buildApiMessageActionStarGift(action: GramJs.MessageActionStarGift) : A
isNameHidden: Boolean(nameHidden),
isSaved: Boolean(saved),
isConverted: Boolean(converted),
// ToDo: Use `!` temporarily to support layer 196
gift: buildApiStarGift(gift)!,
gift: buildApiStarGift(gift),
message: message && buildApiFormattedText(message),
starsToConvert: convertStars?.toJSNumber(),
};

View File

@ -20,6 +20,7 @@ import type {
ApiPrepaidStarsGiveaway,
ApiReceipt,
ApiStarGift,
ApiStarGiftAttribute,
ApiStarGiveawayOption,
ApiStarsAmount,
ApiStarsGiveawayWinnerOption,
@ -31,13 +32,15 @@ import type {
BoughtPaidMedia,
} from '../../types';
import { addWebDocumentToLocalDb } from '../helpers';
import { numberToHexColor } from '../../../util/colors';
import { addDocumentToLocalDb, addWebDocumentToLocalDb } from '../helpers';
import { buildApiStarsSubscriptionPricing } from './chats';
import { buildApiFormattedText, buildApiMessageEntity } from './common';
import { omitVirtualClassFields } from './helpers';
import { buildApiDocument, buildApiWebDocument, buildMessageMediaContent } from './messageContent';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers';
import { buildStatisticsPercentage } from './statistics';
import { buildStickerFromDocument } from './symbols';
export function buildShippingOptions(shippingOptions: GramJs.ShippingOption[] | undefined) {
if (!shippingOptions) {
@ -536,7 +539,7 @@ export function buildApiStarsTransactionPeer(peer: GramJs.TypeStarsTransactionPe
export function buildApiStarsTransaction(transaction: GramJs.StarsTransaction): ApiStarsTransaction {
const {
date, id, peer, stars, description, photo, title, refund, extendedMedia, failed, msgId, pending, gift, reaction,
subscriptionPeriod, stargift, giveawayPostId, starrefCommissionPermille,
subscriptionPeriod, stargift, giveawayPostId, starrefCommissionPermille, stargiftUpgrade,
} = transaction;
if (photo) {
@ -547,7 +550,6 @@ export function buildApiStarsTransaction(transaction: GramJs.StarsTransaction):
.filter(Boolean) as BoughtPaidMedia[];
const starRefCommision = starrefCommissionPermille ? starrefCommissionPermille / 10 : undefined;
const supportedStarGift = (stargift instanceof GramJs.StarGift) ? stargift : undefined;
return {
id,
@ -565,9 +567,10 @@ export function buildApiStarsTransaction(transaction: GramJs.StarsTransaction):
extendedMedia: boughtExtendedMedia,
subscriptionPeriod,
isReaction: reaction,
starGift: supportedStarGift && buildApiStarGift(supportedStarGift),
starGift: stargift && buildApiStarGift(stargift),
giveawayPostId,
starRefCommision,
isGiftUpgrade: stargiftUpgrade,
};
}
@ -610,18 +613,38 @@ export function buildApiStarTopupOption(option: GramJs.TypeStarsTopupOption): Ap
};
}
export function buildApiStarGift(startGift: GramJs.TypeStarGift): ApiStarGift | undefined {
const isTypeSupported = startGift instanceof GramJs.StarGift;
if (!isTypeSupported) return undefined;
export function buildApiStarGift(starGift: GramJs.TypeStarGift): ApiStarGift {
if (starGift instanceof GramJs.StarGiftUnique) {
const {
id, num, ownerId, title, attributes, availabilityIssued, availabilityTotal,
} = starGift;
return {
type: 'starGiftUnique',
id: id.toString(),
number: num,
ownerId: buildApiPeerId(ownerId, 'user'),
attributes: attributes.map(buildApiStarGiftAttribute).filter(Boolean),
title,
totalCount: availabilityTotal,
issuedCount: availabilityIssued,
};
}
const {
id, limited, sticker, stars, availabilityRemains, availabilityTotal, convertStars, firstSaleDate, lastSaleDate,
soldOut,
} = startGift;
id, limited, stars, availabilityRemains, availabilityTotal, convertStars, firstSaleDate, lastSaleDate, soldOut,
birthday, upgradeStars,
} = starGift;
addDocumentToLocalDb(starGift.sticker);
const sticker = buildStickerFromDocument(starGift.sticker)!;
return {
type: 'starGift',
id: id.toString(),
isLimited: limited,
stickerId: sticker.id.toString(),
sticker,
stars: stars.toJSNumber(),
availabilityRemains,
availabilityTotal,
@ -629,17 +652,84 @@ export function buildApiStarGift(startGift: GramJs.TypeStarGift): ApiStarGift |
firstSaleDate,
lastSaleDate,
isSoldOut: soldOut,
isBirthday: birthday,
upgradeStars: upgradeStars?.toJSNumber(),
};
}
export function buildApiStarGiftAttribute(attribute: GramJs.TypeStarGiftAttribute): ApiStarGiftAttribute | undefined {
if (attribute instanceof GramJs.StarGiftAttributeModel) {
const sticker = buildStickerFromDocument(attribute.document);
if (!sticker) {
return undefined;
}
addDocumentToLocalDb(attribute.document);
return {
type: 'model',
name: attribute.name,
rarityPercent: attribute.rarityPermille / 10,
sticker,
};
}
if (attribute instanceof GramJs.StarGiftAttributePattern) {
const sticker = buildStickerFromDocument(attribute.document);
if (!sticker) {
return undefined;
}
addDocumentToLocalDb(attribute.document);
return {
type: 'pattern',
name: attribute.name,
rarityPercent: attribute.rarityPermille / 10,
sticker,
};
}
if (attribute instanceof GramJs.StarGiftAttributeBackdrop) {
const {
name, rarityPermille, centerColor, edgeColor, patternColor, textColor,
} = attribute;
return {
type: 'backdrop',
name,
rarityPercent: rarityPermille / 10,
centerColor: numberToHexColor(centerColor),
edgeColor: numberToHexColor(edgeColor),
patternColor: numberToHexColor(patternColor),
textColor: numberToHexColor(textColor),
};
}
if (attribute instanceof GramJs.StarGiftAttributeOriginalDetails) {
const {
date, recipientId, message, senderId,
} = attribute;
return {
type: 'originalDetails',
date,
recipientId: recipientId && buildApiPeerId(recipientId, 'user'),
message: message && buildApiFormattedText(message),
senderId: senderId && buildApiPeerId(senderId, 'user'),
};
}
return undefined;
}
export function buildApiUserStarGift(userStarGift: GramJs.UserStarGift): ApiUserStarGift {
const {
gift, date, convertStars, fromId, message, msgId, nameHidden, unsaved,
} = userStarGift;
return {
// ToDo: Use `!` temporarily to support layer 196
gift: buildApiStarGift(gift)!,
gift: buildApiStarGift(gift),
date,
starsToConvert: convertStars?.toJSNumber(),
fromId: fromId && buildApiPeerId(fromId, 'user'),

View File

@ -2,8 +2,12 @@ import BigInt from 'big-integer';
import { Api as GramJs } from '../../../lib/gramjs';
import type {
ApiChat, ApiInputStorePaymentPurpose, ApiPeer, ApiRequestInputInvoice,
ApiSticker, ApiThemeParameters,
ApiChat,
ApiInputStorePaymentPurpose,
ApiPeer,
ApiRequestInputInvoice,
ApiStarGiftRegular,
ApiThemeParameters,
ApiUser,
} from '../../types';
@ -29,7 +33,6 @@ import {
buildShippingOptions,
} from '../apiBuilders/payments';
import { buildApiPeerId } from '../apiBuilders/peers';
import { buildStickerFromDocument } from '../apiBuilders/symbols';
import {
buildInputInvoice, buildInputPeer, buildInputStorePaymentPurpose, buildInputThemeParams, buildShippingInfo,
} from '../gramjsBuilders';
@ -430,22 +433,8 @@ export async function fetchStarGifts() {
return undefined;
}
const gifts = result.gifts.map(buildApiStarGift).filter(Boolean);
const stickers : Record<string, ApiSticker> = {};
result.gifts.forEach((gift) => {
if (!(gift instanceof GramJs.StarGift)) return;
if (gift.sticker instanceof GramJs.Document) {
localDb.documents[String(gift.sticker.id)] = gift.sticker;
}
const sticker = buildStickerFromDocument(gift.sticker);
if (sticker) {
stickers[sticker.id] = sticker;
}
});
return { gifts, stickers };
// Right now, only regular star gifts can be bought, but API are not specific
return result.gifts.map(buildApiStarGift).filter((gift): gift is ApiStarGiftRegular => gift.type === 'starGift');
}
export async function fetchUserStarGifts({
@ -467,12 +456,10 @@ export async function fetchUserStarGifts({
return undefined;
}
const supportedGifts = result.gifts.filter(
((gift) => gift instanceof GramJs.StarGift),
).map(buildApiUserStarGift);
const gifts = result.gifts.map(buildApiUserStarGift);
return {
gifts: supportedGifts,
gifts,
nextOffset: result.nextOffset,
};
}
@ -528,13 +515,10 @@ export async function fetchStarsStatus() {
if (!result) {
return undefined;
}
const supportedHistory = result.history?.filter(
(transaction) => !(transaction.stargift instanceof GramJs.StarGiftUnique),
);
return {
nextHistoryOffset: result.nextOffset,
history: supportedHistory?.map(buildApiStarsTransaction),
history: result.history?.map(buildApiStarsTransaction),
nextSubscriptionOffset: result.subscriptionsNextOffset,
subscriptions: result.subscriptions?.map(buildApiStarsSubscription),
balance: buildApiStarsAmount(result.balance),
@ -564,13 +548,9 @@ export async function fetchStarsTransactions({
return undefined;
}
const supportedHistory = result.history?.filter(
(transaction) => !(transaction.stargift instanceof GramJs.StarGiftUnique),
);
return {
nextOffset: result.nextOffset,
history: supportedHistory?.map(buildApiStarsTransaction),
history: result.history?.map(buildApiStarsTransaction),
balance: buildApiStarsAmount(result.balance),
};
}
@ -589,15 +569,12 @@ export async function fetchStarsTransactionById({
})],
}));
const supportedHistory = result?.history?.filter(
(transaction) => !(transaction.stargift instanceof GramJs.StarGiftUnique),
);
if (!supportedHistory?.[0]) {
if (!result?.history?.[0]) {
return undefined;
}
return {
transaction: buildApiStarsTransaction(supportedHistory[0]),
transaction: buildApiStarsTransaction(result?.history[0]),
};
}

View File

@ -7,6 +7,7 @@ import type {
ApiInvoice,
ApiMessageEntity,
ApiPaymentCredentials,
ApiSticker,
BoughtPaidMedia,
} from './messages';
import type { StatisticsOverviewPercentage } from './statistics';
@ -189,10 +190,11 @@ export type ApiInputStorePaymentStarsGiveaway = {
export type ApiInputStorePaymentPurpose = ApiInputStorePaymentGiveaway | ApiInputStorePaymentGiftcode |
ApiInputStorePaymentStarsTopup | ApiInputStorePaymentStarsGift | ApiInputStorePaymentStarsGiveaway;
export type ApiStarGift = {
export interface ApiStarGiftRegular {
type: 'starGift';
isLimited?: true;
id: string;
stickerId: string;
sticker: ApiSticker;
stars: number;
availabilityRemains?: number;
availabilityTotal?: number;
@ -200,7 +202,57 @@ export type ApiStarGift = {
isSoldOut?: true;
firstSaleDate?: number;
lastSaleDate?: number;
};
isBirthday?: true;
upgradeStars?: number;
}
export interface ApiStarGiftUnique {
type: 'starGiftUnique';
id: string;
title: string;
number: number;
ownerId: string;
issuedCount: number;
totalCount: number;
attributes: ApiStarGiftAttribute[];
}
export type ApiStarGift = ApiStarGiftRegular | ApiStarGiftUnique;
export interface ApiStarGiftAttributeModel {
type: 'model';
name: string;
rarityPercent: number;
sticker: ApiSticker;
}
export interface ApiStarGiftAttributePattern {
type: 'pattern';
name: string;
rarityPercent: number;
sticker: ApiSticker;
}
export interface ApiStarGiftAttributeBackdrop {
type: 'backdrop';
name: string;
centerColor: string;
edgeColor: string;
patternColor: string;
textColor: string;
rarityPercent: number;
}
export interface ApiStarGiftAttributeOriginalDetails {
type: 'originalDetails';
senderId?: string;
recipientId: string;
date: number;
message?: ApiFormattedText;
}
export type ApiStarGiftAttribute = ApiStarGiftAttributeModel | ApiStarGiftAttributePattern
| ApiStarGiftAttributeBackdrop | ApiStarGiftAttributeOriginalDetails;
export interface ApiUserStarGift {
isNameHidden?: boolean;
@ -371,6 +423,7 @@ export interface ApiStarsTransaction {
extendedMedia?: BoughtPaidMedia[];
subscriptionPeriod?: number;
starRefCommision?: number;
isGiftUpgrade?: true;
}
export interface ApiStarsSubscription {

View File

@ -1298,7 +1298,9 @@
"GiftPremiumDescriptionLinkCaption" = "See Features >";
"GiftPremiumDescriptionLink" = "https://telegram.org/faq_premium";
"StarsGiftHeader" = "Send a Gift";
"StarsGiftHeaderSelf" = "Buy a Gift";
"StarGiftDescription" = "Give {user} gifts that can be kept on the profile or converted to Stars.";
"StarGiftDescriptionSelf" = "Buy yourself a gift to display on your page or reserve for later.\n\nLimited-edition gifts upgraded to collectibles can be gifted to others later.";
"GiftLimited" = "limited";
"GiftDiscount" = "-{percent}%";
"GiftSoldCount" = "{count} sold";
@ -1342,11 +1344,25 @@
"GiftInfoSoldOutTitle" = "Unavailable";
"GiftInfoSoldOutDescription" = "This gift has been sold out";
"GiftInfoSenderHidden" = "Only you can see the sender's name and message.";
"GiftInfoOwner" = "Owner";
"GiftInfoAvailability" = "Availability";
"GiftInfoIssued" = "{issued}/{total} issued";
"GiftInfoCollectible" = "Collectible #{number}";
"GiftAttributeModel" = "Model";
"GiftAttributeBackdrop" = "Backdrop";
"GiftAttributeSymbol" = "Symbol";
"GiftInfoOriginalInfo" = "Gifted to {user} on {date}."
"GiftInfoOriginalInfoSender" = "Gifted by {sender} to {user} on {date}."
"GiftInfoOriginalInfoText" = "Gifted to {user} on {date} with comment \"{text}\"."
"GiftInfoOriginalInfoTextSender" = "Gifted by {sender} to {user} on {date} with comment \"{text}\"."
"GiftInfoStatus" = "Status";
"GiftInfoStatusNonUnique" = "Non-Unique";
"StarsAmount" = "⭐️{amount}";
"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}";

View File

@ -6,9 +6,12 @@
background-color: var(--accent-background-active-color);
color: var(--accent-color);
cursor: var(--custom-cursor, pointer);
filter: brightness(1);
transition: 150ms filter ease-in;
}
.clickable {
cursor: var(--custom-cursor, pointer);
&:hover {
filter: brightness(1.1);

View File

@ -16,7 +16,7 @@ const BadgeButton = ({
onClick,
}: OwnProps) => {
return (
<div className={buildClassName(styles.root, className)} onClick={onClick}>
<div className={buildClassName(styles.root, onClick && styles.clickable, className)} onClick={onClick}>
{children}
</div>
);

View File

@ -35,6 +35,7 @@ type OwnProps<T = undefined> = {
className?: string;
fluid?: boolean;
withPeerColors?: boolean;
withEmojiStatus?: boolean;
clickArg?: T;
onClick?: (arg: T) => void;
};
@ -59,6 +60,7 @@ const PeerChip = <T,>({
fluid,
isSavedMessages,
withPeerColors,
withEmojiStatus,
onClick,
}: OwnProps<T> & StateProps) => {
const lang = useOldLang();
@ -91,7 +93,9 @@ const PeerChip = <T,>({
);
titleText = getPeerTitle(lang, anyPeer) || title;
titleElement = title || <FullNameTitle peer={anyPeer} isSavedMessages={isSavedMessages} withEmojiStatus />;
titleElement = title || (
<FullNameTitle peer={anyPeer} isSavedMessages={isSavedMessages} withEmojiStatus={withEmojiStatus} />
);
}
const fullClassName = buildClassName(

View File

@ -12,7 +12,7 @@
left: 50%;
transform: translate(-50%, -50%) translate(6px, -6px) rotate(45deg);
font-size: 0.6875rem;
font-size: 0.625rem;
font-weight: var(--font-weight-semibold);
color: var(--color-white);
white-space: nowrap;

View File

@ -21,6 +21,7 @@
border-radius: 0.625rem;
background-color: var(--color-hover-overlay);
pointer-events: none;
z-index: 1;
}
&:hover::before {
@ -34,15 +35,6 @@
left: 0.25rem;
}
.stars {
display: flex;
align-items: center;
gap: 0.125rem;
color: #E88011;
font-weight: var(--font-weight-medium);
}
.hiddenGift {
display: grid;
place-items: center;
@ -60,3 +52,8 @@
font-size: 1.25rem;
backdrop-filter: blur(0.5rem);
}
.radialPattern {
position: absolute;
inset: 0;
}

View File

@ -1,20 +1,22 @@
import React, { memo } from '../../../lib/teact/teact';
import React, { memo, useMemo, useRef } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiSticker, ApiUser, ApiUserStarGift } from '../../../api/types';
import type { ApiUser, ApiUserStarGift } from '../../../api/types';
import { STARS_CURRENCY_CODE } from '../../../config';
import { selectUser } from '../../../global/selectors';
import { formatCurrency } from '../../../util/formatCurrency';
import { CUSTOM_PEER_HIDDEN } from '../../../util/objects/customPeer';
import { formatIntegerCompact } from '../../../util/textFormat';
import { getGiftAttributes, getStickerFromGift, getTotalGiftAvailability } from '../helpers/gifts';
import useFlag from '../../../hooks/useFlag';
import { type ObserveFn, useOnIntersect } from '../../../hooks/useIntersectionObserver';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
import AnimatedIconFromSticker from '../AnimatedIconFromSticker';
import Avatar from '../Avatar';
import Icon from '../icons/Icon';
import RadialPatternBackground from '../profile/RadialPatternBackground';
import GiftRibbon from './GiftRibbon';
import styles from './UserGift.module.scss';
@ -22,20 +24,28 @@ import styles from './UserGift.module.scss';
type OwnProps = {
userId: string;
gift: ApiUserStarGift;
observeIntersection?: ObserveFn;
};
type StateProps = {
fromPeer?: ApiUser;
sticker?: ApiSticker;
};
const GIFT_STICKER_SIZE = 90;
const UserGift = ({
userId, gift, fromPeer, sticker,
userId,
gift,
fromPeer,
observeIntersection,
}: OwnProps & StateProps) => {
const { openGiftInfoModal } = getActions();
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
const [shouldPlay, play] = useFlag();
const oldLang = useOldLang();
const handleClick = useLastCallback(() => {
@ -45,16 +55,48 @@ const UserGift = ({
});
});
const handleOnIntersect = useLastCallback((entry: IntersectionObserverEntry) => {
if (entry.isIntersecting) play();
});
const avatarPeer = (gift.isNameHidden || !fromPeer) ? CUSTOM_PEER_HIDDEN : fromPeer;
const sticker = getStickerFromGift(gift.gift);
const radialPatternBackdrop = useMemo(() => {
const { backdrop, pattern } = getGiftAttributes(gift.gift) || {};
if (!backdrop || !pattern) {
return undefined;
}
const backdropColors = [backdrop.centerColor, backdrop.edgeColor];
const patternColor = backdrop.patternColor;
return (
<RadialPatternBackground
className={styles.radialPattern}
backgroundColors={backdropColors}
patternColor={patternColor}
patternIcon={pattern.sticker}
/>
);
}, [gift.gift]);
useOnIntersect(ref, observeIntersection, sticker ? handleOnIntersect : undefined);
if (!sticker) return undefined;
const totalIssued = getTotalGiftAvailability(gift.gift);
return (
<div className={styles.root} onClick={handleClick}>
<div ref={ref} className={styles.root} onClick={handleClick}>
{radialPatternBackdrop}
<Avatar className={styles.avatar} peer={avatarPeer} size="micro" />
<AnimatedIconFromSticker
sticker={sticker}
noLoop
play={shouldPlay}
nonInteractive
size={GIFT_STICKER_SIZE}
/>
@ -63,13 +105,10 @@ const UserGift = ({
<Icon name="eye-closed-outline" />
</div>
)}
<div className={styles.stars}>
{formatCurrency(gift.gift.stars, STARS_CURRENCY_CODE)}
</div>
{gift.gift.availabilityTotal && (
{totalIssued && (
<GiftRibbon
color="blue"
text={oldLang('Gift2Limited1OfRibbon', formatIntegerCompact(gift.gift.availabilityTotal))}
text={oldLang('Gift2Limited1OfRibbon', formatIntegerCompact(totalIssued))}
/>
)}
</div>
@ -78,11 +117,9 @@ const UserGift = ({
export default memo(withGlobal<OwnProps>(
(global, { gift }): StateProps => {
const sticker = global.stickers.starGifts.stickers[gift.gift.stickerId];
const fromPeer = gift.fromId ? selectUser(global, gift.fromId) : undefined;
return {
sticker,
fromPeer,
};
},

View File

@ -0,0 +1,56 @@
import type {
ApiFormattedText,
ApiStarGift,
ApiStarGiftAttributeBackdrop,
ApiStarGiftAttributeModel,
ApiStarGiftAttributeOriginalDetails,
ApiStarGiftAttributePattern,
ApiSticker,
} from '../../../api/types';
export type GiftAttributes = {
model?: ApiStarGiftAttributeModel;
originalDetails?: ApiStarGiftAttributeOriginalDetails;
pattern?: ApiStarGiftAttributePattern;
backdrop?: ApiStarGiftAttributeBackdrop;
};
export function getStickerFromGift(gift: ApiStarGift): ApiSticker | undefined {
if (gift.type === 'starGift') {
return gift.sticker;
}
return gift.attributes.find((attr): attr is ApiStarGiftAttributeModel => attr.type === 'model')?.sticker;
}
export function getTotalGiftAvailability(gift: ApiStarGift): number | undefined {
if (gift.type === 'starGift') {
return gift.availabilityTotal;
}
return gift.totalCount;
}
export function getGiftMessage(gift: ApiStarGift): ApiFormattedText | undefined {
if (gift.type !== 'starGiftUnique') return undefined;
return gift.attributes.find((attr): attr is ApiStarGiftAttributeOriginalDetails => attr.type === 'model')?.message;
}
export function getGiftAttributes(gift: ApiStarGift): GiftAttributes | undefined {
if (gift.type !== 'starGiftUnique') return undefined;
const model = gift.attributes.find((attr): attr is ApiStarGiftAttributeModel => attr.type === 'model');
const backdrop = gift.attributes.find((attr): attr is ApiStarGiftAttributeBackdrop => attr.type === 'backdrop');
const pattern = gift.attributes.find((attr): attr is ApiStarGiftAttributePattern => attr.type === 'pattern');
const originalDetails = gift.attributes.find((attr): attr is ApiStarGiftAttributeOriginalDetails => (
attr.type === 'originalDetails'
));
return {
model,
originalDetails,
pattern,
backdrop,
};
}

View File

@ -0,0 +1,18 @@
.root {
border-radius: inherit;
overflow: hidden;
&::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(var(--_bg-1), var(--_bg-2)), radial-gradient(circle, #ffffff32, #ffffff00);
}
}
.canvas {
width: 100%;
height: 100%;
object-fit: cover;
position: relative;
}

View File

@ -0,0 +1,167 @@
import React, {
memo, useEffect, useRef, useSignal, useState,
} from '../../../lib/teact/teact';
import type { ApiSticker } from '../../../api/types';
import { requestMutation } from '../../../lib/fasterdom/fasterdom';
import { getStickerMediaHash } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName';
import buildStyle from '../../../util/buildStyle';
import { preloadImage } from '../../../util/files';
import useLastCallback from '../../../hooks/useLastCallback';
import useMedia from '../../../hooks/useMedia';
import useResizeObserver from '../../../hooks/useResizeObserver';
import { useSignalEffect } from '../../../hooks/useSignalEffect';
import styles from './RadialPatternBackground.module.scss';
type OwnProps = {
backgroundColors: string[];
patternColor: string;
patternIcon: ApiSticker;
className?: string;
};
const RINGS = 3;
const BASE_RING_ITEM_COUNT = 8;
const RING_INCREMENT = 0.5;
const CENTER_EMPTINESS = 0.05;
const MAX_RADIUS = 0.5;
const BASE_ICON_SIZE = 20;
const MIN_SIZE = 200;
const PATTERN_POSITIONS = (() => {
const coordinates: { x: number; y: number; alpha: number; sizeFactor: number }[] = [];
for (let ring = 1; ring <= RINGS; ring++) {
const ringItemCount = Math.floor(BASE_RING_ITEM_COUNT * (1 + (ring - 1) * RING_INCREMENT));
const ringProgress = ring / RINGS;
const ringRadius = CENTER_EMPTINESS + (MAX_RADIUS - CENTER_EMPTINESS) * ringProgress;
for (let i = 0; i < ringItemCount; i++) {
const angle = (i / ringItemCount) * Math.PI * 2;
// Slightly oval
const xOffset = ringRadius * 1.71 * Math.cos(angle);
const yOffset = ringRadius * Math.sin(angle);
const x = 0.5 + xOffset;
const y = 0.5 + yOffset;
const alpha = 0.2 + Math.min((1 - ringProgress + (Math.random() / 2 - 0.5)), 0) * 0.8;
const sizeFactor = 1.4 - ringProgress * Math.random();
coordinates.push({
x, y, alpha, sizeFactor,
});
}
}
return coordinates;
})();
const RadialPatternBackground = ({
backgroundColors,
patternColor,
patternIcon,
className,
}: OwnProps) => {
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const canvasRef = useRef<HTMLCanvasElement>(null);
const [getContainerSize, setContainerSize] = useSignal({ width: 0, height: 0 });
const [emojiImage, setEmojiImage] = useState<HTMLImageElement | undefined>();
const previewMediaHash = getStickerMediaHash(patternIcon, 'preview');
const previewUrl = useMedia(previewMediaHash);
useEffect(() => {
if (!previewUrl) return;
preloadImage(previewUrl).then(setEmojiImage);
}, [previewUrl]);
useResizeObserver(containerRef, (entry) => {
setContainerSize({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
});
useEffect(() => {
const container = containerRef.current;
if (container) {
setContainerSize({
width: container.clientWidth,
height: container.clientHeight,
});
}
}, [setContainerSize]);
const draw = useLastCallback(() => {
const canvas = canvasRef.current;
if (!canvas || !emojiImage) return;
const ctx = canvas.getContext('2d')!;
const { width, height } = canvas;
if (!width || !height) return;
ctx.save();
PATTERN_POSITIONS.forEach(({
x, y, alpha, sizeFactor,
}) => {
const centerShift = (width - Math.max(width, MIN_SIZE)) / 2; // Shift coords if canvas is smaller than `MIN_SIZE`
const renderX = x * Math.max(width, MIN_SIZE) + centerShift;
const renderY = y * Math.max(height, MIN_SIZE) + centerShift;
const size = BASE_ICON_SIZE * sizeFactor * (centerShift ? 0.8 : 1);
ctx.globalAlpha = alpha;
ctx.drawImage(emojiImage, renderX - size / 2, renderY - size / 2, size, size);
});
ctx.restore();
ctx.save();
ctx.fillStyle = patternColor;
ctx.globalCompositeOperation = 'source-atop';
ctx.fillRect(0, 0, width, height);
ctx.restore();
});
useEffect(() => {
draw();
}, [emojiImage]);
useSignalEffect(() => {
const { width, height } = getContainerSize();
const canvas = canvasRef.current!;
if (!width || !height) {
return;
}
const maxSide = Math.max(width, height);
const dpr = window.devicePixelRatio;
requestMutation(() => {
canvas.width = maxSide * dpr;
canvas.height = maxSide * dpr;
draw();
});
}, [getContainerSize]);
return (
<div
ref={containerRef}
className={buildClassName(styles.root, className)}
style={buildStyle(
`--_bg-1: ${backgroundColors[0]}`,
`--_bg-2: ${backgroundColors[1] || backgroundColors[0]}`,
)}
>
<canvas className={styles.canvas} ref={canvasRef} />
</div>
);
};
export default memo(RadialPatternBackground);

View File

@ -21,7 +21,6 @@ import {
selectGiftStickerForStars,
selectIsCurrentUserPremium,
selectIsMessageFocused,
selectStarGiftSticker,
selectTabState,
selectTheme,
selectTopicFromMessage,
@ -81,7 +80,6 @@ type StateProps = {
focusDirection?: FocusDirection;
noFocusHighlight?: boolean;
premiumGiftSticker?: ApiSticker;
starGiftSticker?: ApiSticker;
starsGiftSticker?: ApiSticker;
canPlayAnimatedEmojis?: boolean;
patternColor?: string;
@ -108,7 +106,6 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
focusDirection,
noFocusHighlight,
premiumGiftSticker,
starGiftSticker,
starsGiftSticker,
isInsideTopic,
topic,
@ -471,7 +468,7 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
function renderStarGift() {
const starGift = message.content.action?.starGift;
if (!starGift) return undefined;
if (!starGift || starGift.gift.type === 'starGiftUnique') return undefined;
return (
<span
@ -482,7 +479,7 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
>
<AnimatedIconFromSticker
sticker={starGiftSticker}
sticker={starGift.gift.sticker}
play={canPlayAnimatedEmojis}
noLoop
nonInteractive
@ -522,7 +519,7 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
>
<AnimatedIconFromSticker
key={message.id}
sticker={starGiftSticker}
sticker={starsGiftSticker}
play={canPlayAnimatedEmojis}
noLoop
nonInteractive
@ -642,9 +639,7 @@ export default memo(withGlobal<OwnProps>(
const giftDuration = content.action?.months;
const premiumGiftSticker = selectGiftStickerForDuration(global, giftDuration);
const starGift = content.action?.type === 'starGift' ? content.action.starGift?.gift : undefined;
const starCount = content.action?.stars;
const starGiftSticker = starGift?.stickerId ? selectStarGiftSticker(global, starGift.stickerId) : undefined;
const starsGiftSticker = selectGiftStickerForStars(global, starCount);
const topic = selectTopicFromMessage(global, message);
@ -658,7 +653,6 @@ export default memo(withGlobal<OwnProps>(
targetMessage,
isFocused,
premiumGiftSticker,
starGiftSticker,
starsGiftSticker,
topic,
patternColor,

View File

@ -15,7 +15,7 @@ import Modal from '../../ui/Modal';
import styles from './TableInfoModal.module.scss';
type ChatItem = { chatId: string };
type ChatItem = { chatId: string; withEmojiStatus?: boolean };
export type TableData = [TeactNode | undefined, TeactNode | ChatItem][];
@ -76,6 +76,7 @@ const TableInfoModal = ({
className={styles.chatItem}
forceShowSelf
fluid
withEmojiStatus={value.withEmojiStatus}
clickArg={value.chatId}
onClick={handleOpenChat}
/>

View File

@ -1,13 +1,14 @@
import React, { memo } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import React, { memo, useRef } from '../../../lib/teact/teact';
import { getActions } from '../../../global';
import type {
ApiStarGift,
ApiSticker,
ApiStarGiftRegular,
} from '../../../api/types';
import buildClassName from '../../../util/buildClassName';
import useFlag from '../../../hooks/useFlag';
import { type ObserveFn, useOnIntersect } from '../../../hooks/useIntersectionObserver';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
@ -19,24 +20,27 @@ import Button from '../../ui/Button';
import styles from './GiftItem.module.scss';
export type OwnProps = {
gift: ApiStarGift;
onClick: (gift: ApiStarGift) => void;
};
export type StateProps = {
sticker?: ApiSticker;
gift: ApiStarGiftRegular;
observeIntersection?: ObserveFn;
onClick: (gift: ApiStarGiftRegular) => void;
};
const GIFT_STICKER_SIZE = 90;
function GiftItemStar({ sticker, gift, onClick }: OwnProps & StateProps) {
function GiftItemStar({ gift, observeIntersection, onClick }: OwnProps) {
const { openGiftInfoModal } = getActions();
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
const lang = useLang();
const [shouldPlay, play] = useFlag();
const {
stars,
isLimited,
isSoldOut,
sticker,
} = gift;
const handleGiftClick = useLastCallback(() => {
@ -48,10 +52,13 @@ function GiftItemStar({ sticker, gift, onClick }: OwnProps & StateProps) {
onClick(gift);
});
if (!sticker) return undefined;
useOnIntersect(ref, observeIntersection, (entry) => {
if (entry.isIntersecting) play();
});
return (
<div
ref={ref}
className={buildClassName(styles.container, styles.starGift)}
tabIndex={0}
role="button"
@ -60,6 +67,7 @@ function GiftItemStar({ sticker, gift, onClick }: OwnProps & StateProps) {
<AnimatedIconFromSticker
sticker={sticker}
noLoop
play={shouldPlay}
nonInteractive
size={GIFT_STICKER_SIZE}
/>
@ -75,12 +83,4 @@ function GiftItemStar({ sticker, gift, onClick }: OwnProps & StateProps) {
);
}
export default memo(withGlobal<OwnProps>(
(global, { gift }): StateProps => {
const sticker = global.stickers.starGifts.stickers[gift.stickerId];
return {
sticker,
};
},
)(GiftItemStar));
export default memo(GiftItemStar);

View File

@ -6,7 +6,7 @@ import { getActions, withGlobal } from '../../../global';
import type {
ApiPremiumGiftCodeOption,
ApiStarGift,
ApiStarGiftRegular,
ApiStarsAmount,
ApiUser,
} from '../../../api/types';
@ -18,6 +18,7 @@ import { selectUser } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
@ -41,17 +42,19 @@ export type OwnProps = {
modal: TabState['giftModal'];
};
export type GiftOption = ApiPremiumGiftCodeOption | ApiStarGift;
export type GiftOption = ApiPremiumGiftCodeOption | ApiStarGiftRegular;
type StateProps = {
boostPerSentGift?: number;
starGiftsById?: Record<string, ApiStarGift>;
starGiftsById?: Record<string, ApiStarGiftRegular>;
starGiftCategoriesByName: Record<StarGiftCategory, string[]>;
starBalance?: ApiStarsAmount;
user?: ApiUser;
isSelf?: boolean;
};
const AVATAR_SIZE = 100;
const INTERSECTION_THROTTLE = 200;
const PremiumGiftModal: FC<OwnProps & StateProps> = ({
modal,
@ -59,6 +62,7 @@ const PremiumGiftModal: FC<OwnProps & StateProps> = ({
starGiftCategoriesByName,
starBalance,
user,
isSelf,
}) => {
const {
closeGiftModal, requestConfetti,
@ -70,6 +74,9 @@ const PremiumGiftModal: FC<OwnProps & StateProps> = ({
// eslint-disable-next-line no-null/no-null
const giftHeaderRef = useRef<HTMLHeadingElement>(null);
// eslint-disable-next-line no-null/no-null
const scrollerRef = useRef<HTMLDivElement>(null);
const isOpen = Boolean(modal);
const renderingModal = useCurrentOrPrev(modal);
@ -91,6 +98,10 @@ const PremiumGiftModal: FC<OwnProps & StateProps> = ({
return filteredGifts?.reduce((prev, gift) => (prev.amount < gift.amount ? prev : gift));
}, [filteredGifts]);
const {
observe: observeIntersection,
} = useIntersectionObserver({ rootRef: scrollerRef, throttleMs: INTERSECTION_THROTTLE, isDisabled: !isOpen });
const showConfetti = useLastCallback(() => {
const dialog = dialogRef.current;
if (!dialog) return;
@ -145,9 +156,14 @@ const PremiumGiftModal: FC<OwnProps & StateProps> = ({
),
}, { withNodes: true });
const starGiftDescription = lang('StarGiftDescription', {
user: getUserFullName(user)!,
}, { withNodes: true });
const starGiftDescription = isSelf
? lang('StarGiftDescriptionSelf', undefined, {
withNodes: true,
renderTextFilters: ['br'],
})
: lang('StarGiftDescription', {
user: getUserFullName(user)!,
}, { withNodes: true, withMarkdown: true });
function renderGiftPremiumHeader() {
return (
@ -168,7 +184,7 @@ const PremiumGiftModal: FC<OwnProps & StateProps> = ({
function renderStarGiftsHeader() {
return (
<h2 ref={giftHeaderRef} className={buildClassName(styles.headerText, styles.center)}>
{lang('StarsGiftHeader')}
{lang(isSelf ? 'StarsGiftHeaderSelf' : 'StarsGiftHeader')}
</h2>
);
}
@ -195,6 +211,7 @@ const PremiumGiftModal: FC<OwnProps & StateProps> = ({
return (
<GiftItemStar
gift={gift}
observeIntersection={observeIntersection}
onClick={handleGiftClick}
/>
);
@ -233,7 +250,7 @@ const PremiumGiftModal: FC<OwnProps & StateProps> = ({
function renderMainScreen() {
return (
<div className={buildClassName(styles.main, 'custom-scroll')} onScroll={handleScroll}>
<div ref={scrollerRef} className={buildClassName(styles.main, 'custom-scroll')} onScroll={handleScroll}>
<div className={styles.avatars}>
<Avatar
size={AVATAR_SIZE}
@ -241,10 +258,9 @@ const PremiumGiftModal: FC<OwnProps & StateProps> = ({
/>
<img className={styles.logoBackground} src={StarsBackground} alt="" draggable={false} />
</div>
{renderGiftPremiumHeader()}
{renderGiftPremiumDescription()}
{renderPremiumGifts()}
{!isSelf && renderGiftPremiumHeader()}
{!isSelf && renderGiftPremiumDescription()}
{!isSelf && renderPremiumGifts()}
{renderStarGiftsHeader()}
{renderStarGiftsDescription()}
@ -294,7 +310,7 @@ const PremiumGiftModal: FC<OwnProps & StateProps> = ({
slideClassName={styles.headerSlide}
>
<h2 className={styles.commonHeaderText}>
{lang(isHeaderForStarGifts ? 'StarsGiftHeader' : 'GiftPremiumHeader')}
{lang(isHeaderForStarGifts ? (isSelf ? 'StarsGiftHeaderSelf' : 'StarsGiftHeader') : 'GiftPremiumHeader')}
</h2>
</Transition>
</div>
@ -314,9 +330,15 @@ const PremiumGiftModal: FC<OwnProps & StateProps> = ({
};
export default memo(withGlobal<OwnProps>((global, { modal }): StateProps => {
const { starGiftsById, starGiftCategoriesByName, stars } = global;
const {
starGiftsById,
starGiftCategoriesByName,
stars,
currentUserId,
} = global;
const user = modal?.forUserId ? selectUser(global, modal.forUserId) : undefined;
const isSelf = Boolean(currentUserId && modal?.forUserId === currentUserId);
return {
boostPerSentGift: global.appConfig?.boostsPerSentGift,
@ -324,15 +346,13 @@ export default memo(withGlobal<OwnProps>((global, { modal }): StateProps => {
starGiftCategoriesByName,
starBalance: stars?.balance,
user,
isSelf,
};
})(PremiumGiftModal));
function getCategoryKey(category: StarGiftCategory) {
if (category === 'all') {
return -1;
}
if (category === 'limited') {
return 0;
}
if (category === 'all') return -2;
if (category === 'stock') return -1;
if (category === 'limited') return 0;
return category;
}

View File

@ -46,12 +46,9 @@ const StarGiftCategoryList = ({
}
function renderCategoryName(category: StarGiftCategory) {
if (category === 'all') {
return lang('AllGiftsCategory');
}
if (category === 'limited') {
return lang('LimitedGiftsCategory');
}
if (category === 'all') return lang('AllGiftsCategory');
if (category === 'stock') return lang('StockGiftsCategory');
if (category === 'limited') return lang('LimitedGiftsCategory');
return category;
}
@ -64,7 +61,7 @@ const StarGiftCategoryList = ({
)}
onClick={() => handleItemClick(category)}
>
{category !== 'all' && category !== 'limited' && (
{Number.isInteger(category) && (
<StarIcon
className={styles.star}
type="gold"
@ -81,6 +78,7 @@ const StarGiftCategoryList = ({
return (
<div ref={ref} className={buildClassName(styles.list, 'no-scrollbar')}>
{renderCategoryItem('all')}
{renderCategoryItem('stock')}
{renderCategoryItem('limited')}
{starCategories.map(renderCategoryItem)}
</div>

View File

@ -1,3 +1,7 @@
.modal :global(.modal-dialog) {
overflow: hidden;
}
.header {
display: flex;
flex-direction: column;
@ -25,6 +29,7 @@
.description {
text-align: center;
color: var(--_color-description, var(--color-text));
}
.footerDescription {
@ -44,3 +49,32 @@
align-items: center;
gap: 0.125rem;
}
.radialPattern {
position: absolute;
top: -3rem;
left: -1.5rem;
right: -1.5rem;
height: 16.5rem;
z-index: -1;
}
.uniqueAttribute {
display: flex;
align-items: center;
gap: 0.25rem;
}
.uniqueGift {
gap: 0;
.giftSticker {
margin-block: 1rem;
}
.title {
font-size: 1.25rem;
color: white;
}
}

View File

@ -1,17 +1,22 @@
import type { TeactNode } from '../../../../lib/teact/teact';
import React, { memo, useMemo } from '../../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../../global';
import { getActions, getGlobal, withGlobal } from '../../../../global';
import type { ApiSticker, ApiUser } from '../../../../api/types';
import type {
ApiUser,
} from '../../../../api/types';
import type { TabState } from '../../../../global/types';
import { getUserFullName } from '../../../../global/helpers';
import { selectStarGiftSticker, selectUser } from '../../../../global/selectors';
import { selectUser } from '../../../../global/selectors';
import buildClassName from '../../../../util/buildClassName';
import buildStyle from '../../../../util/buildStyle';
import { formatDateTimeToString } from '../../../../util/dates/dateFormat';
import { formatStarsAsIcon, formatStarsAsText } from '../../../../util/localization/format';
import { CUSTOM_PEER_HIDDEN } from '../../../../util/objects/customPeer';
import { getServerTime } from '../../../../util/serverTime';
import { formatInteger } from '../../../../util/textFormat';
import { formatInteger, formatPercent } from '../../../../util/textFormat';
import { getGiftAttributes, getStickerFromGift } from '../../../common/helpers/gifts';
import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities';
import useCurrentOrPrev from '../../../../hooks/useCurrentOrPrev';
@ -24,6 +29,7 @@ import AnimatedIconFromSticker from '../../../common/AnimatedIconFromSticker';
import Avatar from '../../../common/Avatar';
import BadgeButton from '../../../common/BadgeButton';
import StarIcon from '../../../common/icons/StarIcon';
import RadialPatternBackground from '../../../common/profile/RadialPatternBackground';
import Button from '../../../ui/Button';
import ConfirmDialog from '../../../ui/ConfirmDialog';
import Link from '../../../ui/Link';
@ -36,7 +42,6 @@ export type OwnProps = {
};
type StateProps = {
sticker?: ApiSticker;
userFrom?: ApiUser;
targetUser?: ApiUser;
currentUserId?: string;
@ -46,7 +51,7 @@ type StateProps = {
const STICKER_SIZE = 120;
const GiftInfoModal = ({
modal, sticker, userFrom, targetUser, currentUserId, starGiftMaxConvertPeriod,
modal, userFrom, targetUser, currentUserId, starGiftMaxConvertPeriod,
}: OwnProps & StateProps) => {
const {
closeGiftInfoModal,
@ -65,13 +70,16 @@ const GiftInfoModal = ({
const { gift: typeGift } = renderingModal || {};
const isUserGift = typeGift && 'gift' in typeGift;
const userGift = isUserGift ? typeGift : undefined;
const canUpdate = Boolean(userGift?.fromId && userGift.messageId);
const canUpdate = Boolean(userGift?.messageId);
const isSender = userGift?.fromId === currentUserId;
const canConvertDifference = (userGift && starGiftMaxConvertPeriod && (
userGift.date + starGiftMaxConvertPeriod - getServerTime()
)) || 0;
const conversionLeft = Math.ceil(canConvertDifference / 60 / 60 / 24);
const gift = isUserGift ? typeGift.gift : typeGift;
const giftSticker = gift && getStickerFromGift(gift);
const handleClose = useLastCallback(() => {
closeGiftInfoModal();
});
@ -94,15 +102,38 @@ const GiftInfoModal = ({
handleClose();
});
const giftAttributes = useMemo(() => {
return gift && getGiftAttributes(gift);
}, [gift]);
const radialPatternBackdrop = useMemo(() => {
const { backdrop, pattern } = giftAttributes || {};
if (!backdrop || !pattern || !isOpen) {
return undefined;
}
const backdropColors = [backdrop.centerColor, backdrop.edgeColor];
const patternColor = backdrop.patternColor;
return (
<RadialPatternBackground
className={styles.radialPattern}
backgroundColors={backdropColors}
patternColor={patternColor}
patternIcon={pattern.sticker}
/>
);
}, [giftAttributes, isOpen]);
const modalData = useMemo(() => {
if (!typeGift) {
if (!typeGift || !gift) {
return undefined;
}
const {
fromId, isNameHidden, message, starsToConvert, isUnsaved, isConverted,
fromId, isNameHidden, starsToConvert, isUnsaved, isConverted,
} = userGift || {};
const gift = isUserGift ? typeGift.gift : typeGift;
const isVisibleForMe = isNameHidden && targetUser;
@ -110,6 +141,11 @@ const GiftInfoModal = ({
if (!userGift) {
return lang('GiftInfoSoldOutDescription');
}
if (gift.type === 'starGiftUnique') {
return lang('GiftInfoCollectible', {
number: gift.number,
});
}
if (!canUpdate && !isSender) return undefined;
if (!starsToConvert || canConvertDifference < 0) return undefined;
if (isConverted) {
@ -149,14 +185,32 @@ const GiftInfoModal = ({
});
})();
function getTitle() {
if (!userGift) return lang('GiftInfoSoldOutTitle');
if (gift?.type === 'starGiftUnique') return gift.title;
return canUpdate ? lang('GiftInfoReceived') : lang('GiftInfoTitle');
}
const descriptionColor = giftAttributes?.backdrop?.textColor;
const header = (
<div className={styles.header}>
<AnimatedIconFromSticker sticker={sticker} noLoop nonInteractive size={STICKER_SIZE} />
<div
className={buildClassName(styles.header, radialPatternBackdrop && styles.uniqueGift)}
style={buildStyle(descriptionColor && `--_color-description: ${descriptionColor}`)}
>
{radialPatternBackdrop}
<AnimatedIconFromSticker
className={styles.giftSticker}
sticker={giftSticker}
noLoop
nonInteractive
size={STICKER_SIZE}
/>
<h1 className={styles.title}>
{!userGift && lang('GiftInfoSoldOutTitle')}
{userGift && lang(canUpdate ? 'GiftInfoReceived' : 'GiftInfoTitle')}
{getTitle()}
</h1>
{userGift && (
{gift.type === 'starGift' && (
<p className={styles.amount}>
<span className={styles.amount}>
{formatInteger(gift.stars)}
@ -173,68 +227,187 @@ const GiftInfoModal = ({
);
const tableData: TableData = [];
if (fromId || isNameHidden) {
if (gift.type === 'starGift') {
if ((fromId || isNameHidden)) {
tableData.push([
lang('GiftInfoFrom'),
fromId ? { chatId: fromId } : (
<>
<Avatar size="small" peer={CUSTOM_PEER_HIDDEN} />
<span className={styles.unknown}>{oldLang(CUSTOM_PEER_HIDDEN.titleKey!)}</span>
</>
),
]);
}
if (userGift?.date) {
tableData.push([
lang('GiftInfoDate'),
formatDateTimeToString(userGift.date * 1000, lang.code, true),
]);
}
if (gift.firstSaleDate) {
tableData.push([
lang('GiftInfoFirstSale'),
formatDateTimeToString(gift.firstSaleDate * 1000, lang.code, true),
]);
}
if (gift.lastSaleDate) {
tableData.push([
lang('GiftInfoLastSale'),
formatDateTimeToString(gift.lastSaleDate * 1000, lang.code, true),
]);
}
tableData.push([
lang('GiftInfoFrom'),
fromId ? { chatId: fromId } : (
<>
<Avatar size="small" peer={CUSTOM_PEER_HIDDEN} />
<span className={styles.unknown}>{oldLang(CUSTOM_PEER_HIDDEN.titleKey!)}</span>
</>
),
lang('GiftInfoValue'),
<div className={styles.giftValue}>
{formatStarsAsIcon(lang, gift.stars)}
{canUpdate && canConvertDifference > 0 && Boolean(starsToConvert) && (
<BadgeButton onClick={openConvertConfirm}>
{lang('GiftInfoConvert', { amount: starsToConvert }, { pluralValue: starsToConvert })}
</BadgeButton>
)}
</div>,
]);
if (gift.availabilityTotal) {
tableData.push([
lang('GiftInfoAvailability'),
lang('GiftInfoAvailabilityValue', {
count: gift.availabilityRemains || 0,
total: gift.availabilityTotal,
}, {
pluralValue: gift.availabilityRemains || 0,
}),
]);
}
if (gift.upgradeStars) {
tableData.push([
lang('GiftInfoStatus'),
lang('GiftInfoStatusNonUnique'),
]);
}
if (userGift?.message) {
tableData.push([
undefined,
renderTextWithEntities(userGift.message),
]);
}
}
if (userGift?.date) {
if (gift.type === 'starGiftUnique') {
const {
model, backdrop, pattern, originalDetails,
} = giftAttributes || {};
tableData.push([
lang('GiftInfoDate'),
formatDateTimeToString(userGift.date * 1000, lang.code, true),
lang('GiftInfoOwner'),
{ chatId: gift.ownerId },
]);
}
if (gift.firstSaleDate) {
tableData.push([
lang('GiftInfoFirstSale'),
formatDateTimeToString(gift.firstSaleDate * 1000, lang.code, true),
]);
}
if (model) {
tableData.push([
lang('GiftAttributeModel'),
<span className={styles.uniqueAttribute}>
{model.name}<BadgeButton>{formatPercent(model.rarityPercent)}</BadgeButton>
</span>,
]);
}
if (gift.lastSaleDate) {
tableData.push([
lang('GiftInfoLastSale'),
formatDateTimeToString(gift.lastSaleDate * 1000, lang.code, true),
]);
}
if (backdrop) {
tableData.push([
lang('GiftAttributeBackdrop'),
<span className={styles.uniqueAttribute}>
{backdrop.name}<BadgeButton>{formatPercent(backdrop.rarityPercent)}</BadgeButton>
</span>,
]);
}
tableData.push([
lang('GiftInfoValue'),
<div className={styles.giftValue}>
{formatStarsAsIcon(lang, gift.stars)}
{canUpdate && canConvertDifference > 0 && Boolean(starsToConvert) && (
<BadgeButton onClick={openConvertConfirm}>
{lang('GiftInfoConvert', { amount: starsToConvert }, { pluralValue: starsToConvert })}
</BadgeButton>
)}
</div>,
]);
if (pattern) {
tableData.push([
lang('GiftAttributeSymbol'),
<span className={styles.uniqueAttribute}>
{pattern.name}<BadgeButton>{formatPercent(pattern.rarityPercent)}</BadgeButton>
</span>,
]);
}
if (gift.availabilityTotal) {
tableData.push([
lang('GiftInfoAvailability'),
lang('GiftInfoAvailabilityValue', {
count: gift.availabilityRemains || 0,
total: gift.availabilityTotal,
}, {
pluralValue: gift.availabilityRemains || 0,
lang('GiftInfoIssued', {
issued: gift.issuedCount,
total: gift.totalCount,
}),
]);
}
if (message) {
tableData.push([
undefined,
renderTextWithEntities(message),
]);
if (originalDetails) {
const {
date, recipientId, message, senderId,
} = originalDetails;
const global = getGlobal(); // User names does not need to be reactive
const openChat = (id: string) => {
openChatWithInfo({ id });
closeGiftInfoModal();
};
const recipient = selectUser(global, recipientId)!;
const sender = senderId ? selectUser(global, senderId) : undefined;
const formattedDate = formatDateTimeToString(date * 1000, lang.code, true);
const recipientLink = (
// eslint-disable-next-line react/jsx-no-bind
<Link onClick={() => openChat(recipientId)} isPrimary>
{getUserFullName(recipient)}
</Link>
);
let text: TeactNode | undefined;
if (!sender || senderId === recipientId) {
text = message ? lang('GiftInfoOriginalInfoText', {
user: recipientLink,
text: renderTextWithEntities(message),
date: formattedDate,
}, {
withNodes: true,
}) : lang('GiftInfoOriginalInfo', {
user: recipientLink,
date: formattedDate,
}, {
withNodes: true,
});
} else {
const senderLink = (
// eslint-disable-next-line react/jsx-no-bind
<Link onClick={() => openChat(sender.id)} isPrimary>
{getUserFullName(sender)}
</Link>
);
text = message ? lang('GiftInfoOriginalInfoTextSender', {
user: recipientLink,
sender: senderLink,
text: renderTextWithEntities(message),
date: formattedDate,
}, {
withNodes: true,
}) : lang('GiftInfoOriginalInfoSender', {
user: recipientLink,
date: formattedDate,
sender: senderLink,
}, {
withNodes: true,
});
}
tableData.push([
undefined,
<span>{text}</span>,
]);
}
}
const footer = (
@ -274,7 +447,10 @@ const GiftInfoModal = ({
tableData,
footer,
};
}, [typeGift, userGift, isUserGift, targetUser, sticker, lang, canUpdate, canConvertDifference, isSender, oldLang]);
}, [
typeGift, userGift, targetUser, giftSticker, lang, canUpdate, canConvertDifference, isSender, oldLang, gift,
radialPatternBackdrop, giftAttributes,
]);
return (
<>
@ -283,6 +459,7 @@ const GiftInfoModal = ({
header={modalData?.header}
tableData={modalData?.tableData}
footer={modalData?.footer}
className={styles.modal}
onClose={handleClose}
/>
{userGift && (
@ -323,16 +500,12 @@ export default memo(withGlobal<OwnProps>(
(global, { modal }): StateProps => {
const typeGift = modal?.gift;
const isUserGift = typeGift && 'gift' in typeGift;
const gift = isUserGift ? typeGift.gift : typeGift;
const stickerId = gift?.stickerId;
const sticker = stickerId ? selectStarGiftSticker(global, stickerId) : undefined;
const fromId = isUserGift && typeGift.fromId;
const userFrom = fromId ? selectUser(global, fromId) : undefined;
const targetUser = modal?.userId ? selectUser(global, modal.userId) : undefined;
return {
sticker,
userFrom,
targetUser,
currentUserId: global.currentUserId,

View File

@ -42,7 +42,8 @@ const GiftRecipientPicker: FC<OwnProps & StateProps> = ({
const displayedUserIds = useMemo(() => {
const usersById = getGlobal().users.byId;
const filteredContactIds = userIds ? filterUsersByName(userIds, usersById, searchQuery) : [];
const idsWithSelf = userIds ? userIds.concat(currentUserId!) : undefined;
const filteredContactIds = idsWithSelf ? filterUsersByName(idsWithSelf, usersById, searchQuery) : [];
return sortChatIds(unique(filteredContactIds).filter((userId) => {
const user = usersById[userId];
@ -50,8 +51,8 @@ const GiftRecipientPicker: FC<OwnProps & StateProps> = ({
return true;
}
return !isUserBot(user) && userId !== currentUserId;
}));
return !isUserBot(user);
}), undefined, [currentUserId!]);
}, [currentUserId, searchQuery, userIds]);
const handleSelectedUserIdsChange = useLastCallback((selectedId: string) => {
@ -79,6 +80,7 @@ const GiftRecipientPicker: FC<OwnProps & StateProps> = ({
isSearchable
withDefaultPadding
withStatus
forceShowSelf
/>
</PickerModal>
);

View File

@ -8,6 +8,7 @@ export function getTransactionTitle(lang: OldLangFn, transaction: ApiStarsTransa
if (transaction.starRefCommision) {
return lang('StarTransactionCommission', formatPercent(transaction.starRefCommision));
}
if (transaction.isGiftUpgrade) return lang('Gift2TransactionUpgraded');
if (transaction.extendedMedia) return lang('StarMediaPurchase');
if (transaction.subscriptionPeriod) return transaction.title || lang('StarSubscriptionPurchase');
if (transaction.isReaction) return lang('StarsReactionsSent');

View File

@ -71,6 +71,10 @@ const StarsTransactionItem = ({ transaction, className }: OwnProps) => {
avatarPeer = customPeer;
}
if (transaction.isGiftUpgrade && transaction.starGift?.type === 'starGiftUnique') {
description = transaction.starGift.title;
}
if (transaction.photo) {
avatarPeer = undefined;
}

View File

@ -14,11 +14,12 @@ import { buildStarsTransactionCustomPeer, formatStarsTransactionAmount } from '.
import {
selectCanPlayAnimatedEmojis,
selectGiftStickerForStars,
selectPeer, selectStarGiftSticker,
selectPeer,
} from '../../../../global/selectors';
import buildClassName from '../../../../util/buildClassName';
import { copyTextToClipboard } from '../../../../util/clipboard';
import { formatDateTimeToString } from '../../../../util/dates/dateFormat';
import { getStickerFromGift } from '../../../common/helpers/gifts';
import { getTransactionTitle, isNegativeStarsAmount } from '../helpers/transaction';
import useLang from '../../../../hooks/useLang';
@ -57,6 +58,8 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
const oldLang = useOldLang();
const { transaction } = modal || {};
const sticker = transaction?.starGift ? getStickerFromGift(transaction.starGift) : topSticker;
const handleOpenMedia = useLastCallback(() => {
const media = transaction?.extendedMedia;
if (!media) return;
@ -73,7 +76,7 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
}
const {
giveawayPostId, photo, stars,
giveawayPostId, photo, stars, isGiftUpgrade, starGift,
} = transaction;
const customPeer = (transaction.peer && transaction.peer.type !== 'peer'
@ -84,7 +87,7 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
const title = getTransactionTitle(oldLang, transaction);
const messageLink = peer && transaction.messageId
const messageLink = peer && transaction.messageId && !isGiftUpgrade
? getMessageLink(peer, undefined, transaction.messageId) : undefined;
const giveawayMessageLink = peer && giveawayPostId && getMessageLink(peer, undefined, giveawayPostId);
@ -98,9 +101,11 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
: areAllVideos ? oldLang('Stars.Transfer.Videos', mediaAmount)
: oldLang('Media', mediaAmount);
const description = transaction.description || (media ? mediaText : undefined);
const description = transaction.description
|| (isGiftUpgrade && starGift?.type === 'starGiftUnique' ? starGift.title : undefined)
|| (media ? mediaText : undefined);
const shouldDisplayAvatar = !media && !topSticker;
const shouldDisplayAvatar = !media && !sticker;
const avatarPeer = !photo ? (peer || customPeer) : undefined;
const header = (
@ -112,10 +117,10 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
onClick={handleOpenMedia}
/>
)}
{!media && topSticker && (
{!media && sticker && (
<AnimatedIconFromSticker
key={transaction.id}
sticker={topSticker}
sticker={sticker}
play={canPlayAnimatedEmojis}
noLoop
nonInteractive
@ -124,7 +129,7 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
{shouldDisplayAvatar && (
<Avatar peer={avatarPeer} webPhoto={photo} size="giant" />
)}
{!topSticker && (
{!sticker && (
<img
className={buildClassName(styles.starsBackground)}
src={StarsBackground}
@ -154,8 +159,17 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
]);
}
if (isGiftUpgrade) {
tableData.push([
oldLang('StarGiftReason'),
oldLang('StarGiftReasonUpgrade'),
]);
}
let peerLabel;
if (isNegativeStarsAmount(stars) || transaction.isMyGift) {
if (isGiftUpgrade) {
peerLabel = oldLang('Stars.Transaction.GiftFrom');
} else if (isNegativeStarsAmount(stars) || transaction.isMyGift) {
peerLabel = oldLang('Stars.Transaction.To');
} else if (transaction.starRefCommision) {
peerLabel = oldLang('StarsTransaction.StarRefReason.Miniapp');
@ -222,7 +236,7 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
tableData,
footer,
};
}, [transaction, oldLang, lang, peer, topSticker, canPlayAnimatedEmojis]);
}, [transaction, oldLang, lang, peer, sticker, canPlayAnimatedEmojis]);
const prevModalData = usePrevious(starModalData);
const renderingModalData = prevModalData || starModalData;
@ -248,13 +262,10 @@ export default memo(withGlobal<OwnProps>(
const starCount = modal?.transaction.stars;
const starsGiftSticker = modal?.transaction.isGift && selectGiftStickerForStars(global, starCount?.amount);
const starGiftStickerId = modal?.transaction.starGift?.stickerId;
const starGiftSticker = starGiftStickerId && selectStarGiftSticker(global, starGiftStickerId);
return {
peer,
canPlayAnimatedEmojis: selectCanPlayAnimatedEmojis(global),
topSticker: starGiftSticker || starsGiftSticker,
topSticker: starsGiftSticker,
};
},
)(StarsTransactionModal));

View File

@ -123,6 +123,7 @@ const SuggestedStatusModal = ({ modal, currentUser, bot }: OwnProps & StateProps
{mockPeerWithStatus && (
<PeerChip
mockPeer={mockPeerWithStatus}
withEmojiStatus
/>
)}
<Button size="smaller" onClick={handleSetStatus}>

View File

@ -713,7 +713,12 @@ const Profile: FC<OwnProps & StateProps> = ({
</div>
) : resultType === 'gifts' ? (
(gifts?.map((gift) => (
<UserGift userId={chatId} key={`${gift.date}-${gift.fromId}-${gift.gift.id}`} gift={gift} />
<UserGift
userId={chatId}
key={`${gift.date}-${gift.fromId}-${gift.gift.id}`}
gift={gift}
observeIntersection={observeIntersectionForMedia}
/>
)))
) : undefined}
</div>

View File

@ -89,26 +89,27 @@ addActionHandler('loadStarGifts', async (global): Promise<void> => {
return;
}
const { gifts, stickers } = result;
const starGiftsById = buildCollectionByKey(gifts, 'id');
const starGiftsById = buildCollectionByKey(result, 'id');
const starGiftCategoriesByName: Record<StarGiftCategory, string[]> = {
all: [],
stock: [],
limited: [],
};
const allStarGiftIds = Object.keys(starGiftsById);
const allStarGifts = Object.values(starGiftsById);
const limitedStarGiftIds = allStarGifts.map(
(gift) => {
return gift.isLimited ? gift.id : undefined;
},
).filter(Boolean) as string[];
const limitedStarGiftIds = allStarGifts.map((gift) => (gift.isLimited ? gift.id : undefined))
.filter(Boolean) as string[];
const stockedStarGiftIds = allStarGifts.map((gift) => (
gift.availabilityRemains || !gift.availabilityTotal ? gift.id : undefined
)).filter(Boolean) as string[];
starGiftCategoriesByName.all = allStarGiftIds;
starGiftCategoriesByName.limited = limitedStarGiftIds;
starGiftCategoriesByName.stock = stockedStarGiftIds;
allStarGifts.forEach((gift) => {
const starsCategory = gift.stars;
@ -123,12 +124,6 @@ addActionHandler('loadStarGifts', async (global): Promise<void> => {
...global,
starGiftsById,
starGiftCategoriesByName,
stickers: {
...global.stickers,
starGifts: {
stickers,
},
},
};
setGlobal(global);
});

View File

@ -260,11 +260,6 @@ function unsafeMigrateCache(cached: GlobalState, initialState: GlobalState) {
if (!cached.messages.pollById) {
cached.messages.pollById = initialState.messages.pollById;
}
if (!cached.stickers.starGifts) {
cached.stickers.starGifts = initialState.stickers.starGifts;
cached.users.giftsById = initialState.users.giftsById;
}
}
function updateCache(force?: boolean) {

View File

@ -183,6 +183,7 @@ export const INITIAL_GLOBAL_STATE: GlobalState = {
starGiftCategoriesByName: {
all: [],
limited: [],
stock: [],
},
stickers: {
@ -207,9 +208,6 @@ export const INITIAL_GLOBAL_STATE: GlobalState = {
stickers: [],
emojis: [],
},
starGifts: {
stickers: {},
},
forEmoji: {},
},

View File

@ -171,10 +171,6 @@ export function selectIsAlwaysHighPriorityEmoji<T extends GlobalState>(
|| stickerSet.id === RESTRICTED_EMOJI_SET_ID;
}
export function selectStarGiftSticker<T extends GlobalState>(global: T, id: string) {
return global.stickers.starGifts.stickers[id];
}
export function selectGiftStickerForDuration<T extends GlobalState>(global: T, duration = 1) {
const stickers = global.premiumGifts?.stickers;
if (!stickers) return undefined;

View File

@ -27,7 +27,7 @@ import type {
ApiSavedReactionTag,
ApiSession,
ApiSponsoredMessage,
ApiStarGift,
ApiStarGiftRegular,
ApiStarsAmount,
ApiStarTopupOption,
ApiStealthMode,
@ -290,7 +290,7 @@ export type GlobalState = {
};
};
availableEffectById: Record<string, ApiAvailableEffect>;
starGiftsById: Record<string, ApiStarGift>;
starGiftsById: Record<string, ApiStarGiftRegular>;
starGiftCategoriesByName: Record<StarGiftCategory, string[]>;
stickers: {
@ -328,9 +328,6 @@ export type GlobalState = {
stickers: ApiSticker[];
emojis: ApiSticker[];
};
starGifts: {
stickers: Record<string, ApiSticker>;
};
};
customEmojis: {

View File

@ -17,7 +17,7 @@ import type {
ApiPhoto,
ApiReaction,
ApiReactionWithPaid,
ApiStarGift,
ApiStarGiftRegular,
ApiStarsSubscription,
ApiStarsTransaction,
ApiStickerSet,
@ -561,7 +561,7 @@ export type ConfettiStyle = 'poppers' | 'top-down';
export type StarGiftInfo = {
userId: string;
gift: ApiStarGift;
gift: ApiStarGiftRegular;
shouldHideName?: boolean;
message?: ApiFormattedText;
};
@ -631,7 +631,7 @@ export type ConfettiParams = OptionalCombine<{
export type WebPageMediaSize = 'large' | 'small';
export type StarGiftCategory = number | 'all' | 'limited';
export type StarGiftCategory = number | 'all' | 'limited' | 'stock';
export type CallSound = (
'join' | 'allowTalk' | 'leave' | 'connecting' | 'incoming' | 'end' | 'connect' | 'busy' | 'ringing'

View File

@ -1108,6 +1108,8 @@ export interface LangPair {
'GiftPremiumDescriptionLinkCaption': undefined;
'GiftPremiumDescriptionLink': undefined;
'StarsGiftHeader': undefined;
'StarsGiftHeaderSelf': undefined;
'StarGiftDescriptionSelf': undefined;
'GiftLimited': undefined;
'GiftSoldOut': undefined;
'GiftMessagePlaceholder': undefined;
@ -1130,8 +1132,15 @@ export interface LangPair {
'GiftInfoSoldOutTitle': undefined;
'GiftInfoSoldOutDescription': undefined;
'GiftInfoSenderHidden': undefined;
'GiftInfoOwner': undefined;
'GiftAttributeModel': undefined;
'GiftAttributeBackdrop': undefined;
'GiftAttributeSymbol': undefined;
'GiftInfoStatus': undefined;
'GiftInfoStatusNonUnique': undefined;
'AllGiftsCategory': undefined;
'LimitedGiftsCategory': undefined;
'StockGiftsCategory': undefined;
'PremiumGiftDescription': undefined;
'StarsReactionLinkText': undefined;
'StarsReactionLink': undefined;
@ -1547,6 +1556,33 @@ export interface LangPairWithVariables<V extends unknown = LangVariable> {
'GiftInfoSaved': {
'link': V;
};
'GiftInfoIssued': {
'issued': V;
'total': V;
};
'GiftInfoCollectible': {
'number': V;
};
'GiftInfoOriginalInfo': {
'user': V;
'date': V;
};
'GiftInfoOriginalInfoSender': {
'sender': V;
'user': V;
'date': V;
};
'GiftInfoOriginalInfoText': {
'user': V;
'date': V;
'text': V;
};
'GiftInfoOriginalInfoTextSender': {
'sender': V;
'user': V;
'date': V;
'text': V;
};
'StarsAmount': {
'amount': V;
};