import React, { FC, memo, useCallback, useEffect, useMemo, useRef, useState, } from '../../lib/teact/teact'; import { getGlobal, withGlobal } from '../../lib/teact/teactn'; import { ApiMessage, ApiRestrictionReason, MAIN_THREAD_ID } from '../../api/types'; import { GlobalActions, MessageListType } from '../../global/types'; import { LoadMoreDirection } from '../../types'; import { ANIMATION_END_DELAY, MESSAGE_LIST_SLICE, SCHEDULED_WHEN_ONLINE } from '../../config'; import { IS_ANDROID, IS_MOBILE_SCREEN } from '../../util/environment'; import { selectChatMessages, selectIsViewportNewest, selectFirstUnreadId, selectFocusedMessageId, selectChat, selectIsInSelectMode, selectIsChatWithSelf, selectChatBot, selectIsChatBotNotStarted, selectScrollOffset, selectThreadTopMessageId, selectFirstMessageId, selectScheduledMessages, selectCurrentMessageIds, } from '../../modules/selectors'; import { getMessageOriginalId, isActionMessage, 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 buildClassName from '../../util/buildClassName'; import { groupMessages, MessageDateGroup, isAlbum } from './helpers/groupMessages'; import { ObserveFn, useIntersectionObserver } from '../../hooks/useIntersectionObserver'; import useOnChange from '../../hooks/useOnChange'; import useStickyDates from './hooks/useStickyDates'; import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck'; import resetScroll from '../../util/resetScroll'; import fastSmoothScroll, { isAnimatingScroll } from '../../util/fastSmoothScroll'; import renderText from '../common/helpers/renderText'; import useLang, { LangFn } from '../../hooks/useLang'; import useWindowSize from '../../hooks/useWindowSize'; import Loading from '../ui/Loading'; import MessageScroll from './MessageScroll'; import Message from './message/Message'; import ActionMessage from './ActionMessage'; import './MessageList.scss'; type OwnProps = { chatId: number; threadId: number; type: MessageListType; canPost: boolean; onFabToggle: (shouldShow: boolean) => void; onNotchToggle: (shouldShow: boolean) => void; hasTools?: boolean; }; type StateProps = { isChatLoaded?: boolean; isChannelChat?: boolean; isChatWithSelf?: boolean; messageIds?: number[]; messagesById?: Record; firstUnreadId?: number; isViewportNewest?: boolean; isRestricted?: boolean; restrictionReason?: ApiRestrictionReason; focusingId?: number; isSelectModeActive?: boolean; animationLevel?: number; lastMessage?: ApiMessage; botDescription?: string; threadTopMessageId?: number; threadFirstMessageId?: number; hasLinkedChat?: boolean; }; type DispatchProps = Pick; const BOTTOM_THRESHOLD = 100; const UNREAD_DIVIDER_TOP = 10; const UNREAD_DIVIDER_TOP_WITH_TOOLS = 60; const SCROLL_DEBOUNCE = 200; const INTERSECTION_THROTTLE_FOR_MEDIA = IS_ANDROID ? 1000 : 350; const INTERSECTION_MARGIN_FOR_MEDIA = IS_MOBILE_SCREEN ? 300 : 500; const FOCUSING_DURATION = 1000; const BOTTOM_FOCUS_MARGIN = 20; const SELECT_MODE_ANIMATION_DURATION = 200; const FOCUSING_FADE_ANIMATION_DURATION = 200; const UNREAD_DIVIDER_CLASS = 'unread-divider'; const runDebouncedForScroll = debounce((cb) => cb(), SCROLL_DEBOUNCE, false); const MessageList: FC = ({ chatId, threadId, type, hasTools, onFabToggle, onNotchToggle, isChatLoaded, isChannelChat, canPost, isChatWithSelf, messageIds, messagesById, firstUnreadId, isViewportNewest, threadFirstMessageId, isRestricted, restrictionReason, focusingId, isSelectModeActive, animationLevel, loadViewportMessages, markMessageListRead, markMessagesRead, setScrollOffset, lastMessage, botDescription, threadTopMessageId, hasLinkedChat, }) => { // eslint-disable-next-line no-null/no-null const containerRef = useRef(null); // We update local cached `scrollOffsetRef` when opening chat. // Then we update global version every second on scrolling. const scrollOffsetRef = useRef((type === 'thread' && selectScrollOffset(getGlobal(), chatId, threadId)) || 0); const anchorIdRef = useRef(); const anchorTopRef = useRef(); const listItemElementsRef = useRef(); const memoUnreadDividerBeforeIdRef = useRef(); // Updated every time (to be used from intersection callback closure) const memoFirstUnreadIdRef = useRef(); const memoFocusingIdRef = useRef(); const isScrollTopJustUpdatedRef = useRef(false); const shouldAnimateAppearanceRef = useRef(!messageIds); const [containerHeight, setContainerHeight] = useState(); const [hasFocusing, setHasFocusing] = useState(Boolean(focusingId)); const areMessagesLoaded = Boolean(messageIds); useOnChange(() => { // We only need it first time when message list appears if (areMessagesLoaded) { onTickEnd(() => { shouldAnimateAppearanceRef.current = false; }); } }, [areMessagesLoaded]); useOnChange(() => { memoFirstUnreadIdRef.current = firstUnreadId; // Updated only once (to preserve divider even after messages are read) if (!memoUnreadDividerBeforeIdRef.current) { memoUnreadDividerBeforeIdRef.current = firstUnreadId; } }, [firstUnreadId]); const { observe: observeIntersectionForMedia, freeze: freezeForMedia, unfreeze: unfreezeForMedia, } = 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 }); } }); useOnChange(() => { memoFocusingIdRef.current = focusingId; if (focusingId) { freezeForMedia(); freezeForReading(); } else { unfreezeForReading(); unfreezeForMedia(); } }, [focusingId]); const { observe: observeIntersectionForAnimatedStickers } = useIntersectionObserver({ rootRef: containerRef, throttleMs: INTERSECTION_THROTTLE_FOR_MEDIA, }); useEffect(() => { if (focusingId) { setHasFocusing(true); } else { setTimeout(() => { setHasFocusing(false); }, FOCUSING_FADE_ANIMATION_DURATION); } }, [focusingId]); const messageGroups = useMemo(() => { if (!messageIds || !messagesById) { return undefined; } const viewportIds = threadTopMessageId && (!messageIds[0] || threadFirstMessageId === messageIds[0]) ? [threadTopMessageId, ...messageIds] : messageIds; if (!viewportIds.length) { return undefined; } const listedMessages = viewportIds.map((id) => messagesById[id]); return groupMessages(orderBy(listedMessages, ['date', 'id']), memoUnreadDividerBeforeIdRef.current); }, [messageIds, messagesById, threadFirstMessageId, threadTopMessageId]); const [loadMoreBackwards, loadMoreForwards, loadMoreAround] = useMemo( () => (type === 'thread' ? [ debounce(() => loadViewportMessages({ direction: LoadMoreDirection.Backwards }), 1000, true, false), debounce(() => loadViewportMessages({ direction: LoadMoreDirection.Forwards }), 1000, true, false), debounce(() => loadViewportMessages({ direction: LoadMoreDirection.Around }), 1000, true, false), ] : []), // eslint-disable-next-line react-hooks/exhaustive-deps [loadViewportMessages, messageIds], ); const { isScrolled, updateStickyDates } = useStickyDates(); const handleScroll = useCallback(() => { if (isScrollTopJustUpdatedRef.current) { isScrollTopJustUpdatedRef.current = false; return; } const container = containerRef.current!; if (!memoFocusingIdRef.current) { updateStickyDates(container, hasTools); } runDebouncedForScroll(() => { fastRaf(() => { if (!container.parentElement) { return; } scrollOffsetRef.current = container.scrollHeight - container.scrollTop; if (type === 'thread') { setScrollOffset({ chatId, threadId, scrollOffset: scrollOffsetRef.current }); } }); }); }, [updateStickyDates, hasTools, type, setScrollOffset, chatId, threadId]); // Container resize observer (caused by Composer reply/webpage panels) useEffect(() => { if (!('ResizeObserver' in window) || process.env.APP_ENV === 'perf') { return undefined; } const observer = new ResizeObserver(([entry]) => { // During animation if (!(entry.target as HTMLDivElement).offsetParent) { return; } setContainerHeight(entry.contentRect.height); }); observer.observe(containerRef.current!); return () => { observer.disconnect(); }; }, []); // Memorize height for scroll animation const { height: windowHeight } = useWindowSize(); useEffect(() => { containerRef.current!.dataset.normalHeight = String(containerRef.current!.offsetHeight); }, [windowHeight]); // Initial message loading useEffect(() => { if (!loadMoreAround || !isChatLoaded || isRestricted || focusingId) { return; } const container = containerRef.current!; if (!messageIds || ( messageIds.length < MESSAGE_LIST_SLICE / 2 && (container.firstElementChild as HTMLDivElement).clientHeight <= container.offsetHeight )) { loadMoreAround(); } }, [isChatLoaded, messageIds, loadMoreAround, focusingId, isRestricted]); // Remember scroll position before repositioning it useOnChange(() => { if (!messageIds || !listItemElementsRef.current) { return; } const preservedItemElements = listItemElementsRef.current .filter((element) => messageIds.includes(Number(element.dataset.messageId))); // We avoid the very first item as it may be a partly-loaded album // and also because it may be removed when messages limit is reached const anchor = preservedItemElements[1] || preservedItemElements[0]; if (!anchor) { return; } anchorIdRef.current = anchor.id; anchorTopRef.current = anchor.getBoundingClientRect().top; // This should match deps for `useLayoutEffectWithPrevDeps` below }, [messageIds, isViewportNewest, containerHeight, hasTools]); // Handles updated message list, takes care of scroll repositioning useLayoutEffectWithPrevDeps(([ prevMessageIds, prevIsViewportNewest, prevContainerHeight, ]: [ typeof messageIds, typeof isViewportNewest, typeof containerHeight ]) => { const container = containerRef.current!; listItemElementsRef.current = Array.from(container.querySelectorAll('.message-list-item')); // During animation if (!container.offsetParent) { return; } // 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 && (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'); } }, FOCUSING_DURATION); } const { scrollTop, scrollHeight, offsetHeight } = container; const scrollOffset = scrollOffsetRef.current!; const lastItemElement = listItemElementsRef.current[listItemElementsRef.current.length - 1]; // If two messages come at once (e.g. via Quiz Bot) then the first message will update `scrollOffset` // right away (before animation) which creates inconsistency until the animation completes. // To workaround that, we calculate `isAtBottom` with a "buffer" of the latest message height (this is approximate). const lastItemHeight = lastItemElement ? lastItemElement.offsetHeight : 0; const isAtBottom = isViewportNewest && prevIsViewportNewest && ( scrollOffset - (prevContainerHeight || offsetHeight) - lastItemHeight <= BOTTOM_THRESHOLD ); let newScrollTop!: number; const hasFirstMessageChanged = messageIds && prevMessageIds && messageIds[0] !== prevMessageIds[0]; const hasLastMessageChanged = ( messageIds && prevMessageIds && messageIds[messageIds.length - 1] !== prevMessageIds[prevMessageIds.length - 1] ); const isAlreadyFocusing = messageIds && memoFocusingIdRef.current === messageIds[messageIds.length - 1]; if (isAtBottom && hasLastMessageChanged && !hasFirstMessageChanged && !isAlreadyFocusing) { if (lastItemElement) { fastRaf(() => { fastSmoothScroll( container, lastItemElement, 'end', BOTTOM_FOCUS_MARGIN, undefined, undefined, undefined, true, ); }); } newScrollTop = scrollHeight - offsetHeight; scrollOffsetRef.current = Math.max(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 !== undefined && prevContainerHeight !== containerHeight; const anchor = anchorIdRef.current && container.querySelector(`#${anchorIdRef.current}`); const unreadDivider = ( !anchor && memoUnreadDividerBeforeIdRef.current && container.querySelector(`.${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 = unreadDivider.offsetTop - (hasTools ? UNREAD_DIVIDER_TOP_WITH_TOOLS : UNREAD_DIVIDER_TOP); } else { newScrollTop = scrollHeight - scrollOffset; } resetScroll(container, newScrollTop); if (!memoFocusingIdRef.current) { isScrollTopJustUpdatedRef.current = true; fastRaf(() => { isScrollTopJustUpdatedRef.current = false; }); } scrollOffsetRef.current = Math.max(scrollHeight - newScrollTop, offsetHeight); if (process.env.APP_ENV === 'perf') { // eslint-disable-next-line no-console console.timeEnd('scrollTop'); } // This should match deps for `useOnChange` above }, [messageIds, isViewportNewest, containerHeight, hasTools]); useEffect(() => { if (!animationLevel || animationLevel > 0) { dispatchHeavyAnimationEvent(SELECT_MODE_ANIMATION_DURATION + ANIMATION_END_DELAY); } }, [animationLevel, isSelectModeActive]); const lang = useLang(); const isPrivate = Boolean(chatId && isChatPrivate(chatId)); const withUsers = Boolean((!isPrivate && !isChannelChat) || isChatWithSelf); const className = buildClassName( 'MessageList custom-scroll', !withUsers && 'no-avatars', isChannelChat && 'no-avatars', !canPost && 'no-composer', type === 'pinned' && 'type-pinned', isSelectModeActive && 'select-mode-active', hasFocusing && 'has-focusing', isScrolled && 'scrolled', ); return (
{isRestricted ? (
{restrictionReason ? restrictionReason.text : `This is a private ${isChannelChat ? 'channel' : 'chat'}`}
) : botDescription ? (
{renderText(lang(botDescription), ['br', 'emoji', 'links'])}
) : messageIds && !messageGroups ? (
{lang('NoMessages')}
) : ((messageIds && messageGroups) || lastMessage) ? ( {renderMessages( lang, messageGroups || groupMessages([lastMessage!]), observeIntersectionForReading, observeIntersectionForMedia, observeIntersectionForAnimatedStickers, withUsers, anchorIdRef, memoUnreadDividerBeforeIdRef, threadId, type, threadTopMessageId, threadFirstMessageId, hasLinkedChat, messageGroups ? type === 'scheduled' : false, !messageGroups || !shouldAnimateAppearanceRef.current, )} ) : ( )}
); }; function renderMessages( lang: LangFn, messageGroups: MessageDateGroup[], observeIntersectionForReading: ObserveFn, observeIntersectionForMedia: ObserveFn, observeIntersectionForAnimatedStickers: ObserveFn, withUsers: boolean, currentAnchorIdRef: { current: string | undefined }, memoFirstUnreadIdRef: { current: number | undefined }, threadId: number, type: MessageListType, threadTopMessageId?: number, threadFirstMessageId?: number, hasLinkedChat?: boolean, isSchedule = false, noAppearanceAnimation = false, ) { const unreadDivider = (
{lang('UnreadMessages')}
); 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, , ]); } 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.id === threadTopMessageId && (
{lang('DiscussionStarted')}
), ]); })); }); return (
{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)}
{flatten(senderGroups)}
); }); return flatten(dateGroups); } export default memo(withGlobal( (global, { chatId, threadId, type }): StateProps => { const chat = selectChat(global, chatId); if (!chat) { return {}; } const messageIds = selectCurrentMessageIds(global, chatId, threadId, type); const messagesById = type === 'scheduled' ? selectScheduledMessages(global, chatId) : selectChatMessages(global, chatId); const threadTopMessageId = selectThreadTopMessageId(global, chatId, threadId); if ( threadId !== MAIN_THREAD_ID && !(messagesById && threadTopMessageId && messagesById[threadTopMessageId]) ) { return {}; } const { isRestricted, restrictionReason, lastMessage } = chat; const focusingId = selectFocusedMessageId(global, chatId); const withLastMessageWhenPreloading = ( threadId === MAIN_THREAD_ID && !messageIds && !chat.unreadCount && !focusingId && lastMessage && !lastMessage.groupedId ); let botDescription: string | undefined; if (selectIsChatBotNotStarted(global, chatId)) { const chatBot = selectChatBot(global, chatId)!; if (chatBot.fullInfo) { botDescription = chatBot.fullInfo.botDescription || 'NoMessages'; } else { botDescription = 'Updating bot info...'; } } return { isChatLoaded: true, isRestricted, restrictionReason, isChannelChat: isChatChannel(chat), isChatWithSelf: selectIsChatWithSelf(global, chatId), messageIds, messagesById, firstUnreadId: selectFirstUnreadId(global, chatId, threadId), isViewportNewest: type !== 'thread' || selectIsViewportNewest(global, chatId, threadId), threadFirstMessageId: selectFirstMessageId(global, chatId, threadId), focusingId, isSelectModeActive: selectIsInSelectMode(global), animationLevel: global.settings.byKey.animationLevel, ...(withLastMessageWhenPreloading && { lastMessage }), botDescription, threadTopMessageId, hasLinkedChat: chat.fullInfo && ('linkedChatId' in chat.fullInfo) ? Boolean(chat.fullInfo.linkedChatId) : undefined, }; }, (setGlobal, actions): DispatchProps => pick(actions, [ 'loadViewportMessages', 'markMessageListRead', 'markMessagesRead', 'setScrollOffset', ]), )(MessageList));