Unique gifts: Follow up (#5397)

This commit is contained in:
zubiden 2025-01-05 20:18:47 +01:00 committed by Alexander Zinchuk
parent 783a5c2c01
commit 683384074a
13 changed files with 246 additions and 47 deletions

View File

@ -14,6 +14,7 @@ import type {
ApiKeyboardButton,
ApiMessage,
ApiMessageActionStarGift,
ApiMessageActionStarGiftUnique,
ApiMessageEntity,
ApiMessageForwardInfo,
ApiMessageReportResult,
@ -380,6 +381,24 @@ function buildApiMessageActionStarGift(action: GramJs.MessageActionStarGift) : A
};
}
function buildApiMessageActionStarGiftUnique(
action: GramJs.MessageActionStarGiftUnique,
): ApiMessageActionStarGiftUnique {
const {
gift, canExportAt, refunded, saved, transferStars, transferred, upgrade,
} = action;
return {
gift: buildApiStarGift(gift),
canExportAt,
isRefunded: refunded,
isSaved: saved,
transferStars: transferStars?.toJSNumber(),
isTransferred: transferred,
isUpgrade: upgrade,
};
}
function buildAction(
action: GramJs.TypeMessageAction,
senderId: string | undefined,
@ -395,7 +414,7 @@ function buildAction(
let call: Partial<ApiGroupCall> | undefined;
let amount: number | undefined;
let stars: number | undefined;
let starGift: ApiMessageActionStarGift | undefined;
let starGift: ApiMessageActionStarGift | ApiMessageActionStarGiftUnique | undefined;
let currency: string | undefined;
let giftCryptoInfo: {
currency: string;
@ -710,6 +729,7 @@ function buildAction(
transactionId = action.transactionId;
} else if (action instanceof GramJs.MessageActionStarGift && action.gift instanceof GramJs.StarGift) {
type = 'starGift';
starGift = buildApiMessageActionStarGift(action);
if (isOutgoing) {
text = 'ActionGiftOutbound';
translationValues.push('%gift_payment_amount%');
@ -725,7 +745,15 @@ function buildAction(
amount = action.gift.stars.toJSNumber();
currency = STARS_CURRENCY_CODE;
starGift = buildApiMessageActionStarGift(action);
} else if (action instanceof GramJs.MessageActionStarGiftUnique && action.gift instanceof GramJs.StarGiftUnique) {
type = 'starGiftUnique';
text = isOutgoing ? 'Notification.StarsGift.UpgradeYou' : 'Notification.StarsGift.Upgrade';
starGift = buildApiMessageActionStarGiftUnique(action);
if (targetPeerId) {
targetUserIds.push(targetPeerId);
targetChatId = targetPeerId;
}
} else {
text = 'ChatList.UnsupportedMessage';
}

View File

@ -467,6 +467,16 @@ export interface ApiMessageActionStarGift {
starsToConvert?: number;
}
export interface ApiMessageActionStarGiftUnique {
isUpgrade?: true;
isTransferred?: true;
isSaved?: true;
isRefunded?: true;
gift: ApiStarGift;
canExportAt?: number;
transferStars?: number;
}
export interface ApiAction {
mediaType: 'action';
text: string;
@ -487,6 +497,7 @@ export interface ApiAction {
| 'giftCode'
| 'prizeStars'
| 'starGift'
| 'starGiftUnique'
| 'other';
photo?: ApiPhoto;
amount?: number;
@ -497,7 +508,7 @@ export interface ApiAction {
currency: string;
amount: number;
};
starGift?: ApiMessageActionStarGift;
starGift?: ApiMessageActionStarGift | ApiMessageActionStarGiftUnique;
translationValues: string[];
call?: Partial<ApiGroupCall>;
phoneCall?: PhoneCallAction;

View File

@ -93,6 +93,10 @@ export function renderActionMessageText(
.replace('un2', '%action_origin%')
.replace(/\*\*/g, '');
}
if (translationKey.startsWith('Notification.StarsGift.Upgrade')) {
unprocessed = unprocessed
.replace('%@', '%action_origin%');
}
if (translationKey === 'BoostingReceivedPrizeFrom') {
unprocessed = unprocessed
.replace('**%s**', '%target_chat%')

View File

@ -6,7 +6,9 @@
content: '';
position: absolute;
inset: 0;
background: linear-gradient(var(--_bg-1), var(--_bg-2)), radial-gradient(circle, #ffffff32, #ffffff00);
background-image:
radial-gradient(circle closest-side, #ffffff32, #ffffff00),
radial-gradient(closest-side, var(--_bg-1), var(--_bg-2));
}
}

View File

@ -13,14 +13,14 @@ 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 useDevicePixelRatio from '../../../hooks/window/useDevicePixelRatio';
import styles from './RadialPatternBackground.module.scss';
type OwnProps = {
backgroundColors: string[];
patternColor: string;
patternIcon: ApiSticker;
patternColor?: string;
patternIcon?: ApiSticker;
className?: string;
};
@ -28,32 +28,33 @@ 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 MAX_RADIUS = 0.4;
const BASE_ICON_SIZE = 20;
const MIN_SIZE = 200;
const MIN_SIZE = 250;
const PATTERN_POSITIONS = (() => {
const coordinates: { x: number; y: number; alpha: number; sizeFactor: number }[] = [];
const coordinates: { x: number; y: 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;
const angleShift = ring % 2 === 0 ? Math.PI / ringItemCount : 0;
for (let i = 0; i < ringItemCount; i++) {
const angle = (i / ringItemCount) * Math.PI * 2;
const angle = (i / ringItemCount) * Math.PI * 2 + angleShift;
// 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,
x, y, sizeFactor,
});
}
}
@ -73,9 +74,11 @@ const RadialPatternBackground = ({
const [getContainerSize, setContainerSize] = useSignal({ width: 0, height: 0 });
const dpr = useDevicePixelRatio();
const [emojiImage, setEmojiImage] = useState<HTMLImageElement | undefined>();
const previewMediaHash = getStickerMediaHash(patternIcon, 'preview');
const previewMediaHash = patternIcon && getStickerMediaHash(patternIcon, 'preview');
const previewUrl = useMedia(previewMediaHash);
useEffect(() => {
@ -109,22 +112,34 @@ const RadialPatternBackground = ({
ctx.save();
PATTERN_POSITIONS.forEach(({
x, y, alpha, sizeFactor,
x, y, 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 centerShift = (width - Math.max(width, MIN_SIZE * dpr)) / 2; // Shift coords if canvas is smaller than `MIN_SIZE`
const renderX = x * Math.max(width, MIN_SIZE * dpr) + centerShift;
const renderY = y * Math.max(height, MIN_SIZE * dpr) + centerShift;
const size = BASE_ICON_SIZE * sizeFactor * (centerShift ? 0.8 : 1);
const size = BASE_ICON_SIZE * dpr * sizeFactor * (centerShift ? 0.8 : 1);
ctx.globalAlpha = alpha;
ctx.drawImage(emojiImage, renderX - size / 2, renderY - size / 2, size, size);
});
ctx.restore();
if (patternColor) {
ctx.save();
ctx.fillStyle = patternColor;
ctx.globalCompositeOperation = 'source-atop';
ctx.fillRect(0, 0, width, height);
ctx.restore();
}
const radialGradient = ctx.createRadialGradient(width / 2, height / 2, 0, width / 2, height / 2, width / 2);
radialGradient.addColorStop(0, '#FFFFFF00');
radialGradient.addColorStop(1, '#FFFFFF');
// Alpha mask
ctx.save();
ctx.fillStyle = patternColor;
ctx.globalCompositeOperation = 'source-atop';
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = radialGradient;
ctx.fillRect(0, 0, width, height);
ctx.restore();
});
@ -133,7 +148,7 @@ const RadialPatternBackground = ({
draw();
}, [emojiImage]);
useSignalEffect(() => {
useEffect(() => {
const { width, height } = getContainerSize();
const canvas = canvasRef.current!;
if (!width || !height) {
@ -141,14 +156,13 @@ const RadialPatternBackground = ({
}
const maxSide = Math.max(width, height);
const dpr = window.devicePixelRatio;
requestMutation(() => {
canvas.width = maxSide * dpr;
canvas.height = maxSide * dpr;
draw();
});
}, [getContainerSize]);
}, [getContainerSize, dpr]);
return (
<div

View File

@ -5,7 +5,7 @@ import React, {
import { getActions, getGlobal, withGlobal } from '../../global';
import type {
ApiChat, ApiMessage, ApiSticker, ApiTopic, ApiUser,
ApiChat, ApiMessage, ApiMessageActionStarGift, ApiSticker, ApiTopic, ApiUser,
} from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import type { FocusDirection, MessageListType, ThreadId } from '../../types';
@ -28,6 +28,7 @@ import {
} from '../../global/selectors';
import buildClassName from '../../util/buildClassName';
import { formatInteger, formatIntegerCompact } from '../../util/textFormat';
import { getGiftAttributes, getStickerFromGift } from '../common/helpers/gifts';
import { renderActionMessageText } from '../common/helpers/renderActionMessageText';
import renderText from '../common/helpers/renderText';
import { renderTextWithEntities } from '../common/helpers/renderTextWithEntities';
@ -45,6 +46,7 @@ import useFocusMessage from './message/hooks/useFocusMessage';
import AnimatedIconFromSticker from '../common/AnimatedIconFromSticker';
import Avatar from '../common/Avatar';
import GiftRibbon from '../common/gift/GiftRibbon';
import RadialPatternBackground from '../common/profile/RadialPatternBackground';
import Sparkles from '../common/Sparkles';
import ActionMessageSuggestedAvatar from './ActionMessageSuggestedAvatar';
import ActionMessageUpdatedAvatar from './ActionMessageUpdatedAvatar';
@ -163,6 +165,7 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
const isJoinedMessage = isJoinedChannelMessage(message);
const isStarsGift = message.content.action?.type === 'giftStars';
const isStarGift = message.content.action?.type === 'starGift';
const isStarGiftUnique = message.content.action?.type === 'starGiftUnique';
const isPrizeStars = message.content.action?.type === 'prizeStars';
const withServiceReactions = Boolean(message.areReactionsPossible && message?.reactions);
@ -318,7 +321,7 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
const giftMessage = message.content.action?.message;
return (
<span
className="action-message-gift action-message-gift-code"
className="action-message-gift action-message-centered"
tabIndex={0}
role="button"
onClick={handleGiftCodeClick}
@ -366,7 +369,7 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
function renderStarsGift() {
return (
<span
className="action-message-gift action-message-gift-code"
className="action-message-gift action-message-centered"
tabIndex={0}
role="button"
onClick={handleStarGiftClick}
@ -421,9 +424,9 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
}
function renderStarGiftUserDescription() {
const starGift = message.content.action?.starGift;
const starGift = message.content.action?.starGift as ApiMessageActionStarGift;
const targetUser = targetUsers && targetUsers[0]?.firstName;
const starGiftMessage = message.content.action?.starGift?.message;
const starGiftMessage = starGift?.message;
if (!starGift) return undefined;
if (starGiftMessage) {
@ -468,11 +471,11 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
function renderStarGift() {
const starGift = message.content.action?.starGift;
if (!starGift || starGift.gift.type === 'starGiftUnique') return undefined;
if (!starGift || starGift.gift.type !== 'starGift') return undefined;
return (
<span
className="action-message-gift action-message-gift-code action-message-star-gift"
className="action-message-gift action-message-centered"
tabIndex={0}
role="button"
onClick={handleStarGiftClick}
@ -507,12 +510,83 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
);
}
function renderStarGiftUnique() {
const starGift = message.content.action?.starGift;
if (!starGift || starGift.gift.type !== 'starGiftUnique') return undefined;
const sticker = getStickerFromGift(starGift.gift)!;
const attributes = getGiftAttributes(starGift.gift);
const { backdrop, pattern, model } = attributes || {};
if (!backdrop || !pattern || !model) return undefined;
const backgroundColors = [backdrop.centerColor, backdrop.edgeColor];
return (
<span
className="action-message-gift action-message-centered action-message-unique"
tabIndex={0}
role="button"
onClick={handleStarGiftClick}
>
<div className="action-message-unique-background-wrapper">
<RadialPatternBackground
className="action-message-unique-background"
backgroundColors={backgroundColors}
patternColor={backdrop.patternColor}
patternIcon={pattern.sticker}
/>
</div>
<AnimatedIconFromSticker
sticker={sticker}
play={canPlayAnimatedEmojis}
noLoop
nonInteractive
size={STAR_GIFT_STICKER_SIZE}
/>
{renderStarGiftUserCaption()}
<div className="action-message-unique-title" style={`color: ${backdrop.textColor}`}>
{starGift.gift.title} #{starGift.gift.number}
</div>
<div className="action-message-unique-properties" style={`color: ${backdrop.textColor}`}>
<div className="action-message-unique-property">
{oldLang('Gift2AttributeModel')}
</div>
<div className="action-message-unique-value">
{model.name}
</div>
<div className="action-message-unique-property">
{oldLang('Gift2AttributeBackdrop')}
</div>
<div className="action-message-unique-value">
{backdrop.name}
</div>
<div className="action-message-unique-property">
{oldLang('Gift2AttributeSymbol')}
</div>
<div className="action-message-unique-value">
{pattern.name}
</div>
</div>
<div className="action-message-button">
<Sparkles preset="button" />
{oldLang('Gift2UniqueView')}
</div>
<GiftRibbon
color={backdrop.patternColor || 'blue'}
text={oldLang('ActionStarGift')}
/>
</span>
);
}
function renderPrizeStars() {
const isUnclaimed = message.content.action?.isUnclaimed;
return (
<span
className="action-message-gift action-message-gift-code"
className="action-message-gift action-message-centered"
tabIndex={0}
role="button"
onClick={handlePrizeStarsClick}
@ -577,6 +651,7 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
{isGiftCode && renderGiftCode()}
{isStarsGift && renderStarsGift()}
{isStarGift && renderStarGift()}
{isStarGiftUnique && renderStarGiftUnique()}
{isPrizeStars && renderPrizeStars()}
{isSuggestedAvatar && (
<ActionMessageSuggestedAvatar message={message} renderContent={renderContent} />

View File

@ -286,12 +286,37 @@
font-weight: var(--font-weight-semibold);
}
.action-message-gift-code {
.action-message-centered {
margin-inline: auto;
}
.action-message-unique {
&::before {
content: "";
position: absolute;
inset: -0.25rem;
background: var(--pattern-color);
border-radius: calc(var(--border-radius-messages) + 0.25rem);
z-index: -1;
}
}
.action-message-unique-background-wrapper {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: inherit;
}
.action-message-unique-background {
position: absolute;
inset: 0;
top: -6rem;
}
.action-message-user-caption,
.action-message-stars-balance {
position: relative;
margin-top: 0.5rem;
display: flex;
gap: 0.25rem;
@ -305,6 +330,31 @@
font-weight: var(--font-weight-semibold);
}
.action-message-unique-title {
position: relative;
font-size: 0.875rem;
}
.action-message-unique-properties {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.375rem;
font-size: 0.875rem;
margin-top: 0.5rem;
position: relative;
}
.action-message-unique-value {
color: white;
justify-self: flex-start;
}
.action-message-unique-property {
justify-self: flex-end;
font-weight: var(--font-weight-normal);
}
.action-message-user-avatar {
margin-left: 0.25rem;
}
@ -316,6 +366,7 @@
}
.action-message-gift-subtitle {
position: relative;
font-weight: normal;
text-wrap: balance;
font-size: 0.8125rem;

View File

@ -3,7 +3,8 @@
.content {
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: 1rem;
padding-inline: 1rem !important;
max-height: min(92vh, 40rem) !important;
overflow-x: hidden;
}

View File

@ -28,6 +28,7 @@ type OwnProps = {
footer?: TeactNode;
buttonText?: string;
className?: string;
hasBackdrop?: boolean;
onClose: NoneToVoidFunction;
onButtonClick?: NoneToVoidFunction;
};
@ -41,6 +42,7 @@ const TableInfoModal = ({
footer,
buttonText,
className,
hasBackdrop,
onClose,
onButtonClick,
}: OwnProps) => {
@ -55,6 +57,7 @@ const TableInfoModal = ({
isOpen={isOpen}
hasCloseButton={Boolean(title)}
hasAbsoluteCloseButton={!title}
absoluteCloseButtonColor={hasBackdrop ? 'translucent-white' : undefined}
isSlim
title={title}
className={className}

View File

@ -36,7 +36,6 @@
font-size: 0.875rem;
color: var(--color-text-secondary);
text-align: center;
margin-top: 0.5rem;
margin-bottom: 1rem;
}
@ -53,8 +52,8 @@
.radialPattern {
position: absolute;
top: -3rem;
left: -1.5rem;
right: -1.5rem;
left: -1rem;
right: -1rem;
height: 16.5rem;
z-index: -1;
@ -77,4 +76,8 @@
font-size: 1.25rem;
color: white;
}
.description {
font-size: 0.875rem;
}
}

View File

@ -138,14 +138,12 @@ const GiftInfoModal = ({
const isVisibleForMe = isNameHidden && targetUser;
const description = (() => {
if (!userGift) {
return lang('GiftInfoSoldOutDescription');
}
if (gift.type === 'starGiftUnique') {
return lang('GiftInfoCollectible', {
number: gift.number,
});
}
if (!userGift) return lang('GiftInfoSoldOutDescription');
if (!canUpdate && !isSender) return undefined;
if (!starsToConvert || canConvertDifference < 0) return undefined;
if (isConverted) {
@ -186,8 +184,8 @@ const GiftInfoModal = ({
})();
function getTitle() {
if (!userGift) return lang('GiftInfoSoldOutTitle');
if (gift?.type === 'starGiftUnique') return gift.title;
if (!userGift) return lang('GiftInfoSoldOutTitle');
return canUpdate ? lang('GiftInfoReceived') : lang('GiftInfoTitle');
}
@ -219,7 +217,7 @@ const GiftInfoModal = ({
</p>
)}
{description && (
<p className={buildClassName(styles.description, !userGift && styles.soldOut)}>
<p className={buildClassName(styles.description, !userGift && gift?.type === 'starGift' && styles.soldOut)}>
{description}
</p>
)}
@ -457,6 +455,7 @@ const GiftInfoModal = ({
<TableInfoModal
isOpen={isOpen}
header={modalData?.header}
hasBackdrop={Boolean(radialPatternBackdrop)}
tableData={modalData?.tableData}
footer={modalData?.footer}
className={styles.modal}

View File

@ -16,7 +16,7 @@ import useOldLang from '../../hooks/useOldLang';
import useShowTransition from '../../hooks/useShowTransition';
import Icon from '../common/icons/Icon';
import Button from './Button';
import Button, { type OwnProps as ButtonProps } from './Button';
import Portal from './Portal';
import './Modal.scss';
@ -33,6 +33,7 @@ export type OwnProps = {
isSlim?: boolean;
hasCloseButton?: boolean;
hasAbsoluteCloseButton?: boolean;
absoluteCloseButtonColor?: ButtonProps['color'];
noBackdrop?: boolean;
noBackdropClose?: boolean;
children: React.ReactNode;
@ -57,6 +58,7 @@ const Modal: FC<OwnProps> = ({
header,
hasCloseButton,
hasAbsoluteCloseButton,
absoluteCloseButtonColor = 'translucent',
noBackdrop,
noBackdropClose,
children,
@ -137,7 +139,7 @@ const Modal: FC<OwnProps> = ({
<Button
className={buildClassName(hasAbsoluteCloseButton && 'modal-absolute-close-button')}
round
color="translucent"
color={absoluteCloseButtonColor}
size="smaller"
ariaLabel={lang('Close')}
onClick={onClose}

View File

@ -1,4 +1,4 @@
import type { ApiUserStarGift } from '../../../api/types';
import type { ApiMessageActionStarGift, ApiUserStarGift } from '../../../api/types';
import type { ActionReturnType } from '../../types';
import { getCurrentTabId } from '../../../util/establishMultitabRole';
@ -242,8 +242,14 @@ addActionHandler('openGiftInfoModalFromMessage', (global, actions, payload): Act
if (!message || !message.content.action) return;
const action = message.content.action;
if (action.type === 'starGiftUnique') {
actions.openGiftInfoModal({ gift: action.starGift?.gift!, tabId });
return;
}
if (action.type !== 'starGift') return;
const starGift = action.starGift!;
const starGift = action.starGift! as ApiMessageActionStarGift;
const giftReceiverId = message.isOutgoing ? message.chatId : global.currentUserId!;