[Perf] Middle Column: Various optimizations for opening chat

This commit is contained in:
Alexander Zinchuk 2023-04-15 13:50:57 +02:00
parent 2f63a0fcf0
commit d872273def
13 changed files with 186 additions and 106 deletions

View File

@ -1,6 +1,6 @@
import type { FC } from '../../lib/teact/teact';
import React, {
memo, useCallback, useEffect, useMemo, useRef, useState,
memo, useCallback, useEffect, useMemo, useRef,
} from '../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../global';
@ -55,17 +55,17 @@ import resetScroll, { patchChromiumScroll } from '../../util/resetScroll';
import fastSmoothScroll, { isAnimatingScroll } from '../../util/fastSmoothScroll';
import renderText from '../common/helpers/renderText';
import { useStateRef } from '../../hooks/useStateRef';
import useSyncEffect from '../../hooks/useSyncEffect';
import useStickyDates from './hooks/useStickyDates';
import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck';
import useLang from '../../hooks/useLang';
import useWindowSize from '../../hooks/useWindowSize';
import useInterval from '../../hooks/useInterval';
import useNativeCopySelectedMessages from '../../hooks/useNativeCopySelectedMessages';
import useMedia from '../../hooks/useMedia';
import useLayoutEffectWithPrevDeps from '../../hooks/useLayoutEffectWithPrevDeps';
import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps';
import useResizeObserver from '../../hooks/useResizeObserver';
import useContainerHeight from './hooks/useContainerHeight';
import Loading from '../ui/Loading';
import MessageListContent from './MessageListContent';
@ -182,10 +182,11 @@ const MessageList: FC<OwnProps & StateProps> = ({
// We update local cached `scrollOffsetRef` when opening chat.
// Then we update global version every second on scrolling.
const scrollOffsetRef = useRef<number>((type === 'thread'
&& selectScrollOffset(getGlobal(), chatId, threadId))
const scrollOffsetRef = useRef<number>(
(type === 'thread' && selectScrollOffset(getGlobal(), chatId, threadId))
|| selectLastScrollOffset(getGlobal(), chatId, threadId)
|| 0);
|| 0,
);
const anchorIdRef = useRef<string>();
const anchorTopRef = useRef<number>();
@ -196,8 +197,6 @@ const MessageList: FC<OwnProps & StateProps> = ({
const isScrollTopJustUpdatedRef = useRef(false);
const shouldAnimateAppearanceRef = useRef(Boolean(lastMessage));
const [containerHeight, setContainerHeight] = useState<number | undefined>();
const botInfoPhotoUrl = useMedia(botInfo?.photo ? getBotCoverMediaHash(botInfo.photo) : undefined);
const botInfoGifUrl = useMedia(botInfo?.gif ? getDocumentMediaHash(botInfo.gif) : undefined);
const botInfoDimensions = botInfo?.photo ? getPhotoFullDimensions(botInfo.photo) : botInfo?.gif
@ -247,8 +246,11 @@ const MessageList: FC<OwnProps & StateProps> = ({
return undefined;
}
const viewportIds = threadTopMessageId && threadFirstMessageId !== threadTopMessageId
const viewportIds = (
threadTopMessageId
&& threadFirstMessageId !== threadTopMessageId
&& (!messageIds[0] || threadFirstMessageId === messageIds[0])
)
? [threadTopMessageId, ...messageIds]
: messageIds;
@ -342,18 +344,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
updateStickyDates, hasTools, getForceNextPinnedInHeader, onPinnedIntersectionChange, type, chatId, threadId,
]);
// Container resize observer (caused by Composer reply/webpage panels)
const handleResize = useCallback((entry: ResizeObserverEntry) => {
setContainerHeight(entry.contentRect.height);
}, []);
useResizeObserver(containerRef, handleResize);
// Memorize height for scroll animation
const { height: windowHeight } = useWindowSize();
useEffect(() => {
containerRef.current!.dataset.normalHeight = String(containerRef.current!.offsetHeight);
}, [windowHeight, canPost]);
const [getContainerHeight, prevContainerHeightRef] = useContainerHeight(containerRef, canPost && !isSelectModeActive);
// Initial message loading
useEffect(() => {
@ -377,8 +368,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
}
}, [isChatLoaded, messageIds, loadMoreAround, focusingId, isRestricted]);
// Remember scroll position before repositioning it
useSyncEffect(() => {
const rememberScrollPositionRef = useStateRef(() => {
if (!messageIds || !listItemElementsRef.current) {
return;
}
@ -395,13 +385,25 @@ const MessageList: FC<OwnProps & StateProps> = ({
anchorIdRef.current = anchor.id;
anchorTopRef.current = anchor.getBoundingClientRect().top;
// This should match deps for `useLayoutEffectWithPrevDeps` below
}, [messageIds, isViewportNewest, containerHeight, hasTools]);
});
useSyncEffect(
() => rememberScrollPositionRef.current(),
// This will run before modifying content and should match deps for `useLayoutEffectWithPrevDeps` below
[messageIds, isViewportNewest, hasTools, rememberScrollPositionRef],
);
useEffect(
() => rememberScrollPositionRef.current(),
// This is only needed to react on signal updates
[getContainerHeight, rememberScrollPositionRef],
);
// Handles updated message list, takes care of scroll repositioning
useLayoutEffectWithPrevDeps(([
prevMessageIds, prevIsViewportNewest, prevContainerHeight,
]) => {
useLayoutEffectWithPrevDeps(([prevMessageIds, prevIsViewportNewest]) => {
const containerHeight = getContainerHeight();
const prevContainerHeight = prevContainerHeightRef.current;
prevContainerHeightRef.current = containerHeight;
const container = containerRef.current!;
listItemElementsRef.current = Array.from(container.querySelectorAll<HTMLDivElement>('.message-list-item'));
@ -476,7 +478,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
console.time('scrollTop');
}
const isResized = prevContainerHeight !== undefined && prevContainerHeight !== containerHeight;
const isResized = prevContainerHeight && prevContainerHeight !== containerHeight;
const anchor = anchorIdRef.current && container.querySelector(`#${anchorIdRef.current}`);
const unreadDivider = (
!anchor
@ -523,7 +525,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
console.timeEnd('scrollTop');
}
// This should match deps for `useSyncEffect` above
}, [messageIds, isViewportNewest, containerHeight, hasTools]);
}, [messageIds, isViewportNewest, getContainerHeight, prevContainerHeightRef, hasTools]);
useEffectWithPrevDeps(([prevIsSelectModeActive]) => {
if (prevIsSelectModeActive !== undefined) {

View File

@ -208,6 +208,14 @@ const MiddleColumn: FC<OwnProps & StateProps> = ({
const [isNotchShown, setIsNotchShown] = useState<boolean | undefined>();
const [isUnpinModalOpen, setIsUnpinModalOpen] = useState(false);
const {
onIntersectionChanged,
onFocusPinnedMessage,
getCurrentPinnedIndexes,
getLoadingPinnedId,
getForceNextPinnedInHeader,
} = usePinnedMessage(chatId, threadId, pinnedIds);
const isMobileSearchActive = isMobile && hasCurrentTextSearch;
const closeAnimationDuration = isMobile ? LAYER_ANIMATION_DURATION_MS : undefined;
const hasTools = hasPinned && (
@ -235,6 +243,10 @@ const MiddleColumn: FC<OwnProps & StateProps> = ({
const renderingIsChannel = usePrevDuringAnimation(isChannel, closeAnimationDuration);
const renderingShouldJoinToSend = usePrevDuringAnimation(shouldJoinToSend, closeAnimationDuration);
const renderingShouldSendJoinRequest = usePrevDuringAnimation(shouldSendJoinRequest, closeAnimationDuration);
const renderingOnPinnedIntersectionChange = usePrevDuringAnimation(
chatId ? onIntersectionChanged : undefined,
closeAnimationDuration,
);
const prevTransitionKey = usePrevious(currentTransitionKey);
@ -356,14 +368,6 @@ const MiddleColumn: FC<OwnProps & StateProps> = ({
const customBackgroundValue = useCustomBackground(theme, customBackground);
const {
onIntersectionChanged,
onFocusPinnedMessage,
getCurrentPinnedIndexes,
getLoadingPinnedId,
getForceNextPinnedInHeader,
} = usePinnedMessage(chatId, threadId, pinnedIds);
const className = buildClassName(
renderingHasTools && 'has-header-tools',
MASK_IMAGE_DISABLED ? 'mask-image-disabled' : 'mask-image-enabled',
@ -449,13 +453,13 @@ const MiddleColumn: FC<OwnProps & StateProps> = ({
style={customBackgroundValue ? `--custom-background: ${customBackgroundValue}` : undefined}
/>
<div id="middle-column-portals" />
{renderingChatId && renderingThreadId && (
{Boolean(renderingChatId && renderingThreadId) && (
<>
<div className="messages-layout" onDragEnter={renderingCanPost ? handleDragEnter : undefined}>
<MiddleHeader
chatId={renderingChatId}
threadId={renderingThreadId}
messageListType={renderingMessageListType}
chatId={renderingChatId!}
threadId={renderingThreadId!}
messageListType={renderingMessageListType!}
isReady={isReady}
isMobile={isMobile}
getCurrentPinnedIndexes={getCurrentPinnedIndexes}
@ -471,25 +475,25 @@ const MiddleColumn: FC<OwnProps & StateProps> = ({
>
<MessageList
key={`${renderingChatId}-${renderingThreadId}-${renderingMessageListType}`}
chatId={renderingChatId}
threadId={renderingThreadId}
type={renderingMessageListType}
canPost={renderingCanPost}
chatId={renderingChatId!}
threadId={renderingThreadId!}
type={renderingMessageListType!}
canPost={renderingCanPost!}
hasTools={renderingHasTools}
onFabToggle={setIsFabShown}
onNotchToggle={setIsNotchShown}
isReady={isReady}
withBottomShift={withMessageListBottomShift}
withDefaultBg={Boolean(!customBackground && !backgroundColor)}
onPinnedIntersectionChange={onIntersectionChanged}
onPinnedIntersectionChange={renderingOnPinnedIntersectionChange!}
getForceNextPinnedInHeader={getForceNextPinnedInHeader}
/>
<div className={footerClassName}>
{renderingCanPost && (
<Composer
chatId={renderingChatId}
threadId={renderingThreadId}
messageListType={renderingMessageListType}
chatId={renderingChatId!}
threadId={renderingThreadId!}
messageListType={renderingMessageListType!}
dropAreaState={dropAreaState}
onDropHide={handleHideDropArea}
isReady={isReady}
@ -519,8 +523,9 @@ const MiddleColumn: FC<OwnProps & StateProps> = ({
</div>
</div>
)}
{isMobile
&& (renderingCanSubscribe || (renderingShouldJoinToSend && !renderingShouldSendJoinRequest)) && (
{(
isMobile && (renderingCanSubscribe || (renderingShouldJoinToSend && !renderingShouldSendJoinRequest))
) && (
<div className="middle-column-footer-button-container" dir={lang.isRtl ? 'rtl' : undefined}>
<Button
size="tiny"
@ -584,7 +589,7 @@ const MiddleColumn: FC<OwnProps & StateProps> = ({
</Transition>
<FloatingActionButtons
isShown={renderingIsFabShown}
isShown={renderingIsFabShown!}
canPost={renderingCanPost}
withExtraShift={withExtraShift}
/>

View File

@ -0,0 +1,28 @@
import type { RefObject } from 'react';
import { useCallback, useEffect, useRef } from '../../../lib/teact/teact';
import useSignal from '../../../hooks/useSignal';
import useResizeObserver from '../../../hooks/useResizeObserver';
export default function useContainerHeight(containerRef: RefObject<HTMLDivElement>, isComposerVisible: boolean) {
const [getContainerHeight, setContainerHeight] = useSignal<number | undefined>();
// Container resize observer (caused by Composer reply/webpage panels)
const handleResize = useCallback((entry: ResizeObserverEntry) => {
setContainerHeight(entry.contentRect.height);
}, [setContainerHeight]);
useResizeObserver(containerRef, handleResize);
useEffect(() => {
const currentNormalHeight = Number(containerRef.current!.dataset.normalHeight) || 0;
const containerHeight = getContainerHeight();
if (containerHeight && containerHeight > currentNormalHeight && isComposerVisible) {
containerRef.current!.dataset.normalHeight = String(containerHeight);
}
}, [isComposerVisible, containerRef, getContainerHeight]);
const prevContainerHeight = useRef<number>();
return [getContainerHeight, prevContainerHeight] as const;
}

View File

@ -576,7 +576,7 @@ const Message: FC<OwnProps & StateProps> = ({
text, photo, video, audio, voice, document, sticker, contact, poll, webPage, invoice, location, action, game,
} = getMessageContent(message);
const { result: detectedLanguage } = useTextLanguage(areTranslationsEnabled ? text?.text : undefined);
const detectedLanguage = useTextLanguage(areTranslationsEnabled ? text?.text : undefined);
const { isPending: isTranslationPending, translatedText } = useMessageTranslation(
chatTranslations, chatId, messageId, requestedTranslationLanguage,

View File

@ -43,6 +43,7 @@ import {
removeRequestedMessageTranslation,
replaceScheduledMessages,
replaceThreadParam,
safeReplacePinnedIds,
safeReplaceViewportIds,
updateChat,
updateChatMessage,
@ -73,14 +74,16 @@ import {
selectLanguageCode,
selectListedIds,
selectNoWebPage,
selectOutlyingListByMessageId, selectPinnedIds,
selectOutlyingListByMessageId,
selectPinnedIds,
selectRealLastReadId,
selectReplyingToId,
selectScheduledMessage,
selectSendAs,
selectSponsoredMessage,
selectTabState,
selectThreadIdFromMessage, selectThreadOriginChat,
selectThreadIdFromMessage,
selectThreadOriginChat,
selectThreadTopMessageId,
selectUser,
selectViewportIds,
@ -1175,7 +1178,7 @@ addActionHandler('loadPinnedMessages', async (global, actions, payload): Promise
global = getGlobal();
global = addChatMessagesById(global, chat.id, byId);
global = replaceThreadParam(global, chat.id, threadId, 'pinnedIds', ids);
global = safeReplacePinnedIds(global, chat.id, threadId, ids);
global = addUsers(global, buildCollectionByKey(users, 'id'));
global = addChats(global, buildCollectionByKey(chats, 'id'));
setGlobal(global);

View File

@ -14,6 +14,7 @@ import { getUserFullName } from './users';
import { IS_OPUS_SUPPORTED, isWebpSupported } from '../../util/windowEnvironment';
import { getChatTitle, isUserId } from './chats';
import { getGlobal } from '../index';
import { areSortedArraysIntersecting, unique } from '../../util/iteratees';
const RE_LINK = new RegExp(RE_LINK_TEMPLATE, 'i');
@ -253,3 +254,44 @@ export function getMessageSingleInlineButton(message: ApiMessage) {
&& message.inlineButtons[0].length === 1
&& message.inlineButtons[0][0];
}
export function orderHistoryIds(listedIds: number[]) {
return listedIds.sort((a, b) => a - b);
}
export function orderPinnedIds(pinnedIds: number[]) {
return pinnedIds.sort((a, b) => b - a);
}
export function mergeIdRanges(ranges: number[][], idsUpdate: number[]): number[][] {
let hasIntersection = false;
let newOutlyingLists = ranges.length ? ranges.map((list) => {
if (areSortedArraysIntersecting(list, idsUpdate) && !hasIntersection) {
hasIntersection = true;
return orderHistoryIds(unique(list.concat(idsUpdate)));
}
return list;
}) : [idsUpdate];
if (!hasIntersection) {
newOutlyingLists = newOutlyingLists.concat([idsUpdate]);
}
newOutlyingLists.sort((a, b) => a[0] - b[0]);
let length = newOutlyingLists.length;
for (let i = 0; i < length; i++) {
const array = newOutlyingLists[i];
const prevArray = newOutlyingLists[i - 1];
if (prevArray && (prevArray.includes(array[0]) || prevArray.includes(array[0] - 1))) {
newOutlyingLists[i - 1] = orderHistoryIds(unique(array.concat(prevArray)));
newOutlyingLists.splice(i, 1);
length--;
i--;
}
}
return newOutlyingLists;
}

View File

@ -27,11 +27,13 @@ import {
selectTabState, selectOutlyingLists,
} from '../selectors';
import {
areSortedArraysEqual, mergeIdRanges, omit, orderHistoryIds, pickTruthy, unique,
areSortedArraysEqual, omit, pickTruthy, unique,
} from '../../util/iteratees';
import { updateTabState } from './tabs';
import { getCurrentTabId } from '../../util/establishMultitabRole';
import { isLocalMessageId } from '../helpers';
import {
isLocalMessageId, mergeIdRanges, orderHistoryIds, orderPinnedIds,
} from '../helpers';
type MessageStoreSections = {
byId: Record<number, ApiMessage>;
@ -454,6 +456,24 @@ export function safeReplaceViewportIds<T extends GlobalState>(
);
}
export function safeReplacePinnedIds<T extends GlobalState>(
global: T,
chatId: string,
threadId: number,
newPinnedIds: number[],
): T {
const currentIds = selectPinnedIds(global, chatId, threadId) || [];
const newIds = orderPinnedIds(newPinnedIds);
return replaceThreadParam(
global,
chatId,
threadId,
'pinnedIds',
areSortedArraysEqual(currentIds, newIds) ? currentIds : newIds,
);
}
export function updateThreadInfo<T extends GlobalState>(
global: T, chatId: string, threadId: number, update: Partial<ApiThreadInfo> | undefined,
): T {

View File

@ -1,11 +1,14 @@
import { useEffect } from '../lib/teact/teact';
import usePrevious from './usePrevious';
import { useEffect, useRef } from '../lib/teact/teact';
const useEffectWithPrevDeps = <const T extends readonly any[]>(
cb: (args: T | readonly []) => void, dependencies: T, debugKey?: string,
) => {
const prevDeps = usePrevious<T>(dependencies);
const prevDepsRef = useRef<T>();
return useEffect(() => {
const prevDeps = prevDepsRef.current;
prevDepsRef.current = dependencies;
return cb(prevDeps || []);
// eslint-disable-next-line react-hooks-static-deps/exhaustive-deps
}, dependencies, debugKey);

View File

@ -1,11 +1,14 @@
import { useLayoutEffect } from '../lib/teact/teact';
import usePrevious from './usePrevious';
import { useLayoutEffect, useRef } from '../lib/teact/teact';
const useLayoutEffectWithPrevDeps = <const T extends readonly any[]>(
cb: (args: T | readonly []) => void, dependencies: T, debugKey?: string,
) => {
const prevDeps = usePrevious<T>(dependencies);
const prevDepsRef = useRef<T>();
return useLayoutEffect(() => {
const prevDeps = prevDepsRef.current;
prevDepsRef.current = dependencies;
return cb(prevDeps || []);
// eslint-disable-next-line react-hooks-static-deps/exhaustive-deps
}, dependencies, debugKey);

View File

@ -4,7 +4,7 @@ import usePrevious from './usePrevious';
import useForceUpdate from './useForceUpdate';
import useSyncEffect from './useSyncEffect';
export default function usePrevDuringAnimation(current: any, duration?: number) {
export default function usePrevDuringAnimation<T>(current: T, duration?: number) {
const prev = usePrevious(current, true);
const timeoutRef = useRef<number>();
const forceUpdate = useForceUpdate();

View File

@ -1,7 +1,17 @@
import { useState } from '../lib/teact/teact';
import { detectLanguage } from '../util/languageDetection';
import useAsync from './useAsync';
import useSyncEffect from './useSyncEffect';
export default function useTextLanguage(text?: string) {
const language = useAsync(() => (text ? detectLanguage(text) : Promise.resolve(undefined)), [text], undefined);
const [language, setLanguage] = useState<string>();
useSyncEffect(() => {
if (text) {
detectLanguage(text).then(setLanguage);
}
}, [text]);
return language;
}

View File

@ -734,6 +734,7 @@ export function useMemo<T extends any>(resolver: () => T, dependencies: any[], d
if (
byCursor[cursor] === undefined
|| dependencies.length !== byCursor[cursor].dependencies.length
|| dependencies.some((dependency, i) => dependency !== byCursor[cursor].dependencies[i])
) {
if (DEBUG && debugKey) {
@ -741,7 +742,7 @@ export function useMemo<T extends any>(resolver: () => T, dependencies: any[], d
console.log(
`[Teact.useMemo] ${renderingInstance.name} (${debugKey}): Update is caused by:`,
byCursor[cursor]
? getUnequalProps(dependencies, byCursor[cursor].dependencies).join(', ')
? getUnequalProps(byCursor[cursor].dependencies, dependencies).join(', ')
: '[first render]',
);
}

View File

@ -148,43 +148,6 @@ function isObject(value: any): value is object {
return typeof value === 'object' && value !== null;
}
export function orderHistoryIds(listedIds: number[]) {
return listedIds.sort((a, b) => a - b);
}
export function mergeIdRanges(ranges: number[][], idsUpdate: number[]): number[][] {
let hasIntersection = false;
let newOutlyingLists = ranges.length ? ranges.map((list) => {
if (areSortedArraysIntersecting(list, idsUpdate) && !hasIntersection) {
hasIntersection = true;
return orderHistoryIds(unique(list.concat(idsUpdate)));
}
return list;
}) : [idsUpdate];
if (!hasIntersection) {
newOutlyingLists = newOutlyingLists.concat([idsUpdate]);
}
newOutlyingLists.sort((a, b) => a[0] - b[0]);
let length = newOutlyingLists.length;
for (let i = 0; i < length; i++) {
const array = newOutlyingLists[i];
const prevArray = newOutlyingLists[i - 1];
if (prevArray && (prevArray.includes(array[0]) || prevArray.includes(array[0] - 1))) {
newOutlyingLists[i - 1] = orderHistoryIds(unique(array.concat(prevArray)));
newOutlyingLists.splice(i, 1);
length--;
i--;
}
}
return newOutlyingLists;
}
export function findLast<T>(array: Array<T>, predicate: (value: T, index: number, obj: T[]) => boolean): T | undefined {
let cursor = array.length;