InteractiveSparkles: Support in modals (#6120)

This commit is contained in:
Alexander Zinchuk 2025-08-15 18:25:40 +02:00
parent 88ebd32335
commit 922fc7da35
8 changed files with 147 additions and 29 deletions

View File

@ -89,6 +89,7 @@ type OwnProps = {
observeIntersection?: ObserveFn; observeIntersection?: ObserveFn;
onClick?: (e: ReactMouseEvent<HTMLDivElement, MouseEvent>, hasMedia: boolean) => void; onClick?: (e: ReactMouseEvent<HTMLDivElement, MouseEvent>, hasMedia: boolean) => void;
onContextMenu?: (e: React.MouseEvent) => void; onContextMenu?: (e: React.MouseEvent) => void;
onMouseMove?: (e: React.MouseEvent) => void;
}; };
const Avatar: FC<OwnProps> = ({ const Avatar: FC<OwnProps> = ({
@ -115,6 +116,7 @@ const Avatar: FC<OwnProps> = ({
asMessageBubble, asMessageBubble,
onClick, onClick,
onContextMenu, onContextMenu,
onMouseMove,
}) => { }) => {
const { openStoryViewer } = getActions(); const { openStoryViewer } = getActions();
@ -304,6 +306,7 @@ const Avatar: FC<OwnProps> = ({
onClick={handleClick} onClick={handleClick}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseMove={onMouseMove}
> >
<div className="inner"> <div className="inner">
{typeof content === 'string' ? renderText(content, [isBig ? 'hq_emoji' : 'emoji']) : content} {typeof content === 'string' ? renderText(content, [isBig ? 'hq_emoji' : 'emoji']) : content}

View File

@ -0,0 +1,5 @@
.sparkles {
pointer-events: auto;
position: absolute;
top: 0;
}

View File

@ -0,0 +1,62 @@
import { memo, useEffect, useLayoutEffect, useRef } from '../../lib/teact/teact';
import buildClassName from '../../util/buildClassName';
import { PARTICLE_BURST_PARAMS, PARTICLE_COLORS, setupParticles } from '../../util/particles';
import styles from './InteractiveSparkles.module.scss';
interface OwnProps {
color?: 'purple' | 'gold' | 'blue';
centerShift?: readonly [number, number];
isDisabled?: boolean;
className?: string;
onRequestAnimation?: (animate: NoneToVoidFunction) => void;
}
const DEFAULT_PARTICLE_PARAMS = {
centerShift: [0, -36] as const,
};
const InteractiveSparkles = ({
color = 'purple',
centerShift = DEFAULT_PARTICLE_PARAMS.centerShift,
isDisabled,
className,
onRequestAnimation,
}: OwnProps) => {
const canvasRef = useRef<HTMLCanvasElement>();
useLayoutEffect(() => {
if (isDisabled) return undefined;
return setupParticles(canvasRef.current!, {
color: PARTICLE_COLORS[`${color}Gradient`],
centerShift,
});
}, [centerShift, color, isDisabled]);
useEffect(() => {
if (!onRequestAnimation) return;
const animate = () => {
if (isDisabled) return;
setupParticles(canvasRef.current!, {
color: PARTICLE_COLORS[`${color}Gradient`],
centerShift,
...PARTICLE_BURST_PARAMS,
});
};
onRequestAnimation(animate);
}, [centerShift, color, isDisabled, onRequestAnimation]);
return (
<canvas
ref={canvasRef}
className={buildClassName(styles.sparkles, className)}
/>
);
};
export default memo(InteractiveSparkles);

View File

@ -1,14 +1,14 @@
import type { TeactNode } from '@teact'; import type { TeactNode } from '@teact';
import { memo, useLayoutEffect, useRef } from '@teact'; import { memo, useRef } from '@teact';
import type { ApiSticker } from '../../../api/types'; import type { ApiSticker } from '../../../api/types';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { PARTICLE_BURST_PARAMS, PARTICLE_COLORS, setupParticles } from '../../../util/particles.ts';
import { REM } from '../../common/helpers/mediaDimensions'; import { REM } from '../../common/helpers/mediaDimensions';
import useLastCallback from '../../../hooks/useLastCallback.ts'; import useLastCallback from '../../../hooks/useLastCallback.ts';
import InteractiveSparkles from '../../common/InteractiveSparkles';
import StickerView from '../../common/StickerView'; import StickerView from '../../common/StickerView';
import SpeedingDiamond from './SpeedingDiamond.tsx'; import SpeedingDiamond from './SpeedingDiamond.tsx';
import SwayingStar from './SwayingStar.tsx'; import SwayingStar from './SwayingStar.tsx';
@ -40,29 +40,26 @@ function ParticlesHeader({
isDisabled, isDisabled,
className, className,
}: OwnProps) { }: OwnProps) {
const canvasRef = useRef<HTMLCanvasElement>();
const stickerRef = useRef<HTMLDivElement>(); const stickerRef = useRef<HTMLDivElement>();
const triggerSparklesRef = useRef<(() => void) | undefined>();
useLayoutEffect(() => {
if (isDisabled) return undefined;
return setupParticles(canvasRef.current!, {
color: PARTICLE_COLORS[`${color}Gradient`],
...PARTICLE_PARAMS,
});
}, [color, isDisabled]);
const handleMouseMove = useLastCallback(() => { const handleMouseMove = useLastCallback(() => {
setupParticles(canvasRef.current!, { triggerSparklesRef.current?.();
color: PARTICLE_COLORS[`${color}Gradient`], });
...PARTICLE_PARAMS,
...PARTICLE_BURST_PARAMS, const handleRequestAnimation = useLastCallback((animate: NoneToVoidFunction) => {
}); triggerSparklesRef.current = animate;
}); });
return ( return (
<div className={buildClassName(styles.root, className)}> <div className={buildClassName(styles.root, className)}>
<canvas ref={canvasRef} className={styles.particles} /> <InteractiveSparkles
color={color}
centerShift={PARTICLE_PARAMS.centerShift}
isDisabled={isDisabled}
className={styles.particles}
onRequestAnimation={handleRequestAnimation}
/>
{model === 'swaying-star' ? ( {model === 'swaying-star' ? (
<SwayingStar <SwayingStar

View File

@ -199,6 +199,15 @@
padding: 1rem; padding: 1rem;
} }
.avatar {
z-index: 2;
transition: transform 0.25s ease-out;
&:hover {
transform: scale(1.1);
}
}
.logoBackground { .logoBackground {
position: absolute; position: absolute;
left: 50%; left: 50%;

View File

@ -31,6 +31,7 @@ import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang'; import useOldLang from '../../../hooks/useOldLang';
import Avatar from '../../common/Avatar'; import Avatar from '../../common/Avatar';
import InteractiveSparkles from '../../common/InteractiveSparkles';
import SafeLink from '../../common/SafeLink'; import SafeLink from '../../common/SafeLink';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
import Modal from '../../ui/Modal'; import Modal from '../../ui/Modal';
@ -45,8 +46,6 @@ import StarGiftCategoryList from './StarGiftCategoryList';
import styles from './GiftModal.module.scss'; import styles from './GiftModal.module.scss';
import StarsBackground from '../../../assets/stars-bg.png';
export type OwnProps = { export type OwnProps = {
modal: TabState['giftModal']; modal: TabState['giftModal'];
}; };
@ -68,6 +67,7 @@ type StateProps = {
const AVATAR_SIZE = 100; const AVATAR_SIZE = 100;
const INTERSECTION_THROTTLE = 200; const INTERSECTION_THROTTLE = 200;
const SCROLL_THROTTLE = 200; const SCROLL_THROTTLE = 200;
const AVATAR_SPARKLES_CENTER_SHIFT = [0, -50] as const;
const runThrottledForScroll = throttle((cb) => cb(), SCROLL_THROTTLE, true); const runThrottledForScroll = throttle((cb) => cb(), SCROLL_THROTTLE, true);
@ -104,6 +104,7 @@ const GiftModal: FC<OwnProps & StateProps> = ({
const [isGiftScreenHeaderForStarGifts, setIsGiftScreenHeaderForStarGifts] = useState(false); const [isGiftScreenHeaderForStarGifts, setIsGiftScreenHeaderForStarGifts] = useState(false);
const [selectedCategory, setSelectedCategory] = useState<StarGiftCategory>('all'); const [selectedCategory, setSelectedCategory] = useState<StarGiftCategory>('all');
const triggerSparklesRef = useRef<(() => void) | undefined>();
const areAllGiftsDisallowed = useMemo(() => { const areAllGiftsDisallowed = useMemo(() => {
if (!disallowedGifts) { if (!disallowedGifts) {
@ -357,15 +358,30 @@ const GiftModal: FC<OwnProps & StateProps> = ({
handleCloseModal(); handleCloseModal();
}); });
const handleAvatarMouseMove = useLastCallback(() => {
triggerSparklesRef.current?.();
});
const handleRequestAnimation = useLastCallback((animate: NoneToVoidFunction) => {
triggerSparklesRef.current = animate;
});
function renderMainScreen() { function renderMainScreen() {
return ( return (
<div ref={scrollerRef} className={buildClassName(styles.main, 'custom-scroll')} onScroll={handleScroll}> <div ref={scrollerRef} className={buildClassName(styles.main, 'custom-scroll')} onScroll={handleScroll}>
<div className={styles.avatars}> <div className={styles.avatars}>
<Avatar <Avatar
className={styles.avatar}
size={AVATAR_SIZE} size={AVATAR_SIZE}
peer={peer} peer={peer}
onMouseMove={handleAvatarMouseMove}
/>
<InteractiveSparkles
className={styles.logoBackground}
color="gold"
centerShift={AVATAR_SPARKLES_CENTER_SHIFT}
onRequestAnimation={handleRequestAnimation}
/> />
<img className={styles.logoBackground} src={StarsBackground} alt="" draggable={false} />
</div> </div>
{!isSelf && !chat && !disallowedGifts?.shouldDisallowPremiumGifts && ( {!isSelf && !chat && !disallowedGifts?.shouldDisallowPremiumGifts && (
<> <>

View File

@ -135,3 +135,12 @@
width: 150px; width: 150px;
height: 150px; height: 150px;
} }
.avatar {
z-index: 2;
transition: transform 0.25s ease-out;
&:hover {
transform: scale(1.1);
}
}

View File

@ -1,5 +1,5 @@
import type { FC } from '../../../../lib/teact/teact'; import type { FC } from '../../../../lib/teact/teact';
import { memo, useMemo } from '../../../../lib/teact/teact'; import { memo, useMemo, useRef } from '../../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../../global'; import { getActions, withGlobal } from '../../../../global';
import type { import type {
@ -39,6 +39,7 @@ import AnimatedIconFromSticker from '../../../common/AnimatedIconFromSticker';
import Avatar from '../../../common/Avatar'; import Avatar from '../../../common/Avatar';
import Icon from '../../../common/icons/Icon'; import Icon from '../../../common/icons/Icon';
import StarIcon from '../../../common/icons/StarIcon'; import StarIcon from '../../../common/icons/StarIcon';
import InteractiveSparkles from '../../../common/InteractiveSparkles';
import SafeLink from '../../../common/SafeLink'; import SafeLink from '../../../common/SafeLink';
import TableInfoModal, { type TableData } from '../../common/TableInfoModal'; import TableInfoModal, { type TableData } from '../../common/TableInfoModal';
import UniqueGiftHeader from '../../gift/UniqueGiftHeader'; import UniqueGiftHeader from '../../gift/UniqueGiftHeader';
@ -46,7 +47,7 @@ import PaidMediaThumb from './PaidMediaThumb';
import styles from './StarsTransactionModal.module.scss'; import styles from './StarsTransactionModal.module.scss';
import StarsBackground from '../../../../assets/stars-bg.png'; const AVATAR_SPARKLES_CENTER_SHIFT = [0, -50] as const;
export type OwnProps = { export type OwnProps = {
modal: TabState['starsTransactionModal']; modal: TabState['starsTransactionModal'];
@ -71,6 +72,7 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
const lang = useLang(); const lang = useLang();
const oldLang = useOldLang(); const oldLang = useOldLang();
const { transaction } = modal || {}; const { transaction } = modal || {};
const triggerSparklesRef = useRef<(() => void) | undefined>();
const handleOpenMedia = useLastCallback(() => { const handleOpenMedia = useLastCallback(() => {
const media = transaction?.extendedMedia; const media = transaction?.extendedMedia;
@ -82,6 +84,14 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
}); });
}); });
const handleAvatarMouseMove = useLastCallback(() => {
triggerSparklesRef.current?.();
});
const handleRequestAnimation = useLastCallback((animate: NoneToVoidFunction) => {
triggerSparklesRef.current = animate;
});
const starModalData = useMemo(() => { const starModalData = useMemo(() => {
if (!transaction) { if (!transaction) {
return undefined; return undefined;
@ -160,14 +170,20 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
/> />
)} )}
{shouldDisplayAvatar && ( {shouldDisplayAvatar && (
<Avatar peer={avatarPeer} webPhoto={photo} size="giant" /> <Avatar
className={styles.avatar}
peer={avatarPeer}
webPhoto={photo}
size="giant"
onMouseMove={handleAvatarMouseMove}
/>
)} )}
{!sticker && !transaction.isPostsSearch && ( {!sticker && !transaction.isPostsSearch && (
<img <InteractiveSparkles
className={buildClassName(styles.starsBackground)} className={buildClassName(styles.starsBackground)}
src={StarsBackground} color="gold"
alt="" onRequestAnimation={handleRequestAnimation}
draggable={false} centerShift={AVATAR_SPARKLES_CENTER_SHIFT}
/> />
)} )}
{Boolean(title) && <h1 className={styles.title}>{title}</h1>} {Boolean(title) && <h1 className={styles.title}>{title}</h1>}
@ -305,7 +321,8 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
tableData, tableData,
footer, footer,
}; };
}, [transaction, oldLang, lang, peer, canPlayAnimatedEmojis, topSticker, paidMessageCommission]); }, [transaction, oldLang, lang, peer, canPlayAnimatedEmojis, topSticker,
paidMessageCommission, handleRequestAnimation]);
const prevModalData = usePrevious(starModalData); const prevModalData = usePrevious(starModalData);
const renderingModalData = prevModalData || starModalData; const renderingModalData = prevModalData || starModalData;