diff --git a/src/assets/chat-bubble-green.svg b/src/assets/chat-bubble-green.svg deleted file mode 100644 index 6b0ced023..000000000 --- a/src/assets/chat-bubble-green.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/assets/chat-bubble-white.svg b/src/assets/chat-bubble-white.svg deleted file mode 100644 index ededeb4ad..000000000 --- a/src/assets/chat-bubble-white.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/assets/font-icons/toncoin.svg b/src/assets/font-icons/toncoin.svg index 2ebd21efa..ba0e6660b 100644 --- a/src/assets/font-icons/toncoin.svg +++ b/src/assets/font-icons/toncoin.svg @@ -1,3 +1,3 @@ - + diff --git a/src/assets/wave_ripple.svg b/src/assets/wave_ripple.svg new file mode 100644 index 000000000..ebff96f47 --- /dev/null +++ b/src/assets/wave_ripple.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/common/reactions/PaidReactionEmoji.tsx b/src/components/common/reactions/PaidReactionEmoji.tsx index f40077830..65717ae42 100644 --- a/src/components/common/reactions/PaidReactionEmoji.tsx +++ b/src/components/common/reactions/PaidReactionEmoji.tsx @@ -125,6 +125,7 @@ const PaidReactionEmoji = ({ tgsUrl={LOCAL_TGS_URLS.StarReactionEffect} play={isIntersecting} noLoop + forceAlways nonInteractive quality={QUALITY} onEnded={handleEnded} diff --git a/src/components/left/settings/SettingsExperimental.tsx b/src/components/left/settings/SettingsExperimental.tsx index 8c1771844..b35d5a4c1 100644 --- a/src/components/left/settings/SettingsExperimental.tsx +++ b/src/components/left/settings/SettingsExperimental.tsx @@ -7,14 +7,14 @@ import { getActions, withGlobal } from '../../../global'; import { DEBUG_LOG_FILENAME } from '../../../config'; import { getDebugLogs } from '../../../util/debugConsole'; import download from '../../../util/download'; -import { IS_ELECTRON } from '../../../util/windowEnvironment'; +import { IS_ELECTRON, IS_WAVE_TRANSFORM_SUPPORTED } from '../../../util/windowEnvironment'; import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets'; import useHistoryBack from '../../../hooks/useHistoryBack'; import useLastCallback from '../../../hooks/useLastCallback'; import useOldLang from '../../../hooks/useOldLang'; -import AnimatedIcon from '../../common/AnimatedIcon'; +import AnimatedIconWithPreview from '../../common/AnimatedIconWithPreview'; import Checkbox from '../../ui/Checkbox'; import ListItem from '../../ui/ListItem'; @@ -38,7 +38,7 @@ const SettingsExperimental: FC = ({ shouldCollectDebugLogs, shouldDebugExportedSenders, }) => { - const { requestConfetti, setSettingOption } = getActions(); + const { requestConfetti, setSettingOption, requestWave } = getActions(); const lang = useOldLang(); const [isAutoUpdateEnabled, setIsAutoUpdateEnabled] = useState(false); @@ -61,10 +61,14 @@ const SettingsExperimental: FC = ({ window.electron?.setIsAutoUpdateEnabled(isChecked); }, []); + const handleRequestWave = useLastCallback((e: React.MouseEvent) => { + requestWave({ startX: e.clientX, startY: e.clientY }); + }); + return (
- = ({ >
Launch some confetti!
+ +
Start wave
+
+ {IS_WAVE_TRANSFORM_SUPPORTED && } diff --git a/src/components/main/ConfettiContainer.module.scss b/src/components/main/visualEffects/ConfettiContainer.module.scss similarity index 100% rename from src/components/main/ConfettiContainer.module.scss rename to src/components/main/visualEffects/ConfettiContainer.module.scss diff --git a/src/components/main/ConfettiContainer.tsx b/src/components/main/visualEffects/ConfettiContainer.tsx similarity index 91% rename from src/components/main/ConfettiContainer.tsx rename to src/components/main/visualEffects/ConfettiContainer.tsx index 7fd45fe8f..6dd45791e 100644 --- a/src/components/main/ConfettiContainer.tsx +++ b/src/components/main/visualEffects/ConfettiContainer.tsx @@ -1,18 +1,18 @@ -import React, { memo, useRef } from '../../lib/teact/teact'; -import { withGlobal } from '../../global'; +import React, { memo, useRef } from '../../../lib/teact/teact'; +import { withGlobal } from '../../../global'; -import type { ConfettiStyle, TabState } from '../../global/types'; +import type { ConfettiStyle, TabState } from '../../../global/types'; -import { requestMeasure } from '../../lib/fasterdom/fasterdom'; -import { selectTabState } from '../../global/selectors'; -import buildStyle from '../../util/buildStyle'; -import { pick } from '../../util/iteratees'; +import { requestMeasure } from '../../../lib/fasterdom/fasterdom'; +import { selectTabState } from '../../../global/selectors'; +import buildStyle from '../../../util/buildStyle'; +import { pick } from '../../../util/iteratees'; -import useAppLayout from '../../hooks/useAppLayout'; -import useForceUpdate from '../../hooks/useForceUpdate'; -import useLastCallback from '../../hooks/useLastCallback'; -import useSyncEffect from '../../hooks/useSyncEffect'; -import useWindowSize from '../../hooks/window/useWindowSize'; +import useAppLayout from '../../../hooks/useAppLayout'; +import useForceUpdate from '../../../hooks/useForceUpdate'; +import useLastCallback from '../../../hooks/useLastCallback'; +import useSyncEffect from '../../../hooks/useSyncEffect'; +import useWindowSize from '../../../hooks/window/useWindowSize'; import styles from './ConfettiContainer.module.scss'; diff --git a/src/components/main/visualEffects/WaveContainer.module.scss b/src/components/main/visualEffects/WaveContainer.module.scss new file mode 100644 index 000000000..01434ce45 --- /dev/null +++ b/src/components/main/visualEffects/WaveContainer.module.scss @@ -0,0 +1,43 @@ +.root { + position: fixed; + inset: 0; + pointer-events: none; + + z-index: var(--z-overlay-effects); +} + +.wave { + --wave-width: 100vw; + --wave-pos-top: 0%; + --wave-pos-left: 0%; + + position: absolute; + + top: var(--wave-pos-top); + left: var(--wave-pos-left); + width: var(--wave-width); + aspect-ratio: 1 / 1; + + border-radius: 50%; + + background-image: + radial-gradient( + circle, + transparent 52%, + #FFFFFF06 60%, + transparent 68% + ); + + backdrop-filter: url(#wave-filter); + animation: waveGrow 1.5s ease-in; +} + +@keyframes waveGrow { + from { + transform: scale(0); + } + + to { + transform: scale(2.2); + } +} diff --git a/src/components/main/visualEffects/WaveContainer.tsx b/src/components/main/visualEffects/WaveContainer.tsx new file mode 100644 index 000000000..48cd37068 --- /dev/null +++ b/src/components/main/visualEffects/WaveContainer.tsx @@ -0,0 +1,115 @@ +import React, { + memo, useEffect, useState, +} from '../../../lib/teact/teact'; +import { withGlobal } from '../../../global'; + +import type { TabState } from '../../../global/types'; + +import { selectTabState } from '../../../global/selectors'; +import buildClassName from '../../../util/buildClassName'; +import buildStyle from '../../../util/buildStyle'; +import { addSvgDefinition, removeSvgDefinition, SVG_NAMESPACE } from '../../../util/svgController'; +import windowSize from '../../../util/windowSize'; + +import useLastCallback from '../../../hooks/useLastCallback'; + +import styles from './WaveContainer.module.scss'; + +import waveRipple from '../../../assets/wave_ripple.svg'; + +type StateProps = { + waveInfo?: TabState['wave']; +}; + +type Wave = { + startTime: number; + waveWidth: number; + top: number; + left: number; +}; + +const BASE_SIZE_MULTIPLIER = 1.73; +const FILTER_ID = 'wave-filter'; +const FILTER_SCALE = '20'; +const WAVE_COUNT_LIMIT = 7; + +const WaveContainer = ({ waveInfo }: StateProps) => { + const [waves, setWaves] = useState([]); + + const addWave = useLastCallback((newWave: Wave) => { + if (waves.length >= WAVE_COUNT_LIMIT) return; + + setWaves((prevWaves) => [...prevWaves, newWave]); + }); + + useEffect(() => { + if (!waveInfo) return; + + const { startX, startY } = waveInfo; + const { width, height } = windowSize.get(); + + const maxSize = Math.max(width - startX, height - startY, startX, startY); + const overlaySize = maxSize * BASE_SIZE_MULTIPLIER; + const top = startY - overlaySize / 2; + const left = startX - overlaySize / 2; + + addWave({ + startTime: waveInfo.lastWaveTime, + waveWidth: overlaySize, + top, + left, + }); + }, [waveInfo]); + + useEffect(() => { + const filter = document.createElementNS(SVG_NAMESPACE, 'filter'); + filter.setAttribute('x', '0'); + filter.setAttribute('y', '0'); + filter.setAttribute('width', '1'); + filter.setAttribute('height', '1'); + addSvgDefinition(filter, FILTER_ID); + + const feImage = document.createElementNS(SVG_NAMESPACE, 'feImage'); + feImage.setAttribute('href', waveRipple); + feImage.setAttribute('result', 'waveImage'); + filter.appendChild(feImage); + + const feDisplacementMap = document.createElementNS(SVG_NAMESPACE, 'feDisplacementMap'); + feDisplacementMap.setAttribute('in', 'SourceGraphic'); + feDisplacementMap.setAttribute('in2', 'waveImage'); + feDisplacementMap.setAttribute('scale', FILTER_SCALE); + feDisplacementMap.setAttribute('xChannelSelector', 'R'); + feDisplacementMap.setAttribute('yChannelSelector', 'B'); + filter.appendChild(feDisplacementMap); + + return () => { + removeSvgDefinition(FILTER_ID); + }; + }, []); + + return ( +
+ {waves.map((wave) => ( +
setWaves((prevWaves) => prevWaves.filter((w) => w !== wave))} + /> + ))} +
+ ); +}; + +export default memo(withGlobal( + (global): StateProps => { + const tabState = selectTabState(global); + return { + waveInfo: tabState.wave, + }; + }, +)(WaveContainer)); diff --git a/src/components/middle/message/reactions/ReactionButton.module.scss b/src/components/middle/message/reactions/ReactionButton.module.scss index 816e43139..3214dd290 100644 --- a/src/components/middle/message/reactions/ReactionButton.module.scss +++ b/src/components/middle/message/reactions/ReactionButton.module.scss @@ -15,6 +15,7 @@ --reaction-background: #FFBC2E33 !important; --reaction-background-hover: #FFBC2E55 !important; --reaction-text-color: #E98111 !important; + z-index: 2; } &.paid.chosen { diff --git a/src/components/middle/message/reactions/ReactionButton.tsx b/src/components/middle/message/reactions/ReactionButton.tsx index e66699847..75217d519 100644 --- a/src/components/middle/message/reactions/ReactionButton.tsx +++ b/src/components/middle/message/reactions/ReactionButton.tsx @@ -64,7 +64,12 @@ const ReactionButton = ({ onClick, onPaidClick, }: OwnProps) => { - const { openStarsBalanceModal, resetLocalPaidReactions, openPaidReactionModal } = getActions(); + const { + openStarsBalanceModal, + resetLocalPaidReactions, + openPaidReactionModal, + requestWave, + } = getActions(); // eslint-disable-next-line no-null/no-null const ref = useRef(null); // eslint-disable-next-line no-null/no-null @@ -83,6 +88,7 @@ const ReactionButton = ({ if (reaction.reaction.type === 'paid') { e.stopPropagation(); // Prevent default message double click behavior handlePaidClick(); + return; } @@ -129,6 +135,13 @@ const ReactionButton = ({ return; } + if (reaction.localAmount) { + const { left, top } = button.getBoundingClientRect(); + const startX = left + button.offsetWidth / 2; + const startY = top + button.offsetHeight / 2; + requestWave({ startX, startY }); + } + const currentScale = Number(getComputedStyle(button).scale) || 1; animationRef.current?.cancel(); // Animate scaling by 20%, and then returning to 1 diff --git a/src/config.ts b/src/config.ts index 7033ed19a..64b4f9ceb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -167,6 +167,7 @@ export const MAX_INT_32 = 2 ** 31 - 1; export const TMP_CHAT_ID = '0'; export const ANIMATION_END_DELAY = 100; +export const ANIMATION_WAVE_MIN_INTERVAL = 200; export const SCROLL_MIN_DURATION = 300; export const SCROLL_MAX_DURATION = 600; diff --git a/src/global/actions/ui/misc.ts b/src/global/actions/ui/misc.ts index 8901e9b72..cc1e35f79 100644 --- a/src/global/actions/ui/misc.ts +++ b/src/global/actions/ui/misc.ts @@ -4,6 +4,7 @@ import type { ApiError, ApiNotification } from '../../../api/types'; import type { ActionReturnType, GlobalState } from '../../types'; import { + ANIMATION_WAVE_MIN_INTERVAL, DEBUG, GLOBAL_STATE_CACHE_CUSTOM_EMOJI_LIMIT, INACTIVE_MARKER, PAGE_TITLE, } from '../../../config'; import { getAllMultitabTokens, getCurrentTabId, reestablishMasterToSelf } from '../../../util/establishMultitabRole'; @@ -16,7 +17,7 @@ import { refreshFromCache } from '../../../util/localization'; import * as langProvider from '../../../util/oldLangProvider'; import updateIcon from '../../../util/updateIcon'; import { setPageTitle, setPageTitleInstant } from '../../../util/updatePageTitle'; -import { IS_ELECTRON } from '../../../util/windowEnvironment'; +import { IS_ELECTRON, IS_WAVE_TRANSFORM_SUPPORTED } from '../../../util/windowEnvironment'; import { getAllowedAttachmentOptions, getChatTitle } from '../../helpers'; import { addActionHandler, getActions, getGlobal, setGlobal, @@ -489,6 +490,26 @@ addActionHandler('requestConfetti', (global, actions, payload): ActionReturnType }, tabId); }); +addActionHandler('requestWave', (global, actions, payload): ActionReturnType => { + const { + startX, startY, tabId = getCurrentTabId(), + } = payload; + + if (!IS_WAVE_TRANSFORM_SUPPORTED || !selectCanAnimateInterface(global)) return undefined; + + const tabState = selectTabState(global, tabId); + const currentLastTime = tabState.wave?.lastWaveTime || 0; + if (Date.now() - currentLastTime < ANIMATION_WAVE_MIN_INTERVAL) return undefined; + + return updateTabState(global, { + wave: { + lastWaveTime: Date.now(), + startX, + startY, + }, + }, tabId); +}); + addActionHandler('updateAttachmentSettings', (global, actions, payload): ActionReturnType => { const { shouldCompress, shouldSendGrouped, isInvertedMedia, webPageMediaSize, diff --git a/src/global/helpers/reactions.ts b/src/global/helpers/reactions.ts index 34e511310..273f6636a 100644 --- a/src/global/helpers/reactions.ts +++ b/src/global/helpers/reactions.ts @@ -152,6 +152,7 @@ export function addPaidReaction( count: 0, chosenOrder: -1, localAmount: count, + localIsPrivate: isAnonymous, }, ...reactionCount, ]; diff --git a/src/global/types.ts b/src/global/types.ts index 364bc4e82..44c3ab6b6 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -699,6 +699,11 @@ export type TabState = { style?: ConfettiStyle; withStars?: boolean; }; + wave?: { + lastWaveTime: number; + startX: number; + startY: number; + }; urlAuth?: { button?: { @@ -3189,6 +3194,10 @@ export interface ActionPayloads { } & WithTabId) | undefined; closePollModal: WithTabId | undefined; requestConfetti: (ConfettiParams & WithTabId) | WithTabId; + requestWave: { + startX: number; + startY: number; + } & WithTabId; updateAttachmentSettings: { shouldCompress?: boolean; diff --git a/src/hooks/stickers/useColorFilter.ts b/src/hooks/stickers/useColorFilter.ts index 0eb400afc..34e0901ff 100644 --- a/src/hooks/stickers/useColorFilter.ts +++ b/src/hooks/stickers/useColorFilter.ts @@ -1,31 +1,21 @@ import { useEffect } from '../../lib/teact/teact'; +import { addSvgDefinition, removeSvgDefinition, SVG_NAMESPACE } from '../../util/svgController'; import { hexToRgb } from '../../util/switchTheme'; -const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; const SVG_MAP = new Map(); class SvgColorFilter { public filterId: string; - public element: SVGSVGElement; - private referenceCount = 0; constructor(public color: string) { this.filterId = `color-filter-${color.slice(1)}`; - this.element = document.createElementNS(SVG_NAMESPACE, 'svg'); - this.element.width.baseVal.valueAsString = '0px'; - this.element.height.baseVal.valueAsString = '0px'; - - const defs = document.createElementNS(SVG_NAMESPACE, 'defs'); - this.element.appendChild(defs); - const filter = document.createElementNS(SVG_NAMESPACE, 'filter'); - filter.id = this.filterId; filter.setAttribute('color-interpolation-filters', 'sRGB'); - defs.appendChild(filter); + addSvgDefinition(filter, this.filterId); const feColorMatrix = document.createElementNS(SVG_NAMESPACE, 'feColorMatrix'); feColorMatrix.setAttribute('type', 'matrix'); @@ -37,8 +27,6 @@ class SvgColorFilter { ); filter.appendChild(feColorMatrix); - - document.body.appendChild(this.element); } public getFilterId() { @@ -49,7 +37,7 @@ class SvgColorFilter { public removeReference() { this.referenceCount -= 1; if (this.referenceCount === 0) { - this.element.remove(); + removeSvgDefinition(this.filterId); } } diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index 5b091f33c..fa6d98658 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -232,6 +232,7 @@ $color-message-story-mention-to: #74bcff; --symbol-menu-height: 17.6875rem; } + --z-overlay-effects: 10001; --z-modal-confirm: 10000; --z-portal-menu: 10000; --z-symbol-menu-modal: 5000; diff --git a/src/styles/index.scss b/src/styles/index.scss index 38eae501a..7171008bb 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -126,6 +126,10 @@ body.cursor-ew-resize { visibility: hidden; } +.svg-definitions { + display: none; +} + .allow-selection { user-select: text; } diff --git a/src/util/svgController.ts b/src/util/svgController.ts new file mode 100644 index 000000000..0ec75040f --- /dev/null +++ b/src/util/svgController.ts @@ -0,0 +1,30 @@ +import generateUniqueId from './generateUniqueId'; + +export const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; + +const CONTAINER = document.createElementNS(SVG_NAMESPACE, 'svg'); +CONTAINER.setAttribute('viewBox', '0 0 1 1'); +CONTAINER.classList.add('svg-definitions'); +document.body.appendChild(CONTAINER); + +const DEFS = document.createElementNS(SVG_NAMESPACE, 'defs'); +CONTAINER.appendChild(DEFS); + +const DEFINITION_MAP = new Map(); + +export function addSvgDefinition(element: SVGElement, id?: string) { + id ??= generateUniqueId(); + element.id = id; + + DEFS.appendChild(element); + DEFINITION_MAP.set(element.id, element); + return id; +} + +export function removeSvgDefinition(id: string) { + const element = DEFINITION_MAP.get(id); + if (element) { + element.remove(); + DEFINITION_MAP.delete(id); + } +} diff --git a/src/util/windowEnvironment.ts b/src/util/windowEnvironment.ts index a3c7c6d95..54474f5c7 100644 --- a/src/util/windowEnvironment.ts +++ b/src/util/windowEnvironment.ts @@ -69,8 +69,11 @@ export const IS_CANVAS_FILTER_SUPPORTED = ( !IS_TEST && 'filter' in (document.createElement('canvas').getContext('2d') || {}) ); export const IS_REQUEST_FULLSCREEN_SUPPORTED = 'requestFullscreen' in document.createElement('div'); -export const ARE_CALLS_SUPPORTED = !IS_FIREFOX; +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 const TEST_VIDEO = document.createElement('video');