From c6c2336a665631b8ef9e6fe221f392a1da3f3cac Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Tue, 29 Jul 2025 14:33:44 +0200 Subject: [PATCH] Introduce Particles Header --- src/assets/icons/GoldStar.svg | 18 + src/assets/icons/GoldStarFill.svg | 10 + src/assets/icons/StarLogo.svg | 1 - src/assets/premium/PremiumLogo.svg | 181 ----- src/assets/premium/PremiumStar.svg | 18 + src/assets/premium/PremiumStarFill.svg | 10 + src/components/common/AnimatedSticker.tsx | 10 +- src/components/main/premium/GiveawayModal.tsx | 2 +- .../main/premium/PremiumMainModal.module.scss | 7 - .../main/premium/PremiumMainModal.tsx | 62 +- .../modals/common/ParticlesHeader.module.scss | 32 + .../modals/common/ParticlesHeader.tsx | 76 ++ .../modals/common/SpeedingDiamond.module.scss | 21 + .../modals/common/SpeedingDiamond.tsx | 80 ++ .../modals/common/SwayingStar.module.scss | 75 ++ src/components/modals/common/SwayingStar.tsx | 65 ++ .../modals/giftcode/GiftCodeModal.tsx | 2 +- .../stars/StarsBalanceModal.module.scss | 23 - .../modals/stars/StarsBalanceModal.tsx | 43 +- .../modals/stars/gift/StarsGiftModal.tsx | 2 +- src/util/particles.ts | 689 ++++++++++++++++++ 21 files changed, 1151 insertions(+), 276 deletions(-) create mode 100644 src/assets/icons/GoldStar.svg create mode 100644 src/assets/icons/GoldStarFill.svg delete mode 100644 src/assets/icons/StarLogo.svg delete mode 100644 src/assets/premium/PremiumLogo.svg create mode 100644 src/assets/premium/PremiumStar.svg create mode 100644 src/assets/premium/PremiumStarFill.svg create mode 100644 src/components/modals/common/ParticlesHeader.module.scss create mode 100644 src/components/modals/common/ParticlesHeader.tsx create mode 100644 src/components/modals/common/SpeedingDiamond.module.scss create mode 100644 src/components/modals/common/SpeedingDiamond.tsx create mode 100644 src/components/modals/common/SwayingStar.module.scss create mode 100644 src/components/modals/common/SwayingStar.tsx create mode 100644 src/util/particles.ts diff --git a/src/assets/icons/GoldStar.svg b/src/assets/icons/GoldStar.svg new file mode 100644 index 000000000..16dc0ecb1 --- /dev/null +++ b/src/assets/icons/GoldStar.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/GoldStarFill.svg b/src/assets/icons/GoldStarFill.svg new file mode 100644 index 000000000..3fd3e5d25 --- /dev/null +++ b/src/assets/icons/GoldStarFill.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/icons/StarLogo.svg b/src/assets/icons/StarLogo.svg deleted file mode 100644 index d2058b1c8..000000000 --- a/src/assets/icons/StarLogo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/assets/premium/PremiumLogo.svg b/src/assets/premium/PremiumLogo.svg deleted file mode 100644 index ebefe6c80..000000000 --- a/src/assets/premium/PremiumLogo.svg +++ /dev/null @@ -1,181 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/assets/premium/PremiumStar.svg b/src/assets/premium/PremiumStar.svg new file mode 100644 index 000000000..7ea6d3640 --- /dev/null +++ b/src/assets/premium/PremiumStar.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/assets/premium/PremiumStarFill.svg b/src/assets/premium/PremiumStarFill.svg new file mode 100644 index 000000000..73e4cd627 --- /dev/null +++ b/src/assets/premium/PremiumStarFill.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/components/common/AnimatedSticker.tsx b/src/components/common/AnimatedSticker.tsx index 150b4fb04..3b265aae8 100644 --- a/src/components/common/AnimatedSticker.tsx +++ b/src/components/common/AnimatedSticker.tsx @@ -179,11 +179,15 @@ const AnimatedSticker: FC = ({ useSharedIntersectionObserver(sharedCanvas, throttledInit); useEffect(() => { - if (!animation) return; - - animation.setColor(rgbColor.current); + animation?.setColor(rgbColor.current); }, [color, animation]); + useEffect(() => { + if (typeof speed === 'number') { + animation?.setSpeed(speed); + } + }, [speed, animation]); + useUnmountCleanup(() => { animationRef.current?.removeView(viewId); }); diff --git a/src/components/main/premium/GiveawayModal.tsx b/src/components/main/premium/GiveawayModal.tsx index 425b05f2c..86c80d177 100644 --- a/src/components/main/premium/GiveawayModal.tsx +++ b/src/components/main/premium/GiveawayModal.tsx @@ -62,7 +62,7 @@ import GiftBlueRound from '../../../assets/premium/GiftBlueRound.svg'; import GiftGreenRound from '../../../assets/premium/GiftGreenRound.svg'; import GiftRedRound from '../../../assets/premium/GiftRedRound.svg'; import GiftStar from '../../../assets/premium/GiftStar.svg'; -import PremiumLogo from '../../../assets/premium/PremiumLogo.svg'; +import PremiumLogo from '../../../assets/premium/PremiumStar.svg'; export type OwnProps = { isOpen?: boolean; diff --git a/src/components/main/premium/PremiumMainModal.module.scss b/src/components/main/premium/PremiumMainModal.module.scss index 33f4dcc8b..bfee93ce6 100644 --- a/src/components/main/premium/PremiumMainModal.module.scss +++ b/src/components/main/premium/PremiumMainModal.module.scss @@ -43,13 +43,6 @@ @include mixins.adapt-padding-to-scrollbar(0.5rem); } -.logo { - width: 6.25rem; - height: 6.25rem; - min-height: 6.25rem; - margin: 1rem; -} - .status-emoji { --custom-emoji-size: 8rem; diff --git a/src/components/main/premium/PremiumMainModal.tsx b/src/components/main/premium/PremiumMainModal.tsx index bd98ed202..2f49579a5 100644 --- a/src/components/main/premium/PremiumMainModal.tsx +++ b/src/components/main/premium/PremiumMainModal.tsx @@ -1,22 +1,21 @@ -import type { FC } from '../../../lib/teact/teact'; -import type React from '../../../lib/teact/teact'; -import { - memo, useEffect, useMemo, useRef, useState, -} from '../../../lib/teact/teact'; +import type { FC } from '@teact'; +import { memo, useEffect, useMemo, useRef, useState } from '@teact'; import { getActions, withGlobal } from '../../../global'; import type { - ApiPremiumPromo, ApiPremiumSection, ApiPremiumSubscriptionOption, ApiSticker, ApiStickerSet, ApiUser, + ApiPremiumPromo, + ApiPremiumSection, + ApiPremiumSubscriptionOption, + ApiSticker, + ApiStickerSet, + ApiUser, } from '../../../api/types'; import type { GlobalState } from '../../../global/types'; import type { LangPair } from '../../../types/language'; import { PREMIUM_FEATURE_SECTIONS, TME_LINK_PREFIX } from '../../../config'; import { getUserFullName } from '../../../global/helpers'; -import { - selectIsCurrentUserPremium, selectStickerSet, - selectTabState, selectUser, -} from '../../../global/selectors'; +import { selectIsCurrentUserPremium, selectStickerSet, selectTabState, selectUser } from '../../../global/selectors'; import { selectPremiumLimit } from '../../../global/selectors/limits'; import buildClassName from '../../../util/buildClassName'; import { formatCurrency } from '../../../util/formatCurrency'; @@ -31,14 +30,12 @@ import useSyncEffect from '../../../hooks/useSyncEffect'; import CustomEmoji from '../../common/CustomEmoji'; import Icon from '../../common/icons/Icon'; +import ParticlesHeader from '../../modals/common/ParticlesHeader.tsx'; import Button from '../../ui/Button'; import Modal from '../../ui/Modal'; import Transition from '../../ui/Transition'; import PremiumFeatureItem from './PremiumFeatureItem'; -import PremiumFeatureModal, { - PREMIUM_FEATURE_DESCRIPTIONS, - PREMIUM_FEATURE_TITLES, -} from './PremiumFeatureModal'; +import PremiumFeatureModal, { PREMIUM_FEATURE_DESCRIPTIONS, PREMIUM_FEATURE_TITLES } from './PremiumFeatureModal'; import PremiumSubscriptionOption from './PremiumSubscriptionOption'; import styles from './PremiumMainModal.module.scss'; @@ -51,7 +48,6 @@ import PremiumEmoji from '../../../assets/premium/PremiumEmoji.svg'; import PremiumFile from '../../../assets/premium/PremiumFile.svg'; import PremiumLastSeen from '../../../assets/premium/PremiumLastSeen.svg'; import PremiumLimits from '../../../assets/premium/PremiumLimits.svg'; -import PremiumLogo from '../../../assets/premium/PremiumLogo.svg'; import PremiumMessagePrivacy from '../../../assets/premium/PremiumMessagePrivacy.svg'; import PremiumReactions from '../../../assets/premium/PremiumReactions.svg'; import PremiumSpeed from '../../../assets/premium/PremiumSpeed.svg'; @@ -374,29 +370,35 @@ const PremiumMainModal: FC = ({ size="smaller" className={styles.closeButton} color="translucent" - onClick={() => closePremiumModal()} ariaLabel={oldLang('Close')} > - {(fromUserStatusEmoji && !isGift) ? ( - ) : ( - + <> + +

+ {getHeaderText()} +

+
+ {renderText(getHeaderDescription(), ['simple_markdown', 'emoji'])} +
+ )} -

- {getHeaderText()} -

-
- {renderText(getHeaderDescription(), ['simple_markdown', 'emoji'])} -
{!isPremium && !isGift && renderSubscriptionOptions()}

diff --git a/src/components/modals/common/ParticlesHeader.module.scss b/src/components/modals/common/ParticlesHeader.module.scss new file mode 100644 index 000000000..fac56c93c --- /dev/null +++ b/src/components/modals/common/ParticlesHeader.module.scss @@ -0,0 +1,32 @@ +.root { + display: flex; + flex-direction: column; + align-items: center; + padding-top: 9rem; +} + +.particles { + position: absolute; + top: 0; +} + +.title { + z-index: 0; + + margin-inline: 0.5rem; + + font-size: 1.5rem; + font-weight: var(--font-weight-medium); + text-align: center; +} + +.description { + z-index: 0; + + margin-bottom: 1rem; + margin-inline: 0.5rem; + + line-height: 1.375; + text-align: center; + text-wrap: balance; +} diff --git a/src/components/modals/common/ParticlesHeader.tsx b/src/components/modals/common/ParticlesHeader.tsx new file mode 100644 index 000000000..ce4b8613c --- /dev/null +++ b/src/components/modals/common/ParticlesHeader.tsx @@ -0,0 +1,76 @@ +import type { TeactNode } from '@teact'; +import { memo, useLayoutEffect, useRef } from '@teact'; + +import { PARTICLE_BURST_PARAMS, PARTICLE_COLORS, setupParticles } from '../../../util/particles.ts'; + +import useLastCallback from '../../../hooks/useLastCallback.ts'; + +import SpeedingDiamond from './SpeedingDiamond.tsx'; +import SwayingStar from './SwayingStar.tsx'; + +import styles from './ParticlesHeader.module.scss'; + +interface OwnProps { + model: 'swaying-star' | 'speeding-diamond'; + color: 'purple' | 'gold' | 'blue'; + title: TeactNode; + description: TeactNode; + isDisabled?: boolean; +} + +const PARTICLE_PARAMS = { + centerShift: [0, -36] as const, +}; + +function ParticlesHeader({ + model, + color, + title, + description, + isDisabled, +}: OwnProps) { + const canvasRef = useRef(); + + useLayoutEffect(() => { + if (isDisabled) return undefined; + + return setupParticles(canvasRef.current!, { + color: PARTICLE_COLORS[`${color}Gradient`], + ...PARTICLE_PARAMS, + }); + }, [color, isDisabled]); + + const handleMouseMove = useLastCallback(() => { + setupParticles(canvasRef.current!, { + color: PARTICLE_COLORS[`${color}Gradient`], + ...PARTICLE_PARAMS, + ...PARTICLE_BURST_PARAMS, + }); + }); + + return ( +
+ + + {model === 'swaying-star' ? ( + + ) : model === 'speeding-diamond' && ( + + )} + +

+ {title} +

+ +
+ {description} +
+
+ ); +} + +export default memo(ParticlesHeader); diff --git a/src/components/modals/common/SpeedingDiamond.module.scss b/src/components/modals/common/SpeedingDiamond.module.scss new file mode 100644 index 000000000..5628dc0e8 --- /dev/null +++ b/src/components/modals/common/SpeedingDiamond.module.scss @@ -0,0 +1,21 @@ +.root { + position: absolute; + z-index: 1; + top: 0; + + display: flex; + align-items: center; + justify-content: center; + + width: 100%; + height: 14.375rem; +} + +.diamond { + margin-top: calc(-2rem * 2); // Centered minus center shift + transition: transform 0.25s ease-out; + + &:hover { + transform: scale(1.1); + } +} diff --git a/src/components/modals/common/SpeedingDiamond.tsx b/src/components/modals/common/SpeedingDiamond.tsx new file mode 100644 index 000000000..d41eef5f6 --- /dev/null +++ b/src/components/modals/common/SpeedingDiamond.tsx @@ -0,0 +1,80 @@ +import { memo, useState } from '@teact'; + +import { requestMutation } from '../../../lib/fasterdom/fasterdom.ts'; +import { animateSingle } from '../../../util/animation.ts'; +import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets.ts'; + +import useLastCallback from '../../../hooks/useLastCallback.ts'; + +import AnimatedIconWithPreview from '../../common/AnimatedIconWithPreview.tsx'; + +import styles from './SpeedingDiamond.module.scss'; + +interface OwnProps { + onMouseMove: NoneToVoidFunction; +} + +const MAX_SPEED = 5; +const MIN_SPEED = 1; +const SLOWDOWN_DELAY = 300; +const SLOWDOWN_DURATION = 1500; + +let slowdownTimeout: number | undefined; +let isAnimating = true; + +function SpeedingDiamond({ onMouseMove }: OwnProps) { + const [speed, setSpeed] = useState(MIN_SPEED); + + const handleMouseMove = useLastCallback(() => { + if (slowdownTimeout) { + clearTimeout(slowdownTimeout); + slowdownTimeout = undefined; + } + + slowdownTimeout = window.setTimeout(() => { + const startAt = Date.now(); + + isAnimating = true; + + animateSingle(() => { + if (!isAnimating) return false; + + const t = Math.min((Date.now() - startAt) / SLOWDOWN_DURATION, 1); + const speed = (MAX_SPEED - MIN_SPEED) * (1 - transition(t)); + + setSpeed(speed); + + isAnimating = t < 1 && speed > 1; + + return isAnimating; + }, requestMutation); + }, SLOWDOWN_DELAY); + + isAnimating = false; + setSpeed(MAX_SPEED); + onMouseMove(); + }); + + return ( +
+
+ +
+
+ ); +} + +export default memo(SpeedingDiamond); + +function transition(t: number) { + return 1 - ((1 - t) ** 2); +} diff --git a/src/components/modals/common/SwayingStar.module.scss b/src/components/modals/common/SwayingStar.module.scss new file mode 100644 index 000000000..fc48e9ba9 --- /dev/null +++ b/src/components/modals/common/SwayingStar.module.scss @@ -0,0 +1,75 @@ +.root { + position: absolute; + z-index: 1; + top: 0; + + display: flex; + align-items: center; + justify-content: center; + + width: 100%; + height: 14.375rem; +} + +.star { + pointer-events: none; // Let wrapper handle mouse events + + position: relative; + transform-style: preserve-3d; + + width: 6.25rem; + height: 6.25rem; + min-height: 6.25rem; + margin-top: calc(-2.25rem * 2); // Centered minus center shift + + background-image: url('../../../assets/icons/GoldStar.svg'); + background-repeat: no-repeat; + background-position: center; + background-size: contain; + + transition: transform 0.6s ease-out; + + // Create depth effect with pseudo-elements + &::before, + &::after { + pointer-events: none; + content: ''; + + position: absolute; + top: 0; + left: 0; + + width: 100%; + height: 100%; + + background-image: url('../../../assets/icons/GoldStarFill.svg'); + background-repeat: no-repeat; + background-position: center; + background-size: contain; + } + + &::before { + z-index: -1; + transform: translateZ(-3px); + filter: brightness(0.8); + } + + &::after { + z-index: -2; + transform: translateZ(-6px); + filter: brightness(0.8); + } + + .root:hover & { + transition-duration: 0.5s; + } + + &_purple { + background-image: url('../../../assets/premium/PremiumStar.svg'); + + &::before, + &::after { + background-image: url('../../../assets/premium/PremiumStarFill.svg'); + } + } +} diff --git a/src/components/modals/common/SwayingStar.tsx b/src/components/modals/common/SwayingStar.tsx new file mode 100644 index 000000000..f236da6ca --- /dev/null +++ b/src/components/modals/common/SwayingStar.tsx @@ -0,0 +1,65 @@ +import { memo, useRef } from '@teact'; + +import { requestMutation } from '../../../lib/fasterdom/fasterdom.ts'; +import buildClassName from '../../../util/buildClassName.ts'; + +import useLastCallback from '../../../hooks/useLastCallback.ts'; + +import styles from './SwayingStar.module.scss'; + +interface OwnProps { + color: 'purple' | 'gold'; + centerShift: readonly [number, number]; + onMouseMove: NoneToVoidFunction; +} + +const INTERACTIVE_RADIUS = 50; + +function SwayingStar({ + color, + centerShift, + onMouseMove, +}: OwnProps) { + const starRef = useRef(); + + const handleMouseMove = useLastCallback((e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2 + centerShift[0]; + const centerY = rect.top + rect.height / 2 + centerShift[1]; + const mouseX = e.clientX - centerX; + const mouseY = e.clientY - centerY; + const normalizedX = Math.max(-1, Math.min(1, mouseX / INTERACTIVE_RADIUS)); + const normalizedY = Math.max(-1, Math.min(1, mouseY / INTERACTIVE_RADIUS)); + const rotateY = normalizedX * 40; + const rotateX = -normalizedY * 40; + + requestMutation(() => { + starRef.current!.style.transform = `scale(1.1) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; + }); + + onMouseMove(); + }); + + const handleMouseLeave = useLastCallback(() => { + requestMutation(() => { + starRef.current!.style.transform = ''; + }); + }); + + return ( +
+
+
+ ); +} + +export default memo(SwayingStar); diff --git a/src/components/modals/giftcode/GiftCodeModal.tsx b/src/components/modals/giftcode/GiftCodeModal.tsx index 510d7c8be..fc49ff83e 100644 --- a/src/components/modals/giftcode/GiftCodeModal.tsx +++ b/src/components/modals/giftcode/GiftCodeModal.tsx @@ -18,7 +18,7 @@ import TableInfoModal, { type TableData } from '../common/TableInfoModal'; import styles from './GiftCodeModal.module.scss'; -import PremiumLogo from '../../../assets/premium/PremiumLogo.svg'; +import PremiumLogo from '../../../assets/premium/PremiumStar.svg'; export type OwnProps = { modal: TabState['giftCodeModal']; diff --git a/src/components/modals/stars/StarsBalanceModal.module.scss b/src/components/modals/stars/StarsBalanceModal.module.scss index 67b77c094..b4166c8df 100644 --- a/src/components/modals/stars/StarsBalanceModal.module.scss +++ b/src/components/modals/stars/StarsBalanceModal.module.scss @@ -67,13 +67,6 @@ background-color: var(--color-background-secondary); } -.logo { - width: 6.25rem; - height: 6.25rem; - min-height: 6.25rem; - margin: 1rem; -} - .topUpButton, .tonBalanceContainer { margin-bottom: 0.5rem; @@ -105,22 +98,6 @@ color: var(--color-primary); } -.logoBackground { - position: absolute; - top: 0.75rem; - left: 50%; - transform: translateX(-50%); - - height: 8rem; -} - -.headerHext { - margin-inline: 0.5rem; - font-size: 1.5rem; - font-weight: var(--font-weight-medium); - text-align: center; -} - .description { margin-bottom: 1rem; margin-inline: 0.5rem; diff --git a/src/components/modals/stars/StarsBalanceModal.tsx b/src/components/modals/stars/StarsBalanceModal.tsx index de309aef6..4d35225f8 100644 --- a/src/components/modals/stars/StarsBalanceModal.tsx +++ b/src/components/modals/stars/StarsBalanceModal.tsx @@ -1,7 +1,4 @@ -import type React from '../../../lib/teact/teact'; -import { - memo, useEffect, useMemo, useState, -} from '../../../lib/teact/teact'; +import { memo, useEffect, useMemo, useState } from '@teact'; import { getActions, getGlobal, withGlobal } from '../../../global'; import type { ApiStarTopupOption } from '../../../api/types'; @@ -20,7 +17,6 @@ import { getPeerTitle } from '../../../global/helpers/peers'; import { selectChat, selectIsPremiumPurchaseBlocked, selectUser } from '../../../global/selectors'; import buildClassName from '../../../util/buildClassName'; import { convertCurrencyFromBaseUnit, convertTonToUsd, formatCurrencyAsString } from '../../../util/formatCurrency'; -import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets'; import renderText from '../../common/helpers/renderText'; import useFlag from '../../../hooks/useFlag'; @@ -28,7 +24,6 @@ import useLang from '../../../hooks/useLang'; import useLastCallback from '../../../hooks/useLastCallback'; import useOldLang from '../../../hooks/useOldLang'; -import AnimatedIconWithPreview from '../../common/AnimatedIconWithPreview'; import Icon from '../../common/icons/Icon'; import SafeLink from '../../common/SafeLink'; import Button from '../../ui/Button'; @@ -36,6 +31,7 @@ import InfiniteScroll from '../../ui/InfiniteScroll'; import Modal from '../../ui/Modal'; import TabList, { type TabWithProperties } from '../../ui/TabList'; import Transition from '../../ui/Transition'; +import ParticlesHeader from '../common/ParticlesHeader.tsx'; import BalanceBlock from './BalanceBlock'; import StarTopupOptionList from './StarTopupOptionList'; import StarsSubscriptionItem from './subscription/StarsSubscriptionItem'; @@ -43,9 +39,6 @@ import StarsTransactionItem from './transaction/StarsTransactionItem'; import styles from './StarsBalanceModal.module.scss'; -import StarLogo from '../../../assets/icons/StarLogo.svg'; -import StarsBackground from '../../../assets/stars-bg.png'; - const TRANSACTION_TYPES = ['all', 'inbound', 'outbound'] as const; const TRANSACTION_TABS_KEYS: RegularLangKey[] = [ 'StarsTransactionsAll', @@ -178,17 +171,16 @@ const StarsBalanceModal = ({ const renderStarsSection = () => { return ( <> - - -

- {starsNeeded ? oldLang('StarsNeededTitle', ongoingTransactionAmount) : oldLang('TelegramStars')} -

-
- {renderText( + + isDisabled={!isOpen} + /> {canBuyPremium && !areBuyOptionsShown && (