[Perf] [Refactoring] Extract messages rendering to a memoized component
This commit is contained in:
parent
b3e2eda5e6
commit
45a0bca14b
@ -7,8 +7,7 @@ import { ApiMessage, ApiRestrictionReason, MAIN_THREAD_ID } from '../../api/type
|
|||||||
import { GlobalActions, MessageListType } from '../../global/types';
|
import { GlobalActions, MessageListType } from '../../global/types';
|
||||||
import { LoadMoreDirection } from '../../types';
|
import { LoadMoreDirection } from '../../types';
|
||||||
|
|
||||||
import { ANIMATION_END_DELAY, MESSAGE_LIST_SLICE, SCHEDULED_WHEN_ONLINE } from '../../config';
|
import { ANIMATION_END_DELAY, MESSAGE_LIST_SLICE } from '../../config';
|
||||||
import { IS_ANDROID, IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
|
|
||||||
import {
|
import {
|
||||||
selectChatMessages,
|
selectChatMessages,
|
||||||
selectIsViewportNewest,
|
selectIsViewportNewest,
|
||||||
@ -25,42 +24,24 @@ import {
|
|||||||
selectScheduledMessages,
|
selectScheduledMessages,
|
||||||
selectCurrentMessageIds,
|
selectCurrentMessageIds,
|
||||||
} from '../../modules/selectors';
|
} from '../../modules/selectors';
|
||||||
import {
|
import { isChatChannel, isChatPrivate } from '../../modules/helpers';
|
||||||
getMessageOriginalId,
|
import { orderBy, pick } from '../../util/iteratees';
|
||||||
isActionMessage,
|
import { fastRaf, debounce, onTickEnd } from '../../util/schedulers';
|
||||||
isChatChannel,
|
|
||||||
isChatPrivate,
|
|
||||||
isOwnMessage,
|
|
||||||
} from '../../modules/helpers';
|
|
||||||
import {
|
|
||||||
compact,
|
|
||||||
flatten,
|
|
||||||
orderBy,
|
|
||||||
pick,
|
|
||||||
} from '../../util/iteratees';
|
|
||||||
import {
|
|
||||||
fastRaf, debounce, onTickEnd,
|
|
||||||
} from '../../util/schedulers';
|
|
||||||
import { formatHumanDate } from '../../util/dateFormat';
|
|
||||||
import useLayoutEffectWithPrevDeps from '../../hooks/useLayoutEffectWithPrevDeps';
|
import useLayoutEffectWithPrevDeps from '../../hooks/useLayoutEffectWithPrevDeps';
|
||||||
import buildClassName from '../../util/buildClassName';
|
import buildClassName from '../../util/buildClassName';
|
||||||
import { groupMessages, MessageDateGroup, isAlbum } from './helpers/groupMessages';
|
import { groupMessages } from './helpers/groupMessages';
|
||||||
import { preventMessageInputBlur } from './helpers/preventMessageInputBlur';
|
import { preventMessageInputBlur } from './helpers/preventMessageInputBlur';
|
||||||
import { ObserveFn, useIntersectionObserver } from '../../hooks/useIntersectionObserver';
|
|
||||||
import useOnChange from '../../hooks/useOnChange';
|
import useOnChange from '../../hooks/useOnChange';
|
||||||
import useStickyDates from './hooks/useStickyDates';
|
import useStickyDates from './hooks/useStickyDates';
|
||||||
import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck';
|
import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck';
|
||||||
import resetScroll from '../../util/resetScroll';
|
import resetScroll from '../../util/resetScroll';
|
||||||
import fastSmoothScroll, { isAnimatingScroll } from '../../util/fastSmoothScroll';
|
import fastSmoothScroll, { isAnimatingScroll } from '../../util/fastSmoothScroll';
|
||||||
import renderText from '../common/helpers/renderText';
|
import renderText from '../common/helpers/renderText';
|
||||||
import useLang, { LangFn } from '../../hooks/useLang';
|
import useLang from '../../hooks/useLang';
|
||||||
import useWindowSize from '../../hooks/useWindowSize';
|
import useWindowSize from '../../hooks/useWindowSize';
|
||||||
import useBackgroundMode from '../../hooks/useBackgroundMode';
|
|
||||||
|
|
||||||
import Loading from '../ui/Loading';
|
import Loading from '../ui/Loading';
|
||||||
import MessageScroll from './MessageScroll';
|
import MessageListContent from './MessageListContent';
|
||||||
import Message from './message/Message';
|
|
||||||
import ActionMessage from './ActionMessage';
|
|
||||||
|
|
||||||
import './MessageList.scss';
|
import './MessageList.scss';
|
||||||
|
|
||||||
@ -96,16 +77,12 @@ type StateProps = {
|
|||||||
hasLinkedChat?: boolean;
|
hasLinkedChat?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DispatchProps = Pick<GlobalActions, (
|
type DispatchProps = Pick<GlobalActions, 'loadViewportMessages' | 'setScrollOffset' | 'openHistoryCalendar'>;
|
||||||
'loadViewportMessages' | 'markMessageListRead' | 'markMessagesRead' | 'setScrollOffset' | 'openHistoryCalendar'
|
|
||||||
)>;
|
|
||||||
|
|
||||||
const BOTTOM_THRESHOLD = 100;
|
const BOTTOM_THRESHOLD = 100;
|
||||||
const UNREAD_DIVIDER_TOP = 10;
|
const UNREAD_DIVIDER_TOP = 10;
|
||||||
const UNREAD_DIVIDER_TOP_WITH_TOOLS = 60;
|
const UNREAD_DIVIDER_TOP_WITH_TOOLS = 60;
|
||||||
const SCROLL_DEBOUNCE = 200;
|
const SCROLL_DEBOUNCE = 200;
|
||||||
const INTERSECTION_THROTTLE_FOR_MEDIA = IS_ANDROID ? 1000 : 350;
|
|
||||||
const INTERSECTION_MARGIN_FOR_MEDIA = IS_SINGLE_COLUMN_LAYOUT ? 300 : 500;
|
|
||||||
const FOCUSING_DURATION = 1000;
|
const FOCUSING_DURATION = 1000;
|
||||||
const BOTTOM_FOCUS_MARGIN = 20;
|
const BOTTOM_FOCUS_MARGIN = 20;
|
||||||
const SELECT_MODE_ANIMATION_DURATION = 200;
|
const SELECT_MODE_ANIMATION_DURATION = 200;
|
||||||
@ -137,8 +114,6 @@ const MessageList: FC<OwnProps & StateProps & DispatchProps> = ({
|
|||||||
isSelectModeActive,
|
isSelectModeActive,
|
||||||
animationLevel,
|
animationLevel,
|
||||||
loadViewportMessages,
|
loadViewportMessages,
|
||||||
markMessageListRead,
|
|
||||||
markMessagesRead,
|
|
||||||
setScrollOffset,
|
setScrollOffset,
|
||||||
lastMessage,
|
lastMessage,
|
||||||
botDescription,
|
botDescription,
|
||||||
@ -184,65 +159,10 @@ const MessageList: FC<OwnProps & StateProps & DispatchProps> = ({
|
|||||||
}
|
}
|
||||||
}, [firstUnreadId]);
|
}, [firstUnreadId]);
|
||||||
|
|
||||||
const {
|
|
||||||
observe: observeIntersectionForMedia,
|
|
||||||
} = useIntersectionObserver({
|
|
||||||
rootRef: containerRef,
|
|
||||||
throttleMs: INTERSECTION_THROTTLE_FOR_MEDIA,
|
|
||||||
margin: INTERSECTION_MARGIN_FOR_MEDIA,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
observe: observeIntersectionForReading, freeze: freezeForReading, unfreeze: unfreezeForReading,
|
|
||||||
} = useIntersectionObserver({
|
|
||||||
rootRef: containerRef,
|
|
||||||
}, (entries) => {
|
|
||||||
if (type !== 'thread') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let maxId = 0;
|
|
||||||
const mentionIds: number[] = [];
|
|
||||||
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
const { isIntersecting, target } = entry;
|
|
||||||
|
|
||||||
if (!isIntersecting) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { dataset } = target as HTMLDivElement;
|
|
||||||
|
|
||||||
const messageId = Number(dataset.lastMessageId || dataset.messageId);
|
|
||||||
if (messageId > maxId) {
|
|
||||||
maxId = messageId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dataset.hasUnreadMention) {
|
|
||||||
mentionIds.push(messageId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (memoFirstUnreadIdRef.current && maxId >= memoFirstUnreadIdRef.current) {
|
|
||||||
markMessageListRead({ maxId });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mentionIds.length) {
|
|
||||||
markMessagesRead({ messageIds: mentionIds });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
useBackgroundMode(freezeForReading, unfreezeForReading);
|
|
||||||
|
|
||||||
useOnChange(() => {
|
useOnChange(() => {
|
||||||
memoFocusingIdRef.current = focusingId;
|
memoFocusingIdRef.current = focusingId;
|
||||||
}, [focusingId]);
|
}, [focusingId]);
|
||||||
|
|
||||||
const { observe: observeIntersectionForAnimatedStickers } = useIntersectionObserver({
|
|
||||||
rootRef: containerRef,
|
|
||||||
throttleMs: INTERSECTION_THROTTLE_FOR_MEDIA,
|
|
||||||
});
|
|
||||||
|
|
||||||
const messageGroups = useMemo(() => {
|
const messageGroups = useMemo(() => {
|
||||||
if (!messageIds || !messagesById) {
|
if (!messageIds || !messagesById) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -260,15 +180,14 @@ const MessageList: FC<OwnProps & StateProps & DispatchProps> = ({
|
|||||||
return groupMessages(orderBy(listedMessages, ['date', 'id']), memoUnreadDividerBeforeIdRef.current);
|
return groupMessages(orderBy(listedMessages, ['date', 'id']), memoUnreadDividerBeforeIdRef.current);
|
||||||
}, [messageIds, messagesById, threadFirstMessageId, threadTopMessageId]);
|
}, [messageIds, messagesById, threadFirstMessageId, threadTopMessageId]);
|
||||||
|
|
||||||
const [loadMoreBackwards, loadMoreForwards, loadMoreAround] = useMemo(
|
const loadMoreAround = useMemo(() => {
|
||||||
() => (type === 'thread' ? [
|
if (type !== 'thread') {
|
||||||
debounce(() => loadViewportMessages({ direction: LoadMoreDirection.Backwards }), 1000, true, false),
|
return undefined;
|
||||||
debounce(() => loadViewportMessages({ direction: LoadMoreDirection.Forwards }), 1000, true, false),
|
}
|
||||||
debounce(() => loadViewportMessages({ direction: LoadMoreDirection.Around }), 1000, true, false),
|
|
||||||
] : []),
|
return debounce(() => loadViewportMessages({ direction: LoadMoreDirection.Around }), 1000, true, false);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[loadViewportMessages, messageIds],
|
}, [loadViewportMessages, messageIds]);
|
||||||
);
|
|
||||||
|
|
||||||
const { isScrolled, updateStickyDates } = useStickyDates();
|
const { isScrolled, updateStickyDates } = useStickyDates();
|
||||||
|
|
||||||
@ -535,37 +454,26 @@ const MessageList: FC<OwnProps & StateProps & DispatchProps> = ({
|
|||||||
) : messageIds && !messageGroups ? (
|
) : messageIds && !messageGroups ? (
|
||||||
<div className="empty"><span>{lang('NoMessages')}</span></div>
|
<div className="empty"><span>{lang('NoMessages')}</span></div>
|
||||||
) : ((messageIds && messageGroups) || lastMessage) ? (
|
) : ((messageIds && messageGroups) || lastMessage) ? (
|
||||||
<MessageScroll
|
<MessageListContent
|
||||||
containerRef={containerRef}
|
|
||||||
className="messages-container"
|
|
||||||
messageIds={messageIds || [lastMessage!.id]}
|
messageIds={messageIds || [lastMessage!.id]}
|
||||||
loadMoreForwards={loadMoreForwards}
|
messageGroups={messageGroups || groupMessages([lastMessage!])}
|
||||||
loadMoreBackwards={loadMoreBackwards}
|
isViewportNewest={Boolean(isViewportNewest)}
|
||||||
isViewportNewest={isViewportNewest}
|
isUnread={Boolean(firstUnreadId)}
|
||||||
firstUnreadId={firstUnreadId}
|
withUsers={withUsers}
|
||||||
|
noAvatars={noAvatars}
|
||||||
|
containerRef={containerRef}
|
||||||
|
anchorIdRef={anchorIdRef}
|
||||||
|
memoFirstUnreadIdRef={memoUnreadDividerBeforeIdRef}
|
||||||
|
threadId={threadId}
|
||||||
|
type={type}
|
||||||
|
threadTopMessageId={threadTopMessageId}
|
||||||
|
hasLinkedChat={hasLinkedChat}
|
||||||
|
isSchedule={messageGroups ? type === 'scheduled' : false}
|
||||||
|
noAppearanceAnimation={!messageGroups || !shouldAnimateAppearanceRef.current}
|
||||||
onFabToggle={onFabToggle}
|
onFabToggle={onFabToggle}
|
||||||
onNotchToggle={onNotchToggle}
|
onNotchToggle={onNotchToggle}
|
||||||
>
|
openHistoryCalendar={openHistoryCalendar}
|
||||||
{renderMessages(
|
/>
|
||||||
lang,
|
|
||||||
messageGroups || groupMessages([lastMessage!]),
|
|
||||||
observeIntersectionForReading,
|
|
||||||
observeIntersectionForMedia,
|
|
||||||
observeIntersectionForAnimatedStickers,
|
|
||||||
withUsers,
|
|
||||||
noAvatars,
|
|
||||||
anchorIdRef,
|
|
||||||
memoUnreadDividerBeforeIdRef,
|
|
||||||
threadId,
|
|
||||||
type,
|
|
||||||
threadTopMessageId,
|
|
||||||
threadFirstMessageId,
|
|
||||||
hasLinkedChat,
|
|
||||||
messageGroups ? type === 'scheduled' : false,
|
|
||||||
!messageGroups || !shouldAnimateAppearanceRef.current,
|
|
||||||
openHistoryCalendar,
|
|
||||||
)}
|
|
||||||
</MessageScroll>
|
|
||||||
) : (
|
) : (
|
||||||
<Loading color="white" />
|
<Loading color="white" />
|
||||||
)}
|
)}
|
||||||
@ -573,166 +481,6 @@ const MessageList: FC<OwnProps & StateProps & DispatchProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderMessages(
|
|
||||||
lang: LangFn,
|
|
||||||
messageGroups: MessageDateGroup[],
|
|
||||||
observeIntersectionForReading: ObserveFn,
|
|
||||||
observeIntersectionForMedia: ObserveFn,
|
|
||||||
observeIntersectionForAnimatedStickers: ObserveFn,
|
|
||||||
withUsers: boolean,
|
|
||||||
noAvatars: boolean,
|
|
||||||
currentAnchorIdRef: { current: string | undefined },
|
|
||||||
memoFirstUnreadIdRef: { current: number | undefined },
|
|
||||||
threadId: number,
|
|
||||||
type: MessageListType,
|
|
||||||
threadTopMessageId: number | undefined,
|
|
||||||
threadFirstMessageId: number | undefined,
|
|
||||||
hasLinkedChat: boolean | undefined,
|
|
||||||
isSchedule: boolean,
|
|
||||||
noAppearanceAnimation: boolean,
|
|
||||||
openHistoryCalendar: Function,
|
|
||||||
) {
|
|
||||||
const unreadDivider = (
|
|
||||||
<div className={buildClassName(UNREAD_DIVIDER_CLASS, 'local-action-message')} key="unread-messages">
|
|
||||||
<span>{lang('UnreadMessages')}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const messageCountToAnimate = noAppearanceAnimation ? 0 : messageGroups.reduce((acc, messageGroup) => {
|
|
||||||
return acc + flatten(messageGroup.senderGroups).length;
|
|
||||||
}, 0);
|
|
||||||
let appearanceIndex = 0;
|
|
||||||
|
|
||||||
const dateGroups = messageGroups.map((
|
|
||||||
dateGroup: MessageDateGroup,
|
|
||||||
dateGroupIndex: number,
|
|
||||||
dateGroupsArray: MessageDateGroup[],
|
|
||||||
) => {
|
|
||||||
const senderGroups = dateGroup.senderGroups.map((
|
|
||||||
senderGroup,
|
|
||||||
senderGroupIndex,
|
|
||||||
senderGroupsArray,
|
|
||||||
) => {
|
|
||||||
if (senderGroup.length === 1 && !isAlbum(senderGroup[0]) && isActionMessage(senderGroup[0])) {
|
|
||||||
const message = senderGroup[0];
|
|
||||||
const isLastInList = (
|
|
||||||
senderGroupIndex === senderGroupsArray.length - 1
|
|
||||||
&& dateGroupIndex === dateGroupsArray.length - 1
|
|
||||||
);
|
|
||||||
|
|
||||||
return compact([
|
|
||||||
message.id === memoFirstUnreadIdRef.current && unreadDivider,
|
|
||||||
<ActionMessage
|
|
||||||
key={message.id}
|
|
||||||
message={message}
|
|
||||||
observeIntersection={observeIntersectionForReading}
|
|
||||||
appearanceOrder={messageCountToAnimate - ++appearanceIndex}
|
|
||||||
isLastInList={isLastInList}
|
|
||||||
/>,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentDocumentGroupId: string | undefined;
|
|
||||||
|
|
||||||
return flatten(senderGroup.map((
|
|
||||||
messageOrAlbum,
|
|
||||||
messageIndex,
|
|
||||||
) => {
|
|
||||||
const message = isAlbum(messageOrAlbum) ? messageOrAlbum.mainMessage : messageOrAlbum;
|
|
||||||
const album = isAlbum(messageOrAlbum) ? messageOrAlbum : undefined;
|
|
||||||
const isOwn = isOwnMessage(message);
|
|
||||||
const isMessageAlbum = isAlbum(messageOrAlbum);
|
|
||||||
const nextMessage = senderGroup[messageIndex + 1];
|
|
||||||
|
|
||||||
if (message.previousLocalId && currentAnchorIdRef.current === `message${message.previousLocalId}`) {
|
|
||||||
currentAnchorIdRef.current = `message${message.id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const documentGroupId = !isMessageAlbum && message.groupedId ? message.groupedId : undefined;
|
|
||||||
const nextDocumentGroupId = nextMessage && !isAlbum(nextMessage) ? nextMessage.groupedId : undefined;
|
|
||||||
|
|
||||||
const position = {
|
|
||||||
isFirstInGroup: messageIndex === 0,
|
|
||||||
isLastInGroup: messageIndex === senderGroup.length - 1,
|
|
||||||
isFirstInDocumentGroup: Boolean(documentGroupId && documentGroupId !== currentDocumentGroupId),
|
|
||||||
isLastInDocumentGroup: Boolean(documentGroupId && documentGroupId !== nextDocumentGroupId),
|
|
||||||
isLastInList: (
|
|
||||||
messageIndex === senderGroup.length - 1
|
|
||||||
&& senderGroupIndex === senderGroupsArray.length - 1
|
|
||||||
&& dateGroupIndex === dateGroupsArray.length - 1
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
currentDocumentGroupId = documentGroupId;
|
|
||||||
|
|
||||||
const originalId = getMessageOriginalId(message);
|
|
||||||
// Scheduled messages can have local IDs in the middle of the list,
|
|
||||||
// and keys should be ordered, so we prefix it with a date.
|
|
||||||
// However, this may lead to issues if server date is not synchronized with the local one.
|
|
||||||
const key = type !== 'scheduled' ? originalId : `${message.date}_${originalId}`;
|
|
||||||
|
|
||||||
return compact([
|
|
||||||
message.id === memoFirstUnreadIdRef.current ? unreadDivider : undefined,
|
|
||||||
<Message
|
|
||||||
key={key}
|
|
||||||
message={message}
|
|
||||||
observeIntersectionForBottom={observeIntersectionForReading}
|
|
||||||
observeIntersectionForMedia={observeIntersectionForMedia}
|
|
||||||
observeIntersectionForAnimatedStickers={observeIntersectionForAnimatedStickers}
|
|
||||||
album={album}
|
|
||||||
noAvatars={noAvatars}
|
|
||||||
withAvatar={position.isLastInGroup && withUsers && !isOwn && !(message.id === threadTopMessageId)}
|
|
||||||
withSenderName={position.isFirstInGroup && withUsers && !isOwn}
|
|
||||||
threadId={threadId}
|
|
||||||
messageListType={type}
|
|
||||||
noComments={hasLinkedChat === false}
|
|
||||||
appearanceOrder={messageCountToAnimate - ++appearanceIndex}
|
|
||||||
isFirstInGroup={position.isFirstInGroup}
|
|
||||||
isLastInGroup={position.isLastInGroup}
|
|
||||||
isFirstInDocumentGroup={position.isFirstInDocumentGroup}
|
|
||||||
isLastInDocumentGroup={position.isLastInDocumentGroup}
|
|
||||||
isLastInList={position.isLastInList}
|
|
||||||
/>,
|
|
||||||
message.id === threadTopMessageId && (
|
|
||||||
<div className="local-action-message" key="discussion-started">
|
|
||||||
<span>{lang('DiscussionStarted')}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="message-date-group"
|
|
||||||
key={dateGroup.datetime}
|
|
||||||
onMouseDown={preventMessageInputBlur}
|
|
||||||
teactFastList
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={buildClassName('sticky-date', !isSchedule && 'interactive')}
|
|
||||||
key="date-header"
|
|
||||||
onMouseDown={preventMessageInputBlur}
|
|
||||||
onClick={!isSchedule ? () => openHistoryCalendar({ selectedAt: dateGroup.datetime }) : undefined}
|
|
||||||
>
|
|
||||||
<span dir="auto">
|
|
||||||
{isSchedule && dateGroup.originalDate === SCHEDULED_WHEN_ONLINE && (
|
|
||||||
lang('MessageScheduledUntilOnline')
|
|
||||||
)}
|
|
||||||
{isSchedule && dateGroup.originalDate !== SCHEDULED_WHEN_ONLINE && (
|
|
||||||
lang('MessageScheduledOn', formatHumanDate(lang, dateGroup.datetime, undefined, true))
|
|
||||||
)}
|
|
||||||
{!isSchedule && formatHumanDate(lang, dateGroup.datetime)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{flatten(senderGroups)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return flatten(dateGroups);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(withGlobal<OwnProps>(
|
export default memo(withGlobal<OwnProps>(
|
||||||
(global, { chatId, threadId, type }): StateProps => {
|
(global, { chatId, threadId, type }): StateProps => {
|
||||||
const chat = selectChat(global, chatId);
|
const chat = selectChat(global, chatId);
|
||||||
@ -797,8 +545,6 @@ export default memo(withGlobal<OwnProps>(
|
|||||||
},
|
},
|
||||||
(setGlobal, actions): DispatchProps => pick(actions, [
|
(setGlobal, actions): DispatchProps => pick(actions, [
|
||||||
'loadViewportMessages',
|
'loadViewportMessages',
|
||||||
'markMessageListRead',
|
|
||||||
'markMessagesRead',
|
|
||||||
'setScrollOffset',
|
'setScrollOffset',
|
||||||
'openHistoryCalendar',
|
'openHistoryCalendar',
|
||||||
]),
|
]),
|
||||||
|
|||||||
241
src/components/middle/MessageListContent.tsx
Normal file
241
src/components/middle/MessageListContent.tsx
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
import { RefObject } from 'react';
|
||||||
|
import React, { FC, memo } from '../../lib/teact/teact';
|
||||||
|
|
||||||
|
import { MessageListType } from '../../global/types';
|
||||||
|
|
||||||
|
import { SCHEDULED_WHEN_ONLINE } from '../../config';
|
||||||
|
import buildClassName from '../../util/buildClassName';
|
||||||
|
import { compact, flatten } from '../../util/iteratees';
|
||||||
|
import { formatHumanDate } from '../../util/dateFormat';
|
||||||
|
import { getMessageOriginalId, isActionMessage, isOwnMessage } from '../../modules/helpers';
|
||||||
|
import useLang from '../../hooks/useLang';
|
||||||
|
import { isAlbum, MessageDateGroup } from './helpers/groupMessages';
|
||||||
|
import { preventMessageInputBlur } from './helpers/preventMessageInputBlur';
|
||||||
|
import useScrollHooks from './hooks/useScrollHooks';
|
||||||
|
import useMessageObservers from './hooks/useMessageObservers';
|
||||||
|
|
||||||
|
import Message from './message/Message';
|
||||||
|
import ActionMessage from './ActionMessage';
|
||||||
|
|
||||||
|
interface OwnProps {
|
||||||
|
messageIds: number[];
|
||||||
|
messageGroups: MessageDateGroup[];
|
||||||
|
isViewportNewest: boolean;
|
||||||
|
isUnread: boolean;
|
||||||
|
withUsers: boolean;
|
||||||
|
noAvatars: boolean;
|
||||||
|
containerRef: RefObject<HTMLDivElement>;
|
||||||
|
anchorIdRef: { current: string | undefined };
|
||||||
|
memoFirstUnreadIdRef: { current: number | undefined };
|
||||||
|
threadId: number;
|
||||||
|
type: MessageListType;
|
||||||
|
threadTopMessageId: number | undefined;
|
||||||
|
hasLinkedChat: boolean | undefined;
|
||||||
|
isSchedule: boolean;
|
||||||
|
noAppearanceAnimation: boolean;
|
||||||
|
onFabToggle: AnyToVoidFunction;
|
||||||
|
onNotchToggle: AnyToVoidFunction;
|
||||||
|
openHistoryCalendar: Function;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNREAD_DIVIDER_CLASS = 'unread-divider';
|
||||||
|
|
||||||
|
const MessageListContent: FC<OwnProps> = ({
|
||||||
|
messageIds,
|
||||||
|
messageGroups,
|
||||||
|
isViewportNewest,
|
||||||
|
isUnread,
|
||||||
|
withUsers,
|
||||||
|
noAvatars,
|
||||||
|
containerRef,
|
||||||
|
anchorIdRef,
|
||||||
|
memoFirstUnreadIdRef,
|
||||||
|
threadId,
|
||||||
|
type,
|
||||||
|
threadTopMessageId,
|
||||||
|
hasLinkedChat,
|
||||||
|
isSchedule,
|
||||||
|
noAppearanceAnimation,
|
||||||
|
onFabToggle,
|
||||||
|
onNotchToggle,
|
||||||
|
openHistoryCalendar,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
observeIntersectionForMedia,
|
||||||
|
observeIntersectionForReading,
|
||||||
|
observeIntersectionForAnimatedStickers,
|
||||||
|
} = useMessageObservers(type, containerRef, memoFirstUnreadIdRef);
|
||||||
|
|
||||||
|
const {
|
||||||
|
backwardsTriggerRef,
|
||||||
|
forwardsTriggerRef,
|
||||||
|
fabTriggerRef,
|
||||||
|
} = useScrollHooks(
|
||||||
|
type,
|
||||||
|
containerRef,
|
||||||
|
messageIds,
|
||||||
|
isViewportNewest,
|
||||||
|
isUnread,
|
||||||
|
onFabToggle,
|
||||||
|
onNotchToggle,
|
||||||
|
);
|
||||||
|
|
||||||
|
const lang = useLang();
|
||||||
|
|
||||||
|
const unreadDivider = (
|
||||||
|
<div className={buildClassName(UNREAD_DIVIDER_CLASS, 'local-action-message')} key="unread-messages">
|
||||||
|
<span>{lang('UnreadMessages')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const messageCountToAnimate = noAppearanceAnimation ? 0 : messageGroups.reduce((acc, messageGroup) => {
|
||||||
|
return acc + flatten(messageGroup.senderGroups).length;
|
||||||
|
}, 0);
|
||||||
|
let appearanceIndex = 0;
|
||||||
|
|
||||||
|
const dateGroups = messageGroups.map((
|
||||||
|
dateGroup: MessageDateGroup,
|
||||||
|
dateGroupIndex: number,
|
||||||
|
dateGroupsArray: MessageDateGroup[],
|
||||||
|
) => {
|
||||||
|
const senderGroups = dateGroup.senderGroups.map((
|
||||||
|
senderGroup,
|
||||||
|
senderGroupIndex,
|
||||||
|
senderGroupsArray,
|
||||||
|
) => {
|
||||||
|
if (senderGroup.length === 1 && !isAlbum(senderGroup[0]) && isActionMessage(senderGroup[0])) {
|
||||||
|
const message = senderGroup[0];
|
||||||
|
const isLastInList = (
|
||||||
|
senderGroupIndex === senderGroupsArray.length - 1
|
||||||
|
&& dateGroupIndex === dateGroupsArray.length - 1
|
||||||
|
);
|
||||||
|
|
||||||
|
return compact([
|
||||||
|
message.id === memoFirstUnreadIdRef.current && unreadDivider,
|
||||||
|
<ActionMessage
|
||||||
|
key={message.id}
|
||||||
|
message={message}
|
||||||
|
observeIntersection={observeIntersectionForReading}
|
||||||
|
appearanceOrder={messageCountToAnimate - ++appearanceIndex}
|
||||||
|
isLastInList={isLastInList}
|
||||||
|
/>,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentDocumentGroupId: string | undefined;
|
||||||
|
|
||||||
|
return flatten(senderGroup.map((
|
||||||
|
messageOrAlbum,
|
||||||
|
messageIndex,
|
||||||
|
) => {
|
||||||
|
const message = isAlbum(messageOrAlbum) ? messageOrAlbum.mainMessage : messageOrAlbum;
|
||||||
|
const album = isAlbum(messageOrAlbum) ? messageOrAlbum : undefined;
|
||||||
|
const isOwn = isOwnMessage(message);
|
||||||
|
const isMessageAlbum = isAlbum(messageOrAlbum);
|
||||||
|
const nextMessage = senderGroup[messageIndex + 1];
|
||||||
|
|
||||||
|
if (message.previousLocalId && anchorIdRef.current === `message${message.previousLocalId}`) {
|
||||||
|
anchorIdRef.current = `message${message.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentGroupId = !isMessageAlbum && message.groupedId ? message.groupedId : undefined;
|
||||||
|
const nextDocumentGroupId = nextMessage && !isAlbum(nextMessage) ? nextMessage.groupedId : undefined;
|
||||||
|
|
||||||
|
const position = {
|
||||||
|
isFirstInGroup: messageIndex === 0,
|
||||||
|
isLastInGroup: messageIndex === senderGroup.length - 1,
|
||||||
|
isFirstInDocumentGroup: Boolean(documentGroupId && documentGroupId !== currentDocumentGroupId),
|
||||||
|
isLastInDocumentGroup: Boolean(documentGroupId && documentGroupId !== nextDocumentGroupId),
|
||||||
|
isLastInList: (
|
||||||
|
messageIndex === senderGroup.length - 1
|
||||||
|
&& senderGroupIndex === senderGroupsArray.length - 1
|
||||||
|
&& dateGroupIndex === dateGroupsArray.length - 1
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
currentDocumentGroupId = documentGroupId;
|
||||||
|
|
||||||
|
const originalId = getMessageOriginalId(message);
|
||||||
|
// Scheduled messages can have local IDs in the middle of the list,
|
||||||
|
// and keys should be ordered, so we prefix it with a date.
|
||||||
|
// However, this may lead to issues if server date is not synchronized with the local one.
|
||||||
|
const key = type !== 'scheduled' ? originalId : `${message.date}_${originalId}`;
|
||||||
|
|
||||||
|
return compact([
|
||||||
|
message.id === memoFirstUnreadIdRef.current ? unreadDivider : undefined,
|
||||||
|
<Message
|
||||||
|
key={key}
|
||||||
|
message={message}
|
||||||
|
observeIntersectionForBottom={observeIntersectionForReading}
|
||||||
|
observeIntersectionForMedia={observeIntersectionForMedia}
|
||||||
|
observeIntersectionForAnimatedStickers={observeIntersectionForAnimatedStickers}
|
||||||
|
album={album}
|
||||||
|
noAvatars={noAvatars}
|
||||||
|
withAvatar={position.isLastInGroup && withUsers && !isOwn && !(message.id === threadTopMessageId)}
|
||||||
|
withSenderName={position.isFirstInGroup && withUsers && !isOwn}
|
||||||
|
threadId={threadId}
|
||||||
|
messageListType={type}
|
||||||
|
noComments={hasLinkedChat === false}
|
||||||
|
appearanceOrder={messageCountToAnimate - ++appearanceIndex}
|
||||||
|
isFirstInGroup={position.isFirstInGroup}
|
||||||
|
isLastInGroup={position.isLastInGroup}
|
||||||
|
isFirstInDocumentGroup={position.isFirstInDocumentGroup}
|
||||||
|
isLastInDocumentGroup={position.isLastInDocumentGroup}
|
||||||
|
isLastInList={position.isLastInList}
|
||||||
|
/>,
|
||||||
|
message.id === threadTopMessageId && (
|
||||||
|
<div className="local-action-message" key="discussion-started">
|
||||||
|
<span>{lang('DiscussionStarted')}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="message-date-group"
|
||||||
|
key={dateGroup.datetime}
|
||||||
|
onMouseDown={preventMessageInputBlur}
|
||||||
|
teactFastList
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={buildClassName('sticky-date', !isSchedule && 'interactive')}
|
||||||
|
key="date-header"
|
||||||
|
onMouseDown={preventMessageInputBlur}
|
||||||
|
onClick={!isSchedule ? () => openHistoryCalendar({ selectedAt: dateGroup.datetime }) : undefined}
|
||||||
|
>
|
||||||
|
<span dir="auto">
|
||||||
|
{isSchedule && dateGroup.originalDate === SCHEDULED_WHEN_ONLINE && (
|
||||||
|
lang('MessageScheduledUntilOnline')
|
||||||
|
)}
|
||||||
|
{isSchedule && dateGroup.originalDate !== SCHEDULED_WHEN_ONLINE && (
|
||||||
|
lang('MessageScheduledOn', formatHumanDate(lang, dateGroup.datetime, undefined, true))
|
||||||
|
)}
|
||||||
|
{!isSchedule && formatHumanDate(lang, dateGroup.datetime)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{flatten(senderGroups)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="messages-container" teactFastList>
|
||||||
|
<div ref={backwardsTriggerRef} key="backwards-trigger" className="backwards-trigger" />
|
||||||
|
{flatten(dateGroups)}
|
||||||
|
<div
|
||||||
|
ref={forwardsTriggerRef}
|
||||||
|
key="forwards-trigger"
|
||||||
|
className="forwards-trigger"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={fabTriggerRef}
|
||||||
|
key="fab-trigger"
|
||||||
|
className="fab-trigger"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(MessageListContent);
|
||||||
80
src/components/middle/hooks/useMessageObservers.ts
Normal file
80
src/components/middle/hooks/useMessageObservers.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { RefObject } from 'react';
|
||||||
|
import { getDispatch } from '../../../lib/teact/teactn';
|
||||||
|
|
||||||
|
import { MessageListType } from '../../../global/types';
|
||||||
|
|
||||||
|
import { IS_ANDROID, IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment';
|
||||||
|
import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver';
|
||||||
|
import useBackgroundMode from '../../../hooks/useBackgroundMode';
|
||||||
|
|
||||||
|
const INTERSECTION_THROTTLE_FOR_MEDIA = IS_ANDROID ? 1000 : 350;
|
||||||
|
const INTERSECTION_MARGIN_FOR_MEDIA = IS_SINGLE_COLUMN_LAYOUT ? 300 : 500;
|
||||||
|
|
||||||
|
export default function useMessageObservers(
|
||||||
|
type: MessageListType,
|
||||||
|
containerRef: RefObject<HTMLDivElement>,
|
||||||
|
memoFirstUnreadIdRef: { current: number | undefined },
|
||||||
|
) {
|
||||||
|
const { markMessageListRead, markMessagesRead } = getDispatch();
|
||||||
|
|
||||||
|
const {
|
||||||
|
observe: observeIntersectionForMedia,
|
||||||
|
} = useIntersectionObserver({
|
||||||
|
rootRef: containerRef,
|
||||||
|
throttleMs: INTERSECTION_THROTTLE_FOR_MEDIA,
|
||||||
|
margin: INTERSECTION_MARGIN_FOR_MEDIA,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
observe: observeIntersectionForReading, freeze: freezeForReading, unfreeze: unfreezeForReading,
|
||||||
|
} = useIntersectionObserver({
|
||||||
|
rootRef: containerRef,
|
||||||
|
}, (entries) => {
|
||||||
|
if (type !== 'thread') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let maxId = 0;
|
||||||
|
const mentionIds: number[] = [];
|
||||||
|
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const { isIntersecting, target } = entry;
|
||||||
|
|
||||||
|
if (!isIntersecting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { dataset } = target as HTMLDivElement;
|
||||||
|
|
||||||
|
const messageId = Number(dataset.lastMessageId || dataset.messageId);
|
||||||
|
if (messageId > maxId) {
|
||||||
|
maxId = messageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataset.hasUnreadMention) {
|
||||||
|
mentionIds.push(messageId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (memoFirstUnreadIdRef.current && maxId >= memoFirstUnreadIdRef.current) {
|
||||||
|
markMessageListRead({ maxId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mentionIds.length) {
|
||||||
|
markMessagesRead({ messageIds: mentionIds });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useBackgroundMode(freezeForReading, unfreezeForReading);
|
||||||
|
|
||||||
|
const { observe: observeIntersectionForAnimatedStickers } = useIntersectionObserver({
|
||||||
|
rootRef: containerRef,
|
||||||
|
throttleMs: INTERSECTION_THROTTLE_FOR_MEDIA,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
observeIntersectionForMedia,
|
||||||
|
observeIntersectionForReading,
|
||||||
|
observeIntersectionForAnimatedStickers,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,39 +1,39 @@
|
|||||||
import { MutableRefObject } from 'react';
|
import { RefObject } from 'react';
|
||||||
import React, { FC, useCallback, useRef } from '../../lib/teact/teact';
|
import { getDispatch } from '../../../lib/teact/teactn';
|
||||||
|
import { useCallback, useMemo, useRef } from '../../../lib/teact/teact';
|
||||||
|
|
||||||
import { MESSAGE_LIST_SENSITIVE_AREA } from '../../config';
|
import { LoadMoreDirection } from '../../../types';
|
||||||
import resetScroll from '../../util/resetScroll';
|
import { MessageListType } from '../../../global/types';
|
||||||
import { useIntersectionObserver, useOnIntersect } from '../../hooks/useIntersectionObserver';
|
|
||||||
import useOnChange from '../../hooks/useOnChange';
|
|
||||||
|
|
||||||
type OwnProps = {
|
import { debounce } from '../../../util/schedulers';
|
||||||
containerRef: MutableRefObject<HTMLDivElement | null>;
|
import { useIntersectionObserver, useOnIntersect } from '../../../hooks/useIntersectionObserver';
|
||||||
className: string;
|
import { MESSAGE_LIST_SENSITIVE_AREA } from '../../../config';
|
||||||
messageIds: number[];
|
import resetScroll from '../../../util/resetScroll';
|
||||||
loadMoreForwards?: NoneToVoidFunction;
|
import useOnChange from '../../../hooks/useOnChange';
|
||||||
loadMoreBackwards?: NoneToVoidFunction;
|
|
||||||
isViewportNewest?: boolean;
|
|
||||||
firstUnreadId?: number;
|
|
||||||
onFabToggle: AnyToVoidFunction;
|
|
||||||
onNotchToggle: AnyToVoidFunction;
|
|
||||||
children: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
const FAB_THRESHOLD = 50;
|
const FAB_THRESHOLD = 50;
|
||||||
const TOOLS_FREEZE_TIMEOUT = 100;
|
const TOOLS_FREEZE_TIMEOUT = 100;
|
||||||
|
|
||||||
const MessageScroll: FC<OwnProps> = ({
|
export default function useScrollHooks(
|
||||||
containerRef,
|
type: MessageListType,
|
||||||
className,
|
containerRef: RefObject<HTMLDivElement>,
|
||||||
messageIds,
|
messageIds: number[],
|
||||||
loadMoreForwards,
|
isViewportNewest: boolean,
|
||||||
loadMoreBackwards,
|
isUnread: boolean,
|
||||||
isViewportNewest,
|
onFabToggle: AnyToVoidFunction,
|
||||||
firstUnreadId,
|
onNotchToggle: AnyToVoidFunction,
|
||||||
onFabToggle,
|
) {
|
||||||
onNotchToggle,
|
const { loadViewportMessages } = getDispatch();
|
||||||
children,
|
|
||||||
}) => {
|
const [loadMoreBackwards, loadMoreForwards] = useMemo(
|
||||||
|
() => (type === 'thread' ? [
|
||||||
|
debounce(() => loadViewportMessages({ direction: LoadMoreDirection.Backwards }), 1000, true, false),
|
||||||
|
debounce(() => loadViewportMessages({ direction: LoadMoreDirection.Forwards }), 1000, true, false),
|
||||||
|
] : []),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[loadViewportMessages, messageIds],
|
||||||
|
);
|
||||||
|
|
||||||
// eslint-disable-next-line no-null/no-null
|
// eslint-disable-next-line no-null/no-null
|
||||||
const backwardsTriggerRef = useRef<HTMLDivElement>(null);
|
const backwardsTriggerRef = useRef<HTMLDivElement>(null);
|
||||||
// eslint-disable-next-line no-null/no-null
|
// eslint-disable-next-line no-null/no-null
|
||||||
@ -59,9 +59,9 @@ const MessageScroll: FC<OwnProps> = ({
|
|||||||
const isNearBottom = scrollBottom <= FAB_THRESHOLD;
|
const isNearBottom = scrollBottom <= FAB_THRESHOLD;
|
||||||
const isAtBottom = scrollBottom <= 0;
|
const isAtBottom = scrollBottom <= 0;
|
||||||
|
|
||||||
onFabToggle(firstUnreadId ? !isAtBottom : !isNearBottom);
|
onFabToggle(isUnread ? !isAtBottom : !isNearBottom);
|
||||||
onNotchToggle(!isAtBottom);
|
onNotchToggle(!isAtBottom);
|
||||||
}, [messageIds, isViewportNewest, containerRef, onFabToggle, firstUnreadId, onNotchToggle]);
|
}, [messageIds, isViewportNewest, containerRef, onFabToggle, isUnread, onNotchToggle]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
observe: observeIntersection,
|
observe: observeIntersection,
|
||||||
@ -124,22 +124,5 @@ const MessageScroll: FC<OwnProps> = ({
|
|||||||
}, TOOLS_FREEZE_TIMEOUT);
|
}, TOOLS_FREEZE_TIMEOUT);
|
||||||
}, [messageIds]);
|
}, [messageIds]);
|
||||||
|
|
||||||
return (
|
return { backwardsTriggerRef, forwardsTriggerRef, fabTriggerRef };
|
||||||
<div className={className} teactFastList>
|
}
|
||||||
<div ref={backwardsTriggerRef} key="backwards-trigger" className="backwards-trigger" />
|
|
||||||
{children}
|
|
||||||
<div
|
|
||||||
ref={forwardsTriggerRef}
|
|
||||||
key="forwards-trigger"
|
|
||||||
className="forwards-trigger"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
ref={fabTriggerRef}
|
|
||||||
key="fab-trigger"
|
|
||||||
className="fab-trigger"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MessageScroll;
|
|
||||||
Loading…
x
Reference in New Issue
Block a user