[Perf] Introduce Fasterdom and some performance fixes

This commit is contained in:
Alexander Zinchuk 2023-04-23 18:33:02 +04:00
parent 925958d94e
commit dba6963c34
101 changed files with 1691 additions and 982 deletions

View File

@ -156,7 +156,9 @@ const App: FC<StateProps> = ({
useEffect(() => {
updateSizes();
}, []);
useEffect(() => {
if (IS_MULTITAB_SUPPORTED) return;
addActiveTabChangeListener(() => {

View File

@ -1,4 +1,5 @@
import type { ChangeEvent } from 'react';
import { requestMeasure } from '../../lib/fasterdom/fasterdom';
import monkeyPath from '../../assets/monkey.svg';
@ -149,7 +150,7 @@ const AuthPhoneNumber: FC<StateProps> = ({
const isJustPastedRef = useRef(false);
const handlePaste = useCallback(() => {
isJustPastedRef.current = true;
requestAnimationFrame(() => {
requestMeasure(() => {
isJustPastedRef.current = false;
});
}, []);

View File

@ -1,6 +1,7 @@
import React, {
useEffect, useRef, memo, useCallback,
useEffect, useRef, memo, useCallback, useLayoutEffect,
} from '../../lib/teact/teact';
import { disableStrict, enableStrict } from '../../lib/fasterdom/stricterdom';
import { getActions, withGlobal } from '../../global';
import type { FC } from '../../lib/teact/teact';
@ -33,6 +34,7 @@ type StateProps =
const DATA_PREFIX = 'tg://login?token=';
const QR_SIZE = 280;
const QR_PLANE_SIZE = 54;
const QR_CODE_MUTATION_DURATION = 50; // The library is asynchronous and we need to wait for its mutation code
let qrCodeStylingPromise: Promise<typeof import('qr-code-styling')>;
@ -88,7 +90,7 @@ const AuthCode: FC<StateProps> = ({
const transitionClassNames = useMediaTransition(isQrMounted);
useEffect(() => {
useLayoutEffect(() => {
if (!authQrCode || !qrCode) {
return () => {
unmarkQrMounted();
@ -102,6 +104,8 @@ const AuthCode: FC<StateProps> = ({
const container = qrCodeRef.current!;
const data = `${DATA_PREFIX}${authQrCode.token}`;
disableStrict();
qrCode.update({
data,
});
@ -110,6 +114,11 @@ const AuthCode: FC<StateProps> = ({
qrCode.append(container);
markQrMounted();
}
setTimeout(() => {
enableStrict();
}, QR_CODE_MUTATION_DURATION);
return undefined;
}, [connectionState, authQrCode, isQrMounted, markQrMounted, unmarkQrMounted, qrCode]);

View File

@ -1,11 +1,11 @@
import type { RefObject } from 'react';
import type { FC } from '../../lib/teact/teact';
import { requestMeasure } from '../../lib/fasterdom/fasterdom';
import React, {
useEffect, useRef, memo, useCallback, useState, useMemo,
} from '../../lib/teact/teact';
import { fastRaf } from '../../util/schedulers';
import buildClassName from '../../util/buildClassName';
import buildStyle from '../../util/buildStyle';
import generateIdFor from '../../util/generateIdFor';
@ -13,7 +13,7 @@ import generateIdFor from '../../util/generateIdFor';
import useHeavyAnimationCheck, { isHeavyAnimating } from '../../hooks/useHeavyAnimationCheck';
import usePriorityPlaybackCheck, { isPriorityPlaybackActive } from '../../hooks/usePriorityPlaybackCheck';
import useBackgroundMode, { isBackgroundModeActive } from '../../hooks/useBackgroundMode';
import useSyncEffect from '../../hooks/useSyncEffect';
import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps';
import { useStateRef } from '../../hooks/useStateRef';
export type OwnProps = {
@ -122,7 +122,8 @@ const AnimatedSticker: FC<OwnProps> = ({
// Wait until element is properly mounted
if (sharedCanvas && !sharedCanvas.offsetParent) {
setTimeout(exec, ANIMATION_END_TIMEOUT);
// `requestMeasure` is useful as timeouts are run in parallel with image loadings and thus causing reflow
setTimeout(() => requestMeasure(exec), ANIMATION_END_TIMEOUT);
return;
}
@ -156,7 +157,7 @@ const AnimatedSticker: FC<OwnProps> = ({
exec();
} else {
ensureLottie().then(() => {
fastRaf(() => {
requestMeasure(() => {
if (containerRef.current) {
exec();
}
@ -164,8 +165,8 @@ const AnimatedSticker: FC<OwnProps> = ({
});
}
}, [
animation, renderId, tgsUrl, color, isLowPriority, noLoop, onLoad, quality, size, speed, onEnded, onLoop,
viewId, sharedCanvas, sharedCanvasCoords,
animation, renderId, tgsUrl, color, isLowPriority, noLoop, onLoad, quality, size, speed, onEnded, onLoop, viewId,
sharedCanvas, sharedCanvasCoords,
]);
useEffect(() => {
@ -197,7 +198,7 @@ const AnimatedSticker: FC<OwnProps> = ({
}, [animation, playRef, playSegmentRef, viewId]);
const playAnimationOnRaf = useCallback(() => {
fastRaf(playAnimation);
requestMeasure(playAnimation);
}, [playAnimation]);
const pauseAnimation = useCallback(() => {
@ -206,13 +207,13 @@ const AnimatedSticker: FC<OwnProps> = ({
}
}, [animation, viewId]);
useSyncEffect(([prevNoLoop]) => {
useEffectWithPrevDeps(([prevNoLoop]) => {
if (prevNoLoop !== undefined && noLoop !== prevNoLoop) {
animation?.setNoLoop(noLoop);
}
}, [noLoop, animation]);
useSyncEffect(([prevSharedCanvasCoords]) => {
useEffectWithPrevDeps(([prevSharedCanvasCoords]) => {
if (prevSharedCanvasCoords !== undefined && sharedCanvasCoords !== prevSharedCanvasCoords) {
animation?.setSharedCanvasCoords(viewId, sharedCanvasCoords);
}

View File

@ -1,6 +1,6 @@
import React, {
memo,
useLayoutEffect,
useEffect,
useMemo,
useRef,
useState,
@ -71,7 +71,7 @@ const ChatForumLastMessage: FC<OwnProps> = ({
});
}
useLayoutEffect(() => {
useEffect(() => {
const lastMessageElement = lastMessageRef.current;
const mainColumnElement = mainColumnRef.current;
if (!lastMessageElement || !mainColumnElement) return;

View File

@ -35,10 +35,10 @@ import {
} from '../../global/selectors';
import useAsyncRendering from '../right/hooks/useAsyncRendering';
import { useIntersectionObserver } from '../../hooks/useIntersectionObserver';
import useHorizontalScroll from '../../hooks/useHorizontalScroll';
import useLang from '../../hooks/useLang';
import useAppLayout from '../../hooks/useAppLayout';
import { useStickerPickerObservers } from './hooks/useStickerPickerObservers';
import Loading from '../ui/Loading';
import Button from '../ui/Button';
@ -54,6 +54,7 @@ type OwnProps = {
scrollHeaderRef?: RefObject<HTMLDivElement>;
chatId?: string;
className?: string;
isHidden?: boolean;
loadAndPlay: boolean;
idPrefix?: string;
withDefaultTopicIcons?: boolean;
@ -84,9 +85,8 @@ type StateProps = {
isCurrentUserPremium?: boolean;
};
const SMOOTH_SCROLL_DISTANCE = 500;
const SMOOTH_SCROLL_DISTANCE = 100;
const HEADER_BUTTON_WIDTH = 52; // px (including margin)
const STICKER_INTERSECTION_THROTTLE = 200;
const DEFAULT_ID_PREFIX = 'custom-emoji-set';
const TOP_REACTIONS_COUNT = 16;
const RECENT_REACTIONS_COUNT = 32;
@ -99,12 +99,11 @@ const STICKER_SET_IDS_WITH_COVER = new Set([
PREMIUM_STICKER_SET_ID,
]);
const stickerSetIntersections: boolean[] = [];
const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
scrollContainerRef,
scrollHeaderRef,
className,
isHidden,
loadAndPlay,
addedCustomEmojiIds,
customEmojisById,
@ -155,31 +154,12 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
: Object.values(pickTruthy(customEmojisById!, recentCustomEmojiIds!));
}, [customEmojisById, isStatusPicker, recentCustomEmojiIds, recentStatusEmojis]);
const { observe: observeIntersection } = useIntersectionObserver({
rootRef: containerRef,
throttleMs: STICKER_INTERSECTION_THROTTLE,
}, (entries) => {
entries.forEach((entry) => {
const { id } = entry.target as HTMLDivElement;
if (!id || !id.startsWith(idPrefix)) {
return;
}
const index = Number(id.replace(`${idPrefix}-`, ''));
stickerSetIntersections[index] = entry.isIntersecting;
});
const intersectingWithIndexes = stickerSetIntersections
.map((isIntersecting, index) => ({ index, isIntersecting }))
.filter(({ isIntersecting }) => isIntersecting);
if (!intersectingWithIndexes.length) {
return;
}
setActiveSetIndex(intersectingWithIndexes[Math.floor(intersectingWithIndexes.length / 2)].index);
});
const { observe: observeIntersectionForCovers } = useIntersectionObserver({ rootRef: headerRef });
const {
observeIntersectionForSet,
observeIntersectionForPlayingItems,
observeIntersectionForShowingItems,
observeIntersectionForCovers,
} = useStickerPickerObservers(containerRef, headerRef, idPrefix, setActiveSetIndex, isHidden);
const lang = useLang();
@ -412,8 +392,10 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
loadAndPlay={Boolean(canAnimate && loadAndPlay)}
index={i}
idPrefix={idPrefix}
observeIntersection={observeIntersection}
shouldRender={activeSetIndex >= i - 1 && activeSetIndex <= i + 1}
observeIntersection={observeIntersectionForSet}
observeIntersectionForPlayingItems={observeIntersectionForPlayingItems}
observeIntersectionForShowingItems={observeIntersectionForShowingItems}
isNearActive={activeSetIndex >= i - 1 && activeSetIndex <= i + 1}
isSavedMessages={isSavedMessages}
isStatusPicker={isStatusPicker}
isReactionPicker={isReactionPicker}

View File

@ -1,5 +1,5 @@
import type { FC } from '../../lib/teact/teact';
import React, { useRef } from '../../lib/teact/teact';
import React, { useCallback, useRef } from '../../lib/teact/teact';
import type {
ApiUser, ApiMessage, ApiChat,
@ -18,6 +18,7 @@ import { getPictogramDimensions } from './helpers/mediaDimensions';
import buildClassName from '../../util/buildClassName';
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import { IS_TOUCH_ENV, MouseButton } from '../../util/windowEnvironment';
import { useIsIntersecting } from '../../hooks/useIntersectionObserver';
import useMedia from '../../hooks/useMedia';
import useThumbnail from '../../hooks/useThumbnail';
@ -71,6 +72,14 @@ const EmbeddedMessage: FC<OwnProps> = ({
const senderTitle = sender ? getSenderTitle(lang, sender) : message?.forwardInfo?.hiddenUserName;
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (e.type === 'mousedown' && e.button !== MouseButton.Main) {
return;
}
onClick?.();
}, [onClick]);
return (
<div
ref={ref}
@ -79,7 +88,8 @@ const EmbeddedMessage: FC<OwnProps> = ({
className,
sender && !noUserColors && `color-${getUserColorKey(sender)}`,
)}
onClick={message ? onClick : undefined}
onClick={message && IS_TOUCH_ENV ? handleClick : undefined}
onMouseDown={message && !IS_TOUCH_ENV ? handleClick : undefined}
>
{mediaThumbnail && renderPictogram(mediaThumbnail, mediaBlobUrl, isRoundVideo, isProtected, isSpoiler)}
<div className="message-text">

View File

@ -3,6 +3,7 @@ import type { FC } from '../../lib/teact/teact';
import React, {
memo, useEffect, useRef, useState,
} from '../../lib/teact/teact';
import { requestMutation } from '../../lib/fasterdom/fasterdom';
import { MIN_PASSWORD_LENGTH } from '../../config';
import { IS_TOUCH_ENV } from '../../util/windowEnvironment';
@ -72,7 +73,7 @@ const PasswordForm: FC<OwnProps> = ({
useEffect(() => {
if (error) {
requestAnimationFrame(() => {
requestMutation(() => {
inputRef.current!.focus();
inputRef.current!.select();
});

View File

@ -2,6 +2,7 @@ import type { FC } from '../../lib/teact/teact';
import React, {
useCallback, useRef, useEffect, memo,
} from '../../lib/teact/teact';
import { requestMutation } from '../../lib/fasterdom/fasterdom';
import { isUserId } from '../../global/helpers';
@ -58,7 +59,7 @@ const Picker: FC<OwnProps> = ({
useEffect(() => {
setTimeout(() => {
requestAnimationFrame(() => {
requestMutation(() => {
inputRef.current!.focus();
});
}, FOCUS_DELAY_MS);

View File

@ -39,6 +39,7 @@ type OwnProps<T> = {
isCurrentUserPremium?: boolean;
sharedCanvasRef?: React.RefObject<HTMLCanvasElement>;
observeIntersection: ObserveFn;
observeIntersectionForShowing?: ObserveFn;
noShowPremium?: boolean;
onClick?: (arg: OwnProps<T>['clickArg'], isSilent?: boolean, shouldSchedule?: boolean) => void;
clickArg: T;
@ -69,6 +70,7 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
isStatusPicker,
canViewSet,
observeIntersection,
observeIntersectionForShowing,
isSelected,
isCurrentUserPremium,
noShowPremium,
@ -100,6 +102,8 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
const shouldLoad = isIntersecting;
const shouldPlay = isIntersecting && !noAnimate;
const isIntesectingForShowing = useIsIntersecting(ref, observeIntersectionForShowing);
const {
isContextMenuOpen, contextMenuPosition,
handleBeforeContextMenu, handleContextMenu,
@ -287,19 +291,21 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
onClick={handleClick}
onContextMenu={handleContextMenu}
>
<StickerView
containerRef={ref}
sticker={sticker}
isSmall
size={size}
shouldLoop
shouldPreloadPreview
noLoad={!shouldLoad}
noPlay={!shouldPlay}
withSharedAnimation
sharedCanvasRef={sharedCanvasRef}
customColor={customColor}
/>
{isIntesectingForShowing && (
<StickerView
containerRef={ref}
sticker={sticker}
isSmall
size={size}
shouldLoop
shouldPreloadPreview
noLoad={!shouldLoad}
noPlay={!shouldPlay}
withSharedAnimation
sharedCanvasRef={sharedCanvasRef}
customColor={customColor}
/>
)}
{!noShowPremium && isLocked && (
<div
className="sticker-locked"

View File

@ -1,5 +1,5 @@
import React, {
memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,
memo, useCallback, useEffect, useMemo, useRef, useState,
} from '../../lib/teact/teact';
import { getActions, getGlobal } from '../../global';
@ -41,7 +41,7 @@ type OwnProps = {
loadAndPlay: boolean;
index: number;
idPrefix?: string;
shouldRender: boolean;
isNearActive: boolean;
favoriteStickers?: ApiSticker[];
isSavedMessages?: boolean;
isStatusPicker?: boolean;
@ -51,7 +51,9 @@ type OwnProps = {
selectedReactionIds?: string[];
withDefaultTopicIcon?: boolean;
withDefaultStatusIcon?: boolean;
observeIntersection: ObserveFn;
observeIntersection?: ObserveFn;
observeIntersectionForPlayingItems: ObserveFn;
observeIntersectionForShowingItems: ObserveFn;
availableReactions?: ApiAvailableReaction[];
onStickerSelect?: (sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean) => void;
onReactionSelect?: (reaction: ApiReaction) => void;
@ -74,7 +76,7 @@ const StickerSet: FC<OwnProps> = ({
loadAndPlay,
index,
idPrefix,
shouldRender,
isNearActive,
favoriteStickers,
availableReactions,
isSavedMessages,
@ -86,6 +88,8 @@ const StickerSet: FC<OwnProps> = ({
selectedReactionIds,
withDefaultStatusIcon,
observeIntersection,
observeIntersectionForPlayingItems,
observeIntersectionForShowingItems,
onReactionSelect,
onStickerSelect,
onStickerUnfave,
@ -119,9 +123,11 @@ const StickerSet: FC<OwnProps> = ({
const [itemsPerRow, setItemsPerRow] = useState(getItemsPerRowFallback(windowWidth));
const isIntersecting = useIsIntersecting(ref, observeIntersection);
const isIntersecting = useIsIntersecting(ref, observeIntersection ?? observeIntersectionForShowingItems);
const transitionClassNames = useMediaTransition(isIntersecting);
const transitionClassNames = useMediaTransition(shouldRender);
// `isNearActive` is set in advance during animation, but it is not reliable for short sets
const shouldRender = isNearActive || isIntersecting;
const stickerMarginPx = isMobile ? 8 : 16;
const emojiMarginPx = isMobile ? 8 : 10;
@ -194,13 +200,13 @@ const StickerSet: FC<OwnProps> = ({
}, [calculateItemsPerRow]);
useResizeObserver(ref, handleResize);
useLayoutEffect(() => {
useEffect(() => {
if (!ref.current) return;
setItemsPerRow(calculateItemsPerRow(ref.current.clientWidth));
}, [calculateItemsPerRow]);
useEffect(() => {
if (isIntersecting && !stickerSet.stickers?.length && !stickerSet.reactions?.length && stickerSet.accessHash) {
if (shouldRender && !stickerSet.stickers?.length && !stickerSet.reactions?.length && stickerSet.accessHash) {
loadStickers({
stickerSetInfo: {
id: stickerSet.id,
@ -208,7 +214,7 @@ const StickerSet: FC<OwnProps> = ({
},
});
}
}, [isIntersecting, loadStickers, stickerSet]);
}, [shouldRender, loadStickers, stickerSet]);
const isLocked = !isSavedMessages && !isRecent && isEmoji && !isCurrentUserPremium
&& stickerSet.stickers?.some(({ isFree }) => !isFree);
@ -299,7 +305,7 @@ const StickerSet: FC<OwnProps> = ({
isSelected={isSelected}
loadAndPlay={loadAndPlay}
availableReactions={availableReactions}
observeIntersection={observeIntersection}
observeIntersection={observeIntersectionForPlayingItems}
onClick={onReactionSelect!}
sharedCanvasRef={sharedCanvasRef}
sharedCanvasHqRef={sharedCanvasHqRef}
@ -321,7 +327,8 @@ const StickerSet: FC<OwnProps> = ({
key={sticker.id}
sticker={sticker}
size={itemSize}
observeIntersection={observeIntersection}
observeIntersection={observeIntersectionForPlayingItems}
observeIntersectionForShowing={observeIntersectionForShowingItems}
noAnimate={!loadAndPlay}
isSavedMessages={isSavedMessages}
isStatusPicker={isStatusPicker}

View File

@ -0,0 +1,88 @@
import type { RefObject } from 'react';
import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver';
import useSyncEffect from '../../../hooks/useSyncEffect';
import { useRef } from '../../../lib/teact/teact';
import { ANIMATION_END_DELAY } from '../../../config';
const STICKER_INTERSECTION_THROTTLE = 200;
const STICKER_INTERSECTION_MARGIN = 100;
const SLIDE_TRANSITION_DURATION = 350 + ANIMATION_END_DELAY;
export function useStickerPickerObservers(
containerRef: RefObject<HTMLDivElement>,
headerRef: RefObject<HTMLDivElement>,
idPrefix: string,
setActiveSetIndex: (index: number) => void,
isHidden?: boolean,
) {
const stickerSetIntersectionsRef = useRef<boolean[]>([]);
const {
observe: observeIntersectionForSet,
freeze: freezeForSet,
unfreeze: unfreezeForSet,
} = useIntersectionObserver({
rootRef: containerRef,
}, (entries) => {
const stickerSetIntersections = stickerSetIntersectionsRef.current;
entries.forEach((entry) => {
const index = Number(entry.target.id.replace(`${idPrefix}-`, ''));
stickerSetIntersections[index] = entry.isIntersecting;
});
const minIntersectingIndex = stickerSetIntersections.reduce((lowestIndex, isIntersecting, index) => {
return isIntersecting && index < lowestIndex ? index : lowestIndex;
}, Infinity);
if (minIntersectingIndex === Infinity) {
return;
}
setActiveSetIndex(minIntersectingIndex);
});
const {
observe: observeIntersectionForShowingItems,
freeze: freezeForShowingItems,
unfreeze: unfreezeForShowingItems,
} = useIntersectionObserver({
rootRef: containerRef,
throttleMs: STICKER_INTERSECTION_THROTTLE,
margin: STICKER_INTERSECTION_MARGIN,
});
const {
observe: observeIntersectionForPlayingItems,
} = useIntersectionObserver({
rootRef: containerRef,
throttleMs: STICKER_INTERSECTION_THROTTLE,
margin: STICKER_INTERSECTION_MARGIN,
});
const {
observe: observeIntersectionForCovers,
} = useIntersectionObserver({
rootRef: headerRef,
});
useSyncEffect(() => {
if (isHidden) {
freezeForSet();
freezeForShowingItems();
} else {
setTimeout(() => {
unfreezeForShowingItems();
unfreezeForSet();
}, SLIDE_TRANSITION_DURATION);
}
}, [freezeForSet, freezeForShowingItems, isHidden, unfreezeForSet, unfreezeForShowingItems]);
return {
observeIntersectionForSet,
observeIntersectionForShowingItems,
observeIntersectionForPlayingItems,
observeIntersectionForCovers,
};
}

View File

@ -2,6 +2,7 @@ import React, {
memo, useCallback, useEffect, useMemo, useRef, useState,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import { requestNextMutation } from '../../../lib/fasterdom/fasterdom';
import type { FC } from '../../../lib/teact/teact';
import type { ApiChat } from '../../../api/types';
@ -20,7 +21,6 @@ import { getOrderedTopics } from '../../../global/helpers';
import captureEscKeyListener from '../../../util/captureEscKeyListener';
import { waitForTransitionEnd } from '../../../util/cssAnimationEndListeners';
import { captureEvents, SwipeDirection } from '../../../util/captureEvents';
import { fastRaf } from '../../../util/schedulers';
import useInfiniteScroll from '../../../hooks/useInfiniteScroll';
import { useIntersectionObserver, useOnIntersect } from '../../../hooks/useIntersectionObserver';
@ -147,13 +147,11 @@ const ForumPanel: FC<OwnProps & StateProps> = ({
useEffect(() => {
if (prevIsVisible !== isVisible) {
// For performance reasons, we delay animation of the topic list panel to the next animation frame
fastRaf(() => {
requestNextMutation(() => {
if (!ref.current) return;
const dispatchHeavyAnimationStop = dispatchHeavyAnimationEvent();
waitForTransitionEnd(ref.current, () => {
dispatchHeavyAnimationStop();
});
waitForTransitionEnd(ref.current, dispatchHeavyAnimationStop);
onOpenAnimationStart?.();

View File

@ -1,4 +1,5 @@
import React, { useLayoutEffect, useMemo, useRef } from '../../../../lib/teact/teact';
import { requestMutation } from '../../../../lib/fasterdom/fasterdom';
import { getGlobal } from '../../../../global';
import type { AnimationLevel } from '../../../../types';
@ -24,7 +25,6 @@ import useLang from '../../../../hooks/useLang';
import useEnsureMessage from '../../../../hooks/useEnsureMessage';
import useMedia from '../../../../hooks/useMedia';
import { ChatAnimationTypes } from './useChatAnimationType';
import { fastRaf } from '../../../../util/schedulers';
import MessageSummary from '../../../common/MessageSummary';
import ChatForumLastMessage from '../../../common/ChatForumLastMessage';
@ -169,14 +169,14 @@ export default function useChatListEntry({
if (animationType === ChatAnimationTypes.Opacity) {
element.style.opacity = '0';
fastRaf(() => {
requestMutation(() => {
element.classList.add('animate-opacity');
element.style.opacity = '1';
});
} else if (animationType === ChatAnimationTypes.Move) {
element.style.transform = `translate3d(0, ${-orderDiff * CHAT_HEIGHT_PX}px, 0)`;
fastRaf(() => {
requestMutation(() => {
element.classList.add('animate-transform');
element.style.transform = '';
});
@ -185,7 +185,7 @@ export default function useChatListEntry({
}
setTimeout(() => {
fastRaf(() => {
requestMutation(() => {
element.classList.remove('animate-opacity', 'animate-transform');
element.style.opacity = '';
element.style.transform = '';

View File

@ -2,6 +2,7 @@ import type { FC } from '../../../../lib/teact/teact';
import React, {
useCallback, useRef, useEffect, memo,
} from '../../../../lib/teact/teact';
import { requestMutation } from '../../../../lib/fasterdom/fasterdom';
import { getActions, withGlobal } from '../../../../global';
import { isUserId } from '../../../../global/helpers';
@ -66,7 +67,7 @@ const SettingsFoldersChatsPicker: FC<OwnProps & StateProps> = ({
useEffect(() => {
setTimeout(() => {
requestAnimationFrame(() => {
requestMutation(() => {
inputRef.current!.focus();
});
}, FOCUS_DELAY_MS);

View File

@ -1,4 +1,5 @@
import React, { memo, useCallback, useRef } from '../../lib/teact/teact';
import { requestMeasure } from '../../lib/fasterdom/fasterdom';
import { withGlobal } from '../../global';
import type { TabState } from '../../global/types';
@ -162,7 +163,7 @@ const ConfettiContainer: FC<StateProps> = ({ confetti }) => {
});
confettiRef.current = confettiRef.current.filter((c) => !confettiToRemove.includes(c));
if (confettiRef.current.length) {
requestAnimationFrame(updateCanvas);
requestMeasure(updateCanvas);
} else {
isRafStartedRef.current = false;
}
@ -175,7 +176,7 @@ const ConfettiContainer: FC<StateProps> = ({ confetti }) => {
hideTimeout = setTimeout(forceUpdate, CONFETTI_FADEOUT_TIMEOUT);
if (!isRafStartedRef.current) {
isRafStartedRef.current = true;
requestAnimationFrame(updateCanvas);
requestMeasure(updateCanvas);
}
}
return () => {

View File

@ -3,6 +3,7 @@ import React, {
useEffect, memo, useCallback, useState, useRef, useLayoutEffect,
} from '../../lib/teact/teact';
import { addExtraClass } from '../../lib/teact/teact-dom';
import { requestNextMutation } from '../../lib/fasterdom/fasterdom';
import { getActions, getGlobal, withGlobal } from '../../global';
import type { AnimationLevel, LangCode } from '../../types';
@ -32,7 +33,6 @@ import buildClassName from '../../util/buildClassName';
import { waitForTransitionEnd } from '../../util/cssAnimationEndListeners';
import { processDeepLink } from '../../util/deeplink';
import { parseInitialLocationHash, parseLocationHash } from '../../util/routing';
import { fastRaf } from '../../util/schedulers';
import { Bundles, loadBundle } from '../../util/moduleLoader';
import updateIcon from '../../util/updateIcon';
@ -393,7 +393,7 @@ const Main: FC<OwnProps & StateProps> = ({
willAnimateLeftColumnRef.current = true;
if (IS_ANDROID) {
fastRaf(() => {
requestNextMutation(() => {
document.body.classList.toggle('android-left-blackout-open', !isLeftColumnOpen);
});
}

View File

@ -1,6 +1,6 @@
import type { FC } from '../../lib/teact/teact';
import React, {
memo, useCallback, useEffect, useRef, useState,
memo, useCallback, useEffect, useLayoutEffect, useRef, useState,
} from '../../lib/teact/teact';
import type { AnimationLevel, MediaViewerOrigin } from '../../types';
@ -22,12 +22,12 @@ import useSignal from '../../hooks/useSignal';
import useDerivedState from '../../hooks/useDerivedState';
import { useFullscreenStatus } from '../../hooks/useFullscreen';
import useZoomChange from './hooks/useZoomChangeSignal';
import { useSignalRef } from '../../hooks/useSignalRef';
import useControlsSignal from './hooks/useControlsSignal';
import MediaViewerContent from './MediaViewerContent';
import './MediaViewerSlides.scss';
import { useSignalRef } from '../../hooks/useSignalRef';
import useControlsSignal from './hooks/useControlsSignal';
const { easeOutCubic, easeOutQuart } = timingFunctions;
@ -143,7 +143,7 @@ const MediaViewerSlides: FC<OwnProps> = ({
useTimeout(() => setControlsVisible(true), ANIMATION_DURATION);
useEffect(() => {
useLayoutEffect(() => {
const { x, y, scale } = getTransform();
lockControls(scale !== 1);
if (leftSlideRef.current) {
@ -251,7 +251,7 @@ const MediaViewerSlides: FC<OwnProps> = ({
const calculateOffsetBoundaries = (
{ x, y, scale }: Transform,
offsetTop = 0,
):[Transform, boolean, boolean] => {
): [Transform, boolean, boolean] => {
if (!initialContentRect) return [{ x, y, scale }, true, true];
// Get current content boundaries
let inBoundsX = true;
@ -425,7 +425,7 @@ const MediaViewerSlides: FC<OwnProps> = ({
content = activeSlideRef.current.querySelector('img, video');
if (!content) return;
// Store initial content rect, without transformations
initialContentRect = content.getBoundingClientRect();
initialContentRect = content!.getBoundingClientRect();
}
},
onDrag: (event, captureEvent, {

View File

@ -1,5 +1,6 @@
import type { ApiDimensions, ApiMessage } from '../../../api/types';
import { requestMutation } from '../../../lib/fasterdom/fasterdom';
import type { ApiDimensions, ApiMessage } from '../../../api/types';
import { MediaViewerOrigin } from '../../../types';
import { ANIMATION_END_DELAY, MESSAGE_CONTENT_SELECTOR } from '../../../config';
@ -62,27 +63,26 @@ export function animateOpening(
const fromScaleX = fromWidth / toWidth;
const fromScaleY = fromHeight / toHeight;
const ghost = createGhost(bestImageData || fromImage);
applyStyles(ghost, {
top: `${toTop}px`,
left: `${toLeft}px`,
width: `${toWidth}px`,
height: `${toHeight}px`,
transform: `translate3d(${fromTranslateX}px, ${fromTranslateY}px, 0) scale(${fromScaleX}, ${fromScaleY})`,
});
applyShape(ghost, origin);
requestMutation(() => {
const ghost = createGhost(bestImageData || fromImage);
applyStyles(ghost, {
top: `${toTop}px`,
left: `${toLeft}px`,
width: `${toWidth}px`,
height: `${toHeight}px`,
transform: `translate3d(${fromTranslateX}px, ${fromTranslateY}px, 0) scale(${fromScaleX}, ${fromScaleY})`,
});
applyShape(ghost, origin);
document.body.classList.add('ghost-animating');
requestAnimationFrame(() => {
document.body.appendChild(ghost);
document.body.classList.add('ghost-animating');
requestAnimationFrame(() => {
requestMutation(() => {
ghost.style.transform = '';
clearShape(ghost);
setTimeout(() => {
requestAnimationFrame(() => {
requestMutation(() => {
if (document.body.contains(ghost)) {
document.body.removeChild(ghost);
}
@ -146,43 +146,41 @@ export function animateClosing(origin: MediaViewerOrigin, bestImageData: string,
}
const existingGhost = document.getElementsByClassName('ghost')[0] as HTMLDivElement;
const ghost = existingGhost || createGhost(bestImageData || toImage, origin);
if (!existingGhost) {
applyStyles(ghost, {
let styles: Record<string, string>;
if (existingGhost) {
const {
top, left, width, height,
} = existingGhost.getBoundingClientRect();
const scaleX = width / toWidth;
const scaleY = height / toHeight;
styles = {
transition: 'none',
top: `${toTop}px`,
left: `${toLeft}px`,
transformOrigin: 'top left',
transform: `translate3d(${left - toLeft}px, ${top - toTop}px, 0) scale(${scaleX}, ${scaleY})`,
width: `${toWidth}px`,
height: `${toHeight}px`,
};
} else {
styles = {
top: `${toTop}px`,
left: `${toLeft}px`,
width: `${toWidth}px`,
height: `${toHeight}px`,
transform: `translate3d(${fromTranslateX}px, ${fromTranslateY}px, 0) scale(${fromScaleX}, ${fromScaleY})`,
});
};
}
requestAnimationFrame(() => {
if (existingGhost) {
const {
top,
left,
width,
height,
} = existingGhost.getBoundingClientRect();
const scaleX = width / toWidth;
const scaleY = height / toHeight;
applyStyles(ghost, {
transition: 'none',
top: `${toTop}px`,
left: `${toLeft}px`,
transformOrigin: 'top left',
transform: `translate3d(${left - toLeft}px, ${top - toTop}px, 0) scale(${scaleX}, ${scaleY})`,
width: `${toWidth}px`,
height: `${toHeight}px`,
});
}
document.body.classList.add('ghost-animating');
requestMutation(() => {
applyStyles(ghost, styles);
if (!existingGhost) document.body.appendChild(ghost);
document.body.classList.add('ghost-animating');
requestAnimationFrame(() => {
requestMutation(() => {
if (existingGhost) {
existingGhost.style.transition = '';
}
@ -196,7 +194,7 @@ export function animateClosing(origin: MediaViewerOrigin, bestImageData: string,
applyShape(ghost, origin);
setTimeout(() => {
requestAnimationFrame(() => {
requestMutation(() => {
if (document.body.contains(ghost)) {
document.body.removeChild(ghost);
}

View File

@ -5,6 +5,7 @@ import React, {
useCallback,
useState,
} from '../../lib/teact/teact';
import { requestMeasure, requestNextMutation } from '../../lib/fasterdom/fasterdom';
import { getActions, withGlobal } from '../../global';
import type { MessageListType } from '../../global/types';
@ -154,9 +155,9 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
const searchInput = document.querySelector<HTMLInputElement>('#MobileSearch input')!;
searchInput.focus();
} else if (noAnimation) {
// The second RAF is necessary because teact must update the state and render the async component
requestAnimationFrame(() => {
requestAnimationFrame(setFocusInSearchInput);
// The second RAF is necessary because Teact must update the state and render the async component
requestMeasure(() => {
requestNextMutation(setFocusInSearchInput);
});
} else {
setTimeout(setFocusInSearchInput, SEARCH_FOCUS_DELAY_MS);

View File

@ -10,7 +10,7 @@ import {
getMessageMediaHash, getMessageSingleInlineButton,
} from '../../global/helpers';
import buildClassName from '../../util/buildClassName';
import { IS_TOUCH_ENV } from '../../util/windowEnvironment';
import { IS_TOUCH_ENV, MouseButton } from '../../util/windowEnvironment';
import renderText from '../common/helpers/renderText';
import useMedia from '../../hooks/useMedia';
@ -39,7 +39,7 @@ type OwnProps = {
customTitle?: string;
className?: string;
onUnpinMessage?: (id: number) => void;
onClick?: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void;
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
onAllPinnedClick?: () => void;
isLoading?: boolean;
isFullWidth?: boolean;
@ -78,6 +78,14 @@ const HeaderPinnedMessage: FC<OwnProps> = ({
const [noHoverColor, markNoHoverColor, unmarkNoHoverColor] = useFlag();
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (e.type === 'mousedown' && e.button !== MouseButton.Main) {
return;
}
onClick?.(e);
}, [onClick]);
function renderPictogram(thumbDataUri?: string, blobUrl?: string, spoiler?: boolean) {
const { width, height } = getPictogramDimensions();
const srcUrl = blobUrl || thumbDataUri;
@ -140,7 +148,8 @@ const HeaderPinnedMessage: FC<OwnProps> = ({
/>
<div
className={buildClassName(styles.pinnedMessage, noHoverColor && styles.noHover)}
onClick={onClick}
onClick={IS_TOUCH_ENV ? handleClick : undefined}
onMouseDown={!IS_TOUCH_ENV ? handleClick : undefined}
dir={lang.isRtl ? 'rtl' : undefined}
>
<PinnedMessageNavigation

View File

@ -1,12 +1,18 @@
import type { FC } from '../../lib/teact/teact';
import React, {
memo, useCallback, useEffect, useMemo, useRef,
memo,
useCallback,
useEffect,
useMemo,
useRef,
} from '../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../global';
import { requestForcedReflow, forceMeasure, requestMeasure } from '../../lib/fasterdom/fasterdom';
import type { FC } from '../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../global';
import type {
ApiBotInfo, ApiMessage, ApiRestrictionReason, ApiTopic,
} from '../../api/types';
import { MAIN_THREAD_ID } from '../../api/types';
import type { MessageListType } from '../../global/types';
import type { AnimationLevel } from '../../types';
@ -47,7 +53,7 @@ import {
} from '../../global/helpers';
import { orderBy } from '../../util/iteratees';
import { DPR } from '../../util/windowEnvironment';
import { fastRaf, debounce, onTickEnd } from '../../util/schedulers';
import { debounce, onTickEnd } from '../../util/schedulers';
import buildClassName from '../../util/buildClassName';
import { groupMessages } from './helpers/groupMessages';
import { preventMessageInputBlur } from './helpers/preventMessageInputBlur';
@ -321,17 +327,15 @@ const MessageList: FC<OwnProps & StateProps> = ({
onPinnedIntersectionChange({ hasScrolled: true });
}
fastRaf(() => {
if (!container.parentElement) {
return;
}
if (!container.parentElement) {
return;
}
scrollOffsetRef.current = container.scrollHeight - container.scrollTop;
scrollOffsetRef.current = container.scrollHeight - container.scrollTop;
if (type === 'thread') {
setScrollOffset({ chatId, threadId, scrollOffset: scrollOffsetRef.current });
}
});
if (type === 'thread') {
setScrollOffset({ chatId, threadId, scrollOffset: scrollOffsetRef.current });
}
});
}, [
updateStickyDates, hasTools, getForceNextPinnedInHeader, onPinnedIntersectionChange, type, chatId, threadId,
@ -381,7 +385,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
});
useSyncEffect(
() => rememberScrollPositionRef.current(),
() => forceMeasure(() => rememberScrollPositionRef.current()),
// This will run before modifying content and should match deps for `useLayoutEffectWithPrevDeps` below
[messageIds, isViewportNewest, hasTools, rememberScrollPositionRef],
);
@ -397,123 +401,133 @@ const MessageList: FC<OwnProps & StateProps> = ({
const prevContainerHeight = prevContainerHeightRef.current;
prevContainerHeightRef.current = containerHeight;
const container = containerRef.current!;
listItemElementsRef.current = Array.from(container.querySelectorAll<HTMLDivElement>('.message-list-item'));
requestForcedReflow(() => {
const container = containerRef.current!;
listItemElementsRef.current = Array.from(container.querySelectorAll<HTMLDivElement>('.message-list-item'));
const hasLastMessageChanged = (
messageIds && prevMessageIds && messageIds[messageIds.length - 1] !== prevMessageIds[prevMessageIds.length - 1]
);
const hasViewportShifted = (
messageIds?.[0] !== prevMessageIds?.[0] && messageIds?.length === (MESSAGE_LIST_SLICE / 2 + 1)
);
const wasMessageAdded = hasLastMessageChanged && !hasViewportShifted;
const isAlreadyFocusing = messageIds && memoFocusingIdRef.current === messageIds[messageIds.length - 1];
// Add extra height when few messages to allow smooth scroll animation. Uses assumption that `parentElement`
// is a Transition slide and its CSS class can not be reset in a declarative way.
const shouldForceScroll = (
isViewportNewest
&& wasMessageAdded
&& (messageIds && messageIds.length < MESSAGE_LIST_SLICE / 2)
&& !container.parentElement!.classList.contains('force-messages-scroll')
&& (container.firstElementChild as HTMLDivElement)!.clientHeight <= container.offsetHeight * 2
);
if (shouldForceScroll) {
container.parentElement!.classList.add('force-messages-scroll');
setTimeout(() => {
if (container.parentElement) {
container.parentElement.classList.remove('force-messages-scroll');
}
}, MESSAGE_ANIMATION_DURATION);
}
const { scrollTop, scrollHeight, offsetHeight } = container;
const scrollOffset = scrollOffsetRef.current;
const lastItemElement = listItemElementsRef.current[listItemElementsRef.current.length - 1];
let bottomOffset = scrollOffset - (prevContainerHeight || offsetHeight);
if (wasMessageAdded) {
// If two new messages come at once (e.g. when bot responds) then the first message will update `scrollOffset`
// right away (before animation) which creates inconsistency until the animation completes. To work around that,
// we calculate `isAtBottom` with a "buffer" of the latest message height (this is approximate).
const lastItemHeight = lastItemElement ? lastItemElement.offsetHeight : 0;
bottomOffset -= lastItemHeight;
}
const isAtBottom = isViewportNewest && prevIsViewportNewest && bottomOffset <= BOTTOM_THRESHOLD;
let newScrollTop!: number;
if (wasMessageAdded && isAtBottom && !isAlreadyFocusing) {
if (lastItemElement) {
fastRaf(() => {
fastSmoothScroll(
container,
lastItemElement,
'end',
BOTTOM_FOCUS_MARGIN,
);
});
}
newScrollTop = scrollHeight - offsetHeight;
scrollOffsetRef.current = Math.max(Math.ceil(scrollHeight - newScrollTop), offsetHeight);
// Scroll still needs to be restored after container resize
if (!shouldForceScroll) {
return;
}
}
if (process.env.APP_ENV === 'perf') {
// eslint-disable-next-line no-console
console.time('scrollTop');
}
const isResized = prevContainerHeight && prevContainerHeight !== containerHeight;
const anchor = anchorIdRef.current && container.querySelector(`#${anchorIdRef.current}`);
const unreadDivider = (
!anchor
&& memoUnreadDividerBeforeIdRef.current
&& container.querySelector<HTMLDivElement>(`.${UNREAD_DIVIDER_CLASS}`)
);
if (isAtBottom && isResized) {
if (isAnimatingScroll()) {
return;
}
newScrollTop = scrollHeight - offsetHeight;
} else if (anchor) {
const newAnchorTop = anchor.getBoundingClientRect().top;
newScrollTop = scrollTop + (newAnchorTop - (anchorTopRef.current || 0));
} else if (unreadDivider) {
newScrollTop = Math.min(
unreadDivider.offsetTop - (hasTools ? UNREAD_DIVIDER_TOP_WITH_TOOLS : UNREAD_DIVIDER_TOP),
scrollHeight - scrollOffset,
const hasLastMessageChanged = (
messageIds && prevMessageIds && messageIds[messageIds.length - 1] !== prevMessageIds[prevMessageIds.length - 1]
);
} else {
newScrollTop = scrollHeight - scrollOffset;
}
const hasViewportShifted = (
messageIds?.[0] !== prevMessageIds?.[0] && messageIds?.length === (MESSAGE_LIST_SLICE / 2 + 1)
);
const wasMessageAdded = hasLastMessageChanged && !hasViewportShifted;
const isAlreadyFocusing = messageIds && memoFocusingIdRef.current === messageIds[messageIds.length - 1];
resetScroll(container, Math.ceil(newScrollTop));
// Add extra height when few messages to allow smooth scroll animation. Uses assumption that `parentElement`
// is a Transition slide and its CSS class can not be reset in a declarative way.
const shouldForceScroll = (
isViewportNewest
&& wasMessageAdded
&& (messageIds && messageIds.length < MESSAGE_LIST_SLICE / 2)
&& !container.parentElement!.classList.contains('force-messages-scroll')
&& (container.firstElementChild as HTMLDivElement)!.clientHeight <= container.offsetHeight * 2
);
if (!memoFocusingIdRef.current) {
isScrollTopJustUpdatedRef.current = true;
fastRaf(() => {
isScrollTopJustUpdatedRef.current = false;
});
}
const {
scrollTop,
scrollHeight,
offsetHeight,
} = container;
const scrollOffset = scrollOffsetRef.current;
const lastItemElement = listItemElementsRef.current[listItemElementsRef.current.length - 1];
scrollOffsetRef.current = Math.max(Math.ceil(scrollHeight - newScrollTop), offsetHeight);
let bottomOffset = scrollOffset - (prevContainerHeight || offsetHeight);
if (wasMessageAdded) {
// If two new messages come at once (e.g. when bot responds) then the first message will update `scrollOffset`
// right away (before animation) which creates inconsistency until the animation completes. To work around that,
// we calculate `isAtBottom` with a "buffer" of the latest message height (this is approximate).
const lastItemHeight = lastItemElement ? lastItemElement.offsetHeight : 0;
bottomOffset -= lastItemHeight;
}
const isAtBottom = isViewportNewest && prevIsViewportNewest && bottomOffset <= BOTTOM_THRESHOLD;
if (process.env.APP_ENV === 'perf') {
// eslint-disable-next-line no-console
console.timeEnd('scrollTop');
}
let newScrollTop!: number;
if (wasMessageAdded && isAtBottom && !isAlreadyFocusing) {
if (lastItemElement) {
// Break out of `forceLayout`
requestMeasure(() => {
fastSmoothScroll(
container,
lastItemElement,
'end',
BOTTOM_FOCUS_MARGIN,
);
});
}
newScrollTop = scrollHeight - offsetHeight;
scrollOffsetRef.current = Math.max(Math.ceil(scrollHeight - newScrollTop), offsetHeight);
// Scroll still needs to be restored after container resize
if (!shouldForceScroll) {
return undefined;
}
}
if (process.env.APP_ENV === 'perf') {
// eslint-disable-next-line no-console
console.time('scrollTop');
}
const isResized = prevContainerHeight !== undefined && prevContainerHeight !== containerHeight;
if (isResized && isAnimatingScroll()) {
return undefined;
}
const anchor = anchorIdRef.current && container.querySelector(`#${anchorIdRef.current}`);
const unreadDivider = (
!anchor
&& memoUnreadDividerBeforeIdRef.current
&& container.querySelector<HTMLDivElement>(`.${UNREAD_DIVIDER_CLASS}`)
);
if (isAtBottom && isResized) {
newScrollTop = scrollHeight - offsetHeight;
} else if (anchor) {
const newAnchorTop = anchor.getBoundingClientRect().top;
newScrollTop = scrollTop + (newAnchorTop - (anchorTopRef.current || 0));
} else if (unreadDivider) {
newScrollTop = Math.min(
unreadDivider.offsetTop - (hasTools ? UNREAD_DIVIDER_TOP_WITH_TOOLS : UNREAD_DIVIDER_TOP),
scrollHeight - scrollOffset,
);
} else {
newScrollTop = scrollHeight - scrollOffset;
}
return () => {
if (shouldForceScroll) {
container.parentElement!.classList.add('force-messages-scroll');
setTimeout(() => {
if (container.parentElement) {
container.parentElement.classList.remove('force-messages-scroll');
}
}, MESSAGE_ANIMATION_DURATION);
}
resetScroll(container, Math.ceil(newScrollTop));
if (!memoFocusingIdRef.current) {
isScrollTopJustUpdatedRef.current = true;
requestMeasure(() => {
isScrollTopJustUpdatedRef.current = false;
});
}
scrollOffsetRef.current = Math.max(Math.ceil(scrollHeight - newScrollTop), offsetHeight);
if (process.env.APP_ENV === 'perf') {
// eslint-disable-next-line no-console
console.timeEnd('scrollTop');
}
};
});
// This should match deps for `useSyncEffect` above
}, [messageIds, isViewportNewest, getContainerHeight, prevContainerHeightRef, hasTools]);
}, [messageIds, isViewportNewest, hasTools, getContainerHeight, prevContainerHeightRef]);
useEffectWithPrevDeps(([prevIsSelectModeActive]) => {
if (prevIsSelectModeActive !== undefined) {
@ -631,6 +645,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
isChannelChat={isChannelChat}
messageIds={messageIds || [lastMessage!.id]}
messageGroups={messageGroups || groupMessages([lastMessage!])}
getContainerHeight={getContainerHeight}
isViewportNewest={Boolean(isViewportNewest)}
isUnread={Boolean(firstUnreadId)}
withUsers={withUsers}

View File

@ -5,6 +5,7 @@ import { getActions } from '../../global';
import type { MessageListType } from '../../global/types';
import type { PinnedIntersectionChangedCallback } from './hooks/usePinnedMessage';
import type { Signal } from '../../util/signals';
import { SCHEDULED_WHEN_ONLINE } from '../../config';
import { MAIN_THREAD_ID } from '../../api/types';
@ -31,6 +32,7 @@ interface OwnProps {
threadId: number;
messageIds: number[];
messageGroups: MessageDateGroup[];
getContainerHeight: Signal<number | undefined>;
isViewportNewest: boolean;
isUnread: boolean;
withUsers: boolean;
@ -60,6 +62,7 @@ const MessageListContent: FC<OwnProps> = ({
threadId,
messageIds,
messageGroups,
getContainerHeight,
isViewportNewest,
isUnread,
isComments,
@ -96,6 +99,7 @@ const MessageListContent: FC<OwnProps> = ({
type,
containerRef,
messageIds,
getContainerHeight,
isViewportNewest,
isUnread,
onFabToggle,

View File

@ -2,6 +2,7 @@ import type { FC } from '../../lib/teact/teact';
import React, {
useEffect, useState, memo, useMemo, useCallback,
} from '../../lib/teact/teact';
import { requestMutation } from '../../lib/fasterdom/fasterdom';
import { getActions, withGlobal } from '../../global';
import type { ApiChat, ApiChatBannedRights } from '../../api/types';
@ -27,7 +28,7 @@ import {
GENERAL_TOPIC_ID,
TMP_CHAT_ID,
} from '../../config';
import { MASK_IMAGE_DISABLED } from '../../util/windowEnvironment';
import { IS_ANDROID, IS_IOS, MASK_IMAGE_DISABLED } from '../../util/windowEnvironment';
import { DropAreaState } from './composer/DropArea';
import {
selectChat,
@ -277,17 +278,21 @@ const MiddleColumn: FC<OwnProps & StateProps> = ({
// Fix for mobile virtual keyboard
useEffect(() => {
if (!IS_IOS && !IS_ANDROID) {
return undefined;
}
const { visualViewport } = window;
if (!visualViewport) {
return undefined;
}
const handleResize = () => {
if (visualViewport.height !== document.documentElement.clientHeight) {
document.body.classList.add('keyboard-visible');
} else {
document.body.classList.remove('keyboard-visible');
}
const isFixNeeded = visualViewport.height !== document.documentElement.clientHeight;
requestMutation(() => {
document.body.classList.toggle('keyboard-visible', isFixNeeded);
});
};
visualViewport.addEventListener('resize', handleResize);

View File

@ -1,7 +1,8 @@
import type { FC } from '../../lib/teact/teact';
import React, {
memo, useCallback, useEffect, useRef,
memo, useCallback, useEffect, useLayoutEffect, useRef,
} from '../../lib/teact/teact';
import { requestMutation } from '../../lib/fasterdom/fasterdom';
import { getActions, withGlobal } from '../../global';
import type { GlobalState, MessageListType } from '../../global/types';
@ -289,7 +290,7 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
|| (shouldRenderAudioPlayer && renderingAudioMessage);
// Logic for transition to and from custom display of AudioPlayer/PinnedMessage on smaller screens
useEffect(() => {
useLayoutEffect(() => {
const componentEl = componentRef.current;
if (!componentEl) {
return;
@ -309,7 +310,9 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
// Remove animation class to prevent it messing up the show transitions
setTimeout(() => {
componentEl.classList.remove('animated');
requestMutation(() => {
componentEl.classList.remove('animated');
});
}, ANIMATION_DURATION);
} else {
componentEl.classList.remove('tools-stacked');

View File

@ -2,10 +2,12 @@ import type { FC } from '../../lib/teact/teact';
import React, {
memo, useCallback, useEffect, useRef, useState, useLayoutEffect,
} from '../../lib/teact/teact';
import { requestMutation } from '../../lib/fasterdom/fasterdom';
import { getActions, withGlobal } from '../../global';
import type { ApiChat } from '../../api/types';
import { IS_IOS } from '../../util/windowEnvironment';
import { debounce } from '../../util/schedulers';
import {
selectCurrentTextSearch,
@ -69,12 +71,17 @@ const MobileSearchFooter: FC<StateProps> = ({
const { activeElement } = document;
if (activeElement && (activeElement === inputRef.current)) {
const { pageTop, height } = visualViewport;
mainEl.style.transform = `translateY(${pageTop}px)`;
mainEl.style.height = `${height}px`;
document.documentElement.scrollTop = pageTop;
requestMutation(() => {
mainEl.style.transform = `translateY(${pageTop}px)`;
mainEl.style.height = `${height}px`;
document.documentElement.scrollTop = pageTop;
});
} else {
mainEl.style.transform = '';
mainEl.style.height = '';
requestMutation(() => {
mainEl.style.transform = '';
mainEl.style.height = '';
});
}
};
@ -96,14 +103,12 @@ const MobileSearchFooter: FC<StateProps> = ({
}, [chat?.id, focusMessage, foundIds, threadId]);
// Disable native up/down buttons on iOS
useEffect(() => {
useLayoutEffect(() => {
if (!IS_IOS) return;
Array.from(document.querySelectorAll<HTMLInputElement>('input')).forEach((input) => {
input.disabled = Boolean(isActive && input !== inputRef.current);
});
Array.from(document.querySelectorAll<HTMLDivElement>('div[contenteditable]')).forEach((div) => {
div.contentEditable = isActive ? 'false' : 'true';
});
}, [isActive]);
// Blur on exit
@ -113,7 +118,7 @@ const MobileSearchFooter: FC<StateProps> = ({
}
}, [isActive]);
useLayoutEffect(() => {
useEffect(() => {
const searchInput = document.querySelector<HTMLInputElement>('#MobileSearch input')!;
searchInput.blur();
}, [isHistoryCalendarOpen]);

View File

@ -1,7 +1,7 @@
import type { FC } from '../../lib/teact/teact';
import React, {
useRef,
useEffect,
useLayoutEffect,
useMemo,
memo,
} from '../../lib/teact/teact';
@ -27,7 +27,7 @@ const PinnedMessageNavigation: FC<OwnProps> = ({
return calculateMarkup(count, index);
}, [count, index]);
useEffect(() => {
useLayoutEffect(() => {
if (!containerRef.current) {
return;
}

View File

@ -1,6 +1,7 @@
import React, {
memo, useCallback, useEffect, useMemo, useRef, useState,
} from '../../../lib/teact/teact';
import { requestMutation } from '../../../lib/fasterdom/fasterdom';
import { getActions, withGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
@ -360,7 +361,10 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
if (!mainButton || !input) return;
const { width } = mainButton.getBoundingClientRect();
input.style.setProperty('--margin-for-scrollbar', `${width}px`);
requestMutation(() => {
input.style.setProperty('--margin-for-scrollbar', `${width}px`);
});
}, [lang, isOpen]);
const MoreMenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => {

View File

@ -2,6 +2,7 @@ import type { FC } from '../../../lib/teact/teact';
import React, {
memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,
} from '../../../lib/teact/teact';
import { requestMeasure, requestNextMutation } from '../../../lib/fasterdom/fasterdom';
import { getActions, withGlobal } from '../../../global';
import type {
@ -532,7 +533,7 @@ const Composer: FC<OwnProps & StateProps> = ({
setHtml(`${getHtml()}${newHtml}`);
// If selection is outside of input, set cursor at the end of input
requestAnimationFrame(() => {
requestNextMutation(() => {
focusEditableElement(messageInput);
});
}, [isComposerBlocked, getHtml, setHtml]);
@ -755,7 +756,7 @@ const Composer: FC<OwnProps & StateProps> = ({
clearDraft({ chatId, localOnly: true });
// Wait until message animation starts
requestAnimationFrame(() => {
requestMeasure(() => {
resetComposer();
});
}, [
@ -842,7 +843,7 @@ const Composer: FC<OwnProps & StateProps> = ({
}
// Wait until message animation starts
requestAnimationFrame(() => {
requestMeasure(() => {
resetComposer();
});
}, [
@ -906,7 +907,8 @@ const Composer: FC<OwnProps & StateProps> = ({
if (requestedDraftText) {
setHtml(requestedDraftText);
resetOpenChatWithDraft();
requestAnimationFrame(() => {
requestNextMutation(() => {
const messageInput = document.getElementById(EDITABLE_INPUT_ID)!;
focusEditableElement(messageInput, true);
});
@ -939,13 +941,13 @@ const Composer: FC<OwnProps & StateProps> = ({
requestCalendar((scheduledAt) => {
cancelForceShowSymbolMenu();
handleMessageSchedule({ gif, isSilent }, scheduledAt);
requestAnimationFrame(() => {
requestMeasure(() => {
resetComposer(true);
});
});
} else {
sendMessage({ gif, isSilent });
requestAnimationFrame(() => {
requestMeasure(() => {
resetComposer(true);
});
}
@ -971,13 +973,13 @@ const Composer: FC<OwnProps & StateProps> = ({
requestCalendar((scheduledAt) => {
cancelForceShowSymbolMenu();
handleMessageSchedule({ sticker, isSilent }, scheduledAt);
requestAnimationFrame(() => {
requestMeasure(() => {
resetComposer(shouldPreserveInput);
});
});
} else {
sendMessage({ sticker, isSilent, shouldUpdateStickerSetsOrder });
requestAnimationFrame(() => {
requestMeasure(() => {
resetComposer(shouldPreserveInput);
});
}
@ -1015,7 +1017,7 @@ const Composer: FC<OwnProps & StateProps> = ({
}
clearDraft({ chatId, localOnly: true });
requestAnimationFrame(() => {
requestMeasure(() => {
resetComposer();
});
}, [
@ -1025,7 +1027,7 @@ const Composer: FC<OwnProps & StateProps> = ({
const handleBotCommandSelect = useCallback(() => {
clearDraft({ chatId, localOnly: true });
requestAnimationFrame(() => {
requestMeasure(() => {
resetComposer();
});
}, [chatId, clearDraft, resetComposer]);
@ -1519,6 +1521,7 @@ const Composer: FC<OwnProps & StateProps> = ({
className={buildClassName(mainButtonState, !isReady && 'not-ready', activeVoiceRecording && 'recording')}
disabled={areVoiceMessagesNotAllowed}
allowDisabledClick
noFastClick
ariaLabel={lang(sendButtonAriaLabel)}
onClick={mainButtonHandler}
onContextMenu={

View File

@ -53,8 +53,7 @@ const ICONS_BY_CATEGORY: Record<string, string> = {
};
const OPEN_ANIMATION_DELAY = 200;
// Only a few categories are above this height.
const SMOOTH_SCROLL_DISTANCE = 800;
const SMOOTH_SCROLL_DISTANCE = 100;
const FOCUS_MARGIN = 50;
const HEADER_BUTTON_WIDTH = 42; // px. Includes margins
const INTERSECTION_THROTTLE = 200;
@ -94,15 +93,15 @@ const EmojiPicker: FC<OwnProps & StateProps> = ({
categoryIntersections[index] = entry.isIntersecting;
});
const intersectingWithIndexes = categoryIntersections
.map((isIntersecting, index) => ({ index, isIntersecting }))
.filter(({ isIntersecting }) => isIntersecting);
const minIntersectingIndex = categoryIntersections.reduce((lowestIndex, isIntersecting, index) => {
return isIntersecting && index < lowestIndex ? index : lowestIndex;
}, Infinity);
if (!intersectingWithIndexes.length) {
if (minIntersectingIndex === Infinity) {
return;
}
setActiveCategoryIndex(intersectingWithIndexes[Math.floor(intersectingWithIndexes.length / 2)].index);
setActiveCategoryIndex(minIntersectingIndex);
});
const canRenderContents = useAsyncRendering([], MENU_TRANSITION_DURATION);

View File

@ -1,5 +1,5 @@
import React, {
memo, useCallback, useEffect, useRef,
memo, useCallback, useRef,
} from '../../../lib/teact/teact';
import type { ApiSticker } from '../../../api/types';
@ -10,6 +10,7 @@ import findInViewport from '../../../util/findInViewport';
import isFullyVisible from '../../../util/isFullyVisible';
import fastSmoothScrollHorizontal from '../../../util/fastSmoothScrollHorizontal';
import useEffectWithPrevDeps from '../../../hooks/useEffectWithPrevDeps';
import useShowTransition from '../../../hooks/useShowTransition';
import usePrevDuringAnimation from '../../../hooks/usePrevDuringAnimation';
import { useKeyboardNavigation } from './hooks/useKeyboardNavigation';
@ -126,7 +127,11 @@ const EmojiTooltip: FC<OwnProps> = ({
onClose,
});
useEffect(() => {
useEffectWithPrevDeps(([prevSelectedIndex]) => {
if (prevSelectedIndex === undefined || prevSelectedIndex === -1) {
return;
}
setItemVisible(selectedIndex, containerRef);
}, [selectedIndex]);

View File

@ -3,6 +3,7 @@ import type { FC } from '../../../lib/teact/teact';
import React, {
useEffect, useRef, memo, useState, useCallback, useLayoutEffect,
} from '../../../lib/teact/teact';
import { requestMutation, requestForcedReflow } from '../../../lib/fasterdom/fasterdom';
import { getActions, withGlobal } from '../../../global';
import type { IAnchorPosition, ISettings } from '../../../types';
@ -159,36 +160,41 @@ const MessageInput: FC<OwnProps & StateProps> = ({
isActive,
);
const maxInputHeight = isMobile ? 256 : 416;
const maxInputHeight = isAttachmentModalInput ? MAX_ATTACHMENT_MODAL_INPUT_HEIGHT : (isMobile ? 256 : 416);
const updateInputHeight = useCallback((willSend = false) => {
const scroller = inputRef.current!.closest<HTMLDivElement>(`.${SCROLLER_CLASS}`)!;
const clone = scrollerCloneRef.current!;
const currentHeight = Number(scroller.style.height.replace('px', ''));
const maxHeight = isAttachmentModalInput ? MAX_ATTACHMENT_MODAL_INPUT_HEIGHT : maxInputHeight;
const newHeight = Math.min(clone.scrollHeight, maxHeight);
if (newHeight === currentHeight) {
return;
}
requestForcedReflow(() => {
const scroller = inputRef.current!.closest<HTMLDivElement>(`.${SCROLLER_CLASS}`)!;
const currentHeight = Number(scroller.style.height.replace('px', ''));
const clone = scrollerCloneRef.current!;
const { scrollHeight } = clone;
const newHeight = Math.min(scrollHeight, maxInputHeight);
const transitionDuration = Math.round(
TRANSITION_DURATION_FACTOR * Math.log(Math.abs(newHeight - currentHeight)),
);
if (newHeight === currentHeight) {
return undefined;
}
const exec = () => {
scroller.style.height = `${newHeight}px`;
scroller.style.transitionDuration = `${transitionDuration}ms`;
scroller.classList.toggle('overflown', clone.scrollHeight > maxHeight);
};
const isOverflown = scrollHeight > maxInputHeight;
if (willSend) {
// Sync with sending animation
requestAnimationFrame(exec);
} else {
exec();
}
}, [isAttachmentModalInput, maxInputHeight]);
function exec() {
const transitionDuration = Math.round(
TRANSITION_DURATION_FACTOR * Math.log(Math.abs(newHeight - currentHeight)),
);
scroller.style.height = `${newHeight}px`;
scroller.style.transitionDuration = `${transitionDuration}ms`;
scroller.classList.toggle('overflown', isOverflown);
}
useEffect(() => {
if (willSend) {
// Delay to next frame to sync with sending animation
requestMutation(exec);
return undefined;
} else {
return exec;
}
});
}, [maxInputHeight]);
useLayoutEffect(() => {
if (!isAttachmentModalInput) return;
updateInputHeight(false);
}, [isAttachmentModalInput, updateInputHeight]);
@ -481,7 +487,7 @@ const MessageInput: FC<OwnProps & StateProps> = ({
const captureFirstTab = debounce((e: KeyboardEvent) => {
if (e.key === 'Tab' && !getIsDirectTextInputDisabled()) {
e.preventDefault();
requestAnimationFrame(focusInput);
requestMutation(focusInput);
}
}, TAB_INDEX_PRIORITY_TIMEOUT, true, false);

View File

@ -3,6 +3,7 @@ import type { FC } from '../../../lib/teact/teact';
import React, {
memo, useCallback, useEffect, useLayoutEffect, useRef, useState,
} from '../../../lib/teact/teact';
import { requestNextMutation } from '../../../lib/fasterdom/fasterdom';
import type { ApiNewPoll } from '../../../api/types';
@ -85,7 +86,8 @@ const PollModal: FC<OwnProps> = ({
const addNewOption = useCallback((newOptions: string[] = []) => {
setOptions([...newOptions, '']);
requestAnimationFrame(() => {
requestNextMutation(() => {
const list = optionsListRef.current;
if (!list) {
return;
@ -189,7 +191,7 @@ const PollModal: FC<OwnProps> = ({
}
}
requestAnimationFrame(() => {
requestNextMutation(() => {
if (!optionsListRef.current) {
return;
}

View File

@ -24,10 +24,10 @@ import { pickTruthy, uniqueByField } from '../../../util/iteratees';
import { selectChat, selectIsChatWithSelf, selectIsCurrentUserPremium } from '../../../global/selectors';
import useAsyncRendering from '../../right/hooks/useAsyncRendering';
import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver';
import useHorizontalScroll from '../../../hooks/useHorizontalScroll';
import useLang from '../../../hooks/useLang';
import useSendMessageAction from '../../../hooks/useSendMessageAction';
import { useStickerPickerObservers } from '../../common/hooks/useStickerPickerObservers';
import Avatar from '../../common/Avatar';
import Loading from '../../ui/Loading';
@ -43,6 +43,7 @@ type OwnProps = {
chatId: string;
threadId?: number;
className: string;
isHidden?: boolean;
loadAndPlay: boolean;
canSendStickers?: boolean;
onStickerSelect: (
@ -62,16 +63,14 @@ type StateProps = {
isCurrentUserPremium?: boolean;
};
const SMOOTH_SCROLL_DISTANCE = 500;
const SMOOTH_SCROLL_DISTANCE = 100;
const HEADER_BUTTON_WIDTH = 52; // px (including margin)
const STICKER_INTERSECTION_THROTTLE = 200;
const stickerSetIntersections: boolean[] = [];
const StickerPicker: FC<OwnProps & StateProps> = ({
chat,
threadId,
className,
isHidden,
loadAndPlay,
canSendStickers,
recentStickers,
@ -103,31 +102,12 @@ const StickerPicker: FC<OwnProps & StateProps> = ({
const sendMessageAction = useSendMessageAction(chat!.id, threadId);
const { observe: observeIntersection } = useIntersectionObserver({
rootRef: containerRef,
throttleMs: STICKER_INTERSECTION_THROTTLE,
}, (entries) => {
entries.forEach((entry) => {
const { id } = entry.target as HTMLDivElement;
if (!id || !id.startsWith('sticker-set-')) {
return;
}
const index = Number(id.replace('sticker-set-', ''));
stickerSetIntersections[index] = entry.isIntersecting;
});
const intersectingWithIndexes = stickerSetIntersections
.map((isIntersecting, index) => ({ index, isIntersecting }))
.filter(({ isIntersecting }) => isIntersecting);
if (!intersectingWithIndexes.length) {
return;
}
setActiveSetIndex(intersectingWithIndexes[Math.floor(intersectingWithIndexes.length / 2)].index);
});
const { observe: observeIntersectionForCovers } = useIntersectionObserver({ rootRef: headerRef });
const {
observeIntersectionForSet,
observeIntersectionForPlayingItems,
observeIntersectionForShowingItems,
observeIntersectionForCovers,
} = useStickerPickerObservers(containerRef, headerRef, 'sticker-set', setActiveSetIndex, isHidden);
const lang = useLang();
@ -366,8 +346,10 @@ const StickerPicker: FC<OwnProps & StateProps> = ({
stickerSet={stickerSet}
loadAndPlay={Boolean(canAnimate && loadAndPlay)}
index={i}
observeIntersection={observeIntersection}
shouldRender={activeSetIndex >= i - 1 && activeSetIndex <= i + 1}
observeIntersection={observeIntersectionForSet}
observeIntersectionForPlayingItems={observeIntersectionForPlayingItems}
observeIntersectionForShowingItems={observeIntersectionForShowingItems}
isNearActive={activeSetIndex >= i - 1 && activeSetIndex <= i + 1}
favoriteStickers={favoriteStickers}
isSavedMessages={isSavedMessages}
isCurrentUserPremium={isCurrentUserPremium}

View File

@ -24,11 +24,7 @@
padding-right: env(safe-area-inset-right);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
transform: translate3d(
0,
calc(var(--symbol-menu-height) + var(--symbol-menu-footer-height) + env(safe-area-inset-bottom)),
0
);
transform: translate3d(0, calc(var(--symbol-menu-height) + var(--symbol-menu-footer-height) + env(safe-area-inset-bottom)), 0);
&.open:not(.in-attachment-modal) {
transform: translate3d(0, 0, 0);
@ -40,11 +36,7 @@
&.open.in-attachment-modal {
z-index: calc(var(--z-modal) + 1);
transform: translate3d(
0,
calc(var(--symbol-menu-height) + var(--symbol-menu-footer-height)),
0
);
transform: translate3d(0, calc(var(--symbol-menu-height) + var(--symbol-menu-footer-height)), 0);
}
// Target: Old Firefox (Waterfox Classic)
@ -157,6 +149,12 @@
padding: 0;
overflow: hidden;
&:not(.open) {
transform: scale(0.85) !important;
}
transition-duration: 200ms !important;
@supports (overflow: overlay) {
width: var(--symbol-menu-width);
}
@ -244,6 +242,7 @@
&:focus {
background-color: var(--color-interactive-element-hover);
}
@media (hover: hover) {
&:hover {
background-color: var(--color-interactive-element-hover);

View File

@ -1,6 +1,7 @@
import React, {
memo, useCallback, useEffect, useLayoutEffect, useRef, useState,
} from '../../../lib/teact/teact';
import { requestMutation } from '../../../lib/fasterdom/fasterdom';
import { getActions, withGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
@ -8,7 +9,6 @@ import type { ApiSticker, ApiVideo } from '../../../api/types';
import type { GlobalActions } from '../../../global';
import { IS_TOUCH_ENV } from '../../../util/windowEnvironment';
import { fastRaf } from '../../../util/schedulers';
import buildClassName from '../../../util/buildClassName';
import { selectTabState, selectIsCurrentUserPremium } from '../../../global/selectors';
@ -47,7 +47,7 @@ export type OwnProps = {
isSilent?: boolean,
shouldSchedule?: boolean,
shouldPreserveInput?: boolean,
shouldUpdateStickerSetsOrder?: boolean
shouldUpdateStickerSetsOrder?: boolean,
) => void;
onGifSelect?: (gif: ApiVideo, isSilent?: boolean, shouldSchedule?: boolean) => void;
onRemoveSymbol: () => void;
@ -130,24 +130,21 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
}, [isCurrentUserPremium, lastSyncTime, loadPremiumSetStickers]);
useLayoutEffect(() => {
if (!isMobile || isAttachmentModal) {
if (!isMobile || !isOpen || isAttachmentModal) {
return undefined;
}
if (isOpen) {
document.body.classList.add('enable-symbol-menu-transforms');
document.body.classList.add('is-symbol-menu-open');
}
document.body.classList.add('enable-symbol-menu-transforms');
document.body.classList.add('is-symbol-menu-open');
return () => {
if (isOpen) {
fastRaf(() => {
document.body.classList.remove('is-symbol-menu-open');
setTimeout(() => {
document.body.classList.remove('enable-symbol-menu-transforms');
}, ANIMATION_DURATION);
document.body.classList.remove('is-symbol-menu-open');
setTimeout(() => {
requestMutation(() => {
document.body.classList.remove('enable-symbol-menu-transforms');
});
}
}, ANIMATION_DURATION);
};
}, [isAttachmentModal, isMobile, isOpen]);
@ -219,6 +216,7 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
return (
<CustomEmojiPicker
className="picker-tab"
isHidden={!isOpen || !isActive}
loadAndPlay={isOpen && (isActive || isFrom)}
onCustomEmojiSelect={handleCustomEmojiSelect}
chatId={chatId}
@ -228,6 +226,7 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
return (
<StickerPicker
className="picker-tab"
isHidden={!isOpen || !isActive}
loadAndPlay={canSendStickers ? isOpen && (isActive || isFrom) : false}
canSendStickers={canSendStickers}
onStickerSelect={handleStickerSelect}

View File

@ -1,6 +1,7 @@
import type { RefObject } from 'react';
import { useCallback, useEffect } from '../../../../lib/teact/teact';
import twemojiRegex from '../../../../lib/twemojiRegex';
import { requestNextMutation } from '../../../../lib/fasterdom/fasterdom';
import type { ApiSticker } from '../../../../api/types';
import type { Signal } from '../../../../util/signals';
@ -85,7 +86,7 @@ export default function useCustomEmojiTooltip(
setHtml(`${newHtml}${htmlAfterSelection}`);
requestAnimationFrame(() => {
requestNextMutation(() => {
focusEditableElement(inputEl, true, true);
});
}, [getLastEmoji, isEnabled, inputRef, setHtml]);

View File

@ -1,4 +1,5 @@
import { useCallback, useEffect } from '../../../../lib/teact/teact';
import { requestMeasure, requestNextMutation } from '../../../../lib/fasterdom/fasterdom';
import { getActions } from '../../../../global';
import type { ApiDraft } from '../../../../global/types';
@ -21,7 +22,8 @@ let isFrozen = false;
function freeze() {
isFrozen = true;
requestAnimationFrame(() => {
requestMeasure(() => {
isFrozen = false;
});
}
@ -91,7 +93,7 @@ const useDraft = (
if (customEmojiIds.length) loadCustomEmojis({ ids: customEmojiIds });
if (!IS_TOUCH_ENV) {
requestAnimationFrame(() => {
requestNextMutation(() => {
const messageInput = document.querySelector<HTMLDivElement>(EDITABLE_INPUT_CSS_SELECTOR);
if (messageInput) {
focusEditableElement(messageInput, true);

View File

@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from '../../../../lib/teact/teact';
import { getActions } from '../../../../global';
import { requestMeasure, requestNextMutation } from '../../../../lib/fasterdom/fasterdom';
import type { ApiFormattedText, ApiMessage } from '../../../../api/types';
import type { MessageListType } from '../../../../global/types';
@ -12,7 +13,6 @@ import parseMessageInput from '../../../../util/parseMessageInput';
import focusEditableElement from '../../../../util/focusEditableElement';
import { hasMessageMedia } from '../../../../global/helpers';
import { getTextWithEntitiesAsHtml } from '../../../common/helpers/renderTextWithEntities';
import { fastRaf } from '../../../../util/schedulers';
import useBackgroundMode from '../../../../hooks/useBackgroundMode';
import useBeforeUnload from '../../../../hooks/useBeforeUnload';
import { useDebouncedResolver } from '../../../../hooks/useAsyncResolvers';
@ -57,8 +57,8 @@ const useEditing = (
setHtml(html);
setShouldForceShowEditing(true);
// `fastRaf` would execute syncronously in this case
requestAnimationFrame(() => {
requestNextMutation(() => {
const messageInput = document.querySelector<HTMLDivElement>(EDITABLE_INPUT_CSS_SELECTOR);
if (messageInput) {
focusEditableElement(messageInput, true);
@ -72,7 +72,7 @@ const useEditing = (
}
const shouldSetNoWebPage = !('webPage' in editedMessage.content)
&& editedMessage.content.text?.entities?.some((entity) => URL_ENTITIES.has(entity.type));
&& editedMessage.content.text?.entities?.some((entity) => URL_ENTITIES.has(entity.type));
toggleMessageWebPage({
chatId,
@ -120,15 +120,18 @@ const useEditing = (
const restoreNewDraftAfterEditing = useCallback(() => {
if (!draft) return;
// Run 1 frame after editing draft reset
fastRaf(() => {
// Run one frame after editing draft reset
requestMeasure(() => {
setHtml(getTextWithEntitiesAsHtml(draft));
const messageInput = document.querySelector<HTMLDivElement>(EDITABLE_INPUT_CSS_SELECTOR);
if (messageInput) {
requestAnimationFrame(() => {
// Wait one more frame until new HTML is rendered
requestNextMutation(() => {
const messageInput = document.querySelector<HTMLDivElement>(EDITABLE_INPUT_CSS_SELECTOR);
if (messageInput) {
focusEditableElement(messageInput, true);
});
}
}
});
});
}, [draft, setHtml]);

View File

@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from '../../../../lib/teact/teact';
import { requestNextMutation } from '../../../../lib/fasterdom/fasterdom';
import { getGlobal } from '../../../../global';
import type { ApiSticker } from '../../../../api/types';
@ -123,7 +124,7 @@ export default function useEmojiTooltip(
? document.querySelector<HTMLDivElement>(EDITABLE_INPUT_CSS_SELECTOR)!
: document.getElementById(inputId) as HTMLDivElement;
requestAnimationFrame(() => {
requestNextMutation(() => {
focusEditableElement(messageInput, true, true);
});
}

View File

@ -2,6 +2,7 @@ import {
useCallback, useEffect, useRef,
} from '../../../../lib/teact/teact';
import RLottie from '../../../../lib/rlottie/RLottie';
import { requestMeasure } from '../../../../lib/fasterdom/fasterdom';
import type { ApiSticker } from '../../../../api/types';
import type { Signal } from '../../../../util/signals';
@ -14,7 +15,6 @@ import {
removeCustomEmojiInputRenderCallback,
} from '../../../../util/customEmojiManager';
import { round } from '../../../../util/math';
import { fastRaf } from '../../../../util/schedulers';
import AbsoluteVideo from '../../../../util/AbsoluteVideo';
import { REM } from '../../../common/helpers/mediaDimensions';
@ -131,7 +131,7 @@ export default function useInputCustomEmojis(
}
// Wait one frame for DOM to update
fastRaf(() => {
requestMeasure(() => {
synchronizeElements();
});
}, [getHtml, synchronizeElements, inputRef, clearPlayers, sharedCanvasRef, isActive]);
@ -163,7 +163,7 @@ export default function useInputCustomEmojis(
}, []);
const unfreezeAnimationOnRaf = useCallback(() => {
fastRaf(unfreezeAnimation);
requestMeasure(unfreezeAnimation);
}, [unfreezeAnimation]);
// Pausing frame may not happen in background,

View File

@ -3,6 +3,7 @@ import {
useCallback, useEffect, useState,
} from '../../../../lib/teact/teact';
import { getGlobal } from '../../../../global';
import { requestNextMutation } from '../../../../lib/fasterdom/fasterdom';
import type { ApiChatMember, ApiUser } from '../../../../api/types';
import type { Signal } from '../../../../util/signals';
@ -120,7 +121,7 @@ export default function useMentionTooltip(
const caretPosition = getCaretPosition(inputEl);
setHtml(`${newHtml}${htmlAfterSelection}`);
requestAnimationFrame(() => {
requestNextMutation(() => {
const newCaretPosition = caretPosition + shiftCaretPosition + 1;
focusEditableElement(inputEl, forceFocus);
if (newCaretPosition >= 0) {

View File

@ -1,12 +1,15 @@
import {
useCallback, useEffect, useRef, useState,
} from '../../../../lib/teact/teact';
import { requestMutation } from '../../../../lib/fasterdom/fasterdom';
import { IS_SAFARI, IS_VOICE_RECORDING_SUPPORTED } from '../../../../util/windowEnvironment';
import * as voiceRecording from '../../../../util/voiceRecording';
import captureEscKeyListener from '../../../../util/captureEscKeyListener';
type ActiveVoiceRecording = { stop: () => Promise<voiceRecording.Result>; pause: NoneToVoidFunction } | undefined;
type ActiveVoiceRecording =
{ stop: () => Promise<voiceRecording.Result>; pause: NoneToVoidFunction }
| undefined;
const useVoiceRecording = () => {
// eslint-disable-next-line no-null/no-null
@ -27,7 +30,9 @@ const useVoiceRecording = () => {
const { stop, pause } = await voiceRecording.start((tickVolume: number) => {
if (recordButtonRef.current) {
if (startRecordTimeRef.current && Date.now() % 4 === 0) {
recordButtonRef.current.style.boxShadow = `0 0 0 ${(tickVolume || 0) * 50}px rgba(0,0,0,.15)`;
requestMutation(() => {
recordButtonRef.current!.style.boxShadow = `0 0 0 ${(tickVolume || 0) * 50}px rgba(0,0,0,.15)`;
});
}
setCurrentRecordTime(Date.now());
}
@ -47,9 +52,12 @@ const useVoiceRecording = () => {
return undefined;
}
if (recordButtonRef.current) {
recordButtonRef.current.style.boxShadow = 'none';
}
requestMutation(() => {
if (recordButtonRef.current) {
recordButtonRef.current!.style.boxShadow = 'none';
}
});
try {
return activeVoiceRecording!.pause();
} catch (err) {
@ -67,9 +75,13 @@ const useVoiceRecording = () => {
setActiveVoiceRecording(undefined);
startRecordTimeRef.current = undefined;
setCurrentRecordTime(undefined);
if (recordButtonRef.current) {
recordButtonRef.current.style.boxShadow = 'none';
}
requestMutation(() => {
if (recordButtonRef.current) {
recordButtonRef.current!.style.boxShadow = 'none';
}
});
try {
return activeVoiceRecording!.stop();
} catch (err) {

View File

@ -0,0 +1,21 @@
import type { RefObject } from 'react';
import { useLayoutEffect } from '../../../lib/teact/teact';
import { requestForcedReflow } from '../../../lib/fasterdom/fasterdom';
export default function useAuthorWidth(
containerRef: RefObject<HTMLDivElement>,
signature?: string,
) {
useLayoutEffect(() => {
if (!signature) return;
requestForcedReflow(() => {
const width = containerRef.current!.querySelector<HTMLDivElement>('.message-signature')?.offsetWidth;
if (!width) return undefined;
return () => {
containerRef.current!.style.setProperty('--meta-safe-author-width', `${width}px`);
};
});
}, [containerRef, signature]);
}

View File

@ -1,24 +1,33 @@
import type { RefObject } from 'react';
import {
useCallback, useEffect, useMemo, useRef,
} from '../../../lib/teact/teact';
import { requestMeasure } from '../../../lib/fasterdom/fasterdom';
import { getActions } from '../../../global';
import { useMemo, useRef } from '../../../lib/teact/teact';
import { LoadMoreDirection } from '../../../types';
import type { MessageListType } from '../../../global/types';
import type { Signal } from '../../../util/signals';
import { LOCAL_MESSAGE_MIN_ID } from '../../../config';
import { MESSAGE_LIST_SENSITIVE_AREA } from '../../../util/windowEnvironment';
import { debounce } from '../../../util/schedulers';
import { useIntersectionObserver, useOnIntersect } from '../../../hooks/useIntersectionObserver';
import useSyncEffect from '../../../hooks/useSyncEffect';
import { useStateRef } from '../../../hooks/useStateRef';
import { useSignalEffect } from '../../../hooks/useSignalEffect';
import { useDebouncedSignal } from '../../../hooks/useAsyncResolvers';
const FAB_THRESHOLD = 50;
const NOTCH_THRESHOLD = 1; // Notch has zero height so we at least need a 1px margin to intersect
const CONTAINER_HEIGHT_DEBOUNCE = 100;
const TOOLS_FREEZE_TIMEOUT = 250; // Approximate message sending animation duration
export default function useScrollHooks(
type: MessageListType,
containerRef: RefObject<HTMLDivElement>,
messageIds: number[],
getContainerHeight: Signal<number | undefined>,
isViewportNewest: boolean,
isUnread: boolean,
onFabToggle: AnyToVoidFunction,
@ -58,11 +67,12 @@ export default function useScrollHooks(
return;
}
if (!containerRef.current) {
const container = containerRef.current;
if (!container) {
return;
}
const { offsetHeight, scrollHeight, scrollTop } = containerRef.current;
const { offsetHeight, scrollHeight, scrollTop } = container;
const scrollBottom = Math.round(scrollHeight - scrollTop - offsetHeight);
const isNearBottom = scrollBottom <= FAB_THRESHOLD;
const isAtBottom = scrollBottom <= NOTCH_THRESHOLD;
@ -113,6 +123,7 @@ export default function useScrollHooks(
} = useIntersectionObserver({
rootRef: containerRef,
margin: FAB_THRESHOLD * 2,
throttleScheduler: requestMeasure,
}, toggleScrollTools);
useOnIntersect(fabTriggerRef, observeIntersectionForFab);
@ -124,20 +135,19 @@ export default function useScrollHooks(
} = useIntersectionObserver({
rootRef: containerRef,
margin: NOTCH_THRESHOLD,
throttleScheduler: requestMeasure,
}, toggleScrollTools);
useOnIntersect(fabTriggerRef, observeIntersectionForNotch);
const toggleScrollToolsRef = useRef<typeof toggleScrollTools>();
toggleScrollToolsRef.current = toggleScrollTools;
useSyncEffect(() => {
const toggleScrollToolsRef = useStateRef(toggleScrollTools);
useEffect(() => {
if (isReady) {
toggleScrollToolsRef.current!();
}
}, [isReady]);
}, [isReady, toggleScrollToolsRef]);
// Workaround for FAB and notch flickering with tall incoming message
useSyncEffect(() => {
const freezeShortly = useCallback(() => {
freezeForFab();
freezeForNotch();
@ -145,7 +155,14 @@ export default function useScrollHooks(
unfreezeForNotch();
unfreezeForFab();
}, TOOLS_FREEZE_TIMEOUT);
}, [freezeForFab, freezeForNotch, messageIds, unfreezeForFab, unfreezeForNotch]);
}, [freezeForFab, freezeForNotch, unfreezeForFab, unfreezeForNotch]);
// Workaround for FAB and notch flickering with tall incoming message
useSyncEffect(freezeShortly, [freezeShortly, messageIds]);
// Workaround for notch flickering when opening Composer Embedded Message
const getContainerHeightDebounced = useDebouncedSignal(getContainerHeight, CONTAINER_HEIGHT_DEBOUNCE);
useSignalEffect(freezeShortly, [freezeShortly, getContainerHeightDebounced]);
return { backwardsTriggerRef, forwardsTriggerRef, fabTriggerRef };
}

View File

@ -1,6 +1,6 @@
import { useCallback } from '../../../lib/teact/teact';
import { requestMutation } from '../../../lib/fasterdom/fasterdom';
import { fastRaf } from '../../../util/schedulers';
import useRunDebounced from '../../../hooks/useRunDebounced';
import useFlag from '../../../hooks/useFlag';
@ -19,23 +19,25 @@ export default function useStickyDates() {
markIsScrolled();
if (!document.body.classList.contains('is-scrolling-messages')) {
fastRaf(() => {
requestMutation(() => {
document.body.classList.add('is-scrolling-messages');
});
}
runDebounced(() => {
fastRaf(() => {
const stuckDateEl = findStuckDate(container, hasTools);
if (stuckDateEl) {
requestMutation(() => {
stuckDateEl.classList.add('stuck');
});
}
requestMutation(() => {
const currentStuck = document.querySelector('.stuck');
if (currentStuck) {
currentStuck.classList.remove('stuck');
}
const stuckDateEl = findStuckDate(container, hasTools);
if (stuckDateEl) {
stuckDateEl.classList.add('stuck');
}
document.body.classList.remove('is-scrolling-messages');
});
});

View File

@ -1,6 +1,7 @@
import React, {
memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,
} from '../../../lib/teact/teact';
import { requestMutation } from '../../../lib/fasterdom/fasterdom';
import { getActions } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
@ -34,9 +35,10 @@ import useLayoutEffectWithPrevDeps from '../../../hooks/useLayoutEffectWithPrevD
import Avatar from '../../common/Avatar';
import Skeleton from '../../ui/Skeleton';
import mapPin from '../../../assets/map-pin.svg';
import './Location.scss';
import mapPin from '../../../assets/map-pin.svg';
const MOVE_THRESHOLD = 0.0001; // ~11m
const DEFAULT_MAP_CONFIG = {
width: 400,
@ -156,9 +158,11 @@ const Location: FC<OwnProps> = ({
if (mapBlobUrl) {
const contentEl = ref.current!.closest<HTMLDivElement>(MESSAGE_CONTENT_SELECTOR)!;
getCustomAppendixBg(mapBlobUrl, isOwn, isInSelectMode, isSelected, theme).then((appendixBg) => {
contentEl.style.setProperty('--appendix-bg', appendixBg);
contentEl.classList.add('has-appendix-thumb');
contentEl.setAttribute(CUSTOM_APPENDIX_ATTRIBUTE, '');
requestMutation(() => {
contentEl.style.setProperty('--appendix-bg', appendixBg);
contentEl.classList.add('has-appendix-thumb');
contentEl.setAttribute(CUSTOM_APPENDIX_ATTRIBUTE, '');
});
});
}
}, [shouldRenderText, isOwn, isInSelectMode, isSelected, theme, mapBlobUrl]);
@ -182,11 +186,12 @@ const Location: FC<OwnProps> = ({
}, !isExpired ? (secondsBeforeEnd || 0) * 1000 : undefined);
useInterval(() => {
const countdownEl = countdownRef.current;
if (countdownEl) {
updateCountdown(countdownEl);
}
requestMutation(() => {
const countdownEl = countdownRef.current;
if (countdownEl) {
updateCountdown(countdownEl);
}
});
}, secondsBeforeEnd ? 1000 : undefined);
function renderInfo() {

View File

@ -106,7 +106,6 @@ import {
} from './helpers/mediaDimensions';
import { calculateAlbumLayout } from './helpers/calculateAlbumLayout';
import renderText from '../../common/helpers/renderText';
import calculateAuthorWidth from './helpers/calculateAuthorWidth';
import { getServerTime } from '../../../util/serverTime';
import { isElementInViewport } from '../../../util/isElementInViewport';
import { getCustomEmojiSize } from '../composer/helpers/customEmoji';
@ -127,6 +126,7 @@ import useThrottledCallback from '../../../hooks/useThrottledCallback';
import useMessageTranslation from './hooks/useMessageTranslation';
import usePrevious from '../../../hooks/usePrevious';
import useTextLanguage from '../../../hooks/useTextLanguage';
import useAuthorWidth from '../hooks/useAuthorWidth';
import Button from '../../ui/Button';
import Avatar from '../../common/Avatar';
@ -388,9 +388,12 @@ const Message: FC<OwnProps & StateProps> = ({
useOnIntersect(bottomMarkerRef, observeIntersectionForBottom);
const {
isContextMenuOpen, contextMenuPosition,
handleBeforeContextMenu, handleContextMenu: onContextMenu,
handleContextMenuClose, handleContextMenuHide,
isContextMenuOpen,
contextMenuPosition,
handleBeforeContextMenu,
handleContextMenu: onContextMenu,
handleContextMenuClose,
handleContextMenuHide,
} = useContextMenuHandlers(ref, IS_TOUCH_ENV && isInSelectMode, true, IS_ANDROID);
useEffect(() => {
@ -560,7 +563,7 @@ const Message: FC<OwnProps & StateProps> = ({
Boolean(message.views) && 'has-views',
message.isEdited && 'was-edited',
hasReply && 'has-reply',
isContextMenuShown && 'has-menu-open',
isContextMenuOpen && 'has-menu-open',
isFocused && !noFocusHighlight && 'focused',
isForwarding && 'is-forwarding',
message.isDeleting && 'is-deleting',
@ -658,13 +661,19 @@ const Message: FC<OwnProps & StateProps> = ({
ref, messageId, chatId, isFocused, focusDirection, noFocusHighlight, viewportIds, isResizingContainer,
);
const signature = (isChannel && message.postAuthorTitle)
|| (!asForwarded && forwardInfo?.postAuthorTitle)
|| undefined;
useAuthorWidth(ref, signature);
const shouldFocusOnResize = isLastInGroup;
const handleResize = useCallback((entry: ResizeObserverEntry) => {
const lastHeight = messageHeightRef.current;
const newHeight = entry.target.clientHeight;
const newHeight = entry.contentRect.height;
messageHeightRef.current = newHeight;
if (isAnimatingScroll() || !lastHeight || newHeight <= lastHeight) return;
const container = entry.target.closest<HTMLDivElement>('.MessageList');
@ -754,13 +763,6 @@ const Message: FC<OwnProps & StateProps> = ({
reactionsMaxWidth = width + EXTRA_SPACE_FOR_REACTIONS;
}
const signature = (isChannel && message.postAuthorTitle)
|| (!asForwarded && forwardInfo?.postAuthorTitle)
|| undefined;
const metaSafeAuthorWidth = useMemo(() => {
return signature ? calculateAuthorWidth(signature) : undefined;
}, [signature]);
function renderAvatar() {
const isAvatarPeerUser = avatarPeer && isUserId(avatarPeer.id);
const avatarUser = (avatarPeer && isAvatarPeerUser) ? avatarPeer as ApiUser : undefined;
@ -1208,7 +1210,6 @@ const Message: FC<OwnProps & StateProps> = ({
ref={ref}
id={getMessageHtmlId(message.id)}
className={containerClassName}
style={metaSafeAuthorWidth ? `--meta-safe-author-width: ${metaSafeAuthorWidth}px` : undefined}
data-message-id={messageId}
onMouseDown={handleMouseDown}
onClick={handleClick}

View File

@ -14,8 +14,6 @@
}
.bubble {
transition: opacity 0.15s cubic-bezier(0.2, 0, 0.2, 1), transform 0.15s cubic-bezier(0.2, 0, 0.2, 1) !important;
transform: scale(0.7);
overflow: initial;
padding: 0 !important;
}
@ -35,7 +33,6 @@
padding: 0.25rem 0;
}
.backdrop {
touch-action: none;
}

View File

@ -1,6 +1,7 @@
import React, {
useCallback, useRef, useState,
} from '../../../lib/teact/teact';
import { requestMutation } from '../../../lib/fasterdom/fasterdom';
import type { FC } from '../../../lib/teact/teact';
import type { ApiMessage } from '../../../api/types';
@ -159,8 +160,10 @@ const Photo: FC<OwnProps> = ({
const contentEl = ref.current!.closest<HTMLDivElement>(MESSAGE_CONTENT_SELECTOR)!;
if (fullMediaData) {
getCustomAppendixBg(fullMediaData, isOwn, isInSelectMode, isSelected, theme).then((appendixBg) => {
contentEl.style.setProperty('--appendix-bg', appendixBg);
contentEl.setAttribute(CUSTOM_APPENDIX_ATTRIBUTE, '');
requestMutation(() => {
contentEl.style.setProperty('--appendix-bg', appendixBg);
contentEl.setAttribute(CUSTOM_APPENDIX_ATTRIBUTE, '');
});
});
} else {
contentEl.classList.add('has-appendix-thumb');

View File

@ -1,5 +1,7 @@
import type { FC } from '../../../lib/teact/teact';
import React, { useState, useEffect, useRef } from '../../../lib/teact/teact';
import React, {
useState, useEffect, useRef, useLayoutEffect,
} from '../../../lib/teact/teact';
import type { ApiPollAnswer, ApiPollResult } from '../../../api/types';
@ -41,7 +43,7 @@ const PollOption: FC<OwnProps> = ({
}
}, [shouldAnimate, answerPercent]);
useEffect(() => {
useLayoutEffect(() => {
const lineEl = lineRef.current;
if (lineEl && shouldAnimate) {

View File

@ -1,23 +1,23 @@
import type { FC } from '../../../lib/teact/teact';
import React, {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from '../../../lib/teact/teact';
import { requestMutation } from '../../../lib/fasterdom/fasterdom';
import { getActions } from '../../../global';
import type { ApiMessage } from '../../../api/types';
import { ApiMediaFormat } from '../../../api/types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import { ApiMediaFormat } from '../../../api/types';
import { ROUND_VIDEO_DIMENSIONS_PX } from '../../common/helpers/mediaDimensions';
import { getMessageMediaFormat, getMessageMediaHash, getMessageMediaThumbDataUri } from '../../../global/helpers';
import { formatMediaDuration } from '../../../util/dateFormat';
import buildClassName from '../../../util/buildClassName';
import { stopCurrentAudio } from '../../../util/audioPlayer';
import safePlay from '../../../util/safePlay';
import { fastRaf } from '../../../util/schedulers';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import { useIsIntersecting } from '../../../hooks/useIntersectionObserver';
import useMediaWithLoadProgress from '../../../hooks/useMediaWithLoadProgress';
import useShowTransition from '../../../hooks/useShowTransition';
@ -92,7 +92,7 @@ const RoundVideo: FC<OwnProps> = ({
const [isActivated, setIsActivated] = useState<boolean>(false);
const [progress, setProgress] = useState<number>(0);
useEffect(() => {
useLayoutEffect(() => {
if (!isActivated) {
return;
}
@ -133,7 +133,7 @@ const RoundVideo: FC<OwnProps> = ({
setProgress(0);
safePlay(playerRef.current);
fastRaf(() => {
requestMutation(() => {
playingProgressRef.current!.innerHTML = '';
});
}, []);

View File

@ -1,21 +0,0 @@
let element: HTMLSpanElement | undefined;
let fontFamily: string | undefined;
export default function calculateAuthorWidth(text: string) {
if (!fontFamily) {
fontFamily = getComputedStyle(document.documentElement).getPropertyValue('--font-family');
}
if (!element) {
element = document.createElement('span');
element.style.font = `400 12px ${fontFamily}`;
element.style.whiteSpace = 'nowrap';
element.style.position = 'absolute';
element.style.left = '-999px';
element.style.opacity = '.01';
document.body.appendChild(element);
}
element.textContent = text;
return element.offsetWidth;
}

View File

@ -1,6 +1,7 @@
import { useLayoutEffect, useMemo } from '../../../../lib/teact/teact';
import type { FocusDirection } from '../../../../types';
import { useLayoutEffect, useMemo } from '../../../../lib/teact/teact';
import fastSmoothScroll from '../../../../util/fastSmoothScroll';
// This is used when the viewport was replaced.
@ -42,6 +43,8 @@ export default function useFocusMessage(
focusDirection,
undefined,
isResizingContainer,
// We need this to override scroll setting from Message List layout effect
true,
);
}
}, [

View File

@ -1,6 +1,7 @@
import type { RefObject } from 'react';
import type React from '../../../../lib/teact/teact';
import { useEffect, useRef } from '../../../../lib/teact/teact';
import { requestMeasure } from '../../../../lib/fasterdom/fasterdom';
import { getActions } from '../../../../global';
import { IS_ANDROID, IS_TOUCH_ENV } from '../../../../util/windowEnvironment';
@ -10,6 +11,7 @@ import useFlag from '../../../../hooks/useFlag';
import { preventMessageInputBlur } from '../../helpers/preventMessageInputBlur';
import stopEvent from '../../../../util/stopEvent';
import { REM } from '../../../common/helpers/mediaDimensions';
import useThrottledCallback from '../../../../hooks/useThrottledCallback';
const ANDROID_KEYBOARD_HIDE_DELAY_MS = 350;
const SWIPE_ANIMATION_DURATION = 150;
@ -45,7 +47,7 @@ export default function useOuterHandlers(
handleBeforeContextMenu(e);
}
function handleMouseMove(e: React.MouseEvent) {
const handleMouseMove = useThrottledCallback((e: React.MouseEvent) => {
const quickReactionContainer = quickReactionRef.current;
if (!quickReactionContainer) return;
@ -63,7 +65,7 @@ export default function useOuterHandlers(
} else {
unmarkQuickReactionVisible();
}
}
}, [quickReactionRef], requestMeasure);
function handleSendQuickReaction(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
e.stopPropagation();

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef } from '../../../../lib/teact/teact';
import { requestMeasure } from '../../../../lib/fasterdom/fasterdom';
import { fastRaf } from '../../../../util/schedulers';
import useBackgroundMode, { isBackgroundModeActive } from '../../../../hooks/useBackgroundMode';
import useHeavyAnimationCheck, { isHeavyAnimating } from '../../../../hooks/useHeavyAnimationCheck';
import usePriorityPlaybackCheck, { isPriorityPlaybackActive } from '../../../../hooks/usePriorityPlaybackCheck';
@ -18,7 +18,7 @@ export default function useVideoAutoPause(playerRef: { current: HTMLVideoElement
}, [play]);
const unfreezePlayingOnRaf = useCallback(() => {
fastRaf(unfreezePlaying);
requestMeasure(unfreezePlaying);
}, [unfreezePlaying]);
useBackgroundMode(pause, unfreezePlayingOnRaf, !canPlay);

View File

@ -1,4 +1,5 @@
import { useCallback, useEffect } from '../../../lib/teact/teact';
import { requestMutation, requestMeasure } from '../../../lib/fasterdom/fasterdom';
export default function useTransitionFixes(
containerRef: { current: HTMLDivElement | null },
@ -11,7 +12,11 @@ export default function useTransitionFixes(
const transitionEl = container.querySelector<HTMLDivElement>(transitionElSelector);
const tabsEl = container.querySelector<HTMLDivElement>('.TabList');
if (transitionEl && tabsEl) {
transitionEl.style.minHeight = `${container.offsetHeight - tabsEl.offsetHeight}px`;
const newHeight = container.offsetHeight - tabsEl.offsetHeight;
requestMutation(() => {
transitionEl.style.minHeight = `${newHeight}px`;
});
}
}
@ -26,12 +31,18 @@ export default function useTransitionFixes(
// Workaround for scrollable content flickering during animation.
const applyTransitionFix = useCallback(() => {
const container = containerRef.current!;
if (container.style.overflowY !== 'hidden') {
// This callback is called from `Transition.onStart` which is "mutate" phase
requestMeasure(() => {
const container = containerRef.current!;
if (container.style.overflowY === 'hidden') return;
const scrollBarWidth = container.offsetWidth - container.clientWidth;
container.style.overflowY = 'hidden';
container.style.paddingRight = `${scrollBarWidth}px`;
}
requestMutation(() => {
container.style.overflowY = 'hidden';
container.style.paddingRight = `${scrollBarWidth}px`;
});
});
}, [containerRef]);
const releaseTransitionFix = useCallback(() => {

View File

@ -3,6 +3,7 @@ import type { MouseEvent as ReactMouseEvent, RefObject } from 'react';
import type { FC } from '../../lib/teact/teact';
import React, { useRef, useCallback, useState } from '../../lib/teact/teact';
import { IS_TOUCH_ENV, MouseButton } from '../../util/windowEnvironment';
import buildClassName from '../../util/buildClassName';
import buildStyle from '../../util/buildStyle';
@ -34,6 +35,7 @@ export type OwnProps = {
download?: string;
disabled?: boolean;
allowDisabledClick?: boolean;
noFastClick?: boolean;
ripple?: boolean;
faded?: boolean;
tabIndex?: number;
@ -85,6 +87,7 @@ const Button: FC<OwnProps> = ({
download,
disabled,
allowDisabledClick,
noFastClick = color === 'danger',
ripple,
faded,
tabIndex,
@ -140,7 +143,11 @@ const Button: FC<OwnProps> = ({
if ((allowDisabledClick || !disabled) && onMouseDown) {
onMouseDown(e);
}
}, [allowDisabledClick, disabled, noPreventDefault, onMouseDown]);
if (!IS_TOUCH_ENV && e.button === MouseButton.Main && !noFastClick) {
handleClick(e);
}
}, [allowDisabledClick, disabled, handleClick, noFastClick, noPreventDefault, onMouseDown]);
if (href) {
return (
@ -172,7 +179,7 @@ const Button: FC<OwnProps> = ({
id={id}
type={type}
className={fullClassName}
onClick={handleClick}
onClick={IS_TOUCH_ENV || noFastClick ? handleClick : undefined}
onContextMenu={onContextMenu}
onMouseDown={handleMouseDown}
onMouseEnter={onMouseEnter && !disabled ? onMouseEnter : undefined}

View File

@ -85,16 +85,14 @@ const Draggable: FC<OwnProps> = ({
}, [id, onDrag, state.origin.x, state.origin.y]);
const handleMouseUp = useCallback(() => {
requestAnimationFrame(() => {
setState((current) => ({
...current,
isDragging: false,
width: undefined,
height: undefined,
}));
setState((current) => ({
...current,
isDragging: false,
width: undefined,
height: undefined,
}));
onDragEnd();
});
onDragEnd();
}, [onDragEnd]);
useEffect(() => {

View File

@ -1,10 +1,11 @@
import type { RefObject, UIEvent } from 'react';
import { LoadMoreDirection } from '../../types';
import type { FC } from '../../lib/teact/teact';
import React, {
useCallback, useEffect, useLayoutEffect, useMemo, useRef,
} from '../../lib/teact/teact';
import { requestForcedReflow } from '../../lib/fasterdom/fasterdom';
import { LoadMoreDirection } from '../../types';
import type { FC } from '../../lib/teact/teact';
import { debounce } from '../../util/schedulers';
import resetScroll from '../../util/resetScroll';
@ -108,36 +109,41 @@ const InfiniteScroll: FC<OwnProps> = ({
// Restore `scrollTop` after adding items
useLayoutEffect(() => {
const container = containerRef.current!;
const state = stateRef.current;
requestForcedReflow(() => {
const container = containerRef.current!;
const state = stateRef.current;
state.listItemElements = container.querySelectorAll<HTMLDivElement>(itemSelector);
state.listItemElements = container.querySelectorAll<HTMLDivElement>(itemSelector);
let newScrollTop;
let newScrollTop: number;
if (state.currentAnchor && Array.from(state.listItemElements).includes(state.currentAnchor)) {
const { scrollTop } = container;
const newAnchorTop = state.currentAnchor.getBoundingClientRect().top;
newScrollTop = scrollTop + (newAnchorTop - state.currentAnchorTop!);
} else {
const nextAnchor = state.listItemElements[0];
if (nextAnchor) {
state.currentAnchor = nextAnchor;
state.currentAnchorTop = nextAnchor.getBoundingClientRect().top;
if (state.currentAnchor && Array.from(state.listItemElements).includes(state.currentAnchor)) {
const { scrollTop } = container;
const newAnchorTop = state.currentAnchor!.getBoundingClientRect().top;
newScrollTop = scrollTop + (newAnchorTop - state.currentAnchorTop!);
} else {
const nextAnchor = state.listItemElements[0];
if (nextAnchor) {
state.currentAnchor = nextAnchor;
state.currentAnchorTop = nextAnchor.getBoundingClientRect().top;
}
}
}
if (withAbsolutePositioning || noScrollRestore) {
return;
}
if (withAbsolutePositioning || noScrollRestore) {
return undefined;
}
if (noScrollRestoreOnTop && container.scrollTop === 0) {
return;
}
const { scrollTop } = container;
if (noScrollRestoreOnTop && scrollTop === 0) {
return undefined;
}
resetScroll(container, newScrollTop);
return () => {
resetScroll(container, newScrollTop);
state.isScrollTopJustUpdated = true;
state.isScrollTopJustUpdated = true;
};
});
}, [items, itemSelector, noScrollRestore, noScrollRestoreOnTop, cacheBuster, withAbsolutePositioning]);
const handleScroll = useCallback((e: UIEvent<HTMLDivElement>) => {

View File

@ -1,9 +1,9 @@
import type { RefObject } from 'react';
import type { FC, TeactNode } from '../../lib/teact/teact';
import React, { useRef, useCallback } from '../../lib/teact/teact';
import { requestMeasure } from '../../lib/fasterdom/fasterdom';
import { IS_TOUCH_ENV, MouseButton } from '../../util/windowEnvironment';
import { fastRaf } from '../../util/schedulers';
import buildClassName from '../../util/buildClassName';
import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
@ -158,7 +158,7 @@ const ListItem: FC<OwnProps> = ({
if (IS_TOUCH_ENV && !ripple) {
markIsTouched();
fastRaf(unmarkIsTouched);
requestMeasure(unmarkIsTouched);
}
}, [allowDisabledClick, clickArg, disabled, markIsTouched, onClick, ripple, unmarkIsTouched, href]);

View File

@ -27,8 +27,8 @@
z-index: var(--z-menu-bubble);
overscroll-behavior: contain;
transform: scale(0.5);
transition: opacity 0.2s cubic-bezier(0.2, 0, 0.2, 1), transform 0.2s cubic-bezier(0.2, 0, 0.2, 1) !important;
transform: scale(0.85);
transition: opacity 150ms cubic-bezier(0.2, 0, 0.2, 1), transform 150ms cubic-bezier(0.2, 0, 0.2, 1) !important;
&.open {
transform: scale(1);

View File

@ -10,7 +10,7 @@ import buildClassName from '../../util/buildClassName';
import { enableDirectTextInput, disableDirectTextInput } from '../../util/directInputManager';
import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck';
import useShowTransition from '../../hooks/useShowTransition';
import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps';
import useLayoutEffectWithPrevDeps from '../../hooks/useLayoutEffectWithPrevDeps';
import useLang from '../../hooks/useLang';
import useHistoryBack from '../../hooks/useHistoryBack';
@ -88,7 +88,7 @@ const Modal: FC<OwnProps & StateProps> = ({
onBack: onClose,
});
useEffectWithPrevDeps(([prevIsOpen]) => {
useLayoutEffectWithPrevDeps(([prevIsOpen]) => {
document.body.classList.toggle('has-open-dialog', Boolean(isOpen));
if (isOpen || (!isOpen && prevIsOpen !== undefined)) {

View File

@ -1,5 +1,5 @@
import type { FC } from '../../lib/teact/teact';
import React, { useEffect, useRef, memo } from '../../lib/teact/teact';
import React, { useRef, memo, useLayoutEffect } from '../../lib/teact/teact';
import buildClassName from '../../util/buildClassName';
@ -32,18 +32,15 @@ const ProgressSpinner: FC<{
const borderRadius = radius - 1;
const circumference = circleRadius * 2 * Math.PI;
// eslint-disable-next-line no-null/no-null
const container = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!container.current) {
return;
}
const svg = container.current.firstElementChild;
useLayoutEffect(() => {
const container = containerRef.current!;
const svg = container.firstElementChild;
const strokeDashOffset = circumference - Math.min(Math.max(MIN_PROGRESS, progress), MAX_PROGRESS) * circumference;
if (!svg) {
container.current.innerHTML = `<svg
container.innerHTML = `<svg
viewBox="0 0 ${borderRadius * 2} ${borderRadius * 2}"
height="${borderRadius * 2}"
width="${borderRadius * 2}"
@ -63,7 +60,7 @@ const ProgressSpinner: FC<{
} else {
(svg.firstElementChild as SVGElement).setAttribute('stroke-dashoffset', strokeDashOffset.toString());
}
}, [container, circumference, borderRadius, circleRadius, progress]);
}, [containerRef, circumference, borderRadius, circleRadius, progress]);
const className = buildClassName(
`ProgressSpinner size-${size}`,
@ -74,7 +71,7 @@ const ProgressSpinner: FC<{
return (
<div
ref={container}
ref={containerRef}
className={className}
onClick={onClick}
/>

View File

@ -28,9 +28,8 @@ const RippleEffect: FC = () => {
return;
}
const container = e.currentTarget as HTMLDivElement;
const position = container.getBoundingClientRect() as DOMRect;
const container = e.currentTarget;
const position = container.getBoundingClientRect();
const rippleSize = container.offsetWidth / 2;
setRipples([
@ -42,9 +41,7 @@ const RippleEffect: FC = () => {
},
]);
requestAnimationFrame(() => {
cleanUpDebounced();
});
cleanUpDebounced();
}, [ripples, cleanUpDebounced]);
return (

View File

@ -1,8 +1,15 @@
import type { FC } from '../../lib/teact/teact';
import React, { useRef, memo, useEffect } from '../../lib/teact/teact';
import React, {
useRef,
memo,
useEffect,
useLayoutEffect, useCallback,
} from '../../lib/teact/teact';
import { requestForcedReflow, requestMutation } from '../../lib/fasterdom/fasterdom';
import buildClassName from '../../util/buildClassName';
import { IS_TOUCH_ENV, MouseButton } from '../../util/windowEnvironment';
import forceReflow from '../../util/forceReflow';
import buildClassName from '../../util/buildClassName';
import renderText from '../common/helpers/renderText';
import './Tab.scss';
@ -38,12 +45,14 @@ const Tab: FC<OwnProps> = ({
// eslint-disable-next-line no-null/no-null
const tabRef = useRef<HTMLDivElement>(null);
useEffect(() => {
useLayoutEffect(() => {
// Set initial active state
if (isActive && previousActiveTab === undefined && tabRef.current) {
tabRef.current.classList.add(classNames.active);
tabRef.current!.classList.add(classNames.active);
}
}, [isActive, previousActiveTab]);
useEffect(() => {
if (!isActive || previousActiveTab === undefined) {
return;
}
@ -53,7 +62,9 @@ const Tab: FC<OwnProps> = ({
if (!prevTabEl) {
// The number of tabs in the parent component has decreased. It is necessary to add the active tab class name.
if (isActive && !tabEl.classList.contains(classNames.active)) {
tabEl.classList.add(classNames.active);
requestMutation(() => {
tabEl.classList.add(classNames.active);
});
}
return;
}
@ -65,21 +76,38 @@ const Tab: FC<OwnProps> = ({
const shiftLeft = prevPlatformEl.parentElement!.offsetLeft - platformEl.parentElement!.offsetLeft;
const scaleFactor = prevPlatformEl.clientWidth / platformEl.clientWidth;
prevPlatformEl.classList.remove('animate');
platformEl.classList.remove('animate');
platformEl.style.transform = `translate3d(${shiftLeft}px, 0, 0) scale3d(${scaleFactor}, 1, 1)`;
forceReflow(platformEl);
platformEl.classList.add('animate');
platformEl.style.transform = 'none';
requestMutation(() => {
prevPlatformEl.classList.remove('animate');
platformEl.classList.remove('animate');
platformEl.style.transform = `translate3d(${shiftLeft}px, 0, 0) scale3d(${scaleFactor}, 1, 1)`;
prevTabEl.classList.remove(classNames.active);
tabEl.classList.add(classNames.active);
requestForcedReflow(() => {
forceReflow(platformEl);
return () => {
platformEl.classList.add('animate');
platformEl.style.transform = 'none';
prevTabEl.classList.remove(classNames.active);
tabEl.classList.add(classNames.active);
};
});
});
}, [isActive, previousActiveTab]);
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (e.type === 'mousedown' && e.button !== MouseButton.Main) {
return;
}
onClick(clickArg);
}, [clickArg, onClick]);
return (
<div
className={buildClassName('Tab', className)}
onClick={() => onClick(clickArg)}
onClick={IS_TOUCH_ENV ? handleClick : undefined}
onMouseDown={!IS_TOUCH_ENV ? handleClick : undefined}
ref={tabRef}
>
<span>

View File

@ -1,7 +1,10 @@
import type { RefObject } from 'react';
import React, { useLayoutEffect, useRef } from '../../lib/teact/teact';
import React, { useEffect, useLayoutEffect, useRef } from '../../lib/teact/teact';
import { addExtraClass, removeExtraClass, toggleExtraClass } from '../../lib/teact/teact-dom';
import { requestMutation, requestForcedReflow } from '../../lib/fasterdom/fasterdom';
import { getGlobal } from '../../global';
import type { GlobalState } from '../../global/types';
import { ANIMATION_LEVEL_MIN } from '../../config';
@ -169,9 +172,6 @@ function Transition({
return;
}
removeExtraClass(container, 'animating');
toggleExtraClass(container, 'backwards', isBackwards);
if (name === 'none' || animationLevel === ANIMATION_LEVEL_MIN) {
childNodes.forEach((node, i) => {
if (node instanceof HTMLElement) {
@ -199,57 +199,57 @@ function Transition({
});
const dispatchHeavyAnimationStop = dispatchHeavyAnimationEvent();
onStart?.();
requestAnimationFrame(() => {
addExtraClass(container, 'animating');
addExtraClass(container, 'animating');
toggleExtraClass(container, 'backwards', isBackwards);
onStart?.();
function onAnimationEnd() {
const activeElement = container.querySelector<HTMLDivElement>(`.${CLASSES.active}`);
const { clientHeight } = activeElement || {};
function onAnimationEnd() {
requestAnimationFrame(() => {
if (activeKey !== currentKeyRef.current) {
return;
requestMutation(() => {
if (activeKey !== currentKeyRef.current) {
return;
}
removeExtraClass(container, 'animating');
removeExtraClass(container, 'backwards');
childNodes.forEach((node, i) => {
if (node instanceof HTMLElement) {
removeExtraClass(node, 'from');
removeExtraClass(node, 'through');
removeExtraClass(node, 'to');
toggleExtraClass(node, CLASSES.active, i === activeIndex);
}
removeExtraClass(container, 'animating');
removeExtraClass(container, 'backwards');
childNodes.forEach((node, i) => {
if (node instanceof HTMLElement) {
removeExtraClass(node, 'from');
removeExtraClass(node, 'through');
removeExtraClass(node, 'to');
toggleExtraClass(node, CLASSES.active, i === activeIndex);
}
});
if (shouldRestoreHeight) {
const activeElement = container.querySelector<HTMLDivElement>(`.${CLASSES.active}`);
if (activeElement) {
activeElement.style.height = 'auto';
container.style.height = `${activeElement.clientHeight}px`;
}
}
onStop?.();
dispatchHeavyAnimationStop();
cleanup();
});
}
const watchedNode = name === 'mv-slide'
? childNodes[activeIndex]?.firstChild
: name === 'reveal' && isBackwards
? childNodes[prevActiveIndex]
: childNodes[activeIndex];
if (shouldRestoreHeight) {
if (activeElement) {
activeElement.style.height = 'auto';
container.style.height = `${clientHeight}px`;
}
}
if (watchedNode) {
waitForAnimationEnd(watchedNode, onAnimationEnd, undefined, FALLBACK_ANIMATION_END);
} else {
onAnimationEnd();
}
});
onStop?.();
dispatchHeavyAnimationStop();
cleanup();
});
}
const watchedNode = name === 'mv-slide'
? childNodes[activeIndex]?.firstChild
: name === 'reveal' && isBackwards
? childNodes[prevActiveIndex]
: childNodes[activeIndex];
if (watchedNode) {
waitForAnimationEnd(watchedNode, onAnimationEnd, undefined, FALLBACK_ANIMATION_END);
} else {
onAnimationEnd();
}
}, [
activeKey,
nextKey,
@ -268,20 +268,28 @@ function Transition({
forceUpdate,
]);
useLayoutEffect(() => {
if (shouldRestoreHeight) {
const container = containerRef.current!;
const activeElement = container.querySelector<HTMLDivElement>(`.${CLASSES.active}`)
|| container.querySelector<HTMLDivElement>('.from');
const clientHeight = activeElement?.clientHeight;
if (!clientHeight) {
return;
}
useEffect(() => {
if (!shouldRestoreHeight) {
return;
}
const container = containerRef.current!;
const activeElement = container.querySelector<HTMLDivElement>(`.${CLASSES.active}`)
|| container.querySelector<HTMLDivElement>('.from');
if (!activeElement) {
return;
}
const { clientHeight } = activeElement || {};
if (!clientHeight) {
return;
}
requestMutation(() => {
activeElement.style.height = 'auto';
container.style.height = `${clientHeight}px`;
container.style.flexBasis = `${clientHeight}px`;
}
});
}, [shouldRestoreHeight, children]);
const asFastList = !renderCount;
@ -360,27 +368,33 @@ function performSlideOptimized(
const dispatchHeavyAnimationStop = dispatchHeavyAnimationEvent();
requestAnimationFrame(() => {
onStart?.();
onStart?.();
fromSlide.style.transition = 'none';
fromSlide.style.transform = 'translate3d(0, 0, 0)';
fromSlide.style.transition = 'none';
fromSlide.style.transform = 'translate3d(0, 0, 0)';
toSlide.style.transition = 'none';
toSlide.style.transform = `translate3d(${isBackwards ? '-' : ''}100%, 0, 0)`;
toSlide.style.transition = 'none';
toSlide.style.transform = `translate3d(${isBackwards ? '-' : ''}100%, 0, 0)`;
requestForcedReflow(() => {
forceReflow(toSlide);
fromSlide.style.transition = '';
fromSlide.style.transform = `translate3d(${isBackwards ? '' : '-'}100%, 0, 0)`;
return () => {
fromSlide.style.transition = '';
fromSlide.style.transform = `translate3d(${isBackwards ? '' : '-'}100%, 0, 0)`;
toSlide.style.transition = '';
toSlide.style.transform = 'translate3d(0, 0, 0)';
toSlide.style.transition = '';
toSlide.style.transform = 'translate3d(0, 0, 0)';
removeExtraClass(fromSlide, CLASSES.active);
addExtraClass(toSlide, CLASSES.active);
removeExtraClass(fromSlide, CLASSES.active);
addExtraClass(toSlide, CLASSES.active);
};
});
waitForTransitionEnd(fromSlide, () => {
waitForTransitionEnd(fromSlide, () => {
const { clientHeight } = toSlide;
requestMutation(() => {
if (activeKey !== currentKeyRef.current) {
return;
}
@ -390,7 +404,7 @@ function performSlideOptimized(
if (shouldRestoreHeight) {
toSlide.style.height = 'auto';
container.style.height = `${toSlide.clientHeight}px`;
container.style.height = `${clientHeight}px`;
}
onStop?.();

View File

@ -8,6 +8,7 @@ export const PRODUCTION_HOSTNAME = 'web.telegram.org';
export const DEBUG = process.env.APP_ENV !== 'production';
export const DEBUG_MORE = false;
export const STRICTERDOM_ENABLED = DEBUG;
export const IS_MOCKED_CLIENT = process.env.APP_MOCKED_CLIENT === '1';
export const IS_TEST = process.env.APP_ENV === 'test';
@ -143,10 +144,10 @@ export const TMP_CHAT_ID = '0';
export const ANIMATION_END_DELAY = 100;
export const FAST_SMOOTH_MAX_DISTANCE = 1500;
export const FAST_SMOOTH_MIN_DURATION = 250;
export const FAST_SMOOTH_MAX_DURATION = 600;
export const FAST_SMOOTH_SHORT_TRANSITION_MAX_DISTANCE = 750; // px
export const FAST_SMOOTH_MAX_DISTANCE = 750;
export const FAST_SMOOTH_SHORT_TRANSITION_MAX_DISTANCE = 300; // px
// Average duration of message sending animation
export const API_UPDATE_THROTTLE = Math.round((FAST_SMOOTH_MIN_DURATION + FAST_SMOOTH_MAX_DURATION) / 2);

View File

@ -1,4 +1,5 @@
import type { RequiredGlobalActions } from '../../index';
import { requestNextMutation } from '../../../lib/fasterdom/fasterdom';
import {
addActionHandler, getGlobal,
setGlobal,
@ -52,7 +53,7 @@ export function initializeSoundsForSafari() {
sound.currentTime = 0;
sound.muted = false;
requestAnimationFrame(() => {
requestNextMutation(() => {
sound.src = prevSrc;
});
});

View File

@ -1,5 +1,7 @@
import { addActionHandler, getGlobal, setGlobal } from '../../index';
import { addCallback } from '../../../lib/teact/teactn';
import { requestMutation } from '../../../lib/fasterdom/fasterdom';
import { addActionHandler, getGlobal, setGlobal } from '../../index';
import { ANIMATION_LEVEL_MAX } from '../../../config';
import {
IS_ANDROID, IS_IOS, IS_MAC_OS, IS_SAFARI, IS_TOUCH_ENV,
@ -16,7 +18,6 @@ import { callApi } from '../../../api/gramjs';
import type { ActionReturnType, GlobalState } from '../../types';
import { updateTabState } from '../../reducers/tabs';
import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { addCallback } from '../../../lib/teact/teactn';
const HISTORY_ANIMATION_DURATION = 450;
@ -103,31 +104,33 @@ addCallback((global: GlobalState) => {
void setLanguage(language, undefined, true);
document.documentElement.style.setProperty(
'--composer-text-size', `${Math.max(messageTextSize, IS_IOS ? 16 : 15)}px`,
);
document.documentElement.style.setProperty('--message-meta-height', `${Math.floor(messageTextSize * 1.3125)}px`);
document.documentElement.style.setProperty('--message-text-size', `${messageTextSize}px`);
document.documentElement.setAttribute('data-message-text-size', messageTextSize.toString());
document.body.classList.add('initial');
document.body.classList.add(`animation-level-${animationLevel}`);
document.body.classList.add(IS_TOUCH_ENV ? 'is-touch-env' : 'is-pointer-env');
requestMutation(() => {
document.documentElement.style.setProperty(
'--composer-text-size', `${Math.max(messageTextSize, IS_IOS ? 16 : 15)}px`,
);
document.documentElement.style.setProperty('--message-meta-height', `${Math.floor(messageTextSize * 1.3125)}px`);
document.documentElement.style.setProperty('--message-text-size', `${messageTextSize}px`);
document.documentElement.setAttribute('data-message-text-size', messageTextSize.toString());
document.body.classList.add('initial');
document.body.classList.add(`animation-level-${animationLevel}`);
document.body.classList.add(IS_TOUCH_ENV ? 'is-touch-env' : 'is-pointer-env');
if (IS_IOS) {
document.body.classList.add('is-ios');
} else if (IS_ANDROID) {
document.body.classList.add('is-android');
} else if (IS_MAC_OS) {
document.body.classList.add('is-macos');
}
if (IS_SAFARI) {
document.body.classList.add('is-safari');
}
});
switchTheme(theme, animationLevel === ANIMATION_LEVEL_MAX);
startWebsync();
if (IS_IOS) {
document.body.classList.add('is-ios');
} else if (IS_ANDROID) {
document.body.classList.add('is-android');
} else if (IS_MAC_OS) {
document.body.classList.add('is-macos');
}
if (IS_SAFARI) {
document.body.classList.add('is-safari');
}
isUpdated = true;
if (isUpdated) setGlobal(global);
@ -144,7 +147,9 @@ addActionHandler('setIsUiReady', (global, actions, payload): ActionReturnType =>
const { uiReadyState, tabId = getCurrentTabId() } = payload!;
if (uiReadyState === 2) {
document.body.classList.remove('initial');
requestMutation(() => {
document.body.classList.remove('initial');
});
}
return updateTabState(global, {
@ -184,7 +189,10 @@ addActionHandler('disableHistoryAnimations', (global, actions, payload): ActionR
shouldSkipHistoryAnimations: false,
}, tabId);
setGlobal(global);
document.body.classList.remove('no-animate');
requestMutation(() => {
document.body.classList.remove('no-animate');
});
}, HISTORY_ANIMATION_DURATION);
global = updateTabState(global, {

View File

@ -1,5 +1,8 @@
import type { Signal } from '../util/signals';
import useThrottledCallback from './useThrottledCallback';
import useDebouncedCallback from './useDebouncedCallback';
import useDerivedSignal from './useDerivedSignal';
export function useThrottledResolver<T>(resolver: () => T, deps: any[], ms: number, noFirst = false) {
return useThrottledCallback((setValue: (newValue: T) => void) => {
@ -14,3 +17,16 @@ export function useDebouncedResolver<T>(resolver: () => T, deps: any[], ms: numb
// eslint-disable-next-line react-hooks-static-deps/exhaustive-deps
}, deps, ms, noFirst, noLast);
}
export function useDebouncedSignal<T extends any>(
getValue: Signal<T>,
ms: number,
noFirst = false,
noLast = false,
): Signal<T> {
const debouncedResolver = useDebouncedResolver(() => getValue(), [getValue], ms, noFirst, noLast);
return useDerivedSignal(
debouncedResolver, [debouncedResolver, getValue], true,
);
}

View File

@ -1,6 +1,7 @@
import {
useCallback, useLayoutEffect, useMemo, useState,
useCallback, useEffect, useMemo, useState,
} from '../lib/teact/teact';
import { requestMeasure } from '../lib/fasterdom/fasterdom';
import { round } from '../util/math';
@ -27,7 +28,8 @@ export default function useBoundsInSharedCanvas(
// Wait until elements are properly mounted
if (!container.offsetParent || !canvas.offsetParent) {
setTimeout(recalculate, ANIMATION_END_TIMEOUT);
// `requestMeasure` is useful as timeouts are run in parallel with image loadings and thus causing reflow
setTimeout(() => requestMeasure(recalculate), ANIMATION_END_TIMEOUT);
return;
}
@ -38,15 +40,14 @@ export default function useBoundsInSharedCanvas(
const canvasBounds = canvas.getBoundingClientRect();
// Factor coords are used to support rendering while being rescaled (e.g. message appearance animation)
setX(round((targetBounds.left - canvasBounds.left) / canvasBounds.width, 4));
setY(round((targetBounds.top - canvasBounds.top) / canvasBounds.height, 4));
setX(round((targetBounds.left - canvasBounds.left) / canvasBounds.width, 4) || 0);
setY(round((targetBounds.top - canvasBounds.top) / canvasBounds.height, 4) || 0);
setSize(Math.round(targetBounds.width));
}, [containerRef, sharedCanvasRef]);
const throttledRecalculate = useThrottledCallback(recalculate, [recalculate], THROTTLE_MS, false);
useLayoutEffect(recalculate, [recalculate]);
useEffect(recalculate, [recalculate]);
const throttledRecalculate = useThrottledCallback(recalculate, [recalculate], THROTTLE_MS);
useResizeObserver(sharedCanvasRef, throttledRecalculate);
const coords = useMemo(() => (x !== undefined && y !== undefined ? { x, y } : undefined), [x, y]);

View File

@ -1,4 +1,5 @@
import { useEffect, useRef } from '../lib/teact/teact';
import { requestMeasure, requestMutation } from '../lib/fasterdom/fasterdom';
import { IS_CANVAS_FILTER_SUPPORTED } from '../util/windowEnvironment';
import fastBlur from '../lib/fastBlur';
@ -37,25 +38,29 @@ export default function useCanvasBlur(
const img = new Image();
const processBlur = () => {
canvas.width = preferredWidth || img.width;
canvas.height = preferredHeight || img.height;
const width = preferredWidth || img.width;
const height = preferredHeight || img.height;
const ctx = canvas.getContext('2d', { alpha: false })!;
if (IS_CANVAS_FILTER_SUPPORTED) {
ctx.filter = `blur(${radius}px)`;
}
requestMutation(() => {
canvas.width = width;
canvas.height = height;
ctx.drawImage(img, -radius * 2, -radius * 2, canvas.width + radius * 4, canvas.height + radius * 4);
if (IS_CANVAS_FILTER_SUPPORTED) {
ctx.filter = `blur(${radius}px)`;
}
if (!IS_CANVAS_FILTER_SUPPORTED) {
fastBlur(ctx, 0, 0, canvas.width, canvas.height, radius, ITERATIONS);
}
ctx.drawImage(img, -radius * 2, -radius * 2, width + radius * 4, height + radius * 4);
if (!IS_CANVAS_FILTER_SUPPORTED) {
fastBlur(ctx, 0, 0, width, height, radius, ITERATIONS);
}
});
};
img.onload = () => {
if (withRaf) {
requestAnimationFrame(processBlur);
requestMeasure(processBlur);
} else {
processBlur();
}

View File

@ -1,5 +1,7 @@
import type { RefObject } from 'react';
import { useState, useEffect, useCallback } from '../lib/teact/teact';
import { addExtraClass, removeExtraClass } from '../lib/teact/teact-dom';
import { requestMutation } from '../lib/fasterdom/fasterdom';
import type { IAnchorPosition } from '../types';
import {
@ -26,12 +28,16 @@ const useContextMenuHandlers = (
const handleBeforeContextMenu = useCallback((e: React.MouseEvent) => {
if (!isMenuDisabled && e.button === 2) {
(e.target as HTMLElement).classList.add('no-selection');
requestMutation(() => {
addExtraClass(e.target as HTMLElement, 'no-selection');
});
}
}, [isMenuDisabled]);
const handleContextMenu = useCallback((e: React.MouseEvent) => {
(e.target as HTMLElement).classList.remove('no-selection');
requestMutation(() => {
removeExtraClass(e.target as HTMLElement, 'no-selection');
});
if (isMenuDisabled || (shouldDisableOnLink && (e.target as HTMLElement).matches('a[href]'))) {
return;

View File

@ -1,5 +1,5 @@
import {
useCallback, useEffect, useRef, useState,
useCallback, useEffect, useLayoutEffect, useRef, useState,
} from '../lib/teact/teact';
import { hexToRgb } from '../util/switchTheme';
import { getPropertyHexColor } from '../util/themeStyle';
@ -41,8 +41,22 @@ export default function useDynamicColorListener(ref?: React.RefObject<HTMLElemen
rgbColorRef.current = [r, g, b];
}, [hexColor]);
useLayoutEffect(() => {
const el = ref?.current;
if (!el || isDisabled) {
return undefined;
}
el.style.setProperty('transition', TRANSITION_STYLE, 'important');
return () => {
el.style.removeProperty('transition');
};
}, [isDisabled, ref]);
useEffect(() => {
if (!ref?.current) {
const el = ref?.current;
if (!el) {
return undefined;
}
@ -57,12 +71,10 @@ export default function useDynamicColorListener(ref?: React.RefObject<HTMLElemen
updateColor();
}
const el = ref.current;
el.addEventListener('transitionend', handleTransitionEnd);
el.style.setProperty('transition', TRANSITION_STYLE, 'important');
return () => {
el.removeEventListener('transitionend', handleTransitionEnd);
el.style.removeProperty('transition');
};
}, [isDisabled, ref, updateColor]);

View File

@ -1,7 +1,7 @@
import type { RefObject } from 'react';
import { requestMutation } from '../lib/fasterdom/fasterdom';
import { IS_TOUCH_ENV } from '../util/windowEnvironment';
import { fastRaf } from '../util/schedulers';
import { useEffect } from '../lib/teact/teact';
const DEFAULT_DURATION = 400;
@ -15,10 +15,8 @@ export default function useFocusAfterAnimation(
}
setTimeout(() => {
fastRaf(() => {
if (ref.current) {
ref.current.focus();
}
requestMutation(() => {
ref.current?.focus();
});
}, animationDuration);
}, [ref, animationDuration]);

View File

@ -1,8 +1,8 @@
import { useCallback, useRef } from '../lib/teact/teact';
import { getActions } from '../lib/teact/teactn';
import { requestMeasure } from '../lib/fasterdom/fasterdom';
import { IS_TEST } from '../config';
import { fastRaf } from '../util/schedulers';
import { IS_IOS } from '../util/windowEnvironment';
import useSyncEffect from './useSyncEffect';
@ -109,7 +109,10 @@ function processStateOperations(stateOperations: HistoryOperationState[]) {
}
function deferHistoryOperation(historyOperation: HistoryOperation) {
if (!deferredHistoryOperations.length) fastRaf(applyDeferredHistoryOperations);
if (!deferredHistoryOperations.length) {
requestMeasure(applyDeferredHistoryOperations);
}
deferredHistoryOperations.push(historyOperation);
}

View File

@ -1,5 +1,7 @@
import type { RefObject } from 'react';
import { useEffect } from '../lib/teact/teact';
import { requestMutation } from '../lib/fasterdom/fasterdom';
import useAppLayout from './useAppLayout';
// Focus slows down animation, also it breaks transition layout in Chrome
@ -17,7 +19,7 @@ export default function useInputFocusOnOpen(
if (isOpen) {
if (!isMobile) {
setTimeout(() => {
requestAnimationFrame(() => {
requestMutation(() => {
if (inputRef.current?.isConnected) {
inputRef.current.focus();
}

View File

@ -3,7 +3,11 @@ import {
useEffect, useRef, useCallback, useState,
} from '../lib/teact/teact';
import { throttle, debounce } from '../util/schedulers';
import type { Scheduler } from '../util/schedulers';
import {
throttle, debounce, throttleWith,
} from '../util/schedulers';
import useEffectOnce from './useEffectOnce';
import useHeavyAnimationCheck from './useHeavyAnimationCheck';
@ -26,6 +30,7 @@ interface Response {
export function useIntersectionObserver({
rootRef,
throttleMs,
throttleScheduler,
debounceMs,
shouldSkipFirst,
margin,
@ -34,6 +39,7 @@ export function useIntersectionObserver({
}: {
rootRef: RefObject<HTMLDivElement>;
throttleMs?: number;
throttleScheduler?: Scheduler;
debounceMs?: number;
shouldSkipFirst?: boolean;
margin?: number;
@ -99,10 +105,18 @@ export function useIntersectionObserver({
entriesAccumulator.clear();
};
const scheduler = throttleMs ? throttle : debounceMs ? debounce : undefined;
const observerCallback = scheduler
? scheduler(observerCallbackSync, (throttleMs || debounceMs)!, !shouldSkipFirst)
: observerCallbackSync;
let observerCallback: typeof observerCallbackSync;
if (typeof throttleScheduler === 'function') {
observerCallback = throttleWith(throttleScheduler, observerCallbackSync);
} else if (throttleMs) {
observerCallback = throttle(observerCallbackSync, throttleMs, !shouldSkipFirst);
} else if (debounceMs) {
observerCallback = debounce(observerCallbackSync, debounceMs, !shouldSkipFirst);
} else {
observerCallback = observerCallbackSync;
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {

View File

@ -16,8 +16,8 @@ export default function useResizeObserver(
}
const el = ref.current;
const callback: ResizeObserverCallback = ([entry]) => {
// During animation
if (!(entry.target as HTMLElement).offsetParent) {
// Ignore updates when element is not properly mounted (`display: none`)
if (entry.contentRect.width === 0 && entry.contentRect.height === 0) {
return;
}

View File

@ -1,22 +1,22 @@
import { useCallback, useMemo } from '../lib/teact/teact';
import type { fastRaf } from '../util/schedulers';
import { throttle, throttleWithRaf } from '../util/schedulers';
import type { Scheduler } from '../util/schedulers';
import { throttle, throttleWith } from '../util/schedulers';
export default function useThrottledCallback<T extends AnyToVoidFunction>(
fn: T,
deps: any[],
msOrRaf: number | typeof fastRaf,
msOrSchedulerFn: number | Scheduler,
noFirst = false,
) {
// eslint-disable-next-line react-hooks-static-deps/exhaustive-deps
const fnMemo = useCallback(fn, deps);
return useMemo(() => {
if (typeof msOrRaf === 'number') {
return throttle(fnMemo, msOrRaf, !noFirst);
if (typeof msOrSchedulerFn === 'number') {
return throttle(fnMemo, msOrSchedulerFn, !noFirst);
} else {
return throttleWithRaf(fnMemo);
return throttleWith(msOrSchedulerFn, fnMemo);
}
}, [fnMemo, msOrRaf, noFirst]);
}, [fnMemo, msOrSchedulerFn, noFirst]);
}

View File

@ -1,6 +1,6 @@
import type { RefObject } from 'react';
import { useEffect } from '../lib/teact/teact';
import { fastRaf } from '../util/schedulers';
import { requestNextMutation } from '../lib/fasterdom/fasterdom';
// Fix for memory leak when unmounting video element
export default function useVideoCleanup(videoRef: RefObject<HTMLVideoElement>, dependencies: any[]) {
@ -9,7 +9,8 @@ export default function useVideoCleanup(videoRef: RefObject<HTMLVideoElement>, d
return () => {
if (videoEl) {
fastRaf(() => {
// It may be slow (specifically on iOS), so we postpone it after unmounting
requestNextMutation(() => {
videoEl.pause();
videoEl.src = '';
videoEl.load();

View File

@ -3,6 +3,7 @@ import './util/setupServiceWorker';
import React from './lib/teact/teact';
import TeactDOM from './lib/teact/teact-dom';
import { enableStrict, requestMutation } from './lib/fasterdom/fasterdom';
import {
getActions, getGlobal,
@ -11,7 +12,9 @@ import updateWebmanifest from './util/updateWebmanifest';
import { IS_MULTITAB_SUPPORTED } from './util/windowEnvironment';
import './global/init';
import { APP_VERSION, DEBUG, MULTITAB_LOCALSTORAGE_KEY } from './config';
import {
APP_VERSION, DEBUG, MULTITAB_LOCALSTORAGE_KEY, STRICTERDOM_ENABLED,
} from './config';
import { establishMultitabRole, subscribeToMasterChange } from './util/establishMultitabRole';
import { requestGlobal, subscribeToMultitabBroadcastChannel } from './util/multitab';
import { onBeforeUnload } from './util/schedulers';
@ -21,6 +24,12 @@ import App from './components/App';
import './styles/index.scss';
if (STRICTERDOM_ENABLED) {
enableStrict();
}
init();
async function init() {
if (DEBUG) {
// eslint-disable-next-line no-console
@ -56,12 +65,14 @@ async function init() {
console.log('>>> START INITIAL RENDER');
}
updateWebmanifest();
requestMutation(() => {
updateWebmanifest();
TeactDOM.render(
<App />,
document.getElementById('root')!,
);
TeactDOM.render(
<App />,
document.getElementById('root')!,
);
});
if (DEBUG) {
// eslint-disable-next-line no-console
@ -77,5 +88,3 @@ async function init() {
});
}
}
init();

View File

@ -0,0 +1,86 @@
import { fastRaf, throttleWith } from '../../util/schedulers';
import safeExec from '../../util/safeExec';
import { setPhase } from './stricterdom';
let pendingMeasureTasks: NoneToVoidFunction[] = [];
let pendingMutationTasks: NoneToVoidFunction[] = [];
let pendingForceReflowTasks: (() => NoneToVoidFunction | void)[] = [];
const runUpdatePassOnRaf = throttleWithRafFallback(() => {
const currentMeasureTasks = pendingMeasureTasks;
pendingMeasureTasks = [];
currentMeasureTasks.forEach((task) => {
safeExec(task);
});
// We use promises to provide correct order for Mutation Observer callback microtasks
Promise.resolve()
.then(() => {
setPhase('mutate');
const currentMutationTasks = pendingMutationTasks;
pendingMutationTasks = [];
currentMutationTasks.forEach((task) => {
safeExec(task);
});
})
.then(() => {
setPhase('measure');
const pendingForceReflowMutationTasks: NoneToVoidFunction[] = [];
// Will include tasks created during the loop
for (const task of pendingForceReflowTasks) {
safeExec(() => {
const mutationTask = task();
if (mutationTask) {
pendingForceReflowMutationTasks.push(mutationTask);
}
});
}
pendingForceReflowTasks = [];
return pendingForceReflowMutationTasks;
})
.then((pendingForceReflowMutationTasks) => {
setPhase('mutate');
// Will include tasks created during the loop
for (const task of pendingForceReflowMutationTasks) {
safeExec(task);
}
})
.then(() => {
setPhase('measure');
});
});
export function requestMeasure(cb: NoneToVoidFunction) {
pendingMeasureTasks.push(cb);
runUpdatePassOnRaf();
}
export function requestMutation(cb: NoneToVoidFunction) {
pendingMutationTasks.push(cb);
runUpdatePassOnRaf();
}
export function requestNextMutation(cb: () => (NoneToVoidFunction | void)) {
requestMeasure(() => {
requestMutation(cb);
});
}
export function requestForcedReflow(cb: () => (NoneToVoidFunction | void)) {
pendingForceReflowTasks.push(cb);
runUpdatePassOnRaf();
}
function throttleWithRafFallback<F extends AnyToVoidFunction>(fn: F) {
return throttleWith((throttledFn: NoneToVoidFunction) => {
fastRaf(throttledFn, true);
}, fn);
}
export * from './stricterdom';

View File

@ -0,0 +1,46 @@
// https://gist.github.com/paulirish/5d52fb081b3570c81e3a
export default {
Element: {
props: [
'clientLeft', 'clientTop', 'clientWidth', 'clientHeight',
'scrollWidth', 'scrollHeight', 'scrollLeft', 'scrollTop',
] as const,
methods: [
'getClientRects', 'getBoundingClientRect',
'scrollBy', 'scrollTo', 'scrollIntoView', 'scrollIntoViewIfNeeded',
] as const,
},
HTMLElement: {
props: [
'offsetLeft', 'offsetTop', 'offsetWidth', 'offsetHeight', 'offsetParent',
'innerText',
] as const,
methods: ['focus'] as const,
},
window: {
props: [
'scrollX', 'scrollY',
'innerHeight', 'innerWidth',
] as const,
methods: ['getComputedStyle'] as const,
},
VisualViewport: {
props: [
'height', 'width', 'offsetTop', 'offsetLeft',
] as const,
},
Document: {
props: ['scrollingElement'] as const,
methods: ['elementFromPoint'] as const,
},
HTMLInputElement: {
methods: ['select'] as const,
},
MouseEvent: {
props: ['layerX', 'layerY', 'offsetX', 'offsetY'] as const,
},
Range: {
methods: ['getClientRects', 'getBoundingClientRect'] as const,
},
};

View File

@ -0,0 +1,165 @@
import LAYOUT_CAUSES from './layoutCauses';
type Entities = keyof typeof LAYOUT_CAUSES;
type Phase =
'measure'
| 'mutate';
type ErrorHandler = (error: Error) => any;
// eslint-disable-next-line no-console
const DEFAULT_ERROR_HANDLER = console.error;
let onError: ErrorHandler = DEFAULT_ERROR_HANDLER;
const nativeMethods = new Map<string, AnyFunction>();
let phase: Phase = 'measure';
let isStrict = false;
let observer: MutationObserver | undefined;
export function setPhase(newPhase: Phase) {
phase = newPhase;
}
export function getPhase() {
return phase;
}
export function enableStrict() {
if (isStrict) return;
isStrict = true;
setupLayoutDetectors();
setupMutationObserver();
}
export function disableStrict() {
if (!isStrict) return;
clearMutationObserver();
clearLayoutDetectors();
isStrict = false;
}
export function forceMeasure(cb: () => any) {
if (phase !== 'mutate') {
throw new Error('The current phase is \'measure\'');
}
phase = 'measure';
const result = cb();
phase = 'mutate';
return result;
}
export function setHandler(handler?: ErrorHandler) {
onError = handler || DEFAULT_ERROR_HANDLER;
}
function setupLayoutDetectors() {
Object.entries(LAYOUT_CAUSES).forEach(([name, causes]) => {
const entity = window[name as Entities];
const prototype = typeof entity === 'object' ? entity : entity.prototype;
if ('props' in causes) {
causes.props.forEach((prop) => {
const nativeGetter = Object.getOwnPropertyDescriptor(prototype, prop)?.get;
if (!nativeGetter) {
return;
}
nativeMethods.set(`${name}#${prop}`, nativeGetter);
Object.defineProperty(prototype, prop, {
get() {
onMeasure(prop);
return nativeGetter.call(this);
},
});
});
}
if ('methods' in causes) {
causes.methods.forEach((method) => {
const nativeMethod = (prototype as any)[method]!;
nativeMethods.set(`${name}#${method}`, nativeMethod);
// eslint-disable-next-line func-names
(prototype as any)[method] = function (...args: any[]) {
onMeasure(method);
return nativeMethod.apply(this, args);
};
});
}
});
}
function clearLayoutDetectors() {
Object.entries(LAYOUT_CAUSES).forEach(([name, causes]) => {
const entity = window[name as Entities];
const prototype = typeof entity === 'object' ? entity : entity.prototype;
if ('props' in causes) {
causes.props.forEach((prop) => {
const nativeGetter = nativeMethods.get(`${name}#${prop}`);
if (!nativeGetter) {
return;
}
Object.defineProperty(prototype, prop, { get: nativeGetter });
});
}
if ('methods' in causes) {
causes.methods.forEach((method) => {
(prototype as any)[method] = nativeMethods.get(`${name}#${method}`)!;
});
}
});
nativeMethods.clear();
}
function setupMutationObserver() {
observer = new MutationObserver((mutations) => {
if (phase !== 'mutate') {
mutations.forEach(({ target, type, attributeName }) => {
if (!document.contains(target)) {
return;
}
if (type === 'childList' && target instanceof HTMLElement && target.contentEditable) {
return;
}
if (attributeName?.startsWith('data-')) {
return;
}
// eslint-disable-next-line no-console
onError(new Error(`Unexpected mutation detected: \`${type === 'attributes' ? attributeName : type}\``));
});
}
});
observer.observe(document.body, {
childList: true,
attributes: true,
subtree: true,
characterData: false,
});
}
function clearMutationObserver() {
observer?.disconnect();
observer = undefined;
}
function onMeasure(propName: string) {
if (phase !== 'measure') {
onError(new Error(`Unexpected measurement detected: \`${propName}\``));
}
}

View File

@ -1,3 +1,5 @@
import { requestMeasure, requestMutation } from '../fasterdom/fasterdom';
import type { RLottieApi } from './rlottie.worker';
import {
@ -6,7 +8,6 @@ import {
import { createConnector } from '../../util/PostMessageConnector';
import { animate } from '../../util/animation';
import cycleRestrict from '../../util/cycleRestrict';
import { fastRaf } from '../../util/schedulers';
import generateIdFor from '../../util/generateIdFor';
interface Params {
@ -222,25 +223,29 @@ class RLottie {
canvas, ctx,
} = containerInfo;
let [canvasWidth, canvasHeight] = [canvas.width, canvas.height];
if (!canvas.dataset.isJustCleaned || canvas.dataset.isJustCleaned === 'false') {
const sizeFactor = this.calcSizeFactor();
ensureCanvasSize(canvas, sizeFactor);
ctx.clearRect(0, 0, canvas.width, canvas.height);
([canvasWidth, canvasHeight] = ensureCanvasSize(canvas, sizeFactor));
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
canvas.dataset.isJustCleaned = 'true';
fastRaf(() => {
requestMeasure(() => {
canvas.dataset.isJustCleaned = 'false';
});
}
containerInfo.coords = {
x: Math.round((newCoords?.x || 0) * canvas.width),
y: Math.round((newCoords?.y || 0) * canvas.height),
x: Math.round((newCoords?.x || 0) * canvasWidth),
y: Math.round((newCoords?.y || 0) * canvasHeight),
};
const frame = this.getFrame(this.prevFrameIndex) || this.getFrame(Math.round(this.approxFrameIndex));
if (frame && frame !== WAITING) {
ctx.drawImage(frame, containerInfo.coords.x, containerInfo.coords.y);
requestMutation(() => {
ctx.drawImage(frame, containerInfo.coords!.x, containerInfo.coords!.y);
});
}
}
@ -273,21 +278,28 @@ class RLottie {
}
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
canvas.style.width = `${size}px`;
canvas.style.height = `${size}px`;
imgSize = Math.round(size * sizeFactor);
canvas.width = imgSize;
canvas.height = imgSize;
if (!this.imgSize) {
this.imgSize = imgSize;
this.imageData = new ImageData(imgSize, imgSize);
}
container.appendChild(canvas);
requestMutation(() => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
this.views.set(viewId, {
canvas, ctx, onLoad,
canvas.style.width = `${size}px`;
canvas.style.height = `${size}px`;
canvas.width = imgSize;
canvas.height = imgSize;
container.appendChild(canvas);
this.views.set(viewId, {
canvas, ctx, onLoad,
});
});
} else {
if (!container.isConnected) {
@ -297,27 +309,27 @@ class RLottie {
const canvas = container;
const ctx = canvas.getContext('2d')!;
ensureCanvasSize(canvas, sizeFactor);
imgSize = Math.round(this.params.size! * sizeFactor);
if (!this.imgSize) {
this.imgSize = imgSize;
this.imageData = new ImageData(imgSize, imgSize);
}
const [canvasWidth, canvasHeight] = ensureCanvasSize(canvas, sizeFactor);
this.views.set(viewId, {
canvas,
ctx,
isSharedCanvas: true,
coords: {
x: Math.round((coords?.x || 0) * canvas.width),
y: Math.round((coords?.y || 0) * canvas.height),
x: Math.round((coords?.x || 0) * canvasWidth),
y: Math.round((coords?.y || 0) * canvasHeight),
},
onLoad,
});
}
if (!this.imgSize) {
this.imgSize = imgSize;
this.imageData = new ImageData(imgSize, imgSize);
}
if (this.isRendererInited) {
this.doPlay();
}
@ -556,7 +568,7 @@ class RLottie {
}
return true;
});
}, requestMutation);
}
private getFrame(frameIndex: number) {
@ -597,10 +609,15 @@ class RLottie {
function ensureCanvasSize(canvas: HTMLCanvasElement, sizeFactor: number) {
const expectedWidth = Math.round(canvas.offsetWidth * sizeFactor);
const expectedHeight = Math.round(canvas.offsetHeight * sizeFactor);
if (canvas.width !== expectedWidth || canvas.height !== expectedHeight) {
canvas.width = expectedWidth;
canvas.height = expectedHeight;
requestMutation(() => {
canvas.width = expectedWidth;
canvas.height = expectedHeight;
});
}
return [expectedWidth, expectedHeight];
}
export default RLottie;

View File

@ -1,11 +1,13 @@
import type { ReactElement } from 'react';
import { requestMeasure, requestMutation } from '../fasterdom/fasterdom';
import { DEBUG, DEBUG_MORE } from '../../config';
import { throttleWithRafFallback } from '../../util/schedulers';
import { throttleWith } from '../../util/schedulers';
import { orderBy } from '../../util/iteratees';
import { getUnequalProps } from '../../util/arePropsShallowEqual';
import { handleError } from '../../util/handleError';
import { incrementOverlayCounter } from '../../util/debugOverlay';
import { isSignal } from '../../util/signals';
import safeExec from '../../util/safeExec';
export type Props = AnyLiteral;
export type FC<P extends Props = any> = (props: P) => any;
@ -309,15 +311,26 @@ let pendingLayoutEffects = new Map<string, Effect>();
let pendingLayoutCleanups = new Map<string, EffectCleanup>();
let areImmediateEffectsPending = false;
const runUpdatePassOnRaf = throttleWithRafFallback(() => {
/*
Order:
- component effect cleanups
- component effects
- measure tasks
- mutation tasks
- component updates
- component layout effect cleanups
- component layout effects
- forced layout measure tasks
- forced layout mutation tasks
*/
const runUpdatePassOnRaf = throttleWith(requestMeasure, () => {
areImmediateEffectsPending = true;
idsToExcludeFromUpdate = new Set();
const instancesToUpdate = Array
.from(instancesPendingUpdate)
.sort((a, b) => a.id - b.id);
instancesPendingUpdate = new Set();
const currentCleanups = pendingCleanups;
@ -328,18 +341,19 @@ const runUpdatePassOnRaf = throttleWithRafFallback(() => {
pendingEffects = new Map();
currentEffects.forEach((cb) => cb());
instancesToUpdate.forEach(prepareComponentForFrame);
requestMutation(() => {
instancesToUpdate.forEach(prepareComponentForFrame);
instancesToUpdate.forEach((instance) => {
if (idsToExcludeFromUpdate!.has(instance.id)) {
return;
}
instancesToUpdate.forEach((instance) => {
if (idsToExcludeFromUpdate!.has(instance.id)) {
return;
}
forceUpdateComponent(instance);
});
forceUpdateComponent(instance);
areImmediateEffectsPending = false;
runImmediateEffects();
});
areImmediateEffectsPending = false;
runImmediateEffects();
});
export function willRunImmediateEffects() {
@ -356,18 +370,13 @@ export function runImmediateEffects() {
currentLayoutEffects.forEach((cb) => cb());
}
function scheduleUpdate(componentInstance: ComponentInstance) {
instancesPendingUpdate.add(componentInstance);
runUpdatePassOnRaf();
}
export function renderComponent(componentInstance: ComponentInstance) {
idsToExcludeFromUpdate.add(componentInstance.id);
const { Component, props } = componentInstance;
let newRenderedValue;
let newRenderedValue: any;
try {
safeExec(() => {
renderingInstance = componentInstance;
componentInstance.hooks.state.cursor = 0;
componentInstance.hooks.effects.cursor = 0;
@ -414,13 +423,12 @@ export function renderComponent(componentInstance: ComponentInstance) {
incrementOverlayCounter(`${componentName} duration`, duration);
}
}
} catch (err: any) {
}, () => {
// eslint-disable-next-line no-console
console.error(`[Teact] Error while rendering component ${componentInstance.name}`);
handleError(err);
newRenderedValue = componentInstance.renderedValue;
}
});
if (componentInstance.isMounted && newRenderedValue === componentInstance.renderedValue) {
return componentInstance.$element;
@ -468,10 +476,8 @@ export function unmountComponent(componentInstance: ComponentInstance) {
idsToExcludeFromUpdate.add(componentInstance.id);
componentInstance.hooks.effects.byCursor.forEach((effect) => {
try {
effect.cleanup?.();
} catch (err: any) {
handleError(err);
if (effect.cleanup) {
safeExec(effect.cleanup);
}
effect.cleanup = undefined;
@ -559,7 +565,8 @@ export function useState<T>(initial?: T, debugKey?: string): [T, StateHookSetter
byCursor[cursor].nextValue = newValue;
scheduleUpdate(componentInstance);
instancesPendingUpdate.add(componentInstance);
runUpdatePassOnRaf();
if (DEBUG_MORE) {
if (componentInstance.name !== 'TeactNContainer') {
@ -597,81 +604,75 @@ function useEffectBase(
const { cursor, byCursor } = renderingInstance.hooks.effects;
const componentInstance = renderingInstance;
function execCleanup() {
const runEffectCleanup = () => safeExec(() => {
const { cleanup } = byCursor[cursor];
if (!cleanup) {
return;
}
try {
// eslint-disable-next-line @typescript-eslint/naming-convention
let DEBUG_startAt: number | undefined;
if (DEBUG) {
DEBUG_startAt = performance.now();
}
cleanup();
if (DEBUG) {
const duration = performance.now() - DEBUG_startAt!;
const componentName = componentInstance.name;
if (duration > DEBUG_EFFECT_THRESHOLD) {
// eslint-disable-next-line no-console
console.warn(
`[Teact] Slow cleanup at effect cursor #${cursor}: ${componentName}, ${Math.round(duration)} ms`,
);
}
}
} catch (err: any) {
// eslint-disable-next-line no-console
console.error(`[Teact] Error in effect cleanup at cursor #${cursor} in ${componentInstance.name}`);
handleError(err);
// eslint-disable-next-line @typescript-eslint/naming-convention
let DEBUG_startAt: number | undefined;
if (DEBUG) {
DEBUG_startAt = performance.now();
}
byCursor[cursor].cleanup = undefined;
}
cleanup();
function exec() {
if (DEBUG) {
const duration = performance.now() - DEBUG_startAt!;
const componentName = componentInstance.name;
if (duration > DEBUG_EFFECT_THRESHOLD) {
// eslint-disable-next-line no-console
console.warn(
`[Teact] Slow cleanup at effect cursor #${cursor}: ${componentName}, ${Math.round(duration)} ms`,
);
}
}
}, () => {
// eslint-disable-next-line no-console
console.error(`[Teact] Error in effect cleanup at cursor #${cursor} in ${componentInstance.name}`);
}, () => {
byCursor[cursor].cleanup = undefined;
});
const runEffect = () => safeExec(() => {
if (!componentInstance.isMounted) {
return;
}
try {
// eslint-disable-next-line @typescript-eslint/naming-convention
let DEBUG_startAt: number | undefined;
if (DEBUG) {
DEBUG_startAt = performance.now();
}
const result = effect();
if (typeof result === 'function') {
byCursor[cursor].cleanup = result;
}
if (DEBUG) {
const duration = performance.now() - DEBUG_startAt!;
const componentName = componentInstance.name;
if (duration > DEBUG_EFFECT_THRESHOLD) {
// eslint-disable-next-line no-console
console.warn(`[Teact] Slow effect at cursor #${cursor}: ${componentName}, ${Math.round(duration)} ms`);
}
}
} catch (err: any) {
// eslint-disable-next-line no-console
console.error(`[Teact] Error in effect at cursor #${cursor} in ${componentInstance.name}`);
handleError(err);
// eslint-disable-next-line @typescript-eslint/naming-convention
let DEBUG_startAt: number | undefined;
if (DEBUG) {
DEBUG_startAt = performance.now();
}
}
const result = effect();
if (typeof result === 'function') {
byCursor[cursor].cleanup = result;
}
if (DEBUG) {
const duration = performance.now() - DEBUG_startAt!;
const componentName = componentInstance.name;
if (duration > DEBUG_EFFECT_THRESHOLD) {
// eslint-disable-next-line no-console
console.warn(`[Teact] Slow effect at cursor #${cursor}: ${componentName}, ${Math.round(duration)} ms`);
}
}
}, () => {
// eslint-disable-next-line no-console
console.error(`[Teact] Error in effect at cursor #${cursor} in ${componentInstance.name}`);
});
function schedule() {
const effectId = `${componentInstance.id}_${cursor}`;
if (isLayout) {
pendingLayoutCleanups.set(effectId, execCleanup);
pendingLayoutEffects.set(effectId, exec);
pendingLayoutCleanups.set(effectId, runEffectCleanup);
pendingLayoutEffects.set(effectId, runEffect);
} else {
pendingCleanups.set(effectId, execCleanup);
pendingEffects.set(effectId, exec);
pendingCleanups.set(effectId, runEffectCleanup);
pendingEffects.set(effectId, runEffect);
}
runUpdatePassOnRaf();

View File

@ -1,11 +1,12 @@
/* eslint-disable eslint-multitab-tt/set-global-only-variable */
import type { FC, FC_withDebug, Props } from './teact';
import React, { useEffect, useState } from './teact';
import { requestMeasure } from '../fasterdom/fasterdom';
import { DEBUG, DEBUG_MORE } from '../../config';
import useForceUpdate from '../../hooks/useForceUpdate';
import generateIdFor from '../../util/generateIdFor';
import { fastRafWithFallback, throttleWithTickEnd } from '../../util/schedulers';
import { throttleWithTickEnd } from '../../util/schedulers';
import arePropsShallowEqual, { getUnequalProps } from '../../util/arePropsShallowEqual';
import { orderBy } from '../../util/iteratees';
import { handleError } from '../../util/handleError';
@ -76,7 +77,7 @@ function runCallbacks() {
if (forceOnHeavyAnimation) {
forceOnHeavyAnimation = false;
} else if (isHeavyAnimating()) {
fastRafWithFallback(runCallbacksThrottled);
requestMeasure(runCallbacksThrottled);
return;
}

View File

@ -1,4 +1,5 @@
import { fastRaf } from './schedulers';
import type { Scheduler } from './schedulers';
import { requestMeasure } from '../lib/fasterdom/fasterdom';
interface AnimationInstance {
isCancelled: boolean;
@ -6,7 +7,7 @@ interface AnimationInstance {
let currentInstance: AnimationInstance | undefined;
export function animateSingle(tick: Function, instance?: AnimationInstance) {
export function animateSingle(tick: Function, schedulerFn: Scheduler, instance?: AnimationInstance) {
if (!instance) {
if (currentInstance && !currentInstance.isCancelled) {
currentInstance.isCancelled = true;
@ -17,24 +18,24 @@ export function animateSingle(tick: Function, instance?: AnimationInstance) {
}
if (!instance!.isCancelled && tick()) {
fastRaf(() => {
animateSingle(tick, instance);
schedulerFn(() => {
animateSingle(tick, schedulerFn, instance);
});
}
}
export function animate(tick: Function) {
fastRaf(() => {
export function animate(tick: Function, schedulerFn: Scheduler) {
schedulerFn(() => {
if (tick()) {
animate(tick);
animate(tick, schedulerFn);
}
});
}
export function animateInstantly(tick: Function) {
export function animateInstantly(tick: Function, schedulerFn: Scheduler) {
if (tick()) {
fastRaf(() => {
animateInstantly(tick);
schedulerFn(() => {
animateInstantly(tick, schedulerFn);
});
}
}
@ -94,7 +95,7 @@ export function animateNumber({
}
if (t === 1 && onEnd) onEnd();
return t < 1;
});
}, requestMeasure);
return () => {
canceled = true;

View File

@ -1,4 +1,5 @@
import { getActions, getGlobal } from '../global';
import { requestNextMutation } from '../lib/fasterdom/fasterdom';
import { AudioOrigin, GlobalSearchContent } from '../types';
import type { ApiMessage } from '../api/types';
@ -8,7 +9,6 @@ import safePlay from './safePlay';
import { patchSafariProgressiveAudio, isSafariPatchInProgress } from './patchSafariProgressiveAudio';
import type { MessageKey } from '../global/helpers';
import { getMessageKey, parseMessageKey } from '../global/helpers';
import { fastRaf } from './schedulers';
import { selectCurrentMessageList, selectTabState } from '../global/selectors';
type Handler = (eventName: string, e: Event) => void;
@ -178,11 +178,11 @@ export function register(
stop() {
if (currentTrackId === trackId) {
// Hack, reset src to remove default media session notification
// Hack, reset `src` to remove default media session notification
const prevSrc = audio.src;
audio.pause();
// onPause not called otherwise, but required to sync UI
fastRaf(() => {
// `onPause` not called otherwise, but required to sync UI
requestNextMutation(() => {
audio.src = '';
audio.src = prevSrc;
});

View File

@ -2,6 +2,7 @@ import { IS_IOS } from './windowEnvironment';
import { Lethargy } from './lethargy';
import { clamp, round } from './math';
import { debounce } from './schedulers';
import windowSize from './windowSize';
export enum SwipeDirection {
Up,
@ -107,9 +108,10 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
x: false,
y: false,
};
const currentWindowSize = windowSize.get();
let initialTouchCenter = {
x: window.innerWidth / 2,
y: window.innerHeight / 2,
x: currentWindowSize.width / 2,
y: currentWindowSize.height / 2,
};
let initialSwipeAxis: TSwipeAxis | undefined;
const minZoom = options.minZoom ?? 1;
@ -217,9 +219,10 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
x: false,
y: false,
};
const newWindowSize = windowSize.get();
initialTouchCenter = {
x: window.innerWidth / 2,
y: window.innerHeight / 2,
x: newWindowSize.width / 2,
y: newWindowSize.height / 2,
};
captureEvent = undefined;
}
@ -289,7 +292,7 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
// Avoid conflicts with swipe-to-back gestures
if (IS_IOS) {
const x = (e as RealTouchEvent).touches[0].pageX;
if (x <= IOS_SCREEN_EDGE_THRESHOLD || x >= window.innerWidth - IOS_SCREEN_EDGE_THRESHOLD) {
if (x <= IOS_SCREEN_EDGE_THRESHOLD || x >= windowSize.get().width - IOS_SCREEN_EDGE_THRESHOLD) {
return false;
}
}

View File

@ -1,4 +1,6 @@
import { requestMutation } from '../lib/fasterdom/fasterdom';
import EMOJI_REGEX, { removeVS16s } from '../lib/twemojiRegex';
import withCache from './withCache';
// Due to the fact that emoji from Apple do not contain some characters, it is necessary to remove them from emoji-data
@ -32,8 +34,13 @@ function unifiedToNative(unified: string) {
export const LOADED_EMOJIS = new Set<string>();
export function handleEmojiLoad(event: React.SyntheticEvent<HTMLImageElement>) {
event.currentTarget.classList.add('open');
const emoji = event.currentTarget;
LOADED_EMOJIS.add(event.currentTarget.dataset.path!);
requestMutation(() => {
emoji.classList.add('open');
});
}
export function fixNonStandardEmoji(text: string) {

View File

@ -4,13 +4,15 @@ import { FocusDirection } from '../types';
import {
ANIMATION_LEVEL_MIN,
FAST_SMOOTH_MAX_DISTANCE, FAST_SMOOTH_MAX_DURATION, FAST_SMOOTH_MIN_DURATION,
FAST_SMOOTH_MIN_DURATION,
FAST_SMOOTH_MAX_DURATION,
FAST_SMOOTH_MAX_DISTANCE,
FAST_SMOOTH_SHORT_TRANSITION_MAX_DISTANCE,
} from '../config';
import { IS_ANDROID } from './windowEnvironment';
import { dispatchHeavyAnimationEvent } from '../hooks/useHeavyAnimationCheck';
import { animateSingle } from './animation';
import { fastRaf } from './schedulers';
import { requestForcedReflow, requestMutation } from '../lib/fasterdom/fasterdom';
let isAnimating = false;
@ -23,6 +25,35 @@ export default function fastSmoothScroll(
forceDirection?: FocusDirection,
forceDuration?: number,
forceNormalContainerHeight?: boolean,
withForcedReflow = false,
) {
const args = [
container,
element,
position,
margin,
maxDistance,
forceDirection,
forceDuration,
forceNormalContainerHeight,
] as const;
if (withForcedReflow) {
requestForcedReflow(() => measure(...args));
} else {
requestMutation(measure(...args));
}
}
function measure(
container: HTMLElement,
element: HTMLElement,
position: ScrollLogicalPosition | 'centerOrTop',
margin = 0,
maxDistance = FAST_SMOOTH_MAX_DISTANCE,
forceDirection?: FocusDirection,
forceDuration?: number,
forceNormalContainerHeight?: boolean,
) {
if (
forceDirection === FocusDirection.Static
@ -71,29 +102,33 @@ export default function fastSmoothScroll(
path = Math.min(path, remainingPath);
}
if (path === 0) {
return;
}
return () => {
if (currentScrollTop !== scrollFrom) {
container.scrollTop = scrollFrom;
}
const target = scrollFrom + path;
if (path === 0) {
return;
}
if (forceDuration === 0) {
container.scrollTop = target;
return;
}
const target = scrollFrom + path;
isAnimating = true;
if (forceDuration === 0) {
container.scrollTop = target;
return;
}
const absPath = Math.abs(path);
const transition = absPath <= FAST_SMOOTH_SHORT_TRANSITION_MAX_DISTANCE ? shortTransition : longTransition;
const duration = forceDuration || (
FAST_SMOOTH_MIN_DURATION
+ (absPath / FAST_SMOOTH_MAX_DISTANCE) * (FAST_SMOOTH_MAX_DURATION - FAST_SMOOTH_MIN_DURATION)
);
const startAt = Date.now();
const onHeavyAnimationStop = dispatchHeavyAnimationEvent();
isAnimating = true;
const absPath = Math.abs(path);
const transition = absPath <= FAST_SMOOTH_SHORT_TRANSITION_MAX_DISTANCE ? shortTransition : longTransition;
const duration = forceDuration || (
FAST_SMOOTH_MIN_DURATION
+ (absPath / FAST_SMOOTH_MAX_DISTANCE) * (FAST_SMOOTH_MAX_DURATION - FAST_SMOOTH_MIN_DURATION)
);
const startAt = Date.now();
const onHeavyAnimationStop = dispatchHeavyAnimationEvent();
fastRaf(() => {
animateSingle(() => {
const t = Math.min((Date.now() - startAt) / duration, 1);
const currentPath = path * (1 - transition(t));
@ -107,8 +142,8 @@ export default function fastSmoothScroll(
}
return isAnimating;
});
});
}, requestMutation);
};
}
export function isAnimatingScroll() {
@ -140,10 +175,10 @@ function calculateScrollFrom(
return scrollTop;
}
function longTransition(t: number) {
return 1 - ((1 - t) ** 5);
function shortTransition(t: number) {
return 1 - ((1 - t) ** 3);
}
function shortTransition(t: number) {
return 1 - ((1 - t) ** 3.5);
function longTransition(t: number) {
return 1 - ((1 - t) ** 6.5);
}

View File

@ -2,6 +2,7 @@ import { getGlobal } from '../global';
import { ANIMATION_LEVEL_MIN } from '../config';
import { animate } from './animation';
import { requestMutation } from '../lib/fasterdom/fasterdom';
const DEFAULT_DURATION = 300;
@ -12,10 +13,6 @@ export default function fastSmoothScrollHorizontal(container: HTMLElement, left:
duration = 0;
}
return scrollWithJs(container, left, duration);
}
function scrollWithJs(container: HTMLElement, left: number, duration: number) {
const isRtl = container.getAttribute('dir') === 'rtl';
const {
scrollLeft, offsetWidth: containerWidth, scrollWidth, dataset: { scrollId },
@ -41,44 +38,44 @@ function scrollWithJs(container: HTMLElement, left: number, duration: number) {
const target = scrollLeft + path;
if (duration === 0) {
container.scrollLeft = target;
return Promise.resolve();
}
return new Promise<void>((resolve) => {
requestMutation(() => {
if (duration === 0) {
container.scrollLeft = target;
resolve();
return;
}
let isStopped = false;
const id = Math.random().toString();
container.dataset.scrollId = id;
stopById.set(id, () => {
isStopped = true;
let isStopped = false;
const id = Math.random().toString();
container.dataset.scrollId = id;
stopById.set(id, () => {
isStopped = true;
});
container.style.scrollSnapType = 'none';
const startAt = Date.now();
animate(() => {
if (isStopped) return false;
const t = Math.min((Date.now() - startAt) / duration, 1);
const currentPath = path * (1 - transition(t));
container.scrollLeft = Math.round(target - currentPath);
if (t >= 1) {
container.style.scrollSnapType = '';
delete container.dataset.scrollId;
stopById.delete(id);
resolve();
}
return t < 1;
}, requestMutation);
});
});
container.style.scrollSnapType = 'none';
let resolve: VoidFunction;
const promise = new Promise<void>((r) => {
resolve = r;
});
const startAt = Date.now();
animate(() => {
if (isStopped) return false;
const t = Math.min((Date.now() - startAt) / duration, 1);
const currentPath = path * (1 - transition(t));
container.scrollLeft = Math.round(target - currentPath);
if (t >= 1) {
container.style.scrollSnapType = '';
delete container.dataset.scrollId;
stopById.delete(id);
resolve();
}
return t < 1;
});
return promise;
}
function transition(t: number) {

View File

@ -3,12 +3,12 @@ import { throttle } from './schedulers';
import { getAllMultitabTokens } from './establishMultitabRole';
import { IS_MULTITAB_SUPPORTED } from './windowEnvironment';
window.addEventListener('error', handleErrorEvent);
window.addEventListener('unhandledrejection', handleErrorEvent);
// eslint-disable-next-line prefer-destructuring
const APP_ENV = process.env.APP_ENV;
window.addEventListener('error', handleErrorEvent);
window.addEventListener('unhandledrejection', handleErrorEvent);
function handleErrorEvent(e: ErrorEvent | PromiseRejectionEvent) {
// https://stackoverflow.com/questions/49384120/resizeobserver-loop-limit-exceeded
if (e instanceof ErrorEvent && e.message === 'ResizeObserver loop limit exceeded') {

20
src/util/safeExec.ts Normal file
View File

@ -0,0 +1,20 @@
import { DEBUG_MORE } from '../config';
import { handleError } from './handleError';
const SAFE_EXEC_ENABLED = !DEBUG_MORE;
export default function safeExec(cb: Function, rescue?: (err: Error) => void, always?: NoneToVoidFunction) {
if (!SAFE_EXEC_ENABLED) {
return cb();
}
try {
return cb();
} catch (err: any) {
rescue?.(err);
handleError(err);
return undefined;
} finally {
always?.();
}
}

View File

@ -1,6 +1,4 @@
type Scheduler =
typeof requestAnimationFrame
| typeof onTickEnd;
export type Scheduler = typeof requestAnimationFrame | typeof onTickEnd;
export function debounce<F extends AnyToVoidFunction>(
fn: F,
@ -64,18 +62,6 @@ export function throttle<F extends AnyToVoidFunction>(
};
}
export function fastRafWithFallback<F extends AnyToVoidFunction>(fn: F) {
return fastRaf(fn, true);
}
export function throttleWithRafFallback<F extends AnyToVoidFunction>(fn: F) {
return throttleWith(fastRafWithFallback, fn);
}
export function throttleWithRaf<F extends AnyToVoidFunction>(fn: F) {
return throttleWith(fastRaf, fn);
}
export function throttleWithTickEnd<F extends AnyToVoidFunction>(fn: F) {
return throttleWith(onTickEnd, fn);
}

View File

@ -1,9 +1,11 @@
import { requestMutation } from '../lib/fasterdom/fasterdom';
import type { ISettings } from '../types';
import { animate } from './animation';
import { lerp } from './math';
import themeColors from '../styles/themes.json';
import { lerp } from './math';
type RGBAColor = {
r: number;
@ -39,32 +41,36 @@ const switchTheme = (theme: ISettings['theme'], withAnimation: boolean) => {
const startAt = Date.now();
const themeColorTag = document.querySelector('meta[name="theme-color"]');
document.documentElement.classList.remove(`theme-${isDarkTheme ? 'light' : 'dark'}`);
if (isInitialized) {
document.documentElement.classList.add('no-animations');
}
document.documentElement.classList.add(themeClassName);
if (themeColorTag) {
themeColorTag.setAttribute('content', isDarkTheme ? '#212121' : '#fff');
}
requestMutation(() => {
document.documentElement.classList.remove(`theme-${isDarkTheme ? 'light' : 'dark'}`);
if (isInitialized) {
document.documentElement.classList.add('no-animations');
}
document.documentElement.classList.add(themeClassName);
if (themeColorTag) {
themeColorTag.setAttribute('content', isDarkTheme ? '#212121' : '#fff');
}
setTimeout(() => {
document.documentElement.classList.remove('no-animations');
}, ENABLE_ANIMATION_DELAY_MS);
setTimeout(() => {
requestMutation(() => {
document.documentElement.classList.remove('no-animations');
});
}, ENABLE_ANIMATION_DELAY_MS);
isInitialized = true;
isInitialized = true;
if (shouldAnimate) {
animate(() => {
const t = Math.min((Date.now() - startAt) / DURATION_MS, 1);
if (shouldAnimate) {
animate(() => {
const t = Math.min((Date.now() - startAt) / DURATION_MS, 1);
applyColorAnimationStep(startIndex, endIndex, transition(t));
applyColorAnimationStep(startIndex, endIndex, transition(t));
return t < 1;
});
} else {
applyColorAnimationStep(startIndex, endIndex);
}
return t < 1;
}, requestMutation);
} else {
applyColorAnimationStep(startIndex, endIndex);
}
});
};
function transition(t: number) {

View File

@ -1,4 +1,5 @@
import type { IOpusRecorder } from 'opus-recorder';
import { requestMeasure } from '../lib/fasterdom/fasterdom';
export type Result = { blob: Blob; duration: number; waveform: number[] };
@ -96,7 +97,7 @@ function subscribeToAnalyzer(recorder: IOpusRecorder, cb: Function) {
cb(volume < MIN_VOLUME ? 0 : volume);
requestAnimationFrame(tick);
requestMeasure(tick);
}
tick();

Some files were not shown because too many files have changed in this diff Show More