UI: Add super ripple effect (#5081)

This commit is contained in:
zubiden 2024-11-02 21:10:56 +04:00 committed by Alexander Zinchuk
parent 63ce255f4c
commit 36eab14839
22 changed files with 308 additions and 39 deletions

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="46" height="39"><defs><filter id="a" x="0" y="0" width="46" height="39" filterUnits="userSpaceOnUse"><feOffset dy="1"/><feGaussianBlur stdDeviation="1" result="blur"/><feFlood flood-color="#10232f" flood-opacity=".149"/><feComposite operator="in" in2="blur"/><feComposite in="SourceGraphic"/></filter></defs><g filter="url(#a)"><path data-name="Bubble" d="M36 35H15A12 12 0 013 23v-9A12 12 0 0115 2h15a6 6 0 016 6v10a29.759 29.759 0 002.049 8.782 17.4 17.4 0 004.626 6.48A1 1 0 0142 35z" fill="#eeffde"/></g></svg>

Before

Width:  |  Height:  |  Size: 561 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="46.677" height="39"><defs><filter id="a" x="0" y="0" width="46.677" height="39" filterUnits="userSpaceOnUse"><feOffset dy="1"/><feGaussianBlur stdDeviation="1" result="blur"/><feFlood flood-color="#10232f" flood-opacity=".149"/><feComposite operator="in" in2="blur"/><feComposite in="SourceGraphic"/></filter></defs><g filter="url(#a)"><path data-name="Bubble" d="M10.68 35V8a6 6 0 016-6h15a12 12 0 0112 12v9a12 12 0 01-12 12zm-6.676 0a1 1 0 01-.773-1.634l5.4-6.583A33.387 33.387 0 0010.68 18v17z" fill="#fff"/></g></svg>

Before

Width:  |  Height:  |  Size: 568 B

View File

@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<path fill="#fff" transform="translate(-2, -8)" d="M27.32 10.628H8.44c-3.516 0-5.745 3.792-3.976 6.858l11.801 20.455c.77 1.335 2.7 1.335 3.47 0l11.804-20.455c1.767-3.06-.462-6.858-3.975-6.858zM16.255 31.807l-2.57-4.974-6.202-11.092c-.409-.71.096-1.62.953-1.62h7.816V31.81zM28.51 15.739l-6.2 11.096-2.57 4.972V14.119h7.817c.857 0 1.362.91.953 1.62"/>
<path transform="translate(-2, -8)" d="M27.32 10.628H8.44c-3.516 0-5.745 3.792-3.976 6.858l11.801 20.455c.77 1.335 2.7 1.335 3.47 0l11.804-20.455c1.767-3.06-.462-6.858-3.975-6.858zM16.255 31.807l-2.57-4.974-6.202-11.092c-.409-.71.096-1.62.953-1.62h7.816V31.81zM28.51 15.739l-6.2 11.096-2.57 4.972V14.119h7.817c.857 0 1.362.91.953 1.62"/>
</svg>

Before

Width:  |  Height:  |  Size: 443 B

After

Width:  |  Height:  |  Size: 431 B

View File

@ -0,0 +1,27 @@
<!-- Displacement map for "shockwave" -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1">
<style>
#mix {
mix-blend-mode: screen;
}
</style>
<linearGradient id="red">
<stop offset="0%" stop-color="red" />
<stop offset="100%" stop-color="red" stop-opacity="0" />
</linearGradient>
<linearGradient id="blue" gradientTransform="rotate(90)">
<stop offset="0%" stop-color="blue" />
<stop offset="100%" stop-color="blue" stop-opacity="0" />
</linearGradient>
<radialGradient id="grey">
<stop offset="60%" stop-color="grey" stop-opacity="1" />
<stop offset="80%" stop-color="grey" stop-opacity="0" />
<stop offset="100%" stop-color="grey" stop-opacity="1" />
</radialGradient>
<g>
<rect width="1" height="1" fill="black" />
<rect width="1" height="1" fill="url(#red)" />
<rect width="1" height="1" fill="url(#blue)" id="mix" />
<rect width="1" height="1" fill="url(#grey)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 969 B

View File

