diff --git a/src/components/left/settings/SettingsExperimental.tsx b/src/components/left/settings/SettingsExperimental.tsx index b35d5a4c1..354fbee50 100644 --- a/src/components/left/settings/SettingsExperimental.tsx +++ b/src/components/left/settings/SettingsExperimental.tsx @@ -1,13 +1,13 @@ import type { FC } from '../../../lib/teact/teact'; import React, { - memo, useCallback, useEffect, useState, + memo, useCallback, useEffect, useRef, useState, } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; import { DEBUG_LOG_FILENAME } from '../../../config'; import { getDebugLogs } from '../../../util/debugConsole'; import download from '../../../util/download'; -import { IS_ELECTRON, IS_WAVE_TRANSFORM_SUPPORTED } from '../../../util/windowEnvironment'; +import { IS_ELECTRON, IS_SNAP_EFFECT_SUPPORTED, IS_WAVE_TRANSFORM_SUPPORTED } from '../../../util/windowEnvironment'; import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets'; import useHistoryBack from '../../../hooks/useHistoryBack'; @@ -15,6 +15,7 @@ import useLastCallback from '../../../hooks/useLastCallback'; import useOldLang from '../../../hooks/useOldLang'; import AnimatedIconWithPreview from '../../common/AnimatedIconWithPreview'; +import { animateSnap } from '../../main/visualEffects/SnapEffectContainer'; import Checkbox from '../../ui/Checkbox'; import ListItem from '../../ui/ListItem'; @@ -39,6 +40,11 @@ const SettingsExperimental: FC = ({ shouldDebugExportedSenders, }) => { const { requestConfetti, setSettingOption, requestWave } = getActions(); + + // eslint-disable-next-line no-null/no-null + const snapButtonRef = useRef(null); + const [isSnapButtonAnimating, setIsSnapButtonAnimating] = useState(false); + const lang = useOldLang(); const [isAutoUpdateEnabled, setIsAutoUpdateEnabled] = useState(false); @@ -65,6 +71,19 @@ const SettingsExperimental: FC = ({ requestWave({ startX: e.clientX, startY: e.clientY }); }); + const handleSnap = useLastCallback(() => { + const button = snapButtonRef.current; + if (!button) return; + + if (animateSnap(button)) { + setIsSnapButtonAnimating(true); + // Manual reset for debug + setTimeout(() => { + setIsSnapButtonAnimating(false); + }, 1500); + } + }); + return (
@@ -92,6 +111,15 @@ const SettingsExperimental: FC = ({ >
Start wave
+ +
Vaporize this button
+
{Boolean(sectionExpandedStates[index]) && (
- {options.map(({ key, label, disabled }) => ( - - ))} + {options.map(({ key, label, disabled }) => { + if (key === 'snapEffect' && !IS_SNAP_EFFECT_SUPPORTED) return undefined; + return ( + + ); + })}
)}
diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index 152a4a7e7..c09371178 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -87,6 +87,7 @@ import PremiumMainModal from './premium/PremiumMainModal.async'; import StarsGiftingPickerModal from './premium/StarsGiftingPickerModal.async'; import SafeLinkModal from './SafeLinkModal.async'; import ConfettiContainer from './visualEffects/ConfettiContainer'; +import SnapEffectContainer from './visualEffects/SnapEffectContainer'; import WaveContainer from './visualEffects/WaveContainer'; import './Main.scss'; @@ -566,6 +567,7 @@ const Main = ({ {IS_WAVE_TRANSFORM_SUPPORTED && } + diff --git a/src/components/main/visualEffects/SnapEffectContainer.module.scss b/src/components/main/visualEffects/SnapEffectContainer.module.scss new file mode 100644 index 000000000..5d1b41643 --- /dev/null +++ b/src/components/main/visualEffects/SnapEffectContainer.module.scss @@ -0,0 +1,29 @@ +.root { + position: fixed; + inset: 0; + pointer-events: none; + + z-index: var(--z-overlay-effects); +} + +.ghost { + position: absolute; + overflow: visible !important; + + animation: scale 1s ease-in forwards !important; + transform-origin: bottom; +} + +.elementContainer { + overflow: visible; +} + +@keyframes scale { + 0% { + transform: scale(1); + } + + 100% { + transform: scale(1.2); + } +} diff --git a/src/components/main/visualEffects/SnapEffectContainer.tsx b/src/components/main/visualEffects/SnapEffectContainer.tsx new file mode 100644 index 000000000..a960f9a49 --- /dev/null +++ b/src/components/main/visualEffects/SnapEffectContainer.tsx @@ -0,0 +1,172 @@ +import React, { memo } from '../../../lib/teact/teact'; +import { getGlobal } from '../../../global'; + +import { SNAP_EFFECT_CONTAINER_ID, SNAP_EFFECT_ID } from '../../../config'; +import { selectCanAnimateSnapEffect } from '../../../global/selectors'; +import generateUniqueId from '../../../util/generateUniqueId'; +import { SVG_NAMESPACE } from '../../../util/svgController'; + +import styles from './SnapEffectContainer.module.scss'; + +const VISIBLITY_MARGIN = 50; +const DURATION = 1000; + +const SnapEffectContainer = () => { + return ( +
+ ); +}; + +export default memo(SnapEffectContainer); + +export function animateSnap(element: HTMLElement) { + const global = getGlobal(); + const canPlayEffect = selectCanAnimateSnapEffect(global); + if (!canPlayEffect) return false; + // Get element current fixed position on screen + const rect = element.getBoundingClientRect(); + const x = rect.left + window.scrollX; + const y = rect.top + window.scrollY; + const width = rect.width; + const height = rect.height; + + // Check for visibility + if (x + width + VISIBLITY_MARGIN < 0 || x - VISIBLITY_MARGIN > window.innerWidth + || y + height + VISIBLITY_MARGIN < 0 || y - VISIBLITY_MARGIN > window.innerHeight) { + return false; + } + + const seed = Math.floor(Date.now() / 1000); + const filterId = `${SNAP_EFFECT_ID}-${generateUniqueId()}`; + + const svg = document.createElementNS(SVG_NAMESPACE, 'svg'); + svg.setAttribute('class', styles.ghost); + svg.setAttribute('width', `${width}px`); + svg.setAttribute('height', `${height}px`); + svg.setAttribute('style', `left: ${x}px; top: ${y}px;`); + svg.setAttribute('viewBox', `0 0 ${width} ${height}`); + + const defs = document.createElementNS(SVG_NAMESPACE, 'defs'); + svg.appendChild(defs); + + const filter = createFilter(Math.min(width, height), seed); + filter.setAttribute('id', filterId); + defs.appendChild(filter); + + const g = document.createElementNS(SVG_NAMESPACE, 'g'); + g.setAttribute('filter', `url(#${filterId})`); + svg.appendChild(g); + + const foreignObject = document.createElementNS(SVG_NAMESPACE, 'foreignObject'); + foreignObject.setAttribute('class', styles.elementContainer); + foreignObject.setAttribute('width', `${width}px`); + foreignObject.setAttribute('height', `${height}px`); + g.appendChild(foreignObject); + + const computedStyle = window.getComputedStyle(element); + const clone = element.cloneNode(true) as HTMLElement; + Array.from(computedStyle).forEach((key) => ( + clone.style.setProperty(key, computedStyle.getPropertyValue(key), 'important') + )); + + foreignObject.appendChild(clone); + + const snapContainer = document.getElementById(SNAP_EFFECT_CONTAINER_ID)!; + snapContainer.appendChild(svg); + + svg.addEventListener('animationend', () => { + snapContainer.removeChild(svg); + }, { + once: true, + }); + + return true; +} + +function createFilter(smallestSide: number, baseSeed: number = 42) { + const filter = document.createElementNS(SVG_NAMESPACE, 'filter'); + filter.setAttribute('x', '-150%'); + filter.setAttribute('y', '-150%'); + filter.setAttribute('width', '400%'); + filter.setAttribute('height', '400%'); + filter.setAttribute('color-interpolation-filters', 'sRGB'); + + const feTurbulence = document.createElementNS(SVG_NAMESPACE, 'feTurbulence'); + feTurbulence.setAttribute('type', 'fractalNoise'); + feTurbulence.setAttribute('baseFrequency', '0.5'); + feTurbulence.setAttribute('numOctaves', '1'); + feTurbulence.setAttribute('result', 'dustNoise'); + feTurbulence.setAttribute('seed', baseSeed.toString()); + filter.appendChild(feTurbulence); + + const feComponentTransfer = document.createElementNS(SVG_NAMESPACE, 'feComponentTransfer'); + feComponentTransfer.setAttribute('in', 'dustNoise'); + feComponentTransfer.setAttribute('result', 'dustNoiseMask'); + filter.appendChild(feComponentTransfer); + + const feFuncA = document.createElementNS(SVG_NAMESPACE, 'feFuncA'); + feFuncA.setAttribute('type', 'linear'); + feFuncA.setAttribute('slope', '5'); + feFuncA.setAttribute('intercept', '0'); + feComponentTransfer.appendChild(feFuncA); + + const feFuncAAnimate = document.createElementNS(SVG_NAMESPACE, 'animate'); + feFuncAAnimate.setAttribute('attributeName', 'slope'); + feFuncAAnimate.setAttribute('values', '5; 2; 1; 0'); + feFuncAAnimate.setAttribute('dur', `${DURATION}ms`); + feFuncAAnimate.setAttribute('fill', 'freeze'); + feFuncA.appendChild(feFuncAAnimate); + + const feComposite = document.createElementNS(SVG_NAMESPACE, 'feComposite'); + feComposite.setAttribute('in', 'SourceGraphic'); + feComposite.setAttribute('in2', 'dustNoiseMask'); + feComposite.setAttribute('operator', 'in'); + feComposite.setAttribute('result', 'dustySource'); + filter.appendChild(feComposite); + + const feTurbulence2 = document.createElementNS(SVG_NAMESPACE, 'feTurbulence'); + feTurbulence2.setAttribute('type', 'fractalNoise'); + feTurbulence2.setAttribute('baseFrequency', '0.015'); + feTurbulence2.setAttribute('numOctaves', '1'); + feTurbulence2.setAttribute('result', 'displacementNoice1'); + feTurbulence2.setAttribute('seed', (baseSeed + 1).toString()); + filter.appendChild(feTurbulence2); + + const feTurbulence3 = document.createElementNS(SVG_NAMESPACE, 'feTurbulence'); + feTurbulence3.setAttribute('type', 'fractalNoise'); + feTurbulence3.setAttribute('baseFrequency', '1'); + feTurbulence3.setAttribute('numOctaves', '2'); + feTurbulence3.setAttribute('result', 'displacementNoice2'); + feTurbulence3.setAttribute('seed', (baseSeed + 2).toString()); + filter.appendChild(feTurbulence3); + + const feMerge = document.createElementNS(SVG_NAMESPACE, 'feMerge'); + feMerge.setAttribute('result', 'combinedNoise'); + filter.appendChild(feMerge); + + const feMergeNode1 = document.createElementNS(SVG_NAMESPACE, 'feMergeNode'); + feMergeNode1.setAttribute('in', 'displacementNoice1'); + feMerge.appendChild(feMergeNode1); + + const feMergeNode2 = document.createElementNS(SVG_NAMESPACE, 'feMergeNode'); + feMergeNode2.setAttribute('in', 'displacementNoice2'); + feMerge.appendChild(feMergeNode2); + + const feDisplacementMap = document.createElementNS(SVG_NAMESPACE, 'feDisplacementMap'); + feDisplacementMap.setAttribute('in', 'dustySource'); + feDisplacementMap.setAttribute('in2', 'combinedNoise'); + feDisplacementMap.setAttribute('scale', '0'); + + feDisplacementMap.setAttribute('xChannelSelector', 'R'); + feDisplacementMap.setAttribute('yChannelSelector', 'G'); + filter.appendChild(feDisplacementMap); + + const feDisplacementMapAnimate = document.createElementNS(SVG_NAMESPACE, 'animate'); + feDisplacementMapAnimate.setAttribute('attributeName', 'scale'); + feDisplacementMapAnimate.setAttribute('values', `0; ${smallestSide * 3}`); + feDisplacementMapAnimate.setAttribute('dur', `${DURATION}ms`); + feDisplacementMapAnimate.setAttribute('fill', 'freeze'); + feDisplacementMap.appendChild(feDisplacementMapAnimate); + + return filter; +} diff --git a/src/components/middle/message/Message.scss b/src/components/middle/message/Message.scss index bf8bbb0d6..1af7d7f1b 100644 --- a/src/components/middle/message/Message.scss +++ b/src/components/middle/message/Message.scss @@ -268,6 +268,10 @@ } } + &.is-dissolving { + visibility: hidden; + } + &.is-story-mention { --background-color: var(--pattern-color); diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index 14bcf6c2e..6e2633af1 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -157,6 +157,7 @@ import StarIcon from '../../common/icons/StarIcon'; import MessageText from '../../common/MessageText'; import ReactionStaticEmoji from '../../common/reactions/ReactionStaticEmoji'; import TopicChip from '../../common/TopicChip'; +import { animateSnap } from '../../main/visualEffects/SnapEffectContainer'; import Button from '../../ui/Button'; import Album from './Album'; import AnimatedCustomEmoji from './AnimatedCustomEmoji'; @@ -443,6 +444,8 @@ const Message: FC = ({ const lang = useOldLang(); const [isTranscriptionHidden, setTranscriptionHidden] = useState(false); + const [isPlayingSnapAnimation, setIsPlayingSnapAnimation] = useState(false); + const [isPlayingDeleteAnimation, setIsPlayingDeleteAnimation] = useState(false); const [shouldPlayEffect, requestEffect, hideEffect] = useFlag(); const { isMobile, isTouchScreen } = useAppLayout(); @@ -666,6 +669,17 @@ const Message: FC = ({ } }, [focusLastMessage, isLastInList, transcribedText, withVoiceTranscription]); + useEffect(() => { + const element = ref.current; + if (message.isDeleting && element) { + if (animateSnap(element)) { + setIsPlayingSnapAnimation(true); + } else { + setIsPlayingDeleteAnimation(true); + } + } + }, [message.isDeleting]); + const textMessage = album?.hasMultipleCaptions ? undefined : (album?.captionMessage || message); const hasTextContent = textMessage && hasMessageText(textMessage); const hasText = hasTextContent || hasFactCheck; @@ -685,7 +699,8 @@ const Message: FC = ({ isContextMenuOpen && 'has-menu-open', isFocused && !noFocusHighlight && 'focused', isForwarding && 'is-forwarding', - message.isDeleting && 'is-deleting', + isPlayingDeleteAnimation && 'is-deleting', + isPlayingSnapAnimation && 'is-dissolving', isInDocumentGroup && 'is-in-document-group', isAlbum && 'is-album', message.hasUnreadMention && 'has-unread-mention', diff --git a/src/config.ts b/src/config.ts index 537b15abf..a68261dea 100644 --- a/src/config.ts +++ b/src/config.ts @@ -148,6 +148,9 @@ export const CUSTOM_APPENDIX_ATTRIBUTE = 'data-has-custom-appendix'; export const MESSAGE_CONTENT_CLASS_NAME = 'message-content'; export const MESSAGE_CONTENT_SELECTOR = '.message-content'; +export const SNAP_EFFECT_CONTAINER_ID = 'snap-effect-container'; +export const SNAP_EFFECT_ID = 'snap-effect'; + export const STARS_ICON_PLACEHOLDER = '⭐'; export const STARS_CURRENCY_CODE = 'XTR'; diff --git a/src/global/actions/apiUpdaters/messages.ts b/src/global/actions/apiUpdaters/messages.ts index d0e8777ba..1a56c643e 100644 --- a/src/global/actions/apiUpdaters/messages.ts +++ b/src/global/actions/apiUpdaters/messages.ts @@ -56,6 +56,7 @@ import { import { updateUnreadReactions } from '../../reducers/reactions'; import { updateTabState } from '../../reducers/tabs'; import { + selectCanAnimateSnapEffect, selectChat, selectChatLastMessageId, selectChatMessage, @@ -85,6 +86,7 @@ import { } from '../../selectors'; const ANIMATION_DELAY = 350; +const SNAP_ANIMATION_DELAY = 1000; addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { switch (update['@type']) { @@ -1056,11 +1058,13 @@ export function deleteMessages( setGlobal(global); + const isAnimatingAsSnap = selectCanAnimateSnapEffect(global); + setTimeout(() => { global = getGlobal(); global = deleteChatMessages(global, chatId, ids); setGlobal(global); - }, ANIMATION_DELAY); + }, isAnimatingAsSnap ? SNAP_ANIMATION_DELAY : ANIMATION_DELAY); return; } @@ -1099,11 +1103,13 @@ export function deleteMessages( global = deletePeerPhoto(global, commonBoxChatId, message.content.action.photo.id, true); } + const isAnimatingAsSnap = selectCanAnimateSnapEffect(global); + setTimeout(() => { global = getGlobal(); global = deleteChatMessages(global, commonBoxChatId, [id]); setGlobal(global); - }, ANIMATION_DELAY); + }, isAnimatingAsSnap ? SNAP_ANIMATION_DELAY : ANIMATION_DELAY); } }); @@ -1129,6 +1135,8 @@ function deleteScheduledMessages( setGlobal(global); + const isAnimatingAsSnap = selectCanAnimateSnapEffect(global); + setTimeout(() => { global = getGlobal(); global = deleteChatScheduledMessages(global, chatId, ids); @@ -1137,5 +1145,5 @@ function deleteScheduledMessages( global, chatId, MAIN_THREAD_ID, 'scheduledIds', Object.keys(scheduledMessages || {}).map(Number), ); setGlobal(global); - }, ANIMATION_DELAY); + }, isAnimatingAsSnap ? SNAP_ANIMATION_DELAY : ANIMATION_DELAY); } diff --git a/src/global/initialState.ts b/src/global/initialState.ts index e217f2679..d65cb966a 100644 --- a/src/global/initialState.ts +++ b/src/global/initialState.ts @@ -29,6 +29,7 @@ export const INITIAL_PERFORMANCE_STATE_MAX: PerformanceType = { rightColumnAnimations: true, stickerEffects: true, storyRibbonAnimations: true, + snapEffect: true, }; export const INITIAL_PERFORMANCE_STATE_MID: PerformanceType = { @@ -46,6 +47,7 @@ export const INITIAL_PERFORMANCE_STATE_MID: PerformanceType = { rightColumnAnimations: false, stickerEffects: false, storyRibbonAnimations: false, + snapEffect: false, }; export const INITIAL_PERFORMANCE_STATE_MIN: PerformanceType = { @@ -63,6 +65,7 @@ export const INITIAL_PERFORMANCE_STATE_MIN: PerformanceType = { rightColumnAnimations: false, stickerEffects: false, storyRibbonAnimations: false, + snapEffect: false, }; export const INITIAL_GLOBAL_STATE: GlobalState = { diff --git a/src/global/selectors/ui.ts b/src/global/selectors/ui.ts index 41db4e6d3..1b407ba56 100644 --- a/src/global/selectors/ui.ts +++ b/src/global/selectors/ui.ts @@ -4,6 +4,7 @@ import type { GlobalState, TabArgs } from '../types'; import { NewChatMembersProgress, RightColumnContent } from '../../types'; import { getCurrentTabId } from '../../util/establishMultitabRole'; +import { IS_SNAP_EFFECT_SUPPORTED } from '../../util/windowEnvironment'; import { getMessageVideo, getMessageWebPageVideo } from '../helpers/messageMedia'; import { selectCurrentManagement } from './management'; import { selectIsStatisticsShown } from './statistics'; @@ -145,3 +146,7 @@ export function selectIsContextMenuTranslucent(global: T) export function selectIsSynced(global: T) { return global.isSynced; } + +export function selectCanAnimateSnapEffect(global: T) { + return IS_SNAP_EFFECT_SUPPORTED && selectPerformanceSettingsValue(global, 'snapEffect'); +} diff --git a/src/types/index.ts b/src/types/index.ts index 4a3655d4b..a18bdb96c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -57,7 +57,7 @@ export type PerformanceTypeKey = ( 'pageTransitions' | 'messageSendingAnimations' | 'mediaViewerAnimations' | 'messageComposerAnimations' | 'contextMenuAnimations' | 'contextMenuBlur' | 'rightColumnAnimations' | 'animatedEmoji' | 'loopAnimatedStickers' | 'reactionEffects' | 'stickerEffects' | 'autoplayGifs' | 'autoplayVideos' - | 'storyRibbonAnimations' + | 'storyRibbonAnimations' | 'snapEffect' ); export type PerformanceType = { [key in PerformanceTypeKey]: boolean; diff --git a/src/util/svgController.ts b/src/util/svgController.ts index 0ec75040f..927f6b28d 100644 --- a/src/util/svgController.ts +++ b/src/util/svgController.ts @@ -3,6 +3,8 @@ import generateUniqueId from './generateUniqueId'; export const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; const CONTAINER = document.createElementNS(SVG_NAMESPACE, 'svg'); +CONTAINER.setAttribute('width', '0'); +CONTAINER.setAttribute('height', '0'); CONTAINER.setAttribute('viewBox', '0 0 1 1'); CONTAINER.classList.add('svg-definitions'); document.body.appendChild(CONTAINER); diff --git a/src/util/windowEnvironment.ts b/src/util/windowEnvironment.ts index 54474f5c7..a0361e08d 100644 --- a/src/util/windowEnvironment.ts +++ b/src/util/windowEnvironment.ts @@ -71,9 +71,13 @@ export const IS_CANVAS_FILTER_SUPPORTED = ( export const IS_REQUEST_FULLSCREEN_SUPPORTED = 'requestFullscreen' in document.createElement('div'); export const ARE_CALLS_SUPPORTED = !IS_FIREFOX; // https://bugzilla.mozilla.org/show_bug.cgi?id=1923416 export const LAYERS_ANIMATION_NAME = IS_ANDROID ? 'slideFade' : IS_IOS ? 'slideLayers' : 'pushSlide'; + export const IS_WAVE_TRANSFORM_SUPPORTED = !IS_MOBILE && !IS_FIREFOX // https://bugzilla.mozilla.org/show_bug.cgi?id=1808785 && !IS_SAFARI; // https://bugs.webkit.org/show_bug.cgi?id=245510 +export const IS_SNAP_EFFECT_SUPPORTED = !IS_MOBILE + && !IS_FIREFOX // https://bugzilla.mozilla.org/show_bug.cgi?id=1896504 + && !IS_SAFARI; const TEST_VIDEO = document.createElement('video');