diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 3f2a06d4c..c92f4b031 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -800,6 +800,7 @@ export interface ApiMessage { fromRank?: string; isTypingDraft?: boolean; // Local field + wasTypingDraft?: boolean; // Local field } export interface ApiReactions { diff --git a/src/components/middle/MessageList.scss b/src/components/middle/MessageList.scss index 6aa6f3650..8ab1c3d7f 100644 --- a/src/components/middle/MessageList.scss +++ b/src/components/middle/MessageList.scss @@ -1,6 +1,7 @@ @use "../../styles/mixins"; .MessageList { + container-type: size; overflow-x: hidden; overflow-y: scroll; flex: 1; @@ -59,6 +60,18 @@ scroll-snap-align: end; } + .live-tail { + min-height: max(0rem, calc(100cqh - var(--middle-header-panes-height) - 3rem)); + + .message-list-item { + animation: live-tail-message-mount 0.2s ease-out; + + body.no-message-sending-animations & { + animation: none; + } + } + } + @media (max-width: 600px) { width: 100vw; // Patch for an issue on Android when rotating device @@ -396,3 +409,8 @@ } } } + +@keyframes live-tail-message-mount { + from { transform: translateY(2rem); } + to { transform: translateY(0); } +} diff --git a/src/components/middle/MessageList.tsx b/src/components/middle/MessageList.tsx index c7be15ac6..b241ed592 100644 --- a/src/components/middle/MessageList.tsx +++ b/src/components/middle/MessageList.tsx @@ -1,4 +1,4 @@ -import { beginHeavyAnimation, memo, useEffect, useMemo, useRef } from '@teact'; +import { beginHeavyAnimation, memo, useEffect, useMemo, useRef, useState, useUnmountCleanup } from '@teact'; import { addExtraClass, removeExtraClass } from '@teact/teact-dom'; import { getActions, getGlobal, withGlobal } from '../../global'; @@ -11,6 +11,7 @@ import { ANIMATION_END_DELAY, ANONYMOUS_USER_ID, MESSAGE_LIST_SLICE, + SCROLL_MAX_DURATION, SERVICE_NOTIFICATIONS_USER_ID, } from '../../config'; import { forceMeasure, requestMeasure, requestMutation } from '../../lib/fasterdom/fasterdom'; @@ -180,6 +181,7 @@ const BOTTOM_SNAP_THRESHOLD = 7; const UNREAD_DIVIDER_TOP = 10; const SCROLL_DEBOUNCE = 200; const MESSAGE_ANIMATION_DURATION = 500; +const SEND_FOCUS_DURATION = SCROLL_MAX_DURATION + ANIMATION_END_DELAY; const BOTTOM_FOCUS_MARGIN = 0.5 * REM; const SELECT_MODE_ANIMATION_DURATION = 200; @@ -189,6 +191,12 @@ const BOTTOM_SNAP_CLASS = 'with-bottom-snap'; const runDebouncedForScroll = debounce((cb) => cb(), SCROLL_DEBOUNCE, false); +function getShouldReleaseLiveTail(liveTailElement: HTMLDivElement) { + const liveTailMinHeight = parseFloat(getComputedStyle(liveTailElement).minHeight); + + return Boolean(liveTailMinHeight && liveTailElement.scrollHeight > liveTailMinHeight + 1); +} + const MessageList = ({ chatId, threadId, @@ -276,7 +284,11 @@ const MessageList = ({ const isReplacingHistoryRef = useRef(false); const shouldAnimateAppearanceRef = useRef(Boolean(lastMessage)); const scrollSnapDisabledTimerRef = useRef(); - const typingDraftSnapTriggeredIdRef = useRef(); + const isLiveTailBottomSnapSuppressedRef = useRef(false); + const isLiveTailAutoScrollingRef = useRef(false); + const liveTailReleaseTimerRef = useRef(); + const liveTailStartOriginalIdRef = useRef(); + const [releasedLiveTailStartOriginalId, setReleasedLiveTailStartOriginalId] = useState(); const isSavedDialog = getIsSavedDialog(chatId, threadId, currentUserId); const hasOpenChatButton = isSavedDialog @@ -289,6 +301,42 @@ const MessageList = ({ const withUsers = Boolean((!isPrivate && !isChannelChat) || isChatWithSelf || isSystemBotChat || isAnonymousForwards || isChannelWithAvatars); + const liveTailStartOriginalId = useMemo(() => { + if (!messageIds?.length || !messagesById) { + return undefined; + } + + const previousLiveTailStartOriginalId = liveTailStartOriginalIdRef.current; + let renderedLiveTailStartOriginalId: number | undefined; + + for (let i = messageIds.length - 1; i >= 0; i--) { + const message = messagesById[messageIds[i]]; + if (message?.isTypingDraft && !message.isOutgoing) { + return getMessageOriginalId(message); + } + + if ( + previousLiveTailStartOriginalId !== undefined + && message?.wasTypingDraft + && getMessageOriginalId(message) === previousLiveTailStartOriginalId + ) { + renderedLiveTailStartOriginalId = previousLiveTailStartOriginalId; + } + } + + return renderedLiveTailStartOriginalId; + }, [messageIds, messagesById]); + + liveTailStartOriginalIdRef.current = liveTailStartOriginalId; + + const effectiveLiveTailStartOriginalId = liveTailStartOriginalId !== releasedLiveTailStartOriginalId + ? liveTailStartOriginalId + : undefined; + + useUnmountCleanup(() => { + clearTimeout(liveTailReleaseTimerRef.current); + }); + useSyncEffect(() => { // We only need it first time when message list appears if (areMessagesLoaded) { @@ -399,19 +447,22 @@ const MessageList = ({ !isForum ? Number(threadId) : undefined, isChatWithSelf, withUsers, + effectiveLiveTailStartOriginalId, ) : undefined; }, [withUsers, messageIds, messagesById, type, isServiceNotificationsChat, isForum, - threadId, isChatWithSelf, channelJoinInfo]); + threadId, isChatWithSelf, channelJoinInfo, effectiveLiveTailStartOriginalId]); - const currentLastMessageOriginalId = useMemo(() => { - const currentLastMessageId = messageIds?.[messageIds.length - 1]; - const currentLastMessage = currentLastMessageId !== undefined ? messagesById?.[currentLastMessageId] : undefined; - - return currentLastMessage ? getMessageOriginalId(currentLastMessage) : currentLastMessageId; - }, [messageIds, messagesById]); + const currentLastMessageId = messageIds?.[messageIds.length - 1]; + const currentLastMessage = currentLastMessageId !== undefined ? messagesById?.[currentLastMessageId] : undefined; + const currentLastMessageOriginalId = currentLastMessage + ? getMessageOriginalId(currentLastMessage) + : currentLastMessageId; + const isCurrentLastMessageTypingDraft = Boolean( + currentLastMessage?.isTypingDraft || currentLastMessage?.wasTypingDraft, + ); useInterval(() => { if (!messageIds || !messagesById || type === 'scheduled' || isAccountFrozen || !isActive) return; @@ -494,6 +545,13 @@ const MessageList = ({ const bottomTrigger = container?.querySelector('.fab-trigger'); if (!container || !bottomTrigger) return; + if (effectiveLiveTailStartOriginalId !== undefined && isLiveTailBottomSnapSuppressedRef.current) { + requestMutation(() => { + removeExtraClass(container, BOTTOM_SNAP_CLASS); + }); + return; + } + // Check if fab-trigger + threshold are entering the viewport const viewportBottom = container.scrollTop + container.offsetHeight; const triggerPosition = bottomTrigger.offsetTop; @@ -517,29 +575,13 @@ const MessageList = ({ } }); - const handleTallTypingDraft = useLastCallback((messageId: number, isNearExit: boolean) => { - if (!isNearExit) { - if (typingDraftSnapTriggeredIdRef.current === messageId) { - typingDraftSnapTriggeredIdRef.current = undefined; - } + const allowLiveTailBottomSnap = useLastCallback(() => { + if (effectiveLiveTailStartOriginalId === undefined || !isLiveTailBottomSnapSuppressedRef.current) { return; } - if (typingDraftSnapTriggeredIdRef.current === messageId) { - return; - } - - const container = containerRef.current; - if (!container || !container.classList.contains(BOTTOM_SNAP_CLASS)) return; - - typingDraftSnapTriggeredIdRef.current = messageId; - - clearTimeout(scrollSnapDisabledTimerRef.current); - scrollSnapDisabledTimerRef.current = undefined; - - requestMutation(() => { - removeExtraClass(container, BOTTOM_SNAP_CLASS); - }); + isLiveTailBottomSnapSuppressedRef.current = false; + updateBottomSnapClass(); }); const handleScroll = useLastCallback(() => { @@ -557,6 +599,16 @@ const MessageList = ({ updateStickyDates(container); } + if (isLiveTailAutoScrollingRef.current) { + if (!isAnimatingScroll()) { + requestMeasure(() => { + isLiveTailAutoScrollingRef.current = false; + }); + } + } else { + allowLiveTailBottomSnap(); + } + // Check if scroll should be snapped, but only if there's no new message animation in progress if (scrollSnapDisabledTimerRef.current === undefined) { updateBottomSnapClass(); @@ -585,6 +637,11 @@ const MessageList = ({ const [getContainerHeight, prevContainerHeightRef] = useContainerHeight(containerRef, canPost && !isSelectModeActive); const handleWheel = useLastCallback((e: React.WheelEvent) => { + if (e.deltaY > 0) { + isLiveTailAutoScrollingRef.current = false; + allowLiveTailBottomSnap(); + } + // Remove snap when scrolling up to avoid scroll bug // https://bugzilla.mozilla.org/show_bug.cgi?id=1753188 if (IS_FIREFOX && e.deltaY < 0) { @@ -644,7 +701,7 @@ const MessageList = ({ forceMeasure(() => rememberScrollPositionRef.current()); }, // This will run before modifying content and should match deps for `useLayoutEffectWithPrevDeps` below - [messageIds, isViewportNewest, rememberScrollPositionRef], + [messageIds, isViewportNewest, effectiveLiveTailStartOriginalId, rememberScrollPositionRef], ); useEffect( () => rememberScrollPositionRef.current(), @@ -653,7 +710,9 @@ const MessageList = ({ ); // Handles updated message list, takes care of scroll repositioning - useLayoutEffectWithPrevDeps(([prevMessageIds, prevIsViewportNewest, prevCurrentLastMessageOriginalId]) => { + useLayoutEffectWithPrevDeps(([ + prevMessageIds, prevIsViewportNewest, prevCurrentLastMessageOriginalId, prevLiveTailStartOriginalId, + ]) => { if (process.env.APP_ENV === 'perf') { // eslint-disable-next-line no-console console.time('scrollTop'); @@ -685,11 +744,34 @@ const MessageList = ({ messageIds?.[0] !== prevMessageIds?.[0] && messageIds?.length === (MESSAGE_LIST_SLICE / 2 + 1) ); const wasMessageAdded = hasLastMessageChanged && !hasViewportShifted; + const wasLiveTailCreated = Boolean( + effectiveLiveTailStartOriginalId !== undefined + && effectiveLiveTailStartOriginalId !== prevLiveTailStartOriginalId, + ); + const hasLiveTail = effectiveLiveTailStartOriginalId !== undefined; + if (wasLiveTailCreated) { + isLiveTailBottomSnapSuppressedRef.current = true; + } else if (!hasLiveTail) { + isLiveTailBottomSnapSuppressedRef.current = false; + } + + const shouldReleaseLiveTail = Boolean( + wasMessageAdded + && currentLastMessageOriginalId !== undefined + && hasLiveTail + && !wasLiveTailCreated + && !isCurrentLastMessageTypingDraft + && forceMeasure(() => { + const liveTailElement = container.querySelector('.live-tail'); + return liveTailElement ? getShouldReleaseLiveTail(liveTailElement) : false; + }), + ); // Add extra height when few messages to allow scroll animation if ( isViewportNewest && wasMessageAdded + && !hasLiveTail && (messageIds && messageIds.length < MESSAGE_LIST_SLICE / 2) && !container.parentElement!.classList.contains(FORCE_MESSAGES_SCROLL_CLASS) && forceMeasure(() => ( @@ -730,10 +812,13 @@ const MessageList = ({ bottomOffset -= lastItemHeight; } const isAtBottom = isViewportNewest && prevIsViewportNewest && bottomOffset <= BOTTOM_THRESHOLD; + const shouldFocusLiveTail = wasLiveTailCreated && isAtBottom; const isAlreadyFocusing = messageIds && memoFocusingIdRef.current === messageIds[messageIds.length - 1]; // Animate incoming message, but if app is in background mode, scroll to the first unread - if (wasMessageAdded && isAtBottom && !isAlreadyFocusing) { + if (wasMessageAdded && isAtBottom && (!isAlreadyFocusing || shouldReleaseLiveTail) && ( + !hasLiveTail || shouldReleaseLiveTail + )) { // Break out of `forceLayout` requestMeasure(() => { const isScrollToBottom = !isBackgroundModeActive() || !firstUnreadElement; @@ -744,6 +829,15 @@ const MessageList = ({ margin: BOTTOM_FOCUS_MARGIN, forceDuration: noMessageSendingAnimation ? 0 : undefined, }); + + if (shouldReleaseLiveTail && effectiveLiveTailStartOriginalId !== undefined) { + clearTimeout(liveTailReleaseTimerRef.current); + + liveTailReleaseTimerRef.current = window.setTimeout(() => { + liveTailReleaseTimerRef.current = undefined; + setReleasedLiveTailStartOriginalId(effectiveLiveTailStartOriginalId); + }, SEND_FOCUS_DURATION); + } }); } @@ -758,9 +852,27 @@ const MessageList = ({ && memoUnreadDividerBeforeIdRef.current && container.querySelector(`.${UNREAD_DIVIDER_CLASS}`) ); + const liveTailElement = shouldFocusLiveTail + ? container.querySelector('.live-tail') + : undefined; + const animateLiveTailScroll = liveTailElement + ? animateScroll({ + container, + element: liveTailElement, + position: 'end', + maxDistance: Number.MAX_SAFE_INTEGER, + forceDuration: noMessageSendingAnimation ? 0 : undefined, + shouldReturnMutationFn: true, + }) + : undefined; let newScrollTop!: number; - if (isAtBottom && isResized) { + if (liveTailElement) { + const liveTailOffset = getOffsetToContainer(liveTailElement, container).top; + newScrollTop = liveTailOffset + liveTailElement.offsetHeight - offsetHeight; + } else if (shouldFocusLiveTail) { + newScrollTop = scrollHeight - offsetHeight; + } else if (isAtBottom && isResized) { newScrollTop = scrollHeight - offsetHeight; } else if (anchor) { const newAnchorTop = anchor.getBoundingClientRect().top; @@ -775,6 +887,19 @@ const MessageList = ({ } return () => { + if (animateLiveTailScroll) { + if (Math.abs(newScrollTop - scrollTop) >= 1) { + isLiveTailAutoScrollingRef.current = true; + } + + animateLiveTailScroll(); + scrollOffsetRef.current = Math.max(Math.ceil(scrollHeight - newScrollTop), offsetHeight); + requestMeasure(() => { + isReplacingHistoryRef.current = false; + }); + return; + } + resetScroll(container, Math.ceil(newScrollTop)); requestMeasure(() => { isReplacingHistoryRef.current = false; @@ -804,6 +929,8 @@ const MessageList = ({ messageIds, isViewportNewest, currentLastMessageOriginalId, + effectiveLiveTailStartOriginalId, + isCurrentLastMessageTypingDraft, getContainerHeight, prevContainerHeightRef, noMessageSendingAnimation, @@ -916,6 +1043,7 @@ const MessageList = ({ anchorIdRef={anchorIdRef} memoUnreadDividerBeforeIdRef={memoUnreadDividerBeforeIdRef} memoFirstUnreadIdRef={memoFirstUnreadIdRef} + liveTailStartOriginalId={effectiveLiveTailStartOriginalId} isReplacingHistoryRef={isReplacingHistoryRef} threadId={threadId} type={type} @@ -933,7 +1061,6 @@ const MessageList = ({ onScrollDownToggle={onScrollDownToggle} onNotchToggle={onNotchToggle} onIntersectPinnedMessage={onIntersectPinnedMessage} - onTallTypingDraft={handleTallTypingDraft} /> ) : ( diff --git a/src/components/middle/MessageListContent.tsx b/src/components/middle/MessageListContent.tsx index 952db32c8..9b39ce1e2 100644 --- a/src/components/middle/MessageListContent.tsx +++ b/src/components/middle/MessageListContent.tsx @@ -1,4 +1,4 @@ -import type { ElementRef } from '../../lib/teact/teact'; +import type { ElementRef, TeactNode } from '../../lib/teact/teact'; import { getIsHeavyAnimating, memo } from '../../lib/teact/teact'; import { getActions, getGlobal } from '../../global'; @@ -70,6 +70,7 @@ interface OwnProps { anchorIdRef: { current: string | undefined }; memoUnreadDividerBeforeIdRef: { current: number | undefined }; memoFirstUnreadIdRef: { current: number | undefined }; + liveTailStartOriginalId?: number; isReplacingHistoryRef: { current: boolean }; type: MessageListType; isReady: boolean; @@ -86,11 +87,23 @@ interface OwnProps { onScrollDownToggle?: BooleanToVoidFunction; onNotchToggle?: AnyToVoidFunction; onIntersectPinnedMessage?: OnIntersectPinnedMessage; - onTallTypingDraft?: (messageId: number, isNearExit: boolean) => void; } const UNREAD_DIVIDER_CLASS = 'unread-divider'; +function senderGroupContainsOriginalId( + senderGroup: (ApiMessage | IAlbum | IDocumentGroup)[], + originalId: number, +) { + return senderGroup.some((messageOrAlbum) => { + if (isAlbum(messageOrAlbum) || isDocumentGroup(messageOrAlbum)) { + return messageOrAlbum.messages.some((message) => getMessageOriginalId(message) === originalId); + } + + return getMessageOriginalId(messageOrAlbum) === originalId; + }); +} + const MessageListContent = ({ canShowAds, chatId, @@ -111,6 +124,7 @@ const MessageListContent = ({ anchorIdRef, memoUnreadDividerBeforeIdRef, memoFirstUnreadIdRef, + liveTailStartOriginalId, isReplacingHistoryRef, type, isReady, @@ -127,7 +141,6 @@ const MessageListContent = ({ onScrollDownToggle, onNotchToggle, onIntersectPinnedMessage, - onTallTypingDraft, }: OwnProps) => { const { openHistoryCalendar } = getActions(); @@ -159,7 +172,6 @@ const MessageListContent = ({ backwardsTriggerRef, forwardsTriggerRef, fabTriggerRef, - observeIntersectionForTopExit, } = useScrollHooks({ type, containerRef, @@ -352,6 +364,7 @@ const MessageListContent = ({ ) { const isOwn = isOwnMessage(message); const originalId = getMessageOriginalId(message); + const isInLiveTail = liveTailStartOriginalId !== undefined && originalId >= liveTailStartOriginalId; const key = isServiceNotificationMessage(message) ? `${message.date}_${originalId}` : originalId; const shouldShowGuestAvatar = isPrivate && !withUsers && Boolean(message.guestChatViaId); @@ -384,11 +397,11 @@ const MessageListContent = ({ isFirstInDocumentGroup={position.isFirstInDocumentGroup} isLastInDocumentGroup={position.isLastInDocumentGroup} isLastInList={position.isLastInList} + shouldIgnoreSendFocus={isInLiveTail && isOwn} + isQuickPreview={isQuickPreview} memoFirstUnreadIdRef={memoFirstUnreadIdRef} getIsMessageListReady={getIsReady} - observeIntersectionForTopExit={observeIntersectionForTopExit} onMessageUnmount={onMessageUnmount} - onTallTypingDraft={onTallTypingDraft} />, ]); } @@ -504,43 +517,97 @@ const MessageListContent = ({ ), ]); - }).flat(); + }); } - const dateGroups = messageGroups.map(( + function renderDateHeader(dateGroup: MessageDateGroup) { + return ( +
openHistoryCalendar({ selectedAt: dateGroup.datetime }) : undefined} + > + + {isSchedule && dateGroup.originalDate === SCHEDULED_WHEN_ONLINE && ( + oldLang('MessageScheduledUntilOnline') + )} + {isSchedule && dateGroup.originalDate !== SCHEDULED_WHEN_ONLINE && ( + oldLang('MessageScheduledOn', formatHumanDate(oldLang, dateGroup.datetime, undefined, true)) + )} + {!isSchedule && formatMessageListDate(lang, new Date(dateGroup.datetime))} + +
+ ); + } + + function renderDateGroup( + dateGroup: MessageDateGroup, + children: TeactNode[], + keySuffix: string, + shouldAddFirstClass: boolean, + ) { + return ( +
+ {children} +
+ ); + } + + let isRenderingLiveTail = false; + const dateGroups: TeactNode[] = []; + const liveTailDateGroups: TeactNode[] = []; + + messageGroups.forEach(( dateGroup: MessageDateGroup, dateGroupIndex: number, dateGroupsArray: MessageDateGroup[], ) => { const senderGroups = calculateSenderGroups(dateGroup, dateGroupIndex, dateGroupsArray); + const beforeTailChildren: TeactNode[] = []; + const liveTailChildren: TeactNode[] = []; - return ( -
-
openHistoryCalendar({ selectedAt: dateGroup.datetime }) : undefined} - > - - {isSchedule && dateGroup.originalDate === SCHEDULED_WHEN_ONLINE && ( - oldLang('MessageScheduledUntilOnline') - )} - {isSchedule && dateGroup.originalDate !== SCHEDULED_WHEN_ONLINE && ( - oldLang('MessageScheduledOn', formatHumanDate(oldLang, dateGroup.datetime, undefined, true)) - )} - {!isSchedule && formatMessageListDate(lang, new Date(dateGroup.datetime))} - -
- {senderGroups.flat()} -
- ); + if (isRenderingLiveTail) { + liveTailChildren.push(renderDateHeader(dateGroup)); + } else { + beforeTailChildren.push(renderDateHeader(dateGroup)); + } + + senderGroups.forEach((senderGroupElements, senderGroupIndex) => { + const isLiveTailStart = ( + !isRenderingLiveTail + && liveTailStartOriginalId !== undefined + && senderGroupContainsOriginalId( + dateGroup.senderGroups[senderGroupIndex], + liveTailStartOriginalId, + ) + ); + + if (isLiveTailStart) { + isRenderingLiveTail = true; + } + + const target = isRenderingLiveTail ? liveTailChildren : beforeTailChildren; + target.push(...senderGroupElements); + }); + + const shouldAddFirstClass = !(nameChangeDate || photoChangeDate) && dateGroupIndex === 0; + if (beforeTailChildren.length) { + dateGroups.push(renderDateGroup( + dateGroup, beforeTailChildren, 'before-tail', shouldAddFirstClass, + )); + } + + if (liveTailChildren.length) { + liveTailDateGroups.push(renderDateGroup( + dateGroup, liveTailChildren, 'live-tail', shouldAddFirstClass && !beforeTailChildren.length, + )); + } }); return ( @@ -548,7 +615,12 @@ const MessageListContent = ({ {withHistoryTriggers &&
} {shouldRenderAccountInfo && } - {dateGroups.flat()} + {dateGroups} + {Boolean(liveTailDateGroups.length) && ( +
+ {liveTailDateGroups} +
+ )} {isViewportNewest && renderBotForumTopicAction()} {withHistoryTriggers && (
{ if (isReady) { updateScrollTools(); @@ -204,6 +195,5 @@ export default function useScrollHooks({ backwardsTriggerRef, forwardsTriggerRef, fabTriggerRef, - observeIntersectionForTopExit, }; } diff --git a/src/components/middle/message/Message.scss b/src/components/middle/message/Message.scss index 6052b739d..d0623e7fb 100644 --- a/src/components/middle/message/Message.scss +++ b/src/components/middle/message/Message.scss @@ -809,17 +809,6 @@ visibility: hidden; } - .top-marker { - position: absolute; - top: 0; - left: 0; - - width: 1px; - height: 1px; - - visibility: hidden; - } - .giveaway-result-content { min-width: 17rem; } diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index 30c2da8d8..ec47dcfac 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -239,14 +239,14 @@ type OwnProps = { appearanceOrder: number; isJustAdded: boolean; isThreadTop?: boolean; + shouldIgnoreSendFocus?: boolean; + isQuickPreview?: boolean; memoFirstUnreadIdRef?: { current: number | undefined }; getIsMessageListReady?: Signal; observeIntersectionForBottom?: ObserveFn; observeIntersectionForLoading?: ObserveFn; observeIntersectionForPlaying?: ObserveFn; - observeIntersectionForTopExit?: ObserveFn; onMessageUnmount?: (messageId: number) => void; - onTallTypingDraft?: (messageId: number, isNearExit: boolean) => void; } & MessagePositionProperties; type StateProps = { @@ -477,9 +477,8 @@ const Message = ({ observeIntersectionForBottom, observeIntersectionForLoading, observeIntersectionForPlaying, - observeIntersectionForTopExit, + isQuickPreview, onMessageUnmount, - onTallTypingDraft, }: OwnProps & StateProps) => { const { toggleMessageSelection, @@ -491,6 +490,7 @@ const Message = ({ animateUnreadReaction, focusMessage, markTypingDraftDone, + markMessageListRead, markMentionsRead, markPollVotesRead, openThread, @@ -498,7 +498,6 @@ const Message = ({ } = getActions(); const ref = useRef(); - const topMarkerRef = useRef(); const bottomMarkerRef = useRef(); const quickReactionRef = useRef(); @@ -519,16 +518,7 @@ const Message = ({ const [declineReason, setDeclineReason] = useState(''); const { isMobile, isTouchScreen } = useAppLayout(); - useOnIntersect(bottomMarkerRef, observeIntersectionForBottom); - - const handleTypingDraftNearExit = useLastCallback(({ isIntersecting }: IntersectionObserverEntry) => { - onTallTypingDraft?.(messageId, !isIntersecting); - }); - useOnIntersect( - topMarkerRef, - isTypingDraft && isLastInList ? observeIntersectionForTopExit : undefined, - handleTypingDraftNearExit, - ); + useOnIntersect(bottomMarkerRef, isTypingDraft ? undefined : observeIntersectionForBottom); const { isContextMenuOpen, @@ -993,9 +983,23 @@ const Message = ({ || undefined; useEffect(() => { + if (isTypingDraft) { + return; + } + const bottomMarker = bottomMarkerRef.current; if (!bottomMarker || !isElementInViewport(bottomMarker)) return; + if ( + message.wasTypingDraft + && !isQuickPreview + && !isOwn + && memoFirstUnreadIdRef?.current + && messageId >= memoFirstUnreadIdRef.current + ) { + markMessageListRead({ maxId: messageId }); + } + if (hasUnreadReaction) { animateUnreadReaction({ chatId, messageIds: [messageId] }); } @@ -1021,10 +1025,16 @@ const Message = ({ hasUnreadPollVote, album, chatId, + isQuickPreview, + isOwn, + isTypingDraft, + markMessageListRead, messageId, + memoFirstUnreadIdRef, animateUnreadReaction, markPollVotesRead, message.hasUnreadMention, + message.wasTypingDraft, ]); const albumLayout = useMemo(() => { @@ -1914,10 +1924,6 @@ const Message = ({ onMouseMove={withQuickReactionButton ? handleMouseMove : undefined} onMouseLeave={(withQuickReactionButton || isInDocumentGroupNotLast) ? handleMouseLeave : undefined} > -
( } = selectTabState(global); const { message, album, documentGroup, withSenderName, withAvatar, threadId, messageListType, - isLastInDocumentGroup, isFirstInGroup, + isLastInDocumentGroup, isFirstInGroup, shouldIgnoreSendFocus, } = ownProps; const { id, chatId, viaBotId, guestChatViaId, isOutgoing, forwardInfo, transcriptionId, isPinned, @@ -2158,11 +2164,18 @@ export default memo(withGlobal( const storySender = storyReplyPeerId ? selectPeer(global, storyReplyPeerId) : undefined; const uploadProgress = selectUploadProgress(global, message); - const isFocused = messageListType === 'thread' && ( + const isFocusTarget = messageListType === 'thread' && ( album ? album.messages.some((m) => selectIsMessageFocused(global, m, threadId)) : selectIsMessageFocused(global, message, threadId) ); + const shouldIgnoreFocus = Boolean( + isFocusTarget + && shouldIgnoreSendFocus + && focusedMessage?.noHighlight + && focusedMessage.isResizingContainer, + ); + const isFocused = isFocusTarget && !shouldIgnoreFocus; const { direction: focusDirection, noHighlight: noFocusHighlight, isResizingContainer, diff --git a/src/global/actions/api/messages.ts b/src/global/actions/api/messages.ts index d8c9884b7..70b2dfa1f 100644 --- a/src/global/actions/api/messages.ts +++ b/src/global/actions/api/messages.ts @@ -1287,6 +1287,7 @@ addActionHandler('reportChannelSpam', (global, actions, payload): ActionReturnTy addActionHandler('markMessageListRead', (global, actions, payload): ActionReturnType => { if (selectIsCurrentUserFrozen(global)) return undefined; const { maxId, tabId = getCurrentTabId() } = payload; + if (isLocalMessageId(maxId)) return undefined; const currentMessageList = selectCurrentMessageList(global, tabId); if (!currentMessageList) { diff --git a/src/global/actions/apiUpdaters/messages.ts b/src/global/actions/apiUpdaters/messages.ts index 986253291..22bc9a820 100644 --- a/src/global/actions/apiUpdaters/messages.ts +++ b/src/global/actions/apiUpdaters/messages.ts @@ -213,6 +213,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { ...message, previousLocalId: matchedTypingDraftEntry.message.id, isTypingDraft: true, + wasTypingDraft: true, } : message; global = updateWithLocalMedia(global, chatId, id, true, nextMessage); diff --git a/src/global/selectors/messages.ts b/src/global/selectors/messages.ts index 684c23300..39b6405d3 100644 --- a/src/global/selectors/messages.ts +++ b/src/global/selectors/messages.ts @@ -862,6 +862,7 @@ export function selectFirstUnreadId( return ( (!lastReadId || id > lastReadId) && byId[id] + && !byId[id].isTypingDraft && (!byId[id].isOutgoing || byId[id].isFromScheduled) && id > lastReadServiceNotificationId );