@ -125,6 +125,7 @@ const PaidReactionEmoji = ({
tgsUrl={LOCAL_TGS_URLS.StarReactionEffect}
play={isIntersecting}
noLoop
forceAlways
nonInteractive
quality={QUALITY}
onEnded={handleEnded}

View File

@ -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<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
window.electron?.setIsAutoUpdateEnabled(isChecked);
}, []);
const handleRequestWave = useLastCallback((e: React.MouseEvent<HTMLElement, MouseEvent>) => {
requestWave({ startX: e.clientX, startY: e.clientY });
});
return (
<div className="settings-content custom-scroll">
<div className="settings-content-header no-border">
<AnimatedIcon
<AnimatedIconWithPreview
tgsUrl={LOCAL_TGS_URLS.Experimental}
size={200}
className="experimental-duck"
@ -81,6 +85,13 @@ const SettingsExperimental: FC<OwnProps & StateProps> = ({
>
<div className="title">Launch some confetti!</div>
</ListItem>
<ListItem
onClick={handleRequestWave}
icon="story-expired"
disabled={!IS_WAVE_TRANSFORM_SUPPORTED}
>
<div className="title">Start wave</div>
</ListItem>
<Checkbox
label="Allow HTTP Transport"

View File

@ -37,7 +37,7 @@ import { processDeepLink } from '../../util/deeplink';
import { Bundles, loadBundle } from '../../util/moduleLoader';
import { parseInitialLocationHash, parseLocationHash } from '../../util/routing';
import updateIcon from '../../util/updateIcon';
import { IS_ANDROID, IS_ELECTRON } from '../../util/windowEnvironment';
import { IS_ANDROID, IS_ELECTRON, IS_WAVE_TRANSFORM_SUPPORTED } from '../../util/windowEnvironment';
import useInterval from '../../hooks/schedulers/useInterval';
import useTimeout from '../../hooks/schedulers/useTimeout';
@ -72,7 +72,6 @@ import RightColumn from '../right/RightColumn';
import StoryViewer from '../story/StoryViewer.async';
import AttachBotRecipientPicker from './AttachBotRecipientPicker.async';
import BotTrustModal from './BotTrustModal.async';
import ConfettiContainer from './ConfettiContainer';
import DeleteFolderDialog from './DeleteFolderDialog.async';
import Dialogs from './Dialogs.async';
import DownloadManager from './DownloadManager';
@ -88,6 +87,8 @@ import PremiumGiftingPickerModal from './premium/PremiumGiftingPickerModal.async
import PremiumMainModal from './premium/PremiumMainModal.async';
import StarsGiftingPickerModal from './premium/StarsGiftingPickerModal.async';
import SafeLinkModal from './SafeLinkModal.async';
import ConfettiContainer from './visualEffects/ConfettiContainer';
import WaveContainer from './visualEffects/WaveContainer';
import './Main.scss';
@ -565,6 +566,7 @@ const Main = ({
<GameModal openedGame={openedGame} gameTitle={gameTitle} />
<DownloadManager />
<ConfettiContainer />
{IS_WAVE_TRANSFORM_SUPPORTED && <WaveContainer />}
<PhoneCall isActive={isPhoneCallActive} />
<UnreadCount isForAppBadge />
<RatePhoneCallModal isOpen={isRatePhoneCallModalOpen} />

View File

@ -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';

View File

@ -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);
}
}

View File

@ -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<Wave[]>([]);
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 (
<div className={buildClassName(styles.root)} teactFastList>
{waves.map((wave) => (
<div
className={styles.wave}
style={buildStyle(
`--wave-width: ${wave.waveWidth}px`,
`--wave-pos-top: ${wave.top}px`,
`--wave-pos-left: ${wave.left}px`,
)}
key={wave.startTime}
onAnimationEnd={() => setWaves((prevWaves) => prevWaves.filter((w) => w !== wave))}
/>
))}
</div>
);
};
export default memo(withGlobal(
(global): StateProps => {
const tabState = selectTabState(global);
return {
waveInfo: tabState.wave,
};
},
)(WaveContainer));

View File

@ -15,6 +15,7 @@
--reaction-background: #FFBC2E33 !important;
--reaction-background-hover: #FFBC2E55 !important;
--reaction-text-color: #E98111 !important;
z-index: 2;
}
&.paid.chosen {

View File

@ -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<HTMLButtonElement>(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

View File

@ -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;

View File

@ -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,

View File

@ -152,6 +152,7 @@ export function addPaidReaction(
count: 0,
chosenOrder: -1,
localAmount: count,
localIsPrivate: isAnonymous,
},
...reactionCount,
];

View File

@ -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;

View File

@ -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<string, SvgColorFilter>();
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);
}
}

View File

@ -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;

View File

@ -126,6 +126,10 @@ body.cursor-ew-resize {
visibility: hidden;
}
.svg-definitions {
display: none;
}
.allow-selection {
user-select: text;
}

30
src/util/svgController.ts Normal file
View File

@ -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<string, SVGElement>();
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);
}
}

View File

@ -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');