Message: Play effect on deletion (#5125)

This commit is contained in:
zubiden 2024-11-09 15:40:09 +04:00 committed by Alexander Zinchuk
parent a68ee36582
commit 6babbae9f9
14 changed files with 297 additions and 18 deletions

View File

@ -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<OwnProps & StateProps> = ({
shouldDebugExportedSenders,
}) => {
const { requestConfetti, setSettingOption, requestWave } = getActions();
// eslint-disable-next-line no-null/no-null
const snapButtonRef = useRef<HTMLDivElement>(null);
const [isSnapButtonAnimating, setIsSnapButtonAnimating] = useState(false);
const lang = useOldLang();
const [isAutoUpdateEnabled, setIsAutoUpdateEnabled] = useState(false);
@ -65,6 +71,19 @@ const SettingsExperimental: FC<OwnProps & StateProps> = ({
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 (
<div className="settings-content custom-scroll">
<div className="settings-content-header no-border">
@ -92,6 +111,15 @@ const SettingsExperimental: FC<OwnProps & StateProps> = ({
>
<div className="title">Start wave</div>
</ListItem>
<ListItem
ref={snapButtonRef}
onClick={handleSnap}
icon="spoiler"
disabled={!IS_SNAP_EFFECT_SUPPORTED}
style={isSnapButtonAnimating ? 'visibility: hidden' : ''}
>
<div className="title">Vaporize this button</div>
</ListItem>
<Checkbox
label="Allow HTTP Transport"

View File

@ -15,7 +15,7 @@ import {
} from '../../../global/initialState';
import { selectPerformanceSettings } from '../../../global/selectors';
import { areDeepEqual } from '../../../util/areDeepEqual';
import { IS_BACKDROP_BLUR_SUPPORTED } from '../../../util/windowEnvironment';
import { IS_BACKDROP_BLUR_SUPPORTED, IS_SNAP_EFFECT_SUPPORTED } from '../../../util/windowEnvironment';
import useHistoryBack from '../../../hooks/useHistoryBack';
import useOldLang from '../../../hooks/useOldLang';
@ -60,6 +60,7 @@ const PERFORMANCE_OPTIONS: PerformanceSection[] = [
{ key: 'contextMenuAnimations', label: 'Context Menu Animation' },
{ key: 'contextMenuBlur', label: 'Context Menu Blur', disabled: !IS_BACKDROP_BLUR_SUPPORTED },
{ key: 'rightColumnAnimations', label: 'Right Column Animation' },
{ key: 'snapEffect', label: 'Dust-effect deletion' },
]],
['Stickers and Emoji', [
{ key: 'animatedEmoji', label: 'Allow Animated Emoji' },
@ -195,16 +196,19 @@ function SettingsPerformance({
</div>
{Boolean(sectionExpandedStates[index]) && (
<div className="DropdownList DropdownList--open">
{options.map(({ key, label, disabled }) => (
<Checkbox
key={key}
name={key}
checked={performanceSettings[key]}
label={lang(label)}
disabled={disabled}
onChange={handlePropertyChange}
/>
))}
{options.map(({ key, label, disabled }) => {
if (key === 'snapEffect' && !IS_SNAP_EFFECT_SUPPORTED) return undefined;
return (
<Checkbox
key={key}
name={key}
checked={performanceSettings[key]}
label={lang(label)}
disabled={disabled}
onChange={handlePropertyChange}
/>
);
})}
</div>
)}
</div>

View File

@ -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 = ({
<DownloadManager />
<ConfettiContainer />
{IS_WAVE_TRANSFORM_SUPPORTED && <WaveContainer />}
<SnapEffectContainer />
<PhoneCall isActive={isPhoneCallActive} />
<UnreadCount isForAppBadge />
<RatePhoneCallModal isOpen={isRatePhoneCallModalOpen} />

View File

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

View File

@ -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 (
<div className={styles.root} id={SNAP_EFFECT_CONTAINER_ID} />
);
};
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;
}

View File

@ -268,6 +268,10 @@
}
}
&.is-dissolving {
visibility: hidden;
}
&.is-story-mention {
--background-color: var(--pattern-color);

View File

@ -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<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
}
}, [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<OwnProps & StateProps> = ({
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',

View File

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

View File

@ -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<T extends GlobalState>(
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<T extends GlobalState>(
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<T extends GlobalState>(
setGlobal(global);
const isAnimatingAsSnap = selectCanAnimateSnapEffect(global);
setTimeout(() => {
global = getGlobal();
global = deleteChatScheduledMessages(global, chatId, ids);
@ -1137,5 +1145,5 @@ function deleteScheduledMessages<T extends GlobalState>(
global, chatId, MAIN_THREAD_ID, 'scheduledIds', Object.keys(scheduledMessages || {}).map(Number),
);
setGlobal(global);
}, ANIMATION_DELAY);
}, isAnimatingAsSnap ? SNAP_ANIMATION_DELAY : ANIMATION_DELAY);
}

View File

@ -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 = {

View File

@ -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<T extends GlobalState>(global: T)
export function selectIsSynced<T extends GlobalState>(global: T) {
return global.isSynced;
}
export function selectCanAnimateSnapEffect<T extends GlobalState>(global: T) {
return IS_SNAP_EFFECT_SUPPORTED && selectPerformanceSettingsValue(global, 'snapEffect');
}

View File

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

View File

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

View File

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