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;
onClick?: (e: ReactMouseEvent<HTMLDivElement, MouseEvent>, hasMedia: boolean) => void;
onContextMenu?: (e: React.MouseEvent) => void;
onMouseMove?: (e: React.MouseEvent) => void;
};
const Avatar: FC<OwnProps> = ({
@ -115,6 +116,7 @@ const Avatar: FC<OwnProps> = ({
asMessageBubble,
onClick,
onContextMenu,
onMouseMove,
}) => {
const { openStoryViewer } = getActions();
@ -304,6 +306,7 @@ const Avatar: FC<OwnProps> = ({
onClick={handleClick}
onContextMenu={onContextMenu}
onMouseDown={handleMouseDown}
onMouseMove={onMouseMove}
>
<div className="inner">
{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 { memo, useLayoutEffect, useRef } from '@teact';
import { memo, useRef } from '@teact';
import type { ApiSticker } from '../../../api/types';
import buildClassName from '../../../util/buildClassName';
import { PARTICLE_BURST_PARAMS, PARTICLE_COLORS, setupParticles } from '../../../util/particles.ts';
import { REM } from '../../common/helpers/mediaDimensions';
import useLastCallback from '../../../hooks/useLastCallback.ts';
import InteractiveSparkles from '../../common/InteractiveSparkles';
import StickerView from '../../common/StickerView';
import SpeedingDiamond from './SpeedingDiamond.tsx';
import SwayingStar from './SwayingStar.tsx';
@ -40,29 +40,26 @@ function ParticlesHeader({
isDisabled,
className,
}: OwnProps) {
const canvasRef = useRef<HTMLCanvasElement>();
const stickerRef = useRef<HTMLDivElement>();
useLayoutEffect(() => {
if (isDisabled) return undefined;
return setupParticles(canvasRef.current!, {
color: PARTICLE_COLORS[`${color}Gradient`],
...PARTICLE_PARAMS,
});
}, [color, isDisabled]);
const triggerSparklesRef = useRef<(() => void) | undefined>();
const handleMouseMove = useLastCallback(() => {
setupParticles(canvasRef.current!, {
color: PARTICLE_COLORS[`${color}Gradient`],
...PARTICLE_PARAMS,
...PARTICLE_BURST_PARAMS,
});
triggerSparklesRef.current?.();
});
const handleRequestAnimation = useLastCallback((animate: NoneToVoidFunction) => {
triggerSparklesRef.current = animate;
});
return (
<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' ? (
<SwayingStar

View File

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

View File

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

View File

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